优化仪表盘内容

This commit is contained in:
2026-01-23 09:33:28 +08:00
parent 1963ea7244
commit 8283555457
6 changed files with 817 additions and 350 deletions

View File

@@ -0,0 +1,144 @@
export default class UploadAdapter {
constructor(loader, options) {
this.loader = loader;
this.options = options;
this.timeout = 60000; // 60秒超时
}
upload() {
return this.loader.file.then(
(file) =>
new Promise((resolve, reject) => {
this._initRequest();
this._initListeners(resolve, reject, file);
this._sendRequest(file);
this._initTimeout(reject);
}),
);
}
abort() {
if (this.xhr) {
this.xhr.abort();
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
}
_initRequest() {
const xhr = (this.xhr = new XMLHttpRequest());
xhr.open("POST", this.options.upload.uploadUrl, true);
xhr.responseType = "json";
}
_initListeners(resolve, reject, file) {
const xhr = this.xhr;
const loader = this.loader;
const genericErrorText = `Couldn't upload file: ${file.name}.`;
xhr.addEventListener("error", () => {
console.error("[UploadAdapter] Upload error for file:", file.name);
reject(genericErrorText);
});
xhr.addEventListener("abort", () => {
console.warn("[UploadAdapter] Upload aborted for file:", file.name);
reject();
});
xhr.addEventListener("timeout", () => {
console.error("[UploadAdapter] Upload timeout for file:", file.name);
reject(`Upload timeout: ${file.name}. Please try again.`);
});
xhr.addEventListener("load", () => {
const response = xhr.response;
// 检查响应状态码
if (xhr.status >= 200 && xhr.status < 300) {
if (!response) {
console.error("[UploadAdapter] Empty response for file:", file.name);
reject(genericErrorText);
return;
}
// 检查业务状态码(假设 code=1 表示成功)
if (response.code == 1 || response.code == undefined) {
const url = response.data?.url || response.data?.src;
if (!url) {
console.error("[UploadAdapter] No URL in response for file:", file.name, response);
reject("Upload succeeded but no URL returned");
return;
}
resolve({ default: url });
} else {
const errorMessage = response.message || genericErrorText;
console.error("[UploadAdapter] Upload failed for file:", file.name, "Error:", errorMessage);
reject(errorMessage);
}
} else {
console.error("[UploadAdapter] HTTP error for file:", file.name, "Status:", xhr.status);
reject(`Server error (${xhr.status}): ${file.name}`);
}
});
// 上传进度监听
if (xhr.upload) {
xhr.upload.addEventListener("progress", (evt) => {
if (evt.lengthComputable) {
loader.uploadTotal = evt.total;
loader.uploaded = evt.loaded;
}
});
}
}
_initTimeout(reject) {
// 清除之前的超时定时器(如果有)
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
// 设置新的超时定时器
this.timeoutId = setTimeout(() => {
if (this.xhr) {
this.xhr.abort();
reject(new Error("Upload timeout"));
}
}, this.timeout);
}
_sendRequest(file) {
// 设置请求超时
this.xhr.timeout = this.timeout;
// Set headers if specified.
const headers = this.options.upload.headers || {};
const extendData = this.options.upload.extendData || {};
// Use the withCredentials flag if specified.
const withCredentials = this.options.upload.withCredentials || false;
const uploadName = this.options.upload.uploadName || "file";
for (const headerName of Object.keys(headers)) {
this.xhr.setRequestHeader(headerName, headers[headerName]);
}
this.xhr.withCredentials = withCredentials;
const data = new FormData();
for (const key of Object.keys(extendData)) {
data.append(key, extendData[key]);
}
data.append(uploadName, file);
this.xhr.send(data);
}
}
export function UploadAdapterPlugin(editor) {
editor.plugins.get("FileRepository").createUploadAdapter = (loader) => {
return new UploadAdapter(loader, editor.config._config);
};
}

View File

@@ -0,0 +1,389 @@
<template>
<div :style="{ '--editor-height': editorHeight }">
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur"
@focus="onFocus"></ckeditor>
</div>
</template>
<script setup>
import {
ClassicEditor,
Alignment,
AutoImage,
Autoformat,
BlockQuote,
Bold,
CodeBlock,
DataFilter,
DataSchema,
Essentials,
FindAndReplace,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
GeneralHtmlSupport,
Heading,
Highlight,
HorizontalLine,
Image,
ImageCaption,
ImageInsert,
ImageResize,
ImageStyle,
ImageToolbar,
ImageUpload,
Indent,
IndentBlock,
Italic,
Link,
LinkImage,
List,
MediaEmbed,
MediaEmbedToolbar,
Mention,
Paragraph,
PasteFromOffice,
RemoveFormat,
SelectAll,
ShowBlocks,
SourceEditing,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Style,
Subscript,
Superscript,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
TextTransformation,
TodoList,
Underline,
Undo,
WordCount,
} from "ckeditor5";
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
import { UploadAdapterPlugin } from "./UploadAdapter.js";
import { ref, computed, watch } from "vue";
import { useCurrentInstance } from "@/utils/tool";
import coreTranslations from "ckeditor5/translations/zh-cn.js";
import "ckeditor5/ckeditor5.css";
const { proxy } = useCurrentInstance();
// 组件名称
defineOptions({
name: "scCkeditor"
});
// Props 定义
const props = defineProps({
modelValue: {
type: String,
default: "",
},
placeholder: {
type: String,
default: "请输入内容……",
},
toolbar: {
type: String,
default: "basic",
},
height: {
type: String,
default: "400px",
},
disabled: {
type: Boolean,
default: false,
},
});
// Emits 定义
const emit = defineEmits(["update:modelValue"]);
// 工具栏配置常量
const TOOLBARS = {
full: [
"sourceEditing",
"undo",
"redo",
"heading",
"style",
"|",
"superscript",
"subscript",
"removeFormat",
"bold",
"italic",
"underline",
"link",
"fontBackgroundColor",
"fontFamily",
"fontSize",
"fontColor",
"|",
"outdent",
"indent",
"alignment",
"bulletedList",
"numberedList",
"todoList",
"|",
"blockQuote",
"insertTable",
"imageInsert",
"mediaEmbed",
"highlight",
"horizontalLine",
"selectAll",
"showBlocks",
"specialCharacters",
"codeBlock",
"findAndReplace",
],
basic: [
"sourceEditing",
"undo",
"redo",
"heading",
"|",
"removeFormat",
"bold",
"italic",
"underline",
"link",
"fontBackgroundColor",
"fontFamily",
"fontSize",
"fontColor",
"|",
"outdent",
"indent",
"alignment",
"bulletedList",
"numberedList",
"todoList",
"|",
"insertTable",
"imageInsert",
"mediaEmbed",
],
simple: [
"undo",
"redo",
"heading",
"|",
"removeFormat",
"bold",
"italic",
"underline",
"link",
"fontBackgroundColor",
"fontFamily",
"fontSize",
"fontColor",
"|",
"insertTable",
"imageInsert",
"mediaEmbed",
],
};
// 插件配置常量
const PLUGINS = [
Alignment,
AutoImage,
Autoformat,
BlockQuote,
Bold,
CodeBlock,
DataFilter,
DataSchema,
Essentials,
FindAndReplace,
FontBackgroundColor,
FontColor,
FontFamily,
FontSize,
GeneralHtmlSupport,
Heading,
Highlight,
HorizontalLine,
Image,
ImageCaption,
ImageInsert,
ImageResize,
ImageStyle,
ImageToolbar,
ImageUpload,
Indent,
IndentBlock,
Italic,
Link,
LinkImage,
List,
MediaEmbed,
MediaEmbedToolbar,
Mention,
Paragraph,
PasteFromOffice,
RemoveFormat,
SelectAll,
ShowBlocks,
SourceEditing,
SpecialCharacters,
SpecialCharactersArrows,
SpecialCharactersCurrency,
SpecialCharactersEssentials,
SpecialCharactersLatin,
SpecialCharactersMathematical,
SpecialCharactersText,
Style,
Subscript,
Superscript,
Table,
TableCaption,
TableCellProperties,
TableColumnResize,
TableProperties,
TableToolbar,
TextTransformation,
TodoList,
Underline,
Undo,
WordCount,
UploadAdapterPlugin,
];
// 响应式数据
const editorData = ref("");
const editorHeight = ref(props.height);
const editor = ClassicEditor;
// 编辑器配置
const editorConfig = computed(() => ({
language: { ui: "zh-cn", content: "zh-cn" },
translations: [coreTranslations],
plugins: PLUGINS,
toolbar: {
shouldNotGroupWhenFull: true,
items: TOOLBARS[props.toolbar] || TOOLBARS.basic,
},
placeholder: props.placeholder,
image: {
styles: ["alignLeft", "alignCenter", "alignRight"],
toolbar: [
"imageTextAlternative",
"toggleImageCaption",
"|",
"imageStyle:alignLeft",
"imageStyle:alignCenter",
"imageStyle:alignRight",
"|",
"linkImage",
],
},
mediaEmbed: {
previewsInData: true,
providers: [
{
name: "mp4",
url: /\.(mp4|avi|mov|flv|wmv|mkv)$/i,
html: match => {
const url = match["input"];
return ('<video controls width="100%" height="100%" src="' + url + '"></video>')
},
},
],
},
fontSize: {
options: [10, 12, 14, 16, 18, 20, 22, 24, 26, 30, 32, 36],
},
style: {
definitions: [
{
name: "Article category",
element: "h3",
classes: ["category"],
},
{
name: "Info box",
element: "p",
classes: ["info-box"],
},
],
},
upload: {
uploadUrl: proxy?.$API?.common?.upload?.url || "",
withCredentials: false,
extendData: { type: "images" },
headers: {
Authorization: "Bearer " + proxy?.$TOOL?.data?.get("TOKEN"),
},
},
}));
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
editorData.value = newVal ?? "";
},
{ immediate: true }
);
// 监听 height 变化
watch(
() => props.height,
(newVal) => {
editorHeight.value = newVal;
}
);
// 移除图片宽高的正则替换函数
const stripImageDimensions = (html) => {
return html.replace(/<img[^>]*>/gi, (match) => {
return match
.replace(/width="[^"]*"/gi, "")
.replace(/height="[^"]*"/gi, "");
});
};
// 失去焦点事件 - 移除图片的固定宽高,避免响应式布局问题
const onBlur = () => {
const cleanedData = stripImageDimensions(editorData.value);
editorData.value = cleanedData;
emit("update:modelValue", cleanedData);
};
</script>
<style>
:root {
--ck-z-panel: 9999;
}
.ck-content {
height: var(--editor-height);
}
.ck-source-editing-area,
.ck-source-editing-area textarea {
height: var(--editor-height);
}
.ck-source-editing-area textarea {
overflow-y: scroll !important;
}
</style>

View File

@@ -30,6 +30,14 @@ export default {
//语言 //语言
LANG: 'zh-cn', LANG: 'zh-cn',
DASHBOARD_LAYOUT: 'widgets', //控制台首页默认布局
DEFAULT_GRID: {
//默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6]
layout: [24, 12, 12],
//小组件分布com取值:pages/home/components 文件名
compsList: [["welcome"], ["info"], ["ver"]],
},
//是否加密localStorage, 为空不加密 //是否加密localStorage, 为空不加密
//支持多种加密方式: 'AES', 'BASE64', 'DES' //支持多种加密方式: 'AES', 'BASE64', 'DES'
LS_ENCRYPTION: '', LS_ENCRYPTION: '',

View File

@@ -6,8 +6,8 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed, defineAsyncComponent } from 'vue'
import { defineAsyncComponent } from 'vue' import config from '@/config'
// 定义组件名称 // 定义组件名称
defineOptions({ defineOptions({
@@ -15,7 +15,7 @@ defineOptions({
}) })
const loading = ref(true) const loading = ref(true)
const dashboard = ref(import.meta.env.VITE_APP_DASHBOARD || 'work') const dashboard = ref(config.DASHBOARD_LAYOUT || 'work')
// 动态导入组件 // 动态导入组件
const components = { const components = {

View File

@@ -5,11 +5,15 @@
<div class="widgets-top-title">控制台</div> <div class="widgets-top-title">控制台</div>
<div class="widgets-top-actions"> <div class="widgets-top-actions">
<a-button v-if="customizing" type="primary" shape="round" @click="handleSave"> <a-button v-if="customizing" type="primary" shape="round" @click="handleSave">
<template #icon><CheckOutlined /></template> <template #icon>
<CheckOutlined />
</template>
完成 完成
</a-button> </a-button>
<a-button v-else type="primary" shape="round" @click="handleCustom"> <a-button v-else type="primary" shape="round" @click="handleCustom">
<template #icon><EditOutlined /></template> <template #icon>
<EditOutlined />
</template>
自定义 自定义
</a-button> </a-button>
</div> </div>
@@ -20,33 +24,19 @@
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" /> <a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
</div> </div>
<a-row :gutter="15"> <a-row :gutter="15">
<a-col <a-col v-for="(item, index) in grid.layout" :key="index" :md="item" :xs="24">
v-for="(item, index) in grid.layout"
:key="index"
:md="item"
:xs="24"
>
<div class="draggable-wrapper"> <div class="draggable-wrapper">
<draggable <draggable v-model="grid.compsList[index]" item-key="key" :animation="200"
v-model="grid.compsList[index]" handle=".customize-overlay" group="widgets" class="draggable-box">
item-key="key"
:animation="200"
handle=".customize-overlay"
group="widgets"
class="draggable-box"
>
<template #item="{ element }"> <template #item="{ element }">
<div class="widgets-item"> <div class="widgets-item">
<component :is="allComps[element]" /> <component :is="allComps[element]" />
<div v-if="customizing" class="customize-overlay"> <div v-if="customizing" class="customize-overlay">
<a-button <a-button class="close" type="primary" ghost shape="circle"
class="close" @click="removeComp(element)">
type="primary" <template #icon>
ghost <CloseOutlined />
shape="circle" </template>
@click="removeComp(element)"
>
<template #icon><CloseOutlined /></template>
</a-button> </a-button>
<label> <label>
<component :is="allComps[element].icon" /> <component :is="allComps[element].icon" />
@@ -64,15 +54,8 @@
</div> </div>
<!-- 自定义侧边栏 --> <!-- 自定义侧边栏 -->
<a-drawer <a-drawer v-if="customizing" :open="customizing" :width="360" placement="right" :closable="false" :mask="false"
v-if="customizing" class="widgets-drawer">
:open="customizing"
:width="360"
placement="right"
:closable="false"
:mask="false"
class="widgets-drawer"
>
<template #title> <template #title>
<div class="widgets-aside-title"> <div class="widgets-aside-title">
<PlusCircleOutlined /> 添加部件 <PlusCircleOutlined /> 添加部件
@@ -81,7 +64,9 @@
<template #extra> <template #extra>
<a-button type="text" @click="handleClose"> <a-button type="text" @click="handleClose">
<template #icon><CloseOutlined /></template> <template #icon>
<CloseOutlined />
</template>
</a-button> </a-button>
</template> </template>
@@ -89,33 +74,24 @@
<div class="select-layout"> <div class="select-layout">
<h3>选择布局</h3> <h3>选择布局</h3>
<div class="select-layout-options"> <div class="select-layout-options">
<div <div class="select-layout-item item01" :class="{ active: grid.layout.join(',') === '12,6,6' }"
class="select-layout-item item01" @click="setLayout([12, 6, 6])">
:class="{ active: grid.layout.join(',') === '12,6,6' }"
@click="setLayout([12, 6, 6])"
>
<a-row :gutter="2"> <a-row :gutter="2">
<a-col :span="12"><span></span></a-col> <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-col :span="6"><span></span></a-col> <a-col :span="6"><span></span></a-col>
</a-row> </a-row>
</div> </div>
<div <div class="select-layout-item item02" :class="{ active: grid.layout.join(',') === '24,16,8' }"
class="select-layout-item item02" @click="setLayout([24, 16, 8])">
:class="{ active: grid.layout.join(',') === '24,16,8' }"
@click="setLayout([24, 16, 8])"
>
<a-row :gutter="2"> <a-row :gutter="2">
<a-col :span="24"><span></span></a-col> <a-col :span="24"><span></span></a-col>
<a-col :span="16"><span></span></a-col> <a-col :span="16"><span></span></a-col>
<a-col :span="8"><span></span></a-col> <a-col :span="8"><span></span></a-col>
</a-row> </a-row>
</div> </div>
<div <div class="select-layout-item item03" :class="{ active: grid.layout.join(',') === '24' }"
class="select-layout-item item03" @click="setLayout([24])">
:class="{ active: grid.layout.join(',') === '24' }"
@click="setLayout([24])"
>
<a-row :gutter="2"> <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-col :span="24"><span></span></a-col>
@@ -141,7 +117,9 @@
</div> </div>
<div class="item-actions"> <div class="item-actions">
<a-button type="primary" @click="addComp(item)"> <a-button type="primary" @click="addComp(item)">
<template #icon><PlusOutlined /></template> <template #icon>
<PlusOutlined />
</template>
</a-button> </a-button>
</div> </div>
</div> </div>
@@ -158,90 +136,17 @@
import { ref, reactive, computed, onMounted, nextTick } from 'vue' import { ref, reactive, computed, onMounted, nextTick } from 'vue'
import { Empty } from 'ant-design-vue' import { Empty } from 'ant-design-vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import {
CheckOutlined,
EditOutlined,
CloseOutlined,
PlusCircleOutlined,
PlusOutlined,
} from '@ant-design/icons-vue'
import allComps from './components' import allComps from './components'
import config from '@/config'
// 定义组件名称 // 定义组件名称
defineOptions({ defineOptions({
name: 'WidgetsPage', name: 'WidgetsPage',
}) })
// 定义组件元数据
allComps.welcome.icon = 'GiftOutlined'
allComps.welcome.title = '欢迎'
allComps.welcome.description = '项目特色以及文档链接'
allComps.info.icon = 'MonitorOutlined'
allComps.info.title = '系统信息'
allComps.info.description = '当前项目系统信息'
allComps.about.icon = 'SettingOutlined'
allComps.about.title = '关于项目'
allComps.about.description = '点个星星支持一下'
allComps.echarts.icon = 'LineChartOutlined'
allComps.echarts.title = '实时收入'
allComps.echarts.description = 'Echarts组件演示'
allComps.progress.icon = 'DashboardOutlined'
allComps.progress.title = '进度环'
allComps.progress.description = '进度环原子组件演示'
allComps.time.icon = 'ClockCircleOutlined'
allComps.time.title = '时钟'
allComps.time.description = '演示部件效果'
allComps.sms.icon = 'MessageOutlined'
allComps.sms.title = '短信统计'
allComps.sms.description = '短信统计'
allComps.ver.icon = 'FileTextOutlined'
allComps.ver.title = '版本信息'
allComps.ver.description = '当前项目版本信息'
// 导入图标组件
import {
GiftOutlined,
MonitorOutlined,
SettingOutlined as SettingIcon,
LineChartOutlined,
DashboardOutlined,
ClockCircleOutlined,
MessageOutlined,
FileTextOutlined,
} from '@ant-design/icons-vue'
// 图标映射
const iconMap = {
GiftOutlined,
MonitorOutlined,
SettingOutlined: SettingIcon,
LineChartOutlined,
DashboardOutlined,
ClockCircleOutlined,
MessageOutlined,
FileTextOutlined,
}
// 替换组件中的icon引用
Object.keys(allComps).forEach((key) => {
if (allComps[key].icon && typeof allComps[key].icon === 'string') {
allComps[key].icon = iconMap[allComps[key].icon] || GiftOutlined
}
})
const customizing = ref(false) const customizing = ref(false)
const widgetsRef = ref(null) const widgetsRef = ref(null)
const defaultGrid = { const defaultGrid = config.DEFAULT_GRID
layout: [12, 6, 6],
compsList: [['welcome', 'info'], ['echarts', 'progress'], ['time', 'sms']],
}
const grid = reactive({ layout: [], compsList: [] }) const grid = reactive({ layout: [], compsList: [] })
// 初始化 // 初始化
@@ -252,7 +157,7 @@ const initGrid = () => {
const parsed = JSON.parse(savedGrid) const parsed = JSON.parse(savedGrid)
grid.layout = parsed.layout grid.layout = parsed.layout
grid.compsList = parsed.compsList grid.compsList = parsed.compsList
} catch (e) { } catch {
resetToDefault() resetToDefault()
} }
} else { } else {
@@ -316,9 +221,9 @@ const addComp = (item) => {
grid.compsList[0].push(item.key) grid.compsList[0].push(item.key)
} }
const removeComp = (key) => grid.compsList.forEach((list, index) => { const removeComp = (key) => grid.compsList.forEach((list, index) => {
grid.compsList[index] = list.filter((k) => k !== key) grid.compsList[index] = list.filter((k) => k !== key)
}) })
const handleSave = () => { const handleSave = () => {
customizing.value = false customizing.value = false
@@ -588,9 +493,11 @@ const emit = defineEmits(['on-mounted'])
.customizing .widgets { .customizing .widgets {
transform: scale(1) !important; transform: scale(1) !important;
} }
.customizing .widgets-drawer { .customizing .widgets-drawer {
width: 100% !important; width: 100% !important;
} }
.customizing .widgets-wrapper { .customizing .widgets-wrapper {
margin-right: 0; margin-right: 0;
} }

View File

@@ -21,15 +21,15 @@
</div> </div>
<div v-else class="apps-grid"> <div v-else class="apps-grid">
<draggable v-model="myApps" item-key="id" :animation="200" ghost-class="ghost" drag-class="dragging" <draggable v-model="myApps" item-key="path" :animation="200" ghost-class="ghost" drag-class="dragging"
class="draggable-grid"> class="draggable-grid">
<template #item="{ element }"> <template #item="{ element }">
<div class="app-item" @click="handleAppClick(element)"> <div class="app-item" @click="handleAppClick(element)">
<div class="app-icon"> <div class="app-icon">
<component :is="element.icon" /> <component :is="getIconComponent(element.meta?.icon)" />
</div> </div>
<div class="app-name">{{ element.name }}</div> <div class="app-name">{{ element.meta?.title }}</div>
<div class="app-description">{{ element.description }}</div> <div class="app-description">{{ element.meta?.description || '点击打开' }}</div>
</div> </div>
</template> </template>
</draggable> </draggable>
@@ -37,7 +37,7 @@
</a-card> </a-card>
<!-- 添加应用抽屉 --> <!-- 添加应用抽屉 -->
<a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right" class="app-drawer"> <a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right">
<div class="drawer-content"> <div class="drawer-content">
<div class="app-section"> <div class="app-section">
<div class="section-header"> <div class="section-header">
@@ -48,40 +48,25 @@
<span class="count">{{ myApps.length }} 个应用</span> <span class="count">{{ myApps.length }} 个应用</span>
</div> </div>
<p class="tips">拖拽卡片调整顺序点击移除按钮移除应用</p> <p class="tips">拖拽卡片调整顺序点击移除按钮移除应用</p>
<div class="draggable-wrapper"> <draggable v-model="myApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
<draggable drag-class="drawer-dragging" class="drawer-grid" group="apps">
v-model="myApps" <template #item="{ element }">
item-key="id" <div class="drawer-app-card">
:animation="200" <div class="remove-btn" @click.stop="removeApp(element.path)">
ghost-class="drawer-ghost" <CloseOutlined />
drag-class="drawer-dragging"
class="draggable-list"
group="apps">
<template #item="{ element }">
<div class="drawer-app-item">
<div class="drag-handle">
<HolderOutlined />
</div>
<div class="app-icon">
<component :is="element.icon" />
</div>
<div class="app-info">
<div class="app-name">{{ element.name }}</div>
<div class="app-description">{{ element.description }}</div>
</div>
<a-button type="text" danger size="small" @click.stop="removeApp(element.id)">
<template #icon>
<CloseOutlined />
</template>
移除
</a-button>
</div> </div>
</template> <div class="app-icon">
</draggable> <component :is="getIconComponent(element.meta?.icon)" />
<div v-if="myApps.length === 0" class="empty-zone"> </div>
<a-empty description="暂无常用应用" :image="false" /> <div class="app-name">{{ element.meta?.title }}</div>
</div> </div>
</div> </template>
<template #footer>
<div v-if="myApps.length === 0" class="empty-zone">
<a-empty description="暂无常用应用,从下方拖入应用" :image="false" />
</div>
</template>
</draggable>
</div> </div>
<a-divider style="margin: 24px 0" /> <a-divider style="margin: 24px 0" />
@@ -95,34 +80,22 @@
<span class="count">{{ allApps.length }} 个可用</span> <span class="count">{{ allApps.length }} 个可用</span>
</div> </div>
<p class="tips">拖拽卡片到上方添加为常用应用</p> <p class="tips">拖拽卡片到上方添加为常用应用</p>
<div class="draggable-wrapper"> <draggable v-model="allApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
<draggable drag-class="drawer-dragging" class="drawer-grid" group="apps">
v-model="allApps" <template #item="{ element }">
item-key="id" <div class="drawer-app-card">
:animation="200" <div class="app-icon">
ghost-class="drawer-ghost" <component :is="getIconComponent(element.meta?.icon)" />
drag-class="drawer-dragging"
class="draggable-list"
group="apps">
<template #item="{ element }">
<div class="drawer-app-item">
<div class="drag-handle">
<HolderOutlined />
</div>
<div class="app-icon">
<component :is="element.icon" />
</div>
<div class="app-info">
<div class="app-name">{{ element.name }}</div>
<div class="app-description">{{ element.description }}</div>
</div>
</div> </div>
</template> <div class="app-name">{{ element.meta?.title }}</div>
</draggable> </div>
<div v-if="allApps.length === 0" class="empty-zone"> </template>
<a-empty description="所有应用已添加" :image="false" /> <template #footer>
</div> <div v-if="allApps.length === 0" class="empty-zone">
</div> <a-empty description="所有应用已添加" :image="false" />
</div>
</template>
</draggable>
</div> </div>
</div> </div>
@@ -135,47 +108,73 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import draggable from 'vuedraggable' import draggable from 'vuedraggable'
import { useUserStore } from '@/stores/modules/user'
import * as icons from '@ant-design/icons-vue'
// 定义组件名称 // 定义组件名称
defineOptions({ defineOptions({
name: 'MyApp', name: 'MyApp',
}) })
// 应用数据 const router = useRouter()
const allAppsList = [ const userStore = useUserStore()
{ id: 1, name: '用户管理', description: '管理系统用户', icon: 'UserOutlined', path: '/auth/user' },
{ id: 2, name: '角色管理', description: '管理系统角色', icon: 'SettingOutlined', path: '/auth/role' }, // 从菜单中提取所有应用项(扁平化菜单树)
{ id: 3, name: '日志管理', description: '查看系统日志', icon: 'FileTextOutlined', path: '/system/log' }, const extractMenuItems = (menus) => {
{ id: 4, name: '数据统计', description: '查看数据报表', icon: 'BarChartOutlined', path: '/statistics' }, const items = []
{ id: 5, name: '日程安排', description: '查看日程', icon: 'CalendarOutlined', path: '/schedule' },
{ id: 6, name: '消息中心', description: '查看消息', icon: 'MessageOutlined', path: '/messages' }, const traverse = (menuList) => {
{ id: 7, name: '订单管理', description: '管理订单', icon: 'ShoppingCartOutlined', path: '/orders' }, 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 drawerVisible = ref(false)
const myApps = ref([]) const myApps = ref([])
const allApps = ref([]) const allApps = ref([])
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined
}
// 从本地存储加载数据 // 从本地存储加载数据
const loadApps = () => { const loadApps = () => {
const savedApps = localStorage.getItem('myApps') const savedApps = localStorage.getItem('myApps')
if (savedApps) { if (savedApps) {
const savedIds = JSON.parse(savedApps) const savedPaths = JSON.parse(savedApps)
myApps.value = allAppsList.filter(app => savedIds.includes(app.id)) myApps.value = allAvailableApps.value.filter(app => savedPaths.includes(app.path))
} else { } else {
// 默认显示前4个应用 // 默认显示前4个应用
myApps.value = allAppsList.slice(0, 4) myApps.value = allAvailableApps.value.slice(0, 4)
} }
updateAllApps() updateAllApps()
} }
// 更新全部应用列表(排除已添加的) // 更新全部应用列表(排除已添加的)
const updateAllApps = () => { const updateAllApps = () => {
const myAppIds = myApps.value.map(app => app.id) const myAppPaths = myApps.value.map(app => app.path)
allApps.value = allAppsList.filter(app => !myAppIds.includes(app.id)) allApps.value = allAvailableApps.value.filter(app => !myAppPaths.includes(app.path))
} }
// 显示抽屉 // 显示抽屉
@@ -185,8 +184,8 @@ const showDrawer = () => {
} }
// 移除应用 // 移除应用
const removeApp = (id) => { const removeApp = (path) => {
const index = myApps.value.findIndex(app => app.id === id) const index = myApps.value.findIndex(app => app.path === path)
if (index > -1) { if (index > -1) {
myApps.value.splice(index, 1) myApps.value.splice(index, 1)
updateAllApps() updateAllApps()
@@ -195,20 +194,32 @@ const removeApp = (id) => {
// 应用点击处理 // 应用点击处理
const handleAppClick = (app) => { const handleAppClick = (app) => {
// 这里可以跳转到对应的路由 if (app.path) {
message.info(`打开应用: ${app.name}`) router.push(app.path)
} else {
message.warning('该应用没有配置路由')
}
} }
// 保存设置 // 保存设置
const handleSave = () => { const handleSave = () => {
const appIds = myApps.value.map(app => app.id) const appPaths = myApps.value.map(app => app.path)
localStorage.setItem('myApps', JSON.stringify(appIds)) localStorage.setItem('myApps', JSON.stringify(appPaths))
message.success('保存成功') message.success('保存成功')
drawerVisible.value = false drawerVisible.value = false
} }
// 初始化 // 初始化
loadApps() loadApps()
// 监听菜单变化,重新加载应用
watch(
() => userStore.menu,
() => {
loadApps()
},
{ deep: true }
)
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
@@ -269,6 +280,9 @@ loadApps()
font-size: 40px; font-size: 40px;
color: #1890ff; color: #1890ff;
margin-bottom: 12px; margin-bottom: 12px;
display: flex;
align-items: center;
justify-content: center;
} }
.app-name { .app-name {
@@ -285,166 +299,171 @@ loadApps()
} }
} }
// 抽屉样式(不使用 scoped 的 deep直接使用全局样式 // 抽屉样式 - 使用全局样式避免深度问题
.my-app .app-drawer { :deep(.my-app .ant-drawer-body) {
.ant-drawer-body { padding: 16px;
padding: 16px; }
}
.ant-drawer-footer { :deep(.my-app .ant-drawer-footer) {
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
} }
.drawer-content { .drawer-content {
.app-section { .app-section {
margin-bottom: 0; margin-bottom: 0;
.section-header { .section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
h3 {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 6px;
margin-bottom: 8px; font-size: 16px;
font-weight: 600;
margin: 0;
color: #262626;
h3 { .anticon {
display: flex; font-size: 18px;
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 { .count {
font-size: 13px; font-size: 12px;
color: #8c8c8c; color: #999;
margin-bottom: 12px; background: #f5f5f5;
line-height: 1.5; 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;
} }
.draggable-wrapper { &:hover {
position: relative; border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
.draggable-list { transform: translateY(-2px);
min-height: 80px;
}
.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-app-item { .remove-btn {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 16px; justify-content: center;
background: #fff; background: #ff4d4f;
border: 1px solid #f0f0f0; color: #fff;
border-radius: 8px; border-radius: 50%;
margin-bottom: 12px; font-size: 12px;
transition: all 0.25s ease; cursor: pointer;
cursor: grab; opacity: 0;
transition: opacity 0.25s;
&:last-child {
margin-bottom: 0;
}
&:active {
cursor: grabbing;
}
&:hover { &:hover {
border-color: #1890ff; background: #ff7875;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
transform: translateX(2px);
} }
}
.drag-handle { &:hover .remove-btn {
font-size: 16px; opacity: 1;
color: #bfbfbf; }
margin-right: 12px;
cursor: grab;
transition: color 0.25s;
&:hover { .app-icon {
color: #1890ff; 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;
.app-icon { :deep(.anticon) {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
border-radius: 12px;
font-size: 24px; font-size: 24px;
color: #1890ff;
margin-right: 16px;
flex-shrink: 0;
}
.app-info {
flex: 1;
min-width: 0;
.app-name {
font-size: 15px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-description {
font-size: 13px;
color: #8c8c8c;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
} }
} }
// 拖拽时的样式 .app-name {
.drawer-ghost { font-size: 14px;
opacity: 0.4; font-weight: 500;
background: #e6f7ff; color: #262626;
border-color: #1890ff; margin-bottom: 4px;
border-style: dashed; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.drawer-dragging { .app-description {
opacity: 0.6; font-size: 12px;
transform: scale(0.98); color: #8c8c8c;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 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> </style>