初始化项目

This commit is contained in:
2026-02-08 22:38:13 +08:00
commit 334d2c6312
201 changed files with 32724 additions and 0 deletions
@@ -0,0 +1,50 @@
<template>
<div class="icon-picker-demo">
<a-card title="图标选择器演示" style="max-width: 600px; margin: 20px auto;">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="选择图标">
<sc-icon-picker v-model="selectedIcon" @change="handleIconChange" />
</a-form-item>
<a-form-item label="选中值">
<a-input v-model:value="selectedIcon" readonly />
</a-form-item>
<a-form-item label="图标预览">
<div v-if="selectedIcon" class="icon-preview">
<component :is="selectedIcon" style="font-size: 48px;" />
</div>
<a-empty v-else description="未选择图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</a-form-item>
</a-form>
</a-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Empty } from 'ant-design-vue'
import ScIconPicker from '@/components/scIconPicker/index.vue'
const selectedIcon = ref('')
const handleIconChange = (value) => {
console.log('图标已选择:', value)
}
</script>
<style scoped lang="scss">
.icon-picker-demo {
padding: 20px;
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
min-height: 88px;
}
}
</style>
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="home-page">
<a-skeleton v-if="loading" active />
<component v-else :is="dashboardComponent" @on-mounted="handleMounted" />
</div>
</template>
<script setup>
import { ref, onMounted, computed, defineAsyncComponent } from 'vue'
import config from '@/config'
// 定义组件名称
defineOptions({
name: 'HomePage',
})
const loading = ref(true)
const dashboard = ref(config.DASHBOARD_LAYOUT || 'work')
// 动态导入组件
const components = {
work: defineAsyncComponent(() => import('./work/index.vue')),
widgets: defineAsyncComponent(() => import('./widgets/index.vue')),
}
const dashboardComponent = computed(() => {
return components[dashboard.value] || components.work
})
const handleMounted = () => {
loading.value = false
}
onMounted(() => {
// 模拟加载延迟
setTimeout(() => {
loading.value = false
}, 300)
})
</script>
<style scoped lang="scss">
.home-page {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,27 @@
<template>
<a-card :bordered="true" title="关于项目" class="about-card">
<p>高性能 / 精致 / 优雅基于Vue3 + Ant Design Vue 的中后台前端解决方案如果喜欢就点个星星支持一下</p>
<p>
<a href="https://gitee.com/lolicode/scui" target="_blank">
<img src="https://gitee.com/lolicode/scui/badge/star.svg?theme=dark" alt="star" style="vertical-align: middle;">
</a>
</p>
</a-card>
</template>
<script setup>
// 定义组件名称
defineOptions({
name: 'AboutWidget',
})
</script>
<style scoped lang="scss">
.about-card {
p {
color: #999;
margin-top: 10px;
line-height: 1.8;
}
}
</style>
@@ -0,0 +1,132 @@
<template>
<a-card :bordered="true" title="实时收入">
<a-spin :spinning="loading">
<div ref="chartRef" style="width: 100%; height: 300px;"></div>
</a-spin>
</a-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import * as echarts from 'echarts'
// 定义组件名称
defineOptions({
name: 'EchartsWidget',
})
const chartRef = ref(null)
const loading = ref(true)
let chart = null
let timer = null
// 初始化图表
const initChart = () => {
if (!chartRef.value) return
chart = echarts.init(chartRef.value)
// 生成初始数据
const now = new Date()
const xData = []
const yData = []
for (let i = 29; i >= 0; i--) {
const time = new Date(now.getTime() - i * 2000)
xData.unshift(time.toLocaleTimeString().replace(/^\D*/, ''))
yData.push(Math.round(Math.random() * 0))
}
const option = {
tooltip: {
trigger: 'axis',
},
xAxis: {
boundaryGap: false,
type: 'category',
data: xData,
},
yAxis: [
{
type: 'value',
name: '价格',
splitLine: {
show: false,
},
},
],
series: [
{
name: '收入',
type: 'line',
symbol: 'none',
lineStyle: {
width: 1,
color: '#409EFF',
},
areaStyle: {
opacity: 0.1,
color: '#79bbff',
},
data: yData,
},
],
}
chart.setOption(option)
// 模拟实时更新
timer = setInterval(() => {
const newTime = new Date().toLocaleTimeString().replace(/^\D*/, '')
const newValue = Math.round(Math.random() * 100)
xData.shift()
xData.push(newTime)
yData.shift()
yData.push(newValue)
chart.setOption({
xAxis: {
data: xData,
},
series: [
{
data: yData,
},
],
})
}, 2100)
}
onMounted(() => {
// 模拟加载延迟
setTimeout(() => {
loading.value = false
initChart()
}, 500)
// 监听窗口大小变化
window.addEventListener('resize', handleResize)
})
const handleResize = () => {
if (chart) {
chart.resize()
}
}
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
if (chart) {
chart.dispose()
}
window.removeEventListener('resize', handleResize)
})
</script>
<style scoped lang="scss">
// 样式根据需要添加
</style>
@@ -0,0 +1,8 @@
import { markRaw } from 'vue'
const resultComps = {}
const files = import.meta.glob('./*.vue', { eager: true })
Object.keys(files).forEach((fileName) => {
let comp = files[fileName]
resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default
})
export default markRaw(resultComps)
@@ -0,0 +1,63 @@
<template>
<div class="info-card">
<div class="header">
<span class="title">系统信息</span>
</div>
<a-spin :spinning="loading">
<a-descriptions bordered :column="1">
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
{{ item.values }}
</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import systemApi from '@/api/system'
// 定义组件名称
defineOptions({
name: 'InfoWidget',
})
const loading = ref(true)
const sysInfo = ref([])
const getSystemList = async () => {
try {
const res = await systemApi.info.get()
if (res.code === 1) {
sysInfo.value = res.data
}
} catch (error) {
console.error('获取系统信息失败:', error)
} finally {
loading.value = false
}
}
onMounted(() => {
getSystemList()
})
</script>
<style scoped lang="scss">
.info-card {
background-color: #ffffff;
padding: 16px;
border-radius: 10px;
margin: 16px 0;
.header{
padding-bottom: 10px;
.title{
font-size: 18px;
}
}
:deep(.ant-descriptions-item-label) {
width: 140px;
font-weight: 500;
}
}
</style>
@@ -0,0 +1,36 @@
<template>
<a-card :bordered="true" title="进度环">
<div class="progress">
<a-progress type="dashboard" :percent="85.5" :width="160">
<template #format="percent">
<div class="percentage-value">{{ percent }}%</div>
<div class="percentage-label">当前进度</div>
</template>
</a-progress>
</div>
</a-card>
</template>
<script setup>
// 定义组件名称
defineOptions({
name: 'ProgressWidget',
})
</script>
<style scoped lang="scss">
.progress {
text-align: center;
.percentage-value {
font-size: 28px;
font-weight: 500;
}
.percentage-label {
font-size: 12px;
margin-top: 10px;
color: #999;
}
}
</style>
@@ -0,0 +1,93 @@
<template>
<a-row :gutter="10">
<a-col :span="4">
<a-card :bordered="true" title="今日数量">
<a-statistic :value="count.today" :formatter="formatNumber" />
</a-card>
</a-col>
<a-col :span="4">
<a-card :bordered="true" title="昨日数量">
<a-statistic :value="count.yesterday" :formatter="formatNumber" />
</a-card>
</a-col>
<a-col :span="4">
<a-card :bordered="true" title="本周数量">
<a-statistic :value="count.week" :formatter="formatNumber" />
</a-card>
</a-col>
<a-col :span="4">
<a-card :bordered="true" title="上周数量">
<a-statistic :value="count.last_week" :formatter="formatNumber" />
</a-card>
</a-col>
<a-col :span="4">
<a-card :bordered="true" title="今年数量">
<a-statistic :value="count.year" :formatter="formatNumber" />
</a-card>
</a-col>
<a-col :span="4">
<a-card :bordered="true" title="总数量">
<a-statistic :value="count.all" :formatter="formatNumber" />
</a-card>
</a-col>
</a-row>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import systemApi from '@/api/system'
// 定义组件名称
defineOptions({
name: 'SmsWidget',
})
const count = ref({})
const getSmsCount = async () => {
try {
// 注意:API中可能没有短信统计接口,这里使用模拟数据
// 如果有接口,请取消注释以下代码
// const res = await systemApi.sms.count.get()
// if (res.code === 1) {
// count.value = res.data
// }
// 模拟数据
count.value = {
today: 1234,
yesterday: 5678,
week: 45678,
last_week: 43210,
year: 567890,
all: 1234567
}
} catch (error) {
console.error('获取短信统计失败:', error)
}
}
const formatNumber = (value) => {
return value.toLocaleString()
}
onMounted(() => {
getSmsCount()
})
</script>
<style scoped lang="scss">
:deep(.ant-card-head-title) {
padding: 12px 0;
font-size: 14px;
}
:deep(.ant-card-body) {
padding: 20px;
}
:deep(.ant-statistic-content) {
font-size: 24px;
font-weight: 500;
}
</style>
@@ -0,0 +1,72 @@
<template>
<a-card :bordered="true" title="时钟" class="time-card">
<div class="time">
<h2>{{ time }}</h2>
<p>{{ day }}</p>
</div>
</a-card>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import tool from '@/utils/tool'
// 定义组件名称
defineOptions({
name: 'TimeWidget',
})
const time = ref('')
const day = ref('')
let timer = null
const showTime = () => {
time.value = tool.dateFormat(new Date(), 'hh:mm:ss')
day.value = tool.dateFormat(new Date(), 'yyyy年MM月dd日')
}
onMounted(() => {
showTime()
timer = setInterval(() => {
showTime()
}, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script>
<style scoped lang="scss">
.time-card {
background: linear-gradient(to right, #8e54e9, #4776e6);
color: #fff;
:deep(.ant-card-head-title) {
color: #fff;
}
:deep(.ant-card-head) {
border-color: rgba(255, 255, 255, 0.2);
}
:deep(.ant-card-body) {
background: transparent;
}
}
.time {
h2 {
font-size: 40px;
margin: 0;
}
p {
font-size: 14px;
margin-top: 13px;
opacity: 0.7;
}
}
</style>
@@ -0,0 +1,69 @@
<template>
<div class="ver-card">
<div class="header">
<span class="title">版本信息</span>
</div>
<a-spin :spinning="loading">
<a-descriptions bordered :column="1">
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
{{ item.values }}
</a-descriptions-item>
</a-descriptions>
</a-spin>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import systemApi from '@/api/system'
// 定义组件名称
defineOptions({
name: 'VerWidget',
})
const loading = ref(true)
const sysInfo = ref([])
const getSystemList = async () => {
try {
const res = await systemApi.version.get()
if (res.code === 1) {
sysInfo.value = res.data
}
} catch (error) {
console.error('获取版本信息失败:', error)
// 使用模拟数据作为fallback
sysInfo.value = [
{ label: '系统版本', values: '1.0.0' },
{ label: '框架版本', values: 'Vue 3.x' },
{ label: '构建时间', values: '2024-01-01' },
]
} finally {
loading.value = false
}
}
onMounted(() => {
getSystemList()
})
</script>
<style scoped lang="scss">
.ver-card {
background-color: #ffffff;
padding: 16px;
border-radius: 10px;
margin: 16px 0;
.header{
padding-bottom: 10px;
.title{
font-size: 18px;
}
}
:deep(.ant-descriptions-item-label) {
width: 160px;
font-weight: 500;
}
}
</style>
@@ -0,0 +1,97 @@
<template>
<a-card :bordered="true" title="欢迎">
<div class="welcome">
<div class="logo">
<img :src="logoImage" alt="logo">
<h2>VueAdmin</h2>
</div>
<div class="tips">
<div class="tips-item">
<div class="tips-item-icon"><MenuOutlined /></div>
<div class="tips-item-message">这里是项目控制台你可以点击右上方的"自定义"按钮来添加移除或者移动部件</div>
</div>
<div class="tips-item">
<div class="tips-item-icon"><RocketOutlined /></div>
<div class="tips-item-message">在提高前端算力减少带宽请求和代码执行力上多次优化并且持续着</div>
</div>
<div class="tips-item">
<div class="tips-item-icon"><CoffeeOutlined /></div>
<div class="tips-item-message">项目目的让前端工作更快乐</div>
</div>
</div>
</div>
</a-card>
</template>
<script setup>
import { MenuOutlined, RocketOutlined, CoffeeOutlined } from '@ant-design/icons-vue'
import logoImage from '@/assets/images/logo.png'
// 定义组件名称
defineOptions({
name: 'WelcomeWidget',
})
</script>
<style scoped lang="scss">
.welcome {
display: flex;
flex-direction: row;
align-items: center;
}
.welcome .logo {
text-align: center;
padding: 0 40px;
display: flex;
flex-direction: column;
align-items: center;
img {
width: 100px;
height: 100px;
margin-bottom: 20px;
}
h2 {
font-size: 30px;
font-weight: normal;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
}
}
.tips {
padding: 0 40px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.tips-item {
display: flex;
align-items: center;
justify-content: center;
padding: 7.5px 0;
}
.tips-item-icon {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 18px;
margin-right: 20px;
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
.tips-item-message {
flex: 1;
font-size: 14px;
}
</style>
@@ -0,0 +1,505 @@
<template>
<div :class="['widgets-home', customizing ? 'customizing' : '']" ref="main">
<div class="widgets-content">
<div class="widgets-top">
<div class="widgets-top-title">控制台</div>
<div class="widgets-top-actions">
<a-button v-if="customizing" type="primary" shape="round" @click="handleSave">
<template #icon>
<CheckOutlined />
</template>
完成
</a-button>
<a-button v-else type="primary" shape="round" @click="handleCustom">
<template #icon>
<EditOutlined />
</template>
自定义
</a-button>
</div>
</div>
<div class="widgets" ref="widgetsRef">
<div class="widgets-wrapper">
<div v-if="nowCompsList.length <= 0" class="no-widgets">
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
<a-row :gutter="15">
<a-col v-for="(item, index) in grid.layout" :key="index" :md="item" :xs="24">
<div class="draggable-wrapper">
<draggable v-model="grid.compsList[index]" item-key="key" :animation="200"
handle=".customize-overlay" group="widgets" class="draggable-box">
<template #item="{ element }">
<div class="widgets-item">
<component :is="allComps[element]" />
<div v-if="customizing" class="customize-overlay">
<a-button class="close" type="primary" ghost shape="circle"
@click="removeComp(element)">
<template #icon>
<CloseOutlined />
</template>
</a-button>
<label>
<component :is="allComps[element].icon" />
{{ allComps[element].title }}
</label>
</div>
</div>
</template>
</draggable>
</div>
</a-col>
</a-row>
</div>
</div>
</div>
<!-- 自定义侧边栏 -->
<a-drawer v-if="customizing" :open="customizing" :width="360" placement="right" :closable="false" :mask="false"
class="widgets-drawer">
<template #title>
<div class="widgets-aside-title">
<PlusCircleOutlined /> 添加部件
</div>
</template>
<template #extra>
<a-button type="text" @click="handleClose">
<template #icon>
<CloseOutlined />
</template>
</a-button>
</template>
<!-- 布局选择 -->
<div class="select-layout">
<h3>选择布局</h3>
<div class="select-layout-options">
<div class="select-layout-item item01" :class="{ active: grid.layout.join(',') === '12,6,6' }"
@click="setLayout([12, 6, 6])">
<a-row :gutter="2">
<a-col :span="12"><span></span></a-col>
<a-col :span="6"><span></span></a-col>
<a-col :span="6"><span></span></a-col>
</a-row>
</div>
<div class="select-layout-item item02" :class="{ active: grid.layout.join(',') === '24,16,8' }"
@click="setLayout([24, 16, 8])">
<a-row :gutter="2">
<a-col :span="24"><span></span></a-col>
<a-col :span="16"><span></span></a-col>
<a-col :span="8"><span></span></a-col>
</a-row>
</div>
<div class="select-layout-item item03" :class="{ active: grid.layout.join(',') === '24' }"
@click="setLayout([24])">
<a-row :gutter="2">
<a-col :span="24"><span></span></a-col>
<a-col :span="24"><span></span></a-col>
<a-col :span="24"><span></span></a-col>
</a-row>
</div>
</div>
</div>
<!-- 部件列表 -->
<div class="widgets-list">
<h3>可用部件</h3>
<div v-if="myCompsList.length <= 0" class="widgets-list-nodata">
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div>
<div v-for="item in myCompsList" :key="item.key" class="widgets-list-item">
<div class="item-logo">
<component :is="item.icon" />
</div>
<div class="item-info">
<h2>{{ item.title }}</h2>
<p>{{ item.description }}</p>
</div>
<div class="item-actions">
<a-button type="primary" @click="addComp(item)">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</div>
</div>
</div>
<template #footer>
<a-button @click="handleResetDefault">恢复默认</a-button>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { Empty } from 'ant-design-vue'
import draggable from 'vuedraggable'
import allComps from './components'
import config from '@/config'
// 定义组件名称
defineOptions({
name: 'WidgetsPage',
})
const customizing = ref(false)
const widgetsRef = ref(null)
const defaultGrid = config.DEFAULT_GRID
const grid = reactive({ layout: [], compsList: [] })
// 初始化
const initGrid = () => {
const savedGrid = localStorage.getItem('widgetsGrid')
if (savedGrid) {
try {
const parsed = JSON.parse(savedGrid)
grid.layout = parsed.layout
grid.compsList = parsed.compsList
} catch {
resetToDefault()
}
} else {
resetToDefault()
}
}
const resetToDefault = () => {
grid.layout = [...defaultGrid.layout]
grid.compsList = defaultGrid.compsList.map((arr) => [...arr])
}
// 计算属性
const allCompsList = computed(() => {
const list = []
for (const key in allComps) {
list.push({
key,
title: allComps[key].title,
icon: allComps[key].icon,
description: allComps[key].description,
})
}
const myCompKeys = grid.compsList.flat()
list.forEach((comp) => {
comp.disabled = myCompKeys.includes(comp.key)
})
return list
})
const myCompsList = computed(() => {
return allCompsList.value.filter((item) => !item.disabled)
})
const nowCompsList = computed(() => {
return grid.compsList.flat()
})
// 方法
const handleCustom = () => {
customizing.value = true
const oldWidth = widgetsRef.value?.offsetWidth || 0
nextTick(() => {
if (widgetsRef.value) {
const scale = widgetsRef.value.offsetWidth / oldWidth
widgetsRef.value.style.setProperty('transform', `scale(${scale})`)
}
})
}
const setLayout = (layout) => {
grid.layout = layout
if (layout.join(',') === '24') {
grid.compsList[0] = [...grid.compsList[0], ...grid.compsList[1], ...grid.compsList[2]]
grid.compsList[1] = []
grid.compsList[2] = []
}
}
const addComp = (item) => {
grid.compsList[0].push(item.key)
}
const removeComp = (key) => grid.compsList.forEach((list, index) => {
grid.compsList[index] = list.filter((k) => k !== key)
})
const handleSave = () => {
customizing.value = false
if (widgetsRef.value) {
widgetsRef.value.style.removeProperty('transform')
}
localStorage.setItem('widgetsGrid', JSON.stringify(grid))
emit('on-mounted')
}
const handleResetDefault = () => {
customizing.value = false
if (widgetsRef.value) {
widgetsRef.value.style.removeProperty('transform')
}
resetToDefault()
localStorage.removeItem('widgetsGrid')
}
const handleClose = () => {
customizing.value = false
if (widgetsRef.value) {
widgetsRef.value.style.removeProperty('transform')
}
}
// 生命周期
onMounted(() => {
initGrid()
emit('on-mounted')
})
const emit = defineEmits(['on-mounted'])
</script>
<style scoped lang="scss">
.widgets-home {
display: flex;
flex-direction: row;
flex: 1;
height: 100%;
}
.widgets-content {
flex: 1;
overflow: auto;
overflow-x: hidden;
padding: 15px;
}
.widgets-top {
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.widgets-top-title {
font-size: 18px;
font-weight: bold;
}
.widgets {
transform-origin: top left;
transition: transform 0.15s;
}
.customizing .widgets-wrapper {
margin-right: -360px;
}
.customizing .widgets-wrapper .ant-col {
padding-bottom: 15px;
}
.customizing .widgets-wrapper .draggable-wrapper {
border: 1px dashed #1890ff;
padding: 15px;
}
.customizing .widgets-wrapper .no-widgets {
display: none;
}
.customizing .widgets-item {
position: relative;
margin-bottom: 15px;
}
.customize-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
cursor: grab;
}
.customize-overlay:active {
cursor: grabbing;
}
.customize-overlay label {
background: #1890ff;
color: #fff;
height: 40px;
padding: 0 30px;
border-radius: 40px;
font-size: 18px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
margin-top: 8px;
.anticon {
margin-right: 15px;
font-size: 24px;
}
}
.customize-overlay .close {
position: absolute;
top: 15px;
right: 15px;
}
.widgets-list {
margin-top: 24px;
h3 {
font-size: 14px;
margin-bottom: 12px;
}
}
.widgets-list-item {
display: flex;
flex-direction: row;
padding: 15px;
align-items: center;
background: #fafafa;
border-radius: 8px;
margin-bottom: 8px;
transition: background 0.3s;
&:hover {
background: #f0f0f0;
}
}
.widgets-list-item .item-logo {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(180, 180, 180, 0.1);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-right: 15px;
color: #6a8bad;
}
.widgets-list-item .item-info {
flex: 1;
}
.widgets-list-item .item-info h2 {
font-size: 16px;
font-weight: normal;
cursor: default;
margin: 0 0 4px 0;
}
.widgets-list-item .item-info p {
font-size: 12px;
color: #999;
cursor: default;
margin: 0;
}
.widgets-wrapper .sortable-ghost {
opacity: 0.5;
}
.select-layout {
margin-bottom: 24px;
h3 {
font-size: 14px;
margin-bottom: 12px;
}
}
.select-layout-options {
display: flex;
gap: 12px;
}
.select-layout-item {
width: 60px;
height: 60px;
border: 2px solid #d9d9d9;
padding: 5px;
cursor: pointer;
border-radius: 4px;
transition: all 0.3s;
span {
display: block;
background: #d9d9d9;
height: 46px;
border-radius: 2px;
}
&.item02 span {
height: 30px;
}
&.item02 .ant-col:nth-child(1) span {
height: 14px;
margin-bottom: 2px;
}
&.item03 span {
height: 14px;
margin-bottom: 2px;
}
&:hover {
border-color: #1890ff;
}
&.active {
border-color: #1890ff;
span {
background: #1890ff;
}
}
}
.widgets-drawer {
:deep(.ant-drawer-body) {
padding: 0;
}
.widgets-aside-title {
display: flex;
align-items: center;
gap: 8px;
.anticon {
font-size: 18px;
}
}
}
@media (max-width: 992px) {
.customizing .widgets {
transform: scale(1) !important;
}
.customizing .widgets-drawer {
width: 100% !important;
}
.customizing .widgets-wrapper {
margin-right: 0;
}
}
</style>
@@ -0,0 +1,469 @@
<template>
<div class="my-app">
<a-card :bordered="false">
<template #title>
<div class="card-title">
<SettingOutlined />
<span>我的常用应用</span>
</div>
</template>
<template #extra>
<a-button type="primary" @click="showDrawer">
<template #icon>
<PlusOutlined />
</template>
添加应用
</a-button>
</template>
<div v-if="myApps.length === 0" class="empty-state">
<a-empty description="暂无常用应用,请点击上方按钮添加" />
</div>
<div v-else class="apps-grid">
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="ghost" drag-class="dragging"
class="draggable-grid">
<template #item="{ element }">
<div class="app-item" @click="handleAppClick(element)">
<div class="app-icon">
<component :is="getIconComponent(element.meta?.icon)" />
</div>
<div class="app-name">{{ element.meta?.title }}</div>
<div class="app-description">{{ element.meta?.description || '点击打开' }}</div>
</div>
</template>
</draggable>
</div>
</a-card>
<!-- 添加应用抽屉 -->
<a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right">
<div class="drawer-content">
<div class="app-section">
<div class="section-header">
<h3>
<StarFilled />
我的常用
</h3>
<span class="count">{{ myApps.length }} 个应用</span>
</div>
<p class="tips">拖拽卡片调整顺序点击移除按钮移除应用</p>
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
drag-class="drawer-dragging" class="drawer-grid" group="apps">
<template #item="{ element }">
<div class="drawer-app-card">
<div class="remove-btn" @click.stop="removeApp(element.path)">
<CloseOutlined />
</div>
<div class="app-icon">
<component :is="getIconComponent(element.meta?.icon)" />
</div>
<div class="app-name">{{ element.meta?.title }}</div>
</div>
</template>
<template #footer>
<div v-if="myApps.length === 0" class="empty-zone">
<a-empty description="暂无常用应用,从下方拖入应用" :image="false" />
</div>
</template>
</draggable>
</div>
<a-divider style="margin: 24px 0" />
<div class="app-section">
<div class="section-header">
<h3>
<AppstoreOutlined />
全部应用
</h3>
<span class="count">{{ allApps.length }} 个可用</span>
</div>
<p class="tips">拖拽卡片到上方添加为常用应用</p>
<draggable v-model="allApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
drag-class="drawer-dragging" class="drawer-grid" group="apps">
<template #item="{ element }">
<div class="drawer-app-card">
<div class="app-icon">
<component :is="getIconComponent(element.meta?.icon)" />
</div>
<div class="app-name">{{ element.meta?.title }}</div>
</div>
</template>
<template #footer>
<div v-if="allApps.length === 0" class="empty-zone">
<a-empty description="所有应用已添加" :image="false" />
</div>
</template>
</draggable>
</div>
</div>
<template #footer>
<a-button @click="drawerVisible = false">取消</a-button>
<a-button type="primary" @click="handleSave">保存设置</a-button>
</template>
</a-drawer>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import draggable from 'vuedraggable'
import { useUserStore } from '@/stores/modules/user'
import * as icons from '@ant-design/icons-vue'
// 定义组件名称
defineOptions({
name: 'MyApp',
})
const router = useRouter()
const userStore = useUserStore()
// 从菜单中提取所有应用项(扁平化菜单树)
const extractMenuItems = (menus) => {
const items = []
const traverse = (menuList) => {
for (const menu of menuList) {
// 只添加有路径的菜单项(排除父级菜单项)
if (menu.path && (!menu.children || menu.children.length === 0)) {
items.push(menu)
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
traverse(menu.children)
}
}
}
traverse(menus)
return items
}
// 获取所有可用应用
const allAvailableApps = computed(() => {
return extractMenuItems(userStore.menu || [])
})
const drawerVisible = ref(false)
const myApps = ref([])
const allApps = ref([])
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined
}
// 从本地存储加载数据
const loadApps = () => {
const savedApps = localStorage.getItem('myApps')
if (savedApps) {
const savedPaths = JSON.parse(savedApps)
myApps.value = allAvailableApps.value.filter(app => savedPaths.includes(app.path))
} else {
// 默认显示前4个应用
myApps.value = allAvailableApps.value.slice(0, 4)
}
updateAllApps()
}
// 更新全部应用列表(排除已添加的)
const updateAllApps = () => {
const myAppPaths = myApps.value.map(app => app.path)
allApps.value = allAvailableApps.value.filter(app => !myAppPaths.includes(app.path))
}
// 显示抽屉
const showDrawer = () => {
drawerVisible.value = true
updateAllApps()
}
// 移除应用
const removeApp = (path) => {
const index = myApps.value.findIndex(app => app.path === path)
if (index > -1) {
myApps.value.splice(index, 1)
updateAllApps()
}
}
// 应用点击处理
const handleAppClick = (app) => {
if (app.path) {
router.push(app.path)
} else {
message.warning('该应用没有配置路由')
}
}
// 保存设置
const handleSave = () => {
const appPaths = myApps.value.map(app => app.path)
localStorage.setItem('myApps', JSON.stringify(appPaths))
message.success('保存成功')
drawerVisible.value = false
}
// 初始化
loadApps()
// 监听菜单变化,重新加载应用
watch(
() => userStore.menu,
() => {
loadApps()
},
{ deep: true }
)
</script>
<style scoped lang="scss">
.my-app {
padding: 20px;
.card-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
.empty-state {
padding: 40px 0;
}
.apps-grid {
.draggable-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}
.app-item {
padding: 24px;
text-align: center;
background: #fafafa;
border-radius: 8px;
cursor: grab;
transition: all 0.3s;
border: 1px solid #f0f0f0;
&:active {
cursor: grabbing;
}
&:hover {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
border-color: #1890ff;
}
&.dragging {
opacity: 0.5;
}
&.ghost {
opacity: 0.3;
background: #e6f7ff;
border-color: #1890ff;
border-style: dashed;
}
.app-icon {
font-size: 40px;
color: #1890ff;
margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.app-name {
font-size: 16px;
font-weight: 500;
margin-bottom: 4px;
}
.app-description {
font-size: 12px;
color: #999;
}
}
}
}
// 抽屉样式 - 使用全局样式避免深度问题
:deep(.my-app .ant-drawer-body) {
padding: 16px;
}
:deep(.my-app .ant-drawer-footer) {
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
.drawer-content {
.app-section {
margin-bottom: 0;
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
h3 {
display: flex;
align-items: center;
gap: 6px;
font-size: 16px;
font-weight: 600;
margin: 0;
color: #262626;
.anticon {
font-size: 18px;
}
}
.count {
font-size: 12px;
color: #999;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
}
}
.tips {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 12px;
line-height: 1.5;
}
.empty-zone {
text-align: center;
padding: 30px 20px;
background: #fafafa;
border: 2px dashed #d9d9d9;
border-radius: 8px;
.ant-empty-description {
color: #bfbfbf;
margin: 0;
}
}
// 抽屉内卡片式网格布局
.drawer-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
min-height: 80px;
}
.drawer-app-card {
position: relative;
padding: 20px 16px;
text-align: center;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
cursor: grab;
transition: all 0.25s ease;
&:active {
cursor: grabbing;
}
&:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
transform: translateY(-2px);
}
.remove-btn {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background: #ff4d4f;
color: #fff;
border-radius: 50%;
font-size: 12px;
cursor: pointer;
opacity: 0;
transition: opacity 0.25s;
&:hover {
background: #ff7875;
}
}
&:hover .remove-btn {
opacity: 1;
}
.app-icon {
width: 48px;
height: 48px;
margin: 0 auto 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
border-radius: 12px;
font-size: 24px;
color: #1890ff;
:deep(.anticon) {
font-size: 24px;
}
}
.app-name {
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-description {
font-size: 12px;
color: #8c8c8c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
// 拖拽时的样式
.drawer-ghost {
opacity: 0.4;
background: #e6f7ff;
border-color: #1890ff;
border-style: dashed;
}
.drawer-dragging {
opacity: 0.6;
transform: scale(0.95);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
}
</style>
@@ -0,0 +1,21 @@
<template>
<div class="work-page">
<MyApp />
</div>
</template>
<script setup>
import MyApp from './components/myapp.vue'
// 定义组件名称
defineOptions({
name: 'WorkPage',
})
</script>
<style scoped lang="scss">
.work-page {
width: 100%;
height: 100%;
}
</style>