前端代码格式化

This commit is contained in:
molong committed 2026-02-19 11:46:27 +08:00
1 parent d310a29c03
commit f0f0763ceb
101 files changed
+8952 -13203

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 300,
"useTabs": true,
"tabWidth": 4
}
+49 -59
View File
@@ -1,141 +1,131 @@
<script setup>
import { onMounted, onUnmounted, computed, watch, nextTick } from "vue";
import { storeToRefs } from "pinia";
import { useI18nStore } from "./stores/modules/i18n";
import { useLayoutStore } from "./stores/modules/layout";
import { useUserStore } from "./stores/modules/user";
import { useMessageStore } from "./stores/modules/message";
import { useWebSocket } from "./hooks/useWebSocket";
import { theme } from "ant-design-vue";
import i18n from "./i18n";
import zhCN from "ant-design-vue/es/locale/zh_CN";
import enUS from "ant-design-vue/es/locale/en_US";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import "dayjs/locale/en";
import { onMounted, onUnmounted, computed, watch, nextTick } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18nStore } from './stores/modules/i18n'
import { useLayoutStore } from './stores/modules/layout'
import { useUserStore } from './stores/modules/user'
import { useMessageStore } from './stores/modules/message'
import { useWebSocket } from './hooks/useWebSocket'
import { theme } from 'ant-design-vue'
import i18n from './i18n'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import enUS from 'ant-design-vue/es/locale/en_US'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import 'dayjs/locale/en'
// 定义组件名称
defineOptions({
name: "App",
});
name: 'App',
})
// i18n store
const i18nStore = useI18nStore();
const i18nStore = useI18nStore()
// layout store
const layoutStore = useLayoutStore();
const layoutStore = useLayoutStore()
// user store
const userStore = useUserStore();
const userStore = useUserStore()
// message store
const messageStore = useMessageStore();
const messageStore = useMessageStore()
// WebSocket
const { initWebSocket, closeWebSocket } = useWebSocket();
const { initWebSocket, closeWebSocket } = useWebSocket()
// 解构 themeColor 以确保响应式
const { themeColor } = storeToRefs(layoutStore);
const { themeColor } = storeToRefs(layoutStore)
// Ant Design Vue 语言配置
const antLocale = computed(() => {
return i18nStore.currentLocale === "zh-CN" ? zhCN : enUS;
});
return i18nStore.currentLocale === 'zh-CN' ? zhCN : enUS
})
// 获取弹出容器
const getPopupContainer = () => {
return document.body;
};
return document.body
}
// Ant Design Vue 主题配置
const antdTheme = computed(() => {
return {
algorithm: theme.defaultAlgorithm,
token: {
colorPrimary: themeColor.value || "#1890ff",
colorPrimary: themeColor.value || '#1890ff',
borderRadius: 6,
fontSize: 14,
},
components: {
Layout: {
headerBg: "#fff",
siderBg: "#001529",
headerBg: '#fff',
siderBg: '#001529',
},
Menu: {
darkItemBg: "#001529",
darkItemSelectedBg: themeColor.value || "#1890ff",
darkItemHoverBg: "#002140",
darkItemBg: '#001529',
darkItemSelectedBg: themeColor.value || '#1890ff',
darkItemHoverBg: '#002140',
},
},
};
});
}
})
// 监听主题颜色变化,更新 CSS 变量
watch(
themeColor,
(newColor) => {
if (newColor) {
document.documentElement.style.setProperty(
"--primary-color",
newColor,
);
document.documentElement.style.setProperty('--primary-color', newColor)
}
},
{ immediate: true },
);
)
// 监听用户信息变化,当用户信息完整时初始化 WebSocket
watch(
() => [userStore.token, userStore.userInfo],
() => {
if (userStore.isUserInfoComplete()) {
initWebSocket();
initWebSocket()
} else if (!userStore.isLoggedIn()) {
// 用户未登录,关闭 WebSocket
closeWebSocket();
closeWebSocket()
}
},
{ deep: true },
);
)
onMounted(async () => {
await nextTick();
await nextTick()
// 恢复消息数据
messageStore.restoreMessages();
messageStore.restoreMessages()
// 从持久化的 store 中读取语言设置并同步到 i18n
i18n.global.locale.value = i18nStore.currentLocale;
i18n.global.locale.value = i18nStore.currentLocale
// 同步 dayjs 语言
dayjs.locale(i18nStore.currentLocale === "zh-CN" ? "zh-cn" : "en");
dayjs.locale(i18nStore.currentLocale === 'zh-CN' ? 'zh-cn' : 'en')
// 初始化主题颜色到 CSS 变量
if (layoutStore.themeColor) {
document.documentElement.style.setProperty(
"--primary-color",
layoutStore.themeColor,
);
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
}
// 尝试初始化 WebSocket 连接
if (userStore.isUserInfoComplete()) {
initWebSocket();
initWebSocket()
}
});
})
onUnmounted(() => {
// 关闭 WebSocket 连接
closeWebSocket();
});
closeWebSocket()
})
</script>
<template>
<a-config-provider
:locale="antLocale"
:theme="antdTheme"
:getPopupContainer="getPopupContainer"
>
<a-config-provider :locale="antLocale" :theme="antdTheme" :getPopupContainer="getPopupContainer">
<router-view />
</a-config-provider>
</template>
+91 -116
View File
@@ -1,36 +1,36 @@
import request from "@/utils/request";
import request from '@/utils/request'
export default {
// 认证相关
login: {
post: async function (params) {
return await request.post("auth/login", params);
return await request.post('auth/login', params)
},
},
logout: {
post: async function () {
return await request.post("auth/logout");
return await request.post('auth/logout')
},
},
me: {
get: async function () {
return await request.get("auth/me");
return await request.get('auth/me')
},
},
changePassword: {
post: async function (params) {
return await request.post("auth/change-password", params);
return await request.post('auth/change-password', params)
},
},
// 文件上传
upload: {
post: async function (file) {
const formData = new FormData();
formData.append("file", file);
return await request.post("system/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
const formData = new FormData()
formData.append('file', file)
return await request.post('system/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
@@ -38,71 +38,68 @@ export default {
user: {
list: {
get: async function (params) {
return await request.get("auth/user", { params });
return await request.get('auth/user', { params })
},
},
detail: {
get: async function (id) {
return await request.get(`auth/user/${id}`);
return await request.get(`auth/user/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("auth/user", params);
return await request.post('auth/user', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/user/${id}`, params);
return await request.put(`auth/user/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/user/${id}`);
return await request.delete(`auth/user/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post("auth/user/batch-delete", params);
return await request.post('auth/user/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post("auth/user/batch-status", params);
return await request.post('auth/user/batch-status', params)
},
},
batchDepartment: {
post: async function (params) {
return await request.post(
"auth/user/batch-department",
params,
);
return await request.post('auth/user/batch-department', params)
},
},
batchRoles: {
post: async function (params) {
return await request.post("auth/user/batch-roles", params);
return await request.post('auth/user/batch-roles', params)
},
},
export: {
post: async function (params) {
return await request.post("auth/user/export", params, {
responseType: "blob",
});
return await request.post('auth/user/export', params, {
responseType: 'blob',
})
},
},
import: {
post: async function (formData) {
return await request.post("auth/user/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('auth/user/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
downloadTemplate: {
get: async function () {
return await request.get("auth/user/download-template", {
responseType: "blob",
});
return await request.get('auth/user/download-template', {
responseType: 'blob',
})
},
},
},
@@ -111,34 +108,27 @@ export default {
onlineUser: {
count: {
get: async function () {
return await request.get("auth/online-user/count");
return await request.get('auth/online-user/count')
},
},
list: {
get: async function (params) {
return await request.get("auth/online-user", { params });
return await request.get('auth/online-user', { params })
},
},
sessions: {
get: async function (userId) {
return await request.get(
`auth/online-user/${userId}/sessions`,
);
return await request.get(`auth/online-user/${userId}/sessions`)
},
},
offline: {
post: async function (userId, params) {
return await request.post(
`auth/online-user/${userId}/offline`,
params,
);
return await request.post(`auth/online-user/${userId}/offline`, params)
},
},
offlineAll: {
post: async function (userId) {
return await request.post(
`auth/online-user/${userId}/offline-all`,
);
return await request.post(`auth/online-user/${userId}/offline-all`)
},
},
},
@@ -147,84 +137,81 @@ export default {
role: {
list: {
get: async function (params) {
return await request.get("auth/role", { params });
return await request.get('auth/role', { params })
},
},
all: {
get: async function () {
return await request.get("auth/role/all");
return await request.get('auth/role/all')
},
},
detail: {
get: async function (id) {
return await request.get(`auth/role/${id}`);
return await request.get(`auth/role/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("auth/role", params);
return await request.post('auth/role', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/role/${id}`, params);
return await request.put(`auth/role/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/role/${id}`);
return await request.delete(`auth/role/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post("auth/role/batch-delete", params);
return await request.post('auth/role/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post("auth/role/batch-status", params);
return await request.post('auth/role/batch-status', params)
},
},
permissions: {
get: async function (id) {
return await request.get(`auth/role/${id}/permissions`);
return await request.get(`auth/role/${id}/permissions`)
},
post: async function (id, params) {
return await request.post(
`auth/role/${id}/permissions`,
params,
);
return await request.post(`auth/role/${id}/permissions`, params)
},
},
copy: {
post: async function (id, params) {
return await request.post(`auth/role/${id}/copy`, params);
return await request.post(`auth/role/${id}/copy`, params)
},
},
batchCopy: {
post: async function (params) {
return await request.post("auth/role/batch-copy", params);
return await request.post('auth/role/batch-copy', params)
},
},
export: {
post: async function (params) {
return await request.post("auth/role/export", params, {
responseType: "blob",
});
return await request.post('auth/role/export', params, {
responseType: 'blob',
})
},
},
import: {
post: async function (formData) {
return await request.post("auth/role/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('auth/role/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
downloadTemplate: {
get: async function () {
return await request.get("auth/role/download-template", {
responseType: "blob",
});
return await request.get('auth/role/download-template', {
responseType: 'blob',
})
},
},
},
@@ -233,74 +220,68 @@ export default {
permission: {
list: {
get: async function (params) {
return await request.get("auth/permission", { params });
return await request.get('auth/permission', { params })
},
},
tree: {
get: async function () {
return await request.get("auth/permission/tree");
return await request.get('auth/permission/tree')
},
},
menu: {
get: async function () {
return await request.get("auth/permission/menu");
return await request.get('auth/permission/menu')
},
},
detail: {
get: async function (id) {
return await request.get(`auth/permission/${id}`);
return await request.get(`auth/permission/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("auth/permission", params);
return await request.post('auth/permission', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/permission/${id}`, params);
return await request.put(`auth/permission/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/permission/${id}`);
return await request.delete(`auth/permission/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"auth/permission/batch-delete",
params,
);
return await request.post('auth/permission/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post(
"auth/permission/batch-status",
params,
);
return await request.post('auth/permission/batch-status', params)
},
},
export: {
post: async function (params) {
return await request.post("auth/permission/export", params, {
responseType: "blob",
});
return await request.post('auth/permission/export', params, {
responseType: 'blob',
})
},
},
import: {
post: async function (formData) {
return await request.post("auth/permission/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('auth/permission/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
downloadTemplate: {
get: async function () {
return await request.get("auth/permission/download-template", {
responseType: "blob",
});
return await request.get('auth/permission/download-template', {
responseType: 'blob',
})
},
},
},
@@ -309,75 +290,69 @@ export default {
department: {
list: {
get: async function (params) {
return await request.get("auth/department", { params });
return await request.get('auth/department', { params })
},
},
tree: {
get: async function (params) {
return await request.get("auth/department/tree", { params });
return await request.get('auth/department/tree', { params })
},
},
all: {
get: async function () {
return await request.get("auth/department/all");
return await request.get('auth/department/all')
},
},
detail: {
get: async function (id) {
return await request.get(`auth/department/${id}`);
return await request.get(`auth/department/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("auth/department", params);
return await request.post('auth/department', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`auth/department/${id}`, params);
return await request.put(`auth/department/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`auth/department/${id}`);
return await request.delete(`auth/department/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"auth/department/batch-delete",
params,
);
return await request.post('auth/department/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post(
"auth/department/batch-status",
params,
);
return await request.post('auth/department/batch-status', params)
},
},
export: {
post: async function (params) {
return await request.post("auth/department/export", params, {
responseType: "blob",
});
return await request.post('auth/department/export', params, {
responseType: 'blob',
})
},
},
import: {
post: async function (formData) {
return await request.post("auth/department/import", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('auth/department/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
downloadTemplate: {
get: async function () {
return await request.get("auth/department/download-template", {
responseType: "blob",
});
return await request.get('auth/department/download-template', {
responseType: 'blob',
})
},
},
},
};
}
+98 -132
View File
@@ -1,57 +1,51 @@
import request from "@/utils/request";
import request from '@/utils/request'
export default {
// 系统配置管理
config: {
list: {
get: async function (params) {
return await request.get("system/setting", { params });
return await request.get('system/setting', { params })
},
},
groups: {
get: async function () {
return await request.get("system/setting/groups");
return await request.get('system/setting/groups')
},
},
all: {
get: async function (params) {
return await request.get("system/setting/all", { params });
return await request.get('system/setting/all', { params })
},
},
detail: {
get: async function (id) {
return await request.get(`system/setting/${id}`);
return await request.get(`system/setting/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("system/setting", params);
return await request.post('system/setting', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/setting/${id}`, params);
return await request.put(`system/setting/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/setting/${id}`);
return await request.delete(`system/setting/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"system/setting/batch-delete",
params,
);
return await request.post('system/setting/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post(
"system/setting/batch-status",
params,
);
return await request.post('system/setting/batch-status', params)
},
},
},
@@ -60,40 +54,40 @@ export default {
log: {
list: {
get: async function (params) {
return await request.get("system/log", { params });
return await request.get('system/log', { params })
},
},
detail: {
get: async function (id) {
return await request.get(`system/log/${id}`);
return await request.get(`system/log/${id}`)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/log/${id}`);
return await request.delete(`system/log/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post("system/log/batch-delete", params);
return await request.post('system/log/batch-delete', params)
},
},
clear: {
post: async function (params) {
return await request.post("system/log/clear", params);
return await request.post('system/log/clear', params)
},
},
export: {
get: async function (params) {
return await request.get("system/log/export", {
return await request.get('system/log/export', {
params,
responseType: "blob",
});
responseType: 'blob',
})
},
},
statistics: {
get: async function (params) {
return await request.get("system/log/statistics", { params });
return await request.get('system/log/statistics', { params })
},
},
},
@@ -102,48 +96,42 @@ export default {
dictionary: {
list: {
get: async function (params) {
return await request.get("system/dictionary", { params });
return await request.get('system/dictionary', { params })
},
},
all: {
get: async function () {
return await request.get("system/dictionary/all");
return await request.get('system/dictionary/all')
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionary/${id}`);
return await request.get(`system/dictionary/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("system/dictionary", params);
return await request.post('system/dictionary', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/dictionary/${id}`, params);
return await request.put(`system/dictionary/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/dictionary/${id}`);
return await request.delete(`system/dictionary/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"system/dictionary/batch-delete",
params,
);
return await request.post('system/dictionary/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post(
"system/dictionary/batch-status",
params,
);
return await request.post('system/dictionary/batch-status', params)
},
},
items: {
@@ -151,7 +139,7 @@ export default {
get: async function (code) {
return await request.get(`system/dictionary/code`, {
params: { code },
});
})
},
},
},
@@ -161,51 +149,42 @@ export default {
dictionaryItem: {
list: {
get: async function (params) {
return await request.get("system/dictionary-item", { params });
return await request.get('system/dictionary-item', { params })
},
},
all: {
get: async function () {
return await request.get("system/dictionary-item/all");
return await request.get('system/dictionary-item/all')
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionary-item/${id}`);
return await request.get(`system/dictionary-item/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("system/dictionary-item", params);
return await request.post('system/dictionary-item', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(
`system/dictionary-item/${id}`,
params,
);
return await request.put(`system/dictionary-item/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/dictionary-item/${id}`);
return await request.delete(`system/dictionary-item/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"system/dictionary-item/batch-delete",
params,
);
return await request.post('system/dictionary-item/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post(
"system/dictionary-item/batch-status",
params,
);
return await request.post('system/dictionary-item/batch-status', params)
},
},
},
@@ -214,52 +193,52 @@ export default {
task: {
list: {
get: async function (params) {
return await request.get("system/task", { params });
return await request.get('system/task', { params })
},
},
all: {
get: async function () {
return await request.get("system/task/all");
return await request.get('system/task/all')
},
},
detail: {
get: async function (id) {
return await request.get(`system/task/${id}`);
return await request.get(`system/task/${id}`)
},
},
add: {
post: async function (params) {
return await request.post("system/task", params);
return await request.post('system/task', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/task/${id}`, params);
return await request.put(`system/task/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/task/${id}`);
return await request.delete(`system/task/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post("system/task/batch-delete", params);
return await request.post('system/task/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post("system/task/batch-status", params);
return await request.post('system/task/batch-status', params)
},
},
run: {
post: async function (id) {
return await request.post(`system/task/${id}/run`);
return await request.post(`system/task/${id}/run`)
},
},
statistics: {
get: async function () {
return await request.get("system/task/statistics");
return await request.get('system/task/statistics')
},
},
},
@@ -268,62 +247,62 @@ export default {
city: {
list: {
get: async function (params) {
return await request.get("system/city", { params });
return await request.get('system/city', { params })
},
},
tree: {
get: async function () {
return await request.get("system/city/tree");
return await request.get('system/city/tree')
},
},
detail: {
get: async function (id) {
return await request.get(`system/city/${id}`);
return await request.get(`system/city/${id}`)
},
},
children: {
get: async function (code) {
return await request.get(`system/city/code/${code}/children`);
return await request.get(`system/city/code/${code}/children`)
},
},
provinces: {
get: async function () {
return await request.get("system/city/provinces");
return await request.get('system/city/provinces')
},
},
cities: {
get: async function (provinceId) {
return await request.get(`system/city/${provinceId}/cities`);
return await request.get(`system/city/${provinceId}/cities`)
},
},
districts: {
get: async function (cityId) {
return await request.get(`system/city/${cityId}/districts`);
return await request.get(`system/city/${cityId}/districts`)
},
},
add: {
post: async function (params) {
return await request.post("system/city", params);
return await request.post('system/city', params)
},
},
edit: {
put: async function (id, params) {
return await request.put(`system/city/${id}`, params);
return await request.put(`system/city/${id}`, params)
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/city/${id}`);
return await request.delete(`system/city/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post("system/city/batch-delete", params);
return await request.post('system/city/batch-delete', params)
},
},
batchStatus: {
post: async function (params) {
return await request.post("system/city/batch-status", params);
return await request.post('system/city/batch-status', params)
},
},
},
@@ -332,31 +311,31 @@ export default {
upload: {
single: {
post: async function (formData) {
return await request.post("system/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('system/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
multiple: {
post: async function (formData) {
return await request.post("system/upload/multiple", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('system/upload/multiple', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
base64: {
post: async function (params) {
return await request.post("system/upload/base64", params);
return await request.post('system/upload/base64', params)
},
},
delete: {
post: async function (params) {
return await request.post("system/upload/delete", params);
return await request.post('system/upload/delete', params)
},
},
batchDelete: {
post: async function (params) {
return await request.post("system/upload/batch-delete", params);
return await request.post('system/upload/batch-delete', params)
},
},
},
@@ -365,78 +344,69 @@ export default {
notification: {
list: {
get: async function (params) {
return await request.get("system/notification", { params });
return await request.get('system/notification', { params })
},
},
unread: {
get: async function (params) {
return await request.get("system/notification/unread", {
return await request.get('system/notification/unread', {
params,
});
})
},
},
unreadCount: {
get: async function () {
return await request.get("system/notification/unread-count");
return await request.get('system/notification/unread-count')
},
},
detail: {
get: async function (id) {
return await request.get(`system/notification/${id}`);
return await request.get(`system/notification/${id}`)
},
},
markAsRead: {
post: async function (id) {
return await request.post(`system/notification/${id}/read`);
return await request.post(`system/notification/${id}/read`)
},
},
batchMarkAsRead: {
post: async function (params) {
return await request.post(
"system/notification/batch-read",
params,
);
return await request.post('system/notification/batch-read', params)
},
},
markAllAsRead: {
post: async function () {
return await request.post("system/notification/read-all");
return await request.post('system/notification/read-all')
},
},
delete: {
delete: async function (id) {
return await request.delete(`system/notification/${id}`);
return await request.delete(`system/notification/${id}`)
},
},
batchDelete: {
post: async function (params) {
return await request.post(
"system/notification/batch-delete",
params,
);
return await request.post('system/notification/batch-delete', params)
},
},
clearRead: {
post: async function () {
return await request.post("system/notification/clear-read");
return await request.post('system/notification/clear-read')
},
},
statistics: {
get: async function () {
return await request.get("system/notification/statistics");
return await request.get('system/notification/statistics')
},
},
send: {
post: async function (params) {
return await request.post("system/notification/send", params);
return await request.post('system/notification/send', params)
},
},
retryUnsent: {
post: async function (params) {
return await request.post(
"system/notification/retry-unsent",
params,
);
return await request.post('system/notification/retry-unsent', params)
},
},
},
@@ -446,78 +416,74 @@ export default {
config: {
all: {
get: async function () {
return await request.get("system/config");
return await request.get('system/config')
},
},
group: {
get: async function (params) {
return await request.get("system/config/group", {
return await request.get('system/config/group', {
params,
});
})
},
},
key: {
get: async function (params) {
return await request.get("system/config/key", { params });
return await request.get('system/config/key', { params })
},
},
},
dictionary: {
all: {
get: async function () {
return await request.get("system/dictionary");
return await request.get('system/dictionary')
},
},
code: {
get: async function (params) {
return await request.get("system/dictionary/code", {
return await request.get('system/dictionary/code', {
params,
});
})
},
},
detail: {
get: async function (id) {
return await request.get(`system/dictionary/${id}`);
return await request.get(`system/dictionary/${id}`)
},
},
},
city: {
tree: {
get: async function () {
return await request.get("system/city/tree");
return await request.get('system/city/tree')
},
},
provinces: {
get: async function () {
return await request.get("system/city/provinces");
return await request.get('system/city/provinces')
},
},
cities: {
get: async function (provinceId) {
return await request.get(
`system/city/${provinceId}/cities`,
);
return await request.get(`system/city/${provinceId}/cities`)
},
},
districts: {
get: async function (cityId) {
return await request.get(
`system/city/${cityId}/districts`,
);
return await request.get(`system/city/${cityId}/districts`)
},
},
detail: {
get: async function (id) {
return await request.get(`system/city/${id}`);
return await request.get(`system/city/${id}`)
},
},
},
upload: {
post: async function (formData) {
return await request.post("system/upload", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return await request.post('system/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
},
},
};
}
+1 -3
View File
@@ -5,9 +5,7 @@
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+11 -50
View File
@@ -34,52 +34,29 @@
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-end) 100%
);
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
position: relative;
overflow: hidden;
// Tech pattern background
&::before {
content: "";
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(
circle at 20% 50%,
rgba(255, 107, 53, 0.03) 0%,
transparent 50%
),
radial-gradient(
circle at 80% 20%,
rgba(255, 179, 71, 0.05) 0%,
transparent 40%
),
radial-gradient(
circle at 40% 80%,
rgba(255, 127, 80, 0.04) 0%,
transparent 40%
);
background-image: radial-gradient(circle at 20% 50%, rgba(255, 107, 53, 0.03) 0%, transparent 50%), radial-gradient(circle at 80% 20%, rgba(255, 179, 71, 0.05) 0%, transparent 40%), radial-gradient(circle at 40% 80%, rgba(255, 127, 80, 0.04) 0%, transparent 40%);
pointer-events: none;
}
// Animated tech elements
&::after {
content: "";
content: '';
position: absolute;
width: 600px;
height: 600px;
background: radial-gradient(
circle,
rgba(255, 107, 53, 0.08) 0%,
transparent 70%
);
background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%);
border-radius: 50%;
top: -200px;
right: -200px;
@@ -114,18 +91,14 @@
// Tech accent line
&::before {
content: "";
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 80px;
height: 4px;
background: linear-gradient(
90deg,
var(--auth-primary),
var(--auth-secondary)
);
background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary));
border-radius: 0 0 4px 4px;
}
}
@@ -139,11 +112,7 @@
font-weight: 700;
color: var(--text-primary);
margin-bottom: 8px;
background: linear-gradient(
135deg,
var(--auth-primary-dark),
var(--auth-primary)
);
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -220,20 +189,12 @@
transition: all 0.3s ease;
&.ant-btn-primary {
background: linear-gradient(
135deg,
var(--auth-primary),
var(--auth-primary-dark)
);
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
border: none;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
&:hover {
background: linear-gradient(
135deg,
var(--auth-primary-light),
var(--auth-primary)
);
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
@@ -291,7 +252,7 @@
&::before,
&::after {
content: "";
content: '';
flex: 1;
height: 1px;
background: var(--border-color);
+5 -5
View File
@@ -1,14 +1,14 @@
import * as AIcons from "@ant-design/icons-vue";
import * as ElementPlusIconsVue from "@element-plus/icons-vue";
import * as AIcons from '@ant-design/icons-vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
export default {
install(app) {
for (let icon in AIcons) {
app.component(`${icon}`, AIcons[icon]);
app.component(`${icon}`, AIcons[icon])
}
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(`El${key}`, component);
app.component(`El${key}`, component)
}
},
};
}
+24 -41
View File
@@ -1,32 +1,15 @@
<template>
<div class="sc-cron">
<a-input
v-model:value="innerValue"
placeholder="请输入Cron表达式"
allow-clear
@change="handleChange"
/>
<a-input v-model:value="innerValue" placeholder="请输入Cron表达式" allow-clear @change="handleChange" />
<div class="cron-tips">
<div class="tip-title">快捷设置:</div>
<a-space>
<a-button size="small" @click="setCron('0 0 * * *')"
>每天零点</a-button
>
<a-button size="small" @click="setCron('0 0 * * 0')"
>每周零点</a-button
>
<a-button size="small" @click="setCron('0 0 1 * *')"
>每月1号零点</a-button
>
<a-button size="small" @click="setCron('0 0/6 * * *')"
>每6小时</a-button
>
<a-button size="small" @click="setCron('0 * * * *')"
>每小时</a-button
>
<a-button size="small" @click="setCron('0 */30 * * *')"
>每30分钟</a-button
>
<a-button size="small" @click="setCron('0 0 * * *')">每天零点</a-button>
<a-button size="small" @click="setCron('0 0 * * 0')">每周零点</a-button>
<a-button size="small" @click="setCron('0 0 1 * *')">每月1号零点</a-button>
<a-button size="small" @click="setCron('0 0/6 * * *')">每6小时</a-button>
<a-button size="small" @click="setCron('0 * * * *')">每小时</a-button>
<a-button size="small" @click="setCron('0 */30 * * *')">每30分钟</a-button>
</a-space>
</div>
<div class="cron-description">格式: 秒 分 时 日 月 周</div>
@@ -34,48 +17,48 @@
</template>
<script setup>
import { ref, watch } from "vue";
import { ref, watch } from 'vue'
defineOptions({
name: "scCron",
});
name: 'scCron',
})
const props = defineProps({
modelValue: {
type: String,
default: "",
default: '',
},
shortcuts: {
type: Array,
default: () => [],
},
});
})
const emit = defineEmits(["update:modelValue", "change"]);
const emit = defineEmits(['update:modelValue', 'change'])
const innerValue = ref(props.modelValue);
const innerValue = ref(props.modelValue)
// 监听外部值变化
watch(
() => props.modelValue,
(newVal) => {
innerValue.value = newVal;
innerValue.value = newVal
},
);
)
// 设置Cron表达式
const setCron = (cron) => {
innerValue.value = cron;
emit("update:modelValue", cron);
emit("change", cron);
};
innerValue.value = cron
emit('update:modelValue', cron)
emit('change', cron)
}
// 处理输入变化
const handleChange = (e) => {
const value = e.target.value;
emit("update:modelValue", value);
emit("change", value);
};
const value = e.target.value
emit('update:modelValue', value)
emit('change', value)
}
</script>
<style scoped lang="scss">
@@ -1,164 +1,144 @@
export default class UploadAdapter {
constructor(loader, options) {
this.loader = loader;
this.options = options;
this.timeout = 60000; // 60秒超时
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);
this._initRequest()
this._initListeners(resolve, reject, file)
this._sendRequest(file)
this._initTimeout(reject)
}),
);
)
}
abort() {
if (this.xhr) {
this.xhr.abort();
this.xhr.abort()
}
if (this.timeoutId) {
clearTimeout(this.timeoutId);
clearTimeout(this.timeoutId)
}
}
_initRequest() {
const xhr = (this.xhr = new XMLHttpRequest());
const xhr = (this.xhr = new XMLHttpRequest())
xhr.open("POST", this.options.upload.uploadUrl, true);
xhr.responseType = "json";
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}.`;
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('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('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('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;
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;
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;
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;
console.error('[UploadAdapter] No URL in response for file:', file.name, response)
reject('Upload succeeded but no URL returned')
return
}
resolve({ default: url });
resolve({ default: url })
} else {
const errorMessage = response.message || genericErrorText;
console.error(
"[UploadAdapter] Upload failed for file:",
file.name,
"Error:",
errorMessage,
);
reject(errorMessage);
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}`);
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) => {
xhr.upload.addEventListener('progress', (evt) => {
if (evt.lengthComputable) {
loader.uploadTotal = evt.total;
loader.uploaded = evt.loaded;
loader.uploadTotal = evt.total
loader.uploaded = evt.loaded
}
});
})
}
}
_initTimeout(reject) {
// 清除之前的超时定时器(如果有)
if (this.timeoutId) {
clearTimeout(this.timeoutId);
clearTimeout(this.timeoutId)
}
// 设置新的超时定时器
this.timeoutId = setTimeout(() => {
if (this.xhr) {
this.xhr.abort();
reject(new Error("Upload timeout"));
this.xhr.abort()
reject(new Error('Upload timeout'))
}
}, this.timeout);
}, this.timeout)
}
_sendRequest(file) {
// 设置请求超时
this.xhr.timeout = this.timeout;
this.xhr.timeout = this.timeout
// Set headers if specified.
const headers = this.options.upload.headers || {};
const extendData = this.options.upload.extendData || {};
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";
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.setRequestHeader(headerName, headers[headerName])
}
this.xhr.withCredentials = withCredentials;
this.xhr.withCredentials = withCredentials
const data = new FormData();
const data = new FormData()
for (const key of Object.keys(extendData)) {
data.append(key, extendData[key]);
data.append(key, extendData[key])
}
data.append(uploadName, file);
data.append(uploadName, file)
this.xhr.send(data);
this.xhr.send(data)
}
}
export function UploadAdapterPlugin(editor) {
editor.plugins.get("FileRepository").createUploadAdapter = (loader) => {
return new UploadAdapter(loader, editor.config._config);
};
editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
return new UploadAdapter(loader, editor.config._config)
}
}
+111 -151
View File
@@ -1,13 +1,6 @@
<template>
<div :style="{ '--editor-height': editorHeight }">
<ckeditor
:editor="editor"
v-model="editorData"
:config="editorConfig"
:disabled="disabled"
@blur="onBlur"
@focus="onFocus"
></ckeditor>
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur" @focus="onFocus"></ckeditor>
</div>
</template>
@@ -75,137 +68,119 @@ import {
Underline,
Undo,
WordCount,
} from "ckeditor5";
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
import { UploadAdapterPlugin } from "./UploadAdapter.js";
} 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 { ref, computed, watch } from 'vue'
import { useCurrentInstance } from '@/utils/tool'
import coreTranslations from "ckeditor5/translations/zh-cn.js";
import "ckeditor5/ckeditor5.css";
import coreTranslations from 'ckeditor5/translations/zh-cn.js'
import 'ckeditor5/ckeditor5.css'
const { proxy } = useCurrentInstance();
const { proxy } = useCurrentInstance()
// 组件名称
defineOptions({
name: "scCkeditor",
});
name: 'scCkeditor',
})
// Props 定义
const props = defineProps({
modelValue: {
type: String,
default: "",
default: '',
},
placeholder: {
type: String,
default: "请输入内容……",
default: '请输入内容……',
},
toolbar: {
type: String,
default: "basic",
default: 'basic',
},
height: {
type: String,
default: "400px",
default: '400px',
},
disabled: {
type: Boolean,
default: false,
},
});
})
// Emits 定义
const emit = defineEmits(["update:modelValue"]);
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",
'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",
'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",
],
};
simple: ['undo', 'redo', 'heading', '|', 'removeFormat', 'bold', 'italic', 'underline', 'link', 'fontBackgroundColor', 'fontFamily', 'fontSize', 'fontColor', '|', 'insertTable', 'imageInsert', 'mediaEmbed'],
}
// 插件配置常量
const PLUGINS = [
@@ -271,16 +246,16 @@ const PLUGINS = [
Undo,
WordCount,
UploadAdapterPlugin,
];
]
// 响应式数据
const editorData = ref("");
const editorHeight = ref(props.height);
const editor = ClassicEditor;
const editorData = ref('')
const editorHeight = ref(props.height)
const editor = ClassicEditor
// 编辑器配置
const editorConfig = computed(() => ({
language: { ui: "zh-cn", content: "zh-cn" },
language: { ui: 'zh-cn', content: 'zh-cn' },
translations: [coreTranslations],
plugins: PLUGINS,
toolbar: {
@@ -289,31 +264,18 @@ const editorConfig = computed(() => ({
},
placeholder: props.placeholder,
image: {
styles: ["alignLeft", "alignCenter", "alignRight"],
toolbar: [
"imageTextAlternative",
"toggleImageCaption",
"|",
"imageStyle:alignLeft",
"imageStyle:alignCenter",
"imageStyle:alignRight",
"|",
"linkImage",
],
styles: ['alignLeft', 'alignCenter', 'alignRight'],
toolbar: ['imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'linkImage'],
},
mediaEmbed: {
previewsInData: true,
providers: [
{
name: "mp4",
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>'
);
const url = match['input']
return '<video controls width="100%" height="100%" src="' + url + '"></video>'
},
},
],
@@ -324,59 +286,57 @@ const editorConfig = computed(() => ({
style: {
definitions: [
{
name: "Article category",
element: "h3",
classes: ["category"],
name: 'Article category',
element: 'h3',
classes: ['category'],
},
{
name: "Info box",
element: "p",
classes: ["info-box"],
name: 'Info box',
element: 'p',
classes: ['info-box'],
},
],
},
upload: {
uploadUrl: proxy?.$API?.common?.upload?.url || "",
uploadUrl: proxy?.$API?.common?.upload?.url || '',
withCredentials: false,
extendData: { type: "images" },
extendData: { type: 'images' },
headers: {
Authorization: "Bearer " + proxy?.$TOOL?.data?.get("TOKEN"),
Authorization: 'Bearer ' + proxy?.$TOOL?.data?.get('TOKEN'),
},
},
}));
}))
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
editorData.value = newVal ?? "";
editorData.value = newVal ?? ''
},
{ immediate: true },
);
)
// 监听 height 变化
watch(
() => props.height,
(newVal) => {
editorHeight.value = newVal;
editorHeight.value = newVal
},
);
)
// 移除图片宽高的正则替换函数
const stripImageDimensions = (html) => {
return html.replace(/<img[^>]*>/gi, (match) => {
return match
.replace(/width="[^"]*"/gi, "")
.replace(/height="[^"]*"/gi, "");
});
};
return match.replace(/width="[^"]*"/gi, '').replace(/height="[^"]*"/gi, '')
})
}
// 失去焦点事件 - 移除图片的固定宽高,避免响应式布局问题
const onBlur = () => {
const cleanedData = stripImageDimensions(editorData.value);
editorData.value = cleanedData;
emit("update:modelValue", cleanedData);
};
const cleanedData = stripImageDimensions(editorData.value)
editorData.value = cleanedData
emit('update:modelValue', cleanedData)
}
</script>
<style>
+85 -164
View File
@@ -6,33 +6,24 @@
```vue
<template>
<a-button type="primary" @click="showExport = true">
<export-outlined /> 导出数据
</a-button>
<a-button type="primary" @click="showExport = true"> <export-outlined /> 导出数据 </a-button>
<sc-export
v-model:open="showExport"
:api="authApi.users.export.post"
title="导出用户"
filename="用户数据"
@success="handleExportSuccess"
@error="handleExportError"
/>
<sc-export v-model:open="showExport" :api="authApi.users.export.post" title="导出用户" filename="用户数据" @success="handleExportSuccess" @error="handleExportError" />
</template>
<script setup>
import { ref } from "vue";
import authApi from "@/api/auth";
import { ref } from 'vue'
import authApi from '@/api/auth'
const showExport = ref(false);
const showExport = ref(false)
const handleExportSuccess = (data) => {
console.log("导出成功", data);
};
console.log('导出成功', data)
}
const handleExportError = (message) => {
console.log("导出失败", message);
};
console.log('导出失败', message)
}
</script>
```
@@ -68,26 +59,14 @@ const handleExportError = (message) => {
```vue
<template>
<sc-export
v-model:open="showExport"
:api="exportApi"
@success="handleSuccess"
>
<sc-export v-model:open="showExport" :api="exportApi" @success="handleSuccess">
<template #formParams="{ formData }">
<a-form layout="vertical">
<a-form-item label="时间范围">
<a-range-picker
v-model:value="formData.date_range"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
<a-range-picker v-model:value="formData.date_range" format="YYYY-MM-DD" value-format="YYYY-MM-DD" />
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="formData.status"
placeholder="请选择状态"
:options="statusOptions"
/>
<a-select v-model:value="formData.status" placeholder="请选择状态" :options="statusOptions" />
</a-form-item>
</a-form>
</template>
@@ -101,40 +80,30 @@ const handleExportError = (message) => {
```vue
<template>
<a-button type="primary" @click="handleExport">
<export-outlined /> 导出用户
</a-button>
<a-button type="primary" @click="handleExport"> <export-outlined /> 导出用户 </a-button>
<sc-export
v-model:open="exportVisible"
:api="authApi.users.export.post"
title="导出用户数据"
filename="用户数据"
tip="将导出当前筛选条件下的所有用户数据"
@success="handleExportSuccess"
@error="handleExportError"
/>
<sc-export v-model:open="exportVisible" :api="authApi.users.export.post" title="导出用户数据" filename="用户数据" tip="将导出当前筛选条件下的所有用户数据" @success="handleExportSuccess" @error="handleExportError" />
</template>
<script setup>
import { ref } from "vue";
import { message } from "ant-design-vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
const exportVisible = ref(false);
const exportVisible = ref(false)
const handleExport = () => {
exportVisible.value = true;
};
exportVisible.value = true
}
const handleExportSuccess = (params) => {
message.success("导出成功");
};
message.success('导出成功')
}
const handleExportError = (errorMessage) => {
message.error("导出失败:" + errorMessage);
};
message.error('导出失败:' + errorMessage)
}
</script>
```
@@ -142,39 +111,16 @@ const handleExportError = (errorMessage) => {
```vue
<template>
<a-button type="primary" @click="handleExport">
<export-outlined /> 导出用户
</a-button>
<a-button type="primary" @click="handleExport"> <export-outlined /> 导出用户 </a-button>
<sc-export
v-model:open="exportVisible"
:api="authApi.users.export.post"
title="导出用户数据"
filename="用户数据"
:show-options="false"
:show-field-select="false"
:show-format-select="false"
@success="handleExportSuccess"
@error="handleExportError"
>
<sc-export v-model:open="exportVisible" :api="authApi.users.export.post" title="导出用户数据" filename="用户数据" :show-options="false" :show-field-select="false" :show-format-select="false" @success="handleExportSuccess" @error="handleExportError">
<template #formParams="{ formData }">
<a-form layout="vertical">
<a-form-item label="时间范围">
<a-range-picker
v-model:value="formData.date_range"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
<a-range-picker v-model:value="formData.date_range" format="YYYY-MM-DD" value-format="YYYY-MM-DD" style="width: 100%" />
</a-form-item>
<a-form-item label="部门">
<a-select
v-model:value="formData.department_id"
placeholder="请选择部门"
:options="departmentOptions"
:field-names="{ label: 'name', value: 'id' }"
allow-clear
/>
<a-select v-model:value="formData.department_id" placeholder="请选择部门" :options="departmentOptions" :field-names="{ label: 'name', value: 'id' }" allow-clear />
</a-form-item>
<a-form-item label="状态">
<a-select
@@ -188,13 +134,7 @@ const handleExportError = (errorMessage) => {
/>
</a-form-item>
<a-form-item label="角色">
<a-select
v-model:value="formData.role_ids"
mode="multiple"
placeholder="请选择角色"
:options="roleOptions"
:field-names="{ label: 'name', value: 'id' }"
/>
<a-select v-model:value="formData.role_ids" mode="multiple" placeholder="请选择角色" :options="roleOptions" :field-names="{ label: 'name', value: 'id' }" />
</a-form-item>
</a-form>
</template>
@@ -202,41 +142,38 @@ const handleExportError = (errorMessage) => {
</template>
<script setup>
import { ref, onMounted } from "vue";
import { message } from "ant-design-vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
const exportVisible = ref(false);
const departmentOptions = ref([]);
const roleOptions = ref([]);
const exportVisible = ref(false)
const departmentOptions = ref([])
const roleOptions = ref([])
// 加载选项数据
onMounted(async () => {
try {
const [deptRes, roleRes] = await Promise.all([
authApi.departments.all.get(),
authApi.roles.all.get(),
]);
departmentOptions.value = deptRes.data || [];
roleOptions.value = roleRes.data || [];
const [deptRes, roleRes] = await Promise.all([authApi.departments.all.get(), authApi.roles.all.get()])
departmentOptions.value = deptRes.data || []
roleOptions.value = roleRes.data || []
} catch (error) {
console.error("加载选项失败", error);
console.error('加载选项失败', error)
}
});
})
const handleExport = () => {
exportVisible.value = true;
};
exportVisible.value = true
}
const handleExportSuccess = (params) => {
message.success("导出成功");
console.log("导出参数", params);
};
message.success('导出成功')
console.log('导出参数', params)
}
const handleExportError = (errorMessage) => {
message.error("导出失败:" + errorMessage);
};
message.error('导出失败:' + errorMessage)
}
</script>
```
@@ -244,9 +181,7 @@ const handleExportError = (errorMessage) => {
```vue
<template>
<a-button type="primary" @click="handleExport">
<export-outlined /> 导出数据
</a-button>
<a-button type="primary" @click="handleExport"> <export-outlined /> 导出数据 </a-button>
<sc-export
v-model:open="exportVisible"
@@ -277,46 +212,46 @@ const handleExportError = (errorMessage) => {
</template>
<script setup>
import { ref } from "vue";
import { message } from "ant-design-vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { ExportOutlined } from '@ant-design/icons-vue'
const exportVisible = ref(false);
const exportVisible = ref(false)
// 字段选项
const fieldOptions = ref([
{ label: "用户名", value: "username" },
{ label: "姓名", value: "name" },
{ label: "邮箱", value: "email" },
{ label: "手机号", value: "phone" },
{ label: "部门", value: "department" },
{ label: "角色", value: "roles" },
{ label: "状态", value: "status" },
{ label: "创建时间", value: "created_at" },
{ label: "最后登录", value: "last_login_at" },
]);
{ label: '用户名', value: 'username' },
{ label: '姓名', value: 'name' },
{ label: '邮箱', value: 'email' },
{ label: '手机号', value: 'phone' },
{ label: '部门', value: 'department' },
{ label: '角色', value: 'roles' },
{ label: '状态', value: 'status' },
{ label: '创建时间', value: 'created_at' },
{ label: '最后登录', value: 'last_login_at' },
])
// 导出API
const exportApi = async (params) => {
// 这里调用实际的导出接口
// 示例:
// return await authApi.users.export.post(params)
console.log("导出参数", params);
console.log('导出参数', params)
// 返回一个 blob 对象
return new Blob(["test data"], { type: "application/vnd.ms-excel" });
};
return new Blob(['test data'], { type: 'application/vnd.ms-excel' })
}
const handleExport = () => {
exportVisible.value = true;
};
exportVisible.value = true
}
const handleExportSuccess = (params) => {
message.success("导出成功");
};
message.success('导出成功')
}
const handleExportError = (errorMessage) => {
message.error("导出失败:" + errorMessage);
};
message.error('导出失败:' + errorMessage)
}
</script>
```
@@ -333,29 +268,15 @@ const handleExportError = (errorMessage) => {
```vue
<template>
<sc-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
>
<sc-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination">
<template #toolbar>
<a-space>
<a-button type="primary" @click="handleExport">
<export-outlined /> 导出数据
</a-button>
<a-button type="primary" @click="handleExport"> <export-outlined /> 导出数据 </a-button>
</a-space>
</template>
</sc-table>
<sc-export
v-model:open="exportVisible"
:api="exportApi"
title="导出用户数据"
filename="用户数据"
:show-options="false"
@success="handleExportSuccess"
>
<sc-export v-model:open="exportVisible" :api="exportApi" title="导出用户数据" filename="用户数据" :show-options="false" @success="handleExportSuccess">
<template #formParams="{ formData }">
<a-form layout="vertical">
<a-form-item label="导出范围">
@@ -374,22 +295,22 @@ const handleExportError = (errorMessage) => {
</template>
<script setup>
import { ref } from "vue";
import { ExportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
import { ref } from 'vue'
import { ExportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
const exportVisible = ref(false);
const exportVisible = ref(false)
const handleExport = () => {
exportVisible.value = true;
};
exportVisible.value = true
}
const exportApi = async (params) => {
return await authApi.users.export.post(params);
};
return await authApi.users.export.post(params)
}
const handleExportSuccess = () => {
// 导出成功后的处理
};
}
</script>
```
@@ -1,12 +1,5 @@
<template>
<a-modal
v-model:open="visible"
:title="title"
:width="600"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-modal v-model:open="visible" :title="title" :width="600" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<div class="export-modal">
<!-- 表单参数插槽 -->
<div v-if="$slots.formParams" class="form-params">
@@ -17,29 +10,13 @@
<div v-if="showOptions" class="export-options">
<a-form layout="vertical">
<a-form-item label="导出字段" v-if="showFieldSelect">
<a-select
v-model:value="selectedFields"
mode="multiple"
:options="fieldOptions"
placeholder="请选择要导出的字段"
:show-arrow="true"
>
<a-select v-model:value="selectedFields" mode="multiple" :options="fieldOptions" placeholder="请选择要导出的字段" :show-arrow="true">
<template #dropdownRender="{ menuNode: menu }">
<v-nodes :vnodes="menu" />
<a-divider style="margin: 4px 0" />
<div style="padding: 4px 8px">
<a-button
type="text"
size="small"
@click="selectAllFields"
>全选</a-button
>
<a-button
type="text"
size="small"
@click="clearAllFields"
>清空</a-button
>
<a-button type="text" size="small" @click="selectAllFields">全选</a-button>
<a-button type="text" size="small" @click="clearAllFields">清空</a-button>
</div>
</template>
</a-select>
@@ -54,12 +31,7 @@
</a-form-item>
<a-form-item label="文件名">
<a-input
v-model:value="filename"
placeholder="请输入文件名"
:max-length="50"
show-count
/>
<a-input v-model:value="filename" placeholder="请输入文件名" :max-length="50" show-count />
</a-form-item>
</a-form>
</div>
@@ -75,8 +47,8 @@
</template>
<script setup>
import { ref, watch } from "vue";
import { message } from "ant-design-vue";
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
const props = defineProps({
// 是否显示弹窗
@@ -87,7 +59,7 @@ const props = defineProps({
// 弹窗标题
title: {
type: String,
default: "导出数据",
default: '导出数据',
},
// 导出API接口
api: {
@@ -117,150 +89,150 @@ const props = defineProps({
// 默认导出格式
defaultFormat: {
type: String,
default: "xlsx",
default: 'xlsx',
},
// 默认文件名
defaultFilename: {
type: String,
default: "",
default: '',
},
// 提示信息
tip: {
type: String,
default: "",
default: '',
},
});
})
const emit = defineEmits(["update:open", "success", "error", "change"]);
const emit = defineEmits(['update:open', 'success', 'error', 'change'])
// 弹窗显示状态
const visible = ref(false);
const visible = ref(false)
// 加载状态
const loading = ref(false);
const loading = ref(false)
// 表单数据(用于插槽)
const formData = ref({});
const formData = ref({})
// 导出选项
const selectedFields = ref([]);
const exportFormat = ref(props.defaultFormat);
const filename = ref(props.defaultFilename || "导出数据");
const selectedFields = ref([])
const exportFormat = ref(props.defaultFormat)
const filename = ref(props.defaultFilename || '导出数据')
// VNodes 组件(用于 dropdownRender)
const VNodes = (_, { attrs }) => {
return attrs.vnodes;
};
return attrs.vnodes
}
// 监听外部 open 变化
watch(
() => props.open,
(val) => {
visible.value = val;
visible.value = val
if (val) {
// 打开时重置表单
formData.value = {};
selectedFields.value = [];
exportFormat.value = props.defaultFormat;
filename.value = props.defaultFilename || "导出数据";
formData.value = {}
selectedFields.value = []
exportFormat.value = props.defaultFormat
filename.value = props.defaultFilename || '导出数据'
}
},
{ immediate: true },
);
)
// 监听内部 visible 变化,同步到外部
watch(visible, (val) => {
emit("update:open", val);
});
emit('update:open', val)
})
// 全选字段
const selectAllFields = () => {
selectedFields.value = props.fieldOptions.map((item) => item.value);
};
selectedFields.value = props.fieldOptions.map((item) => item.value)
}
// 清空字段
const clearAllFields = () => {
selectedFields.value = [];
};
selectedFields.value = []
}
// 确认导出
const handleOk = async () => {
// 构建导出参数
const exportParams = {};
const exportParams = {}
// 添加表单参数(如果有)
if (Object.keys(formData.value).length > 0) {
Object.assign(exportParams, formData.value);
Object.assign(exportParams, formData.value)
}
// 添加字段选择(如果有)
if (props.showFieldSelect && selectedFields.value.length > 0) {
exportParams.fields = selectedFields.value;
exportParams.fields = selectedFields.value
}
// 添加格式选择(如果有)
if (props.showFormatSelect) {
exportParams.format = exportFormat.value;
exportParams.format = exportFormat.value
}
try {
loading.value = true;
loading.value = true
// 调用导出接口
const blob = await props.api(exportParams);
const blob = await props.api(exportParams)
// 确定文件扩展名
let ext = exportFormat.value;
let ext = exportFormat.value
if (!props.showFormatSelect && blob.type) {
// 如果没有格式选择,根据 blob 类型判断
if (blob.type.includes("sheet") || blob.type.includes("excel")) {
ext = "xlsx";
} else if (blob.type.includes("csv")) {
ext = "csv";
if (blob.type.includes('sheet') || blob.type.includes('excel')) {
ext = 'xlsx'
} else if (blob.type.includes('csv')) {
ext = 'csv'
}
}
// 创建下载链接
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${filename.value}.${ext}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${filename.value}.${ext}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success("导出成功");
emit("success", exportParams);
handleCancel();
message.success('导出成功')
emit('success', exportParams)
handleCancel()
} catch (error) {
message.error("导出失败:" + error.message);
emit("error", error.message, error);
message.error('导出失败:' + error.message)
emit('error', error.message, error)
} finally {
loading.value = false;
loading.value = false
}
};
}
// 取消导出
const handleCancel = () => {
visible.value = false;
formData.value = {};
selectedFields.value = [];
filename.value = props.defaultFilename || "导出数据";
};
visible.value = false
formData.value = {}
selectedFields.value = []
filename.value = props.defaultFilename || '导出数据'
}
</script>
<script>
export default {
name: "ScExport",
name: 'ScExport',
components: {
VNodes: {
render() {
return this.$slots.default ? this.$slots.default() : null;
return this.$slots.default ? this.$slots.default() : null
},
},
},
};
}
</script>
<style scoped>
+56 -173
View File
@@ -1,21 +1,6 @@
<template>
<a-form
:model="formData"
:rules="rules"
:label-col="labelCol"
:wrapper-col="wrapperCol"
:layout="layout"
@finish="handleFinish"
@finish-failed="handleFinishFailed"
>
<a-form-item
v-for="item in formItems"
:key="item.field"
:label="item.label"
:name="item.field"
:required="item.required"
:colon="item.colon"
>
<a-form :model="formData" :rules="rules" :label-col="labelCol" :wrapper-col="wrapperCol" :layout="layout" @finish="handleFinish" @finish-failed="handleFinishFailed">
<a-form-item v-for="item in formItems" :key="item.field" :label="item.label" :name="item.field" :required="item.required" :colon="item.colon">
<!-- 输入框 -->
<template v-if="item.type === 'input'">
<a-input
@@ -27,9 +12,7 @@
:type="item.inputType || 'text'"
:prefix="item.prefix"
:suffix="item.suffix"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
@@ -43,23 +26,13 @@
:rows="item.rows || 4"
:max-length="item.maxLength"
:show-count="item.showCount"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
<!-- 密码输入框 -->
<template v-else-if="item.type === 'password'">
<a-input-password
v-model:value="formData[item.field]"
:placeholder="item.placeholder || `请输入${item.label}`"
:disabled="item.disabled"
:max-length="item.maxLength"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
<a-input-password v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled" :max-length="item.maxLength" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 数字输入框 -->
@@ -74,9 +47,7 @@
:precision="item.precision"
:controls="item.controls !== false"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
@@ -91,46 +62,24 @@
:options="item.options"
:field-names="item.fieldNames"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
>
<template v-if="!item.options" #notFoundContent>
<a-empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无数据" />
</template>
</a-select>
</template>
<!-- 单选框 -->
<template v-else-if="item.type === 'radio'">
<a-radio-group
v-model:value="formData[item.field]"
:disabled="item.disabled"
:button-style="item.buttonStyle"
@change="
item.onChange && item.onChange(formData[item.field])
"
>
<a-radio-group v-model:value="formData[item.field]" :disabled="item.disabled" :button-style="item.buttonStyle" @change="item.onChange && item.onChange(formData[item.field])">
<template v-if="item.options">
<a-radio
v-for="opt in item.options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
<a-radio v-for="opt in item.options" :key="opt.value" :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</a-radio>
</template>
<template v-else-if="item.buttonStyle === 'solid'">
<a-radio-button
v-for="opt in item.options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
<a-radio-button v-for="opt in item.options" :key="opt.value" :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</a-radio-button>
</template>
@@ -139,20 +88,9 @@
<!-- 多选框 -->
<template v-else-if="item.type === 'checkbox'">
<a-checkbox-group
v-model:value="formData[item.field]"
:disabled="item.disabled"
@change="
item.onChange && item.onChange(formData[item.field])
"
>
<a-checkbox-group v-model:value="formData[item.field]" :disabled="item.disabled" @change="item.onChange && item.onChange(formData[item.field])">
<template v-if="item.options">
<a-checkbox
v-for="opt in item.options"
:key="opt.value"
:value="opt.value"
:disabled="opt.disabled"
>
<a-checkbox v-for="opt in item.options" :key="opt.value" :value="opt.value" :disabled="opt.disabled">
{{ opt.label }}
</a-checkbox>
</template>
@@ -161,15 +99,7 @@
<!-- 开关 -->
<template v-else-if="item.type === 'switch'">
<a-switch
v-model:checked="formData[item.field]"
:disabled="item.disabled"
:checked-children="item.checkedChildren || '开'"
:un-checked-children="item.unCheckedChildren || '关'"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
<a-switch v-model:checked="formData[item.field]" :disabled="item.disabled" :checked-children="item.checkedChildren || '开'" :un-checked-children="item.unCheckedChildren || '关'" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 日期选择 -->
@@ -181,9 +111,7 @@
:format="item.format || 'YYYY-MM-DD'"
:value-format="item.valueFormat || 'YYYY-MM-DD'"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
@@ -196,9 +124,7 @@
:format="item.format || 'YYYY-MM-DD'"
:value-format="item.valueFormat || 'YYYY-MM-DD'"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
@@ -211,9 +137,7 @@
:format="item.format || 'HH:mm:ss'"
:value-format="item.valueFormat || 'HH:mm:ss'"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
@@ -230,10 +154,7 @@
:disabled="item.disabled"
@change="(info) => item.onChange && item.onChange(info)"
>
<a-button
v-if="item.listType !== 'picture-card'"
type="primary"
>
<a-button v-if="item.listType !== 'picture-card'" type="primary">
<UploadOutlined />
点击上传
</a-button>
@@ -246,31 +167,12 @@
<!-- 评分 -->
<template v-else-if="item.type === 'rate'">
<a-rate
v-model:value="formData[item.field]"
:disabled="item.disabled"
:count="item.count || 5"
:allow-half="item.allowHalf"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
<a-rate v-model:value="formData[item.field]" :disabled="item.disabled" :count="item.count || 5" :allow-half="item.allowHalf" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 滑块 -->
<template v-else-if="item.type === 'slider'">
<a-slider
v-model:value="formData[item.field]"
:disabled="item.disabled"
:min="item.min || 0"
:max="item.max || 100"
:step="item.step || 1"
:marks="item.marks"
:range="item.range"
@change="
item.onChange && item.onChange(formData[item.field])
"
/>
<a-slider v-model:value="formData[item.field]" :disabled="item.disabled" :min="item.min || 0" :max="item.max || 100" :step="item.step || 1" :marks="item.marks" :range="item.range" @change="item.onChange && item.onChange(formData[item.field])" />
</template>
<!-- 级联选择 -->
@@ -283,19 +185,13 @@
:change-on-select="item.changeOnSelect"
:field-names="item.fieldNames"
style="width: 100%"
@change="
item.onChange && item.onChange(formData[item.field])
"
@change="item.onChange && item.onChange(formData[item.field])"
/>
</template>
<!-- 自定义插槽 -->
<template v-else-if="item.type === 'slot'">
<slot
:name="item.slotName || item.field"
:field="item.field"
:value="formData[item.field]"
></slot>
<slot :name="item.slotName || item.field" :field="item.field" :value="formData[item.field]"></slot>
</template>
<!-- 提示信息 -->
@@ -307,27 +203,14 @@
<!-- 表单操作按钮 -->
<a-form-item v-if="showActions" :wrapper-col="actionWrapperCol">
<a-space>
<a-button
type="primary"
html-type="submit"
:loading="loading"
:size="buttonSize"
>
{{ submitText || "提交" }}
<a-button type="primary" html-type="submit" :loading="loading" :size="buttonSize">
{{ submitText || '提交' }}
</a-button>
<a-button
v-if="showReset"
@click="handleReset"
:size="buttonSize"
>
{{ resetText || "重置" }}
<a-button v-if="showReset" @click="handleReset" :size="buttonSize">
{{ resetText || '重置' }}
</a-button>
<a-button
v-if="showCancel"
@click="handleCancel"
:size="buttonSize"
>
{{ cancelText || "取消" }}
<a-button v-if="showCancel" @click="handleCancel" :size="buttonSize">
{{ cancelText || '取消' }}
</a-button>
<slot name="actions"></slot>
</a-space>
@@ -339,9 +222,9 @@
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { Empty } from "ant-design-vue";
import { UploadOutlined, PlusOutlined } from "@ant-design/icons-vue";
import { ref, reactive, computed, watch } from 'vue'
import { Empty } from 'ant-design-vue'
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue'
const props = defineProps({
// 表单项配置
@@ -358,7 +241,7 @@ const props = defineProps({
// 表单布局
layout: {
type: String,
default: "horizontal", // horizontal, vertical, inline
default: 'horizontal', // horizontal, vertical, inline
},
// 标签宽度
labelCol: {
@@ -397,75 +280,75 @@ const props = defineProps({
// 按钮大小
buttonSize: {
type: String,
default: "middle",
default: 'middle',
},
// 加载状态
loading: {
type: Boolean,
default: false,
},
});
})
const emit = defineEmits(["finish", "finish-failed", "reset", "cancel"]);
const emit = defineEmits(['finish', 'finish-failed', 'reset', 'cancel'])
// 表单数据
const formData = reactive({ ...props.initialValues });
const formData = reactive({ ...props.initialValues })
// 表单验证规则
const rules = computed(() => {
const result = {};
const result = {}
props.formItems.forEach((item) => {
if (item.rules && item.rules.length > 0) {
result[item.field] = item.rules;
result[item.field] = item.rules
}
});
return result;
});
})
return result
})
// 监听初始值变化
watch(
() => props.initialValues,
(newVal) => {
Object.assign(formData, newVal);
Object.assign(formData, newVal)
},
{ deep: true },
);
)
// 表单提交
const handleFinish = (values) => {
emit("finish", values);
};
emit('finish', values)
}
// 表单验证失败
const handleFinishFailed = (errorInfo) => {
emit("finish-failed", errorInfo);
};
emit('finish-failed', errorInfo)
}
// 重置表单
const handleReset = () => {
Object.assign(formData, props.initialValues);
emit("reset", formData);
};
Object.assign(formData, props.initialValues)
emit('reset', formData)
}
// 取消操作
const handleCancel = () => {
emit("cancel");
};
emit('cancel')
}
// 暴露方法给父组件
defineExpose({
formData,
resetForm: handleReset,
setFieldValue: (field, value) => {
formData[field] = value;
formData[field] = value
},
getFieldValue: (field) => {
return formData[field];
return formData[field]
},
setFieldsValue: (values) => {
Object.assign(formData, values);
Object.assign(formData, values)
},
});
})
</script>
<style scoped lang="scss">
File diff suppressed because it is too large. Load diff
@@ -6,35 +6,25 @@
```vue
<template>
<a-button type="primary" @click="showImport = true">
<import-outlined /> 导入数据
</a-button>
<a-button type="primary" @click="showImport = true"> <import-outlined /> 导入数据 </a-button>
<sc-import
v-model:open="showImport"
:api="authApi.users.import.post"
:template-api="authApi.users.downloadTemplate.get"
title="导入用户"
filename="用户数据"
@success="handleImportSuccess"
@error="handleImportError"
/>
<sc-import v-model:open="showImport" :api="authApi.users.import.post" :template-api="authApi.users.downloadTemplate.get" title="导入用户" filename="用户数据" @success="handleImportSuccess" @error="handleImportError" />
</template>
<script setup>
import { ref } from "vue";
import authApi from "@/api/auth";
import { ref } from 'vue'
import authApi from '@/api/auth'
const showImport = ref(false);
const showImport = ref(false)
const handleImportSuccess = (data) => {
console.log("导入成功", data);
console.log('导入成功', data)
// 刷新列表等操作
};
}
const handleImportError = (message) => {
console.log("导入失败", message);
};
console.log('导入失败', message)
}
</script>
```
@@ -69,19 +59,11 @@ const handleImportError = (message) => {
```vue
<template>
<sc-import
v-model:open="showImport"
:api="importApi"
@success="handleSuccess"
>
<sc-import v-model:open="showImport" :api="importApi" @success="handleSuccess">
<template #formParams="{ formData }">
<a-form layout="vertical">
<a-form-item label="部门">
<a-select
v-model:value="formData.department_id"
placeholder="请选择部门"
:options="departmentOptions"
/>
<a-select v-model:value="formData.department_id" placeholder="请选择部门" :options="departmentOptions" />
</a-form-item>
<a-form-item label="覆盖已有数据">
<a-switch v-model:checked="formData.overwrite" />
@@ -96,9 +78,7 @@ const handleImportError = (message) => {
```vue
<template>
<a-button type="primary" @click="handleImport">
<import-outlined /> 导入用户
</a-button>
<a-button type="primary" @click="handleImport"> <import-outlined /> 导入用户 </a-button>
<sc-import
v-model:open="importVisible"
@@ -117,28 +97,13 @@ const handleImportError = (message) => {
<template #formParams="{ formData }">
<a-form layout="vertical">
<a-form-item label="所属部门" required>
<a-select
v-model:value="formData.department_id"
placeholder="请选择部门"
:options="departmentOptions"
:field-names="{ label: 'name', value: 'id' }"
/>
<a-select v-model:value="formData.department_id" placeholder="请选择部门" :options="departmentOptions" :field-names="{ label: 'name', value: 'id' }" />
</a-form-item>
<a-form-item label="用户角色">
<a-select
v-model:value="formData.role_ids"
mode="multiple"
placeholder="请选择角色"
:options="roleOptions"
:field-names="{ label: 'name', value: 'id' }"
/>
<a-select v-model:value="formData.role_ids" mode="multiple" placeholder="请选择角色" :options="roleOptions" :field-names="{ label: 'name', value: 'id' }" />
</a-form-item>
<a-form-item label="是否激活">
<a-switch
v-model:checked="formData.is_active"
checked-children="是"
un-checked-children="否"
/>
<a-switch v-model:checked="formData.is_active" checked-children="是" un-checked-children="否" />
</a-form-item>
</a-form>
</template>
@@ -146,45 +111,42 @@ const handleImportError = (message) => {
</template>
<script setup>
import { ref, onMounted } from "vue";
import { message } from "ant-design-vue";
import { ImportOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { ImportOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
const importVisible = ref(false);
const departmentOptions = ref([]);
const roleOptions = ref([]);
const importVisible = ref(false)
const departmentOptions = ref([])
const roleOptions = ref([])
// 加载选项数据
onMounted(async () => {
try {
const [deptRes, roleRes] = await Promise.all([
authApi.departments.all.get(),
authApi.roles.all.get(),
]);
departmentOptions.value = deptRes.data || [];
roleOptions.value = roleRes.data || [];
const [deptRes, roleRes] = await Promise.all([authApi.departments.all.get(), authApi.roles.all.get()])
departmentOptions.value = deptRes.data || []
roleOptions.value = roleRes.data || []
} catch (error) {
console.error("加载选项失败", error);
console.error('加载选项失败', error)
}
});
})
const handleImport = () => {
importVisible.value = true;
};
importVisible.value = true
}
const handleImportSuccess = (data) => {
message.success("导入成功");
message.success('导入成功')
// 刷新列表或执行其他操作
};
}
const handleImportError = (errorMessage) => {
message.error("导入失败:" + errorMessage);
};
message.error('导入失败:' + errorMessage)
}
const handleFileChange = (fileList) => {
console.log("文件列表变化", fileList);
};
console.log('文件列表变化', fileList)
}
</script>
```
+87 -113
View File
@@ -1,13 +1,5 @@
<template>
<a-modal
v-model:open="visible"
:title="title"
:width="600"
:confirm-loading="loading"
:ok-button-props="{ disabled: !fileList.length }"
@ok="handleOk"
@cancel="handleCancel"
>
<a-modal v-model:open="visible" :title="title" :width="600" :confirm-loading="loading" :ok-button-props="{ disabled: !fileList.length }" @ok="handleOk" @cancel="handleCancel">
<div class="import-modal">
<!-- 表单参数插槽 -->
<div v-if="$slots.formParams" class="form-params">
@@ -16,16 +8,7 @@
<!-- 上传区域 -->
<div class="upload-section">
<a-upload-dragger
v-model:file-list="fileList"
:custom-request="customUpload"
:before-upload="beforeUpload"
:accept="accept"
:max-count="1"
:disabled="loading"
@change="handleChange"
@drop="handleDrop"
>
<a-upload-dragger v-model:file-list="fileList" :custom-request="customUpload" :before-upload="beforeUpload" :accept="accept" :max-count="1" :disabled="loading" @change="handleChange" @drop="handleDrop">
<p class="ant-upload-drag-icon">
<inbox-outlined />
</p>
@@ -43,14 +26,7 @@
<span>请先下载模板,按照模板格式填写数据后上传</span>
</template>
<template #action>
<a-button
type="link"
size="small"
:loading="templateLoading"
@click="handleDownloadTemplate"
>
<download-outlined /> 下载模板
</a-button>
<a-button type="link" size="small" :loading="templateLoading" @click="handleDownloadTemplate"> <download-outlined /> 下载模板 </a-button>
</template>
</a-alert>
</div>
@@ -66,9 +42,9 @@
</template>
<script setup>
import { ref, watch } from "vue";
import { message } from "ant-design-vue";
import { InboxOutlined, DownloadOutlined } from "@ant-design/icons-vue";
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { InboxOutlined, DownloadOutlined } from '@ant-design/icons-vue'
const props = defineProps({
// 是否显示弹窗
@@ -79,7 +55,7 @@ const props = defineProps({
// 弹窗标题
title: {
type: String,
default: "导入数据",
default: '导入数据',
},
// 导入API接口
api: {
@@ -94,7 +70,7 @@ const props = defineProps({
// 接受的文件类型
accept: {
type: String,
default: ".xlsx,.xls,.csv",
default: '.xlsx,.xls,.csv',
},
// 文件大小限制(MB)
maxSize: {
@@ -109,184 +85,182 @@ const props = defineProps({
// 提示信息
tip: {
type: String,
default: "",
default: '',
},
// 文件名(用于下载)
filename: {
type: String,
default: "导入数据",
default: '导入数据',
},
});
})
const emit = defineEmits(["update:open", "success", "error", "change"]);
const emit = defineEmits(['update:open', 'success', 'error', 'change'])
// 弹窗显示状态
const visible = ref(false);
const visible = ref(false)
// 文件列表
const fileList = ref([]);
const fileList = ref([])
// 加载状态
const loading = ref(false);
const templateLoading = ref(false);
const loading = ref(false)
const templateLoading = ref(false)
// 表单数据(用于插槽)
const formData = ref({});
const formData = ref({})
// 文件类型提示
const acceptTip = ref(props.accept || "支持 .xlsx, .xls, .csv 格式文件");
const acceptTip = ref(props.accept || '支持 .xlsx, .xls, .csv 格式文件')
// 监听外部 open 变化
watch(
() => props.open,
(val) => {
visible.value = val;
visible.value = val
if (val) {
// 打开时重置表单
fileList.value = [];
formData.value = {};
fileList.value = []
formData.value = {}
}
},
{ immediate: true },
);
)
// 监听内部 visible 变化,同步到外部
watch(visible, (val) => {
emit("update:open", val);
});
emit('update:open', val)
})
// 自定义上传
const customUpload = (options) => {
const { onSuccess } = options;
const { onSuccess } = options
// 这里不直接上传,只是标记为准备上传
onSuccess({}, options.file);
};
onSuccess({}, options.file)
}
// 上传前校验
const beforeUpload = (file) => {
// 文件类型校验
const acceptTypes = props.accept
.split(",")
.map((type) => type.trim().toLowerCase());
const fileName = file.name.toLowerCase();
const isValidType = acceptTypes.some((type) => fileName.endsWith(type));
const acceptTypes = props.accept.split(',').map((type) => type.trim().toLowerCase())
const fileName = file.name.toLowerCase()
const isValidType = acceptTypes.some((type) => fileName.endsWith(type))
if (!isValidType) {
message.error(`文件格式不正确,仅支持 ${props.accept} 格式`);
return false;
message.error(`文件格式不正确,仅支持 ${props.accept} 格式`)
return false
}
// 文件大小校验
const maxSizeBytes = props.maxSize * 1024 * 1024;
const maxSizeBytes = props.maxSize * 1024 * 1024
if (file.size > maxSizeBytes) {
message.error(`文件大小不能超过 ${props.maxSize}MB`);
return false;
message.error(`文件大小不能超过 ${props.maxSize}MB`)
return false
}
return true;
};
return true
}
// 处理文件变化
const handleChange = ({ fileList: newFileList }) => {
fileList.value = newFileList;
emit("change", newFileList);
};
fileList.value = newFileList
emit('change', newFileList)
}
// 拖拽相关
const handleDrop = (e) => {
e.preventDefault();
};
e.preventDefault()
}
// 下载模板
const handleDownloadTemplate = async () => {
if (!props.templateApi) {
message.error("未配置模板下载接口");
return;
message.error('未配置模板下载接口')
return
}
try {
templateLoading.value = true;
const blob = await props.templateApi();
templateLoading.value = true
const blob = await props.templateApi()
// 创建下载链接
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${props.filename}-模板.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${props.filename}-模板.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success("模板下载成功");
message.success('模板下载成功')
} catch (error) {
message.error("模板下载失败:" + error.message);
message.error('模板下载失败:' + error.message)
} finally {
templateLoading.value = false;
templateLoading.value = false
}
};
}
// 确认导入
const handleOk = async () => {
if (fileList.value.length === 0) {
message.error("请选择要导入的文件");
return;
message.error('请选择要导入的文件')
return
}
const file = fileList.value[0];
const formDataObj = new FormData();
const file = fileList.value[0]
const formDataObj = new FormData()
// 添加文件
if (file.originFileObj) {
formDataObj.append("file", file.originFileObj);
formDataObj.append('file', file.originFileObj)
} else if (file.url) {
// 如果是已有文件,可能需要重新处理
message.error("请重新选择文件");
return;
message.error('请重新选择文件')
return
}
// 添加表单参数(如果有)
if (Object.keys(formData.value).length > 0) {
Object.keys(formData.value).forEach((key) => {
const value = formData.value[key];
if (value !== null && value !== undefined && value !== "") {
const value = formData.value[key]
if (value !== null && value !== undefined && value !== '') {
if (Array.isArray(value)) {
formDataObj.append(key, JSON.stringify(value));
} else if (typeof value === "object") {
formDataObj.append(key, JSON.stringify(value));
formDataObj.append(key, JSON.stringify(value))
} else if (typeof value === 'object') {
formDataObj.append(key, JSON.stringify(value))
} else {
formDataObj.append(key, value);
formDataObj.append(key, value)
}
}
});
})
}
try {
loading.value = true;
const res = await props.api(formDataObj);
loading.value = true
const res = await props.api(formDataObj)
if (res.code === 200 || res.success) {
message.success("导入成功");
emit("success", res.data, res);
handleCancel();
message.success('导入成功')
emit('success', res.data, res)
handleCancel()
} else {
message.error(res.message || "导入失败");
emit("error", res.message, res);
message.error(res.message || '导入失败')
emit('error', res.message, res)
}
} catch (error) {
message.error("导入失败:" + error.message);
emit("error", error.message, error);
message.error('导入失败:' + error.message)
emit('error', error.message, error)
} finally {
loading.value = false;
loading.value = false
}
};
}
// 取消导入
const handleCancel = () => {
visible.value = false;
fileList.value = [];
formData.value = {};
};
visible.value = false
fileList.value = []
formData.value = {}
}
</script>
<style scoped>
+89 -177
View File
@@ -26,14 +26,14 @@
</template>
<script setup>
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const value = ref("");
const value = ref('')
const options = ref([
{ label: "选项1", value: "1" },
{ label: "选项2", value: "2" },
]);
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
])
</script>
```
@@ -80,28 +80,23 @@ const options = ref([
<template>
<a-form>
<a-form-item label="状态">
<sc-select
v-model:value="form.status"
source-type="data"
:data="statusOptions"
placeholder="请选择状态"
/>
<sc-select v-model:value="form.status" source-type="data" :data="statusOptions" placeholder="请选择状态" />
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const form = reactive({
status: "",
});
status: '',
})
const statusOptions = [
{ label: "启用", value: 1 },
{ label: "禁用", value: 0 },
];
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
]
</script>
```
@@ -113,27 +108,18 @@ const statusOptions = [
<template>
<a-form>
<a-form-item label="用户">
<sc-select
v-model:value="form.userId"
source-type="api"
api="users"
:api-params="{ page: 1, page_size: 100 }"
:enable-api-cache="true"
:api-cache-time="600000"
placeholder="请选择用户"
:field-names="{ label: 'username', value: 'id' }"
/>
<sc-select v-model:value="form.userId" source-type="api" api="users" :api-params="{ page: 1, page_size: 100 }" :enable-api-cache="true" :api-cache-time="600000" placeholder="请选择用户" :field-names="{ label: 'username', value: 'id' }" />
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const form = reactive({
userId: "",
});
userId: '',
})
</script>
```
@@ -145,33 +131,23 @@ const form = reactive({
<template>
<a-form>
<a-form-item label="性别">
<sc-select
v-model:value="form.gender"
source-type="dictionary"
dictionary-code="gender"
placeholder="请选择性别"
/>
<sc-select v-model:value="form.gender" source-type="dictionary" dictionary-code="gender" placeholder="请选择性别" />
</a-form-item>
<a-form-item label="用户状态">
<sc-select
v-model:value="form.userStatus"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择用户状态"
/>
<sc-select v-model:value="form.userStatus" source-type="dictionary" dictionary-code="user_status" placeholder="请选择用户状态" />
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const form = reactive({
gender: "",
userStatus: "",
});
gender: '',
userStatus: '',
})
</script>
```
@@ -181,20 +157,14 @@ const form = reactive({
```vue
<template>
<sc-select
v-model:value="value"
source-type="api"
api="roles/all"
:immediate="true"
placeholder="请选择角色"
/>
<sc-select v-model:value="value" source-type="api" api="roles/all" :immediate="true" placeholder="请选择角色" />
</template>
<script setup>
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const value = ref("");
const value = ref('')
</script>
```
@@ -204,20 +174,14 @@ const value = ref("");
```vue
<template>
<sc-select
v-model:value="value"
source-type="api"
api="roles"
:data-processor="processData"
placeholder="请选择角色"
/>
<sc-select v-model:value="value" source-type="api" api="roles" :data-processor="processData" placeholder="请选择角色" />
</template>
<script setup>
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const value = ref("");
const value = ref('')
// 自定义数据处理函数
function processData(data) {
@@ -225,7 +189,7 @@ function processData(data) {
label: `${item.name} (${item.description})`,
value: item.id,
extra: item.description,
}));
}))
}
</script>
```
@@ -235,30 +199,24 @@ function processData(data) {
```vue
<template>
<div>
<sc-select
ref="selectRef"
v-model:value="value"
source-type="api"
api="users"
placeholder="请选择用户"
/>
<sc-select ref="selectRef" v-model:value="value" source-type="api" api="users" placeholder="请选择用户" />
<a-button @click="handleRefresh">刷新数据</a-button>
</div>
</template>
<script setup>
import { ref } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
const selectRef = ref(null);
const value = ref("");
const selectRef = ref(null)
const value = ref('')
// 刷新数据
function handleRefresh() {
if (selectRef.value) {
selectRef.value.refresh();
message.success("数据已刷新");
selectRef.value.refresh()
message.success('数据已刷新')
}
}
</script>
@@ -268,13 +226,7 @@ function handleRefresh() {
```vue
<template>
<sc-select
v-model:value="value"
source-type="api"
api="users"
:enable-api-cache="false"
placeholder="请选择用户"
/>
<sc-select v-model:value="value" source-type="api" api="users" :enable-api-cache="false" placeholder="请选择用户" />
</template>
```
@@ -282,20 +234,14 @@ function handleRefresh() {
```vue
<template>
<sc-select
v-model:value="value"
mode="multiple"
source-type="dictionary"
dictionary-code="tags"
placeholder="请选择标签"
/>
<sc-select v-model:value="value" mode="multiple" source-type="dictionary" dictionary-code="tags" placeholder="请选择标签" />
</template>
<script setup>
import { ref } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { ref } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const value = ref([]);
const value = ref([])
</script>
```
@@ -303,36 +249,28 @@ const value = ref([]);
```vue
<template>
<sc-select
v-model:value="value"
source-type="data"
:data="options"
placeholder="请选择"
>
<sc-select v-model:value="value" source-type="data" :data="options" placeholder="请选择">
<template #suffixIcon>
<SearchOutlined />
</template>
<template #notFoundContent>
<a-empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无数据"
/>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" description="暂无数据" />
</template>
</sc-select>
</template>
<script setup>
import { ref } from "vue";
import { SearchOutlined } from "@ant-design/icons-vue";
import { Empty } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import { ref } from 'vue'
import { SearchOutlined } from '@ant-design/icons-vue'
import { Empty } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
const value = ref("");
const value = ref('')
const options = ref([
{ label: "选项1", value: "1" },
{ label: "选项2", value: "2" },
]);
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
])
</script>
```
@@ -345,16 +283,16 @@ const options = ref([
1. 字符串/数字数组
```javascript
["选项1", "选项2", "选项3"][(1, 2, 3)];
;['选项1', '选项2', '选项3'][(1, 2, 3)]
```
2. 对象数组(标准格式)
```javascript
[
{ label: "选项1", value: "1" },
{ label: "选项2", value: "2" },
];
;[
{ label: '选项1', value: '1' },
{ label: '选项2', value: '2' },
]
```
3. 对象数组(自定义字段)
@@ -413,10 +351,10 @@ API 接口需要返回以下格式:
组件会自动转换为:
```javascript
[
{ label: "启用", value: "1", name: "启用", sort: 1, status: 1 },
{ label: "禁用", value: "0", name: "禁用", sort: 2, status: 1 },
];
;[
{ label: '启用', value: '1', name: '启用', sort: 1, status: 1 },
{ label: '禁用', value: '0', name: '禁用', sort: 2, status: 1 },
]
```
## 字典数据缓存机制
@@ -431,25 +369,25 @@ API 接口需要返回以下格式:
### 清空缓存
```javascript
import { useDictionaryStore } from "@/stores/modules/dictionary";
import { useDictionaryStore } from '@/stores/modules/dictionary'
const dictionaryStore = useDictionaryStore();
const dictionaryStore = useDictionaryStore()
// 清空字典缓存
dictionaryStore.clearCache();
dictionaryStore.clearCache()
```
### 获取缓存信息
```javascript
import { useDictionaryStore } from "@/stores/modules/dictionary";
import { useDictionaryStore } from '@/stores/modules/dictionary'
const dictionaryStore = useDictionaryStore();
const dictionaryStore = useDictionaryStore()
// 获取缓存信息
const info = dictionaryStore.getCacheInfo();
console.log("字典数量:", info.count);
console.log("最后加载时间:", new Date(info.lastLoadTime));
const info = dictionaryStore.getCacheInfo()
console.log('字典数量:', info.count)
console.log('最后加载时间:', new Date(info.lastLoadTime))
```
## API 数据缓存机制
@@ -515,8 +453,8 @@ A: 检查以下几点:
A: 使用 `refresh()` 方法:
```javascript
const selectRef = ref(null);
selectRef.value?.refresh();
const selectRef = ref(null)
selectRef.value?.refresh()
```
### Q: 如何禁用缓存?
@@ -548,67 +486,41 @@ A: 设置 `enableApiCache` 为 `false`:
<!-- 使用字典数据 -->
<a-form-item label="用户状态">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择用户状态"
/>
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="user_status" placeholder="请选择用户状态" />
</a-form-item>
<!-- 使用 API 数据 -->
<a-form-item label="所属角色">
<sc-select
v-model:value="form.roleId"
source-type="api"
api="roles/all"
:enable-api-cache="true"
:immediate="true"
:field-names="{ label: 'name', value: 'id' }"
placeholder="请选择角色"
/>
<sc-select v-model:value="form.roleId" source-type="api" api="roles/all" :enable-api-cache="true" :immediate="true" :field-names="{ label: 'name', value: 'id' }" placeholder="请选择角色" />
</a-form-item>
<!-- 多选模式 -->
<a-form-item label="标签">
<sc-select
v-model:value="form.tags"
mode="multiple"
source-type="dictionary"
dictionary-code="tags"
placeholder="请选择标签"
/>
<sc-select v-model:value="form.tags" mode="multiple" source-type="dictionary" dictionary-code="tags" placeholder="请选择标签" />
</a-form-item>
<!-- 带搜索 -->
<a-form-item label="省份">
<sc-select
v-model:value="form.province"
source-type="api"
api="cities/provinces"
show-search
:filter-option="filterOption"
placeholder="请选择省份"
/>
<sc-select v-model:value="form.province" source-type="api" api="cities/provinces" show-search :filter-option="filterOption" placeholder="请选择省份" />
</a-form-item>
</a-form>
</template>
<script setup>
import { reactive } from "vue";
import scSelect from "@/components/scSelect/index.vue";
import { reactive } from 'vue'
import scSelect from '@/components/scSelect/index.vue'
const form = reactive({
dataSource: "",
status: "",
roleId: "",
dataSource: '',
status: '',
roleId: '',
tags: [],
province: "",
});
province: '',
})
// 搜索过滤
function filterOption(input, option) {
return option.label.toLowerCase().includes(input.toLowerCase());
return option.label.toLowerCase().includes(input.toLowerCase())
}
</script>
```
@@ -1,11 +1,5 @@
<template>
<a-select
v-bind="$attrs"
:loading="loading"
:options="options"
:field-names="fieldNames"
@focus="handleFocus"
>
<a-select v-bind="$attrs" :loading="loading" :options="options" :field-names="fieldNames" @focus="handleFocus">
<template v-for="(_, slot) of $slots" #[slot]="scope">
<slot :name="slot" v-bind="scope || {}" />
</template>
@@ -13,21 +7,21 @@
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { useDictionaryStore } from "@/stores/modules/dictionary";
import request from "@/utils/request";
import { ref, computed, watch } from 'vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import request from '@/utils/request'
defineOptions({
name: "ScSelect",
name: 'ScSelect',
inheritAttrs: false,
});
})
const props = defineProps({
// 数据源类型:data(直接数据)、api(接口数据)、dictionary(字典数据)
sourceType: {
type: String,
default: "data",
validator: (value) => ["data", "api", "dictionary"].includes(value),
default: 'data',
validator: (value) => ['data', 'api', 'dictionary'].includes(value),
},
// 直接数据(当 sourceType 为 data 时使用)
data: {
@@ -37,7 +31,7 @@ const props = defineProps({
// API 接口地址(当 sourceType 为 api 时使用)
api: {
type: String,
default: "",
default: '',
},
// API 请求参数(当 sourceType 为 api 时使用)
apiParams: {
@@ -47,7 +41,7 @@ const props = defineProps({
// 字典编码(当 sourceType 为 dictionary 时使用)
dictionaryCode: {
type: String,
default: "",
default: '',
},
// 是否启用 API 数据缓存(当 sourceType 为 api 时使用)
enableApiCache: {
@@ -63,8 +57,8 @@ const props = defineProps({
fieldNames: {
type: Object,
default: () => ({
label: "label",
value: "value",
label: 'label',
value: 'value',
}),
},
// 是否在组件挂载时立即加载数据
@@ -77,62 +71,62 @@ const props = defineProps({
type: Function,
default: null,
},
});
})
const dictionaryStore = useDictionaryStore();
const loading = ref(false);
const apiData = ref(null);
const apiCacheTime = ref(null);
const dictionaryStore = useDictionaryStore()
const loading = ref(false)
const apiData = ref(null)
const apiCacheTime = ref(null)
const options = computed(() => {
switch (props.sourceType) {
case "data":
return processData(props.data);
case "api":
return processData(apiData.value || []);
case "dictionary":
return processData(getDictionaryData());
case 'data':
return processData(props.data)
case 'api':
return processData(apiData.value || [])
case 'dictionary':
return processData(getDictionaryData())
default:
return [];
return []
}
});
})
// API 数据缓存
const apiCache = new Map();
const apiCache = new Map()
/**
* 处理数据格式
*/
function processData(data) {
if (!data || !Array.isArray(data)) {
return [];
return []
}
// 如果有自定义处理函数,使用自定义处理
if (props.dataProcessor && typeof props.dataProcessor === "function") {
return props.dataProcessor(data);
if (props.dataProcessor && typeof props.dataProcessor === 'function') {
return props.dataProcessor(data)
}
// 默认处理:确保数据有 label 和 value 字段
return data.map((item) => {
if (typeof item === "string" || typeof item === "number") {
if (typeof item === 'string' || typeof item === 'number') {
return {
label: item,
value: item,
};
}
}
// 如果已经有 label 和 value 字段,直接返回
if (item.label !== undefined && item.value !== undefined) {
return item;
return item
}
// 尝试使用 fieldNames 映射
const labelKey = props.fieldNames.label || "label";
const valueKey = props.fieldNames.value || "value";
const labelKey = props.fieldNames.label || 'label'
const valueKey = props.fieldNames.value || 'value'
return {
label: item[labelKey] || item.name || item.title || item.id,
value: item[valueKey] !== undefined ? item[valueKey] : item.id,
...item,
};
});
}
})
}
/**
@@ -140,9 +134,9 @@ function processData(data) {
*/
function getDictionaryData() {
if (!props.dictionaryCode) {
return [];
return []
}
return dictionaryStore.dictionaries[props.dictionaryCode] || [];
return dictionaryStore.dictionaries[props.dictionaryCode] || []
}
/**
@@ -150,42 +144,42 @@ function getDictionaryData() {
*/
async function loadApiData() {
if (!props.api) {
return;
return
}
// 检查缓存
if (props.enableApiCache) {
const cacheKey = props.api + JSON.stringify(props.apiParams);
const cached = apiCache.get(cacheKey);
const cacheKey = props.api + JSON.stringify(props.apiParams)
const cached = apiCache.get(cacheKey)
if (cached && Date.now() - cached.time < props.apiCacheTime) {
apiData.value = cached.data;
return;
apiData.value = cached.data
return
}
}
loading.value = true;
loading.value = true
try {
const res = await request.get(props.api, { params: props.apiParams });
const res = await request.get(props.api, { params: props.apiParams })
if (res.code === 200) {
const data = res.data || res.list || [];
apiData.value = data;
const data = res.data || res.list || []
apiData.value = data
// 缓存数据
if (props.enableApiCache) {
const cacheKey = props.api + JSON.stringify(props.apiParams);
const cacheKey = props.api + JSON.stringify(props.apiParams)
apiCache.set(cacheKey, {
data,
time: Date.now(),
});
})
}
}
} catch (error) {
console.error("加载 API 数据失败:", error);
apiData.value = [];
console.error('加载 API 数据失败:', error)
apiData.value = []
} finally {
loading.value = false;
loading.value = false
}
}
@@ -194,21 +188,21 @@ async function loadApiData() {
*/
async function loadDictionaryData() {
if (!props.dictionaryCode) {
return;
return
}
// 检查缓存
if (dictionaryStore.dictionaries[props.dictionaryCode]) {
return;
return
}
loading.value = true;
loading.value = true
try {
await dictionaryStore.getDictionary(props.dictionaryCode);
await dictionaryStore.getDictionary(props.dictionaryCode)
} catch (error) {
console.error("加载字典数据失败:", error);
console.error('加载字典数据失败:', error)
} finally {
loading.value = false;
loading.value = false
}
}
@@ -217,16 +211,16 @@ async function loadDictionaryData() {
*/
async function handleFocus() {
switch (props.sourceType) {
case "api":
case 'api':
if (!apiData.value || apiData.value.length === 0) {
await loadApiData();
await loadApiData()
}
break;
case "dictionary":
break
case 'dictionary':
if (!dictionaryStore.dictionaries[props.dictionaryCode]) {
await loadDictionaryData();
await loadDictionaryData()
}
break;
break
}
}
@@ -235,12 +229,12 @@ async function handleFocus() {
*/
async function refresh() {
switch (props.sourceType) {
case "api":
await loadApiData();
break;
case "dictionary":
await dictionaryStore.getDictionary(props.dictionaryCode, true);
break;
case 'api':
await loadApiData()
break
case 'dictionary':
await dictionaryStore.getDictionary(props.dictionaryCode, true)
break
}
}
@@ -248,50 +242,50 @@ async function refresh() {
watch(
() => props.data,
(newVal) => {
if (props.sourceType === "data") {
if (props.sourceType === 'data') {
// data 类型不需要特殊处理,计算属性会自动更新
}
},
{ immediate: true },
);
)
watch(
() => props.api,
() => {
if (props.sourceType === "api") {
apiData.value = null;
if (props.sourceType === 'api') {
apiData.value = null
}
},
);
)
watch(
() => props.apiParams,
() => {
if (props.sourceType === "api") {
apiData.value = null;
if (props.sourceType === 'api') {
apiData.value = null
}
},
{ deep: true },
);
)
watch(
() => props.dictionaryCode,
() => {
if (props.sourceType === "dictionary") {
if (props.sourceType === 'dictionary') {
// dictionary 变化时,数据会自动从 store 获取
}
},
);
)
// 组件挂载时立即加载数据
if (props.immediate) {
switch (props.sourceType) {
case "api":
loadApiData();
break;
case "dictionary":
loadDictionaryData();
break;
case 'api':
loadApiData()
break
case 'dictionary':
loadDictionaryData()
break
}
}
@@ -300,7 +294,7 @@ defineExpose({
refresh,
loadApiData,
loadDictionaryData,
});
})
</script>
<style scoped lang="scss">
+119 -210
View File
@@ -28,22 +28,13 @@
</template>
<!-- 自定义插槽 -->
<template v-else-if="column.slot">
<slot
:name="column.slot || column.dataIndex"
:text="text"
:record="record"
:index="index"
:column="column"
></slot>
<slot :name="column.slot || column.dataIndex" :text="text" :record="record" :index="index" :column="column"></slot>
</template>
</template>
<!-- 空状态 -->
<template #emptyText>
<a-empty
:image="Empty.PRESENTED_IMAGE_SIMPLE"
:description="emptyText"
/>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="emptyText" />
</template>
</a-table>
</div>
@@ -51,22 +42,14 @@
<!-- 工具栏 -->
<div v-if="showToolbar" class="sc-table-tool">
<div class="tool-left">
<a-pagination
v-bind="pagination"
@change="handlePaginationChange"
@showSizeChange="handlePaginationChange"
/>
<a-pagination v-bind="pagination" @change="handlePaginationChange" @showSizeChange="handlePaginationChange" />
</div>
<div class="tool-right">
<!-- 右侧工具栏插槽 -->
<slot name="toolRight"></slot>
<!-- 刷新按钮 -->
<a-tooltip v-if="showRefresh" title="刷新">
<a-button
shape="circle"
:loading="loading"
@click="handleRefresh"
>
<a-button shape="circle" :loading="loading" @click="handleRefresh">
<template #icon>
<SyncOutlined />
</template>
@@ -75,12 +58,7 @@
<!-- 表格设置按钮 -->
<a-tooltip v-if="showColumnSetting" title="表格设置">
<a-popover
v-model:open="tableSettingVisible"
placement="topRight"
trigger="click"
:width="240"
>
<a-popover v-model:open="tableSettingVisible" placement="topRight" trigger="click" :width="240">
<template #content>
<div class="table-setting">
<div class="table-setting-header">
@@ -89,35 +67,16 @@
<div class="table-setting-body">
<!-- 边框设置 -->
<div class="setting-item">
<span class="setting-label"
>显示边框</span
>
<a-switch
v-model:checked="
tableSettings.bordered
"
size="small"
/>
<span class="setting-label">显示边框</span>
<a-switch v-model:checked="tableSettings.bordered" size="small" />
</div>
<!-- 表格大小 -->
<div class="setting-item">
<span class="setting-label"
>表格大小</span
>
<a-radio-group
v-model:value="tableSettings.size"
size="small"
button-style="solid"
>
<a-radio-button value="small"
>小</a-radio-button
>
<a-radio-button value="middle"
>中</a-radio-button
>
<a-radio-button value="large"
>大</a-radio-button
>
<span class="setting-label">表格大小</span>
<a-radio-group v-model:value="tableSettings.size" size="small" button-style="solid">
<a-radio-button value="small">小</a-radio-button>
<a-radio-button value="middle">中</a-radio-button>
<a-radio-button value="large">大</a-radio-button>
</a-radio-group>
</div>
</div>
@@ -133,11 +92,7 @@
<!-- 列设置按钮 -->
<a-tooltip v-if="showColumnSetting" title="列设置">
<a-popover
v-model:open="columnSettingVisible"
placement="topRight"
trigger="click"
>
<a-popover v-model:open="columnSettingVisible" placement="topRight" trigger="click">
<template #content>
<div class="column-setting">
<div class="column-setting-header">
@@ -152,28 +107,13 @@
dragging: draggingIndex === index,
}"
draggable="true"
@dragstart="
handleDragStart(index, $event)
"
@dragover="
handleDragOver(index, $event)
"
@dragstart="handleDragStart(index, $event)"
@dragover="handleDragOver(index, $event)"
@dragend="handleDragEnd"
@drop="handleDrop(index)"
>
<HolderOutlined class="drag-handle" />
<a-checkbox
:checked="
visibleColumns.includes(colKey)
"
@change="
(e) =>
toggleColumn(
colKey,
e.target.checked,
)
"
>
<a-checkbox :checked="visibleColumns.includes(colKey)" @change="(e) => toggleColumn(colKey, e.target.checked)">
{{ getColumnTitle(colKey) }}
</a-checkbox>
</div>
@@ -193,20 +133,12 @@
</template>
<script setup>
import {
ref,
computed,
watch,
reactive,
useTemplateRef,
onMounted,
onBeforeUnmount,
} from "vue";
import { Empty } from "ant-design-vue";
import { ref, computed, watch, reactive, useTemplateRef, onMounted, onBeforeUnmount } from 'vue'
import { Empty } from 'ant-design-vue'
defineOptions({
name: "scTable",
});
name: 'scTable',
})
const props = defineProps({
// 数据源
@@ -223,7 +155,7 @@ const props = defineProps({
// 行的唯一标识
rowKey: {
type: [String, Function],
default: "id",
default: 'id',
},
// 加载状态
loading: {
@@ -239,7 +171,7 @@ const props = defineProps({
total: 0,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
pageSizeOptions: ["20", "50", "100", "200"],
pageSizeOptions: ['20', '50', '100', '200'],
}),
},
// 行选择配置
@@ -250,7 +182,7 @@ const props = defineProps({
// 表格大小
size: {
type: String,
default: "middle", // large, middle, small
default: 'middle', // large, middle, small
},
// 是否显示边框
bordered: {
@@ -280,7 +212,7 @@ const props = defineProps({
// 序号列标题
indexTitle: {
type: String,
default: "序号",
default: '序号',
},
// 是否显示工具栏
showToolbar: {
@@ -300,7 +232,7 @@ const props = defineProps({
// 空状态文字
emptyText: {
type: String,
default: "暂无数据",
default: '暂无数据',
},
// 树形表格配置
defaultExpandAll: {
@@ -311,62 +243,59 @@ const props = defineProps({
type: Boolean,
default: false,
},
});
})
// 展开的行keys
const expandedRowKeys = ref([]);
const expandedRowKeys = ref([])
const tableContent = useTemplateRef("tableContent");
const tableWrapper = useTemplateRef("tableWrapper");
const tableContent = useTemplateRef('tableContent')
const tableWrapper = useTemplateRef('tableWrapper')
let scroll = ref({
scrollToFirstRowOnChange: true,
x: "max-content",
x: 'max-content',
y: true,
});
})
// 递归获取所有节点的key
const getAllNodeKeys = (nodes) => {
const keys = [];
const keys = []
const traverse = (list) => {
list.forEach((node) => {
// 如果节点有children且不为空,则该节点需要展开
if (node.children && node.children.length > 0) {
const key =
typeof props.rowKey === "function"
? props.rowKey(node)
: node[props.rowKey];
keys.push(key);
traverse(node.children);
const key = typeof props.rowKey === 'function' ? props.rowKey(node) : node[props.rowKey]
keys.push(key)
traverse(node.children)
}
});
};
traverse(nodes);
return keys;
};
})
}
traverse(nodes)
return keys
}
// 监听数据变化,自动展开所有节点
watch(
() => props.dataSource,
(newData) => {
if (props.defaultExpandAll && newData && newData.length > 0) {
expandedRowKeys.value = getAllNodeKeys(newData);
expandedRowKeys.value = getAllNodeKeys(newData)
} else {
expandedRowKeys.value = [];
expandedRowKeys.value = []
}
},
{ immediate: true, deep: true },
);
)
onMounted(() => {
updateTableHeight();
});
updateTableHeight()
})
const updateTableHeight = () => {
if (tableContent.value) {
const tableHeight = tableContent.value.clientHeight - 56;
scroll.value.y = tableHeight > 0 ? tableHeight : 400;
const tableHeight = tableContent.value.clientHeight - 56
scroll.value.y = tableHeight > 0 ? tableHeight : 400
}
};
}
// 根据表格宽度优化横向滚动配置
watch(
@@ -374,210 +303,190 @@ watch(
() => {
// 如果列有固定宽度且总宽度较大,使用max-content
// 否则使用true让表格自适应
const hasFixedColumns = props.columns.some((col) => col.width);
const hasFixedColumns = props.columns.some((col) => col.width)
if (hasFixedColumns || props.showIndex) {
scroll.value.x = "max-content";
scroll.value.x = 'max-content'
} else {
scroll.value.x = true;
scroll.value.x = true
}
},
{ immediate: true, deep: true },
);
)
// 表格设置状态
const tableSettings = reactive({
bordered: props.bordered,
size: props.size,
});
})
// 监听props变化
watch(
() => props.bordered,
(val) => {
tableSettings.bordered = val;
tableSettings.bordered = val
},
);
)
watch(
() => props.size,
(val) => {
tableSettings.size = val;
tableSettings.size = val
},
);
)
const emit = defineEmits([
"refresh",
"change",
"resizeColumn",
"select",
"selectAll",
"selectNone",
"paginationChange",
]);
const emit = defineEmits(['refresh', 'change', 'resizeColumn', 'select', 'selectAll', 'selectNone', 'paginationChange'])
// 列设置相关
const columnSettingVisible = ref(false);
const tableSettingVisible = ref(false);
const visibleColumns = ref([]);
const sortedColumns = ref([]); // 排序后的列key数组
const draggingIndex = ref(-1); // 当前拖拽的索引
const columnSettingVisible = ref(false)
const tableSettingVisible = ref(false)
const visibleColumns = ref([])
const sortedColumns = ref([]) // 排序后的列key数组
const draggingIndex = ref(-1) // 当前拖拽的索引
// 所有列
const allColumns = computed(() => {
return props.columns.filter(
(col) => col.dataIndex && col.dataIndex !== "_index",
);
});
return props.columns.filter((col) => col.dataIndex && col.dataIndex !== '_index')
})
// 获取列标题
const getColumnTitle = (colKey) => {
const col = allColumns.value.find((c) => (c.dataIndex || c.key) === colKey);
return col ? col.title : colKey;
};
const col = allColumns.value.find((c) => (c.dataIndex || c.key) === colKey)
return col ? col.title : colKey
}
// 初始化可见列和排序
watch(
() => props.columns,
(newColumns) => {
const columnKeys = newColumns
.filter((col) => col.dataIndex && col.dataIndex !== "_index")
.map((col) => col.dataIndex || col.key);
const columnKeys = newColumns.filter((col) => col.dataIndex && col.dataIndex !== '_index').map((col) => col.dataIndex || col.key)
// 如果是首次初始化,使用原始顺序
if (sortedColumns.value.length === 0) {
sortedColumns.value = [...columnKeys];
sortedColumns.value = [...columnKeys]
} else {
// 保留已存在的顺序,添加新列
const existingKeys = sortedColumns.value.filter((key) =>
columnKeys.includes(key),
);
const newKeys = columnKeys.filter(
(key) => !existingKeys.includes(key),
);
sortedColumns.value = [...existingKeys, ...newKeys];
const existingKeys = sortedColumns.value.filter((key) => columnKeys.includes(key))
const newKeys = columnKeys.filter((key) => !existingKeys.includes(key))
sortedColumns.value = [...existingKeys, ...newKeys]
}
visibleColumns.value = [...sortedColumns.value];
visibleColumns.value = [...sortedColumns.value]
},
{ immediate: true, deep: true },
);
)
// 切换列的显示状态
const toggleColumn = (colKey, checked) => {
if (checked) {
if (!visibleColumns.value.includes(colKey)) {
visibleColumns.value.push(colKey);
visibleColumns.value.push(colKey)
}
} else {
visibleColumns.value = visibleColumns.value.filter(
(key) => key !== colKey,
);
visibleColumns.value = visibleColumns.value.filter((key) => key !== colKey)
}
};
}
// 拖拽开始
const handleDragStart = (index, event) => {
draggingIndex.value = index;
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", index.toString());
};
draggingIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', index.toString())
}
// 拖拽经过
const handleDragOver = (index, event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
};
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
}
// 拖拽结束
const handleDragEnd = () => {
draggingIndex.value = -1;
};
draggingIndex.value = -1
}
// 拖拽放置
const handleDrop = (dropIndex) => {
if (draggingIndex.value === dropIndex) return;
if (draggingIndex.value === dropIndex) return
const draggedKey = sortedColumns.value[draggingIndex.value];
const newColumns = [...sortedColumns.value];
const draggedKey = sortedColumns.value[draggingIndex.value]
const newColumns = [...sortedColumns.value]
// 移除被拖拽的项
newColumns.splice(draggingIndex.value, 1);
newColumns.splice(draggingIndex.value, 1)
// 插入到新位置
newColumns.splice(dropIndex, 0, draggedKey);
newColumns.splice(dropIndex, 0, draggedKey)
sortedColumns.value = newColumns;
draggingIndex.value = -1;
};
sortedColumns.value = newColumns
draggingIndex.value = -1
}
// 处理刷新
const handleRefresh = () => {
emit("refresh");
};
emit('refresh')
}
// 处理分页变化
const handlePaginationChange = (page, pageSize) => {
emit("paginationChange", { page, pageSize });
};
emit('paginationChange', { page, pageSize })
}
// 处理表格变化(排序、筛选)
const handleTableChange = (filters, sorter, extra) => {
emit("change", { filters, sorter, extra });
};
emit('change', { filters, sorter, extra })
}
// 处理列宽调整
const handleResizeColumn = (width, column) => {
emit("resizeColumn", { width, column });
};
emit('resizeColumn', { width, column })
}
// 获取表格序号
const getTableIndex = (index) => {
const { current = 1, pageSize = 10 } = props.pagination || {};
return (current - 1) * pageSize + index + 1;
};
const { current = 1, pageSize = 10 } = props.pagination || {}
return (current - 1) * pageSize + index + 1
}
// 表格列配置
const tableColumns = computed(() => {
let columns = [];
let columns = []
// 添加序号列
if (props.showIndex) {
columns.push({
title: props.indexTitle,
dataIndex: "_index",
key: "_index",
dataIndex: '_index',
key: '_index',
width: props.indexColumnWidth,
align: "center",
fixed: "left",
});
align: 'center',
fixed: 'left',
})
}
// 添加数据列(按排序顺序)
sortedColumns.value.forEach((colKey) => {
// 过滤掉未显示的列
if (!visibleColumns.value.includes(colKey)) {
return;
return
}
const col = props.columns.find(
(c) => (c.dataIndex || c.key) === colKey,
);
const col = props.columns.find((c) => (c.dataIndex || c.key) === colKey)
if (col) {
columns.push({
...col,
customRender: col.slot ? undefined : col.customRender,
});
})
}
});
})
return columns;
});
return columns
})
// 暴露方法给父组件
defineExpose({
refresh: handleRefresh,
getTableIndex,
});
})
</script>
<style scoped lang="scss">
@@ -1,16 +1,6 @@
<template>
<div class="file-upload">
<a-upload
v-model:file-list="fileList"
:custom-request="customUpload"
:before-upload="beforeUpload"
:accept="accept"
:max-count="maxCount"
:disabled="disabled"
:multiple="multiple"
@change="handleChange"
@remove="handleRemove"
>
<a-upload v-model:file-list="fileList" :custom-request="customUpload" :before-upload="beforeUpload" :accept="accept" :max-count="maxCount" :disabled="disabled" :multiple="multiple" @change="handleChange" @remove="handleRemove">
<a-button v-if="!disabled">
<upload-outlined />
上传文件
@@ -20,10 +10,10 @@
</template>
<script setup>
import { ref, watch } from "vue";
import { message } from "ant-design-vue";
import { UploadOutlined } from "@ant-design/icons-vue";
import uploadConfig from "@/config/upload";
import { ref, watch } from 'vue'
import { message } from 'ant-design-vue'
import { UploadOutlined } from '@ant-design/icons-vue'
import uploadConfig from '@/config/upload'
const props = defineProps({
// 文件列表
@@ -39,7 +29,7 @@ const props = defineProps({
// 接受的文件类型,例如 '.pdf,.doc,.docx' 或 '*'
accept: {
type: String,
default: "*",
default: '*',
},
// 是否禁用
disabled: {
@@ -56,133 +46,125 @@ const props = defineProps({
type: Boolean,
default: true,
},
});
})
const emit = defineEmits(["update:modelValue", "change", "remove"]);
const emit = defineEmits(['update:modelValue', 'change', 'remove'])
// 文件列表
const fileList = ref([]);
const fileList = ref([])
// 初始化文件列表
const initFileList = () => {
if (props.modelValue) {
if (typeof props.modelValue === "string") {
if (typeof props.modelValue === 'string') {
// 单文件上传,字符串格式
fileList.value = props.modelValue
? [
{
uid: "-1",
name: "file",
status: "done",
uid: '-1',
name: 'file',
status: 'done',
url: props.modelValue,
response: {
src: props.modelValue,
},
},
]
: [];
: []
} else if (Array.isArray(props.modelValue)) {
// 多文件上传,数组格式
fileList.value = props.modelValue.map((url, index) => ({
uid: `-${index}`,
name: `file${index}`,
status: "done",
status: 'done',
url: url,
response: {
src: url,
},
}));
}))
}
} else {
fileList.value = [];
fileList.value = []
}
};
}
// 监听外部值变化
watch(
() => props.modelValue,
() => {
initFileList();
initFileList()
},
{ immediate: true },
);
)
// 自定义上传
const customUpload = (options) => {
const { file, onProgress, onSuccess, onError } = options;
const formData = new FormData();
formData.append(uploadConfig.filename || "file", file);
const { file, onProgress, onSuccess, onError } = options
const formData = new FormData()
formData.append(uploadConfig.filename || 'file', file)
// 使用文件上传API对象
const apiObj = uploadConfig.apiObjFile || uploadConfig.apiObj;
const apiObj = uploadConfig.apiObjFile || uploadConfig.apiObj
apiObj(formData, {
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
);
onProgress({ percent }, file);
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
onProgress({ percent }, file)
},
})
.then((res) => {
const data = uploadConfig.parseData(res);
const data = uploadConfig.parseData(res)
if (data.code === uploadConfig.successCode) {
onSuccess(data, file);
message.success("上传成功");
onSuccess(data, file)
message.success('上传成功')
} else {
onError(new Error(data.msg || "上传失败"));
message.error(data.msg || "上传失败");
onError(new Error(data.msg || '上传失败'))
message.error(data.msg || '上传失败')
}
})
.catch((error) => {
onError(error);
message.error("上传失败:" + error.message);
});
};
onError(error)
message.error('上传失败:' + error.message)
})
}
// 上传前校验
const beforeUpload = (file) => {
const maxSizeMB = uploadConfig.maxSizeFile || uploadConfig.maxSize || 10;
const maxSizeBytes = maxSizeMB * 1024 * 1024;
const maxSizeMB = uploadConfig.maxSizeFile || uploadConfig.maxSize || 10
const maxSizeBytes = maxSizeMB * 1024 * 1024
if (file.size > maxSizeBytes) {
message.error(`文件大小不能超过 ${maxSizeMB}MB`);
return false;
message.error(`文件大小不能超过 ${maxSizeMB}MB`)
return false
}
return true;
};
return true
}
// 处理文件列表变化
const handleChange = ({ fileList: newFileList }) => {
fileList.value = newFileList;
fileList.value = newFileList
// 提取成功的文件URL
const successFiles = newFileList
.filter(
(file) =>
file.status === "done" && (file.url || file.response?.src),
)
.map((file) => file.url || file.response?.src);
const successFiles = newFileList.filter((file) => file.status === 'done' && (file.url || file.response?.src)).map((file) => file.url || file.response?.src)
// 触发更新事件
if (props.returnUrl) {
// 返回URL字符串或数组
const value =
props.maxCount === 1 ? successFiles[0] || "" : successFiles;
emit("update:modelValue", value);
emit("change", value, newFileList);
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
emit('update:modelValue', value)
emit('change', value, newFileList)
} else {
// 返回完整文件列表
emit("update:modelValue", newFileList);
emit("change", newFileList);
emit('update:modelValue', newFileList)
emit('change', newFileList)
}
};
}
// 处理文件移除
const handleRemove = (file) => {
emit("remove", file);
return true;
};
emit('remove', file)
return true
}
</script>
<style scoped>
+115 -150
View File
@@ -20,39 +20,26 @@
class="custom-upload"
:class="{ 'drag-over': isDragOver }"
>
<div
v-if="fileList.length < maxCount && !disabled"
class="upload-area"
>
<div v-if="fileList.length < maxCount && !disabled" class="upload-area">
<loading-outlined v-if="uploading" class="upload-icon" />
<plus-outlined v-else class="upload-icon" />
<div class="ant-upload-text">
{{ uploading ? "上传中..." : uploadText }}
{{ uploading ? '上传中...' : uploadText }}
</div>
<div v-if="tip" class="ant-upload-tip">{{ tip }}</div>
</div>
</a-upload>
<a-modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
:width="800"
@cancel="handleCancel"
>
<img
alt="图片预览"
style="width: 100%; max-height: 600px; object-fit: contain"
:src="previewImage"
/>
<a-modal :open="previewVisible" :title="previewTitle" :footer="null" :width="800" @cancel="handleCancel">
<img alt="图片预览" style="width: 100%; max-height: 600px; object-fit: contain" :src="previewImage" />
</a-modal>
</div>
</template>
<script setup>
import { ref, watch, computed } from "vue";
import { message, Modal } from "ant-design-vue";
import { PlusOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import uploadConfig from "@/config/upload";
import { ref, watch, computed } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, LoadingOutlined } from '@ant-design/icons-vue'
import uploadConfig from '@/config/upload'
const props = defineProps({
// 图片列表
@@ -68,7 +55,7 @@ const props = defineProps({
// 接受的文件类型
accept: {
type: String,
default: "image/*",
default: 'image/*',
},
// 是否禁用
disabled: {
@@ -83,12 +70,12 @@ const props = defineProps({
// 上传按钮文字
uploadText: {
type: String,
default: "上传图片",
default: '上传图片',
},
// 提示文字
tip: {
type: String,
default: "",
default: '',
},
// 最小宽度(像素)
minWidth: {
@@ -120,258 +107,236 @@ const props = defineProps({
type: Function,
default: null,
},
});
})
const emit = defineEmits([
"update:modelValue",
"change",
"preview",
"remove",
"uploadSuccess",
"uploadError",
]);
const emit = defineEmits(['update:modelValue', 'change', 'preview', 'remove', 'uploadSuccess', 'uploadError'])
// 文件列表
const fileList = ref([]);
const fileList = ref([])
// 预览相关
const previewVisible = ref(false);
const previewImage = ref("");
const previewVisible = ref(false)
const previewImage = ref('')
const previewTitle = computed(() => {
return previewImage.value ? "图片预览" : "";
});
return previewImage.value ? '图片预览' : ''
})
// 上传状态
const uploading = ref(false);
const uploading = ref(false)
// 拖拽状态
const isDragOver = ref(false);
const isDragOver = ref(false)
// 初始化文件列表
const initFileList = () => {
if (props.modelValue) {
if (typeof props.modelValue === "string") {
if (typeof props.modelValue === 'string') {
// 单图上传,字符串格式
fileList.value = props.modelValue
? [
{
uid: "-1",
name: "image.png",
status: "done",
uid: '-1',
name: 'image.png',
status: 'done',
url: props.modelValue,
},
]
: [];
: []
} else if (Array.isArray(props.modelValue)) {
// 多图上传,数组格式
fileList.value = props.modelValue.map((url, index) => ({
uid: `-${index}`,
name: `image${index}.png`,
status: "done",
status: 'done',
url: url,
}));
}))
}
} else {
fileList.value = [];
fileList.value = []
}
};
}
// 监听外部值变化
watch(
() => props.modelValue,
() => {
initFileList();
initFileList()
},
{ immediate: true },
);
)
// 自定义上传
const customUpload = (options) => {
const { file, onProgress, onSuccess, onError } = options;
const formData = new FormData();
formData.append(uploadConfig.filename || "file", file);
const { file, onProgress, onSuccess, onError } = options
const formData = new FormData()
formData.append(uploadConfig.filename || 'file', file)
uploading.value = true;
uploading.value = true
uploadConfig
.apiObj(formData, {
onUploadProgress: (progressEvent) => {
const percent = Math.round(
(progressEvent.loaded / progressEvent.total) * 100,
);
onProgress({ percent }, file);
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
onProgress({ percent }, file)
},
})
.then((res) => {
const data = uploadConfig.parseData(res);
const data = uploadConfig.parseData(res)
if (data.code === uploadConfig.successCode) {
onSuccess(data, file);
message.success("上传成功");
emit("uploadSuccess", data, file);
onSuccess(data, file)
message.success('上传成功')
emit('uploadSuccess', data, file)
} else {
onError(new Error(data.msg || "上传失败"));
message.error(data.msg || "上传失败");
emit("uploadError", data.msg || "上传失败", file);
onError(new Error(data.msg || '上传失败'))
message.error(data.msg || '上传失败')
emit('uploadError', data.msg || '上传失败', file)
}
})
.catch((error) => {
onError(error);
message.error("上传失败:" + error.message);
emit("uploadError", error.message, file);
onError(error)
message.error('上传失败:' + error.message)
emit('uploadError', error.message, file)
})
.finally(() => {
uploading.value = false;
});
};
uploading.value = false
})
}
// 上传前校验
const beforeUpload = async (file) => {
// 文件大小校验
const maxSizeMB = uploadConfig.maxSize || 10;
const maxSizeBytes = maxSizeMB * 1024 * 1024;
const maxSizeMB = uploadConfig.maxSize || 10
const maxSizeBytes = maxSizeMB * 1024 * 1024
if (file.size > maxSizeBytes) {
message.error(`图片大小不能超过 ${maxSizeMB}MB`);
return false;
message.error(`图片大小不能超过 ${maxSizeMB}MB`)
return false
}
// 图片尺寸校验
if (
props.minWidth ||
props.maxWidth ||
props.minHeight ||
props.maxHeight
) {
if (props.minWidth || props.maxWidth || props.minHeight || props.maxHeight) {
try {
const dimensions = await getImageDimensions(file);
const { width, height } = dimensions;
const dimensions = await getImageDimensions(file)
const { width, height } = dimensions
if (props.minWidth && width < props.minWidth) {
message.error(`图片宽度不能小于 ${props.minWidth}px`);
return false;
message.error(`图片宽度不能小于 ${props.minWidth}px`)
return false
}
if (props.maxWidth && width > props.maxWidth) {
message.error(`图片宽度不能大于 ${props.maxWidth}px`);
return false;
message.error(`图片宽度不能大于 ${props.maxWidth}px`)
return false
}
if (props.minHeight && height < props.minHeight) {
message.error(`图片高度不能小于 ${props.minHeight}px`);
return false;
message.error(`图片高度不能小于 ${props.minHeight}px`)
return false
}
if (props.maxHeight && height > props.maxHeight) {
message.error(`图片高度不能大于 ${props.maxHeight}px`);
return false;
message.error(`图片高度不能大于 ${props.maxHeight}px`)
return false
}
} catch (error) {
message.error("图片尺寸校验失败");
return false;
message.error('图片尺寸校验失败')
return false
}
}
return true;
};
return true
}
// 获取图片尺寸
const getImageDimensions = (file) => {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
const img = new Image()
const reader = new FileReader()
reader.onload = (e) => {
img.src = e.target.result;
img.src = e.target.result
img.onload = () => {
resolve({ width: img.width, height: img.height });
};
img.onerror = reject;
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
};
resolve({ width: img.width, height: img.height })
}
img.onerror = reject
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
// 处理预览
const handlePreview = async (file) => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj);
file.preview = await getBase64(file.originFileObj)
}
previewImage.value = file.url || file.preview;
previewVisible.value = true;
emit("preview", file);
};
previewImage.value = file.url || file.preview
previewVisible.value = true
emit('preview', file)
}
// 获取Base64
const getBase64 = (file) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
};
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = () => resolve(reader.result)
reader.onerror = (error) => reject(error)
})
}
// 处理文件列表变化
const handleChange = ({ fileList: newFileList }) => {
// 更新文件列表,确保上传成功的文件有正确的 url
const updatedFileList = newFileList.map((file) => {
// 如果文件上传成功且有响应数据但没有 url,则设置 url
if (file.status === "done" && file.response?.src && !file.url) {
if (file.status === 'done' && file.response?.src && !file.url) {
return {
...file,
url: file.response.src,
};
}
}
return file;
});
return file
})
fileList.value = updatedFileList;
fileList.value = updatedFileList
// 过滤掉失败的文件
const validFileList = updatedFileList.filter(
(file) => file.status !== "error",
);
const validFileList = updatedFileList.filter((file) => file.status !== 'error')
// 提取成功的文件URL
const successFiles = validFileList
.filter(
(file) =>
file.status === "done" && (file.url || file.response?.src),
)
.map((file) => file.url || file.response?.src);
const successFiles = validFileList.filter((file) => file.status === 'done' && (file.url || file.response?.src)).map((file) => file.url || file.response?.src)
// 触发更新事件
if (props.returnUrl) {
// 返回URL字符串或数组
const value =
props.maxCount === 1 ? successFiles[0] || "" : successFiles;
emit("update:modelValue", value);
emit("change", value, validFileList);
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
emit('update:modelValue', value)
emit('change', value, validFileList)
} else {
// 返回完整文件列表
emit("update:modelValue", validFileList);
emit("change", validFileList);
emit('update:modelValue', validFileList)
emit('change', validFileList)
}
};
}
// 拖拽相关
const handleDragEnter = (e) => {
e.preventDefault();
isDragOver.value = true;
};
e.preventDefault()
isDragOver.value = true
}
const handleDragLeave = (e) => {
e.preventDefault();
isDragOver.value = false;
};
e.preventDefault()
isDragOver.value = false
}
const handleDrop = (e) => {
e.preventDefault();
isDragOver.value = false;
};
e.preventDefault()
isDragOver.value = false
}
// 取消预览
const handleCancel = () => {
previewVisible.value = false;
};
previewVisible.value = false
}
</script>
<style scoped>
+23 -23
View File
@@ -1,28 +1,28 @@
// 默认配置
const defaultConfig = {
APP_NAME: "vueadmin",
DASHBOARD_URL: "/dashboard",
APP_NAME: 'vueadmin',
DASHBOARD_URL: '/dashboard',
// 白名单路由(不需要登录即可访问)
whiteList: ["/login", "/register", "/reset-password"],
whiteList: ['/login', '/register', '/reset-password'],
//版本号
APP_VER: "1.6.6",
APP_VER: '1.6.6',
//内核版本号
CORE_VER: "1.6.6",
CORE_VER: '1.6.6',
//接口地址
API_URL: "http://127.0.0.1:8000/admin/",
WS_URL: "127.0.0.1:8000",
API_URL: 'http://127.0.0.1:8000/admin/',
WS_URL: '127.0.0.1:8000',
//请求超时
TIMEOUT: 50000,
//TokenName
TOKEN_NAME: "authorization",
TOKEN_NAME: 'authorization',
//Token前缀,注意最后有个空格,如不需要需设置空字符串
TOKEN_PREFIX: "Bearer ",
TOKEN_PREFIX: 'Bearer ',
//追加其他头
HEADERS: {},
@@ -30,39 +30,39 @@ const defaultConfig = {
//请求是否开启缓存
REQUEST_CACHE: false,
//语言
LANG: "zh-cn",
LANG: 'zh-cn',
DASHBOARD_LAYOUT: "widgets", //控制台首页默认布局
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"]],
compsList: [['welcome'], ['info'], ['ver']],
},
//是否加密localStorage, 为空不加密
//支持多种加密方式: 'AES', 'BASE64', 'DES'
LS_ENCRYPTION: "",
LS_ENCRYPTION: '',
//localStorage加密秘钥,位数建议填写8的倍数
LS_ENCRYPTION_key: "2XNN4K8LC0ELVWN4",
LS_ENCRYPTION_key: '2XNN4K8LC0ELVWN4',
//localStorage加密模式,AES支持: 'ECB', 'CBC', 'CTR', 'OFB', 'CFB'
LS_ENCRYPTION_mode: "ECB",
LS_ENCRYPTION_mode: 'ECB',
//localStorage加密填充方式,AES支持: 'Pkcs7', 'ZeroPadding', 'Iso10126', 'Iso97971'
LS_ENCRYPTION_padding: "Pkcs7",
LS_ENCRYPTION_padding: 'Pkcs7',
//localStorage默认过期时间(单位:小时),0表示永不过期
LS_DEFAULT_EXPIRE: 720, // 30天
//DES加密秘钥,必须是8字节
LS_DES_key: "12345678",
};
// 合并 public/config.js 中的覆盖配置
if (typeof window !== "undefined" && window.SY_CONFIG) {
Object.assign(defaultConfig, window.SY_CONFIG);
LS_DES_key: '12345678',
}
export default defaultConfig;
// 合并 public/config.js 中的覆盖配置
if (typeof window !== 'undefined' && window.SY_CONFIG) {
Object.assign(defaultConfig, window.SY_CONFIG)
}
export default defaultConfig
+2 -2
View File
@@ -2,6 +2,6 @@
* 静态路由配置
* 这些路由会根据用户角色进行过滤后添加到路由中
*/
const userRoutes = [];
const userRoutes = []
export default userRoutes;
export default userRoutes
+4 -4
View File
@@ -1,10 +1,10 @@
import systemApi from "@/api/system";
import systemApi from '@/api/system'
//上传配置
export default {
apiObj: systemApi.upload.post, //上传请求API对象
filename: "file", //form请求时文件的key
filename: 'file', //form请求时文件的key
successCode: 1, //请求完成代码
maxSize: 10, //最大文件大小 默认10MB
parseData: function (res) {
@@ -13,8 +13,8 @@ export default {
fileName: res.data.name, //分析文件名称
src: res.data.url, //分析图片远程地址结构
msg: res.message, //分析描述字段结构
};
}
},
apiObjFile: systemApi.upload.post, //附件上传请求API对象
maxSizeFile: 10, //最大文件大小 默认10MB
};
}
+5 -5
View File
@@ -1,9 +1,9 @@
import { useI18n as useVueI18n } from "vue-i18n";
import { useI18nStore } from "@/stores/modules/i18n";
import { useI18n as useVueI18n } from 'vue-i18n'
import { useI18nStore } from '@/stores/modules/i18n'
export function useI18n() {
const { t, locale, availableLocales } = useVueI18n();
const i18nStore = useI18nStore();
const { t, locale, availableLocales } = useVueI18n()
const i18nStore = useI18nStore()
return {
t,
@@ -12,5 +12,5 @@ export function useI18n() {
setLocale: i18nStore.setLocale,
currentLocale: i18nStore.currentLocale,
localeLabel: i18nStore.localeLabel,
};
}
}
+75 -95
View File
@@ -1,5 +1,5 @@
import { ref, reactive, computed, onMounted } from "vue";
import { message } from "ant-design-vue";
import { ref, reactive, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
/**
* 表格通用hooks
@@ -15,36 +15,25 @@ import { message } from "ant-design-vue";
* @returns {Object} 返回表格相关的状态和方法
*/
export function useTable(options = {}) {
const {
api,
searchForm: initialSearchForm = {},
columns = [],
rowKey = "id",
needPagination = true,
paginationConfig = {},
needSelection = false,
immediateLoad = true,
} = options;
const { api, searchForm: initialSearchForm = {}, columns = [], rowKey = 'id', needPagination = true, paginationConfig = {}, needSelection = false, immediateLoad = true } = options
// 表格引用
const tableRef = ref(null);
const tableRef = ref(null)
// 搜索表单
const searchForm = reactive({ ...initialSearchForm });
const searchForm = reactive({ ...initialSearchForm })
// 表格数据
const tableData = ref([]);
const tableData = ref([])
// 加载状态
const loading = ref(false);
const loading = ref(false)
// 选中的行数据
const selectedRows = ref([]);
const selectedRows = ref([])
// 选中的行keys
const selectedRowKeys = computed(() =>
selectedRows.value.map((item) => item[rowKey]),
);
const selectedRowKeys = computed(() => selectedRows.value.map((item) => item[rowKey]))
// 分页配置
const defaultPaginationConfig = {
@@ -53,180 +42,171 @@ export function useTable(options = {}) {
total: 0,
showSizeChanger: true,
showTotal: (total) => `共 ${total} 条`,
pageSizeOptions: ["20", "50", "100", "200"],
};
pageSizeOptions: ['20', '50', '100', '200'],
}
const pagination = reactive({
...defaultPaginationConfig,
...paginationConfig,
});
})
// 行选择配置
const rowSelection = computed(() => {
if (!needSelection) return null;
if (!needSelection) return null
return {
selectedRowKeys: selectedRowKeys.value,
onChange: (keys, rows) => {
selectedRows.value = rows;
selectedRows.value = rows
},
};
});
}
})
// 行选择事件处理(用于scTable的@select事件)
const handleSelectChange = (record, selected, selectedRows) => {
if (!needSelection) return;
if (!needSelection) return
if (selected) {
selectedRows.value.push(record);
selectedRows.value.push(record)
} else {
const index = selectedRows.value.findIndex(
(item) => item[rowKey] === record[rowKey],
);
const index = selectedRows.value.findIndex((item) => item[rowKey] === record[rowKey])
if (index > -1) {
selectedRows.value.splice(index, 1);
selectedRows.value.splice(index, 1)
}
}
};
}
// 全选/取消全选处理(用于scTable的@selectAll事件)
const handleSelectAll = (selected, selectedRows, changeRows) => {
if (!needSelection) return;
if (!needSelection) return
if (selected) {
changeRows.forEach((record) => {
if (
!selectedRows.value.find(
(item) => item[rowKey] === record[rowKey],
)
) {
selectedRows.value.push(record);
if (!selectedRows.value.find((item) => item[rowKey] === record[rowKey])) {
selectedRows.value.push(record)
}
});
})
} else {
changeRows.forEach((record) => {
const index = selectedRows.value.findIndex(
(item) => item[rowKey] === record[rowKey],
);
const index = selectedRows.value.findIndex((item) => item[rowKey] === record[rowKey])
if (index > -1) {
selectedRows.value.splice(index, 1);
selectedRows.value.splice(index, 1)
}
});
})
}
};
}
// 加载数据
const loadData = async (params = {}) => {
if (!api) {
console.warn("useTable: 未提供api函数,无法加载数据");
return;
console.warn('useTable: 未提供api函数,无法加载数据')
return
}
loading.value = true;
loading.value = true
try {
const requestParams = {
...searchForm,
...params,
};
}
// 如果需要分页,添加分页参数
if (needPagination) {
requestParams.page = pagination.current;
requestParams.limit = pagination.pageSize;
requestParams.page = pagination.current
requestParams.limit = pagination.pageSize
}
// 调用API函数,确保this上下文正确
const res = await api(requestParams);
const res = await api(requestParams)
if (res.code === 200) {
// 如果是分页数据
if (needPagination) {
tableData.value = res.data?.list || [];
pagination.total = res.data?.total || 0;
tableData.value = res.data?.list || []
pagination.total = res.data?.total || 0
} else {
// 非分页数据(如树形数据)
// 确保数据是数组,如果不是数组则包装成数组
const data = res.data;
const data = res.data
if (Array.isArray(data)) {
tableData.value = data;
} else if (data && typeof data === "object") {
tableData.value = data
} else if (data && typeof data === 'object') {
// 如果返回的是对象,可能包含 list 或 items 等字段
tableData.value =
data.list || data.items || data.data || [];
tableData.value = data.list || data.items || data.data || []
} else {
tableData.value = [];
tableData.value = []
}
}
} else {
message.error(res.message || "加载数据失败");
message.error(res.message || '加载数据失败')
}
} catch (error) {
console.error("加载数据失败:", error);
message.error("加载数据失败");
console.error('加载数据失败:', error)
message.error('加载数据失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 分页变化处理
const handlePaginationChange = ({ page, pageSize }) => {
if (!needPagination) return;
pagination.current = page;
pagination.pageSize = pageSize;
loadData();
};
if (!needPagination) return
pagination.current = page
pagination.pageSize = pageSize
loadData()
}
// 搜索
const handleSearch = () => {
if (needPagination) {
pagination.current = 1;
pagination.current = 1
}
loadData();
};
loadData()
}
// 重置
const handleReset = () => {
// 重置搜索表单为初始值
Object.keys(searchForm).forEach((key) => {
searchForm[key] = initialSearchForm[key];
});
searchForm[key] = initialSearchForm[key]
})
// 清空选择
selectedRows.value = [];
selectedRows.value = []
// 重置分页
if (needPagination) {
pagination.current = 1;
pagination.current = 1
}
// 重新加载数据
loadData();
};
loadData()
}
// 刷新表格
const refreshTable = () => {
loadData();
};
loadData()
}
// 清空选择
const clearSelection = () => {
selectedRows.value = [];
};
selectedRows.value = []
}
// 设置选中行
const setSelectedRows = (rows) => {
selectedRows.value = rows;
};
selectedRows.value = rows
}
// 更新搜索表单
const setSearchForm = (data) => {
Object.assign(searchForm, data);
};
Object.assign(searchForm, data)
}
// 直接设置表格数据(用于特殊场景)
const setTableData = (data) => {
tableData.value = data;
};
tableData.value = data
}
// 组件挂载时自动加载数据
if (immediateLoad) {
onMounted(() => {
loadData();
});
loadData()
})
}
return {
@@ -255,5 +235,5 @@ export function useTable(options = {}) {
setSelectedRows,
setSearchForm,
setTableData,
};
}
}
+108 -108
View File
@@ -1,11 +1,11 @@
import { ref } from "vue";
import { getWebSocket } from "@/utils/websocket";
import { useUserStore } from "@/stores/modules/user";
import { useMessageStore } from "@/stores/modules/message";
import { useDictionaryStore } from "@/stores/modules/dictionary";
import { useNotificationStore } from "@/stores/modules/notification";
import { message, notification } from "ant-design-vue";
import config from "@/config";
import { ref } from 'vue'
import { getWebSocket } from '@/utils/websocket'
import { useUserStore } from '@/stores/modules/user'
import { useMessageStore } from '@/stores/modules/message'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import { useNotificationStore } from '@/stores/modules/notification'
import { message, notification } from 'ant-design-vue'
import config from '@/config'
/**
* WebSocket Composable
@@ -13,13 +13,13 @@ import config from "@/config";
* 处理 WebSocket 连接和消息监听
*/
export function useWebSocket() {
const ws = ref(null);
const userStore = useUserStore();
const messageStore = useMessageStore();
const dictionaryStore = useDictionaryStore();
const notificationStore = useNotificationStore();
const reconnectTimer = ref(null);
const isInitialized = ref(false);
const ws = ref(null)
const userStore = useUserStore()
const messageStore = useMessageStore()
const dictionaryStore = useDictionaryStore()
const notificationStore = useNotificationStore()
const reconnectTimer = ref(null)
const isInitialized = ref(false)
/**
* 初始化 WebSocket 连接
@@ -27,29 +27,29 @@ export function useWebSocket() {
function initWebSocket() {
// 如果已经初始化,不再重复初始化
if (isInitialized.value) {
console.log("WebSocket 已初始化,跳过重复初始化");
return;
console.log('WebSocket 已初始化,跳过重复初始化')
return
}
// 检查用户信息是否完整
if (!userStore.isUserInfoComplete()) {
console.warn("用户信息不完整,等待用户信息加载...");
console.warn('用户信息不完整,等待用户信息加载...')
// 延迟重试,等待用户信息加载
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
clearTimeout(reconnectTimer.value)
}
reconnectTimer.value = setTimeout(() => {
initWebSocket();
}, 1000);
return;
initWebSocket()
}, 1000)
return
}
try {
console.log("开始初始化 WebSocket...", {
console.log('开始初始化 WebSocket...', {
userId: userStore.userInfo.id,
username: userStore.userInfo.username,
});
})
// 使用配置文件中的 WS_URL
// getWebSocket 会自动处理重复连接问题
@@ -59,21 +59,21 @@ export function useWebSocket() {
onMessage: handleMessage,
onError: handleError,
onClose: handleClose,
});
})
// 注册消息处理器
registerMessageHandlers();
registerMessageHandlers()
// 标记为已初始化
isInitialized.value = true;
isInitialized.value = true
// 连接(如果还未连接)
if (ws.value && !ws.value.isConnected) {
ws.value.connect();
ws.value.connect()
}
} catch (error) {
console.error("初始化 WebSocket 失败:", error);
isInitialized.value = false;
console.error('初始化 WebSocket 失败:', error)
isInitialized.value = false
}
}
@@ -81,37 +81,37 @@ export function useWebSocket() {
* 注册所有消息处理器
*/
function registerMessageHandlers() {
if (!ws.value) return;
if (!ws.value) return
// 字典更新
ws.value.on("dictionary_update", handleDictionaryUpdate);
ws.value.on("dictionary_item_update", handleDictionaryItemUpdate);
ws.value.on('dictionary_update', handleDictionaryUpdate)
ws.value.on('dictionary_item_update', handleDictionaryItemUpdate)
// 系统通知
ws.value.on("notification", handleNotification);
ws.value.on('notification', handleNotification)
// 数据更新
ws.value.on("data_update", handleDataUpdate);
ws.value.on('data_update', handleDataUpdate)
// 心跳响应
ws.value.on("heartbeat_response", handleHeartbeatResponse);
ws.value.on('heartbeat_response', handleHeartbeatResponse)
// 连接确认
ws.value.on("connected", handleConnected);
ws.value.on('connected', handleConnected)
}
/**
* 取消注册所有消息处理器
*/
function unregisterMessageHandlers() {
if (!ws.value) return;
if (!ws.value) return
ws.value.off("dictionary_update");
ws.value.off("dictionary_item_update");
ws.value.off("notification");
ws.value.off("data_update");
ws.value.off("heartbeat_response");
ws.value.off("connected");
ws.value.off('dictionary_update')
ws.value.off('dictionary_item_update')
ws.value.off('notification')
ws.value.off('data_update')
ws.value.off('heartbeat_response')
ws.value.off('connected')
}
/**
@@ -119,41 +119,41 @@ export function useWebSocket() {
* 注意:认证已在连接时通过 URL 参数完成,无需单独发送 auth 事件
*/
function handleOpen(event) {
console.log("WebSocket 连接已建立(已通过 URL 参数完成认证)", event);
console.log('WebSocket 连接已建立(已通过 URL 参数完成认证)', event)
}
/**
* 处理连接确认
*/
function handleConnected(data) {
console.log("WebSocket 连接已确认", data);
message.success("实时连接已建立");
console.log('WebSocket 连接已确认', data)
message.success('实时连接已建立')
}
/**
* 处理接收消息
*/
function handleMessage(message, event) {
console.log("收到 WebSocket 消息:", message);
console.log('收到 WebSocket 消息:', message)
}
/**
* 处理错误
*/
function handleError(error) {
console.error("WebSocket 错误:", error);
message.error("实时连接出现错误,正在重连...");
console.error('WebSocket 错误:', error)
message.error('实时连接出现错误,正在重连...')
}
/**
* 处理连接关闭
*/
function handleClose(event) {
console.log("WebSocket 连接已关闭", event);
console.log('WebSocket 连接已关闭', event)
// 如果不是手动关闭,显示提示
if (event.code !== 1000) {
message.warning("实时连接已断开");
message.warning('实时连接已断开')
}
}
@@ -161,64 +161,64 @@ export function useWebSocket() {
* 处理心跳响应
*/
function handleHeartbeatResponse(data) {
console.log("收到心跳响应:", data);
console.log('收到心跳响应:', data)
}
/**
* 处理系统通知
*/
function handleNotification(data) {
console.log("收到系统通知:", data);
console.log('收到系统通知:', data)
const { title, message: content, type, timestamp, ...rest } = data;
const { title, message: content, type, timestamp, ...rest } = data
// 构建通知数据格式
const notificationData = {
id: rest.id || Date.now(),
title: title || "系统通知",
content: content || "",
type: type || "info",
category: rest.category || "system",
title: title || '系统通知',
content: content || '',
type: type || 'info',
category: rest.category || 'system',
is_read: false,
created_at: rest.created_at || new Date().toISOString(),
...rest,
};
}
// 更新通知 store
notificationStore.handleWebSocketMessage({
type: "notification",
type: 'notification',
data: notificationData,
});
})
// 添加到消息 store(保持兼容性)
messageStore.addMessage({
type: type || "notification",
title: title || "系统通知",
content: content || "",
type: type || 'notification',
title: title || '系统通知',
content: content || '',
timestamp: timestamp || Date.now(),
});
})
// 显示通知(根据类型)
const notificationConfig = {
message: title || "系统通知",
message: title || '系统通知',
description: content,
duration: 4.5,
placement: "topRight",
};
placement: 'topRight',
}
// 根据类型设置不同样式
switch (type) {
case "success":
notification.success(notificationConfig);
break;
case "error":
notification.error(notificationConfig);
break;
case "warning":
notification.warning(notificationConfig);
break;
case 'success':
notification.success(notificationConfig)
break
case 'error':
notification.error(notificationConfig)
break
case 'warning':
notification.warning(notificationConfig)
break
default:
notification.info(notificationConfig);
notification.info(notificationConfig)
}
}
@@ -226,24 +226,24 @@ export function useWebSocket() {
* 处理数据更新
*/
async function handleDataUpdate(data) {
console.log("收到数据更新:", data);
console.log('收到数据更新:', data)
const { resource_type, action, timestamp } = data;
const { resource_type, action, timestamp } = data
// 添加到消息 store
messageStore.handleDataUpdate(data);
messageStore.handleDataUpdate(data)
// 根据资源类型执行相应操作
switch (resource_type) {
case "dictionary":
case "dictionary_item":
case 'dictionary':
case 'dictionary_item':
// 刷新字典缓存
try {
await dictionaryStore.refresh(true);
await dictionaryStore.refresh(true)
} catch (error) {
console.error("刷新字典缓存失败:", error);
console.error('刷新字典缓存失败:', error)
}
break;
break
// 可以添加其他资源类型的处理
}
}
@@ -252,16 +252,16 @@ export function useWebSocket() {
* 处理字典分类更新
*/
async function handleDictionaryUpdate(data) {
console.log("字典分类已更新:", data);
console.log('字典分类已更新:', data)
try {
// 刷新字典缓存
await dictionaryStore.refresh(true);
await dictionaryStore.refresh(true)
// 显示通知
message.success("字典数据已更新");
message.success('字典数据已更新')
} catch (error) {
console.error("刷新字典缓存失败:", error);
console.error('刷新字典缓存失败:', error)
}
}
@@ -269,16 +269,16 @@ export function useWebSocket() {
* 处理字典项更新
*/
async function handleDictionaryItemUpdate(data) {
console.log("字典项已更新:", data);
console.log('字典项已更新:', data)
try {
// 刷新字典缓存
await dictionaryStore.refresh(true);
await dictionaryStore.refresh(true)
// 显示通知
message.success("字典数据已更新");
message.success('字典数据已更新')
} catch (error) {
console.error("刷新字典缓存失败:", error);
console.error('刷新字典缓存失败:', error)
}
}
@@ -287,9 +287,9 @@ export function useWebSocket() {
*/
function send(type, data) {
if (ws.value && ws.value.isConnected) {
ws.value.send(type, data);
ws.value.send(type, data)
} else {
console.warn("WebSocket 未连接,无法发送消息");
console.warn('WebSocket 未连接,无法发送消息')
}
}
@@ -299,37 +299,37 @@ export function useWebSocket() {
function closeWebSocket() {
// 清除重试定时器
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
reconnectTimer.value = null;
clearTimeout(reconnectTimer.value)
reconnectTimer.value = null
}
if (ws.value) {
// 取消注册消息处理器
unregisterMessageHandlers();
unregisterMessageHandlers()
ws.value.disconnect();
ws.value = null;
ws.value.disconnect()
ws.value = null
}
// 重置初始化标记
isInitialized.value = false;
isInitialized.value = false
}
/**
* 重新连接 WebSocket
*/
function reconnect() {
closeWebSocket();
closeWebSocket()
setTimeout(() => {
initWebSocket();
}, 1000);
initWebSocket()
}, 1000)
}
/**
* 检查连接状态
*/
function isConnected() {
return ws.value && ws.value.isConnected;
return ws.value && ws.value.isConnected
}
return {
@@ -339,5 +339,5 @@ export function useWebSocket() {
reconnect,
isConnected,
send,
};
}
}
+9 -9
View File
@@ -1,15 +1,15 @@
import { createI18n } from "vue-i18n";
import zh from "./locales/zh-CN";
import en from "./locales/en-US";
import { createI18n } from 'vue-i18n'
import zh from './locales/zh-CN'
import en from './locales/en-US'
const i18n = createI18n({
legacy: false,
locale: "zh-CN",
fallbackLocale: "en-US",
locale: 'zh-CN',
fallbackLocale: 'en-US',
messages: {
"zh-CN": zh,
"en-US": en,
'zh-CN': zh,
'en-US': en,
},
});
})
export default i18n;
export default i18n
+242 -243
View File
@@ -1,263 +1,262 @@
export default {
common: {
welcome: "Welcome",
login: "Login",
logout: "Logout",
register: "Register",
searchMenu: "Search Menu",
searchPlaceholder: "Please enter menu name to search",
noResults: "No matching menus found",
searchTips: "Keyboard Shortcuts Tips",
navigateResults: "Use up/down arrows to navigate",
selectResult: "Press Enter to select",
closeSearch: "Press ESC to close",
taskCenter: "Task Center",
totalTasks: "Total Tasks",
pendingTasks: "Pending",
completedTasks: "Completed",
searchTasks: "Search tasks...",
all: "All",
pending: "Pending",
completed: "Completed",
taskTitle: "Task Title",
enterTaskTitle: "Please enter task title",
taskPriority: "Task Priority",
priorityHigh: "High",
priorityMedium: "Medium",
priorityLow: "Low",
confirmDelete: "Confirm Delete",
addTask: "Add Task",
pleaseEnterTaskTitle: "Please enter task title",
added: "Added",
deleted: "Deleted",
justNow: "Just now",
clearCache: "Clear Cache",
confirmClearCache: "Confirm Clear Cache",
clearCacheConfirm:
"Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.",
cacheCleared: "Cache cleared",
clearCacheFailed: "Failed to clear cache",
messages: "Messages",
tasks: "Tasks",
notification: "Notification",
task: "Task",
warning: "Warning",
markAllAsRead: "Mark All as Read",
clearAll: "Clear All",
noMessages: "No Messages",
noTasks: "No Tasks",
confirmClear: "Confirm Clear",
confirmClearMessages: "Are you sure you want to clear all messages?",
markedAsRead: "Marked as Read",
realtimeConnected: "Real-time connection established",
realtimeDisconnected: "Real-time connection disconnected",
realtimeError: "Real-time connection error, reconnecting...",
dataUpdated: "Data Updated",
dataCreated: "Data Created",
dataDeleted: "Data Deleted",
fullscreen: "Fullscreen",
personalCenter: "Personal Center",
systemSettings: "System Settings",
searchEmpty: "Please enter search content",
searching: "Searching: ",
cleared: "Cleared",
languageChanged: "Language Changed",
settingsDeveloping: "System settings feature is under development",
logoutSuccess: "Logout Successful",
logoutFailed: "Logout Failed",
confirmLogout: "Confirm Logout",
logoutConfirm: "Are you sure you want to logout?",
username: "Username",
password: "Password",
confirmPassword: "Confirm Password",
email: "Email",
phone: "Phone",
rememberMe: "Remember Me",
forgotPassword: "Forgot Password?",
submit: "Submit",
cancel: "Cancel",
save: "Save",
edit: "Edit",
delete: "Delete",
add: "Add",
search: "Search",
reset: "Reset",
confirm: "Confirm",
back: "Back",
next: "Next",
previous: "Previous",
refresh: "Refresh",
export: "Export",
import: "Import",
download: "Download",
upload: "Upload",
view: "View",
detail: "Detail",
settings: "Settings",
profile: "Profile",
language: "Language",
theme: "Theme",
dark: "Dark",
light: "Light",
loading: "Loading...",
noData: "No Data",
success: "Operation Successful",
error: "Operation Failed",
warning: "Warning",
info: "Info",
confirmDelete: "Are you sure you want to delete?",
confirmLogout: "Are you sure you want to logout?",
addConfig: "Add Config",
editConfig: "Edit Config",
configCategory: "Config Category",
configName: "Config Name",
configTitle: "Config Title",
configType: "Config Type",
configValue: "Config Value",
configTip: "Config Tip",
typeText: "Text",
typeTextarea: "Textarea",
typeNumber: "Number",
typeSwitch: "Switch",
typeSelect: "Select",
typeMultiselect: "Multiselect",
typeDatetime: "Datetime",
typeColor: "Color",
pleaseSelect: "Please Select",
pleaseEnter: "Please Enter",
noConfig: "No Config",
fetchConfigFailed: "Failed to fetch config",
addSuccess: "Added Successfully",
addFailed: "Failed to Add",
editSuccess: "Edited Successfully",
editFailed: "Failed to Edit",
saveSuccess: "Saved Successfully",
saveFailed: "Failed to Save",
resetSuccess: "Reset Successfully",
required: "This field is required",
operation: "Operation",
time: "Time",
status: "Status",
enabled: "Enabled",
disabled: "Disabled",
yes: "Yes",
no: "No",
areaManage: "Area Management",
areaName: "Area Name",
areaCode: "Area Code",
areaLevel: "Area Level",
parentArea: "Parent Area",
province: "Province",
city: "City",
district: "District",
street: "Street",
unknown: "Unknown",
addArea: "Add Area",
editArea: "Edit Area",
remark: "Remark",
sort: "Sort",
createTime: "Create Time",
action: "Action",
batchDelete: "Batch Delete",
confirmBatchDelete: "Confirm Batch Delete",
batchDeleteConfirm: "Are you sure you want to delete the selected",
items: "items?",
deleteConfirm: "Are you sure you want to delete",
selectDataFirst: "Please select data to operate first",
pleaseEnterNumber: "Please enter a valid number",
exitFullScreen: "Exit Fullscreen",
columns: "Columns",
columnSettings: "Column Settings",
selectAll: "Select All",
unselectAll: "Unselect All",
retry: "Retry",
fetchDataFailed: "Failed to fetch data",
welcome: 'Welcome',
login: 'Login',
logout: 'Logout',
register: 'Register',
searchMenu: 'Search Menu',
searchPlaceholder: 'Please enter menu name to search',
noResults: 'No matching menus found',
searchTips: 'Keyboard Shortcuts Tips',
navigateResults: 'Use up/down arrows to navigate',
selectResult: 'Press Enter to select',
closeSearch: 'Press ESC to close',
taskCenter: 'Task Center',
totalTasks: 'Total Tasks',
pendingTasks: 'Pending',
completedTasks: 'Completed',
searchTasks: 'Search tasks...',
all: 'All',
pending: 'Pending',
completed: 'Completed',
taskTitle: 'Task Title',
enterTaskTitle: 'Please enter task title',
taskPriority: 'Task Priority',
priorityHigh: 'High',
priorityMedium: 'Medium',
priorityLow: 'Low',
confirmDelete: 'Confirm Delete',
addTask: 'Add Task',
pleaseEnterTaskTitle: 'Please enter task title',
added: 'Added',
deleted: 'Deleted',
justNow: 'Just now',
clearCache: 'Clear Cache',
confirmClearCache: 'Confirm Clear Cache',
clearCacheConfirm: 'Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.',
cacheCleared: 'Cache cleared',
clearCacheFailed: 'Failed to clear cache',
messages: 'Messages',
tasks: 'Tasks',
notification: 'Notification',
task: 'Task',
warning: 'Warning',
markAllAsRead: 'Mark All as Read',
clearAll: 'Clear All',
noMessages: 'No Messages',
noTasks: 'No Tasks',
confirmClear: 'Confirm Clear',
confirmClearMessages: 'Are you sure you want to clear all messages?',
markedAsRead: 'Marked as Read',
realtimeConnected: 'Real-time connection established',
realtimeDisconnected: 'Real-time connection disconnected',
realtimeError: 'Real-time connection error, reconnecting...',
dataUpdated: 'Data Updated',
dataCreated: 'Data Created',
dataDeleted: 'Data Deleted',
fullscreen: 'Fullscreen',
personalCenter: 'Personal Center',
systemSettings: 'System Settings',
searchEmpty: 'Please enter search content',
searching: 'Searching: ',
cleared: 'Cleared',
languageChanged: 'Language Changed',
settingsDeveloping: 'System settings feature is under development',
logoutSuccess: 'Logout Successful',
logoutFailed: 'Logout Failed',
confirmLogout: 'Confirm Logout',
logoutConfirm: 'Are you sure you want to logout?',
username: 'Username',
password: 'Password',
confirmPassword: 'Confirm Password',
email: 'Email',
phone: 'Phone',
rememberMe: 'Remember Me',
forgotPassword: 'Forgot Password?',
submit: 'Submit',
cancel: 'Cancel',
save: 'Save',
edit: 'Edit',
delete: 'Delete',
add: 'Add',
search: 'Search',
reset: 'Reset',
confirm: 'Confirm',
back: 'Back',
next: 'Next',
previous: 'Previous',
refresh: 'Refresh',
export: 'Export',
import: 'Import',
download: 'Download',
upload: 'Upload',
view: 'View',
detail: 'Detail',
settings: 'Settings',
profile: 'Profile',
language: 'Language',
theme: 'Theme',
dark: 'Dark',
light: 'Light',
loading: 'Loading...',
noData: 'No Data',
success: 'Operation Successful',
error: 'Operation Failed',
warning: 'Warning',
info: 'Info',
confirmDelete: 'Are you sure you want to delete?',
confirmLogout: 'Are you sure you want to logout?',
addConfig: 'Add Config',
editConfig: 'Edit Config',
configCategory: 'Config Category',
configName: 'Config Name',
configTitle: 'Config Title',
configType: 'Config Type',
configValue: 'Config Value',
configTip: 'Config Tip',
typeText: 'Text',
typeTextarea: 'Textarea',
typeNumber: 'Number',
typeSwitch: 'Switch',
typeSelect: 'Select',
typeMultiselect: 'Multiselect',
typeDatetime: 'Datetime',
typeColor: 'Color',
pleaseSelect: 'Please Select',
pleaseEnter: 'Please Enter',
noConfig: 'No Config',
fetchConfigFailed: 'Failed to fetch config',
addSuccess: 'Added Successfully',
addFailed: 'Failed to Add',
editSuccess: 'Edited Successfully',
editFailed: 'Failed to Edit',
saveSuccess: 'Saved Successfully',
saveFailed: 'Failed to Save',
resetSuccess: 'Reset Successfully',
required: 'This field is required',
operation: 'Operation',
time: 'Time',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
yes: 'Yes',
no: 'No',
areaManage: 'Area Management',
areaName: 'Area Name',
areaCode: 'Area Code',
areaLevel: 'Area Level',
parentArea: 'Parent Area',
province: 'Province',
city: 'City',
district: 'District',
street: 'Street',
unknown: 'Unknown',
addArea: 'Add Area',
editArea: 'Edit Area',
remark: 'Remark',
sort: 'Sort',
createTime: 'Create Time',
action: 'Action',
batchDelete: 'Batch Delete',
confirmBatchDelete: 'Confirm Batch Delete',
batchDeleteConfirm: 'Are you sure you want to delete the selected',
items: 'items?',
deleteConfirm: 'Are you sure you want to delete',
selectDataFirst: 'Please select data to operate first',
pleaseEnterNumber: 'Please enter a valid number',
exitFullScreen: 'Exit Fullscreen',
columns: 'Columns',
columnSettings: 'Column Settings',
selectAll: 'Select All',
unselectAll: 'Unselect All',
retry: 'Retry',
fetchDataFailed: 'Failed to fetch data',
},
menu: {
dashboard: "Dashboard",
userManagement: "User Management",
roleManagement: "Role Management",
permissionManagement: "Permission Management",
systemSettings: "System Settings",
logManagement: "Log Management",
dashboard: 'Dashboard',
userManagement: 'User Management',
roleManagement: 'Role Management',
permissionManagement: 'Permission Management',
systemSettings: 'System Settings',
logManagement: 'Log Management',
},
login: {
title: "User Login",
subtitle: "Welcome back, please login to your account",
loginButton: "Login",
loginSuccess: "Login Successful",
loginFailed: "Login Failed",
usernamePlaceholder: "Please enter username",
passwordPlaceholder: "Please enter password",
title: 'User Login',
subtitle: 'Welcome back, please login to your account',
loginButton: 'Login',
loginSuccess: 'Login Successful',
loginFailed: 'Login Failed',
usernamePlaceholder: 'Please enter username',
passwordPlaceholder: 'Please enter password',
noAccount: "Don't have an account?",
registerNow: "Register Now",
forgotPassword: "Forgot Password?",
rememberMe: "Remember Me",
registerNow: 'Register Now',
forgotPassword: 'Forgot Password?',
rememberMe: 'Remember Me',
},
register: {
title: "User Registration",
subtitle: "Create your account and get started",
registerButton: "Register",
registerSuccess: "Registration Successful",
registerFailed: "Registration Failed",
usernamePlaceholder: "Please enter username",
emailPlaceholder: "Please enter email address",
passwordPlaceholder: "Please enter password",
confirmPasswordPlaceholder: "Please enter password again",
usernameRule: "Username length between 3 to 20 characters",
emailRule: "Please enter a valid email address",
passwordRule: "Password length between 6 to 20 characters",
agreeRule: "Please agree to the user agreement",
agreeTerms: "I have read and agree to the",
terms: "User Agreement",
hasAccount: "Already have an account?",
loginNow: "Login Now",
title: 'User Registration',
subtitle: 'Create your account and get started',
registerButton: 'Register',
registerSuccess: 'Registration Successful',
registerFailed: 'Registration Failed',
usernamePlaceholder: 'Please enter username',
emailPlaceholder: 'Please enter email address',
passwordPlaceholder: 'Please enter password',
confirmPasswordPlaceholder: 'Please enter password again',
usernameRule: 'Username length between 3 to 20 characters',
emailRule: 'Please enter a valid email address',
passwordRule: 'Password length between 6 to 20 characters',
agreeRule: 'Please agree to the user agreement',
agreeTerms: 'I have read and agree to the',
terms: 'User Agreement',
hasAccount: 'Already have an account?',
loginNow: 'Login Now',
},
resetPassword: {
title: "Reset Password",
subtitle: "Reset your password via email verification code",
resetButton: "Reset Password",
resetSuccess: "Password reset successful",
resetFailed: "Reset failed",
emailPlaceholder: "Please enter email address",
codePlaceholder: "Please enter verification code",
newPasswordPlaceholder: "Please enter new password",
confirmPasswordPlaceholder: "Please enter new password again",
emailRule: "Please enter a valid email address",
codeRule: "Verification code must be 6 characters",
passwordRule: "Password length between 6 to 20 characters",
sendCode: "Send Code",
codeSent: "Verification code has been sent to your email",
resendCode: "Resend in {seconds} seconds",
sendCodeFirst: "Please enter email address first",
backToLogin: "Back to Login",
title: 'Reset Password',
subtitle: 'Reset your password via email verification code',
resetButton: 'Reset Password',
resetSuccess: 'Password reset successful',
resetFailed: 'Reset failed',
emailPlaceholder: 'Please enter email address',
codePlaceholder: 'Please enter verification code',
newPasswordPlaceholder: 'Please enter new password',
confirmPasswordPlaceholder: 'Please enter new password again',
emailRule: 'Please enter a valid email address',
codeRule: 'Verification code must be 6 characters',
passwordRule: 'Password length between 6 to 20 characters',
sendCode: 'Send Code',
codeSent: 'Verification code has been sent to your email',
resendCode: 'Resend in {seconds} seconds',
sendCodeFirst: 'Please enter email address first',
backToLogin: 'Back to Login',
},
layout: {
toggleSidebar: "Toggle Sidebar",
collapse: "Collapse",
expand: "Expand",
logout: "Logout",
toggleSidebar: 'Toggle Sidebar',
collapse: 'Collapse',
expand: 'Expand',
logout: 'Logout',
},
table: {
total: "Total {total} items",
selected: "{selected} items selected",
actions: "Actions",
noData: "No Data",
sort: "Sort",
filter: "Filter",
total: 'Total {total} items',
selected: '{selected} items selected',
actions: 'Actions',
noData: 'No Data',
sort: 'Sort',
filter: 'Filter',
},
pagination: {
goTo: "Go to",
page: "Page",
total: "Total {total} items",
itemsPerPage: "{size} items per page",
goTo: 'Go to',
page: 'Page',
total: 'Total {total} items',
itemsPerPage: '{size} items per page',
},
form: {
required: "This field is required",
invalidEmail: "Please enter a valid email address",
invalidPhone: "Please enter a valid phone number",
passwordMismatch: "Passwords do not match",
minLength: "Minimum {min} characters required",
maxLength: "Maximum {max} characters allowed",
required: 'This field is required',
invalidEmail: 'Please enter a valid email address',
invalidPhone: 'Please enter a valid phone number',
passwordMismatch: 'Passwords do not match',
minLength: 'Minimum {min} characters required',
maxLength: 'Maximum {max} characters allowed',
},
};
}
+242 -243
View File
@@ -1,262 +1,261 @@
export default {
common: {
welcome: "欢迎使用",
login: "登录",
logout: "退出登录",
register: "注册",
searchMenu: "搜索菜单",
searchPlaceholder: "请输入菜单名称进行搜索",
noResults: "未找到匹配的菜单",
searchTips: "快捷键操作提示",
navigateResults: "使用上下键导航",
selectResult: "按回车键选择",
closeSearch: "按 ESC 关闭",
taskCenter: "任务中心",
totalTasks: "总任务",
pendingTasks: "待完成",
completedTasks: "已完成",
searchTasks: "搜索任务...",
all: "全部",
pending: "待完成",
completed: "已完成",
taskTitle: "任务标题",
enterTaskTitle: "请输入任务标题",
taskPriority: "任务优先级",
priorityHigh: "高",
priorityMedium: "中",
priorityLow: "低",
confirmDelete: "确认删除",
addTask: "添加任务",
pleaseEnterTaskTitle: "请输入任务标题",
added: "已添加",
deleted: "已删除",
justNow: "刚刚",
clearCache: "清除缓存",
confirmClearCache: "确认清除缓存",
clearCacheConfirm:
"确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。",
cacheCleared: "缓存已清除",
clearCacheFailed: "清除缓存失败",
messages: "消息",
tasks: "任务",
notification: "通知",
task: "任务",
warning: "警告",
markAllAsRead: "全部标为已读",
clearAll: "清空全部",
noMessages: "暂无消息",
noTasks: "暂无任务",
confirmClear: "确认清空",
confirmClearMessages: "确定要清空所有消息吗?",
markedAsRead: "已标记为已读",
realtimeConnected: "实时连接已建立",
realtimeDisconnected: "实时连接已断开",
realtimeError: "实时连接出现错误,正在重连...",
dataUpdated: "数据已更新",
dataCreated: "数据已创建",
dataDeleted: "数据已删除",
fullscreen: "全屏",
personalCenter: "个人中心",
systemSettings: "系统设置",
searchEmpty: "请输入搜索内容",
searching: "正在搜索:",
cleared: "已清空",
languageChanged: "语言已切换",
settingsDeveloping: "系统设置功能开发中",
logoutSuccess: "退出成功",
logoutFailed: "退出失败",
confirmLogout: "确认退出",
logoutConfirm: "确定要退出登录吗?",
username: "用户名",
password: "密码",
confirmPassword: "确认密码",
email: "邮箱",
phone: "手机号",
rememberMe: "记住我",
forgotPassword: "忘记密码?",
submit: "提交",
cancel: "取消",
save: "保存",
edit: "编辑",
delete: "删除",
add: "添加",
search: "搜索",
reset: "重置",
confirm: "确认",
back: "返回",
next: "下一步",
previous: "上一步",
refresh: "刷新",
export: "导出",
import: "导入",
download: "下载",
upload: "上传",
view: "查看",
detail: "详情",
settings: "设置",
profile: "个人资料",
language: "语言",
theme: "主题",
dark: "暗色",
light: "亮色",
loading: "加载中...",
noData: "暂无数据",
success: "操作成功",
error: "操作失败",
warning: "警告",
info: "提示",
confirmDelete: "确定要删除吗?",
confirmLogout: "确定要退出登录吗?",
addConfig: "添加配置",
editConfig: "编辑配置",
configCategory: "配置分类",
configName: "配置名称",
configTitle: "配置标题",
configType: "配置类型",
configValue: "配置值",
configTip: "配置提示",
typeText: "文本",
typeTextarea: "文本域",
typeNumber: "数字",
typeSwitch: "开关",
typeSelect: "下拉选择",
typeMultiselect: "多选",
typeDatetime: "日期时间",
typeColor: "颜色",
pleaseSelect: "请选择",
pleaseEnter: "请输入",
noConfig: "暂无配置",
fetchConfigFailed: "获取配置失败",
addSuccess: "添加成功",
addFailed: "添加失败",
editSuccess: "编辑成功",
editFailed: "编辑失败",
saveSuccess: "保存成功",
saveFailed: "保存失败",
resetSuccess: "重置成功",
required: "此项为必填项",
operation: "操作",
time: "时间",
status: "状态",
enabled: "启用",
disabled: "禁用",
yes: "是",
no: "否",
areaManage: "地区管理",
areaName: "地区名称",
areaCode: "地区编码",
areaLevel: "地区级别",
parentArea: "上级地区",
province: "省份",
city: "城市",
district: "区县",
street: "街道",
unknown: "未知",
addArea: "添加地区",
editArea: "编辑地区",
remark: "备注",
sort: "排序",
createTime: "创建时间",
action: "操作",
batchDelete: "批量删除",
confirmBatchDelete: "确认批量删除",
batchDeleteConfirm: "确定要删除选中的",
items: "条数据吗?",
deleteConfirm: "确定要删除",
selectDataFirst: "请先选择要操作的数据",
pleaseEnterNumber: "请输入有效的数字",
exitFullScreen: "退出全屏",
columns: "列设置",
columnSettings: "列显示设置",
selectAll: "全选",
unselectAll: "取消全选",
retry: "重试",
welcome: '欢迎使用',
login: '登录',
logout: '退出登录',
register: '注册',
searchMenu: '搜索菜单',
searchPlaceholder: '请输入菜单名称进行搜索',
noResults: '未找到匹配的菜单',
searchTips: '快捷键操作提示',
navigateResults: '使用上下键导航',
selectResult: '按回车键选择',
closeSearch: '按 ESC 关闭',
taskCenter: '任务中心',
totalTasks: '总任务',
pendingTasks: '待完成',
completedTasks: '已完成',
searchTasks: '搜索任务...',
all: '全部',
pending: '待完成',
completed: '已完成',
taskTitle: '任务标题',
enterTaskTitle: '请输入任务标题',
taskPriority: '任务优先级',
priorityHigh: '高',
priorityMedium: '中',
priorityLow: '低',
confirmDelete: '确认删除',
addTask: '添加任务',
pleaseEnterTaskTitle: '请输入任务标题',
added: '已添加',
deleted: '已删除',
justNow: '刚刚',
clearCache: '清除缓存',
confirmClearCache: '确认清除缓存',
clearCacheConfirm: '确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。',
cacheCleared: '缓存已清除',
clearCacheFailed: '清除缓存失败',
messages: '消息',
tasks: '任务',
notification: '通知',
task: '任务',
warning: '警告',
markAllAsRead: '全部标为已读',
clearAll: '清空全部',
noMessages: '暂无消息',
noTasks: '暂无任务',
confirmClear: '确认清空',
confirmClearMessages: '确定要清空所有消息吗?',
markedAsRead: '已标记为已读',
realtimeConnected: '实时连接已建立',
realtimeDisconnected: '实时连接已断开',
realtimeError: '实时连接出现错误,正在重连...',
dataUpdated: '数据已更新',
dataCreated: '数据已创建',
dataDeleted: '数据已删除',
fullscreen: '全屏',
personalCenter: '个人中心',
systemSettings: '系统设置',
searchEmpty: '请输入搜索内容',
searching: '正在搜索:',
cleared: '已清空',
languageChanged: '语言已切换',
settingsDeveloping: '系统设置功能开发中',
logoutSuccess: '退出成功',
logoutFailed: '退出失败',
confirmLogout: '确认退出',
logoutConfirm: '确定要退出登录吗?',
username: '用户名',
password: '密码',
confirmPassword: '确认密码',
email: '邮箱',
phone: '手机号',
rememberMe: '记住我',
forgotPassword: '忘记密码?',
submit: '提交',
cancel: '取消',
save: '保存',
edit: '编辑',
delete: '删除',
add: '添加',
search: '搜索',
reset: '重置',
confirm: '确认',
back: '返回',
next: '下一步',
previous: '上一步',
refresh: '刷新',
export: '导出',
import: '导入',
download: '下载',
upload: '上传',
view: '查看',
detail: '详情',
settings: '设置',
profile: '个人资料',
language: '语言',
theme: '主题',
dark: '暗色',
light: '亮色',
loading: '加载中...',
noData: '暂无数据',
success: '操作成功',
error: '操作失败',
warning: '警告',
info: '提示',
confirmDelete: '确定要删除吗?',
confirmLogout: '确定要退出登录吗?',
addConfig: '添加配置',
editConfig: '编辑配置',
configCategory: '配置分类',
configName: '配置名称',
configTitle: '配置标题',
configType: '配置类型',
configValue: '配置值',
configTip: '配置提示',
typeText: '文本',
typeTextarea: '文本域',
typeNumber: '数字',
typeSwitch: '开关',
typeSelect: '下拉选择',
typeMultiselect: '多选',
typeDatetime: '日期时间',
typeColor: '颜色',
pleaseSelect: '请选择',
pleaseEnter: '请输入',
noConfig: '暂无配置',
fetchConfigFailed: '获取配置失败',
addSuccess: '添加成功',
addFailed: '添加失败',
editSuccess: '编辑成功',
editFailed: '编辑失败',
saveSuccess: '保存成功',
saveFailed: '保存失败',
resetSuccess: '重置成功',
required: '此项为必填项',
operation: '操作',
time: '时间',
status: '状态',
enabled: '启用',
disabled: '禁用',
yes: '是',
no: '否',
areaManage: '地区管理',
areaName: '地区名称',
areaCode: '地区编码',
areaLevel: '地区级别',
parentArea: '上级地区',
province: '省份',
city: '城市',
district: '区县',
street: '街道',
unknown: '未知',
addArea: '添加地区',
editArea: '编辑地区',
remark: '备注',
sort: '排序',
createTime: '创建时间',
action: '操作',
batchDelete: '批量删除',
confirmBatchDelete: '确认批量删除',
batchDeleteConfirm: '确定要删除选中的',
items: '条数据吗?',
deleteConfirm: '确定要删除',
selectDataFirst: '请先选择要操作的数据',
pleaseEnterNumber: '请输入有效的数字',
exitFullScreen: '退出全屏',
columns: '列设置',
columnSettings: '列显示设置',
selectAll: '全选',
unselectAll: '取消全选',
retry: '重试',
},
menu: {
dashboard: "仪表板",
userManagement: "用户管理",
roleManagement: "角色管理",
permissionManagement: "权限管理",
systemSettings: "系统设置",
logManagement: "日志管理",
dashboard: '仪表板',
userManagement: '用户管理',
roleManagement: '角色管理',
permissionManagement: '权限管理',
systemSettings: '系统设置',
logManagement: '日志管理',
},
login: {
title: "用户登录",
subtitle: "欢迎回来,请登录您的账户",
loginButton: "登录",
loginSuccess: "登录成功",
loginFailed: "登录失败",
usernamePlaceholder: "请输入用户名",
passwordPlaceholder: "请输入密码",
noAccount: "还没有账户?",
registerNow: "立即注册",
forgotPassword: "忘记密码?",
rememberMe: "记住我",
title: '用户登录',
subtitle: '欢迎回来,请登录您的账户',
loginButton: '登录',
loginSuccess: '登录成功',
loginFailed: '登录失败',
usernamePlaceholder: '请输入用户名',
passwordPlaceholder: '请输入密码',
noAccount: '还没有账户?',
registerNow: '立即注册',
forgotPassword: '忘记密码?',
rememberMe: '记住我',
},
register: {
title: "用户注册",
subtitle: "创建您的账户,开始使用",
registerButton: "注册",
registerSuccess: "注册成功",
registerFailed: "注册失败",
usernamePlaceholder: "请输入用户名",
emailPlaceholder: "请输入邮箱地址",
passwordPlaceholder: "请输入密码",
confirmPasswordPlaceholder: "请再次输入密码",
usernameRule: "用户名长度在 3 到 20 个字符",
emailRule: "请输入正确的邮箱地址",
passwordRule: "密码长度在 6 到 20 个字符",
agreeRule: "请同意用户协议",
agreeTerms: "我已阅读并同意",
terms: "用户协议",
hasAccount: "已有账户?",
loginNow: "立即登录",
title: '用户注册',
subtitle: '创建您的账户,开始使用',
registerButton: '注册',
registerSuccess: '注册成功',
registerFailed: '注册失败',
usernamePlaceholder: '请输入用户名',
emailPlaceholder: '请输入邮箱地址',
passwordPlaceholder: '请输入密码',
confirmPasswordPlaceholder: '请再次输入密码',
usernameRule: '用户名长度在 3 到 20 个字符',
emailRule: '请输入正确的邮箱地址',
passwordRule: '密码长度在 6 到 20 个字符',
agreeRule: '请同意用户协议',
agreeTerms: '我已阅读并同意',
terms: '用户协议',
hasAccount: '已有账户?',
loginNow: '立即登录',
},
resetPassword: {
title: "重置密码",
subtitle: "通过邮箱验证码重置您的密码",
resetButton: "重置密码",
resetSuccess: "密码重置成功",
resetFailed: "重置失败",
emailPlaceholder: "请输入邮箱地址",
codePlaceholder: "请输入验证码",
newPasswordPlaceholder: "请输入新密码",
confirmPasswordPlaceholder: "请再次输入新密码",
emailRule: "请输入正确的邮箱地址",
codeRule: "验证码长度为6位",
passwordRule: "密码长度在 6 到 20 个字符",
sendCode: "发送验证码",
codeSent: "验证码已发送到您的邮箱",
resendCode: "{seconds}秒后重新发送",
sendCodeFirst: "请先输入邮箱地址",
backToLogin: "返回登录",
title: '重置密码',
subtitle: '通过邮箱验证码重置您的密码',
resetButton: '重置密码',
resetSuccess: '密码重置成功',
resetFailed: '重置失败',
emailPlaceholder: '请输入邮箱地址',
codePlaceholder: '请输入验证码',
newPasswordPlaceholder: '请输入新密码',
confirmPasswordPlaceholder: '请再次输入新密码',
emailRule: '请输入正确的邮箱地址',
codeRule: '验证码长度为6位',
passwordRule: '密码长度在 6 到 20 个字符',
sendCode: '发送验证码',
codeSent: '验证码已发送到您的邮箱',
resendCode: '{seconds}秒后重新发送',
sendCodeFirst: '请先输入邮箱地址',
backToLogin: '返回登录',
},
layout: {
toggleSidebar: "切换侧边栏",
collapse: "折叠",
expand: "展开",
logout: "退出登录",
toggleSidebar: '切换侧边栏',
collapse: '折叠',
expand: '展开',
logout: '退出登录',
},
table: {
total: "共 {total} 条",
selected: "已选择 {selected} 项",
actions: "操作",
noData: "暂无数据",
sort: "排序",
filter: "筛选",
total: '共 {total} 条',
selected: '已选择 {selected} 项',
actions: '操作',
noData: '暂无数据',
sort: '排序',
filter: '筛选',
},
pagination: {
goTo: "前往",
page: "页",
total: "共 {total} 条",
itemsPerPage: "每页 {size} 条",
goTo: '前往',
page: '页',
total: '共 {total} 条',
itemsPerPage: '每页 {size} 条',
},
form: {
required: "此项为必填项",
invalidEmail: "请输入有效的邮箱地址",
invalidPhone: "请输入有效的手机号",
passwordMismatch: "两次输入的密码不一致",
minLength: "最少需要 {min} 个字符",
maxLength: "最多允许 {max} 个字符",
required: '此项为必填项',
invalidEmail: '请输入有效的邮箱地址',
invalidPhone: '请输入有效的手机号',
passwordMismatch: '两次输入的密码不一致',
minLength: '最少需要 {min} 个字符',
maxLength: '最多允许 {max} 个字符',
},
};
}
@@ -1,13 +1,7 @@
<template>
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item
v-for="(item, index) in breadcrumbList"
:key="item.path"
>
<span
v-if="index === breadcrumbList.length - 1"
class="no-redirect"
>
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
<span v-if="index === breadcrumbList.length - 1" class="no-redirect">
<component :is="item.meta?.icon || 'FileTextOutlined'" />
{{ item.meta.title }}
</span>
@@ -20,47 +14,47 @@
</template>
<script setup>
import { ref, watch } from "vue";
import { useRoute } from "vue-router";
import config from "@/config";
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import config from '@/config'
// 定义组件名称(多词命名)
defineOptions({
name: "LayoutBreadcrumb",
});
name: 'LayoutBreadcrumb',
})
const route = useRoute();
const breadcrumbList = ref([]);
const route = useRoute()
const breadcrumbList = ref([])
// 获取面包屑列表
const getBreadcrumb = () => {
let matched = route.matched.filter((item) => item.meta && item.meta.title);
let matched = route.matched.filter((item) => item.meta && item.meta.title)
// 如果第一个不是首页,添加首页
const first = matched[0];
const first = matched[0]
if (first && first.path !== config.DASHBOARD_URL) {
matched = [
{
path: config.DASHBOARD_URL,
meta: { title: "", icon: "HomeOutlined" },
meta: { title: '', icon: 'HomeOutlined' },
},
].concat(matched);
].concat(matched)
}
breadcrumbList.value = matched;
};
breadcrumbList.value = matched
}
// 处理点击面包屑
const handleLink = () => {
return;
};
return
}
// 监听路由变化
watch(
() => route.path,
() => {
getBreadcrumb();
getBreadcrumb()
},
{ immediate: true },
);
)
</script>
@@ -1,27 +1,15 @@
<template>
<template v-for="item in menuItems" :key="item.path || item.name">
<!-- 有子菜单 - 使用递归 -->
<a-sub-menu
v-if="item.children && item.children.length > 0"
:key="`${item.path}`"
>
<a-sub-menu v-if="item.children && item.children.length > 0" :key="`${item.path}`">
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>
<template #title>{{ item.title || item.name }}</template>
<navMenu
:menu-items="item.children"
:active-path="activePath"
:parent-path="item.path"
/>
<navMenu :menu-items="item.children" :active-path="activePath" :parent-path="item.path" />
</a-sub-menu>
<!-- 无子菜单的菜单项 -->
<a-menu-item
v-else
:key="item.path"
:class="{ 'ant-menu-item-selected': item.path === activePath }"
@click="handleMenuClick(item)"
>
<a-menu-item v-else :key="item.path" :class="{ 'ant-menu-item-selected': item.path === activePath }" @click="handleMenuClick(item)">
<template #icon v-if="item.meta?.icon">
<component :is="getIconComponent(item.meta.icon)" />
</template>
@@ -31,8 +19,8 @@
</template>
<script setup>
import { useRouter } from "vue-router";
import * as icons from "@ant-design/icons-vue";
import { useRouter } from 'vue-router'
import * as icons from '@ant-design/icons-vue'
defineProps({
menuItems: {
@@ -41,25 +29,25 @@ defineProps({
},
activePath: {
type: String,
default: "",
default: '',
},
parentPath: {
type: String,
default: "",
default: '',
},
});
})
const router = useRouter();
const router = useRouter()
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined;
};
return icons[iconName] || icons.FileTextOutlined
}
// 处理菜单点击
const handleMenuClick = (item) => {
if (item.path) {
router.push(item.path);
router.push(item.path)
}
};
}
</script>
+69 -100
View File
@@ -1,36 +1,14 @@
<template>
<a-modal
v-model:open="visible"
:title="$t('common.searchMenu')"
:footer="null"
:width="600"
:destroyOnClose="true"
@cancel="handleClose"
>
<a-modal v-model:open="visible" :title="$t('common.searchMenu')" :footer="null" :width="600" :destroyOnClose="true" @cancel="handleClose">
<div class="menu-search">
<a-input
v-model:value="searchKeyword"
:placeholder="$t('common.searchPlaceholder')"
size="large"
allow-clear
@input="handleSearch"
@keydown="handleKeydown"
ref="searchInputRef"
>
<a-input v-model:value="searchKeyword" :placeholder="$t('common.searchPlaceholder')" size="large" allow-clear @input="handleSearch" @keydown="handleKeydown" ref="searchInputRef">
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<div v-if="searchResults.length > 0" class="search-results">
<div
v-for="(item, index) in searchResults"
:key="item.path"
class="result-item"
:class="{ active: selectedIndex === index }"
@click="handleSelect(item)"
@mouseenter="selectedIndex = index"
>
<div v-for="(item, index) in searchResults" :key="item.path" class="result-item" :class="{ active: selectedIndex === index }" @click="handleSelect(item)" @mouseenter="selectedIndex = index">
<div class="result-icon">
<component :is="item.icon || 'MenuOutlined'" />
</div>
@@ -48,20 +26,20 @@
</div>
<div v-else class="search-tips">
<div class="tip-title">{{ $t("common.searchTips") }}</div>
<div class="tip-title">{{ $t('common.searchTips') }}</div>
<div class="tip-list">
<div class="tip-item">
<kbd>↑</kbd>
<kbd>↓</kbd>
<span>{{ $t("common.navigateResults") }}</span>
<span>{{ $t('common.navigateResults') }}</span>
</div>
<div class="tip-item">
<kbd>Enter</kbd>
<span>{{ $t("common.selectResult") }}</span>
<span>{{ $t('common.selectResult') }}</span>
</div>
<div class="tip-item">
<kbd>Esc</kbd>
<span>{{ $t("common.closeSearch") }}</span>
<span>{{ $t('common.closeSearch') }}</span>
</div>
</div>
</div>
@@ -70,137 +48,128 @@
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue";
import { useRouter } from "vue-router";
import { SearchOutlined, MenuOutlined } from "@ant-design/icons-vue";
import { useUserStore } from "@/stores/modules/user";
import { useI18n } from "vue-i18n";
import { ref, computed, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { SearchOutlined, MenuOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18n } from 'vue-i18n'
// 定义组件名称
defineOptions({
name: "MenuSearch",
});
name: 'MenuSearch',
})
const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const visible = defineModel("visible", { type: Boolean, default: false });
const searchKeyword = ref("");
const searchResults = ref([]);
const selectedIndex = ref(0);
const searchInputRef = ref(null);
const visible = defineModel('visible', { type: Boolean, default: false })
const searchKeyword = ref('')
const searchResults = ref([])
const selectedIndex = ref(0)
const searchInputRef = ref(null)
// 将扁平化的菜单数据转换为可搜索格式
function flattenMenus(menus, breadcrumbs = []) {
const result = [];
const result = []
menus.forEach((menu) => {
if (menu.hidden) return;
if (menu.hidden) return
const currentBreadcrumbs = [...breadcrumbs, menu.title];
const currentBreadcrumbs = [...breadcrumbs, menu.title]
// 如果有路径且不是外部链接,添加到搜索结果
if (menu.path && !menu.path.startsWith("http")) {
if (menu.path && !menu.path.startsWith('http')) {
result.push({
title: menu.title,
path: menu.path,
icon: menu.icon,
breadcrumbs: currentBreadcrumbs.join(" / "),
});
breadcrumbs: currentBreadcrumbs.join(' / '),
})
}
// 递归处理子菜单
if (menu.children && menu.children.length > 0) {
const children = flattenMenus(menu.children, currentBreadcrumbs);
result.push(...children);
const children = flattenMenus(menu.children, currentBreadcrumbs)
result.push(...children)
}
});
})
return result;
return result
}
// 获取所有菜单项
const allMenus = computed(() => {
const menus = userStore.menu || [];
return flattenMenus(menus);
});
const menus = userStore.menu || []
return flattenMenus(menus)
})
// 执行搜索
function handleSearch() {
if (!searchKeyword.value.trim()) {
searchResults.value = [];
selectedIndex.value = 0;
return;
searchResults.value = []
selectedIndex.value = 0
return
}
const keyword = searchKeyword.value.toLowerCase().trim();
const keyword = searchKeyword.value.toLowerCase().trim()
searchResults.value = allMenus.value.filter((menu) => {
return (
menu.title.toLowerCase().includes(keyword) ||
menu.breadcrumbs.toLowerCase().includes(keyword)
);
});
return menu.title.toLowerCase().includes(keyword) || menu.breadcrumbs.toLowerCase().includes(keyword)
})
selectedIndex.value = 0;
selectedIndex.value = 0
}
// 键盘导航
function handleKeydown(e) {
if (!searchResults.value.length) return;
if (!searchResults.value.length) return
switch (e.key) {
case "ArrowUp":
e.preventDefault();
selectedIndex.value =
selectedIndex.value > 0
? selectedIndex.value - 1
: searchResults.value.length - 1;
break;
case "ArrowDown":
e.preventDefault();
selectedIndex.value =
selectedIndex.value < searchResults.value.length - 1
? selectedIndex.value + 1
: 0;
break;
case "Enter":
e.preventDefault();
case 'ArrowUp':
e.preventDefault()
selectedIndex.value = selectedIndex.value > 0 ? selectedIndex.value - 1 : searchResults.value.length - 1
break
case 'ArrowDown':
e.preventDefault()
selectedIndex.value = selectedIndex.value < searchResults.value.length - 1 ? selectedIndex.value + 1 : 0
break
case 'Enter':
e.preventDefault()
if (searchResults.value[selectedIndex.value]) {
handleSelect(searchResults.value[selectedIndex.value]);
handleSelect(searchResults.value[selectedIndex.value])
}
break;
case "Escape":
e.preventDefault();
handleClose();
break;
break
case 'Escape':
e.preventDefault()
handleClose()
break
}
}
// 选择菜单项
function handleSelect(item) {
visible.value = false;
router.push(item.path);
visible.value = false
router.push(item.path)
}
// 关闭搜索弹窗
function handleClose() {
visible.value = false;
searchKeyword.value = "";
searchResults.value = [];
selectedIndex.value = 0;
visible.value = false
searchKeyword.value = ''
searchResults.value = []
selectedIndex.value = 0
}
// 监听弹窗显示,自动聚焦输入框
watch(visible, (newVal) => {
if (newVal) {
nextTick(() => {
searchInputRef.value?.focus();
});
searchInputRef.value?.focus()
})
} else {
handleClose();
handleClose()
}
});
})
</script>
<style scoped lang="scss">
@@ -1,10 +1,5 @@
<template>
<a-drawer
v-model:open="open"
title="布局配置"
placement="right"
:width="420"
>
<a-drawer v-model:open="open" title="布局配置" placement="right" :width="420">
<div class="setting-content">
<div class="setting-item">
<div class="setting-title">布局模式</div>
@@ -18,25 +13,16 @@
}"
@click="handleLayoutChange(mode.value)"
>
<div
class="layout-preview"
:class="`preview-${mode.value}`"
>
<div class="layout-preview" :class="`preview-${mode.value}`">
<div class="preview-sidebar"></div>
<div
v-if="mode.value === 'default'"
class="preview-sidebar-2"
></div>
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
<div class="preview-content">
<div class="preview-header"></div>
<div class="preview-body"></div>
</div>
</div>
<div class="layout-name">{{ mode.label }}</div>
<CheckOutlined
v-if="layoutStore.layoutMode === mode.value"
class="check-icon"
/>
<CheckOutlined v-if="layoutStore.layoutMode === mode.value" class="check-icon" />
</div>
</div>
</div>
@@ -44,14 +30,7 @@
<div class="setting-item">
<div class="setting-title">主题颜色</div>
<div class="color-list">
<div
v-for="color in themeColors"
:key="color"
class="color-item"
:class="{ active: themeColor === color }"
:style="{ backgroundColor: color }"
@click="changeThemeColor(color)"
>
<div v-for="color in themeColors" :key="color" class="color-item" :class="{ active: themeColor === color }" :style="{ backgroundColor: color }" @click="changeThemeColor(color)">
<CheckOutlined v-if="themeColor === color" />
</div>
</div>
@@ -62,17 +41,11 @@
<div class="toggle-list">
<div class="toggle-item">
<span>显示标签栏</span>
<a-switch
v-model:checked="showTags"
@change="handleShowTagsChange"
/>
<a-switch v-model:checked="showTags" @change="handleShowTagsChange" />
</div>
<div class="toggle-item">
<span>显示面包屑</span>
<a-switch
v-model:checked="showBreadcrumb"
@change="handleShowBreadcrumbChange"
/>
<a-switch v-model:checked="showBreadcrumb" @change="handleShowBreadcrumbChange" />
</div>
</div>
</div>
@@ -91,126 +64,108 @@
</template>
<script setup>
import { ref, watch, onMounted } from "vue";
import { message } from "ant-design-vue";
import { useLayoutStore } from "@/stores/modules/layout";
import { CheckOutlined, ReloadOutlined } from "@ant-design/icons-vue";
import { ref, watch, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { useLayoutStore } from '@/stores/modules/layout'
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
// 定义组件名称(多词命名)
defineOptions({
name: "LayoutSetting",
});
name: 'LayoutSetting',
})
const layoutStore = useLayoutStore();
const layoutStore = useLayoutStore()
const open = ref(false);
const themeColor = ref("#1890ff");
const showTags = ref(true);
const showBreadcrumb = ref(true);
const open = ref(false)
const themeColor = ref('#1890ff')
const showTags = ref(true)
const showBreadcrumb = ref(true)
const layoutModes = [
{ value: "default", label: "默认布局" },
{ value: "menu", label: "菜单布局" },
{ value: "top", label: "顶部布局" },
];
{ value: 'default', label: '默认布局' },
{ value: 'menu', label: '菜单布局' },
{ value: 'top', label: '顶部布局' },
]
const themeColors = [
"#1890ff",
"#f5222d",
"#fa541c",
"#faad14",
"#13c2c2",
"#52c41a",
"#2f54eb",
"#722ed1",
];
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
const openDrawer = () => {
open.value = true;
};
open.value = true
}
const closeDrawer = () => {
open.value = false;
};
open.value = false
}
defineExpose({
openDrawer,
closeDrawer,
});
})
// 切换布局
const handleLayoutChange = (mode) => {
layoutStore.setLayoutMode(mode);
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode;
message.success(`已切换到${modeLabel}`);
};
layoutStore.setLayoutMode(mode)
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
message.success(`已切换到${modeLabel}`)
}
// 切换主题颜色
const changeThemeColor = (color) => {
themeColor.value = color;
themeColor.value = color
// 更新 CSS 变量
document.documentElement.style.setProperty("--primary-color", color);
message.success("主题颜色已更新");
};
document.documentElement.style.setProperty('--primary-color', color)
message.success('主题颜色已更新')
}
// 切换标签栏显示
const handleShowTagsChange = (checked) => {
showTags.value = checked;
showTags.value = checked
// 触发自定义事件或更新状态
document.documentElement.style.setProperty(
"--show-tags",
checked ? "block" : "none",
);
message.success(checked ? "标签栏已显示" : "标签栏已隐藏");
};
document.documentElement.style.setProperty('--show-tags', checked ? 'block' : 'none')
message.success(checked ? '标签栏已显示' : '标签栏已隐藏')
}
// 切换面包屑显示
const handleShowBreadcrumbChange = (checked) => {
showBreadcrumb.value = checked;
message.success(checked ? "面包屑已显示" : "面包屑已隐藏");
};
showBreadcrumb.value = checked
message.success(checked ? '面包屑已显示' : '面包屑已隐藏')
}
// 重置设置
const handleResetSettings = () => {
themeColor.value = "#1890ff";
showTags.value = true;
showBreadcrumb.value = true;
layoutStore.setLayoutMode("default");
document.documentElement.style.setProperty("--primary-color", "#1890ff");
document.documentElement.style.setProperty("--show-tags", "block");
message.success("设置已重置");
};
themeColor.value = '#1890ff'
showTags.value = true
showBreadcrumb.value = true
layoutStore.setLayoutMode('default')
document.documentElement.style.setProperty('--primary-color', '#1890ff')
document.documentElement.style.setProperty('--show-tags', 'block')
message.success('设置已重置')
}
// 初始化
onMounted(() => {
// 从本地存储或其他地方恢复设置
const savedThemeColor = localStorage.getItem("themeColor");
const savedThemeColor = localStorage.getItem('themeColor')
if (savedThemeColor) {
themeColor.value = savedThemeColor;
document.documentElement.style.setProperty(
"--primary-color",
savedThemeColor,
);
themeColor.value = savedThemeColor
document.documentElement.style.setProperty('--primary-color', savedThemeColor)
}
const savedShowTags = localStorage.getItem("showTags");
const savedShowTags = localStorage.getItem('showTags')
if (savedShowTags !== null) {
showTags.value = savedShowTags === "true";
document.documentElement.style.setProperty(
"--show-tags",
savedShowTags === "true" ? "block" : "none",
);
showTags.value = savedShowTags === 'true'
document.documentElement.style.setProperty('--show-tags', savedShowTags === 'true' ? 'block' : 'none')
}
});
})
// 监听设置变化并保存到本地存储
watch(themeColor, (newVal) => {
localStorage.setItem("themeColor", newVal);
});
localStorage.setItem('themeColor', newVal)
})
watch(showTags, (newVal) => {
localStorage.setItem("showTags", String(newVal));
});
localStorage.setItem('showTags', String(newVal))
})
</script>
<style scoped lang="scss">
@@ -1,57 +1,26 @@
<template>
<a-menu
mode="inline"
:theme="theme"
:collapsed="collapsed"
:selected-keys="selectedKeys"
:open-keys="openKeys"
@select="handleSelect"
@open-change="handleOpenChange"
class="side-menu"
>
<a-menu mode="inline" :theme="theme" :collapsed="collapsed" :selected-keys="selectedKeys" :open-keys="openKeys" @select="handleSelect" @open-change="handleOpenChange" class="side-menu">
<template v-for="item in menuList">
<!-- 有子菜单 -->
<a-sub-menu
v-if="item.children && item.children.length > 0"
:key="item.path + '-submenu'"
>
<a-sub-menu v-if="item.children && item.children.length > 0" :key="item.path + '-submenu'">
<template #icon>
<component :is="item.meta?.icon || 'MenuOutlined'" />
</template>
<template #title>{{ item.meta?.title || item.name }}</template>
<a-menu-item
v-for="child in item.children.filter(
(sub) => !sub.children || sub.children.length === 0,
)"
:key="child.path"
>
<a-menu-item v-for="child in item.children.filter((sub) => !sub.children || sub.children.length === 0)" :key="child.path">
<template #icon>
<component :is="child.meta?.icon || 'FileOutlined'" />
</template>
{{ child.meta?.title || child.name }}
</a-menu-item>
<a-sub-menu
v-for="child in item.children.filter(
(sub) => sub.children && sub.children.length > 0,
)"
:key="child.path"
>
<a-sub-menu v-for="child in item.children.filter((sub) => sub.children && sub.children.length > 0)" :key="child.path">
<template #icon>
<component
:is="child.meta?.icon || 'AppstoreOutlined'"
/>
<component :is="child.meta?.icon || 'AppstoreOutlined'" />
</template>
<template #title>{{
child.meta?.title || child.name
}}</template>
<a-menu-item
v-for="grandChild in child.children"
:key="grandChild.path"
>
<template #title>{{ child.meta?.title || child.name }}</template>
<a-menu-item v-for="grandChild in child.children" :key="grandChild.path">
<template #icon>
<component
:is="grandChild.meta?.icon || 'FileOutlined'"
/>
<component :is="grandChild.meta?.icon || 'FileOutlined'" />
</template>
{{ grandChild.meta?.title || grandChild.name }}
</a-menu-item>
@@ -69,9 +38,9 @@
</template>
<script setup>
import { ref, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { getUserMenu } from "@/api/menu";
import { ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getUserMenu } from '@/api/menu'
const props = defineProps({
collapsed: {
@@ -80,108 +49,106 @@ const props = defineProps({
},
theme: {
type: String,
default: "light",
default: 'light',
},
});
})
const route = useRoute();
const router = useRouter();
const route = useRoute()
const router = useRouter()
const menuList = ref([]);
const selectedKeys = ref([]);
const openKeys = ref([]);
const menuList = ref([])
const selectedKeys = ref([])
const openKeys = ref([])
// 获取菜单数据
const getMenuList = async () => {
try {
const res = await getUserMenu();
const res = await getUserMenu()
if (res.code === 200) {
menuList.value = res.data || [];
menuList.value = res.data || []
}
} catch (error) {
console.error("获取菜单失败:", error);
console.error('获取菜单失败:', error)
// 模拟数据
menuList.value = [
{
path: "/home",
name: "Home",
meta: { title: "首页", icon: "HomeOutlined" },
path: '/home',
name: 'Home',
meta: { title: '首页', icon: 'HomeOutlined' },
},
{
path: "/system",
name: "System",
meta: { title: "系统管理", icon: "SettingOutlined" },
path: '/system',
name: 'System',
meta: { title: '系统管理', icon: 'SettingOutlined' },
children: [
{
path: "/system/user",
name: "User",
meta: { title: "用户管理", icon: "UserOutlined" },
path: '/system/user',
name: 'User',
meta: { title: '用户管理', icon: 'UserOutlined' },
},
{
path: "/system/role",
name: "Role",
meta: { title: "角色管理", icon: "TeamOutlined" },
path: '/system/role',
name: 'Role',
meta: { title: '角色管理', icon: 'TeamOutlined' },
},
{
path: "/system/menu",
name: "Menu",
meta: { title: "菜单管理", icon: "MenuOutlined" },
path: '/system/menu',
name: 'Menu',
meta: { title: '菜单管理', icon: 'MenuOutlined' },
},
],
},
];
]
}
};
}
// 更新选中的菜单
const updateSelectedKeys = () => {
selectedKeys.value = [route.path];
selectedKeys.value = [route.path]
// 获取父级菜单路径
const matched = route.matched
.filter((item) => item.path !== "/" && item.path !== route.path)
.map((item) => item.path);
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path).map((item) => item.path)
// 折叠时不自动展开
if (!props.collapsed) {
openKeys.value = matched;
openKeys.value = matched
}
};
}
// 处理菜单选择
const handleSelect = ({ key }) => {
router.push(key);
};
router.push(key)
}
// 处理菜单展开/收起
const handleOpenChange = (keys) => {
openKeys.value = keys;
};
openKeys.value = keys
}
// 监听路由变化
watch(
() => route.path,
() => {
updateSelectedKeys();
updateSelectedKeys()
},
{ immediate: true },
);
)
// 监听折叠状态
watch(
() => props.collapsed,
(val) => {
if (val) {
openKeys.value = [];
openKeys.value = []
} else {
updateSelectedKeys();
updateSelectedKeys()
}
},
);
)
onMounted(() => {
getMenuList();
});
getMenuList()
})
</script>
<style scoped lang="scss">
+111 -129
View File
@@ -24,11 +24,7 @@
</div>
<div class="tags-actions">
<a-dropdown
v-model:open="actionMenuVisible"
trigger="click"
placement="bottomRight"
>
<a-dropdown v-model:open="actionMenuVisible" trigger="click" placement="bottomRight">
<a-button size="small" type="text">
<MoreOutlined />
</a-button>
@@ -77,10 +73,7 @@
<ReloadOutlined />
<span>刷新</span>
</a-menu-item>
<a-menu-item
v-if="selectedTag && !selectedTag.meta?.affix"
key="close"
>
<a-menu-item v-if="selectedTag && !selectedTag.meta?.affix" key="close">
<CloseOutlined />
<span>关闭</span>
</a-menu-item>
@@ -99,39 +92,39 @@
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLayoutStore } from "@/stores/modules/layout";
import config from "@/config";
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import config from '@/config'
defineOptions({
name: "TagsView",
});
name: 'TagsView',
})
const route = useRoute();
const router = useRouter();
const layoutStore = useLayoutStore();
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const showTags = ref(true);
const selectedTag = ref(null);
const visitedViews = computed(() => layoutStore.viewTags);
const showTags = ref(true)
const selectedTag = ref(null)
const visitedViews = computed(() => layoutStore.viewTags)
// 右键菜单状态
const contextMenu = ref({
visible: false,
x: 0,
y: 0,
});
})
// 顶部操作菜单状态
const actionMenuVisible = ref(false);
const actionMenuVisible = ref(false)
// 判断是否是当前激活的标签
const isActive = (tag) => {
return tag.fullPath === route.fullPath;
};
return tag.fullPath === route.fullPath
}
// 添加标签
const addTags = () => {
const { name } = route;
const { name } = route
if (name && !route.meta?.noCache) {
layoutStore.updateViewTags({
fullPath: route.fullPath,
@@ -140,231 +133,220 @@ const addTags = () => {
query: route.query,
params: route.params,
meta: route.meta,
});
})
}
};
}
// 移除标签
const closeSelectedTag = (view) => {
// 如果是固定标签,不允许关闭
if (view.meta?.affix) {
return;
return
}
layoutStore.removeViewTags(view.fullPath);
layoutStore.removeViewTags(view.fullPath)
// 如果关闭的是当前激活的标签,需要跳转
if (isActive(view)) {
const nextTag = visitedViews.value.find(
(tag) => tag.fullPath !== view.fullPath,
);
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
if (nextTag) {
router.push(nextTag.fullPath);
router.push(nextTag.fullPath)
} else {
// 如果没有其他标签,跳转到首页
router.push(config.DASHBOARD_URL);
router.push(config.DASHBOARD_URL)
}
}
};
}
// 关闭其他标签
const closeOthersTags = () => {
if (!selectedTag.value || !selectedTag.value.fullPath) {
return;
return
}
// 保留固定标签和当前选中的标签
const tagsToKeep = visitedViews.value.filter(
(tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath,
);
const tagsToKeep = visitedViews.value.filter((tag) => tag.meta?.affix || tag.fullPath === selectedTag.value.fullPath)
// 更新标签列表
layoutStore.viewTags = tagsToKeep;
layoutStore.viewTags = tagsToKeep
// 如果当前不在选中的标签页,跳转到选中的标签
if (!isActive(selectedTag.value)) {
router.push(selectedTag.value.fullPath);
router.push(selectedTag.value.fullPath)
}
};
}
// 关闭所有标签
const closeAllTags = () => {
// 只保留固定标签
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix);
layoutStore.viewTags = affixTags;
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
layoutStore.viewTags = affixTags
// 如果还有固定标签,跳转到第一个固定标签
if (affixTags.length > 0) {
router.push(affixTags[0].fullPath);
router.push(affixTags[0].fullPath)
} else {
// 如果没有固定标签,跳转到首页
router.push(config.DASHBOARD_URL);
router.push(config.DASHBOARD_URL)
}
};
}
// 关闭左侧标签
const closeLeftTags = () => {
const currentTag =
selectedTag.value || visitedViews.value.find((tag) => isActive(tag));
if (!currentTag) return;
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentIndex = visitedViews.value.findIndex(
(tag) => tag.fullPath === currentTag.fullPath,
);
if (currentIndex === -1) return;
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
// 保留当前标签及其右侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index >= currentIndex;
});
return tag.meta?.affix || index >= currentIndex
})
layoutStore.viewTags = tagsToKeep;
};
layoutStore.viewTags = tagsToKeep
}
// 关闭右侧标签
const closeRightTags = () => {
const currentTag =
selectedTag.value || visitedViews.value.find((tag) => isActive(tag));
if (!currentTag) return;
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
if (!currentTag) return
const currentIndex = visitedViews.value.findIndex(
(tag) => tag.fullPath === currentTag.fullPath,
);
if (currentIndex === -1) return;
const currentIndex = visitedViews.value.findIndex((tag) => tag.fullPath === currentTag.fullPath)
if (currentIndex === -1) return
// 保留当前标签及其左侧的标签,以及所有固定标签
const tagsToKeep = visitedViews.value.filter((tag, index) => {
return tag.meta?.affix || index <= currentIndex;
});
return tag.meta?.affix || index <= currentIndex
})
layoutStore.viewTags = tagsToKeep;
};
layoutStore.viewTags = tagsToKeep
}
// 点击标签
const clickTag = (tag) => {
if (!isActive(tag)) {
router.push(tag.fullPath);
router.push(tag.fullPath)
}
};
}
// 刷新指定标签
const refreshTag = (tag) => {
// 如果刷新的是当前激活的标签
if (isActive(tag)) {
// 调用 store 的刷新方法,触发组件重新渲染
layoutStore.refreshTag();
layoutStore.refreshTag()
} else {
// 如果刷新的是其他标签,先跳转到该标签
router.push(tag.fullPath);
router.push(tag.fullPath)
}
};
}
// 刷新当前选中的标签(用于顶部操作按钮)
const refreshSelectedTag = () => {
// 找到当前激活的标签
const currentTag = visitedViews.value.find((tag) => isActive(tag));
const currentTag = visitedViews.value.find((tag) => isActive(tag))
if (currentTag) {
refreshTag(currentTag);
refreshTag(currentTag)
}
};
}
// 右键菜单处理
const handleContextMenu = (event, tag) => {
event.preventDefault();
event.stopPropagation();
event.preventDefault()
event.stopPropagation()
selectedTag.value = tag;
selectedTag.value = tag
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
};
};
}
}
// 关闭右键菜单
const closeContextMenu = () => {
contextMenu.value.visible = false;
};
contextMenu.value.visible = false
}
// 菜单点击处理
const handleMenuClick = ({ key }) => {
switch (key) {
case "refresh":
case 'refresh':
if (selectedTag.value) {
refreshTag(selectedTag.value);
refreshTag(selectedTag.value)
}
break;
case "close":
break
case 'close':
if (selectedTag.value && !selectedTag.value.meta?.affix) {
closeSelectedTag(selectedTag.value);
closeSelectedTag(selectedTag.value)
}
break;
case "closeOthers":
closeOthersTags();
break;
case "closeAll":
closeAllTags();
break;
break
case 'closeOthers':
closeOthersTags()
break
case 'closeAll':
closeAllTags()
break
}
closeContextMenu();
};
closeContextMenu()
}
// 顶部操作菜单点击处理
const handleActionMenuClick = ({ key }) => {
switch (key) {
case "refresh":
refreshSelectedTag();
break;
case "closeOthers":
closeOthersTags();
break;
case "closeLeft":
closeLeftTags();
break;
case "closeRight":
closeRightTags();
break;
case "closeAll":
closeAllTags();
break;
case 'refresh':
refreshSelectedTag()
break
case 'closeOthers':
closeOthersTags()
break
case 'closeLeft':
closeLeftTags()
break
case 'closeRight':
closeRightTags()
break
case 'closeAll':
closeAllTags()
break
}
actionMenuVisible.value = false;
};
actionMenuVisible.value = false
}
// 点击其他地方关闭右键菜单
const handleClickOutside = (event) => {
if (contextMenu.value.visible) {
const menuElement = document.querySelector(".context-menu");
const menuElement = document.querySelector('.context-menu')
if (menuElement && !menuElement.contains(event.target)) {
closeContextMenu();
closeContextMenu()
}
}
};
}
// 监听路由变化,自动添加标签
watch(
() => route.fullPath,
() => {
addTags();
addTags()
// 更新当前选中的标签
selectedTag.value =
visitedViews.value.find((tag) => isActive(tag)) || null;
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
},
{ immediate: true },
);
)
onMounted(() => {
addTags();
addTags()
// 初始化选中的标签
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null;
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
// 添加点击事件监听器
document.addEventListener("click", handleClickOutside);
});
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
// 移除点击事件监听器
document.removeEventListener("click", handleClickOutside);
});
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped lang="scss">
+77 -145
View File
@@ -1,22 +1,16 @@
<template>
<a-drawer
v-model:open="visible"
:title="$t('common.taskCenter')"
placement="right"
:width="400"
:destroyOnClose="true"
>
<a-drawer v-model:open="visible" :title="$t('common.taskCenter')" placement="right" :width="400" :destroyOnClose="true">
<div class="task-drawer">
<!-- 任务统计 -->
<div class="task-stats">
<div class="stat-item">
<div class="stat-number">{{ totalTasks }}</div>
<div class="stat-label">{{ $t("common.totalTasks") }}</div>
<div class="stat-label">{{ $t('common.totalTasks') }}</div>
</div>
<div class="stat-item">
<div class="stat-number pending">{{ pendingTasks }}</div>
<div class="stat-label">
{{ $t("common.pendingTasks") }}
{{ $t('common.pendingTasks') }}
</div>
</div>
<div class="stat-item">
@@ -24,46 +18,27 @@
{{ completedTasks }}
</div>
<div class="stat-label">
{{ $t("common.completedTasks") }}
{{ $t('common.completedTasks') }}
</div>
</div>
</div>
<!-- 操作栏 -->
<div class="task-actions">
<a-input
v-model:value="searchKeyword"
:placeholder="$t('common.searchTasks')"
allow-clear
@input="handleSearch"
>
<a-input v-model:value="searchKeyword" :placeholder="$t('common.searchTasks')" allow-clear @input="handleSearch">
<template #prefix>
<SearchOutlined />
</template>
</a-input>
<div class="filter-buttons">
<a-button
:type="filterType === 'all' ? 'primary' : 'default'"
size="small"
@click="setFilter('all')"
>
{{ $t("common.all") }}
<a-button :type="filterType === 'all' ? 'primary' : 'default'" size="small" @click="setFilter('all')">
{{ $t('common.all') }}
</a-button>
<a-button
:type="filterType === 'pending' ? 'primary' : 'default'"
size="small"
@click="setFilter('pending')"
>
{{ $t("common.pending") }}
<a-button :type="filterType === 'pending' ? 'primary' : 'default'" size="small" @click="setFilter('pending')">
{{ $t('common.pending') }}
</a-button>
<a-button
:type="
filterType === 'completed' ? 'primary' : 'default'
"
size="small"
@click="setFilter('completed')"
>
{{ $t("common.completed") }}
<a-button :type="filterType === 'completed' ? 'primary' : 'default'" size="small" @click="setFilter('completed')">
{{ $t('common.completed') }}
</a-button>
</div>
</div>
@@ -71,41 +46,21 @@
<!-- 任务列表 -->
<div class="task-list">
<div v-if="filteredTasks.length > 0">
<div
v-for="task in filteredTasks"
:key="task.id"
class="task-item"
:class="{ completed: task.completed }"
>
<div v-for="task in filteredTasks" :key="task.id" class="task-item" :class="{ completed: task.completed }">
<div class="task-checkbox">
<a-checkbox
:checked="task.completed"
@change="toggleTask(task)"
/>
<a-checkbox :checked="task.completed" @change="toggleTask(task)" />
</div>
<div class="task-content">
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">
<span
class="task-priority"
:class="task.priority"
>
{{
$t(
`common.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`,
)
}}
<span class="task-priority" :class="task.priority">
{{ $t(`common.priority${task.priority.charAt(0).toUpperCase() + task.priority.slice(1)}`) }}
</span>
<span class="task-time">{{ task.time }}</span>
</div>
</div>
<div class="task-actions">
<a-popconfirm
:title="$t('common.confirmDelete')"
:ok-text="$t('common.confirm')"
:cancel-text="$t('common.cancel')"
@confirm="deleteTask(task.id)"
>
<a-popconfirm :title="$t('common.confirmDelete')" :ok-text="$t('common.confirm')" :cancel-text="$t('common.cancel')" @confirm="deleteTask(task.id)">
<DeleteOutlined class="action-icon" />
</a-popconfirm>
</div>
@@ -118,40 +73,25 @@
<div class="drawer-footer">
<a-button @click="showAddTask">
<PlusOutlined />
{{ $t("common.addTask") }}
{{ $t('common.addTask') }}
</a-button>
<a-button danger @click="clearAllTasks">
{{ $t("common.clearAll") }}
{{ $t('common.clearAll') }}
</a-button>
</div>
</div>
<!-- 添加任务弹窗 -->
<a-modal
v-model:open="addTaskVisible"
:title="$t('common.addTask')"
:ok-text="$t('common.confirm')"
:cancel-text="$t('common.cancel')"
@ok="confirmAddTask"
>
<a-modal v-model:open="addTaskVisible" :title="$t('common.addTask')" :ok-text="$t('common.confirm')" :cancel-text="$t('common.cancel')" @ok="confirmAddTask">
<a-form layout="vertical">
<a-form-item :label="$t('common.taskTitle')">
<a-input
v-model:value="newTask.title"
:placeholder="$t('common.enterTaskTitle')"
/>
<a-input v-model:value="newTask.title" :placeholder="$t('common.enterTaskTitle')" />
</a-form-item>
<a-form-item :label="$t('common.taskPriority')">
<a-select v-model:value="newTask.priority">
<a-select-option value="low">{{
$t("common.priorityLow")
}}</a-select-option>
<a-select-option value="medium">{{
$t("common.priorityMedium")
}}</a-select-option>
<a-select-option value="high">{{
$t("common.priorityHigh")
}}</a-select-option>
<a-select-option value="low">{{ $t('common.priorityLow') }}</a-select-option>
<a-select-option value="medium">{{ $t('common.priorityMedium') }}</a-select-option>
<a-select-option value="high">{{ $t('common.priorityHigh') }}</a-select-option>
</a-select>
</a-form-item>
</a-form>
@@ -160,101 +100,93 @@
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import {
SearchOutlined,
DeleteOutlined,
PlusOutlined,
} from "@ant-design/icons-vue";
import { useI18n } from "vue-i18n";
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
// 定义组件名称
defineOptions({
name: "TaskDrawer",
});
name: 'TaskDrawer',
})
const { t } = useI18n();
const { t } = useI18n()
const visible = defineModel("visible", { type: Boolean, default: false });
const visible = defineModel('visible', { type: Boolean, default: false })
const tasks = defineModel("tasks", { type: Array, default: () => [] });
const tasks = defineModel('tasks', { type: Array, default: () => [] })
// 搜索关键词
const searchKeyword = ref("");
const searchKeyword = ref('')
// 筛选类型:all, pending, completed
const filterType = ref("all");
const filterType = ref('all')
// 添加任务弹窗
const addTaskVisible = ref(false);
const addTaskVisible = ref(false)
const newTask = ref({
title: "",
priority: "medium",
});
title: '',
priority: 'medium',
})
// 统计数据
const totalTasks = computed(() => tasks.value.length);
const pendingTasks = computed(
() => tasks.value.filter((t) => !t.completed).length,
);
const completedTasks = computed(
() => tasks.value.filter((t) => t.completed).length,
);
const totalTasks = computed(() => tasks.value.length)
const pendingTasks = computed(() => tasks.value.filter((t) => !t.completed).length)
const completedTasks = computed(() => tasks.value.filter((t) => t.completed).length)
// 筛选后的任务列表
const filteredTasks = computed(() => {
let result = [...tasks.value];
let result = [...tasks.value]
// 按状态筛选
if (filterType.value === "pending") {
result = result.filter((t) => !t.completed);
} else if (filterType.value === "completed") {
result = result.filter((t) => t.completed);
if (filterType.value === 'pending') {
result = result.filter((t) => !t.completed)
} else if (filterType.value === 'completed') {
result = result.filter((t) => t.completed)
}
// 按关键词搜索
if (searchKeyword.value.trim()) {
const keyword = searchKeyword.value.toLowerCase();
result = result.filter((t) => t.title.toLowerCase().includes(keyword));
const keyword = searchKeyword.value.toLowerCase()
result = result.filter((t) => t.title.toLowerCase().includes(keyword))
}
return result;
});
return result
})
// 切换任务状态
const toggleTask = (task) => {
task.completed = !task.completed;
};
task.completed = !task.completed
}
// 删除任务
const deleteTask = (id) => {
const index = tasks.value.findIndex((t) => t.id === id);
const index = tasks.value.findIndex((t) => t.id === id)
if (index > -1) {
tasks.value.splice(index, 1);
message.success(t("common.deleted"));
tasks.value.splice(index, 1)
message.success(t('common.deleted'))
}
};
}
// 清空所有任务
const clearAllTasks = () => {
tasks.value = [];
message.success(t("common.cleared"));
};
tasks.value = []
message.success(t('common.cleared'))
}
// 显示添加任务弹窗
const showAddTask = () => {
newTask.value = {
title: "",
priority: "medium",
};
addTaskVisible.value = true;
};
title: '',
priority: 'medium',
}
addTaskVisible.value = true
}
// 确认添加任务
const confirmAddTask = () => {
if (!newTask.value.title.trim()) {
message.warning(t("common.pleaseEnterTaskTitle"));
return;
message.warning(t('common.pleaseEnterTaskTitle'))
return
}
tasks.value.unshift({
@@ -262,30 +194,30 @@ const confirmAddTask = () => {
title: newTask.value.title,
priority: newTask.value.priority,
completed: false,
time: t("common.justNow"),
});
time: t('common.justNow'),
})
addTaskVisible.value = false;
message.success(t("common.added"));
};
addTaskVisible.value = false
message.success(t('common.added'))
}
// 设置筛选类型
const setFilter = (type) => {
filterType.value = type;
};
filterType.value = type
}
// 搜索处理
const handleSearch = () => {
// 搜索逻辑在 computed 中自动处理
};
}
// 监听抽窗关闭,重置搜索和筛选
watch(visible, (newVal) => {
if (!newVal) {
searchKeyword.value = "";
filterType.value = "all";
searchKeyword.value = ''
filterType.value = 'all'
}
});
})
</script>
<style scoped lang="scss">
+189 -287
View File
@@ -8,12 +8,7 @@
</a-tooltip>
<!-- 消息通知 -->
<a-dropdown
v-model:open="messageVisible"
:trigger="['click']"
placement="bottomRight"
@openChange="handleMessageDropdownOpen"
>
<a-dropdown v-model:open="messageVisible" :trigger="['click']" placement="bottomRight" @openChange="handleMessageDropdownOpen">
<a-badge :count="messageCount" :offset="[-5, 5]">
<a-button type="text" class="action-btn">
<BellOutlined />
@@ -23,22 +18,13 @@
<a-card class="dropdown-card" :bordered="false">
<template #title>
<div class="message-header">
<span>{{ $t("common.messages") }}</span>
<span>{{ $t('common.messages') }}</span>
<a-space size="small">
<a-button
v-if="messageCount > 0"
type="link"
size="small"
@click="markAllAsRead"
>
{{ $t("common.markAllAsRead") }}
<a-button v-if="messageCount > 0" type="link" size="small" @click="markAllAsRead">
{{ $t('common.markAllAsRead') }}
</a-button>
<a-button
type="link"
size="small"
@click="clearMessages"
>
{{ $t("common.clearAll") }}
<a-button type="link" size="small" @click="clearMessages">
{{ $t('common.clearAll') }}
</a-button>
</a-space>
</div>
@@ -46,74 +32,35 @@
<!-- 消息类型筛选 -->
<div class="message-tabs">
<a-tabs
v-model:activeKey="currentMessageType"
size="small"
@change="changeMessageType"
>
<a-tabs v-model:activeKey="currentMessageType" size="small" @change="changeMessageType">
<a-tab-pane key="all" :tab="$t('common.all')" />
<a-tab-pane
key="notification"
:tab="$t('common.notification')"
/>
<a-tab-pane key="notification" :tab="$t('common.notification')" />
<a-tab-pane key="task" :tab="$t('common.task')" />
<a-tab-pane
key="warning"
:tab="$t('common.warning')"
/>
<a-tab-pane key="warning" :tab="$t('common.warning')" />
</a-tabs>
</div>
<div class="message-list">
<div
v-for="msg in messages"
:key="msg.id"
class="message-item"
:class="{ unread: !msg.is_read }"
@click="handleMessageRead(msg)"
>
<div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ unread: !msg.is_read }" @click="handleMessageRead(msg)">
<div class="message-content">
<div class="message-title">{{ msg.title }}</div>
<div class="message-content-text">
{{ msg.content }}
</div>
<div class="message-time">
{{
notificationStore.formatNotificationTime(
msg.created_at,
)
}}
{{ notificationStore.formatNotificationTime(msg.created_at) }}
</div>
</div>
<a-button
type="text"
size="small"
danger
class="delete-btn"
@click.stop="handleDeleteMessage(msg.id)"
>
<a-button type="text" size="small" danger class="delete-btn" @click.stop="handleDeleteMessage(msg.id)">
<DeleteOutlined />
</a-button>
</div>
<a-empty
v-if="messages.length === 0"
:description="$t('common.noMessages')"
/>
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
</div>
<!-- 分页 -->
<div
v-if="messagesTotal > messagesPageSize"
class="message-pagination"
>
<a-pagination
v-model:current="messagesPage"
v-model:pageSize="messagesPageSize"
:total="messagesTotal"
size="small"
:show-size-changer="false"
@change="handleMessagePageChange"
/>
<div v-if="messagesTotal > messagesPageSize" class="message-pagination">
<a-pagination v-model:current="messagesPage" v-model:pageSize="messagesPageSize" :total="messagesTotal" size="small" :show-size-changer="false" @change="handleMessagePageChange" />
</div>
</a-card>
</template>
@@ -122,11 +69,7 @@
<!-- 任务列表 -->
<a-tooltip :title="$t('common.taskCenter')">
<a-badge :count="taskCount" :offset="[-5, 5]">
<a-button
type="text"
@click="taskVisible = true"
class="action-btn"
>
<a-button type="text" @click="taskVisible = true" class="action-btn">
<CheckSquareOutlined />
</a-button>
</a-badge>
@@ -139,11 +82,7 @@
</a-button>
<template #overlay>
<a-menu @click="handleLanguageChange">
<a-menu-item
v-for="locale in i18nStore.availableLocales"
:key="locale.value"
:disabled="i18nStore.currentLocale === locale.value"
>
<a-menu-item v-for="locale in i18nStore.availableLocales" :key="locale.value" :disabled="i18nStore.currentLocale === locale.value">
<span>{{ locale.label }}</span>
</a-menu-item>
</a-menu>
@@ -162,34 +101,29 @@
<a-dropdown :trigger="['click']">
<div class="user-info">
<a-avatar :size="32" :src="userStore.user?.avatar || ''">
{{
userStore.user?.username?.charAt(0)?.toUpperCase() ||
"U"
}}
{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}
</a-avatar>
<span class="username">{{
userStore.user?.username || "Admin"
}}</span>
<span class="username">{{ userStore.user?.username || 'Admin' }}</span>
<DownOutlined />
</div>
<template #overlay>
<a-menu @click="handleMenuClick">
<a-menu-item key="profile">
<UserOutlined />
<span>{{ $t("common.personalCenter") }}</span>
<span>{{ $t('common.personalCenter') }}</span>
</a-menu-item>
<a-menu-item key="settings">
<SettingOutlined />
<span>{{ $t("common.systemSettings") }}</span>
<span>{{ $t('common.systemSettings') }}</span>
</a-menu-item>
<a-menu-item key="clearCache">
<DeleteOutlined />
<span>{{ $t("common.clearCache") }}</span>
<span>{{ $t('common.clearCache') }}</span>
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout">
<LogoutOutlined />
<span>{{ $t("common.logout") }}</span>
<span>{{ $t('common.logout') }}</span>
</a-menu-item>
</a-menu>
</template>
@@ -204,112 +138,98 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useRouter } from "vue-router";
import { message, Modal } from "ant-design-vue";
import { useUserStore } from "@/stores/modules/user";
import { useI18nStore } from "@/stores/modules/i18n";
import { useNotificationStore } from "@/stores/modules/notification";
import {
DownOutlined,
UserOutlined,
LogoutOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
BellOutlined,
CheckSquareOutlined,
GlobalOutlined,
SearchOutlined,
SettingOutlined,
DeleteOutlined,
} from "@ant-design/icons-vue";
import { useI18n } from "vue-i18n";
import search from "./search.vue";
import task from "./task.vue";
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { message, Modal } from 'ant-design-vue'
import { useUserStore } from '@/stores/modules/user'
import { useI18nStore } from '@/stores/modules/i18n'
import { useNotificationStore } from '@/stores/modules/notification'
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useI18n } from 'vue-i18n'
import search from './search.vue'
import task from './task.vue'
// 定义组件名称(多词命名)
defineOptions({
name: "UserBar",
});
name: 'UserBar',
})
const { t } = useI18n();
const router = useRouter();
const userStore = useUserStore();
const i18nStore = useI18nStore();
const notificationStore = useNotificationStore();
const { t } = useI18n()
const router = useRouter()
const userStore = useUserStore()
const i18nStore = useI18nStore()
const notificationStore = useNotificationStore()
const isFullscreen = ref(false);
const searchVisible = ref(false);
const taskVisible = ref(false);
const messageVisible = ref(false);
const currentMessageType = ref("all");
const messagesPage = ref(1);
const messagesPageSize = ref(10);
const notificationsList = ref([]);
const isFullscreen = ref(false)
const searchVisible = ref(false)
const taskVisible = ref(false)
const messageVisible = ref(false)
const currentMessageType = ref('all')
const messagesPage = ref(1)
const messagesPageSize = ref(10)
const notificationsList = ref([])
// 未读消息数量
const messageCount = computed(() => notificationStore.unreadCount);
const messageCount = computed(() => notificationStore.unreadCount)
// 消息总数(用于分页)
const messagesTotal = computed(() => notificationStore.total);
const messagesTotal = computed(() => notificationStore.total)
// 从 store 获取消息数据
const messages = computed(() => notificationsList.value);
const messages = computed(() => notificationsList.value)
// 任务数据
const tasks = ref([
{
id: 1,
title: "完成用户审核",
priority: "high",
title: '完成用户审核',
priority: 'high',
completed: false,
time: "今天",
time: '今天',
},
{
id: 2,
title: "更新系统文档",
priority: "medium",
title: '更新系统文档',
priority: 'medium',
completed: false,
time: "明天",
time: '明天',
},
{
id: 3,
title: "优化数据库查询",
priority: "low",
title: '优化数据库查询',
priority: 'low',
completed: true,
time: "昨天",
time: '昨天',
},
]);
])
const taskCount = computed(
() => tasks.value.filter((t) => !t.completed).length,
);
const taskCount = computed(() => tasks.value.filter((t) => !t.completed).length)
// 切换全屏
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
isFullscreen.value = true;
document.documentElement.requestFullscreen()
isFullscreen.value = true
} else {
document.exitFullscreen();
isFullscreen.value = false;
document.exitFullscreen()
isFullscreen.value = false
}
};
}
// 监听全屏变化
const handleFullscreenChange = () => {
isFullscreen.value = !!document.fullscreenElement;
};
isFullscreen.value = !!document.fullscreenElement
}
// 加载未读通知
const loadNotifications = async () => {
try {
await notificationStore.fetchUnreadCount();
await loadUnreadNotifications();
await notificationStore.fetchUnreadCount()
await loadUnreadNotifications()
} catch (error) {
console.error("加载通知失败:", error);
console.error('加载通知失败:', error)
}
};
}
// 加载未读通知列表
const loadUnreadNotifications = async () => {
@@ -317,16 +237,13 @@ const loadUnreadNotifications = async () => {
const res = await notificationStore.fetchUnreadNotifications({
page: messagesPage.value,
page_size: messagesPageSize.value,
type:
currentMessageType.value === "all"
? null
: currentMessageType.value,
});
notificationsList.value = res.list || [];
type: currentMessageType.value === 'all' ? null : currentMessageType.value,
})
notificationsList.value = res.list || []
} catch (error) {
console.error("加载未读通知列表失败:", error);
console.error('加载未读通知列表失败:', error)
}
};
}
// 加载所有通知
const loadAllNotifications = async () => {
@@ -334,231 +251,224 @@ const loadAllNotifications = async () => {
await notificationStore.fetchNotifications({
page: messagesPage.value,
page_size: messagesPageSize.value,
type:
currentMessageType.value === "all"
? null
: currentMessageType.value,
});
notificationsList.value = notificationStore.notifications;
type: currentMessageType.value === 'all' ? null : currentMessageType.value,
})
notificationsList.value = notificationStore.notifications
} catch (error) {
console.error("加载通知列表失败:", error);
console.error('加载通知列表失败:', error)
}
};
}
onMounted(() => {
document.addEventListener("fullscreenchange", handleFullscreenChange);
document.addEventListener('fullscreenchange', handleFullscreenChange)
// 加载通知数据
loadNotifications();
loadNotifications()
// 注意:WebSocket 已在 App.vue 中统一初始化,这里不需要重复调用
// 只需要处理消息即可,消息处理已经在 useWebSocket 中注册
});
})
onUnmounted(() => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
});
document.removeEventListener('fullscreenchange', handleFullscreenChange)
})
// 显示搜索功能
const showSearch = () => {
searchVisible.value = true;
};
searchVisible.value = true
}
// 清除消息
const clearMessages = async () => {
Modal.confirm({
title: t("common.confirmClear"),
content: t("common.confirmClearMessages"),
okText: t("common.confirm"),
cancelText: t("common.cancel"),
title: t('common.confirmClear'),
content: t('common.confirmClearMessages'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
await notificationStore.clearReadNotifications();
message.success(t("common.cleared"));
notificationsList.value = [];
await notificationStore.clearReadNotifications()
message.success(t('common.cleared'))
notificationsList.value = []
} catch (error) {
console.error("清空消息失败:", error);
console.error('清空消息失败:', error)
}
},
});
};
})
}
// 标记消息为已读
const handleMessageRead = async (msg) => {
if (!msg.is_read) {
try {
await notificationStore.markAsRead(msg.id);
await notificationStore.markAsRead(msg.id)
// 更新本地状态
const notification = notificationsList.value.find(
(n) => n.id === msg.id,
);
const notification = notificationsList.value.find((n) => n.id === msg.id)
if (notification) {
notification.is_read = true;
notification.read_at = new Date().toISOString();
notification.is_read = true
notification.read_at = new Date().toISOString()
}
} catch (error) {
console.error("标记已读失败:", error);
console.error('标记已读失败:', error)
}
}
};
}
// 标记所有消息为已读
const markAllAsRead = async () => {
Modal.confirm({
title: "确认全部已读",
content: "确定要将所有消息标记为已读吗?",
okText: "确定",
cancelText: "取消",
title: '确认全部已读',
content: '确定要将所有消息标记为已读吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await notificationStore.markAllAsRead();
message.success(t("common.markedAsRead"));
await notificationStore.markAllAsRead()
message.success(t('common.markedAsRead'))
// 更新本地状态
notificationsList.value.forEach((n) => {
n.is_read = true;
n.read_at = n.read_at || new Date().toISOString();
});
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
} catch (error) {
console.error("标记全部已读失败:", error);
console.error('标记全部已读失败:', error)
}
},
});
};
})
}
// 删除消息
const handleDeleteMessage = async (msgId) => {
Modal.confirm({
title: "确认删除",
content: "确定要删除这条消息吗?",
okText: "确定",
cancelText: "取消",
title: '确认删除',
content: '确定要删除这条消息吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await notificationStore.deleteNotification(msgId);
message.success("删除成功");
await notificationStore.deleteNotification(msgId)
message.success('删除成功')
// 更新本地状态
const index = notificationsList.value.findIndex(
(n) => n.id === msgId,
);
const index = notificationsList.value.findIndex((n) => n.id === msgId)
if (index !== -1) {
notificationsList.value.splice(index, 1);
notificationsList.value.splice(index, 1)
}
} catch (error) {
console.error("删除消息失败:", error);
console.error('删除消息失败:', error)
}
},
});
};
})
}
// 切换消息类型
const changeMessageType = async (type) => {
currentMessageType.value = type;
messagesPage.value = 1;
if (type === "all") {
await loadAllNotifications();
currentMessageType.value = type
messagesPage.value = 1
if (type === 'all') {
await loadAllNotifications()
} else {
await loadUnreadNotifications();
await loadUnreadNotifications()
}
};
}
// 分页变化
const handleMessagePageChange = async (page) => {
messagesPage.value = page;
if (currentMessageType.value === "all") {
await loadAllNotifications();
messagesPage.value = page
if (currentMessageType.value === 'all') {
await loadAllNotifications()
} else {
await loadUnreadNotifications();
await loadUnreadNotifications()
}
};
}
// 下拉框打开时加载数据
const handleMessageDropdownOpen = async (open) => {
if (open) {
await loadNotifications();
await loadNotifications()
}
};
}
// 显示任务抽屉
const showTasks = () => {
taskVisible.value = true;
};
taskVisible.value = true
}
// 切换语言
const handleLanguageChange = ({ key }) => {
i18nStore.setLocale(key);
message.success(t("common.languageChanged"));
};
i18nStore.setLocale(key)
message.success(t('common.languageChanged'))
}
// 处理菜单点击
const handleMenuClick = ({ key }) => {
switch (key) {
case "profile":
router.push("/ucenter");
break;
case "settings":
router.push("/system/setting");
break;
case "clearCache":
handleClearCache();
break;
case "logout":
handleLogout();
break;
case 'profile':
router.push('/ucenter')
break
case 'settings':
router.push('/system/setting')
break
case 'clearCache':
handleClearCache()
break
case 'logout':
handleLogout()
break
}
};
}
// 清除缓存
const handleClearCache = () => {
Modal.confirm({
title: t("common.confirmClearCache"),
content: t("common.clearCacheConfirm"),
okText: t("common.confirm"),
cancelText: t("common.cancel"),
title: t('common.confirmClearCache'),
content: t('common.clearCacheConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: () => {
try {
// 清除 localStorage
localStorage.clear();
localStorage.clear()
// 清除 sessionStorage
sessionStorage.clear();
sessionStorage.clear()
// 清除所有缓存
if ("caches" in window) {
if ('caches' in window) {
caches.keys().then((names) => {
names.forEach((name) => {
caches.delete(name);
});
});
caches.delete(name)
})
})
}
message.success(t("common.cacheCleared"));
message.success(t('common.cacheCleared'))
// 延迟刷新页面以应用缓存清除
setTimeout(() => {
window.location.reload();
}, 1000);
window.location.reload()
}, 1000)
} catch (error) {
message.error(t("common.clearCacheFailed"));
console.error("清除缓存失败:", error);
message.error(t('common.clearCacheFailed'))
console.error('清除缓存失败:', error)
}
},
});
};
})
}
// 退出登录
const handleLogout = () => {
Modal.confirm({
title: t("common.confirmLogout"),
content: t("common.logoutConfirm"),
okText: t("common.confirm"),
cancelText: t("common.cancel"),
title: t('common.confirmLogout'),
content: t('common.logoutConfirm'),
okText: t('common.confirm'),
cancelText: t('common.cancel'),
onOk: async () => {
try {
await userStore.logout();
message.success(t("common.logoutSuccess"));
router.push("/login");
await userStore.logout()
message.success(t('common.logoutSuccess'))
router.push('/login')
} catch {
message.error(t("common.logoutFailed"));
message.error(t('common.logoutFailed'))
}
},
});
};
})
}
</script>
<style scoped lang="scss">
@@ -766,21 +676,13 @@ const handleLogout = () => {
}
&.unread {
background: linear-gradient(
135deg,
rgba(24, 144, 255, 0.05) 0%,
rgba(24, 144, 255, 0.02) 100%
);
background: linear-gradient(135deg, rgba(24, 144, 255, 0.05) 0%, rgba(24, 144, 255, 0.02) 100%);
border: 1px solid rgba(24, 144, 255, 0.15);
border-left: 4px solid #1890ff;
padding-left: 14px;
&:hover {
background: linear-gradient(
135deg,
rgba(24, 144, 255, 0.08) 0%,
rgba(24, 144, 255, 0.04) 100%
);
background: linear-gradient(135deg, rgba(24, 144, 255, 0.08) 0%, rgba(24, 144, 255, 0.04) 100%);
border-color: rgba(24, 144, 255, 0.25);
}
+91 -160
View File
@@ -5,11 +5,7 @@
<!-- 第一个侧边栏:显示一级菜单 -->
<a-layout-sider theme="dark" width="70" class="left-sidebar">
<div class="logo-box">
<img
src="@/assets/images/logo.png"
alt="logo"
class="logo-image"
/>
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
</div>
<ul class="left-nav">
<li
@@ -27,38 +23,13 @@
</a-layout-sider>
<!-- 第二个侧边栏:显示选中的父菜单的子菜单 -->
<a-layout-sider
v-if="
selectedParentMenu &&
selectedParentMenu.children &&
selectedParentMenu.children.length > 0
"
theme="light"
:collapsed="sidebarCollapsed"
:collapsible="true"
@collapse="handleCollapse"
width="200"
:collapsed-width="64"
class="right-sidebar"
>
<a-layout-sider v-if="selectedParentMenu && selectedParentMenu.children && selectedParentMenu.children.length > 0" theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse" width="200" :collapsed-width="64" class="right-sidebar">
<div class="parent-title">
<component
:is="getIconComponent(selectedParentMenu.meta?.icon)"
/>
<span v-if="!sidebarCollapsed">{{
selectedParentMenu.title
}}</span>
<component :is="getIconComponent(selectedParentMenu.meta?.icon)" />
<span v-if="!sidebarCollapsed">{{ selectedParentMenu.title }}</span>
</div>
<a-menu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
mode="inline"
:selected-keys="[route.path]"
>
<navMenu
:menu-items="selectedParentMenu.children"
:active-path="route.path"
/>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline" :selected-keys="[route.path]">
<navMenu :menu-items="selectedParentMenu.children" :active-path="route.path" />
</a-menu>
</a-layout-sider>
@@ -82,32 +53,12 @@
<!-- Menu布局:左侧菜单栏布局 -->
<template v-else-if="layoutMode === 'menu'">
<a-layout-sider
theme="light"
style="border-right: 1px solid #f0f0f0"
:collapsed="sidebarCollapsed"
:collapsible="true"
@collapse="handleCollapse"
class="full-menu-sidebar"
width="200"
:collapsed-width="64"
>
<a-layout-sider theme="light" style="border-right: 1px solid #f0f0f0" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse" class="full-menu-sidebar" width="200" :collapsed-width="64">
<div class="logo-box-full">
<img
src="@/assets/images/logo.png"
alt="logo"
class="logo-image"
/>
<span v-if="!sidebarCollapsed" class="app-name">{{
config.APP_NAME
}}</span>
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<span v-if="!sidebarCollapsed" class="app-name">{{ config.APP_NAME }}</span>
</div>
<a-menu
v-model:openKeys="openKeys"
v-model:selectedKeys="selectedKeys"
mode="inline"
:selected-keys="[route.path]"
>
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline" :selected-keys="[route.path]">
<navMenu :menu-items="menuList" :active-path="route.path" />
</a-menu>
</a-layout-sider>
@@ -134,23 +85,11 @@
<a-layout-header class="app-header top-header">
<div class="top-header-left">
<div class="logo-box-top">
<img
src="@/assets/images/logo.png"
alt="logo"
class="logo-image"
/>
<img src="@/assets/images/logo.png" alt="logo" class="logo-image" />
<span class="app-name">{{ config.APP_NAME }}</span>
</div>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="horizontal"
:selected-keys="[route.path]"
style="line-height: 60px"
>
<navMenu
:menu-items="menuList"
:active-path="route.path"
/>
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]" style="line-height: 60px">
<navMenu :menu-items="menuList" :active-path="route.path" />
</a-menu>
</div>
<userbar />
@@ -178,143 +117,139 @@
</template>
<script setup>
import { computed, ref, watch, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useLayoutStore } from "@/stores/modules/layout";
import { useUserStore } from "@/stores/modules/user";
import { SettingOutlined } from "@ant-design/icons-vue";
import * as icons from "@ant-design/icons-vue";
import config from "@/config/index.js";
import { computed, ref, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLayoutStore } from '@/stores/modules/layout'
import { useUserStore } from '@/stores/modules/user'
import { SettingOutlined } from '@ant-design/icons-vue'
import * as icons from '@ant-design/icons-vue'
import config from '@/config/index.js'
import userbar from "./components/userbar.vue";
import navMenu from "./components/navMenu.vue";
import breadcrumb from "./components/breadcrumb.vue";
import tags from "./components/tags.vue";
import setting from "./components/setting.vue";
import userbar from './components/userbar.vue'
import navMenu from './components/navMenu.vue'
import breadcrumb from './components/breadcrumb.vue'
import tags from './components/tags.vue'
import setting from './components/setting.vue'
// 定义组件名称(多词命名)
defineOptions({
name: "AppLayouts",
});
name: 'AppLayouts',
})
const route = useRoute();
const router = useRouter();
const layoutStore = useLayoutStore();
const userStore = useUserStore();
const route = useRoute()
const router = useRouter()
const layoutStore = useLayoutStore()
const userStore = useUserStore()
const settingRef = ref(null);
const settingRef = ref(null)
const layoutMode = computed(() => layoutStore.layoutMode);
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed);
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu);
const layoutMode = computed(() => layoutStore.layoutMode)
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
// 缓存的视图列表
const cachedViews = computed(() => {
return layoutStore.viewTags
.filter((tag) => !tag.meta?.noCache)
.map((tag) => tag.name);
});
return layoutStore.viewTags.filter((tag) => !tag.meta?.noCache).map((tag) => tag.name)
})
// 布局类名
const layoutClass = computed(() => {
return {
"layout-default": layoutMode.value === "default",
"layout-menu": layoutMode.value === "menu",
"layout-top": layoutMode.value === "top",
"is-collapse": sidebarCollapsed.value,
};
});
'layout-default': layoutMode.value === 'default',
'layout-menu': layoutMode.value === 'menu',
'layout-top': layoutMode.value === 'top',
'is-collapse': sidebarCollapsed.value,
}
})
// 获取刷新 key
const refreshKey = computed(() => layoutStore.refreshKey);
const refreshKey = computed(() => layoutStore.refreshKey)
const openKeys = ref([]);
const selectedKeys = ref([]);
const openKeys = ref([])
const selectedKeys = ref([])
const menuList = computed(() => {
return userStore.menu;
});
return userStore.menu
})
// 获取图标组件
const getIconComponent = (iconName) => {
return icons[iconName] || icons.FileTextOutlined;
};
return icons[iconName] || icons.FileTextOutlined
}
// 处理父菜单点击(默认布局的第一级菜单)
const handleParentMenuClick = (item) => {
// 设置选中的父菜单
layoutStore.setSelectedParentMenu(item);
layoutStore.setSelectedParentMenu(item)
// 如果没有子菜单,直接跳转
if (!item.children || item.children.length === 0) {
if (item.path) {
router.push(item.path);
router.push(item.path)
}
} else {
// 默认展开第一个子菜单
if (item.children.length > 0 && item.children[0].path) {
router.push(item.children[0].path);
router.push(item.children[0].path)
}
}
};
}
// 处理折叠
const handleCollapse = (collapsed) => {
layoutStore.sidebarCollapsed = collapsed;
};
layoutStore.sidebarCollapsed = collapsed
}
// 打开设置抽屉
const openSetting = () => {
settingRef.value?.openDrawer();
};
settingRef.value?.openDrawer()
}
// 更新选中的菜单和展开的菜单
const updateMenuState = () => {
selectedKeys.value = [route.path];
selectedKeys.value = [route.path]
// 获取所有父级路径
const matched = route.matched.filter(
(item) => item.path !== "/" && item.path !== route.path,
);
const parentPaths = matched.map((item) => item.path);
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path)
const parentPaths = matched.map((item) => item.path)
// 对于不同的布局模式,处理方式不同
if (layoutMode.value === "default") {
if (layoutMode.value === 'default') {
// 默认布局:找到当前路由对应的父菜单
const currentMenu = findMenuByPath(menuList.value, route.path);
const currentMenu = findMenuByPath(menuList.value, route.path)
if (currentMenu) {
// 如果当前菜单有子菜单,设置为选中的父菜单
if (currentMenu.children && currentMenu.children.length > 0) {
layoutStore.setSelectedParentMenu(currentMenu);
layoutStore.setSelectedParentMenu(currentMenu)
} else {
// 如果当前菜单是子菜单,找到它的父菜单
const parentMenu = findParentMenu(menuList.value, route.path);
const parentMenu = findParentMenu(menuList.value, route.path)
if (parentMenu) {
layoutStore.setSelectedParentMenu(parentMenu);
layoutStore.setSelectedParentMenu(parentMenu)
} else {
layoutStore.setSelectedParentMenu(currentMenu);
layoutStore.setSelectedParentMenu(currentMenu)
}
}
}
} else if (!sidebarCollapsed.value) {
// 其他布局模式:展开所有父级菜单
openKeys.value = parentPaths;
openKeys.value = parentPaths
}
};
}
// 根据路径查找菜单
const findMenuByPath = (menus, path) => {
for (const menu of menus) {
if (menu.path === path) {
return menu;
return menu
}
if (menu.children && menu.children.length > 0) {
const found = findMenuByPath(menu.children, path);
const found = findMenuByPath(menu.children, path)
if (found) {
return found;
return found
}
}
}
return null;
};
return null
}
// 查找父菜单
const findParentMenu = (menus, path) => {
@@ -322,62 +257,58 @@ const findParentMenu = (menus, path) => {
if (menu.children && menu.children.length > 0) {
for (const child of menu.children) {
if (child.path === path) {
return menu;
return menu
}
if (child.children && child.children.length > 0) {
const found = findParentMenu([child], path);
const found = findParentMenu([child], path)
if (found) {
return menu;
return menu
}
}
}
}
}
return null;
};
return null
}
// 监听路由变化,更新菜单状态
watch(
() => route.path,
(newPath) => {
console.log("路由变化:", newPath);
updateMenuState();
console.log('路由变化:', newPath)
updateMenuState()
},
{ immediate: true },
);
)
// 监听布局模式变化,确保菜单状态正确
watch(
() => layoutMode.value,
() => {
updateMenuState();
updateMenuState()
},
);
)
// 监听折叠状态
watch(
() => sidebarCollapsed.value,
(val) => {
if (val) {
openKeys.value = [];
openKeys.value = []
} else {
updateMenuState();
updateMenuState()
}
},
);
)
// 初始化
onMounted(() => {
// 如果还没有选中的父菜单,默认选中第一个
if (
layoutMode.value === "default" &&
!selectedParentMenu.value &&
menuList.value.length > 0
) {
layoutStore.setSelectedParentMenu(menuList.value[0]);
if (layoutMode.value === 'default' && !selectedParentMenu.value && menuList.value.length > 0) {
layoutStore.setSelectedParentMenu(menuList.value[0])
}
updateMenuState();
});
updateMenuState()
})
</script>
<style scoped lang="scss">
+13 -31
View File
@@ -9,9 +9,7 @@
<div class="not-found-content">
<div class="error-code">404</div>
<div class="error-title">页面未找到</div>
<div class="error-description">
抱歉,您访问的页面不存在或已被移除
</div>
<div class="error-description">抱歉,您访问的页面不存在或已被移除</div>
<div class="action-buttons">
<a-button type="primary" size="large" @click="goBack">
@@ -32,21 +30,21 @@
</template>
<script setup>
import { useRouter } from "vue-router";
import { ArrowLeftOutlined, HomeOutlined } from "@ant-design/icons-vue";
import "@/assets/style/auth.scss";
import { useRouter } from 'vue-router'
import { ArrowLeftOutlined, HomeOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
const router = useRouter();
const router = useRouter()
// Go back to previous page
const goBack = () => {
router.back();
};
router.back()
}
// Go to home page
const goHome = () => {
router.push("/");
};
router.push('/')
}
</script>
<style scoped lang="scss">
@@ -56,11 +54,7 @@ const goHome = () => {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-end) 100%
);
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
position: relative;
overflow: hidden;
@@ -128,11 +122,7 @@ const goHome = () => {
.error-code {
font-size: 120px;
font-weight: 700;
background: linear-gradient(
135deg,
var(--auth-primary-dark),
var(--auth-primary)
);
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -180,21 +170,13 @@ const goHome = () => {
border-radius: 12px;
&.ant-btn-primary {
background: linear-gradient(
135deg,
var(--auth-primary),
var(--auth-primary-dark)
);
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
border: none;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(
135deg,
var(--auth-primary-light),
var(--auth-primary)
);
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
+19 -38
View File
@@ -8,45 +8,38 @@
<div class="empty-content">
<div class="empty-icon">
<InboxOutlined
:style="{ fontSize: '120px', color: '#ff6b35' }"
/>
<InboxOutlined :style="{ fontSize: '120px', color: '#ff6b35' }" />
</div>
<div class="empty-title">暂无数据</div>
<div class="empty-description">
{{ description || "当前页面暂无数据,请稍后再试" }}
{{ description || '当前页面暂无数据,请稍后再试' }}
</div>
<a-button
v-if="showButton"
type="primary"
size="large"
@click="handleAction"
>
<a-button v-if="showButton" type="primary" size="large" @click="handleAction">
<template #icon v-if="buttonIcon">
<component :is="buttonIcon" />
</template>
{{ buttonText || "刷新页面" }}
{{ buttonText || '刷新页面' }}
</a-button>
</div>
</div>
</template>
<script setup>
import { InboxOutlined } from "@ant-design/icons-vue";
import { useRouter } from "vue-router";
import { InboxOutlined } from '@ant-design/icons-vue'
import { useRouter } from 'vue-router'
defineOptions({
name: "EmptyPage",
});
name: 'EmptyPage',
})
const router = useRouter();
const router = useRouter()
defineProps({
description: {
type: String,
default: "当前页面暂无数据,请稍后再试",
default: '当前页面暂无数据,请稍后再试',
},
showButton: {
type: Boolean,
@@ -54,21 +47,21 @@ defineProps({
},
buttonText: {
type: String,
default: "刷新页面",
default: '刷新页面',
},
buttonIcon: {
type: [String, Object],
default: null,
},
});
})
const emit = defineEmits(["action"]);
const emit = defineEmits(['action'])
const handleAction = () => {
emit("action");
emit('action')
// Default behavior: refresh page
router.go(0);
};
router.go(0)
}
</script>
<style scoped lang="scss">
@@ -78,11 +71,7 @@ const handleAction = () => {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(
135deg,
var(--bg-gradient-start) 0%,
var(--bg-gradient-end) 100%
);
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
position: relative;
overflow: hidden;
@@ -182,22 +171,14 @@ const handleAction = () => {
padding: 0 40px;
font-size: 16px;
font-weight: 600;
background: linear-gradient(
135deg,
var(--auth-primary),
var(--auth-primary-dark)
);
background: linear-gradient(135deg, var(--auth-primary), var(--auth-primary-dark));
border: none;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(255, 107, 53, 0.35);
transition: all 0.3s ease;
&:hover {
background: linear-gradient(
135deg,
var(--auth-primary-light),
var(--auth-primary)
);
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
transform: translateY(-2px);
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
}
+16 -16
View File
@@ -1,20 +1,20 @@
import { createApp } from "vue";
import { createApp } from 'vue'
import Antd from "ant-design-vue";
import "ant-design-vue/dist/reset.css";
import "@/assets/style/app.scss";
import App from "./App.vue";
import router from "./router";
import pinia from "./stores";
import i18n from "./i18n";
import boot from "./boot";
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import '@/assets/style/app.scss'
import App from './App.vue'
import router from './router'
import pinia from './stores'
import i18n from './i18n'
import boot from './boot'
const app = createApp(App);
const app = createApp(App)
app.use(Antd);
app.use(router);
app.use(pinia);
app.use(i18n);
app.use(boot);
app.use(Antd)
app.use(router)
app.use(pinia)
app.use(i18n)
app.use(boot)
app.mount("#app");
app.mount('#app')
@@ -1,61 +1,20 @@
<template>
<a-modal
:title="titleMap[mode]"
:open="visible"
:width="500"
:destroy-on-close="true"
:footer="null"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
:disabled="mode === 'show'"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="上级部门" name="parent_id">
<a-tree-select
v-model:value="form.parent_id"
:tree-data="filteredDepartments"
:field-names="departmentFieldNames"
:tree-default-expand-all="false"
placeholder="请选择上级部门"
allow-clear
tree-node-filter-prop="name"
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }"
/>
<a-tree-select v-model:value="form.parent_id" :tree-data="filteredDepartments" :field-names="departmentFieldNames" :tree-default-expand-all="false" placeholder="请选择上级部门" allow-clear tree-node-filter-prop="name" :dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" />
</a-form-item>
<a-form-item label="部门名称" name="name">
<a-input
v-model:value="form.name"
placeholder="请输入部门名称"
allow-clear
></a-input>
<a-input v-model:value="form.name" placeholder="请输入部门名称" allow-clear></a-input>
</a-form-item>
<a-form-item label="负责人" name="leader">
<a-input
v-model:value="form.leader"
placeholder="请输入负责人"
allow-clear
></a-input>
<a-input v-model:value="form.leader" placeholder="请输入负责人" allow-clear></a-input>
</a-form-item>
<a-form-item label="联系电话" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入联系电话"
allow-clear
></a-input>
<a-input v-model:value="form.phone" placeholder="请输入联系电话" allow-clear></a-input>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
placeholder="请输入排序"
/>
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" placeholder="请输入排序" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="form.status">
@@ -65,13 +24,7 @@
</a-form-item>
<a-form-item :wrapper-col="{ offset: 5 }">
<div style="display: flex; gap: 10px">
<a-button
v-if="mode !== 'show'"
type="primary"
:loading="isSaveing"
@click="submit"
>保 存</a-button
>
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
<a-button @click="handleCancel">取 消</a-button>
</div>
</a-form-item>
@@ -80,66 +33,66 @@
</template>
<script setup>
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
defineOptions({
name: "DepartmentSaveDialog",
});
name: 'DepartmentSaveDialog',
})
const emit = defineEmits(["success", "closed"]);
const emit = defineEmits(['success', 'closed'])
const mode = ref("add");
const mode = ref('add')
const titleMap = {
add: "新增部门",
edit: "编辑部门",
show: "查看部门",
};
const visible = ref(false);
const isSaveing = ref(false);
add: '新增部门',
edit: '编辑部门',
show: '查看部门',
}
const visible = ref(false)
const isSaveing = ref(false)
// 表单数据
const form = reactive({
id: "",
name: "",
leader: "",
phone: "",
id: '',
name: '',
leader: '',
phone: '',
sort: 0,
parent_id: null,
status: 1,
});
})
// 表单引用
const dialogForm = ref();
const dialogForm = ref()
// 验证规则
const rules = {
name: [{ required: true, message: "请输入部门名称", trigger: "blur" }],
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
sort: [
{ required: true, message: "请输入排序", trigger: "change" },
{ type: "number", message: "排序必须为数字", trigger: "change" },
{ required: true, message: '请输入排序', trigger: 'change' },
{ type: 'number', message: '排序必须为数字', trigger: 'change' },
],
};
}
// 部门数据
const departments = ref([]);
const departments = ref([])
const departmentFieldNames = {
label: "name",
value: "id",
children: "children",
};
label: 'name',
value: 'id',
children: 'children',
}
// 当前编辑的部门ID(用于过滤树)
const currentEditId = ref(null);
const currentEditId = ref(null)
// 过滤后的部门树(编辑时排除自己和子部门)
const filteredDepartments = computed(() => {
if (mode.value === "add") {
return departments.value;
if (mode.value === 'add') {
return departments.value
}
return filterDepartments(departments.value, currentEditId.value);
});
return filterDepartments(departments.value, currentEditId.value)
})
// 递归过滤部门树
const filterDepartments = (tree, excludeId) => {
@@ -147,96 +100,94 @@ const filterDepartments = (tree, excludeId) => {
.filter((item) => item.id !== excludeId)
.map((item) => ({
...item,
children: item.children
? filterDepartments(item.children, excludeId)
: undefined,
}));
};
children: item.children ? filterDepartments(item.children, excludeId) : undefined,
}))
}
// 显示对话框
const open = (openMode = "add") => {
mode.value = openMode;
visible.value = true;
const open = (openMode = 'add') => {
mode.value = openMode
visible.value = true
return {
setData,
open,
close,
};
};
}
}
// 关闭对话框
const close = () => {
visible.value = false;
};
visible.value = false
}
// 处理取消
const handleCancel = () => {
emit("closed");
visible.value = false;
};
emit('closed')
visible.value = false
}
// 表单提交方法
const submit = async () => {
try {
await dialogForm.value.validate();
isSaveing.value = true;
let res = {};
form.parent_id = form.parent_id || 0;
await dialogForm.value.validate()
isSaveing.value = true
let res = {}
form.parent_id = form.parent_id || 0
if (mode.value === "add") {
res = await authApi.departments.add.post(form);
if (mode.value === 'add') {
res = await authApi.department.add.post(form)
} else {
res = await authApi.departments.edit.put(form.id, form);
res = await authApi.department.edit.put(form.id, form)
}
isSaveing.value = false;
isSaveing.value = false
if (res.code === 200) {
emit("success", form, mode.value);
visible.value = false;
message.success("操作成功");
emit('success', form, mode.value)
visible.value = false
message.success('操作成功')
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("表单验证失败", error);
isSaveing.value = false;
console.error('表单验证失败', error)
isSaveing.value = false
}
};
}
// 加载部门树数据
const loadDepartments = async () => {
try {
const res = await authApi.departments.tree.get();
const res = await authApi.department.tree.get()
if (res.code === 200) {
departments.value = res.data || [];
departments.value = res.data || []
}
} catch (error) {
console.error("加载部门树失败:", error);
message.error("加载部门树失败");
console.error('加载部门树失败:', error)
message.error('加载部门树失败')
}
};
}
// 表单注入数据
const setData = (data) => {
form.id = data.id;
currentEditId.value = data.id;
form.name = data.name;
form.leader = data.leader || "";
form.phone = data.phone || "";
form.sort = data.sort || 0;
form.parent_id = data.parent_id || null;
form.status = data.status !== undefined ? data.status : 1;
};
form.id = data.id
currentEditId.value = data.id
form.name = data.name
form.leader = data.leader || ''
form.phone = data.phone || ''
form.sort = data.sort || 0
form.parent_id = data.parent_id || null
form.status = data.status !== undefined ? data.status : 1
}
// 组件挂载时加载数据
loadDepartments();
loadDepartments()
// 暴露方法给父组件
defineExpose({
open,
setData,
close,
});
})
</script>
<style></style>
@@ -4,18 +4,8 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="部门名称"
allow-clear
style="width: 200px"
/>
<a-select
v-model:value="searchForm.status"
placeholder="状态"
allow-clear
style="width: 120px"
>
<a-input v-model:value="searchForm.keyword" placeholder="部门名称" allow-clear style="width: 200px" />
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 120px">
<a-select-option :value="1">正常</a-select-option>
<a-select-option :value="0">禁用</a-select-option>
</a-select>
@@ -35,17 +25,11 @@
<template #icon><plus-outlined /></template>
新增
</a-button>
<a-button
:disabled="selectedRows.length === 0"
@click="handleBatchStatus(1)"
>
<a-button :disabled="selectedRows.length === 0" @click="handleBatchStatus(1)">
<template #icon><check-circle-outlined /></template>
启用
</a-button>
<a-button
:disabled="selectedRows.length === 0"
@click="handleBatchStatus(0)"
>
<a-button :disabled="selectedRows.length === 0" @click="handleBatchStatus(0)">
<template #icon><stop-outlined /></template>
禁用
</a-button>
@@ -57,22 +41,16 @@
<template #overlay>
<a-menu>
<a-menu-item @click="handleExport">
<template #icon
><download-outlined
/></template>
<template #icon><download-outlined /></template>
导出
</a-menu-item>
<a-menu-item @click="handleImport">
<template #icon
><upload-outlined
/></template>
<template #icon><upload-outlined /></template>
导入
</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="handleBatchDelete">
<template #icon
><delete-outlined
/></template>
<template #icon><delete-outlined /></template>
批量删除
</a-menu-item>
</a-menu>
@@ -84,46 +62,23 @@
<!-- 表格内容区域 -->
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="false"
:row-key="rowKey"
:row-selection="rowSelection"
:default-expand-all="true"
@refresh="refreshTable"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="false" :row-key="rowKey" :row-selection="rowSelection" :default-expand-all="true" @refresh="refreshTable" @select="handleSelectChange" @selectAll="handleSelectAll">
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'green' : 'red'">
{{ record.status === 1 ? "正常" : "禁用" }}
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleView(record)"
>
<a-button type="link" size="small" @click="handleView(record)">
<template #icon><eye-outlined /></template>
查看
</a-button>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<a-button type="link" size="small" @click="handleEdit(record)">
<template #icon><edit-outlined /></template>
编辑
</a-button>
<a-popconfirm
title="确定删除该部门吗?如果该部门下有子部门或用户,将无法删除"
@confirm="handleDelete(record)"
>
<a-popconfirm title="确定删除该部门吗?如果该部门下有子部门或用户,将无法删除" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>
<template #icon><delete-outlined /></template>
删除
@@ -136,294 +91,262 @@
</div>
<!-- 新增/编辑/查看部门弹窗 -->
<save-dialog
v-if="dialog.save"
ref="saveDialogRef"
@success="handleSaveSuccess"
@closed="dialog.save = false"
/>
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
<!-- 导入部门弹窗 -->
<sc-import
v-model:open="dialog.import"
title="导入部门"
:api="authApi.departments.import.post"
:template-api="authApi.departments.downloadTemplate.get"
filename="部门"
@success="handleImportSuccess"
/>
<sc-import v-model:open="dialog.import" title="导入部门" :api="authApi.department.import.post" :template-api="authApi.department.downloadTemplate.get" filename="部门" @success="handleImportSuccess" />
<!-- 导出部门弹窗 -->
<sc-export
v-model:open="dialog.export"
title="导出部门"
:api="handleExportApi"
:default-filename="`部门列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有部门数据"
@success="handleExportSuccess"
/>
<sc-export v-model:open="dialog.export" title="导出部门" :api="handleExportApi" :default-filename="`部门列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有部门数据" @success="handleExportSuccess" />
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { message, Modal } from "ant-design-vue";
import scTable from "@/components/scTable/index.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import saveDialog from "./components/SaveDialog.vue";
import authApi from "@/api/auth";
import { useTable } from "@/hooks/useTable";
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import scTable from '@/components/scTable/index.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import saveDialog from './components/SaveDialog.vue'
import authApi from '@/api/auth'
import { useTable } from '@/hooks/useTable'
defineOptions({
name: "authDepartment",
});
name: 'authDepartment',
})
// 使用useTable hooks
const {
tableRef,
searchForm,
tableData,
loading,
selectedRows,
rowSelection,
handleSearch,
handleReset,
handleSelectChange,
handleSelectAll,
refreshTable,
} = useTable({
api: authApi.departments.tree.get,
const { tableRef, searchForm, tableData, loading, selectedRows, rowSelection, handleSearch, handleReset, handleSelectChange, handleSelectAll, refreshTable } = useTable({
api: authApi.department.tree.get,
searchForm: {
keyword: "",
keyword: '',
status: null,
},
columns: [],
needPagination: false,
needSelection: true,
immediateLoad: false,
});
})
// 对话框状态
const dialog = reactive({
save: false,
import: false,
export: false,
});
})
// 弹窗引用
const saveDialogRef = ref(null);
const saveDialogRef = ref(null)
// 行key
const rowKey = "id";
const rowKey = 'id'
// 表格列配置
const columns = [
{
title: "#",
dataIndex: "_index",
key: "_index",
title: '#',
dataIndex: '_index',
key: '_index',
width: 60,
align: "center",
align: 'center',
},
{ title: "部门名称", dataIndex: "name", key: "name", width: 300 },
{ title: "负责人", dataIndex: "leader", key: "leader", width: 120 },
{ title: "联系电话", dataIndex: "phone", key: "phone", width: 150 },
{ title: '部门名称', dataIndex: 'name', key: 'name', width: 300 },
{ title: '负责人', dataIndex: 'leader', key: 'leader', width: 120 },
{ title: '联系电话', dataIndex: 'phone', key: 'phone', width: 150 },
{
title: "排序",
dataIndex: "sort",
key: "sort",
title: '排序',
dataIndex: 'sort',
key: 'sort',
width: 100,
align: "center",
align: 'center',
},
{
title: "状态",
dataIndex: "status",
key: "status",
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: "center",
slot: "status",
align: 'center',
slot: 'status',
},
{
title: "操作",
dataIndex: "action",
key: "action",
title: '操作',
dataIndex: 'action',
key: 'action',
width: 220,
align: "center",
slot: "action",
fixed: "right",
align: 'center',
slot: 'action',
fixed: 'right',
},
];
]
// 新增部门
const handleAdd = () => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("add");
}, 0);
};
saveDialogRef.value?.open('add')
}, 0)
}
// 查看部门
const handleView = (record) => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("show").setData(record);
}, 0);
};
saveDialogRef.value?.open('show').setData(record)
}, 0)
}
// 编辑部门
const handleEdit = (record) => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("edit").setData(record);
}, 0);
};
saveDialogRef.value?.open('edit').setData(record)
}, 0)
}
// 删除部门
const handleDelete = async (record) => {
try {
const res = await authApi.departments.delete.delete(record.id);
const res = await authApi.department.delete.delete(record.id)
if (res.code === 200) {
message.success("删除成功");
refreshTable();
message.success('删除成功')
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("删除部门失败:", error);
console.error('删除部门失败:', error)
// 如果是验证错误,显示具体错误信息
if (error.response?.data?.message) {
message.error(error.response.data.message);
message.error(error.response.data.message)
} else {
message.error("删除失败");
message.error('删除失败')
}
}
};
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要删除的部门");
return;
message.warning('请选择要删除的部门')
return
}
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定删除选中的 ${selectedRows.value.length} 个部门吗?如果删除项中含有子集或用户,将会被一并删除`,
okText: "确定",
cancelText: "取消",
okType: "danger",
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.departments.batchDelete.post({ ids });
const ids = selectedRows.value.map((item) => item.id)
const res = await authApi.department.batchDelete.post({ ids })
if (res.code === 200) {
message.success(res.message || "删除成功");
selectedRows.value = [];
refreshTable();
message.success(res.message || '删除成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("批量删除部门失败:", error);
console.error('批量删除部门失败:', error)
if (error.response?.data?.message) {
message.error(error.response.data.message);
message.error(error.response.data.message)
} else {
message.error("删除失败");
message.error('删除失败')
}
}
},
});
};
})
}
// 批量更新状态
const handleBatchStatus = (status) => {
if (selectedRows.value.length === 0) {
message.warning("请选择要操作的部门");
return;
message.warning('请选择要操作的部门')
return
}
Modal.confirm({
title: "确认操作",
content: `确定${status === 1 ? "启用" : "禁用"}选中的 ${selectedRows.value.length} 个部门吗?`,
okText: "确定",
cancelText: "取消",
title: '确认操作',
content: `确定${status === 1 ? '启用' : '禁用'}选中的 ${selectedRows.value.length} 个部门吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.departments.batchStatus.post({
const ids = selectedRows.value.map((item) => item.id)
const res = await authApi.department.batchStatus.post({
ids,
status,
});
})
if (res.code === 200) {
message.success(res.message || "操作成功");
selectedRows.value = [];
refreshTable();
message.success(res.message || '操作成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("批量更新状态失败:", error);
message.error("操作失败");
console.error('批量更新状态失败:', error)
message.error('操作失败')
}
},
});
};
})
}
// 导出部门
const handleExport = () => {
dialog.export = true;
};
dialog.export = true
}
// 导出API封装
const handleExportApi = async () => {
const ids = selectedRows.value.map((item) => item.id);
return await authApi.departments.export.post({
const ids = selectedRows.value.map((item) => item.id)
return await authApi.department.export.post({
ids: ids.length > 0 ? ids : undefined,
});
};
})
}
// 导出成功回调
const handleExportSuccess = () => {
selectedRows.value = [];
};
selectedRows.value = []
}
// 导入部门
const handleImport = () => {
dialog.import = true;
};
dialog.import = true
}
// 导入成功回调
const handleImportSuccess = () => {
refreshTable();
};
refreshTable()
}
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.departments.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "部门导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
const blob = await authApi.department.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '部门导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error("下载模板失败:", error);
message.error("下载失败");
console.error('下载模板失败:', error)
message.error('下载失败')
}
};
}
// 保存成功回调
const handleSaveSuccess = () => {
refreshTable();
};
refreshTable()
}
// 初始化
onMounted(() => {
refreshTable();
});
refreshTable()
})
</script>
@@ -5,11 +5,7 @@
<a-row :gutter="16">
<a-col :span="6">
<a-card>
<a-statistic
title="在线用户总数"
:value="onlineCount"
:value-style="{ color: '#3f8600' }"
>
<a-statistic title="在线用户总数" :value="onlineCount" :value-style="{ color: '#3f8600' }">
<template #prefix>
<UserOutlined style="font-size: 24px" />
</template>
@@ -20,44 +16,22 @@
<a-card>
<a-form layout="inline" :model="searchForm">
<a-form-item label="刷新间隔">
<a-select
v-model:value="refreshInterval"
style="width: 150px"
@change="handleRefreshIntervalChange"
>
<a-select-option :value="0"
>不自动刷新</a-select-option
>
<a-select-option :value="5000"
>5秒</a-select-option
>
<a-select-option :value="10000"
>10秒</a-select-option
>
<a-select-option :value="30000"
>30秒</a-select-option
>
<a-select-option :value="60000"
>60秒</a-select-option
>
<a-select v-model:value="refreshInterval" style="width: 150px" @change="handleRefreshIntervalChange">
<a-select-option :value="0">不自动刷新</a-select-option>
<a-select-option :value="5000">5秒</a-select-option>
<a-select-option :value="10000">10秒</a-select-option>
<a-select-option :value="30000">30秒</a-select-option>
<a-select-option :value="60000">60秒</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-space>
<a-button
type="primary"
@click="handleRefresh"
:loading="loading"
>
<template #icon
><ReloadOutlined
/></template>
<a-button type="primary" @click="handleRefresh" :loading="loading">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button @click="handleRefreshAllOffline">
<template #icon
><StopOutlined
/></template>
<template #icon><StopOutlined /></template>
全部下线
</a-button>
</a-space>
@@ -72,12 +46,7 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="用户名"
allow-clear
style="width: 200px"
/>
<a-input v-model:value="searchForm.keyword" placeholder="用户名" allow-clear style="width: 200px" />
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -92,18 +61,10 @@
<!-- 表格内容 -->
<div class="table-content">
<sc-table
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
@refresh="refreshTable"
>
<sc-table ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" :row-key="rowKey" @refresh="refreshTable">
<template #status="{ record }">
<a-tag :color="record.is_online ? 'success' : 'default'">
{{ record.is_online ? "在线" : "离线" }}
{{ record.is_online ? '在线' : '离线' }}
</a-tag>
</template>
<template #lastActive="{ record }">
@@ -111,73 +72,44 @@
</template>
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleViewSessions(record)"
>
查看会话
</a-button>
<a-popconfirm
title="确定强制该用户下线吗?"
@confirm="handleOffline(record)"
>
<a-button type="link" size="small" danger>
强制下线
</a-button>
<a-button type="link" size="small" @click="handleViewSessions(record)"> 查看会话 </a-button>
<a-popconfirm title="确定强制该用户下线吗?" @confirm="handleOffline(record)">
<a-button type="link" size="small" danger> 强制下线 </a-button>
</a-popconfirm>
<a-button
type="link"
size="small"
danger
@click="handleOfflineAll(record)"
>
全部下线
</a-button>
<a-button type="link" size="small" danger @click="handleOfflineAll(record)"> 全部下线 </a-button>
</a-space>
</template>
</sc-table>
</div>
<!-- 会话详情弹窗 -->
<sessions-dialog
v-if="dialog.sessions"
ref="sessionsDialogRef"
@success="handleSessionsSuccess"
@closed="dialog.sessions = false"
/>
<sessions-dialog v-if="dialog.sessions" ref="sessionsDialogRef" @success="handleSessionsSuccess" @closed="dialog.sessions = false" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from "vue";
import { message, Modal } from "ant-design-vue";
import {
UserOutlined,
SearchOutlined,
RedoOutlined,
ReloadOutlined,
StopOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import sessionsDialog from "./sessions.vue";
import authApi from "@/api/auth";
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { UserOutlined, SearchOutlined, RedoOutlined, ReloadOutlined, StopOutlined } from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import sessionsDialog from './sessions.vue'
import authApi from '@/api/auth'
defineOptions({
name: "authOnlineUsers",
});
name: 'authOnlineUsers',
})
// 表格引用
const tableRef = ref(null);
const tableRef = ref(null)
// 搜索表单
const searchForm = reactive({
keyword: "",
});
keyword: '',
})
// 表格数据
const tableData = ref([]);
const loading = ref(false);
const tableData = ref([])
const loading = ref(false)
const pagination = reactive({
current: 1,
pageSize: 20,
@@ -185,257 +117,254 @@ const pagination = reactive({
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total) => `共 ${total} 条`,
});
})
// 行key
const rowKey = "id";
const rowKey = 'id'
// 在线用户数量
const onlineCount = ref(0);
const onlineCount = ref(0)
// 刷新定时器
const refreshInterval = ref(30000); // 默认30秒
let refreshTimer = null;
const refreshInterval = ref(30000) // 默认30秒
let refreshTimer = null
// 对话框状态
const dialog = reactive({
sessions: false,
});
})
// 弹窗引用
const sessionsDialogRef = ref(null);
const sessionsDialogRef = ref(null)
// 表格列配置
const columns = [
{
title: "#",
dataIndex: "_index",
key: "_index",
title: '#',
dataIndex: '_index',
key: '_index',
width: 60,
align: "center",
align: 'center',
},
{ title: "用户名", dataIndex: "username", key: "username", width: 150 },
{ title: "真实姓名", dataIndex: "real_name", key: "real_name", width: 150 },
{ title: "邮箱", dataIndex: "email", key: "email", width: 200 },
{ title: "手机号", dataIndex: "phone", key: "phone", width: 150 },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 150 },
{ title: '真实姓名', dataIndex: 'real_name', key: 'real_name', width: 150 },
{ title: '邮箱', dataIndex: 'email', key: 'email', width: 200 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 150 },
{
title: "状态",
dataIndex: "status",
key: "status",
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: "center",
slot: "status",
align: 'center',
slot: 'status',
},
{
title: "最后活跃时间",
dataIndex: "last_active_at",
key: "last_active_at",
title: '最后活跃时间',
dataIndex: 'last_active_at',
key: 'last_active_at',
width: 180,
slot: "lastActive",
slot: 'lastActive',
},
{
title: "最后登录IP",
dataIndex: "last_login_ip",
key: "last_login_ip",
title: '最后登录IP',
dataIndex: 'last_login_ip',
key: 'last_login_ip',
width: 150,
},
{
title: "操作",
dataIndex: "action",
key: "action",
title: '操作',
dataIndex: 'action',
key: 'action',
width: 200,
align: "center",
slot: "action",
fixed: "right",
align: 'center',
slot: 'action',
fixed: 'right',
},
];
]
// 加载在线用户数量
const loadOnlineCount = async () => {
try {
const res = await authApi.onlineUsers.count.get();
const res = await authApi.onlineUser.count.get()
if (res.code === 200) {
onlineCount.value = res.data || 0;
onlineCount.value = res.data || 0
}
} catch (error) {
console.error("获取在线用户数量失败:", error);
console.error('获取在线用户数量失败:', error)
}
};
}
// 加载在线用户列表
const loadOnlineUsers = async () => {
try {
loading.value = true;
loading.value = true
const params = {
...searchForm,
limit: pagination.pageSize,
};
const res = await authApi.onlineUsers.list.get(params);
loading.value = false;
}
const res = await authApi.onlineUser.list.get(params)
loading.value = false
if (res.code === 200) {
// 添加序号
const list = res.data?.list || [];
const list = res.data?.list || []
tableData.value = list.map((item, index) => ({
...item,
_index:
(pagination.current - 1) * pagination.pageSize + index + 1,
}));
pagination.total = res.data?.total || 0;
_index: (pagination.current - 1) * pagination.pageSize + index + 1,
}))
pagination.total = res.data?.total || 0
}
} catch (error) {
console.error("加载在线用户列表失败:", error);
loading.value = false;
console.error('加载在线用户列表失败:', error)
loading.value = false
}
};
}
// 刷新表格
const refreshTable = () => {
loadOnlineCount();
loadOnlineUsers();
};
loadOnlineCount()
loadOnlineUsers()
}
// 搜索
const handleSearch = () => {
pagination.current = 1;
refreshTable();
};
pagination.current = 1
refreshTable()
}
// 重置
const handleReset = () => {
searchForm.keyword = "";
pagination.current = 1;
refreshTable();
};
searchForm.keyword = ''
pagination.current = 1
refreshTable()
}
// 刷新按钮
const handleRefresh = () => {
refreshTable();
message.success("刷新成功");
};
refreshTable()
message.success('刷新成功')
}
// 刷新间隔变化
const handleRefreshIntervalChange = (value) => {
clearRefreshTimer();
clearRefreshTimer()
if (value > 0) {
startRefreshTimer(value);
startRefreshTimer(value)
}
};
}
// 启动刷新定时器
const startRefreshTimer = (interval) => {
refreshTimer = setInterval(() => {
refreshTable();
}, interval);
};
refreshTable()
}, interval)
}
// 清除刷新定时器
const clearRefreshTimer = () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
clearInterval(refreshTimer)
refreshTimer = null
}
};
}
// 查看用户会话
const handleViewSessions = (record) => {
dialog.sessions = true;
dialog.sessions = true
setTimeout(() => {
sessionsDialogRef.value?.open().setData(record);
}, 0);
};
sessionsDialogRef.value?.open().setData(record)
}, 0)
}
// 强制用户下线(单个)
const handleOffline = async (record) => {
try {
const res = await authApi.onlineUsers.offline.post(record.id, {});
const res = await authApi.onlineUser.offline.post(record.id, {})
if (res.code === 200) {
message.success("强制下线成功");
refreshTable();
message.success('强制下线成功')
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("强制下线失败:", error);
message.error("操作失败");
console.error('强制下线失败:', error)
message.error('操作失败')
}
};
}
// 强制用户所有设备下线
const handleOfflineAll = async (record) => {
try {
const res = await authApi.onlineUsers.offlineAll.post(record.id);
const res = await authApi.onlineUser.offlineAll.post(record.id)
if (res.code === 200) {
message.success("全部下线成功");
refreshTable();
message.success('全部下线成功')
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("全部下线失败:", error);
message.error("操作失败");
console.error('全部下线失败:', error)
message.error('操作失败')
}
};
}
// 全部下线
const handleRefreshAllOffline = () => {
Modal.confirm({
title: "确认操作",
content: "确定要强制所有在线用户下线吗?",
okText: "确定",
cancelText: "取消",
okType: "danger",
title: '确认操作',
content: '确定要强制所有在线用户下线吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
// 这里需要遍历所有在线用户并下线
const onlineUsers = tableData.value.filter(
(user) => user.is_online,
);
const onlineUsers = tableData.value.filter((user) => user.is_online)
for (const user of onlineUsers) {
await authApi.onlineUsers.offlineAll.post(user.id);
await authApi.onlineUser.offlineAll.post(user.id)
}
message.success("全部下线成功");
refreshTable();
message.success('全部下线成功')
refreshTable()
} catch (error) {
console.error("全部下线失败:", error);
message.error("操作失败");
console.error('全部下线失败:', error)
message.error('操作失败')
}
},
});
};
})
}
// 会话操作成功回调
const handleSessionsSuccess = () => {
refreshTable();
};
refreshTable()
}
// 格式化日期
const formatDate = (date) => {
if (!date) return "-";
const d = new Date(date);
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 初始化
onMounted(() => {
refreshTable();
refreshTable()
// 启动自动刷新
if (refreshInterval.value > 0) {
startRefreshTimer(refreshInterval.value);
startRefreshTimer(refreshInterval.value)
}
});
})
// 组件卸载时清除定时器
onUnmounted(() => {
clearRefreshTimer();
});
clearRefreshTimer()
})
</script>
<style scoped lang="scss">
@@ -1,35 +1,18 @@
<template>
<a-modal
title="用户会话详情"
:open="visible"
:width="800"
:destroy-on-close="true"
:footer="null"
@cancel="handleCancel"
>
<a-modal title="用户会话详情" :open="visible" :width="800" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
<div class="sessions-content">
<!-- 用户信息 -->
<div class="user-info">
<a-descriptions :column="3" bordered size="small">
<a-descriptions-item label="用户名">{{
userInfo.username
}}</a-descriptions-item>
<a-descriptions-item label="真实姓名">{{
userInfo.real_name
}}</a-descriptions-item>
<a-descriptions-item label="用户名">{{ userInfo.username }}</a-descriptions-item>
<a-descriptions-item label="真实姓名">{{ userInfo.real_name }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag
:color="userInfo.is_online ? 'success' : 'default'"
>
{{ userInfo.is_online ? "在线" : "离线" }}
<a-tag :color="userInfo.is_online ? 'success' : 'default'">
{{ userInfo.is_online ? '在线' : '离线' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{
userInfo.email
}}</a-descriptions-item>
<a-descriptions-item label="手机号">{{
userInfo.phone
}}</a-descriptions-item>
<a-descriptions-item label="邮箱" :span="2">{{ userInfo.email }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ userInfo.phone }}</a-descriptions-item>
</a-descriptions>
</div>
@@ -37,93 +20,42 @@
<div class="sessions-list">
<div class="list-header">
<span>会话列表({{ sessions.length }} 个)</span>
<a-button
type="link"
size="small"
danger
@click="handleOfflineAll"
>
全部下线
</a-button>
<a-button type="link" size="small" danger @click="handleOfflineAll"> 全部下线 </a-button>
</div>
<a-list :data-source="sessions" :loading="loading" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta>
<template #avatar>
<a-avatar
shape="square"
:icon="
item.device_type === 'pc'
? 'DesktopOutlined'
: 'MobileOutlined'
"
/>
<a-avatar shape="square" :icon="item.device_type === 'pc' ? 'DesktopOutlined' : 'MobileOutlined'" />
</template>
<template #title>
<div class="session-title">
<span>{{
getDeviceName(item.device_type)
}}</span>
<a-tag
:color="
item.is_online
? 'success'
: 'default'
"
size="small"
>
{{
item.is_online ? "活跃" : "过期"
}}
<span>{{ getDeviceName(item.device_type) }}</span>
<a-tag :color="item.is_online ? 'success' : 'default'" size="small">
{{ item.is_online ? '活跃' : '过期' }}
</a-tag>
</div>
</template>
<template #description>
<div class="session-info">
<div>
<span class="label">IP地址:</span
>{{ item.ip_address }}
</div>
<div>
<span class="label">登录时间:</span
>{{ formatDate(item.created_at) }}
</div>
<div>
<span class="label">最后活跃:</span
>{{
formatDate(item.last_active_at)
}}
</div>
<div v-if="item.user_agent">
<span class="label">浏览器:</span
>{{ item.user_agent }}
</div>
<div><span class="label">IP地址:</span>{{ item.ip_address }}</div>
<div><span class="label">登录时间:</span>{{ formatDate(item.created_at) }}</div>
<div><span class="label">最后活跃:</span>{{ formatDate(item.last_active_at) }}</div>
<div v-if="item.user_agent"><span class="label">浏览器:</span>{{ item.user_agent }}</div>
</div>
</template>
</a-list-item-meta>
<template #actions>
<a-popconfirm
v-if="item.is_online"
title="确定强制该会话下线吗?"
@confirm="handleOffline(item)"
>
<a-button type="link" size="small" danger>
强制下线
</a-button>
<a-popconfirm v-if="item.is_online" title="确定强制该会话下线吗?" @confirm="handleOffline(item)">
<a-button type="link" size="small" danger> 强制下线 </a-button>
</a-popconfirm>
<a-tag v-else color="default" size="small"
>已过期</a-tag
>
<a-tag v-else color="default" size="small">已过期</a-tag>
</template>
</a-list-item>
</template>
</a-list>
<a-empty
v-if="!loading && sessions.length === 0"
description="暂无会话数据"
:image-size="80"
/>
<a-empty v-if="!loading && sessions.length === 0" description="暂无会话数据" :image-size="80" />
</div>
</div>
<template #footer>
@@ -133,152 +65,152 @@
</template>
<script setup>
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
defineOptions({
name: "OnlineUserSessions",
});
name: 'OnlineUserSessions',
})
const emit = defineEmits(["success", "closed"]);
const emit = defineEmits(['success', 'closed'])
const visible = ref(false);
const loading = ref(false);
const visible = ref(false)
const loading = ref(false)
// 用户信息
const userInfo = reactive({
id: "",
username: "",
real_name: "",
email: "",
phone: "",
id: '',
username: '',
real_name: '',
email: '',
phone: '',
is_online: false,
});
})
// 会话列表
const sessions = ref([]);
const sessions = ref([])
// 打开对话框
const open = () => {
visible.value = true;
visible.value = true
return {
open,
setData,
close,
};
};
}
}
// 关闭对话框
const close = () => {
visible.value = false;
};
visible.value = false
}
// 处理取消
const handleCancel = () => {
emit("closed");
visible.value = false;
};
emit('closed')
visible.value = false
}
// 加载用户会话
const loadUserSessions = async (userId) => {
try {
loading.value = true;
const res = await authApi.onlineUsers.sessions.get(userId);
loading.value = false;
loading.value = true
const res = await authApi.onlineUser.sessions.get(userId)
loading.value = false
if (res.code === 200) {
sessions.value = res.data || [];
sessions.value = res.data || []
}
} catch (error) {
console.error("加载用户会话失败:", error);
loading.value = false;
message.error("加载会话失败");
console.error('加载用户会话失败:', error)
loading.value = false
message.error('加载会话失败')
}
};
}
// 设置数据
const setData = (data) => {
userInfo.id = data.id;
userInfo.username = data.username;
userInfo.real_name = data.real_name;
userInfo.email = data.email;
userInfo.phone = data.phone;
userInfo.is_online = data.is_online;
userInfo.id = data.id
userInfo.username = data.username
userInfo.real_name = data.real_name
userInfo.email = data.email
userInfo.phone = data.phone
userInfo.is_online = data.is_online
// 加载会话列表
loadUserSessions(data.id);
};
loadUserSessions(data.id)
}
// 强制会话下线
const handleOffline = async (session) => {
try {
const res = await authApi.onlineUsers.offline.post(userInfo.id, {
const res = await authApi.onlineUser.offline.post(userInfo.id, {
token: session.token,
});
})
if (res.code === 200) {
message.success("强制下线成功");
emit("success");
message.success('强制下线成功')
emit('success')
// 重新加载会话列表
loadUserSessions(userInfo.id);
loadUserSessions(userInfo.id)
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("强制下线失败:", error);
message.error("操作失败");
console.error('强制下线失败:', error)
message.error('操作失败')
}
};
}
// 全部下线
const handleOfflineAll = async () => {
try {
const res = await authApi.onlineUsers.offlineAll.post(userInfo.id);
const res = await authApi.onlineUser.offlineAll.post(userInfo.id)
if (res.code === 200) {
message.success("全部下线成功");
emit("success");
message.success('全部下线成功')
emit('success')
// 重新加载会话列表
loadUserSessions(userInfo.id);
loadUserSessions(userInfo.id)
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("全部下线失败:", error);
message.error("操作失败");
console.error('全部下线失败:', error)
message.error('操作失败')
}
};
}
// 获取设备名称
const getDeviceName = (deviceType) => {
const deviceMap = {
pc: "电脑端",
mobile: "手机端",
tablet: "平板端",
unknown: "未知设备",
};
return deviceMap[deviceType] || deviceMap.unknown;
};
pc: '电脑端',
mobile: '手机端',
tablet: '平板端',
unknown: '未知设备',
}
return deviceMap[deviceType] || deviceMap.unknown
}
// 格式化日期
const formatDate = (date) => {
if (!date) return "-";
const d = new Date(date);
return d.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
if (!date) return '-'
const d = new Date(date)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 暴露方法给父组件
defineExpose({
open,
setData,
close,
});
})
</script>
<style scoped lang="scss">
@@ -1,37 +1,15 @@
<template>
<a-form
:model="form"
:rules="rules"
ref="formRef"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form :model="form" :rules="rules" ref="formRef" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<!-- 第一行:权限名称和类型 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="权限名称" name="title" required>
<a-input
v-model:value="form.title"
placeholder="如:用户管理"
allow-clear
maxlength="50"
show-count
/>
<a-input v-model:value="form.title" placeholder="如:用户管理" allow-clear maxlength="50" show-count />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="权限类型"
name="type"
required
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-radio-group
v-model:value="form.type"
button-style="solid"
@change="handleTypeChange"
>
<a-form-item label="权限类型" name="type" required :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-radio-group v-model:value="form.type" button-style="solid" @change="handleTypeChange">
<a-radio-button value="menu">菜单</a-radio-button>
<a-radio-button value="api">接口</a-radio-button>
<a-radio-button value="button">按钮</a-radio-button>
@@ -45,29 +23,13 @@
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="上级权限" name="parent_id">
<a-tree-select
v-model:value="form.parent_id"
:tree-data="menuOptions"
:field-names="menuFieldNames"
:tree-default-expand-all="false"
show-icon
placeholder="顶级权限"
allow-clear
tree-node-filter-prop="title"
:disabled="!!menuId"
/>
<a-tree-select v-model:value="form.parent_id" :tree-data="menuOptions" :field-names="menuFieldNames" :tree-default-expand-all="false" show-icon placeholder="顶级权限" allow-clear tree-node-filter-prop="title" :disabled="!!menuId" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="权限编码" name="name" required>
<a-input
v-model:value="form.name"
placeholder="如:system.users.index"
allow-clear
/>
<div class="form-tip">
格式:模块.功能.操作,系统唯一标识,用于权限验证
</div>
<a-input v-model:value="form.name" placeholder="如:system.users.index" allow-clear />
<div class="form-tip">格式:模块.功能.操作,系统唯一标识,用于权限验证</div>
</a-form-item>
</a-col>
</a-row>
@@ -75,38 +37,18 @@
<!-- 第三行:路由地址和组件路径(菜单类型才显示) -->
<a-row v-if="form.type === 'menu'" :gutter="16">
<a-col :span="12">
<a-form-item
label="路由地址"
name="path"
:required="isLeafNode"
>
<a-input
v-model:value="form.path"
placeholder="/system/users"
allow-clear
/>
<a-form-item label="路由地址" name="path" :required="isLeafNode">
<a-input v-model:value="form.path" placeholder="/system/users" allow-clear />
<div class="form-tip">前端路由路径,如 /system/users</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="组件路径"
name="component"
:required="isLeafNode"
>
<a-input
v-model:value="form.component"
placeholder="system/users/index"
allow-clear
>
<a-form-item label="组件路径" name="component" :required="isLeafNode">
<a-input v-model:value="form.component" placeholder="system/users/index" allow-clear>
<template #addonBefore>pages/</template>
</a-input>
<div class="form-tip" v-if="!isLeafNode">
父级菜单或包含子菜单时不需要填写
</div>
<div class="form-tip" v-else>
最后一级菜单必须填写,如 system/users/index
</div>
<div class="form-tip" v-if="!isLeafNode">父级菜单或包含子菜单时不需要填写</div>
<div class="form-tip" v-else>最后一级菜单必须填写,如 system/users/index</div>
</a-form-item>
</a-col>
</a-row>
@@ -115,14 +57,8 @@
<a-row v-if="form.type === 'api'" :gutter="16">
<a-col :span="12">
<a-form-item label="API路由" name="path" required>
<a-input
v-model:value="form.path"
placeholder="如:users.index"
allow-clear
/>
<div class="form-tip">
后端 API 路由名称,用于接口权限验证
</div>
<a-input v-model:value="form.path" placeholder="如:users.index" allow-clear />
<div class="form-tip">后端 API 路由名称,用于接口权限验证</div>
</a-form-item>
</a-col>
</a-row>
@@ -131,11 +67,7 @@
<a-row v-if="form.type === 'url'" :gutter="16">
<a-col :span="12">
<a-form-item label="链接地址" name="path" required>
<a-input
v-model:value="form.path"
placeholder="https://example.com"
allow-clear
/>
<a-input v-model:value="form.path" placeholder="https://example.com" allow-clear />
<div class="form-tip">外部链接地址</div>
</a-form-item>
</a-col>
@@ -145,20 +77,12 @@
<a-row v-if="form.type === 'menu'" :gutter="16">
<a-col :span="12">
<a-form-item label="菜单图标" name="icon">
<sc-icon-picker
v-model:value="form.icon"
placeholder="请选择图标"
/>
<sc-icon-picker v-model:value="form.icon" placeholder="请选择图标" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<div class="form-tip">数值越小越靠前</div>
</a-form-item>
</a-col>
@@ -172,36 +96,16 @@
<h4>选项设置</h4>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="显示选项"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-checkbox v-model:checked="form.hidden"
>隐藏菜单</a-checkbox
>
<a-checkbox v-model:checked="form.hiddenBreadcrumb"
>隐藏面包屑</a-checkbox
>
<a-checkbox v-model:checked="form.affix"
>固定标签页</a-checkbox
>
<a-form-item label="显示选项" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-checkbox v-model:checked="form.hidden">隐藏菜单</a-checkbox>
<a-checkbox v-model:checked="form.hiddenBreadcrumb">隐藏面包屑</a-checkbox>
<a-checkbox v-model:checked="form.affix">固定标签页</a-checkbox>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="页面缓存"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-switch
v-model:checked="form.keepAlive"
checked-children="启用"
un-checked-children="禁用"
/>
<div class="form-tip">
启用后页面会被缓存,切换回来时保留状态
</div>
<a-form-item label="页面缓存" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-switch v-model:checked="form.keepAlive" checked-children="启用" un-checked-children="禁用" />
<div class="form-tip">启用后页面会被缓存,切换回来时保留状态</div>
</a-form-item>
</a-col>
</a-row>
@@ -228,34 +132,17 @@
<!-- 状态 -->
<a-row :gutter="16">
<a-col :span="12">
<a-form-item
label="启用状态"
name="status"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
<a-form-item label="启用状态" name="status" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
<div class="form-tip">禁用后该权限将不生效</div>
</a-form-item>
</a-col>
</a-row>
<!-- 操作按钮 -->
<a-form-item
:wrapper-col="{ span: 18, offset: 5 }"
style="margin-top: 32px"
>
<a-form-item :wrapper-col="{ span: 18, offset: 5 }" style="margin-top: 32px">
<a-space>
<a-button
type="primary"
@click="handleSave"
:loading="loading"
size="large"
>
<a-button type="primary" @click="handleSave" :loading="loading" size="large">
<template #icon><CheckOutlined /></template>
保存
</a-button>
@@ -269,159 +156,152 @@
</template>
<script setup>
import { ref, reactive, watch, computed, onMounted } from "vue";
import { message } from "ant-design-vue";
import { CheckOutlined, CloseOutlined } from "@ant-design/icons-vue";
import authApi from "@/api/auth";
import scIconPicker from "@/components/scIconPicker/index.vue";
import { ref, reactive, watch, computed, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { CheckOutlined, CloseOutlined } from '@ant-design/icons-vue'
import authApi from '@/api/auth'
import scIconPicker from '@/components/scIconPicker/index.vue'
defineOptions({
name: "PermissionSaveForm",
});
name: 'PermissionSaveForm',
})
const props = defineProps({
menu: { type: [Object, Array], default: () => [] },
menuId: { type: [Number, String], default: null },
parentId: { type: [Number, String], default: null },
});
})
const emit = defineEmits(["success", "cancel"]);
const emit = defineEmits(['success', 'cancel'])
// 表单数据
const form = reactive({
id: "",
id: '',
parent_id: 0,
name: "",
title: "",
path: "",
component: "",
icon: "",
name: '',
title: '',
path: '',
component: '',
icon: '',
sort: 0,
type: "menu",
type: 'menu',
status: 1,
target: "_self",
target: '_self',
// meta 字段内容
hidden: false,
hiddenBreadcrumb: false,
keepAlive: false,
affix: false,
});
})
// 表单引用
const formRef = ref();
const loading = ref(false);
const formRef = ref()
const loading = ref(false)
// 验证规则
const rules = {
title: [
{ required: true, message: "请输入权限名称", trigger: "blur" },
{ max: 50, message: "权限名称不能超过50个字符", trigger: "blur" },
{ required: true, message: '请输入权限名称', trigger: 'blur' },
{ max: 50, message: '权限名称不能超过50个字符', trigger: 'blur' },
],
name: [
{ required: true, message: "请输入权限编码", trigger: "blur" },
{ required: true, message: '请输入权限编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_.]*$/,
message: "权限编码格式不正确,格式:模块.功能.操作",
trigger: "blur",
message: '权限编码格式不正确,格式:模块.功能.操作',
trigger: 'blur',
},
],
type: [{ required: true, message: "请选择权限类型", trigger: "change" }],
type: [{ required: true, message: '请选择权限类型', trigger: 'change' }],
path: (rule, value) => {
// 根据类型动态验证
if (
form.type === "menu" ||
form.type === "api" ||
form.type === "url"
) {
if (form.type === 'menu' || form.type === 'api' || form.type === 'url') {
if (!value || !value.trim()) {
return Promise.reject("请输入" + getPathLabel(form.type));
return Promise.reject('请输入' + getPathLabel(form.type))
}
}
return Promise.resolve();
return Promise.resolve()
},
component: (rule, value) => {
// 仅在菜单类型且为叶子节点时验证
if (form.type === "menu" && isLeafNode.value) {
if (form.type === 'menu' && isLeafNode.value) {
if (!value || !value.trim()) {
return Promise.reject("请输入组件路径");
return Promise.reject('请输入组件路径')
}
}
return Promise.resolve();
return Promise.resolve()
},
};
}
// 路径字段标签
const getPathLabel = (type) => {
const labelMap = {
menu: "路由地址",
api: "API路由",
url: "链接地址",
};
return labelMap[type] || "路径";
};
menu: '路由地址',
api: 'API路由',
url: '链接地址',
}
return labelMap[type] || '路径'
}
// 状态开关计算属性
const statusChecked = computed({
get: () => form.status === 1,
set: (val) => {
form.status = val ? 1 : 0;
form.status = val ? 1 : 0
},
});
})
// 判断是否为叶子节点(没有子节点的节点)
const isLeafNode = computed(() => {
// 这里需要根据当前节点是否有子节点来判断
// 暂时返回 false,需要根据实际数据判断
return !hasChildren.value;
});
return !hasChildren.value
})
// 判断是否有子节点
const hasChildren = computed(() => {
if (!form.id || !props.menu) return false;
const node = findMenuNode(props.menu, form.id);
return node && node.children && node.children.length > 0;
});
if (!form.id || !props.menu) return false
const node = findMenuNode(props.menu, form.id)
return node && node.children && node.children.length > 0
})
// 菜单选项
const menuOptions = ref([]);
const menuOptions = ref([])
const menuFieldNames = {
value: "id",
label: "title",
children: "children",
};
value: 'id',
label: 'title',
children: 'children',
}
// 筛单化菜单树,排除自己和子节点
const treeToMap = (tree, excludeId = null) => {
const map = [];
const map = []
tree.forEach((item) => {
if (item.id === excludeId) return; // 排除自己
if (item.id === excludeId) return // 排除自己
const obj = {
id: item.id,
parent_id: item.parent_id,
title: item.title,
children:
item.children && item.children.length > 0
? treeToMap(item.children, excludeId)
: null,
};
map.push(obj);
});
return map;
};
children: item.children && item.children.length > 0 ? treeToMap(item.children, excludeId) : null,
}
map.push(obj)
})
return map
}
// 查找权限节点
const findMenuNode = (tree, id) => {
for (const node of tree) {
if (node.id === id) {
return node;
return node
}
if (node.children && node.children.length > 0) {
const found = findMenuNode(node.children, id);
if (found) return found;
const found = findMenuNode(node.children, id)
if (found) return found
}
}
return null;
};
return null
}
// 监听菜单树变化
watch(
@@ -429,85 +309,85 @@ watch(
(newVal) => {
if (newVal) {
// 排除当前编辑的节点,避免选择自己作为父节点
menuOptions.value = treeToMap(newVal, props.menuId);
menuOptions.value = treeToMap(newVal, props.menuId)
}
},
{ deep: true, immediate: true },
);
)
// 监听 menuId 变化,从菜单树中查找并赋值
watch(
() => props.menuId,
(newVal) => {
if (newVal && props.menu && props.menu.length > 0) {
const menuNode = findMenuNode(props.menu, newVal);
const menuNode = findMenuNode(props.menu, newVal)
if (menuNode) {
setData(menuNode, props.parentId);
setData(menuNode, props.parentId)
}
} else if (!newVal) {
// 清空表单
resetForm();
resetForm()
}
},
);
)
// 类型切换处理
const handleTypeChange = () => {
// 类型切换时清空一些字段
form.path = "";
form.component = "";
form.icon = "";
form.path = ''
form.component = ''
form.icon = ''
// 非菜单类型时清空meta相关字段
if (form.type !== "menu") {
form.hidden = false;
form.hiddenBreadcrumb = false;
form.keepAlive = false;
form.affix = false;
if (form.type !== 'menu') {
form.hidden = false
form.hiddenBreadcrumb = false
form.keepAlive = false
form.affix = false
}
// 非链接类型时重置target
if (form.type !== "url") {
form.target = "_self";
if (form.type !== 'url') {
form.target = '_self'
}
};
}
// 重置表单
const resetForm = () => {
Object.assign(form, {
id: "",
id: '',
parent_id: props.parentId || 0,
name: "",
title: "",
path: "",
component: "",
icon: "",
name: '',
title: '',
path: '',
component: '',
icon: '',
sort: 0,
type: "menu",
type: 'menu',
status: 1,
target: "_self",
target: '_self',
hidden: false,
hiddenBreadcrumb: false,
keepAlive: false,
affix: false,
});
};
})
}
// 加载权限详情
const loadMenuDetail = async (id) => {
try {
const res = await authApi.permissions.detail.get(id);
const res = await authApi.permission.detail.get(id)
if (res.code === 200 && res.data) {
setData(res.data, props.parentId);
setData(res.data, props.parentId)
}
} catch (error) {
console.error("加载权限详情失败:", error);
console.error('加载权限详情失败:', error)
}
};
}
// 保存
const handleSave = async () => {
try {
await formRef.value.validate();
loading.value = true;
await formRef.value.validate()
loading.value = true
// 构建提交数据
const submitData = {
@@ -523,104 +403,97 @@ const handleSave = async () => {
status: form.status,
target: form.target,
meta: null,
};
}
// 仅菜单类型才有meta字段
if (form.type === "menu") {
if (form.type === 'menu') {
submitData.meta = {
hidden: form.hidden,
hiddenBreadcrumb: form.hiddenBreadcrumb,
keepAlive: form.keepAlive,
affix: form.affix,
};
}
}
// 根据类型处理空值
if (form.type === "button" || form.type === "api") {
submitData.component = "";
submitData.icon = "";
if (form.type === 'button' || form.type === 'api') {
submitData.component = ''
submitData.icon = ''
}
if (form.type === "button") {
submitData.path = "";
if (form.type === 'button') {
submitData.path = ''
}
if (
form.type === "api" ||
form.type === "button" ||
form.type === "url"
) {
submitData.meta = null;
if (form.type === 'api' || form.type === 'button' || form.type === 'url') {
submitData.meta = null
}
let res = {};
let res = {}
if (form.id) {
res = await authApi.permissions.edit.put(form.id, submitData);
res = await authApi.permission.edit.put(form.id, submitData)
} else {
res = await authApi.permissions.add.post(submitData);
res = await authApi.permission.add.post(submitData)
}
loading.value = false;
loading.value = false
if (res.code === 200) {
message.success("保存成功");
emit("success");
message.success('保存成功')
emit('success')
} else {
message.error(res.message || "保存失败");
message.error(res.message || '保存失败')
}
} catch (error) {
console.error("表单验证失败", error);
loading.value = false;
console.error('表单验证失败', error)
loading.value = false
if (error?.errorFields) {
// 表单验证失败
return;
return
}
message.error("保存失败");
message.error('保存失败')
}
};
}
// 表单注入数据
const setData = (data, pid) => {
form.id = data.id || "";
form.parent_id = data.parent_id !== undefined ? data.parent_id : pid || 0;
form.name = data.name || "";
form.title = data.title || "";
form.path = data.path || "";
form.component = data.component || "";
form.icon = data.icon || "";
form.sort = data.sort || 0;
form.type = data.type || "menu";
form.status = data.status !== undefined ? data.status : 1;
form.target = data.target || "_self";
form.id = data.id || ''
form.parent_id = data.parent_id !== undefined ? data.parent_id : pid || 0
form.name = data.name || ''
form.title = data.title || ''
form.path = data.path || ''
form.component = data.component || ''
form.icon = data.icon || ''
form.sort = data.sort || 0
form.type = data.type || 'menu'
form.status = data.status !== undefined ? data.status : 1
form.target = data.target || '_self'
// 解析 meta 字段
const meta =
data.meta && typeof data.meta === "string"
? JSON.parse(data.meta)
: data.meta || {};
form.hidden = meta.hidden || false;
form.hiddenBreadcrumb = meta.hiddenBreadcrumb || false;
form.keepAlive = meta.keepAlive || false;
form.affix = meta.affix || false;
};
const meta = data.meta && typeof data.meta === 'string' ? JSON.parse(data.meta) : data.meta || {}
form.hidden = meta.hidden || false
form.hiddenBreadcrumb = meta.hiddenBreadcrumb || false
form.keepAlive = meta.keepAlive || false
form.affix = meta.affix || false
}
// 初始化
onMounted(() => {
if (props.menuId) {
loadMenuDetail(props.menuId);
loadMenuDetail(props.menuId)
} else if (props.parentId) {
form.parent_id = props.parentId;
form.parent_id = props.parentId
}
});
})
// 清空表单验证
const clearValidate = () => {
formRef.value?.clearValidate();
};
formRef.value?.clearValidate()
}
// 暴露方法给父组件
defineExpose({
setData,
clearValidate,
resetForm,
});
})
</script>
<style scoped lang="scss">
@@ -3,43 +3,24 @@
<div class="left-box">
<div class="header">
<div class="search-wrapper">
<a-input
v-model:value="menuFilterText"
placeholder="搜索权限名称或编码..."
allow-clear
@change="handleMenuSearch"
>
<a-input v-model:value="menuFilterText" placeholder="搜索权限名称或编码..." allow-clear @change="handleMenuSearch">
<template #prefix>
<SearchOutlined
style="color: rgba(0, 0, 0, 0.45)"
/>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
</a-input>
</div>
<div class="actions">
<a-space size="small">
<a-tooltip
:title="isAllExpanded ? '折叠全部' : '展开全部'"
>
<a-button
type="text"
size="small"
@click="handleToggleExpand"
>
<a-tooltip :title="isAllExpanded ? '折叠全部' : '展开全部'">
<a-button type="text" size="small" @click="handleToggleExpand">
<template #icon>
<UnorderedListOutlined
v-if="!isAllExpanded"
/>
<UnorderedListOutlined v-if="!isAllExpanded" />
<OrderedListOutlined v-else />
</template>
</a-button>
</a-tooltip>
<a-tooltip title="添加根权限">
<a-button
type="text"
size="small"
@click="handleAdd(null)"
>
<a-button type="text" size="small" @click="handleAdd(null)">
<template #icon><PlusOutlined /></template>
</a-button>
</a-tooltip>
@@ -67,40 +48,19 @@
@check="onMenuCheck"
>
<template #icon="{ dataRef }">
<FolderOutlined
v-if="
dataRef.type === 'menu' &&
dataRef.children?.length
"
/>
<FolderOpenOutlined
v-else-if="dataRef.type === 'menu'"
/>
<FolderOutlined v-if="dataRef.type === 'menu' && dataRef.children?.length" />
<FolderOpenOutlined v-else-if="dataRef.type === 'menu'" />
<ApiOutlined v-else-if="dataRef.type === 'api'" />
<ControlOutlined v-else />
</template>
<template #title="{ dataRef }">
<span class="tree-node-content">
<span class="tree-node-title">{{
dataRef.title
}}</span>
<a-tag
v-if="dataRef.name"
class="tree-node-code"
size="small"
>{{ dataRef.name }}</a-tag
>
<a-tag
v-if="dataRef.type !== 'menu'"
:color="getTypeColor(dataRef.type)"
size="small"
>
<span class="tree-node-title">{{ dataRef.title }}</span>
<a-tag v-if="dataRef.name" class="tree-node-code" size="small">{{ dataRef.name }}</a-tag>
<a-tag v-if="dataRef.type !== 'menu'" :color="getTypeColor(dataRef.type)" size="small">
{{ getTypeLabel(dataRef.type) }}
</a-tag>
<span
v-if="!dataRef.status"
class="tree-node-disabled"
>
<span v-if="!dataRef.status" class="tree-node-disabled">
<StopOutlined />
</span>
</span>
@@ -112,24 +72,13 @@
<div class="right-box">
<div class="header">
<div class="title-wrapper">
<span class="title">{{
selectedMenu?.title || "请选择权限节点"
}}</span>
<a-tag
v-if="selectedMenu"
:color="getTypeColor(selectedMenu.type)"
size="small"
>
<span class="title">{{ selectedMenu?.title || '请选择权限节点' }}</span>
<a-tag v-if="selectedMenu" :color="getTypeColor(selectedMenu.type)" size="small">
{{ getTypeLabel(selectedMenu.type) }}
</a-tag>
</div>
<a-space>
<a-button
v-if="checkedMenuKeys.length > 0"
danger
size="small"
@click="handleDeleteBatch"
>
<a-button v-if="checkedMenuKeys.length > 0" danger size="small" @click="handleDeleteBatch">
<template #icon><DeleteOutlined /></template>
批量删除 ({{ checkedMenuKeys.length }})
</a-button>
@@ -140,15 +89,9 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleImport">
<ImportOutlined />导入权限
</a-menu-item>
<a-menu-item @click="handleExport">
<ExportOutlined />导出权限
</a-menu-item>
<a-menu-item @click="handleDownloadTemplate">
<DownloadOutlined />下载模板
</a-menu-item>
<a-menu-item @click="handleImport"> <ImportOutlined />导入权限 </a-menu-item>
<a-menu-item @click="handleExport"> <ExportOutlined />导出权限 </a-menu-item>
<a-menu-item @click="handleDownloadTemplate"> <DownloadOutlined />下载模板 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -160,411 +103,355 @@
</div>
<div class="body">
<a-spin :spinning="detailLoading" :delay="200">
<save-form
v-if="selectedMenu"
:menu="menuTree"
:menu-id="selectedMenu.id"
:parent-id="parentId"
@success="handleSaveSuccess"
/>
<a-empty
v-else
description="请选择左侧权限节点后操作"
:image-size="100"
/>
<save-form v-if="selectedMenu" :menu="menuTree" :menu-id="selectedMenu.id" :parent-id="parentId" @success="handleSaveSuccess" />
<a-empty v-else description="请选择左侧权限节点后操作" :image-size="100" />
</a-spin>
</div>
</div>
</div>
<!-- 导入权限弹窗 -->
<sc-import
v-model:open="dialog.import"
title="导入权限"
:api="authApi.permissions.import.post"
:template-api="authApi.permissions.downloadTemplate.get"
filename="权限"
@success="handleImportSuccess"
/>
<sc-import v-model:open="dialog.import" title="导入权限" :api="authApi.permission.import.post" :template-api="authApi.permission.downloadTemplate.get" filename="权限" @success="handleImportSuccess" />
<!-- 导出权限弹窗 -->
<sc-export
v-model:open="dialog.export"
title="导出权限"
:api="handleExportApi"
:default-filename="`权限列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有权限数据"
@success="handleExportSuccess"
/>
<sc-export v-model:open="dialog.export" title="导出权限" :api="handleExportApi" :default-filename="`权限列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有权限数据" @success="handleExportSuccess" />
</template>
<script setup>
import { ref, onMounted, nextTick } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
ReloadOutlined,
PlusOutlined,
FolderOutlined,
FolderOpenOutlined,
ApiOutlined,
ControlOutlined,
UnorderedListOutlined,
OrderedListOutlined,
DeleteOutlined,
StopOutlined,
ImportOutlined,
ExportOutlined,
DownloadOutlined,
MoreOutlined,
} from "@ant-design/icons-vue";
import { computed } from "vue";
import saveForm from "./components/SaveForm.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import authApi from "@/api/auth";
import { ref, onMounted, nextTick } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, ReloadOutlined, PlusOutlined, FolderOutlined, FolderOpenOutlined, ApiOutlined, ControlOutlined, UnorderedListOutlined, OrderedListOutlined, DeleteOutlined, StopOutlined, ImportOutlined, ExportOutlined, DownloadOutlined, MoreOutlined } from '@ant-design/icons-vue'
import { computed } from 'vue'
import saveForm from './components/SaveForm.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import authApi from '@/api/auth'
defineOptions({
name: "authPermission",
});
name: 'authPermission',
})
// 菜单树数据
const menuTree = ref([]);
const filteredMenuTree = ref([]);
const selectedMenuKeys = ref([]);
const checkedMenuKeys = ref([]);
const expandedKeys = ref([]);
const menuFilterText = ref("");
const menuTree = ref([])
const filteredMenuTree = ref([])
const selectedMenuKeys = ref([])
const checkedMenuKeys = ref([])
const expandedKeys = ref([])
const menuFilterText = ref('')
// 当前选中的菜单
const selectedMenu = ref(null);
const parentId = ref(null);
const selectedMenu = ref(null)
const parentId = ref(null)
// 加载状态
const loading = ref(false);
const detailLoading = ref(false);
const loading = ref(false)
const detailLoading = ref(false)
// 对话框状态
const dialog = ref({
import: false,
export: false,
});
})
// 树引用
const treeRef = ref();
const treeRef = ref()
// 是否全部展开
const isAllExpanded = computed(() => {
const allKeys = getAllKeys(filteredMenuTree.value);
return allKeys.length > 0 && expandedKeys.value.length === allKeys.length;
});
const allKeys = getAllKeys(filteredMenuTree.value)
return allKeys.length > 0 && expandedKeys.value.length === allKeys.length
})
// 切换展开/折叠
const handleToggleExpand = () => {
if (isAllExpanded.value) {
handleCollapseAll();
handleCollapseAll()
} else {
handleExpandAll();
handleExpandAll()
}
};
}
// 加载权限树
const loadMenuTree = async () => {
try {
loading.value = true;
const res = await authApi.permissions.tree.get();
loading.value = true
const res = await authApi.permission.tree.get()
if (res.code === 200) {
menuTree.value = res.data || [];
filteredMenuTree.value = res.data || [];
menuTree.value = res.data || []
filteredMenuTree.value = res.data || []
// 默认展开第一层
expandAllKeys(menuTree.value, 1);
expandAllKeys(menuTree.value, 1)
} else {
message.error(res.message || "加载权限树失败");
message.error(res.message || '加载权限树失败')
}
} catch (error) {
console.error("加载权限树失败:", error);
message.error("加载权限树失败");
console.error('加载权限树失败:', error)
message.error('加载权限树失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 刷新
const handleRefresh = () => {
loadMenuTree();
loadMenuTree()
if (selectedMenu.value) {
// 重新获取当前选中的权限详情
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id);
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
if (menuNode) {
selectedMenu.value = menuNode;
selectedMenu.value = menuNode
}
}
};
}
// 搜索权限
const handleMenuSearch = (e) => {
const keyword = (e.target?.value || "").trim();
menuFilterText.value = keyword;
const keyword = (e.target?.value || '').trim()
menuFilterText.value = keyword
if (!keyword) {
filteredMenuTree.value = menuTree.value;
return;
filteredMenuTree.value = menuTree.value
return
}
// 递归过滤权限树(支持搜索名称和编码)
const filterTree = (nodes) => {
return nodes.reduce((acc, node) => {
const titleMatch =
node.title &&
node.title.toLowerCase().includes(keyword.toLowerCase());
const nameMatch =
node.name &&
node.name.toLowerCase().includes(keyword.toLowerCase());
const isMatch = titleMatch || nameMatch;
const filteredChildren = node.children
? filterTree(node.children)
: [];
const titleMatch = node.title && node.title.toLowerCase().includes(keyword.toLowerCase())
const nameMatch = node.name && node.name.toLowerCase().includes(keyword.toLowerCase())
const isMatch = titleMatch || nameMatch
const filteredChildren = node.children ? filterTree(node.children) : []
if (isMatch || filteredChildren.length > 0) {
acc.push({
...node,
children:
filteredChildren.length > 0
? filteredChildren
: undefined,
});
children: filteredChildren.length > 0 ? filteredChildren : undefined,
})
}
return acc;
}, []);
};
return acc
}, [])
}
filteredMenuTree.value = filterTree(menuTree.value);
filteredMenuTree.value = filterTree(menuTree.value)
// 搜索时展开所有匹配节点
expandAllKeys(filteredMenuTree.value);
};
expandAllKeys(filteredMenuTree.value)
}
// 查找权限节点
const findMenuNode = (tree, id) => {
for (const node of tree) {
if (node.id === id) {
return node;
return node
}
if (node.children && node.children.length > 0) {
const found = findMenuNode(node.children, id);
if (found) return found;
const found = findMenuNode(node.children, id)
if (found) return found
}
}
return null;
};
return null
}
// 查找父节点ID
const findParentId = (tree, id) => {
for (const node of tree) {
if (node.children && node.children.length > 0) {
const child = node.children.find((child) => child.id === id);
const child = node.children.find((child) => child.id === id)
if (child) {
return node.id;
return node.id
}
const found = findParentId(node.children, id);
if (found !== null) return found;
const found = findParentId(node.children, id)
if (found !== null) return found
}
}
return null;
};
return null
}
// 限制选择事件
const onMenuSelect = (selectedKeys, { selected }) => {
if (selected) {
const menuId = selectedKeys[0];
const menuNode = findMenuNode(menuTree.value, menuId);
selectedMenu.value = menuNode;
parentId.value = findParentId(menuTree.value, menuId);
const menuId = selectedKeys[0]
const menuNode = findMenuNode(menuTree.value, menuId)
selectedMenu.value = menuNode
parentId.value = findParentId(menuTree.value, menuId)
} else {
selectedMenu.value = null;
parentId.value = null;
selectedMenu.value = null
parentId.value = null
}
};
}
// 限制勾选事件
const onMenuCheck = (checkedKeys, info) => {
console.log("checkedKeys:", checkedKeys, "info:", info);
};
console.log('checkedKeys:', checkedKeys, 'info:', info)
}
// 获取所有节点ID(用于展开/折叠)
const getAllKeys = (nodes) => {
const keys = [];
const keys = []
const traverse = (items) => {
items.forEach((item) => {
keys.push(item.id);
keys.push(item.id)
if (item.children?.length) {
traverse(item.children);
traverse(item.children)
}
});
};
traverse(nodes);
return keys;
};
})
}
traverse(nodes)
return keys
}
// 展开全部
const handleExpandAll = () => {
expandedKeys.value = getAllKeys(filteredMenuTree.value);
};
expandedKeys.value = getAllKeys(filteredMenuTree.value)
}
// 折叠全部
const handleCollapseAll = () => {
expandedKeys.value = [];
};
expandedKeys.value = []
}
// 自动展开指定层级的节点
const expandAllKeys = (nodes, maxLevel = 3) => {
const keys = [];
const keys = []
const traverse = (items, level = 1) => {
items.forEach((item) => {
if (level < maxLevel && item.children?.length) {
keys.push(item.id);
traverse(item.children, level + 1);
keys.push(item.id)
traverse(item.children, level + 1)
}
});
};
traverse(nodes);
expandedKeys.value = keys;
};
})
}
traverse(nodes)
expandedKeys.value = keys
}
// 获取权限类型标签
const getTypeLabel = (type) => {
const typeMap = {
menu: "菜单",
api: "接口",
button: "按钮",
url: "链接",
};
return typeMap[type] || type;
};
menu: '菜单',
api: '接口',
button: '按钮',
url: '链接',
}
return typeMap[type] || type
}
// 获取权限类型颜色
const getTypeColor = (type) => {
const colorMap = {
menu: "blue",
api: "green",
button: "orange",
url: "purple",
};
return colorMap[type] || "default";
};
menu: 'blue',
api: 'green',
button: 'orange',
url: 'purple',
}
return colorMap[type] || 'default'
}
// 批量删除权限
const handleDeleteBatch = async () => {
if (checkedMenuKeys.value.length === 0) {
message.warning("请选择需要删除的权限");
return;
message.warning('请选择需要删除的权限')
return
}
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定删除已选择的 ${checkedMenuKeys.value.length} 个权限吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const res = await authApi.permissions.batchDelete.post({
const res = await authApi.permission.batchDelete.post({
ids: checkedMenuKeys.value,
});
})
if (res.code === 200) {
message.success("删除成功");
message.success('删除成功')
// 如果当前选中的权限被删除了,清空选择
if (
selectedMenu.value &&
checkedMenuKeys.value.includes(selectedMenu.value.id)
) {
selectedMenu.value = null;
selectedMenuKeys.value = [];
if (selectedMenu.value && checkedMenuKeys.value.includes(selectedMenu.value.id)) {
selectedMenu.value = null
selectedMenuKeys.value = []
}
checkedMenuKeys.value = [];
await loadMenuTree();
checkedMenuKeys.value = []
await loadMenuTree()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("删除权限失败:", error);
message.error("删除失败");
console.error('删除权限失败:', error)
message.error('删除失败')
}
},
});
};
})
}
// 保存成功回调
const handleSaveSuccess = async () => {
await loadMenuTree();
await loadMenuTree()
// 重新设置当前选中的权限
if (selectedMenu.value) {
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id);
selectedMenu.value = menuNode;
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
selectedMenu.value = menuNode
}
message.success("保存成功");
};
message.success('保存成功')
}
// 导出权限
const handleExport = () => {
dialog.value.export = true;
};
dialog.value.export = true
}
// 导出API封装
const handleExportApi = async () => {
return await authApi.permissions.export.post({
ids:
checkedMenuKeys.value.length > 0
? checkedMenuKeys.value
: undefined,
});
};
return await authApi.permission.export.post({
ids: checkedMenuKeys.value.length > 0 ? checkedMenuKeys.value : undefined,
})
}
// 导出成功回调
const handleExportSuccess = () => {
checkedMenuKeys.value = [];
};
checkedMenuKeys.value = []
}
// 导入权限
const handleImport = () => {
dialog.value.import = true;
};
dialog.value.import = true
}
// 导入成功回调
const handleImportSuccess = () => {
loadMenuTree();
};
loadMenuTree()
}
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.permissions.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "权限导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
const blob = await authApi.permission.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '权限导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error("下载模板失败:", error);
message.error("下载失败");
console.error('下载模板失败:', error)
message.error('下载失败')
}
};
}
// 初始化
onMounted(() => {
loadMenuTree();
});
loadMenuTree()
})
defineExpose({
loadMenuTree,
handleExpandAll,
handleCollapseAll,
});
})
</script>
<style scoped lang="scss">
@@ -640,7 +527,7 @@ defineExpose({
}
.tree-node-code {
font-family: "Consolas", "Monaco", monospace;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
background: #f0f0f0;
border: none;
@@ -1,151 +1,129 @@
<template>
<a-modal
:title="title"
:open="visible"
:width="500"
:destroy-on-close="true"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-modal :title="title" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="角色名称" name="name">
<a-input
v-model:value="form.name"
placeholder="请输入新角色名称"
allow-clear
/>
<a-input v-model:value="form.name" placeholder="请输入新角色名称" allow-clear />
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input
v-model:value="form.code"
placeholder="请输入新角色编码"
allow-clear
/>
<a-input v-model:value="form.code" placeholder="请输入新角色编码" allow-clear />
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleCancel">取 消</a-button>
<a-button type="primary" :loading="loading" @click="handleOk"
>确 定</a-button
>
<a-button type="primary" :loading="loading" @click="handleOk">确 定</a-button>
</a-space>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
const emit = defineEmits(["success", "closed"]);
const emit = defineEmits(['success', 'closed'])
const visible = ref(false);
const loading = ref(false);
const sourceId = ref(null);
const sourceName = ref("");
const sourceCode = ref("");
const visible = ref(false)
const loading = ref(false)
const sourceId = ref(null)
const sourceName = ref('')
const sourceCode = ref('')
// 表单数据
const form = reactive({
name: "",
code: "",
});
name: '',
code: '',
})
// 标题
const title = computed(() => (sourceId.value ? "复制角色" : "批量复制"));
const title = computed(() => (sourceId.value ? '复制角色' : '批量复制'))
// 表单引用
const dialogForm = ref();
const dialogForm = ref()
// 验证规则
const rules = {
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [
{ required: true, message: "请输入角色编码", trigger: "blur" },
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "角色编码只能包含字母、数字和下划线",
trigger: "blur",
message: '角色编码只能包含字母、数字和下划线',
trigger: 'blur',
},
],
};
}
// 打开对话框
const open = (data = null) => {
if (data && data.id) {
// 单个复制
sourceId.value = data.id;
sourceName.value = data.name;
sourceCode.value = data.code;
form.name = `${data.name}_副本`;
form.code = `${data.code}_copy`;
sourceId.value = data.id
sourceName.value = data.name
sourceCode.value = data.code
form.name = `${data.name}_副本`
form.code = `${data.code}_copy`
} else {
// 批量复制(暂不支持自定义名称,直接在后端处理)
sourceId.value = null;
sourceName.value = "";
sourceCode.value = "";
form.name = "";
form.code = "";
sourceId.value = null
sourceName.value = ''
sourceCode.value = ''
form.name = ''
form.code = ''
}
visible.value = true;
visible.value = true
return {
open,
close,
};
};
}
}
// 关闭对话框
const close = () => {
visible.value = false;
};
visible.value = false
}
// 处理取消
const handleCancel = () => {
emit("closed");
visible.value = false;
};
emit('closed')
visible.value = false
}
// 处理确定
const handleOk = async () => {
try {
if (sourceId.value) {
// 单个复制
await dialogForm.value.validate();
loading.value = true;
const res = await authApi.roles.copy.post(sourceId.value, {
await dialogForm.value.validate()
loading.value = true
const res = await authApi.role.copy.post(sourceId.value, {
name: form.name,
code: form.code,
});
loading.value = false;
})
loading.value = false
if (res.code === 200) {
emit("success");
visible.value = false;
message.success("复制成功");
emit('success')
visible.value = false
message.success('复制成功')
} else {
message.error(res.message || "复制失败");
message.error(res.message || '复制失败')
}
} else {
// 批量复制(通过外部传入的 ids)
emit("success");
visible.value = false;
emit('success')
visible.value = false
}
} catch (error) {
console.error("复制失败:", error);
loading.value = false;
console.error('复制失败:', error)
loading.value = false
}
};
}
// 暴露方法给父组件
defineExpose({
open,
close,
});
})
</script>
@@ -1,23 +1,8 @@
<template>
<a-modal
title="角色权限设置"
:open="visible"
:width="600"
:destroy-on-close="true"
@cancel="handleCancel"
>
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" @cancel="handleCancel">
<div class="permission-content">
<div class="permission-tree">
<a-tree
ref="menuTreeRef"
v-model:checkedKeys="checkedPermissionIds"
:tree-data="permissionTree"
:field-names="fieldNames"
:checkable="true"
:default-expand-all="true"
:check-strictly="false"
:selectable="false"
>
<a-tree ref="menuTreeRef" v-model:checkedKeys="checkedPermissionIds" :tree-data="permissionTree" :field-names="fieldNames" :checkable="true" :default-expand-all="true" :check-strictly="false" :selectable="false">
<template #title="{ title }">
{{ title }}
</template>
@@ -27,131 +12,129 @@
<template #footer>
<a-space>
<a-button @click="handleCancel">取 消</a-button>
<a-button type="primary" :loading="isSaveing" @click="submit"
>保 存</a-button
>
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
</a-space>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
const emit = defineEmits(["success", "closed"]);
const emit = defineEmits(['success', 'closed'])
const visible = ref(false);
const isSaveing = ref(false);
const menuTreeRef = ref();
const visible = ref(false)
const isSaveing = ref(false)
const menuTreeRef = ref()
// 权限树数据
const permissionTree = ref([]);
const checkedPermissionIds = ref([]);
const permissionTree = ref([])
const checkedPermissionIds = ref([])
// 树字段映射
const fieldNames = {
title: "title",
key: "id",
children: "children",
};
title: 'title',
key: 'id',
children: 'children',
}
// 表单数据
const form = reactive({
role_id: "",
role_id: '',
permission_ids: [],
});
})
// 打开对话框
const open = () => {
visible.value = true;
visible.value = true
return {
open,
setData,
close,
};
};
}
}
// 关闭对话框
const close = () => {
visible.value = false;
};
visible.value = false
}
// 处理取消
const handleCancel = () => {
emit("closed");
visible.value = false;
};
emit('closed')
visible.value = false
}
// 提交保存
const submit = async () => {
try {
isSaveing.value = true;
isSaveing.value = true
// 获取选中的权限 ID
form.permission_ids = checkedPermissionIds.value || [];
form.permission_ids = checkedPermissionIds.value || []
const res = await authApi.roles.permissions.post(form.role_id, {
const res = await authApi.role.permissions.post(form.role_id, {
permission_ids: form.permission_ids,
});
})
isSaveing.value = false;
isSaveing.value = false
if (res.code === 200) {
emit("success", form);
visible.value = false;
message.success("操作成功");
emit('success', form)
visible.value = false
message.success('操作成功')
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("保存权限失败:", error);
isSaveing.value = false;
message.error("操作失败");
console.error('保存权限失败:', error)
isSaveing.value = false
message.error('操作失败')
}
};
}
// 获取权限树
const loadPermissionTree = async () => {
try {
const res = await authApi.permissions.tree.get();
permissionTree.value = res.data || [];
const res = await authApi.permission.tree.get()
permissionTree.value = res.data || []
} catch (error) {
console.error("获取权限树失败:", error);
message.error("获取权限树失败");
console.error('获取权限树失败:', error)
message.error('获取权限树失败')
}
};
}
// 获取角色已有权限
const loadRolePermissions = async (roleId) => {
try {
const res = await authApi.roles.permissions.get(roleId);
const res = await authApi.role.permissions.get(roleId)
if (res.code === 200 && res.data) {
checkedPermissionIds.value = res.data.map((item) => item.id);
checkedPermissionIds.value = res.data.map((item) => item.id)
}
} catch (error) {
console.error("获取角色权限失败:", error);
console.error('获取角色权限失败:', error)
}
};
}
// 设置数据
const setData = async (data) => {
form.role_id = data.id;
checkedPermissionIds.value = [];
form.role_id = data.id
checkedPermissionIds.value = []
// 加载角色已有的权限
await loadRolePermissions(data.id);
};
await loadRolePermissions(data.id)
}
// 组件挂载时加载数据
loadPermissionTree();
loadPermissionTree()
// 暴露方法给父组件
defineExpose({
open,
setData,
close,
});
})
</script>
<style scoped>
@@ -1,187 +1,142 @@
<template>
<a-modal
:title="titleMap[mode]"
:open="visible"
:width="500"
:destroy-on-close="true"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
:disabled="mode === 'show'"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" @cancel="handleCancel">
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="角色名称" name="name">
<a-input
v-model:value="form.name"
placeholder="请输入角色名称"
allow-clear
></a-input>
<a-input v-model:value="form.name" placeholder="请输入角色名称" allow-clear></a-input>
</a-form-item>
<a-form-item label="角色编码" name="code">
<a-input
v-model:value="form.code"
placeholder="请输入角色编码"
allow-clear
:disabled="mode === 'edit'"
></a-input>
<a-input v-model:value="form.code" placeholder="请输入角色编码" allow-clear :disabled="mode === 'edit'"></a-input>
</a-form-item>
<a-form-item label="角色描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入角色描述"
:rows="4"
allow-clear
></a-textarea>
<a-textarea v-model:value="form.description" placeholder="请输入角色描述" :rows="4" allow-clear></a-textarea>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:step="1"
style="width: 100%"
placeholder="请输入排序"
/>
<a-input-number v-model:value="form.sort" :min="0" :step="1" style="width: 100%" placeholder="请输入排序" />
</a-form-item>
<a-form-item label="状态" name="status">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="role_status"
placeholder="请选择状态"
allow-clear
/>
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="role_status" placeholder="请选择状态" allow-clear />
</a-form-item>
</a-form>
<template #footer>
<a-space>
<a-button @click="handleCancel">取 消</a-button>
<a-button
v-if="mode !== 'show'"
type="primary"
:loading="isSaveing"
@click="submit"
>保 存</a-button
>
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
</a-space>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import authApi from "@/api/auth";
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import authApi from '@/api/auth'
const emit = defineEmits(["success", "closed"]);
const emit = defineEmits(['success', 'closed'])
const mode = ref("add");
const mode = ref('add')
const titleMap = {
add: "新增角色",
edit: "编辑角色",
show: "查看角色",
};
const visible = ref(false);
const isSaveing = ref(false);
add: '新增角色',
edit: '编辑角色',
show: '查看角色',
}
const visible = ref(false)
const isSaveing = ref(false)
// 表单数据
const form = reactive({
id: "",
name: "",
code: "",
description: "",
id: '',
name: '',
code: '',
description: '',
sort: 1,
status: null,
});
})
// 表单引用
const dialogForm = ref();
const dialogForm = ref()
// 验证规则
const rules = {
name: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [
{ required: true, message: "请输入角色编码", trigger: "blur" },
{ required: true, message: '请输入角色编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z0-9_]+$/,
message: "角色编码只能包含字母、数字和下划线",
trigger: "blur",
message: '角色编码只能包含字母、数字和下划线',
trigger: 'blur',
},
],
sort: [
{ required: true, message: "请输入排序", trigger: "change" },
{ type: "number", message: "排序必须为数字", trigger: "change" },
{ required: true, message: '请输入排序', trigger: 'change' },
{ type: 'number', message: '排序必须为数字', trigger: 'change' },
],
};
}
// 显示对话框
const open = (openMode = "add") => {
mode.value = openMode;
visible.value = true;
const open = (openMode = 'add') => {
mode.value = openMode
visible.value = true
return {
setData,
open,
close,
};
};
}
}
// 关闭对话框
const close = () => {
visible.value = false;
};
visible.value = false
}
// 处理取消
const handleCancel = () => {
emit("closed");
visible.value = false;
};
emit('closed')
visible.value = false
}
// 表单提交方法
const submit = async () => {
try {
await dialogForm.value.validate();
isSaveing.value = true;
let res = {};
if (mode.value === "add") {
res = await authApi.roles.add.post(form);
await dialogForm.value.validate()
isSaveing.value = true
let res = {}
if (mode.value === 'add') {
res = await authApi.role.add.post(form)
} else {
res = await authApi.roles.edit.put(form.id, form);
res = await authApi.role.edit.put(form.id, form)
}
isSaveing.value = false;
isSaveing.value = false
if (res.code === 200) {
emit("success", form, mode.value);
visible.value = false;
message.success("操作成功");
emit('success', form, mode.value)
visible.value = false
message.success('操作成功')
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("表单验证失败", error);
isSaveing.value = false;
console.error('表单验证失败', error)
isSaveing.value = false
}
};
}
// 表单注入数据
const setData = (data) => {
form.id = data.id;
form.name = data.name;
form.code = data.code;
form.description = data.description || "";
form.sort = data.sort;
form.status = data.status !== undefined ? data.status : null;
};
form.id = data.id
form.name = data.name
form.code = data.code
form.description = data.description || ''
form.sort = data.sort
form.status = data.status !== undefined ? data.status : null
}
// 暴露方法给父组件
defineExpose({
open,
setData,
close,
});
})
</script>
<style></style>
+183 -288
View File
@@ -3,12 +3,7 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="角色名称"
allow-clear
style="width: 180px"
/>
<a-input v-model:value="searchForm.keyword" placeholder="角色名称" allow-clear style="width: 180px" />
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -27,16 +22,10 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchStatus">
<CheckCircleOutlined />批量启用/禁用
</a-menu-item>
<a-menu-item @click="handleBatchCopy">
<CopyOutlined />批量复制
</a-menu-item>
<a-menu-item @click="handleBatchStatus"> <CheckCircleOutlined />批量启用/禁用 </a-menu-item>
<a-menu-item @click="handleBatchCopy"> <CopyOutlined />批量复制 </a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleBatchDelete" danger>
<DeleteOutlined />批量删除
</a-menu-item>
<a-menu-item @click="handleBatchDelete" danger> <DeleteOutlined />批量删除 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -47,15 +36,9 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleImport">
<ImportOutlined />导入角色
</a-menu-item>
<a-menu-item @click="handleExport">
<ExportOutlined />导出角色
</a-menu-item>
<a-menu-item @click="handleDownloadTemplate">
<DownloadOutlined />下载模板
</a-menu-item>
<a-menu-item @click="handleImport"> <ImportOutlined />导入角色 </a-menu-item>
<a-menu-item @click="handleExport"> <ExportOutlined />导出角色 </a-menu-item>
<a-menu-item @click="handleDownloadTemplate"> <DownloadOutlined />下载模板 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -66,38 +49,16 @@
</div>
</div>
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" :row-key="rowKey" :row-selection="rowSelection" @refresh="refreshTable" @paginationChange="handlePaginationChange" @select="handleSelectChange" @selectAll="handleSelectAll">
<template #status="{ record }">
<a-tag :color="record.status === 1 ? 'success' : 'error'">
{{ record.status === 1 ? "正常" : "禁用" }}
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
size="small"
@click="handleCopy(record)"
>复制</a-button
>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleCopy(record)">复制</a-button>
<a-dropdown>
<a-button type="link" size="small">
更多
@@ -105,21 +66,10 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleView(record)">
<SearchOutlined />查看
</a-menu-item>
<a-menu-item
@click="handlePermission(record)"
>
<ImportOutlined />权限
</a-menu-item>
<a-menu-item @click="handleView(record)"> <SearchOutlined />查看 </a-menu-item>
<a-menu-item @click="handlePermission(record)"> <ImportOutlined />权限 </a-menu-item>
<a-menu-divider />
<a-menu-item
@click="handleDelete(record)"
danger
>
<DeleteOutlined />删除
</a-menu-item>
<a-menu-item @click="handleDelete(record)" danger> <DeleteOutlined />删除 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -130,104 +80,49 @@
</div>
<!-- 新增/编辑角色弹窗 -->
<save-dialog
v-if="dialog.save"
ref="saveDialogRef"
@success="handleSaveSuccess"
@closed="dialog.save = false"
/>
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
<!-- 权限设置弹窗 -->
<permission-dialog
v-if="dialog.permission"
ref="permissionDialogRef"
@success="permissionSuccess"
@closed="dialog.permission = false"
/>
<permission-dialog v-if="dialog.permission" ref="permissionDialogRef" @success="permissionSuccess" @closed="dialog.permission = false" />
<!-- 导入角色弹窗 -->
<sc-import
v-model:open="dialog.import"
title="导入角色"
:api="authApi.roles.import.post"
:template-api="authApi.roles.downloadTemplate.get"
filename="角色"
@success="handleImportSuccess"
/>
<sc-import v-model:open="dialog.import" title="导入角色" :api="authApi.role.import.post" :template-api="authApi.role.downloadTemplate.get" filename="角色" @success="handleImportSuccess" />
<!-- 导出角色弹窗 -->
<sc-export
v-model:open="dialog.export"
title="导出角色"
:api="handleExportApi"
:default-filename="`角色列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有角色数据"
@success="handleExportSuccess"
/>
<sc-export v-model:open="dialog.export" title="导出角色" :api="handleExportApi" :default-filename="`角色列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有角色数据" @success="handleExportSuccess" />
<!-- 复制角色弹窗 -->
<copy-dialog
v-if="dialog.copy"
ref="copyDialogRef"
@success="handleCopySuccess"
@closed="dialog.copy = false"
/>
<copy-dialog v-if="dialog.copy" ref="copyDialogRef" @success="handleCopySuccess" @closed="dialog.copy = false" />
</template>
<script setup>
import { ref, reactive } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
PlusOutlined,
DownOutlined,
CheckCircleOutlined,
CopyOutlined,
DeleteOutlined,
ImportOutlined,
ExportOutlined,
DownloadOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import saveDialog from "./components/SaveDialog.vue";
import permissionDialog from "./components/PermissionDialog.vue";
import copyDialog from "./components/CopyDialog.vue";
import authApi from "@/api/auth";
import { useTable } from "@/hooks/useTable";
import { ref, reactive } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, RedoOutlined, PlusOutlined, DownOutlined, CheckCircleOutlined, CopyOutlined, DeleteOutlined, ImportOutlined, ExportOutlined, DownloadOutlined } from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import saveDialog from './components/SaveDialog.vue'
import permissionDialog from './components/PermissionDialog.vue'
import copyDialog from './components/CopyDialog.vue'
import authApi from '@/api/auth'
import { useTable } from '@/hooks/useTable'
defineOptions({
name: "authRole",
});
name: 'authRole',
})
// 使用useTable hooks
const {
tableRef,
searchForm,
tableData,
loading,
pagination,
selectedRows,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable,
} = useTable({
api: authApi.roles.list.get,
const { tableRef, searchForm, tableData, loading, pagination, selectedRows, rowSelection, handleSearch, handleReset, handlePaginationChange, handleSelectChange, handleSelectAll, refreshTable } = useTable({
api: authApi.role.list.get,
searchForm: {
keyword: "",
keyword: '',
status: null,
},
columns: [],
needPagination: true,
needSelection: true,
});
})
// 对话框状态
const dialog = reactive({
@@ -236,287 +131,287 @@ const dialog = reactive({
import: false,
export: false,
copy: false,
});
})
// 弹窗引用
const saveDialogRef = ref(null);
const permissionDialogRef = ref(null);
const copyDialogRef = ref(null);
const saveDialogRef = ref(null)
const permissionDialogRef = ref(null)
const copyDialogRef = ref(null)
// 行key
const rowKey = "id";
const rowKey = 'id'
// 表格列配置
const columns = [
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{ title: "角色名称", dataIndex: "name", key: "name", width: 200 },
{ title: "角色编码", dataIndex: "code", key: "code", width: 200 },
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{ title: '角色名称', dataIndex: 'name', key: 'name', width: 200 },
{ title: '角色编码', dataIndex: 'code', key: 'code', width: 200 },
{
title: "描述",
dataIndex: "description",
key: "description",
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: "排序",
dataIndex: "sort",
key: "sort",
title: '排序',
dataIndex: 'sort',
key: 'sort',
width: 100,
align: "center",
align: 'center',
},
{
title: "状态",
dataIndex: "status",
key: "status",
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: "center",
slot: "status",
align: 'center',
slot: 'status',
},
{
title: "操作",
dataIndex: "action",
key: "action",
title: '操作',
dataIndex: 'action',
key: 'action',
width: 180,
align: "center",
slot: "action",
fixed: "right",
align: 'center',
slot: 'action',
fixed: 'right',
},
];
]
// 新增角色
const handleAdd = () => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("add");
}, 0);
};
saveDialogRef.value?.open('add')
}, 0)
}
// 查看角色
const handleView = (record) => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("show").setData(record);
}, 0);
};
saveDialogRef.value?.open('show').setData(record)
}, 0)
}
// 编辑角色
const handleEdit = (record) => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("edit").setData(record);
}, 0);
};
saveDialogRef.value?.open('edit').setData(record)
}, 0)
}
// 删除角色
const handleDelete = (record) => {
Modal.confirm({
title: "确认删除",
content: "确定删除该角色吗?",
okText: "确定",
cancelText: "取消",
okType: "danger",
title: '确认删除',
content: '确定删除该角色吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const res = await authApi.roles.delete.delete(record.id);
const res = await authApi.role.delete.delete(record.id)
if (res.code === 200) {
message.success("删除成功");
refreshTable();
message.success('删除成功')
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("删除角色失败:", error);
message.error("删除失败");
console.error('删除角色失败:', error)
message.error('删除失败')
}
},
});
};
})
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要删除的角色");
return;
message.warning('请选择要删除的角色')
return
}
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定删除选中的 ${selectedRows.value.length} 个角色吗?`,
okText: "确定",
cancelText: "取消",
okType: "danger",
okText: '确定',
cancelText: '取消',
okType: 'danger',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.roles.batchDelete.post({ ids });
const ids = selectedRows.value.map((item) => item.id)
const res = await authApi.role.batchDelete.post({ ids })
if (res.code === 200) {
message.success("删除成功");
selectedRows.value = [];
refreshTable();
message.success('删除成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("批量删除角色失败:", error);
message.error("删除失败");
console.error('批量删除角色失败:', error)
message.error('删除失败')
}
},
});
};
})
}
// 批量更新状态
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要操作的角色");
return;
message.warning('请选择要操作的角色')
return
}
Modal.confirm({
title: "确认操作",
content: "确定要批量启用/禁用选中的角色吗?",
okText: "确定",
cancelText: "取消",
title: '确认操作',
content: '确定要批量启用/禁用选中的角色吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const status = selectedRows.value[0].status === 1 ? 0 : 1;
const res = await authApi.roles.batchStatus.post({
const ids = selectedRows.value.map((item) => item.id)
const status = selectedRows.value[0].status === 1 ? 0 : 1
const res = await authApi.role.batchStatus.post({
ids,
status,
});
})
if (res.code === 200) {
message.success("操作成功");
selectedRows.value = [];
refreshTable();
message.success('操作成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("批量更新状态失败:", error);
message.error("操作失败");
console.error('批量更新状态失败:', error)
message.error('操作失败')
}
},
});
};
})
}
// 复制角色
const handleCopy = (record) => {
dialog.copy = true;
dialog.copy = true
setTimeout(() => {
copyDialogRef.value?.open(record);
}, 0);
};
copyDialogRef.value?.open(record)
}, 0)
}
// 批量复制角色
const handleBatchCopy = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要复制的角色");
return;
message.warning('请选择要复制的角色')
return
}
Modal.confirm({
title: "确认批量复制",
title: '确认批量复制',
content: `确定复制选中的 ${selectedRows.value.length} 个角色吗?`,
okText: "确定",
cancelText: "取消",
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.roles.batchCopy.post({ ids });
const ids = selectedRows.value.map((item) => item.id)
const res = await authApi.role.batchCopy.post({ ids })
if (res.code === 200) {
message.success("批量复制成功");
selectedRows.value = [];
refreshTable();
message.success('批量复制成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "批量复制失败");
message.error(res.message || '批量复制失败')
}
} catch (error) {
console.error("批量复制角色失败:", error);
message.error("批量复制失败");
console.error('批量复制角色失败:', error)
message.error('批量复制失败')
}
},
});
};
})
}
// 复制成功回调
const handleCopySuccess = () => {
refreshTable();
};
refreshTable()
}
// 权限设置
const handlePermission = (record) => {
if (!record && selectedRows.value.length !== 1) {
message.error("请选择一个角色进行权限设置");
return;
message.error('请选择一个角色进行权限设置')
return
}
const roleData = record || selectedRows.value[0];
dialog.permission = true;
const roleData = record || selectedRows.value[0]
dialog.permission = true
setTimeout(() => {
permissionDialogRef.value?.open().setData(roleData);
}, 0);
};
permissionDialogRef.value?.open().setData(roleData)
}, 0)
}
// 重置
const handleUserReset = () => {
searchForm.keyword = "";
searchForm.status = null;
handleSearch();
};
searchForm.keyword = ''
searchForm.status = null
handleSearch()
}
// 保存成功回调
const handleSaveSuccess = () => {
refreshTable();
};
refreshTable()
}
// 权限设置成功回调
const permissionSuccess = () => {
refreshTable();
};
refreshTable()
}
// 导出角色
const handleExport = async () => {
dialog.export = true;
};
dialog.export = true
}
// 导出API封装
const handleExportApi = async () => {
const ids = selectedRows.value.map((item) => item.id);
return await authApi.roles.export.post({
const ids = selectedRows.value.map((item) => item.id)
return await authApi.role.export.post({
ids: ids.length > 0 ? ids : undefined,
});
};
})
}
// 导出成功回调
const handleExportSuccess = () => {
selectedRows.value = [];
};
selectedRows.value = []
}
// 导入角色
const handleImport = () => {
dialog.import = true;
};
dialog.import = true
}
// 导入成功回调
const handleImportSuccess = () => {
refreshTable();
};
refreshTable()
}
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.roles.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "角色导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
const blob = await authApi.role.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '角色导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error("下载模板失败:", error);
message.error("下载失败");
console.error('下载模板失败:', error)
message.error('下载失败')
}
};
}
</script>
@@ -1,110 +1,94 @@
<template>
<a-modal
v-model:open="visible"
title="批量分配角色"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-modal v-model:open="visible" title="批量分配角色" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="角色">
<a-select
v-model:value="selectedRoleIds"
mode="multiple"
placeholder="请选择角色"
allow-clear
:options="roleOptions"
:field-names="{ label: 'name', value: 'id' }"
:loading="roleLoading"
show-search
:filter-option="filterOption"
/>
<a-select v-model:value="selectedRoleIds" mode="multiple" placeholder="请选择角色" allow-clear :options="roleOptions" :field-names="{ label: 'name', value: 'id' }" :loading="roleLoading" show-search :filter-option="filterOption" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
const visible = ref(false);
const loading = ref(false);
const roleLoading = ref(false);
const roleOptions = ref([]);
const userIds = ref([]);
const selectedRoleIds = ref([]);
const visible = ref(false)
const loading = ref(false)
const roleLoading = ref(false)
const roleOptions = ref([])
const userIds = ref([])
const selectedRoleIds = ref([])
// 打开弹窗
const open = async (ids) => {
visible.value = true;
userIds.value = ids;
selectedRoleIds.value = [];
await loadRoles();
};
visible.value = true
userIds.value = ids
selectedRoleIds.value = []
await loadRoles()
}
// 加载角色列表
const loadRoles = async () => {
try {
roleLoading.value = true;
const res = await authApi.roles.list.get({ page_size: 1000 });
roleLoading.value = true
const res = await authApi.role.list.get({ page_size: 1000 })
if (res.code === 200) {
roleOptions.value = (res.data.list || []).map((role) => ({
id: role.id,
name: role.name,
code: role.code,
}));
}))
}
} catch (error) {
console.error("加载角色列表失败:", error);
console.error('加载角色列表失败:', error)
} finally {
roleLoading.value = false;
roleLoading.value = false
}
};
}
// 角色过滤
const filterOption = (input, option) => {
const name = option?.name?.toLowerCase() || "";
const code = option?.code?.toLowerCase() || "";
const keyword = input?.toLowerCase() || "";
return name.includes(keyword) || code.includes(keyword);
};
const name = option?.name?.toLowerCase() || ''
const code = option?.code?.toLowerCase() || ''
const keyword = input?.toLowerCase() || ''
return name.includes(keyword) || code.includes(keyword)
}
// 确认
const handleOk = async () => {
try {
loading.value = true;
const res = await authApi.users.batchRoles.post({
loading.value = true
const res = await authApi.user.batchRoles.post({
ids: userIds.value,
role_ids: selectedRoleIds.value,
});
})
if (res.code === 200) {
message.success("分配成功");
emit("success");
handleCancel();
message.success('分配成功')
emit('success')
handleCancel()
} else {
message.error(res.message || "分配失败");
message.error(res.message || '分配失败')
}
} catch (error) {
console.error("批量分配角色失败:", error);
message.error(error.message || "分配失败");
console.error('批量分配角色失败:', error)
message.error(error.message || '分配失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 取消
const handleCancel = () => {
visible.value = false;
loading.value = false;
selectedRoleIds.value = [];
};
visible.value = false
loading.value = false
selectedRoleIds.value = []
}
const emit = defineEmits(["success"]);
const emit = defineEmits(['success'])
defineExpose({
open,
});
})
</script>
@@ -1,16 +1,6 @@
<template>
<a-modal
v-model:open="visible"
title="批量分配部门"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
:model="formState"
:label-col="{ span: 4 }"
:wrapper-col="{ span: 20 }"
>
<a-modal v-model:open="visible" title="批量分配部门" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-form :model="formState" :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="部门">
<a-tree-select
v-model:value="formState.department_id"
@@ -32,84 +22,84 @@
</template>
<script setup>
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
const visible = ref(false);
const loading = ref(false);
const departmentTree = ref([]);
const userIds = ref([]);
const visible = ref(false)
const loading = ref(false)
const departmentTree = ref([])
const userIds = ref([])
const formState = reactive({
department_id: undefined,
});
})
// 打开弹窗
const open = (ids) => {
visible.value = true;
userIds.value = ids;
formState.department_id = undefined;
loadDepartmentTree();
};
visible.value = true
userIds.value = ids
formState.department_id = undefined
loadDepartmentTree()
}
// 加载部门树
const loadDepartmentTree = async () => {
try {
const res = await authApi.departments.tree.get();
const res = await authApi.department.tree.get()
if (res.code === 200) {
departmentTree.value = res.data || [];
departmentTree.value = res.data || []
}
} catch (error) {
console.error("加载部门树失败:", error);
console.error('加载部门树失败:', error)
}
};
}
// 树节点过滤
const filterTreeNode = (inputValue, treeNode) => {
const name = treeNode.dataRef.name;
return name ? name.toLowerCase().includes(inputValue.toLowerCase()) : false;
};
const name = treeNode.dataRef.name
return name ? name.toLowerCase().includes(inputValue.toLowerCase()) : false
}
// 确认
const handleOk = async () => {
if (!formState.department_id) {
message.warning("请选择部门");
return;
message.warning('请选择部门')
return
}
try {
loading.value = true;
const res = await authApi.users.batchDepartment.post({
loading.value = true
const res = await authApi.user.batchDepartment.post({
ids: userIds.value,
department_id: formState.department_id,
});
})
if (res.code === 200) {
message.success("分配成功");
emit("success");
handleCancel();
message.success('分配成功')
emit('success')
handleCancel()
} else {
message.error(res.message || "分配失败");
message.error(res.message || '分配失败')
}
} catch (error) {
console.error("批量分配部门失败:", error);
message.error(error.message || "分配失败");
console.error('批量分配部门失败:', error)
message.error(error.message || '分配失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 取消
const handleCancel = () => {
visible.value = false;
loading.value = false;
formState.department_id = undefined;
};
visible.value = false
loading.value = false
formState.department_id = undefined
}
const emit = defineEmits(["success"]);
const emit = defineEmits(['success'])
defineExpose({
open,
});
})
</script>
@@ -1,131 +1,115 @@
<template>
<a-modal
v-model:open="visible"
title="设置角色"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-modal v-model:open="visible" title="设置角色" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
<a-form-item label="用户">
<a-input :value="userForm.username" disabled />
</a-form-item>
<a-form-item label="角色">
<a-select
v-model:value="selectedRoleIds"
mode="multiple"
placeholder="请选择角色"
allow-clear
:options="roleOptions"
:field-names="{ label: 'name', value: 'id' }"
:loading="roleLoading"
show-search
:filter-option="filterOption"
/>
<a-select v-model:value="selectedRoleIds" mode="multiple" placeholder="请选择角色" allow-clear :options="roleOptions" :field-names="{ label: 'name', value: 'id' }" :loading="roleLoading" show-search :filter-option="filterOption" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive } from "vue";
import { message } from "ant-design-vue";
import authApi from "@/api/auth";
import { ref, reactive } from 'vue'
import { message } from 'ant-design-vue'
import authApi from '@/api/auth'
const visible = ref(false);
const loading = ref(false);
const roleLoading = ref(false);
const roleOptions = ref([]);
const selectedRoleIds = ref([]);
const userId = ref(null);
const visible = ref(false)
const loading = ref(false)
const roleLoading = ref(false)
const roleOptions = ref([])
const selectedRoleIds = ref([])
const userId = ref(null)
const userForm = reactive({
username: "",
});
username: '',
})
// 打开弹窗
const open = () => {
visible.value = true;
loadRoles();
};
visible.value = true
loadRoles()
}
// 设置用户数据
const setData = (user) => {
userId.value = user.id;
userForm.username = user.username;
userId.value = user.id
userForm.username = user.username
// 设置已选择的角色
selectedRoleIds.value = (user.roles || []).map((role) => role.id);
};
selectedRoleIds.value = (user.roles || []).map((role) => role.id)
}
// 加载角色列表
const loadRoles = async () => {
try {
roleLoading.value = true;
const res = await authApi.roles.list.get({ page_size: 1000 });
roleLoading.value = true
const res = await authApi.role.list.get({ page_size: 1000 })
if (res.code === 200) {
roleOptions.value = (res.data.list || []).map((role) => ({
id: role.id,
name: role.name,
code: role.code,
}));
}))
}
} catch (error) {
console.error("加载角色列表失败:", error);
console.error('加载角色列表失败:', error)
} finally {
roleLoading.value = false;
roleLoading.value = false
}
};
}
// 角色过滤
const filterOption = (input, option) => {
const name = option?.name?.toLowerCase() || "";
const code = option?.code?.toLowerCase() || "";
const keyword = input?.toLowerCase() || "";
return name.includes(keyword) || code.includes(keyword);
};
const name = option?.name?.toLowerCase() || ''
const code = option?.code?.toLowerCase() || ''
const keyword = input?.toLowerCase() || ''
return name.includes(keyword) || code.includes(keyword)
}
// 确认
const handleOk = async () => {
if (!userId.value) {
message.warning("用户ID不能为空");
return;
message.warning('用户ID不能为空')
return
}
try {
loading.value = true;
const res = await authApi.users.batchRoles.post({
loading.value = true
const res = await authApi.user.batchRoles.post({
ids: [userId.value],
role_ids: selectedRoleIds.value,
});
})
if (res.code === 200) {
message.success("设置成功");
emit("success");
handleCancel();
message.success('设置成功')
emit('success')
handleCancel()
} else {
message.error(res.message || "设置失败");
message.error(res.message || '设置失败')
}
} catch (error) {
console.error("设置角色失败:", error);
message.error(error.message || "设置失败");
console.error('设置角色失败:', error)
message.error(error.message || '设置失败')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 取消
const handleCancel = () => {
visible.value = false;
loading.value = false;
userId.value = null;
userForm.username = "";
selectedRoleIds.value = [];
};
visible.value = false
loading.value = false
userId.value = null
userForm.username = ''
selectedRoleIds.value = []
}
const emit = defineEmits(["success"]);
const emit = defineEmits(['success'])
defineExpose({
open,
setData,
});
})
</script>
@@ -1,293 +1,210 @@
<template>
<a-modal
:title="titleMap[mode]"
:open="visible"
:width="500"
:destroy-on-close="true"
:mask-closable="false"
@cancel="handleCancel"
>
<a-form
:model="form"
:rules="rules"
:disabled="mode === 'show'"
ref="dialogForm"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false" @cancel="handleCancel">
<a-form :model="form" :rules="rules" :disabled="mode === 'show'" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-form-item label="头像" name="avatar">
<sc-upload
v-model="form.avatar"
:cropper="true"
:aspectRatio="1"
title="上传头像"
></sc-upload>
<sc-upload v-model="form.avatar" :cropper="true" :aspectRatio="1" title="上传头像"></sc-upload>
</a-form-item>
<a-form-item label="用户名" name="username">
<a-input
v-model:value="form.username"
placeholder="请输入用户名"
allow-clear
:disabled="mode === 'edit'"
/>
<a-input v-model:value="form.username" placeholder="请输入用户名" allow-clear :disabled="mode === 'edit'" />
</a-form-item>
<a-form-item label="真实姓名" name="real_name">
<a-input
v-model:value="form.real_name"
placeholder="请输入真实姓名"
allow-clear
/>
<a-input v-model:value="form.real_name" placeholder="请输入真实姓名" allow-clear />
</a-form-item>
<a-form-item label="邮箱" name="email">
<a-input
v-model:value="form.email"
placeholder="请输入邮箱"
allow-clear
/>
<a-input v-model:value="form.email" placeholder="请输入邮箱" allow-clear />
</a-form-item>
<a-form-item label="手机号" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号"
allow-clear
/>
<a-input v-model:value="form.phone" placeholder="请输入手机号" allow-clear />
</a-form-item>
<template v-if="mode === 'add'">
<a-form-item label="登录密码" name="password">
<a-input-password
v-model:value="form.password"
placeholder="请输入登录密码"
allow-clear
/>
<a-input-password v-model:value="form.password" placeholder="请输入登录密码" allow-clear />
</a-form-item>
<a-form-item label="确认密码" name="password2">
<a-input-password
v-model:value="form.password2"
placeholder="请再次输入密码"
allow-clear
/>
<a-input-password v-model:value="form.password2" placeholder="请再次输入密码" allow-clear />
</a-form-item>
</template>
<a-form-item label="所属部门" name="department_id">
<a-tree-select
v-model:value="form.department_id"
:tree-data="department"
:field-names="departmentFieldNames"
:tree-default-expand-all="false"
show-icon
placeholder="请选择部门"
allow-clear
tree-node-filter-prop="name"
/>
<a-tree-select v-model:value="form.department_id" :tree-data="department" :field-names="departmentFieldNames" :tree-default-expand-all="false" show-icon placeholder="请选择部门" allow-clear tree-node-filter-prop="name" />
</a-form-item>
<a-form-item label="所属角色" name="role_ids">
<a-select
v-model:value="form.role_ids"
mode="multiple"
placeholder="请选择角色"
allow-clear
style="width: 100%"
>
<a-select-option
v-for="role in rolesList"
:key="role.id"
:value="role.id"
>
<a-select v-model:value="form.role_ids" mode="multiple" placeholder="请选择角色" allow-clear style="width: 100%">
<a-select-option v-for="role in rolesList" :key="role.id" :value="role.id">
{{ role.name }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="性别" name="gender">
<sc-select
v-model:value="form.gender"
source-type="dictionary"
dictionary-code="gender"
placeholder="请选择性别"
allow-clear
/>
<sc-select v-model:value="form.gender" source-type="dictionary" dictionary-code="gender" placeholder="请选择性别" allow-clear />
</a-form-item>
<a-form-item label="状态" name="status">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="user_status"
placeholder="请选择状态"
allow-clear
/>
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="user_status" placeholder="请选择状态" allow-clear />
</a-form-item>
</a-form>
<template #footer>
<a-button @click="handleCancel">取 消</a-button>
<a-button
v-if="mode !== 'show'"
type="primary"
:loading="isSaveing"
@click="submit"
>保 存</a-button
>
<a-button v-if="mode !== 'show'" type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
</template>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed } from "vue";
import { message } from "ant-design-vue";
import scUpload from "@/components/scUpload/index.vue";
import scSelect from "@/components/scSelect/index.vue";
import authApi from "@/api/auth";
import { ref, reactive, computed } from 'vue'
import { message } from 'ant-design-vue'
import scUpload from '@/components/scUpload/index.vue'
import scSelect from '@/components/scSelect/index.vue'
import authApi from '@/api/auth'
const emit = defineEmits(["success", "closed"]);
const emit = defineEmits(['success', 'closed'])
const mode = ref("add");
const mode = ref('add')
const titleMap = {
add: "新增用户",
edit: "编辑用户",
show: "查看用户",
};
const visible = ref(false);
const isSaveing = ref(false);
add: '新增用户',
edit: '编辑用户',
show: '查看用户',
}
const visible = ref(false)
const isSaveing = ref(false)
// 表单数据
const form = reactive({
id: "",
username: "",
avatar: "",
real_name: "",
email: "",
phone: "",
id: '',
username: '',
avatar: '',
real_name: '',
email: '',
phone: '',
department_id: null,
role_ids: [],
gender: null,
status: null,
});
})
// 表单引用
const dialogForm = ref();
const dialogForm = ref()
// 验证规则
const rules = {
username: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{ required: true, message: '请输入用户名', trigger: 'blur' },
{
min: 3,
max: 50,
message: "用户名长度在 3 到 50 个字符",
trigger: "blur",
message: '用户名长度在 3 到 50 个字符',
trigger: 'blur',
},
],
real_name: [
{ required: true, message: "请输入真实姓名", trigger: "blur" },
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{
min: 2,
max: 50,
message: "真实姓名长度在 2 到 50 个字符",
trigger: "blur",
message: '真实姓名长度在 2 到 50 个字符',
trigger: 'blur',
},
],
email: [
{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" },
],
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
phone: [
{
pattern: /^1[3-9]\d{9}$/,
message: "请输入正确的手机号",
trigger: "blur",
message: '请输入正确的手机号',
trigger: 'blur',
},
],
password: [
{ required: true, message: "请输入登录密码", trigger: "blur" },
{ required: true, message: '请输入登录密码', trigger: 'blur' },
{
min: 6,
max: 20,
message: "密码长度在 6 到 20 个字符",
trigger: "blur",
message: '密码长度在 6 到 20 个字符',
trigger: 'blur',
},
{
validator: (rule, value) => {
if (form.password2 !== "") {
dialogForm.value?.validateFields("password2");
if (form.password2 !== '') {
dialogForm.value?.validateFields('password2')
}
return Promise.resolve();
return Promise.resolve()
},
trigger: "change",
trigger: 'change',
},
],
password2: [
{ required: true, message: "请再次输入密码", trigger: "blur" },
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{
validator: (rule, value) => {
if (value !== form.password) {
return Promise.reject(new Error("两次输入密码不一致!"));
return Promise.reject(new Error('两次输入密码不一致!'))
}
return Promise.resolve();
return Promise.resolve()
},
trigger: "blur",
trigger: 'blur',
},
],
};
}
// 部门数据
const department = ref([]);
const department = ref([])
const departmentFieldNames = {
value: "id",
label: "name",
children: "children",
};
value: 'id',
label: 'name',
children: 'children',
}
// 角色列表
const rolesList = ref([]);
const rolesList = ref([])
// 显示对话框
const open = (openMode = "add") => {
mode.value = openMode;
visible.value = true;
const open = (openMode = 'add') => {
mode.value = openMode
visible.value = true
return {
setData,
open,
close,
};
};
}
}
// 关闭对话框
const close = () => {
visible.value = false;
};
visible.value = false
}
// 处理取消
const handleCancel = () => {
emit("closed");
visible.value = false;
};
emit('closed')
visible.value = false
}
// 加载部门树数据
const loadDepartment = async () => {
try {
const res = await authApi.departments.tree.get();
const res = await authApi.department.tree.get()
if (res.code === 200) {
department.value = res.data || [];
department.value = res.data || []
}
} catch (error) {
console.error("加载部门树失败:", error);
console.error('加载部门树失败:', error)
}
};
}
// 加载角色列表
const loadRoles = async () => {
try {
const res = await authApi.roles.all.get();
const res = await authApi.role.all.get()
if (res.code === 200) {
rolesList.value = res.data || [];
rolesList.value = res.data || []
}
} catch (error) {
console.error("加载角色列表失败:", error);
console.error('加载角色列表失败:', error)
}
};
}
// 表单提交方法
const submit = async () => {
try {
await dialogForm.value.validate();
isSaveing.value = true;
await dialogForm.value.validate()
isSaveing.value = true
const submitData = {
username: form.username,
@@ -299,57 +216,57 @@ const submit = async () => {
role_ids: form.role_ids,
gender: form.gender,
status: form.status,
};
if (mode.value === "add") {
submitData.password = form.password;
}
let res = {};
if (mode.value === "add") {
res = await authApi.users.add.post(submitData);
if (mode.value === 'add') {
submitData.password = form.password
}
let res = {}
if (mode.value === 'add') {
res = await authApi.user.add.post(submitData)
} else {
res = await authApi.users.edit.put(form.id, submitData);
res = await authApi.user.edit.put(form.id, submitData)
}
isSaveing.value = false;
isSaveing.value = false
if (res.code === 200) {
emit("success", form, mode.value);
visible.value = false;
message.success("操作成功");
emit('success', form, mode.value)
visible.value = false
message.success('操作成功')
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("表单验证失败", error);
isSaveing.value = false;
console.error('表单验证失败', error)
isSaveing.value = false
}
};
}
// 表单注入数据
const setData = (data) => {
form.id = data.id;
form.username = data.username;
form.avatar = data.avatar;
form.real_name = data.real_name;
form.email = data.email;
form.phone = data.phone;
form.department_id = data.department_id;
form.role_ids = data.roles ? data.roles.map((item) => item.id) : [];
form.gender = data.gender !== undefined ? data.gender : null;
form.status = data.status !== undefined ? data.status : null;
};
form.id = data.id
form.username = data.username
form.avatar = data.avatar
form.real_name = data.real_name
form.email = data.email
form.phone = data.phone
form.department_id = data.department_id
form.role_ids = data.roles ? data.roles.map((item) => item.id) : []
form.gender = data.gender !== undefined ? data.gender : null
form.status = data.status !== undefined ? data.status : null
}
// 组件挂载时加载数据
loadDepartment();
loadRoles();
loadDepartment()
loadRoles()
// 暴露方法给父组件
defineExpose({
open,
setData,
close,
});
})
</script>
<style></style>
+252 -384
View File
@@ -2,12 +2,7 @@
<div class="pages-sidebar-layout user-page">
<div class="left-box">
<div class="header">
<a-input
v-model:value="departmentKeyword"
placeholder="搜索部门..."
allow-clear
@change="handleDeptSearch"
>
<a-input v-model:value="departmentKeyword" placeholder="搜索部门..." allow-clear @change="handleDeptSearch">
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
@@ -28,11 +23,7 @@
@select="onDeptSelect"
>
<template #icon="{ dataRef }">
<ApartmentOutlined
v-if="
dataRef.children && dataRef.children.length > 0
"
/>
<ApartmentOutlined v-if="dataRef.children && dataRef.children.length > 0" />
<UserOutlined v-else />
</template>
</a-tree>
@@ -43,12 +34,7 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.username"
placeholder="用户名"
allow-clear
style="width: 140px"
/>
<a-input v-model:value="searchForm.username" placeholder="用户名" allow-clear style="width: 140px" />
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -67,19 +53,11 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchStatus">
<CheckCircleOutlined />批量启用/禁用
</a-menu-item>
<a-menu-item @click="handleBatchDepartment">
<ApartmentOutlined />批量分配部门
</a-menu-item>
<a-menu-item @click="handleBatchRoles">
<TeamOutlined />批量分配角色
</a-menu-item>
<a-menu-item @click="handleBatchStatus"> <CheckCircleOutlined />批量启用/禁用 </a-menu-item>
<a-menu-item @click="handleBatchDepartment"> <ApartmentOutlined />批量分配部门 </a-menu-item>
<a-menu-item @click="handleBatchRoles"> <TeamOutlined />批量分配角色 </a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleBatchDelete" danger>
<DeleteOutlined />批量删除
</a-menu-item>
<a-menu-item @click="handleBatchDelete" danger> <DeleteOutlined />批量删除 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -90,15 +68,9 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleImport">
<ImportOutlined />导入用户
</a-menu-item>
<a-menu-item @click="handleExport">
<ExportOutlined />导出用户
</a-menu-item>
<a-menu-item @click="handleDownloadTemplate">
<DownloadOutlined />下载模板
</a-menu-item>
<a-menu-item @click="handleImport"> <ImportOutlined />导入用户 </a-menu-item>
<a-menu-item @click="handleExport"> <ExportOutlined />导出用户 </a-menu-item>
<a-menu-item @click="handleDownloadTemplate"> <DownloadOutlined />下载模板 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -128,57 +100,26 @@
</a-avatar>
</template>
<template #status="{ record }">
<a-tag
:color="record.status === 1 ? 'success' : 'error'"
>
{{ record.status === 1 ? "正常" : "禁用" }}
<a-tag :color="record.status === 1 ? 'success' : 'error'">
{{ record.status === 1 ? '正常' : '禁用' }}
</a-tag>
</template>
<template #department="{ record }">
{{ record.department?.name || "-" }}
{{ record.department?.name || '-' }}
</template>
<template #roles="{ record }">
<a-tag
v-for="role in record.roles"
:key="role.id"
color="blue"
>
<a-tag v-for="role in record.roles" :key="role.id" color="blue">
{{ role.name }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleView(record)"
>查看</a-button
>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>编辑</a-button
>
<a-button
type="link"
size="small"
@click="handleRole(record)"
>角色</a-button
>
<a-button
type="link"
size="small"
@click="handleResetPassword(record)"
>重置密码</a-button
>
<a-popconfirm
title="确定删除该用户吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
<a-button type="link" size="small" @click="handleView(record)">查看</a-button>
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
<a-button type="link" size="small" @click="handleRole(record)">角色</a-button>
<a-button type="link" size="small" @click="handleResetPassword(record)">重置密码</a-button>
<a-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
@@ -188,119 +129,57 @@
</div>
<!-- 新增/编辑用户弹窗 -->
<save-dialog
v-if="dialog.save"
ref="saveDialogRef"
@success="handleSaveSuccess"
@closed="dialog.save = false"
/>
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
<!-- 角色设置弹窗 -->
<role-dialog
v-if="dialog.role"
ref="roleDialogRef"
@success="handleRoleSuccess"
@closed="dialog.role = false"
/>
<role-dialog v-if="dialog.role" ref="roleDialogRef" @success="handleRoleSuccess" @closed="dialog.role = false" />
<!-- 批量分配部门弹窗 -->
<department-dialog
v-if="dialog.department"
ref="departmentDialogRef"
@success="handleDepartmentSuccess"
@closed="dialog.department = false"
/>
<department-dialog v-if="dialog.department" ref="departmentDialogRef" @success="handleDepartmentSuccess" @closed="dialog.department = false" />
<!-- 批量分配角色弹窗 -->
<batch-role-dialog
v-if="dialog.batchRole"
ref="batchRoleDialogRef"
@success="handleBatchRoleSuccess"
@closed="dialog.batchRole = false"
/>
<batch-role-dialog v-if="dialog.batchRole" ref="batchRoleDialogRef" @success="handleBatchRoleSuccess" @closed="dialog.batchRole = false" />
<!-- 导入用户弹窗 -->
<sc-import
v-model:open="dialog.import"
title="导入用户"
:api="authApi.users.import.post"
:template-api="authApi.users.downloadTemplate.get"
filename="用户"
@success="handleImportSuccess"
/>
<sc-import v-model:open="dialog.import" title="导入用户" :api="authApi.user.import.post" :template-api="authApi.user.downloadTemplate.get" filename="用户" @success="handleImportSuccess" />
<!-- 导出用户弹窗 -->
<sc-export
v-model:open="dialog.export"
title="导出用户"
:api="handleExportApi"
:default-filename="`用户列表_${Date.now()}`"
:show-options="false"
tip="导出当前选中或所有用户数据"
@success="handleExportSuccess"
/>
<sc-export v-model:open="dialog.export" title="导出用户" :api="handleExportApi" :default-filename="`用户列表_${Date.now()}`" :show-options="false" tip="导出当前选中或所有用户数据" @success="handleExportSuccess" />
</template>
<script setup>
import { ref, reactive, onMounted, watch } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
PlusOutlined,
DeleteOutlined,
CheckCircleOutlined,
ApartmentOutlined,
TeamOutlined,
DownOutlined,
ImportOutlined,
ExportOutlined,
DownloadOutlined,
UserOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import scImport from "@/components/scImport/index.vue";
import scExport from "@/components/scExport/index.vue";
import saveDialog from "./components/SaveDialog.vue";
import roleDialog from "./components/RoleDialog.vue";
import departmentDialog from "./components/DepartmentDialog.vue";
import batchRoleDialog from "./components/BatchRoleDialog.vue";
import authApi from "@/api/auth";
import { useTable } from "@/hooks/useTable";
import { ref, reactive, onMounted, watch } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, RedoOutlined, PlusOutlined, DeleteOutlined, CheckCircleOutlined, ApartmentOutlined, TeamOutlined, DownOutlined, ImportOutlined, ExportOutlined, DownloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import scImport from '@/components/scImport/index.vue'
import scExport from '@/components/scExport/index.vue'
import saveDialog from './components/SaveDialog.vue'
import roleDialog from './components/RoleDialog.vue'
import departmentDialog from './components/DepartmentDialog.vue'
import batchRoleDialog from './components/BatchRoleDialog.vue'
import authApi from '@/api/auth'
import { useTable } from '@/hooks/useTable'
defineOptions({
name: "authUser",
});
name: 'authUser',
})
// 使用useTable hooks
const {
tableRef,
searchForm,
tableData,
loading,
pagination,
selectedRows,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable,
} = useTable({
api: authApi.users.list.get,
const { tableRef, searchForm, tableData, loading, pagination, selectedRows, rowSelection, handleSearch, handleReset, handlePaginationChange, handleSelectChange, handleSelectAll, refreshTable } = useTable({
api: authApi.user.list.get,
searchForm: {
username: "",
real_name: "",
email: "",
phone: "",
username: '',
real_name: '',
email: '',
phone: '',
department_id: null,
status: null,
},
columns: [],
needPagination: true,
needSelection: true,
});
})
// 对话框状态
const dialog = reactive({
@@ -310,422 +189,411 @@ const dialog = reactive({
batchRole: false,
import: false,
export: false,
});
})
// 弹窗引用
const saveDialogRef = ref(null);
const roleDialogRef = ref(null);
const departmentDialogRef = ref(null);
const batchRoleDialogRef = ref(null);
const saveDialogRef = ref(null)
const roleDialogRef = ref(null)
const departmentDialogRef = ref(null)
const batchRoleDialogRef = ref(null)
// 部门树数据
const departmentTree = ref([]);
const filteredDepartmentTree = ref([]);
const selectedDeptKeys = ref([]);
const departmentKeyword = ref("");
const expandedDeptKeys = ref([]);
const departmentTree = ref([])
const filteredDepartmentTree = ref([])
const selectedDeptKeys = ref([])
const departmentKeyword = ref('')
const expandedDeptKeys = ref([])
// 行key
const rowKey = "id";
const rowKey = 'id'
// 递归获取所有部门节点的key
const getAllDepartmentKeys = (nodes) => {
const keys = [];
const keys = []
const traverse = (list) => {
list.forEach((node) => {
// 如果节点有children且不为空,则该节点需要展开
if (node.children && node.children.length > 0) {
keys.push(node.id);
traverse(node.children);
keys.push(node.id)
traverse(node.children)
}
});
};
traverse(nodes);
return keys;
};
})
}
traverse(nodes)
return keys
}
// 监听部门树数据变化,自动展开所有节点
watch(
() => filteredDepartmentTree.value,
(newData) => {
if (newData && newData.length > 0) {
expandedDeptKeys.value = getAllDepartmentKeys(newData);
expandedDeptKeys.value = getAllDepartmentKeys(newData)
} else {
expandedDeptKeys.value = [];
expandedDeptKeys.value = []
}
},
{ immediate: true, deep: true },
);
)
// 表格列配置
const columns = [
{
title: "头像",
dataIndex: "avatar",
key: "avatar",
title: '头像',
dataIndex: 'avatar',
key: 'avatar',
width: 80,
align: "center",
slot: "avatar",
align: 'center',
slot: 'avatar',
},
{ title: "用户名", dataIndex: "username", key: "username", width: 150 },
{ title: "姓名", dataIndex: "real_name", key: "real_name", width: 150 },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 150 },
{ title: '姓名', dataIndex: 'real_name', key: 'real_name', width: 150 },
{
title: "邮箱",
dataIndex: "email",
key: "email",
title: '邮箱',
dataIndex: 'email',
key: 'email',
width: 180,
ellipsis: true,
},
{ title: "手机号", dataIndex: "phone", key: "phone", width: 130 },
{ title: '手机号', dataIndex: 'phone', key: 'phone', width: 130 },
{
title: "部门",
dataIndex: "department",
key: "department",
slot: "department",
title: '部门',
dataIndex: 'department',
key: 'department',
slot: 'department',
width: 150,
ellipsis: true,
},
{
title: "角色",
dataIndex: "roles",
key: "roles",
title: '角色',
dataIndex: 'roles',
key: 'roles',
width: 200,
slot: "roles",
slot: 'roles',
ellipsis: true,
},
{
title: "状态",
dataIndex: "status",
key: "status",
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: "center",
slot: "status",
align: 'center',
slot: 'status',
},
{
title: "最后登录",
dataIndex: "last_login_at",
key: "last_login_at",
title: '最后登录',
dataIndex: 'last_login_at',
key: 'last_login_at',
width: 180,
},
{
title: "操作",
dataIndex: "action",
key: "action",
title: '操作',
dataIndex: 'action',
key: 'action',
width: 280,
align: "center",
slot: "action",
fixed: "right",
align: 'center',
slot: 'action',
fixed: 'right',
},
];
]
// 加载部门树
const loadDepartmentTree = async () => {
try {
const res = await authApi.departments.tree.get();
const res = await authApi.department.tree.get()
if (res.code === 200) {
departmentTree.value = res.data || [];
filteredDepartmentTree.value = res.data || [];
departmentTree.value = res.data || []
filteredDepartmentTree.value = res.data || []
}
} catch (error) {
console.error("加载部门树失败:", error);
console.error('加载部门树失败:', error)
}
};
}
// 部门搜索
const handleDeptSearch = (e) => {
const keyword = e.target?.value || "";
departmentKeyword.value = keyword;
const keyword = e.target?.value || ''
departmentKeyword.value = keyword
if (!keyword) {
filteredDepartmentTree.value = departmentTree.value;
return;
filteredDepartmentTree.value = departmentTree.value
return
}
// 递归过滤部门树
const filterTree = (nodes) => {
return nodes.reduce((acc, node) => {
const isMatch =
node.name &&
node.name.toLowerCase().includes(keyword.toLowerCase());
const filteredChildren = node.children
? filterTree(node.children)
: [];
const isMatch = node.name && node.name.toLowerCase().includes(keyword.toLowerCase())
const filteredChildren = node.children ? filterTree(node.children) : []
if (isMatch || filteredChildren.length > 0) {
acc.push({
...node,
children:
filteredChildren.length > 0
? filteredChildren
: undefined,
});
children: filteredChildren.length > 0 ? filteredChildren : undefined,
})
}
return acc;
}, []);
};
return acc
}, [])
}
filteredDepartmentTree.value = filterTree(departmentTree.value);
};
filteredDepartmentTree.value = filterTree(departmentTree.value)
}
// 重置 - 覆盖useTable的handleReset以添加额外逻辑
const handleUserReset = () => {
searchForm.username = "";
searchForm.real_name = "";
searchForm.email = "";
searchForm.phone = "";
searchForm.status = null;
searchForm.department_id = null;
selectedDeptKeys.value = [];
departmentKeyword.value = "";
filteredDepartmentTree.value = departmentTree.value;
handleSearch();
};
searchForm.username = ''
searchForm.real_name = ''
searchForm.email = ''
searchForm.phone = ''
searchForm.status = null
searchForm.department_id = null
selectedDeptKeys.value = []
departmentKeyword.value = ''
filteredDepartmentTree.value = departmentTree.value
handleSearch()
}
// 部门选择事件
const onDeptSelect = (selectedKeys) => {
if (selectedKeys && selectedKeys.length > 0) {
searchForm.department_id = selectedKeys[0];
searchForm.department_id = selectedKeys[0]
} else {
searchForm.department_id = null;
searchForm.department_id = null
}
handleSearch();
};
handleSearch()
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要删除的用户");
return;
message.warning('请选择要删除的用户')
return
}
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定删除选中的 ${selectedRows.value.length} 个用户吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await authApi.users.batchDelete.post({ ids });
const ids = selectedRows.value.map((item) => item.id)
const res = await authApi.user.batchDelete.post({ ids })
if (res.code === 200) {
message.success("删除成功");
selectedRows.value = [];
refreshTable();
message.success('删除成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("批量删除用户失败:", error);
message.error("删除失败");
console.error('批量删除用户失败:', error)
message.error('删除失败')
}
},
});
};
})
}
// 批量更新状态
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要操作的用户");
return;
message.warning('请选择要操作的用户')
return
}
Modal.confirm({
title: "确认操作",
content: "确定要批量启用/禁用选中的用户吗?",
okText: "确定",
cancelText: "取消",
title: '确认操作',
content: '确定要批量启用/禁用选中的用户吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const status = selectedRows.value[0].status === 1 ? 0 : 1;
const res = await authApi.users.batchStatus.post({
const ids = selectedRows.value.map((item) => item.id)
const status = selectedRows.value[0].status === 1 ? 0 : 1
const res = await authApi.user.batchStatus.post({
ids,
status,
});
})
if (res.code === 200) {
message.success("操作成功");
selectedRows.value = [];
refreshTable();
message.success('操作成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
console.error("批量更新状态失败:", error);
message.error("操作失败");
console.error('批量更新状态失败:', error)
message.error('操作失败')
}
},
});
};
})
}
// 批量分配部门
const handleBatchDepartment = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要分配部门的用户");
return;
message.warning('请选择要分配部门的用户')
return
}
dialog.department = true;
dialog.department = true
setTimeout(() => {
departmentDialogRef.value?.open(
selectedRows.value.map((item) => item.id),
);
}, 0);
};
departmentDialogRef.value?.open(selectedRows.value.map((item) => item.id))
}, 0)
}
// 批量分配角色
const handleBatchRoles = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要分配角色的用户");
return;
message.warning('请选择要分配角色的用户')
return
}
dialog.batchRole = true;
dialog.batchRole = true
setTimeout(() => {
batchRoleDialogRef.value?.open(
selectedRows.value.map((item) => item.id),
);
}, 0);
};
batchRoleDialogRef.value?.open(selectedRows.value.map((item) => item.id))
}, 0)
}
// 导出数据
const handleExport = () => {
dialog.export = true;
};
dialog.export = true
}
// 导出API封装
const handleExportApi = async () => {
const ids = selectedRows.value.map((item) => item.id);
return await authApi.users.export.post({
const ids = selectedRows.value.map((item) => item.id)
return await authApi.user.export.post({
ids: ids.length > 0 ? ids : undefined,
});
};
})
}
// 导出成功回调
const handleExportSuccess = () => {
selectedRows.value = [];
};
selectedRows.value = []
}
// 导入用户
const handleImport = () => {
dialog.import = true;
};
dialog.import = true
}
// 导入成功回调
const handleImportSuccess = () => {
refreshTable();
};
refreshTable()
}
// 下载模板
const handleDownloadTemplate = async () => {
try {
const blob = await authApi.users.downloadTemplate.get();
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "用户导入模板.xlsx";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
message.success("下载成功");
const blob = await authApi.user.downloadTemplate.get()
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = '用户导入模板.xlsx'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success('下载成功')
} catch (error) {
console.error("下载模板失败:", error);
message.error("下载失败");
console.error('下载模板失败:', error)
message.error('下载失败')
}
};
}
// 重置密码
const handleResetPassword = (record) => {
Modal.confirm({
title: "重置密码",
content: "确定要重置该用户的密码吗?重置后密码为: 123456",
okText: "确定",
cancelText: "取消",
title: '重置密码',
content: '确定要重置该用户的密码吗?重置后密码为: 123456',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
// TODO: 实现重置密码接口
message.success("密码重置成功");
message.success('密码重置成功')
} catch (error) {
console.error("重置密码失败:", error);
message.error("重置密码失败");
console.error('重置密码失败:', error)
message.error('重置密码失败')
}
},
});
};
})
}
// 新增用户
const handleAdd = () => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("add");
}, 0);
};
saveDialogRef.value?.open('add')
}, 0)
}
// 查看用户
const handleView = (record) => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("show").setData(record);
}, 0);
};
saveDialogRef.value?.open('show').setData(record)
}, 0)
}
// 编辑用户
const handleEdit = (record) => {
dialog.save = true;
dialog.save = true
setTimeout(() => {
saveDialogRef.value?.open("edit").setData(record);
}, 0);
};
saveDialogRef.value?.open('edit').setData(record)
}, 0)
}
// 设置角色
const handleRole = (record) => {
dialog.role = true;
dialog.role = true
setTimeout(() => {
if (roleDialogRef.value) {
roleDialogRef.value.open();
roleDialogRef.value.setData(record);
roleDialogRef.value.open()
roleDialogRef.value.setData(record)
}
}, 0);
};
}, 0)
}
// 删除用户
const handleDelete = async (record) => {
try {
const res = await authApi.users.delete.delete(record.id);
const res = await authApi.user.delete.delete(record.id)
if (res.code === 200) {
message.success("删除成功");
refreshTable();
message.success('删除成功')
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("删除用户失败:", error);
message.error("删除失败");
console.error('删除用户失败:', error)
message.error('删除失败')
}
};
}
// 保存成功回调
const handleSaveSuccess = () => {
refreshTable();
};
refreshTable()
}
// 角色设置成功回调
const handleRoleSuccess = () => {
refreshTable();
};
refreshTable()
}
// 批量分配部门成功回调
const handleDepartmentSuccess = () => {
selectedRows.value = [];
refreshTable();
};
selectedRows.value = []
refreshTable()
}
// 批量分配角色成功回调
const handleBatchRoleSuccess = () => {
selectedRows.value = [];
refreshTable();
};
selectedRows.value = []
refreshTable()
}
// 初始化
onMounted(() => {
loadDepartmentTree();
});
loadDepartmentTree()
})
</script>
@@ -8,11 +8,7 @@
</div>
</template>
<template #extra>
<a-button
type="primary"
size="small"
@click="showCustomizeModal = true"
>
<a-button type="primary" size="small" @click="showCustomizeModal = true">
<SettingOutlined />
自定义
</a-button>
@@ -20,25 +16,14 @@
<div class="actions-container">
<transition-group name="list" tag="div" class="actions-grid">
<div
v-for="action in displayActions"
:key="action.path"
class="action-item"
:class="{ 'no-actions': displayActions.length === 0 }"
@click="handleActionClick(action)"
>
<div v-for="action in displayActions" :key="action.path" class="action-item" :class="{ 'no-actions': displayActions.length === 0 }" @click="handleActionClick(action)">
<div
class="action-icon-wrapper"
:style="{
background:
action.gradient ||
getGradient(action.color),
background: action.gradient || getGradient(action.color),
}"
>
<component
:is="action.icon || 'AppstoreOutlined'"
class="action-icon"
/>
<component :is="action.icon || 'AppstoreOutlined'" class="action-icon" />
</div>
<div class="action-content">
<div class="action-title">{{ action.title }}</div>
@@ -57,57 +42,30 @@
</a-card>
<!-- 自定义快速操作弹窗 -->
<a-modal
v-model:open="showCustomizeModal"
title="自定义快速操作"
:width="700"
:footer="null"
class="customize-modal"
>
<a-modal v-model:open="showCustomizeModal" title="自定义快速操作" :width="700" :footer="null" class="customize-modal">
<div class="customize-content">
<!-- 已添加的操作 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">已添加的操作</h3>
<a-tag
:color="
tempCustomActions.length >= 8 ? 'error' : 'blue'
"
>
{{ tempCustomActions.length }}/8
</a-tag>
<a-tag :color="tempCustomActions.length >= 8 ? 'error' : 'blue'"> {{ tempCustomActions.length }}/8 </a-tag>
</div>
<div class="selected-actions">
<div class="drag-container">
<div
v-for="(action, index) in tempCustomActions"
:key="action.path"
class="action-tag"
:style="{ borderColor: action.color }"
>
<div v-for="(action, index) in tempCustomActions" :key="action.path" class="action-tag" :style="{ borderColor: action.color }">
<div
class="tag-icon"
:style="{
background: getGradient(action.color),
}"
>
<component
:is="action.icon || 'AppstoreOutlined'"
/>
<component :is="action.icon || 'AppstoreOutlined'" />
</div>
<span class="tag-title">{{
action.title
}}</span>
<CloseOutlined
class="tag-close"
@click="handleRemoveAction(action)"
/>
<span class="tag-title">{{ action.title }}</span>
<CloseOutlined class="tag-close" @click="handleRemoveAction(action)" />
</div>
</div>
<a-empty
v-if="tempCustomActions.length === 0"
description="从下方列表选择添加"
/>
<a-empty v-if="tempCustomActions.length === 0" description="从下方列表选择添加" />
</div>
</div>
@@ -115,58 +73,31 @@
<div class="section">
<div class="section-header">
<h3 class="section-title">可选操作</h3>
<a-tag color="cyan"
>{{ availableMenus.length }}个可用</a-tag
>
<a-tag color="cyan">{{ availableMenus.length }}个可用</a-tag>
</div>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索菜单名称或路径..."
allow-clear
size="large"
class="search-input"
>
<a-input-search v-model:value="searchKeyword" placeholder="搜索菜单名称或路径..." allow-clear size="large" class="search-input">
<template #prefix>
<SearchOutlined />
</template>
</a-input-search>
<div class="available-actions">
<div
v-for="menu in filteredMenus"
:key="menu.path"
class="menu-item"
:class="{ disabled: isAdded(menu) }"
@click="handleAddAction(menu)"
>
<div v-for="menu in filteredMenus" :key="menu.path" class="menu-item" :class="{ disabled: isAdded(menu) }" @click="handleAddAction(menu)">
<div
class="menu-icon"
:style="{
background: getGradient(
menu.meta?.iconColor,
),
background: getGradient(menu.meta?.iconColor),
}"
>
<component
:is="menu.meta?.icon || 'AppstoreOutlined'"
/>
<component :is="menu.meta?.icon || 'AppstoreOutlined'" />
</div>
<div class="menu-info">
<div class="menu-title">
{{
menu.meta?.title ||
menu.title ||
menu.name
}}
{{ menu.meta?.title || menu.title || menu.name }}
</div>
<div class="menu-path">{{ menu.path }}</div>
</div>
<div class="menu-action">
<a-button
v-if="!isAdded(menu)"
type="primary"
size="small"
ghost
>
<a-button v-if="!isAdded(menu)" type="primary" size="small" ghost>
<PlusOutlined />
添加
</a-button>
@@ -176,10 +107,7 @@
</a-tag>
</div>
</div>
<a-empty
v-if="filteredMenus.length === 0"
description="未找到匹配的操作"
/>
<a-empty v-if="filteredMenus.length === 0" description="未找到匹配的操作" />
</div>
</div>
</div>
@@ -188,11 +116,7 @@
<div class="modal-footer">
<a-space>
<a-button @click="handleCancelCustomize">取消</a-button>
<a-button
type="primary"
@click="handleSaveCustomActions"
:loading="saving"
>
<a-button type="primary" @click="handleSaveCustomActions" :loading="saving">
<SaveOutlined />
保存配置
</a-button>
@@ -203,251 +127,218 @@
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useRouter } from "vue-router";
import { useUserStore } from "@/stores/modules/user";
import {
SettingOutlined,
PlusOutlined,
CheckOutlined,
AppstoreOutlined,
ThunderboltOutlined,
CloseOutlined,
SearchOutlined,
SaveOutlined,
} from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { SettingOutlined, PlusOutlined, CheckOutlined, AppstoreOutlined, ThunderboltOutlined, CloseOutlined, SearchOutlined, SaveOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
defineOptions({
name: "QuickActions",
});
name: 'QuickActions',
})
const router = useRouter();
const userStore = useUserStore();
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false);
const showCustomizeModal = ref(false);
const searchKeyword = ref("");
const saving = ref(false);
const customActions = ref([]);
const tempCustomActions = ref([]);
const loading = ref(false)
const showCustomizeModal = ref(false)
const searchKeyword = ref('')
const saving = ref(false)
const customActions = ref([])
const tempCustomActions = ref([])
// 默认快速操作
const defaultActions = ref([]);
const defaultActions = ref([])
// 渐变色映射
const gradientColors = {
"#1890ff": "linear-gradient(135deg, #1890ff 0%, #36cfc9 100%)",
"#52c41a": "linear-gradient(135deg, #52c41a 0%, #95de64 100%)",
"#faad14": "linear-gradient(135deg, #faad14 0%, #ffc53d 100%)",
"#f5222d": "linear-gradient(135deg, #f5222d 0%, #ff4d4f 100%)",
"#722ed1": "linear-gradient(135deg, #722ed1 0%, #9254de 100%)",
"#eb2f96": "linear-gradient(135deg, #eb2f96 0%, #f759ab 100%)",
"#13c2c2": "linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%)",
"#fa8c16": "linear-gradient(135deg, #fa8c16 0%, #ffa940 100%)",
};
'#1890ff': 'linear-gradient(135deg, #1890ff 0%, #36cfc9 100%)',
'#52c41a': 'linear-gradient(135deg, #52c41a 0%, #95de64 100%)',
'#faad14': 'linear-gradient(135deg, #faad14 0%, #ffc53d 100%)',
'#f5222d': 'linear-gradient(135deg, #f5222d 0%, #ff4d4f 100%)',
'#722ed1': 'linear-gradient(135deg, #722ed1 0%, #9254de 100%)',
'#eb2f96': 'linear-gradient(135deg, #eb2f96 0%, #f759ab 100%)',
'#13c2c2': 'linear-gradient(135deg, #13c2c2 0%, #36cfc9 100%)',
'#fa8c16': 'linear-gradient(135deg, #fa8c16 0%, #ffa940 100%)',
}
// 获取渐变色
const getGradient = (color) => {
if (!color) return gradientColors["#1890ff"];
return gradientColors[color] || gradientColors["#1890ff"];
};
if (!color) return gradientColors['#1890ff']
return gradientColors[color] || gradientColors['#1890ff']
}
// 可用的菜单列表(扁平化所有菜单项)
const availableMenus = computed(() => {
const menus = userStore.menu || [];
const flattenMenus = [];
const menus = userStore.menu || []
const flattenMenus = []
const traverse = (menuList) => {
menuList.forEach((menu) => {
// 兼容不同的菜单数据结构
const menuType = menu.type || (menu.path ? "menu" : "");
if (menuType === "menu" && menu.path && !menu.meta?.hidden) {
const menuType = menu.type || (menu.path ? 'menu' : '')
if (menuType === 'menu' && menu.path && !menu.meta?.hidden) {
flattenMenus.push({
...menu,
title: menu.meta?.title || menu.title || menu.name,
icon: menu.meta?.icon || "AppstoreOutlined",
});
icon: menu.meta?.icon || 'AppstoreOutlined',
})
}
if (menu.children && menu.children.length > 0) {
traverse(menu.children);
traverse(menu.children)
}
});
};
})
}
traverse(menus);
return flattenMenus;
});
traverse(menus)
return flattenMenus
})
// 显示的快速操作
const displayActions = computed(() => {
if (customActions.value.length > 0) {
return customActions.value;
return customActions.value
}
return defaultActions.value;
});
return defaultActions.value
})
// 过滤后的菜单
const filteredMenus = computed(() => {
if (!searchKeyword.value) {
return availableMenus.value.slice(0, 50);
return availableMenus.value.slice(0, 50)
}
const keyword = searchKeyword.value.toLowerCase();
const keyword = searchKeyword.value.toLowerCase()
return availableMenus.value.filter((menu) => {
const title = (
menu.meta?.title ||
menu.title ||
menu.name ||
""
).toLowerCase();
const path = (menu.path || "").toLowerCase();
return title.includes(keyword) || path.includes(keyword);
});
});
const title = (menu.meta?.title || menu.title || menu.name || '').toLowerCase()
const path = (menu.path || '').toLowerCase()
return title.includes(keyword) || path.includes(keyword)
})
})
// 检查是否已添加
const isAdded = (menu) => {
return tempCustomActions.value.some((action) => action.path === menu.path);
};
return tempCustomActions.value.some((action) => action.path === menu.path)
}
// 加载自定义操作
const loadCustomActions = () => {
try {
const saved = localStorage.getItem("quick-actions");
const saved = localStorage.getItem('quick-actions')
if (saved) {
customActions.value = JSON.parse(saved);
customActions.value = JSON.parse(saved)
} else {
setDefaultActions();
setDefaultActions()
}
} catch (error) {
console.error("加载自定义操作失败:", error);
setDefaultActions();
console.error('加载自定义操作失败:', error)
setDefaultActions()
}
};
}
// 设置默认快速操作
const setDefaultActions = () => {
const menus = availableMenus.value;
const commonPaths = [
"/system/users",
"/system/roles",
"/system/permissions",
"/system/config",
];
const menus = availableMenus.value
const commonPaths = ['/system/users', '/system/roles', '/system/permissions', '/system/config']
defaultActions.value = menus
.filter((menu) => commonPaths.includes(menu.path))
.map((menu) => ({
...menu,
color: getRandomColor(),
}))
.slice(0, 8);
};
.slice(0, 8)
}
// 获取随机颜色
const getRandomColor = () => {
const colors = [
"#1890ff",
"#52c41a",
"#faad14",
"#f5222d",
"#722ed1",
"#eb2f96",
"#13c2c2",
"#fa8c16",
];
return colors[Math.floor(Math.random() * colors.length)];
};
const colors = ['#1890ff', '#52c41a', '#faad14', '#f5222d', '#722ed1', '#eb2f96', '#13c2c2', '#fa8c16']
return colors[Math.floor(Math.random() * colors.length)]
}
// 添加操作
const handleAddAction = (menu) => {
if (tempCustomActions.value.length >= 8) {
message.warning("最多添加8个快速操作");
return;
message.warning('最多添加8个快速操作')
return
}
if (isAdded(menu)) {
message.info("该操作已添加");
return;
message.info('该操作已添加')
return
}
tempCustomActions.value.push({
path: menu.path,
title: menu.title,
icon: menu.icon,
color: getRandomColor(),
});
message.success(`已添加: ${menu.title}`);
};
})
message.success(`已添加: ${menu.title}`)
}
// 移除操作
const handleRemoveAction = (action) => {
const index = tempCustomActions.value.findIndex(
(item) => item.path === action.path,
);
const index = tempCustomActions.value.findIndex((item) => item.path === action.path)
if (index > -1) {
tempCustomActions.value.splice(index, 1);
message.success(`已移除: ${action.title}`);
tempCustomActions.value.splice(index, 1)
message.success(`已移除: ${action.title}`)
}
};
}
// 保存自定义操作
const handleSaveCustomActions = () => {
if (tempCustomActions.value.length === 0) {
message.warning("请至少添加一个快速操作");
return;
message.warning('请至少添加一个快速操作')
return
}
saving.value = true;
saving.value = true
try {
customActions.value = [...tempCustomActions.value];
localStorage.setItem(
"quick-actions",
JSON.stringify(customActions.value),
);
customActions.value = [...tempCustomActions.value]
localStorage.setItem('quick-actions', JSON.stringify(customActions.value))
setTimeout(() => {
saving.value = false;
showCustomizeModal.value = false;
message.success("保存成功,快速操作已更新");
}, 500);
saving.value = false
showCustomizeModal.value = false
message.success('保存成功,快速操作已更新')
}, 500)
} catch (error) {
saving.value = false;
message.error("保存失败,请重试");
saving.value = false
message.error('保存失败,请重试')
}
};
}
// 取消自定义
const handleCancelCustomize = () => {
showCustomizeModal.value = false;
};
showCustomizeModal.value = false
}
// 点击操作项
const handleActionClick = (action) => {
if (action.path) {
router.push(action.path);
router.push(action.path)
}
};
}
// 监听弹窗打开
watch(showCustomizeModal, (newVal) => {
if (newVal) {
tempCustomActions.value = [...customActions.value];
searchKeyword.value = "";
tempCustomActions.value = [...customActions.value]
searchKeyword.value = ''
}
});
})
// 监听菜单变化
watch(
() => userStore.menu,
(newMenu) => {
if (newMenu && newMenu.length > 0 && customActions.value.length === 0) {
setDefaultActions();
setDefaultActions()
}
},
{ immediate: true },
);
)
onMounted(() => {
loading.value = true;
loadCustomActions();
loading.value = false;
});
loading.value = true
loadCustomActions()
loading.value = false
})
</script>
<style scoped lang="scss">
@@ -766,7 +657,7 @@ onMounted(() => {
.menu-path {
font-size: 13px;
color: rgba(0, 0, 0, 0.45);
font-family: "Courier New", monospace;
font-family: 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -4,9 +4,7 @@
<div class="welcome-content">
<div class="greeting">
<h2 class="greeting-text">{{ greetingText }}</h2>
<p class="welcome-subtitle">
{{ userInfo?.username || "管理员" }},欢迎回来!
</p>
<p class="welcome-subtitle">{{ userInfo?.username || '管理员' }},欢迎回来!</p>
</div>
<div class="welcome-info">
<a-space direction="vertical" :size="8">
@@ -30,68 +28,64 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { useUserStore } from "@/stores/modules/user";
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
} from "@ant-design/icons-vue";
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useUserStore } from '@/stores/modules/user'
import { CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons-vue'
defineOptions({
name: "Welcome",
});
name: 'Welcome',
})
const userStore = useUserStore();
const userInfo = computed(() => userStore.userInfo);
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)
const currentDate = ref("");
const currentTime = ref("");
const weatherText = ref("天气晴朗,适合工作");
let timer = null;
const currentDate = ref('')
const currentTime = ref('')
const weatherText = ref('天气晴朗,适合工作')
let timer = null
// 根据时间段返回问候语
const greetingText = computed(() => {
const hour = new Date().getHours();
const hour = new Date().getHours()
if (hour >= 5 && hour < 12) {
return "早上好";
return '早上好'
} else if (hour >= 12 && hour < 14) {
return "中午好";
return '中午好'
} else if (hour >= 14 && hour < 18) {
return "下午好";
return '下午好'
} else if (hour >= 18 && hour < 23) {
return "晚上好";
return '晚上好'
} else {
return "夜深了";
return '夜深了'
}
});
})
// 更新日期和时间
const updateTime = () => {
const now = new Date();
currentDate.value = now.toLocaleDateString("zh-CN", {
year: "numeric",
month: "long",
day: "numeric",
weekday: "long",
});
currentTime.value = now.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
const now = new Date()
currentDate.value = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
})
currentTime.value = now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
onMounted(() => {
updateTime();
timer = setInterval(updateTime, 1000);
});
updateTime()
timer = setInterval(updateTime, 1000)
})
onUnmounted(() => {
if (timer) {
clearInterval(timer);
clearInterval(timer)
}
});
})
</script>
<style scoped lang="scss">
+4 -4
View File
@@ -13,13 +13,13 @@
</template>
<script setup>
import Welcome from "./components/Welcome.vue";
import QuickActions from "./components/QuickActions.vue";
import Welcome from './components/Welcome.vue'
import QuickActions from './components/QuickActions.vue'
// 定义组件名称
defineOptions({
name: "HomePage",
});
name: 'HomePage',
})
</script>
<style scoped lang="scss">
+55 -90
View File
@@ -12,20 +12,9 @@
<p class="auth-subtitle">登录您的账户继续探索科技世界</p>
</div>
<a-form
ref="loginFormRef"
:model="loginForm"
:rules="loginRules"
class="auth-form"
@finish="handleLogin"
layout="vertical"
>
<a-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="auth-form" @finish="handleLogin" layout="vertical">
<a-form-item name="username">
<a-input
v-model:value="loginForm.username"
placeholder="请输入用户名/邮箱"
size="large"
>
<a-input v-model:value="loginForm.username" placeholder="请输入用户名/邮箱" size="large">
<template #prefix>
<UserOutlined />
</template>
@@ -33,12 +22,7 @@
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="loginForm.password"
placeholder="请输入密码"
size="large"
@pressEnter="handleLogin"
>
<a-input-password v-model:value="loginForm.password" placeholder="请输入密码" size="large" @pressEnter="handleLogin">
<template #prefix>
<LockOutlined />
</template>
@@ -46,26 +30,13 @@
</a-form-item>
<div class="auth-links">
<a-checkbox
v-model:checked="loginForm.rememberMe"
class="remember-me"
>
记住我
</a-checkbox>
<router-link to="/reset-password" class="forgot-password">
忘记密码?
</router-link>
<a-checkbox v-model:checked="loginForm.rememberMe" class="remember-me"> 记住我 </a-checkbox>
<router-link to="/reset-password" class="forgot-password"> 忘记密码? </router-link>
</div>
<a-form-item>
<a-button
type="primary"
:loading="loading"
size="large"
html-type="submit"
block
>
{{ loading ? "登录中..." : "登录" }}
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
{{ loading ? '登录中...' : '登录' }}
</a-button>
</a-form-item>
</a-form>
@@ -73,9 +44,7 @@
<div class="auth-footer">
<p class="auth-footer-text">
还没有账户?
<router-link to="/userRegister" class="auth-link">
立即注册
</router-link>
<router-link to="/userRegister" class="auth-link"> 立即注册 </router-link>
</p>
</div>
</div>
@@ -83,123 +52,119 @@
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter, useRoute } from "vue-router";
import { message } from "ant-design-vue";
import { UserOutlined, LockOutlined } from "@ant-design/icons-vue";
import { useUserStore } from "@/stores/modules/user";
import { useDictionaryStore } from "@/stores/modules/dictionary";
import auth from "@/api/auth";
import config from "@/config";
import "@/assets/style/auth.scss";
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
import { useUserStore } from '@/stores/modules/user'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import auth from '@/api/auth'
import config from '@/config'
import '@/assets/style/auth.scss'
defineOptions({
name: "LoginPage",
});
name: 'LoginPage',
})
const router = useRouter();
const route = useRoute();
const loginFormRef = ref(null);
const loading = ref(false);
const router = useRouter()
const route = useRoute()
const loginFormRef = ref(null)
const loading = ref(false)
const userStore = useUserStore();
const dictionaryStore = useDictionaryStore();
const userStore = useUserStore()
const dictionaryStore = useDictionaryStore()
// Login form data
const loginForm = reactive({
username: "",
password: "",
username: '',
password: '',
rememberMe: false,
});
})
// Form validation rules
const loginRules = {
username: [
{ required: true, message: "请输入用户名或邮箱" },
{ min: 3, max: 50, message: "长度在 3 到 50 个字符" },
{ required: true, message: '请输入用户名或邮箱' },
{ min: 3, max: 50, message: '长度在 3 到 50 个字符' },
],
password: [
{ required: true, message: "请输入密码" },
{ min: 6, message: "密码长度不能少于 6 位" },
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于 6 位' },
],
};
}
// Handle login
const handleLogin = async () => {
if (!loginFormRef.value) return;
if (!loginFormRef.value) return
try {
// Validate form
await loginFormRef.value.validate();
loading.value = true;
await loginFormRef.value.validate()
loading.value = true
// 1. Call login API
const loginResponse = await auth.login.post({
username: loginForm.username,
password: loginForm.password,
});
})
// Check if login was successful
if (loginResponse.code !== 200) {
throw new Error(loginResponse.message || "登录失败");
throw new Error(loginResponse.message || '登录失败')
}
const loginData = loginResponse.data;
const loginData = loginResponse.data
// 2. Store token persistently (接口返回的是 token 字段)
if (loginData.token) {
userStore.setToken(loginData.token);
userStore.setToken(loginData.token)
}
// Store user information if available (登录接口已返回用户信息)
if (loginData.user) {
userStore.setUserInfo(loginData.user);
userStore.setUserInfo(loginData.user)
}
// 3. Store menu and permissions from login response
// 根据接口文档,登录接口已返回 menu 和 permissions 数据
if (loginData.menu) {
userStore.setMenu(loginData.menu);
userStore.setMenu(loginData.menu)
}
if (loginData.permissions) {
userStore.setPermissions(loginData.permissions);
userStore.setPermissions(loginData.permissions)
}
// 4. Load dictionary data (缓存字典数据)
dictionaryStore.loadAllDictionaries().catch((error) => {
console.error("加载字典数据失败:", error);
});
console.error('加载字典数据失败:', error)
})
// Success message
message.success("登录成功!");
message.success('登录成功!')
// Redirect to dashboard or redirect parameter
setTimeout(() => {
// Get redirect from query parameter
const redirect = route.query.redirect;
const redirect = route.query.redirect
if (redirect) {
// If there's a redirect parameter, go there
router.push(redirect);
router.push(redirect)
} else {
// Otherwise, go to configured dashboard URL
router.push(config.DASHBOARD_URL);
router.push(config.DASHBOARD_URL)
}
}, 500);
}, 500)
} catch (error) {
// Clear user data on login failure
userStore.logout();
userStore.logout()
// Show error message
const errorMsg =
error.response?.data?.msg ||
error.msg ||
error.message ||
"登录失败,请检查用户名和密码";
message.error(errorMsg);
const errorMsg = error.response?.data?.msg || error.msg || error.message || '登录失败,请检查用户名和密码'
message.error(errorMsg)
} finally {
loading.value = false;
loading.value = false
}
};
}
</script>
@@ -9,26 +9,12 @@
<div class="auth-card">
<div class="auth-header">
<h1 class="auth-title">找回密码</h1>
<p class="auth-subtitle">
输入您的邮箱,我们将发送重置密码链接
</p>
<p class="auth-subtitle">输入您的邮箱,我们将发送重置密码链接</p>
</div>
<a-form
ref="forgotFormRef"
:model="forgotForm"
:rules="forgotRules"
class="auth-form"
@finish="handleSubmit"
layout="vertical"
>
<a-form ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" class="auth-form" @finish="handleSubmit" layout="vertical">
<a-form-item name="email">
<a-input
v-model:value="forgotForm.email"
placeholder="请输入注册邮箱"
size="large"
@pressEnter="handleSubmit"
>
<a-input v-model:value="forgotForm.email" placeholder="请输入注册邮箱" size="large" @pressEnter="handleSubmit">
<template #prefix>
<MailOutlined />
</template>
@@ -37,36 +23,20 @@
<a-form-item name="captcha" v-if="showCaptcha">
<div style="display: flex; gap: 12px">
<a-input
v-model:value="forgotForm.captcha"
placeholder="请输入验证码"
size="large"
style="flex: 1"
>
<a-input v-model:value="forgotForm.captcha" placeholder="请输入验证码" size="large" style="flex: 1">
<template #prefix>
<SafetyOutlined />
</template>
</a-input>
<a-button
type="default"
size="large"
:disabled="captchaDisabled"
@click="sendCaptcha"
>
<a-button type="default" size="large" :disabled="captchaDisabled" @click="sendCaptcha">
{{ captchaButtonText }}
</a-button>
</div>
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="loading"
size="large"
html-type="submit"
block
>
{{ loading ? "提交中..." : "发送重置链接" }}
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
{{ loading ? '提交中...' : '发送重置链接' }}
</a-button>
</a-form-item>
</a-form>
@@ -74,9 +44,7 @@
<div class="auth-footer">
<p class="auth-footer-text">
想起密码了?
<router-link to="/login" class="auth-link">
返回登录
</router-link>
<router-link to="/login" class="auth-link"> 返回登录 </router-link>
</p>
</div>
</div>
@@ -84,113 +52,113 @@
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import { MailOutlined, SafetyOutlined } from "@ant-design/icons-vue";
import "@/assets/style/auth.scss";
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { MailOutlined, SafetyOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
defineOptions({
name: "ResetPasswordPage",
});
name: 'ResetPasswordPage',
})
const router = useRouter();
const forgotFormRef = ref(null);
const loading = ref(false);
const showCaptcha = ref(false);
const captchaDisabled = ref(false);
const countdown = ref(60);
const router = useRouter()
const forgotFormRef = ref(null)
const loading = ref(false)
const showCaptcha = ref(false)
const captchaDisabled = ref(false)
const countdown = ref(60)
// Forgot password form data
const forgotForm = reactive({
email: "",
captcha: "",
});
email: '',
captcha: '',
})
// Captcha button text
const captchaButtonText = ref("获取验证码");
const captchaButtonText = ref('获取验证码')
// Form validation rules
const forgotRules = {
email: [
{ required: true, message: "请输入邮箱地址" },
{ type: "email", message: "请输入正确的邮箱地址" },
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入正确的邮箱地址' },
],
captcha: [
{ required: true, message: "请输入验证码" },
{ len: 6, message: "验证码为6位数字" },
{ required: true, message: '请输入验证码' },
{ len: 6, message: '验证码为6位数字' },
],
};
}
// Send captcha code
const sendCaptcha = async () => {
if (!forgotForm.email) {
message.warning("请先输入邮箱地址");
return;
message.warning('请先输入邮箱地址')
return
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(forgotForm.email)) {
message.warning("请输入正确的邮箱地址");
return;
message.warning('请输入正确的邮箱地址')
return
}
try {
// Simulate API call - Replace with actual API call
// Example: const response = await sendCaptchaApi(forgotForm.email)
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500))
message.success("验证码已发送至您的邮箱");
message.success('验证码已发送至您的邮箱')
// Start countdown
captchaDisabled.value = true;
captchaDisabled.value = true
const timer = setInterval(() => {
countdown.value--;
captchaButtonText.value = `${countdown.value}秒后重试`;
countdown.value--
captchaButtonText.value = `${countdown.value}秒后重试`
if (countdown.value <= 0) {
clearInterval(timer);
captchaDisabled.value = false;
captchaButtonText.value = "获取验证码";
countdown.value = 60;
clearInterval(timer)
captchaDisabled.value = false
captchaButtonText.value = '获取验证码'
countdown.value = 60
}
}, 1000);
}, 1000)
showCaptcha.value = true;
showCaptcha.value = true
} catch (error) {
console.error("Send captcha failed:", error);
message.error("发送验证码失败,请稍后重试");
console.error('Send captcha failed:', error)
message.error('发送验证码失败,请稍后重试')
}
};
}
// Handle submit
const handleSubmit = async () => {
if (!forgotFormRef.value) return;
if (!forgotFormRef.value) return
try {
await forgotFormRef.value.validate();
loading.value = true;
await forgotFormRef.value.validate()
loading.value = true
// Simulate API call - Replace with actual API call
// Example: const response = await forgotPasswordApi(forgotForm)
// Simulated delay
await new Promise((resolve) => setTimeout(resolve, 1500));
await new Promise((resolve) => setTimeout(resolve, 1500))
// Success message
message.success("密码重置链接已发送至您的邮箱,请注意查收");
message.success('密码重置链接已发送至您的邮箱,请注意查收')
// Redirect to login page
setTimeout(() => {
router.push("/login");
}, 2000);
router.push('/login')
}, 2000)
} catch (error) {
console.error("Forgot password failed:", error);
message.error("提交失败,请检查邮箱地址和验证码");
console.error('Forgot password failed:', error)
message.error('提交失败,请检查邮箱地址和验证码')
} finally {
loading.value = false;
loading.value = false
}
};
}
</script>
@@ -12,20 +12,9 @@
<p class="auth-subtitle">加入我们,开启科技之旅</p>
</div>
<a-form
ref="registerFormRef"
:model="registerForm"
:rules="registerRules"
class="auth-form"
@finish="handleRegister"
layout="vertical"
>
<a-form ref="registerFormRef" :model="registerForm" :rules="registerRules" class="auth-form" @finish="handleRegister" layout="vertical">
<a-form-item name="username">
<a-input
v-model:value="registerForm.username"
placeholder="请输入用户名"
size="large"
>
<a-input v-model:value="registerForm.username" placeholder="请输入用户名" size="large">
<template #prefix>
<UserOutlined />
</template>
@@ -33,11 +22,7 @@
</a-form-item>
<a-form-item name="email">
<a-input
v-model:value="registerForm.email"
placeholder="请输入邮箱地址"
size="large"
>
<a-input v-model:value="registerForm.email" placeholder="请输入邮箱地址" size="large">
<template #prefix>
<MailOutlined />
</template>
@@ -45,11 +30,7 @@
</a-form-item>
<a-form-item name="password">
<a-input-password
v-model:value="registerForm.password"
placeholder="请输入密码(至少6位)"
size="large"
>
<a-input-password v-model:value="registerForm.password" placeholder="请输入密码(至少6位)" size="large">
<template #prefix>
<LockOutlined />
</template>
@@ -57,12 +38,7 @@
</a-form-item>
<a-form-item name="confirmPassword">
<a-input-password
v-model:value="registerForm.confirmPassword"
placeholder="请再次输入密码"
size="large"
@pressEnter="handleRegister"
>
<a-input-password v-model:value="registerForm.confirmPassword" placeholder="请再次输入密码" size="large" @pressEnter="handleRegister">
<template #prefix>
<LockOutlined />
</template>
@@ -70,10 +46,7 @@
</a-form-item>
<a-form-item name="agreeTerms">
<a-checkbox
v-model:checked="registerForm.agreeTerms"
class="remember-me"
>
<a-checkbox v-model:checked="registerForm.agreeTerms" class="remember-me">
我已阅读并同意
<a href="#" class="auth-link">服务条款</a>
和
@@ -82,14 +55,8 @@
</a-form-item>
<a-form-item>
<a-button
type="primary"
:loading="loading"
size="large"
html-type="submit"
block
>
{{ loading ? "注册中..." : "注册" }}
<a-button type="primary" :loading="loading" size="large" html-type="submit" block>
{{ loading ? '注册中...' : '注册' }}
</a-button>
</a-form-item>
</a-form>
@@ -97,9 +64,7 @@
<div class="auth-footer">
<p class="auth-footer-text">
已有账户?
<router-link to="/login" class="auth-link">
立即登录
</router-link>
<router-link to="/login" class="auth-link"> 立即登录 </router-link>
</p>
</div>
</div>
@@ -107,94 +72,90 @@
</template>
<script setup>
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { message } from "ant-design-vue";
import {
UserOutlined,
MailOutlined,
LockOutlined,
} from "@ant-design/icons-vue";
import "@/assets/style/auth.scss";
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { UserOutlined, MailOutlined, LockOutlined } from '@ant-design/icons-vue'
import '@/assets/style/auth.scss'
defineOptions({
name: "RegisterPage",
});
name: 'RegisterPage',
})
const router = useRouter();
const registerFormRef = ref(null);
const loading = ref(false);
const router = useRouter()
const registerFormRef = ref(null)
const loading = ref(false)
// Register form data
const registerForm = reactive({
username: "",
email: "",
password: "",
confirmPassword: "",
username: '',
email: '',
password: '',
confirmPassword: '',
agreeTerms: false,
});
})
// Form validation rules
const registerRules = {
username: [
{ required: true, message: "请输入用户名" },
{ min: 3, max: 20, message: "长度在 3 到 20 个字符" },
{ required: true, message: '请输入用户名' },
{ min: 3, max: 20, message: '长度在 3 到 20 个字符' },
],
email: [
{ required: true, message: "请输入邮箱地址" },
{ type: "email", message: "请输入正确的邮箱地址" },
{ required: true, message: '请输入邮箱地址' },
{ type: 'email', message: '请输入正确的邮箱地址' },
],
password: [
{ required: true, message: "请输入密码" },
{ min: 6, message: "密码长度不能少于 6 位" },
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码长度不能少于 6 位' },
],
confirmPassword: [
{ required: true, message: "请再次输入密码" },
{ required: true, message: '请再次输入密码' },
{
validator: (rule, value) => {
if (value !== registerForm.password) {
return Promise.reject("两次输入的密码不一致");
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve();
return Promise.resolve()
},
},
],
agreeTerms: [
{
type: "enum",
type: 'enum',
enum: [true],
message: "请阅读并同意服务条款和隐私政策",
message: '请阅读并同意服务条款和隐私政策',
transform: (value) => value || false,
},
],
};
}
// Handle register
const handleRegister = async () => {
if (!registerFormRef.value) return;
if (!registerFormRef.value) return
try {
await registerFormRef.value.validate();
loading.value = true;
await registerFormRef.value.validate()
loading.value = true
// Simulate API call - Replace with actual API call
// Example: const response = await registerApi(registerForm)
// Simulated delay
await new Promise((resolve) => setTimeout(resolve, 1500));
await new Promise((resolve) => setTimeout(resolve, 1500))
// Success message
message.success("注册成功!正在跳转到登录页面...");
message.success('注册成功!正在跳转到登录页面...')
// Redirect to login page
setTimeout(() => {
router.push("/login");
}, 1500);
router.push('/login')
}, 1500)
} catch (error) {
console.error("Register failed:", error);
message.error("注册失败,请稍后重试");
console.error('Register failed:', error)
message.error('注册失败,请稍后重试')
} finally {
loading.value = false;
loading.value = false
}
};
}
</script>
@@ -1,66 +1,22 @@
<template>
<a-modal
v-model:open="visible"
:title="title"
:width="500"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-modal v-model:open="visible" :title="title" :width="500" :confirm-loading="loading" @ok="handleOk" @cancel="handleCancel">
<a-form ref="formRef" :model="formData" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-form-item label="城市名称" name="title">
<a-input
v-model:value="formData.title"
placeholder="请输入城市名称"
allow-clear
/>
<a-input v-model:value="formData.title" placeholder="请输入城市名称" allow-clear />
</a-form-item>
<a-form-item label="城市编码" name="code">
<a-input
v-model:value="formData.code"
placeholder="请输入城市编码"
allow-clear
:disabled="mode === 'edit'"
/>
<a-input v-model:value="formData.code" placeholder="请输入城市编码" allow-clear :disabled="mode === 'edit'" />
</a-form-item>
<a-form-item
v-if="mode === 'add'"
label="父级城市"
name="parent_code"
>
<a-select
v-model:value="formData.parent_code"
placeholder="请选择父级城市"
allow-clear
show-search
:filter-option="filterOption"
>
<a-select-option :value="null"
>无(顶级城市)</a-select-option
>
<a-select-option
v-for="city in flatCities"
:key="city.code"
:value="city.code"
>
{{ city.title }} ({{ city.code }})
</a-select-option>
<a-form-item v-if="mode === 'add'" label="父级城市" name="parent_code">
<a-select v-model:value="formData.parent_code" placeholder="请选择父级城市" allow-clear show-search :filter-option="filterOption">
<a-select-option :value="null">无(顶级城市)</a-select-option>
<a-select-option v-for="city in flatCities" :key="city.code" :value="city.code"> {{ city.title }} ({{ city.code }}) </a-select-option>
</a-select>
</a-form-item>
<a-form-item
v-if="mode === 'addChild'"
label="父级城市"
name="parent_code"
>
<a-form-item v-if="mode === 'edit' && formData.parent_code" label="父级城市" name="parent_code">
<a-input v-model:value="parentCityTitle" disabled />
</a-form-item>
</a-form>
@@ -68,9 +24,9 @@
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { message } from "ant-design-vue";
import api from "@/api/system";
import { ref, reactive, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import api from '@/api/system'
const props = defineProps({
visible: {
@@ -83,157 +39,150 @@ const props = defineProps({
},
mode: {
type: String,
default: "add",
default: 'add',
},
});
})
const emit = defineEmits(["update:visible", "success"]);
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref(null);
const loading = ref(false);
const flatCities = ref([]);
const parentCityTitle = ref("");
const formRef = ref(null)
const loading = ref(false)
const flatCities = ref([])
const parentCityTitle = ref('')
const formData = reactive({
title: "",
code: "",
title: '',
code: '',
parent_code: null,
});
})
const rules = {
title: [
{ required: true, message: "请输入城市名称", trigger: "blur" },
{ max: 100, message: "城市名称不能超过100个字符", trigger: "blur" },
{ required: true, message: '请输入城市名称', trigger: 'blur' },
{ max: 100, message: '城市名称不能超过100个字符', trigger: 'blur' },
],
code: [
{ required: true, message: "请输入城市编码", trigger: "blur" },
{ max: 50, message: "城市编码不能超过50个字符", trigger: "blur" },
{ required: true, message: '请输入城市编码', trigger: 'blur' },
{ max: 50, message: '城市编码不能超过50个字符', trigger: 'blur' },
{
pattern: /^[A-Za-z0-9_-]+$/,
message: "城市编码只能包含字母、数字、下划线和横线",
trigger: "blur",
message: '城市编码只能包含字母、数字、下划线和横线',
trigger: 'blur',
},
],
};
}
const title = computed(() => {
const titles = {
add: "新增城市",
addChild: "新增子城市",
edit: "编辑城市",
};
return titles[props.mode] || "城市";
});
add: '新增城市',
edit: '编辑城市',
}
return titles[props.mode] || '城市'
})
const visible = computed({
get: () => props.visible,
set: (val) => emit("update:visible", val),
});
set: (val) => emit('update:visible', val),
})
// 加载所有城市(用于父级选择)
const loadAllCities = async () => {
try {
const res = await api.cities.list.get({ page_size: 1000 });
const res = await api.city.list.get({ page_size: 1000 })
if (res.code === 200) {
flatCities.value = res.data.list || [];
flatCities.value = res.data.list || []
}
} catch (error) {
console.error("加载城市列表失败", error);
console.error('加载城市列表失败', error)
}
};
}
// 搜索过滤
const filterOption = (input, option) => {
return option.children[0].children
.toLowerCase()
.includes(input.toLowerCase());
};
return option.children[0].children.toLowerCase().includes(input.toLowerCase())
}
// 重置表单
const resetForm = () => {
formData.title = "";
formData.code = "";
formData.parent_code = null;
parentCityTitle.value = "";
formRef.value?.clearValidate();
};
formData.title = ''
formData.code = ''
formData.parent_code = null
parentCityTitle.value = ''
formRef.value?.clearValidate()
}
// 监听 cityData 变化
watch(
() => props.cityData,
(val) => {
if (val && props.visible) {
formData.title = val.title || "";
formData.code = val.code || "";
formData.parent_code = val.parent_code || null;
formData.title = val.title || ''
formData.code = val.code || ''
formData.parent_code = val.parent_code || null
if (props.mode === "addChild" && val.parent_code) {
if (props.mode === 'edit' && val.parent_code) {
// 查找父级城市名称
const parentCity = flatCities.value.find(
(city) => city.code === val.parent_code,
);
parentCityTitle.value = parentCity
? `${parentCity.title} (${parentCity.code})`
: val.parent_code;
const parentCity = flatCities.value.find((city) => city.code === val.parent_code)
parentCityTitle.value = parentCity ? `${parentCity.title} (${parentCity.code})` : val.parent_code
}
}
},
{ immediate: true, deep: true },
);
)
// 监听弹窗打开
watch(
() => props.visible,
(val) => {
if (val) {
loadAllCities();
loadAllCities()
} else {
resetForm();
resetForm()
}
},
);
)
// 确定
const handleOk = async () => {
try {
await formRef.value.validate();
loading.value = true;
await formRef.value.validate()
loading.value = true
const data = {
title: formData.title,
code: formData.code,
};
if (props.mode === "add" && formData.parent_code) {
data.parent_code = formData.parent_code;
}
let res;
if (props.mode === "add" || props.mode === "addChild") {
res = await api.cities.add.post(data);
} else if (props.mode === "edit") {
res = await api.cities.edit.put(props.cityData.id, data);
if (props.mode === 'add' && formData.parent_code) {
data.parent_code = formData.parent_code
}
let res
if (props.mode === 'add') {
res = await api.city.add.post(data)
} else if (props.mode === 'edit') {
res = await api.city.edit.put(props.cityData.id, data)
}
if (res.code === 200) {
message.success(props.mode === "edit" ? "更新成功" : "创建成功");
emit("success");
handleCancel();
message.success(props.mode === 'edit' ? '更新成功' : '创建成功')
emit('success')
handleCancel()
}
} catch (error) {
if (error.errorFields) {
// 表单验证错误,不提示
} else {
message.error(error.message || "操作失败");
message.error(error.message || '操作失败')
}
} finally {
loading.value = false;
loading.value = false
}
};
}
// 取消
const handleCancel = () => {
resetForm();
emit("update:visible", false);
};
resetForm()
emit('update:visible', false)
}
</script>
+88 -261
View File
@@ -3,12 +3,7 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="城市名称/编码"
allow-clear
style="width: 180px"
/>
<a-input v-model:value="searchForm.keyword" placeholder="城市名称/编码" allow-clear style="width: 180px" />
<a-button type="primary" @click="handleSearch">
<template #icon><search-outlined /></template>
搜索
@@ -35,14 +30,6 @@
<reload-outlined />
刷新
</a-menu-item>
<a-menu-item @click="handleExpandAll">
<expand-outlined />
展开全部
</a-menu-item>
<a-menu-item @click="handleCollapseAll">
<compress-outlined />
折叠全部
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -50,333 +37,173 @@
</div>
<div class="table-content">
<a-table
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="false"
:row-key="rowKey"
:expand-row-by-click="true"
:default-expand-all-rows="false"
:expand-icon-column-index="1"
:indent-size="20"
@expand="handleExpand"
>
<template #expandedRowRender="{ record }">
<a-table
:columns="childColumns"
:data-source="record.children"
:pagination="false"
:row-key="rowKey"
size="small"
:show-header="false"
>
<template #bodyCell="{ column, record: child }">
<template v-if="column.key === 'title'">
<a-tag color="blue">{{ child.title }}</a-tag>
</template>
<template v-else-if="column.key === 'code'">
{{ child.code }}
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleAddChild(child)"
>
新增子级
</a-button>
<a-button
type="link"
size="small"
@click="handleEdit(child)"
>
编辑
</a-button>
<a-popconfirm
title="确定要删除该城市吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(child.id)"
>
<a-button
type="link"
size="small"
danger
>删除</a-button
>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<scTable :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" :row-key="rowKey" @refresh="refreshTable" @paginationChange="handlePaginationChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<a-tag color="green">{{ record.title }}</a-tag>
<a-tag color="blue">{{ record.title }}</a-tag>
</template>
<template v-else-if="column.key === 'code'">
{{ record.code }}
</template>
<template v-else-if="column.key === 'parent_code'">
<a-tag v-if="record.parent_code" color="purple">{{
record.parent_code
}}</a-tag>
<a-tag v-if="record.parent_code" color="purple">{{ record.parent_code }}</a-tag>
<span v-else class="text-gray-400">-</span>
</template>
<template v-else-if="column.key === 'has_children'">
<a-tag v-if="hasChildren(record)" color="orange"
>有</a-tag
>
<a-tag v-if="hasChildren(record)" color="orange">有</a-tag>
<span v-else class="text-gray-400">无</span>
</template>
<template v-else-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleAddChild(record)"
>
新增子级
</a-button>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
编辑
</a-button>
<a-popconfirm
title="确定要删除该城市吗?"
ok-text="确定"
cancel-text="取消"
@confirm="handleDelete(record.id)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
<a-button type="link" size="small" @click="handleEdit(record)"> 编辑 </a-button>
<a-popconfirm title="确定要删除该城市吗?" ok-text="确定" cancel-text="取消" @confirm="handleDelete(record.id)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</scTable>
</div>
<!-- 新增/编辑弹窗 -->
<SaveDialog
v-model:visible="saveDialogVisible"
:city-data="currentCity"
:mode="saveMode"
@success="handleSaveSuccess"
/>
<SaveDialog v-model:visible="saveDialogVisible" :city-data="currentCity" :mode="saveMode" @success="handleSaveSuccess" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { message } from "ant-design-vue";
import api from "@/api/system";
import SaveDialog from "./components/SaveDialog.vue";
import { ref, reactive, watch } from 'vue'
import { message } from 'ant-design-vue'
import { useTable } from '@/hooks/useTable'
import api from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import SaveDialog from './components/SaveDialog.vue'
// 搜索表单
const searchForm = reactive({
keyword: "",
});
keyword: '',
})
// 表格数据
const tableData = ref([]);
const loading = ref(false);
const expandedKeys = ref([]);
// 使用 useTable Hook
const {
tableRef,
searchForm: hookSearchForm,
tableData,
loading,
pagination,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: api.city.list.get,
searchForm: {
keyword: '',
},
columns: [],
needPagination: true,
})
// 新增/编辑弹窗
const saveDialogVisible = ref(false);
const saveMode = ref("add");
const currentCity = ref(null);
const saveDialogVisible = ref(false)
const saveMode = ref('add')
const currentCity = ref(null)
// 表格列配置
const columns = [
{
title: "城市名称",
key: "title",
width: 250,
},
{
title: "城市编码",
key: "code",
title: '城市名称',
key: 'title',
width: 200,
},
{
title: "父级编码",
key: "parent_code",
width: 200,
title: '城市编码',
key: 'code',
width: 180,
},
{
title: "子级数量",
key: "has_children",
title: '父级编码',
key: 'parent_code',
width: 180,
},
{
title: '子级数量',
key: 'has_children',
width: 120,
align: "center",
align: 'center',
},
{
title: "操作",
key: "action",
width: 200,
fixed: "right",
title: '操作',
key: 'action',
width: 150,
fixed: 'right',
},
];
]
// 子表格列配置
const childColumns = [
{
title: "城市名称",
key: "title",
width: 250,
},
{
title: "城市编码",
key: "code",
width: 200,
},
{
title: "操作",
key: "action",
width: 250,
},
];
const rowKey = (record) => record.id;
const rowKey = (record) => record.id
// 判断是否有子级
const hasChildren = (record) => {
return record.children && record.children.length > 0;
};
// 加载城市树形数据
const loadCityTree = async () => {
loading.value = true;
try {
const res = await api.cities.tree.get();
if (res.code === 200) {
tableData.value = res.data || [];
}
} catch (error) {
message.error("加载城市数据失败");
} finally {
loading.value = false;
}
};
// 搜索
const handleSearch = () => {
loadCityTree();
};
// 重置
const handleReset = () => {
searchForm.keyword = "";
loadCityTree();
};
return record.has_children > 0
}
// 刷新
const handleRefresh = () => {
loadCityTree();
};
// 展开全部
const handleExpandAll = () => {
const allKeys = [];
const collectKeys = (list) => {
list.forEach((item) => {
if (item.children && item.children.length > 0) {
allKeys.push(item.id);
collectKeys(item.children);
}
});
};
collectKeys(tableData.value);
expandedKeys.value = allKeys;
};
// 折叠全部
const handleCollapseAll = () => {
expandedKeys.value = [];
};
// 展开/折叠事件
const handleExpand = (expanded, record) => {
if (expanded) {
if (!expandedKeys.value.includes(record.id)) {
expandedKeys.value.push(record.id);
}
} else {
expandedKeys.value = expandedKeys.value.filter(
(key) => key !== record.id,
);
}
};
refreshTable()
}
// 新增
const handleAdd = () => {
saveMode.value = "add";
saveMode.value = 'add'
currentCity.value = {
title: "",
code: "",
title: '',
code: '',
parent_code: null,
};
saveDialogVisible.value = true;
};
// 新增子级
const handleAddChild = (record) => {
saveMode.value = "addChild";
currentCity.value = {
title: "",
code: "",
parent_code: record.code,
};
saveDialogVisible.value = true;
};
}
saveDialogVisible.value = true
}
// 编辑
const handleEdit = (record) => {
saveMode.value = "edit";
saveMode.value = 'edit'
currentCity.value = {
id: record.id,
title: record.title,
code: record.code,
parent_code: record.parent_code,
};
saveDialogVisible.value = true;
};
}
saveDialogVisible.value = true
}
// 删除
const handleDelete = async (id) => {
try {
const res = await api.cities.delete.delete(id);
const res = await api.city.delete.delete(id)
if (res.code === 200) {
message.success("删除成功");
loadCityTree();
message.success('删除成功')
refreshTable()
}
} catch (error) {
message.error(error.message || "删除失败");
message.error(error.message || '删除失败')
}
};
}
// 保存成功
const handleSaveSuccess = () => {
loadCityTree();
};
refreshTable()
}
onMounted(() => {
loadCityTree();
});
// 将外部 searchForm 同步到 hookSearchForm
watch(
searchForm,
(newVal) => {
Object.assign(hookSearchForm, newVal)
},
{ deep: true },
)
</script>
<style scoped lang="scss">
.city-page {
@extend .pages-base-layout;
.text-gray-400 {
color: #999;
}
@@ -1,316 +0,0 @@
<template>
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 字典名称 -->
<a-form-item label="字典名称" name="name" required>
<a-input
v-model:value="form.name"
placeholder="如:用户状态"
allow-clear
maxlength="50"
show-count
/>
</a-form-item>
<!-- 字典编码 -->
<a-form-item label="字典编码" name="code" required>
<a-input
v-model:value="form.code"
placeholder="如:user_status"
allow-clear
:disabled="isEdit"
/>
<div class="form-tip">
系统唯一标识,只能包含字母、数字、下划线,且必须以字母开头
</div>
</a-form-item>
<!-- 值类型 -->
<a-form-item label="值类型" name="value_type" required>
<a-select
v-model:value="form.value_type"
placeholder="请选择值类型"
allow-clear
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="json">JSON</a-select-option>
</a-select>
<div class="form-tip">
指定字典项值的类型,系统会根据类型自动格式化返回数据
</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<div class="form-tip">数值越小越靠前</div>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<sc-select
v-model:value="form.status"
source-type="dictionary"
dictionary-code="dictionary_status"
placeholder="请选择状态"
allow-clear
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入字典描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
<!-- 底部按钮 -->
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import systemApi from "@/api/system";
// ===== Props =====
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
record: {
type: Object,
default: null,
},
dictionaryList: {
type: Array,
default: () => [],
},
});
// ===== Emits =====
const emit = defineEmits(["update:visible", "success"]);
// ===== 状态 =====
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? "编辑字典类型" : "新增字典类型";
});
// ===== 表单数据 =====
const form = ref({
id: "",
name: "",
code: "",
value_type: "string",
description: "",
status: null,
sort: 0,
});
// ===== 验证规则 =====
// 编码唯一性验证
const validateCodeUnique = async (rule, value) => {
if (!value) return Promise.resolve();
// 检查编码是否已存在(编辑时排除自己)
const exists = props.dictionaryList.some(
(item) => item.code === value && item.id !== props.record?.id,
);
if (exists) {
return Promise.reject("该编码已存在,请使用其他编码");
}
return Promise.resolve();
};
const rules = {
name: [
{ required: true, message: "请输入字典名称", trigger: "blur" },
{
min: 2,
max: 50,
message: "字典名称长度在 2 到 50 个字符",
trigger: "blur",
},
],
code: [
{ required: true, message: "请输入字典编码", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message:
"编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头",
trigger: "blur",
},
{ validator: validateCodeUnique, trigger: "blur" },
],
value_type: [
{ required: true, message: "请选择值类型", trigger: "change" },
],
};
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: "",
name: "",
code: "",
value_type: "string",
description: "",
status: null,
sort: 0,
};
formRef.value?.clearValidate();
};
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || "",
name: data.name || "",
code: data.code || "",
value_type: data.value_type || "string",
description: data.description || "",
status: data.status !== undefined ? data.status : null,
sort: data.sort !== undefined ? data.sort : 0,
};
}
};
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate();
isSaving.value = true;
const submitData = {
name: form.value.name,
code: form.value.code,
value_type: form.value.value_type,
description: form.value.description,
status: form.value.status,
sort: form.value.sort,
};
let res = {};
if (isEdit.value) {
// 编辑
res = await systemApi.dictionaries.edit.put(
form.value.id,
submitData,
);
} else {
// 新增
res = await systemApi.dictionaries.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log("表单验证失败:", error);
} else {
// API 调用失败
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false;
}
};
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm();
emit("update:visible", false);
};
// ===== 监听 visible 变化 =====
watch(
() => props.visible,
(newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
.form-tip {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
margin-top: 16px;
}
:deep(.ant-modal-body) {
max-height: 60vh;
overflow-y: auto;
}
</style>
@@ -1,766 +0,0 @@
<template>
<div class="pages-sidebar-layout dictionary-page">
<!-- 左侧:字典类型列表 -->
<div class="left-box">
<div class="header">
<a-input
v-model:value="dictionaryKeyword"
placeholder="搜索字典..."
allow-clear
@change="handleDictionarySearch"
>
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
</a-input>
<a-button
type="primary"
size="small"
style="margin-top: 12px; width: 100%"
@click="handleAddDictionary"
>
<PlusOutlined /> 新增字典
</a-button>
</div>
<div class="body">
<!-- 字典列表 -->
<div
v-if="filteredDictionaries.length > 0"
class="dictionary-list"
>
<div
v-for="item in filteredDictionaries"
:key="item.id"
:class="[
'dictionary-item',
{ active: selectedDictionaryId === item.id },
]"
@click="handleSelectDictionary(item)"
>
<div class="item-main">
<div class="item-name">{{ item.name }}</div>
<div class="item-code">{{ item.code }}</div>
</div>
<div class="item-meta">
<a-tag
:color="item.status ? 'success' : 'default'"
size="small"
>
{{ item.status ? "启用" : "禁用" }}
</a-tag>
<span class="item-count"
>{{ item.items_count || 0 }} 项</span
>
</div>
<div class="item-actions" @click.stop>
<a-dropdown>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
@click="handleEditDictionary(item)"
>
<EditOutlined />编辑
</a-menu-item>
<a-menu-item
@click="
handleDeleteDictionary(item)
"
danger
>
<DeleteOutlined />删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
<!-- 空状态 -->
<a-empty
v-else-if="!dictionaryLoading"
description="暂无字典类型"
:image-size="80"
>
<a-button type="primary" @click="handleAddDictionary">
创建第一个字典
</a-button>
</a-empty>
</div>
</div>
<!-- 右侧:字典项表格 -->
<div class="right-box">
<!-- 工具栏 -->
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.label"
placeholder="标签名称"
allow-clear
style="width: 140px"
/>
<a-input
v-model:value="searchForm.value"
placeholder="数据值"
allow-clear
style="width: 140px"
/>
<a-select
v-model:value="searchForm.status"
placeholder="状态"
allow-clear
style="width: 100px"
>
<a-select-option :value="true"
>启用</a-select-option
>
<a-select-option :value="false"
>禁用</a-select-option
>
</a-select>
<a-button type="primary" @click="handleItemSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleItemReset">
<template #icon><RedoOutlined /></template>
重置
</a-button>
</a-space>
</div>
<div class="right-panel">
<a-dropdown :disabled="selectedRows.length === 0">
<a-button :disabled="selectedRows.length === 0">
批量操作
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchStatus">
<CheckCircleOutlined />批量启用/禁用
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleBatchDelete" danger>
<DeleteOutlined />批量删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button
type="primary"
:disabled="!selectedDictionaryId"
@click="handleAddItem"
>
<template #icon><PlusOutlined /></template>
新增
</a-button>
</div>
</div>
<!-- 表格内容 -->
<div class="table-content">
<!-- 空状态:未选择字典 -->
<div v-if="!selectedDictionaryId" class="empty-state">
<a-empty
description="请选择左侧字典类型后操作"
:image-size="120"
/>
</div>
<!-- 字典项表格 -->
<scTable
v-else
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<template #color="{ record }">
<span
v-if="record.color"
class="color-cell"
:style="{ backgroundColor: record.color }"
></span>
<span v-else>-</span>
</template>
<template #is_default="{ record }">
<a-tag v-if="record.is_default" color="orange">
<StarOutlined />默认
</a-tag>
<span v-else>-</span>
</template>
<template #status="{ record }">
<a-tag :color="record.status ? 'success' : 'error'">
{{ record.status ? "启用" : "禁用" }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleEditItem(record)"
>
编辑
</a-button>
<a-popconfirm
title="确定删除该字典项吗?"
@confirm="handleDeleteItem(record)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
</a-popconfirm>
</a-space>
</template>
</scTable>
</div>
</div>
</div>
<!-- 字典类型弹窗 -->
<DictionaryDialog
v-if="dialog.dictionary"
v-model:visible="dialog.dictionary"
:record="currentDictionary"
:dictionary-list="dictionaryList"
@success="handleDictionarySuccess"
/>
<!-- 字典项弹窗 -->
<ItemDialog
v-if="dialog.item"
v-model:visible="dialog.item"
:record="currentItem"
:dictionary-id="selectedDictionaryId"
:item-list="tableData"
@success="handleItemSuccess"
/>
</template>
<script setup>
import { ref, reactive, onMounted, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
MoreOutlined,
CheckCircleOutlined,
StarOutlined,
ExclamationCircleOutlined,
} from "@ant-design/icons-vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import scTable from "@/components/scTable/index.vue";
import DictionaryDialog from "./components/DictionaryDialog.vue";
import ItemDialog from "./components/ItemDialog.vue";
import { useDictionaryStore } from "@/stores/modules/dictionary";
// ===== 字典列表相关 =====
const dictionaryList = ref([]);
const filteredDictionaries = ref([]);
const selectedDictionary = ref(null);
const selectedDictionaryId = ref(null);
const dictionaryKeyword = ref("");
const dictionaryLoading = ref(false);
// ===== 字典项相关(使用 useTable Hook)=====
const {
tableRef,
searchForm,
tableData,
loading,
pagination,
selectedRows,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
handleSelectChange,
handleSelectAll,
refreshTable,
} = useTable({
api: systemApi.dictionaryItems.list.get,
searchForm: {
dictionary_id: null,
label: "",
value: "",
status: undefined,
},
columns: [],
needPagination: true,
needSelection: true,
immediateLoad: false, // 不自动加载,等待选择字典
});
// 表格列配置
const columns = [
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{
title: "标签名称",
dataIndex: "label",
key: "label",
width: 150,
ellipsis: true,
},
{
title: "数据值",
dataIndex: "value",
key: "value",
width: 120,
ellipsis: true,
},
{
title: "颜色",
dataIndex: "color",
key: "color",
width: 100,
align: "center",
slot: "color",
},
{
title: "默认项",
dataIndex: "is_default",
key: "is_default",
width: 100,
align: "center",
slot: "is_default",
},
{
title: "排序",
dataIndex: "sort",
key: "sort",
width: 80,
align: "center",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
slot: "status",
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "操作",
dataIndex: "action",
key: "action",
width: 150,
align: "center",
fixed: "right",
slot: "action",
},
];
const rowKey = "id";
// ===== 弹窗状态 =====
const dialog = reactive({
dictionary: false,
item: false,
});
// ===== 当前操作的数据 =====
const currentDictionary = ref(null);
const currentItem = ref(null);
// ===== 方法:加载字典列表 =====
const loadDictionaryList = async () => {
try {
dictionaryLoading.value = true;
const res = await systemApi.dictionaries.all.get();
if (res.code === 200) {
dictionaryList.value = res.data || [];
filteredDictionaries.value = res.data || [];
// 建立字典ID到Code的映射
dictionaryStore.buildIdToCodeMap(res.data || []);
} else {
message.error(res.message || "加载字典列表失败");
}
} catch (error) {
console.error("加载字典列表失败:", error);
message.error("加载字典列表失败");
} finally {
dictionaryLoading.value = false;
}
};
// ===== 方法:搜索过滤字典 =====
const handleDictionarySearch = (e) => {
const keyword = e.target?.value || "";
dictionaryKeyword.value = keyword;
if (!keyword) {
filteredDictionaries.value = dictionaryList.value;
return;
}
// 过滤字典列表(支持搜索名称和编码)
filteredDictionaries.value = dictionaryList.value.filter((dict) => {
return (
dict.name.toLowerCase().includes(keyword.toLowerCase()) ||
dict.code.toLowerCase().includes(keyword.toLowerCase())
);
});
};
// ===== 方法:选择字典 =====
const handleSelectDictionary = (dictionary) => {
selectedDictionary.value = dictionary;
selectedDictionaryId.value = dictionary.id;
// 重置右侧搜索条件
searchForm.label = "";
searchForm.value = "";
searchForm.status = undefined;
// 更新 dictionary_id
searchForm.dictionary_id = dictionary.id;
// 加载字典项列表
handleItemSearch();
};
// ===== 方法:字典项搜索 =====
const handleItemSearch = () => {
if (!selectedDictionaryId.value) {
message.warning("请先选择字典类型");
return;
}
searchForm.dictionary_id = selectedDictionaryId.value;
handleSearch();
};
// ===== 方法:字典项重置 =====
const handleItemReset = () => {
searchForm.label = "";
searchForm.value = "";
searchForm.status = undefined;
searchForm.dictionary_id = selectedDictionaryId.value;
handleSearch();
};
// ===== 方法:新增字典 =====
const handleAddDictionary = () => {
currentDictionary.value = null;
dialog.dictionary = true;
};
// ===== 方法:编辑字典 =====
const handleEditDictionary = (dictionary) => {
currentDictionary.value = { ...dictionary };
dialog.dictionary = true;
};
// ===== 方法:删除字典 =====
const handleDeleteDictionary = (dictionary) => {
const itemCount = dictionary.items_count || 0;
Modal.confirm({
title: "确认删除",
content:
itemCount > 0
? `确定删除字典类型"${dictionary.name}"吗?删除后该字典下的 ${itemCount} 个字典项也会被删除!`
: `确定删除字典类型"${dictionary.name}"吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const res = await systemApi.dictionaries.delete.delete(
dictionary.id,
);
if (res.code === 200) {
message.success("删除成功");
// 如果删除的是当前选中的字典,清空右侧
if (selectedDictionaryId.value === dictionary.id) {
selectedDictionary.value = null;
selectedDictionaryId.value = null;
tableData.value = [];
}
// 刷新字典列表
await loadDictionaryList();
} else {
message.error(res.message || "删除失败");
}
} catch (error) {
console.error("删除字典失败:", error);
message.error("删除失败");
}
},
});
};
// ===== 方法:新增字典项 =====
const handleAddItem = () => {
if (!selectedDictionaryId.value) {
message.warning("请先选择字典类型");
return;
}
currentItem.value = null;
dialog.item = true;
};
// ===== 方法:编辑字典项 =====
const handleEditItem = (record) => {
currentItem.value = { ...record };
dialog.item = true;
};
// ===== 方法:删除字典项 =====
const handleDeleteItem = async (record) => {
try {
const res = await systemApi.dictionaryItems.delete.delete(record.id);
if (res.code === 200) {
message.success("删除成功");
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value);
refreshTable();
// 刷新字典列表以更新项数量
loadDictionaryList();
} else {
message.error(res.message || "删除失败");
}
} catch (error) {
console.error("删除字典项失败:", error);
message.error("删除失败");
}
};
// ===== 方法:批量删除字典项 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要删除的字典项");
return;
}
Modal.confirm({
title: "确认删除",
content: `确定删除选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: "删除",
okType: "danger",
cancelText: "取消",
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.dictionaryItems.batchDelete.post({
ids,
});
if (res.code === 200) {
message.success("删除成功");
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value);
selectedRows.value = [];
refreshTable();
loadDictionaryList();
} else {
message.error(res.message || "删除失败");
}
} catch (error) {
console.error("批量删除失败:", error);
message.error("删除失败");
}
},
});
};
// ===== 方法:批量启用/禁用字典项 =====
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要操作的字典项");
return;
}
const newStatus = selectedRows.value[0].status ? false : true;
const statusText = newStatus === 1 ? "启用" : "禁用";
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.dictionaryItems.batchStatus.post({
ids,
status: newStatus,
});
if (res.code === 200) {
message.success(`${statusText}成功`);
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value);
selectedRows.value = [];
refreshTable();
} else {
message.error(res.message || "操作失败");
}
} catch (error) {
console.error("批量操作失败:", error);
message.error("操作失败");
}
},
});
};
// 初始化字典 store
const dictionaryStore = useDictionaryStore();
// ===== 方法:字典操作成功回调 =====
const handleDictionarySuccess = () => {
dialog.dictionary = false;
// 清理字典缓存
dictionaryStore.clearCache();
loadDictionaryList();
};
// ===== 方法:字典项操作成功回调 =====
const handleItemSuccess = () => {
dialog.item = false;
// 清理字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value);
refreshTable();
loadDictionaryList();
};
// ===== 生命周期 =====
onMounted(() => {
// 加载字典列表
loadDictionaryList();
});
</script>
<style scoped lang="scss">
.dictionary-page {
// 左侧字典列表样式
.left-box {
.body {
padding: 16px;
position: relative;
.dictionary-list {
.dictionary-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #f0f0f0;
background: #fff;
&:hover {
background-color: #fafafa;
border-color: #d9d9d9;
.item-actions {
opacity: 1;
}
}
&.active {
background-color: #e6f7ff;
border-color: #1890ff;
}
.item-main {
flex: 1;
min-width: 0;
.item-name {
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-code {
font-size: 12px;
color: #8c8c8c;
font-family: "Consolas", "Monaco", monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #8c8c8c;
margin-right: 8px;
.item-count {
font-size: 12px;
}
}
.item-actions {
opacity: 0;
transition: opacity 0.2s;
}
}
}
}
}
// 右侧空状态
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: #fff;
border-radius: 4px;
}
}
// 表格自定义列样式
:deep(.sc-table) {
.color-cell {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 2px;
border: 1px solid #d9d9d9;
vertical-align: middle;
}
}
</style>
@@ -0,0 +1,254 @@
<template>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="600px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<!-- 字典名称 -->
<a-form-item label="字典名称" name="name" required>
<a-input v-model:value="form.name" placeholder="如:用户状态" allow-clear maxlength="50" show-count />
</a-form-item>
<!-- 字典编码 -->
<a-form-item label="字典编码" name="code" required>
<a-input v-model:value="form.code" placeholder="如:user_status" allow-clear :disabled="isEdit" />
<div class="form-tip">系统唯一标识,只能包含字母、数字、下划线,且必须以字母开头</div>
</a-form-item>
<!-- 值类型 -->
<a-form-item label="值类型" name="value_type" required>
<a-select v-model:value="form.value_type" placeholder="请选择值类型" allow-clear>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="json">JSON</a-select-option>
</a-select>
<div class="form-tip">指定字典项值的类型,系统会根据类型自动格式化返回数据</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
<div class="form-tip">数值越小越靠前</div>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<sc-select v-model:value="form.status" source-type="dictionary" dictionary-code="dictionary_status" placeholder="请选择状态" allow-clear />
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea v-model:value="form.description" placeholder="请输入字典描述" :rows="3" maxlength="200" show-count />
</a-form-item>
</a-form>
<!-- 底部按钮 -->
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import systemApi from '@/api/system'
// ===== Props =====
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
record: {
type: Object,
default: null,
},
dictionaryList: {
type: Array,
default: () => [],
},
})
// ===== Emits =====
const emit = defineEmits(['update:visible', 'success'])
// ===== 状态 =====
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const title = computed(() => {
return isEdit.value ? '编辑字典类型' : '新增字典类型'
})
// ===== 表单数据 =====
const form = ref({
id: '',
name: '',
code: '',
value_type: 'string',
description: '',
status: null,
sort: 0,
})
// ===== 验证规则 =====
// 编码唯一性验证
const validateCodeUnique = async (rule, value) => {
if (!value) return Promise.resolve()
// 检查编码是否已存在(编辑时排除自己)
const exists = props.dictionaryList.some((item) => item.code === value && item.id !== props.record?.id)
if (exists) {
return Promise.reject('该编码已存在,请使用其他编码')
}
return Promise.resolve()
}
const rules = {
name: [
{ required: true, message: '请输入字典名称', trigger: 'blur' },
{
min: 2,
max: 50,
message: '字典名称长度在 2 到 50 个字符',
trigger: 'blur',
},
],
code: [
{ required: true, message: '请输入字典编码', trigger: 'blur' },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: '编码格式不正确,只能包含字母、数字、下划线,且必须以字母开头',
trigger: 'blur',
},
{ validator: validateCodeUnique, trigger: 'blur' },
],
value_type: [{ required: true, message: '请选择值类型', trigger: 'change' }],
}
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: '',
name: '',
code: '',
value_type: 'string',
description: '',
status: null,
sort: 0,
}
formRef.value?.clearValidate()
}
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || '',
name: data.name || '',
code: data.code || '',
value_type: data.value_type || 'string',
description: data.description || '',
status: data.status !== undefined ? data.status : null,
sort: data.sort !== undefined ? data.sort : 0,
}
}
}
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate()
isSaving.value = true
const submitData = {
name: form.value.name,
code: form.value.code,
value_type: form.value.value_type,
description: form.value.description,
status: form.value.status,
sort: form.value.sort,
}
let res = {}
if (isEdit.value) {
// 编辑
res = await systemApi.dictionary.edit.put(form.value.id, submitData)
} else {
// 新增
res = await systemApi.dictionary.add.post(submitData)
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
} else {
message.error(res.message || '操作失败')
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log('表单验证失败:', error)
} else {
// API 调用失败
console.error('提交失败:', error)
message.error('操作失败')
}
} finally {
isSaving.value = false
}
}
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm()
emit('update:visible', false)
}
// ===== 监听 visible 变化 =====
watch(
() => props.visible,
(newVal) => {
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record)
} else {
resetForm()
}
}
},
{ immediate: true },
)
</script>
<style scoped lang="scss">
.form-tip {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
margin-top: 16px;
}
:deep(.ant-modal-body) {
max-height: 60vh;
overflow-y: auto;
}
</style>
@@ -1,103 +1,46 @@
<template>
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="600px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="600px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<!-- 标签名称 -->
<a-form-item label="标签名称" name="label" required>
<a-input
v-model:value="form.label"
placeholder="如:正常"
allow-clear
maxlength="50"
show-count
/>
<a-input v-model:value="form.label" placeholder="如:正常" allow-clear maxlength="50" show-count />
<div class="form-tip">用于前端显示的文本</div>
</a-form-item>
<!-- 数据值 -->
<a-form-item label="数据值" name="value" required>
<a-input
v-model:value="form.value"
placeholder="如:1"
allow-clear
/>
<a-input v-model:value="form.value" placeholder="如:1" allow-clear />
<div class="form-tip">实际使用的值,同一字典内必须唯一</div>
</a-form-item>
<!-- 颜色标记 -->
<a-form-item label="颜色标记" name="color">
<div style="display: flex; align-items: center">
<input
v-model="form.color"
type="color"
style="width: 60px; height: 32px; cursor: pointer"
/>
<a-input
v-model:value="form.color"
placeholder="#1890ff"
allow-clear
style="flex: 1; margin-left: 10px"
/>
<input v-model="form.color" type="color" style="width: 60px; height: 32px; cursor: pointer" />
<a-input v-model:value="form.color" placeholder="#1890ff" allow-clear style="flex: 1; margin-left: 10px" />
</div>
<div class="form-tip">用于前端展示的颜色标记</div>
</a-form-item>
<!-- 是否默认 -->
<a-form-item label="默认项" name="is_default">
<a-switch
v-model:checked="isDefaultChecked"
checked-children="是"
un-checked-children="否"
/>
<div
class="form-tip"
v-if="form.is_default"
style="color: #faad14"
>
设置为默认项后,同一字典内的其他默认项将自动取消
</div>
<a-switch v-model:checked="isDefaultChecked" checked-children="是" un-checked-children="否" />
<div class="form-tip" v-if="form.is_default" style="color: #faad14">设置为默认项后,同一字典内的其他默认项将自动取消</div>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
<a-switch v-model:checked="statusChecked" checked-children="启用" un-checked-children="禁用" />
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入描述"
:rows="3"
maxlength="200"
show-count
/>
<a-textarea v-model:value="form.description" placeholder="请输入描述" :rows="3" maxlength="200" show-count />
</a-form-item>
</a-form>
@@ -105,21 +48,16 @@
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import systemApi from '@/api/system'
// ===== Props =====
const props = defineProps({
@@ -139,122 +77,120 @@ const props = defineProps({
type: Array,
default: () => [],
},
});
})
// ===== Emits =====
const emit = defineEmits(["update:visible", "success"]);
const emit = defineEmits(['update:visible', 'success'])
// ===== 状态 =====
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const title = computed(() => {
return isEdit.value ? "编辑字典项" : "新增字典项";
});
return isEdit.value ? '编辑字典项' : '新增字典项'
})
// ===== 表单数据 =====
const form = ref({
id: "",
label: "",
value: "",
color: "",
description: "",
id: '',
label: '',
value: '',
color: '',
description: '',
is_default: false,
status: true,
sort: 0,
dictionary_id: null,
});
})
// ===== 计算属性:状态开关 =====
const statusChecked = computed({
get: () => form.value.status === true,
set: (val) => {
form.value.status = val ? true : false;
form.value.status = val ? true : false
},
});
})
// ===== 计算属性:默认项开关 =====
const isDefaultChecked = computed({
get: () => form.value.is_default === true,
set: (val) => {
form.value.is_default = val ? true : false;
form.value.is_default = val ? true : false
},
});
})
// ===== 验证规则 =====
// 数据值唯一性验证
const validateValueUnique = async (rule, value) => {
if (!value) return Promise.resolve();
if (!value) return Promise.resolve()
// 检查数据值是否已存在(同一字典内,编辑时排除自己)
const exists = props.itemList.some(
(item) => item.value === value && item.id !== props.record?.id,
);
const exists = props.itemList.some((item) => item.value === value && item.id !== props.record?.id)
if (exists) {
return Promise.reject("该数据值已存在,请使用其他值");
return Promise.reject('该数据值已存在,请使用其他值')
}
return Promise.resolve();
};
return Promise.resolve()
}
const rules = {
label: [
{ required: true, message: "请输入标签名称", trigger: "blur" },
{ required: true, message: '请输入标签名称', trigger: 'blur' },
{
min: 1,
max: 50,
message: "标签名称长度在 1 到 50 个字符",
trigger: "blur",
message: '标签名称长度在 1 到 50 个字符',
trigger: 'blur',
},
],
value: [
{ required: true, message: "请输入数据值", trigger: "blur" },
{ validator: validateValueUnique, trigger: "blur" },
{ required: true, message: '请输入数据值', trigger: 'blur' },
{ validator: validateValueUnique, trigger: 'blur' },
],
};
}
// ===== 方法:重置表单 =====
const resetForm = () => {
form.value = {
id: "",
label: "",
value: "",
color: "",
description: "",
id: '',
label: '',
value: '',
color: '',
description: '',
is_default: false,
status: true,
sort: 0,
dictionary_id: null,
};
formRef.value?.clearValidate();
};
}
formRef.value?.clearValidate()
}
// ===== 方法:设置数据(编辑时) =====
const setData = (data) => {
if (data) {
form.value = {
id: data.id || "",
label: data.label || "",
value: data.value || "",
color: data.color || "",
description: data.description || "",
id: data.id || '',
label: data.label || '',
value: data.value || '',
color: data.color || '',
description: data.description || '',
is_default: data.is_default || false,
status: data.status !== undefined ? data.status : true,
sort: data.sort !== undefined ? data.sort : 0,
dictionary_id: data.dictionary_id || props.dictionaryId,
};
}
}
};
}
// ===== 方法:提交表单 =====
const handleSubmit = async () => {
try {
// 验证表单
await formRef.value.validate();
await formRef.value.validate()
isSaving.value = true;
isSaving.value = true
const submitData = {
label: form.value.label,
@@ -265,46 +201,43 @@ const handleSubmit = async () => {
status: form.value.status,
sort: form.value.sort,
dictionary_id: props.dictionaryId,
};
}
let res = {};
let res = {}
if (isEdit.value) {
// 编辑
res = await systemApi.dictionaryItems.edit.put(
form.value.id,
submitData,
);
res = await systemApi.dictionaryItems.edit.put(form.value.id, submitData)
} else {
// 新增
res = await systemApi.dictionaryItems.add.post(submitData);
res = await systemApi.dictionaryItems.add.post(submitData)
}
if (res.code === 200) {
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
if (error.errorFields) {
// 表单验证失败
console.log("表单验证失败:", error);
console.log('表单验证失败:', error)
} else {
// API 调用失败
console.error("提交失败:", error);
message.error("操作失败");
console.error('提交失败:', error)
message.error('操作失败')
}
} finally {
isSaving.value = false;
isSaving.value = false
}
};
}
// ===== 方法:取消 =====
const handleCancel = () => {
resetForm();
emit("update:visible", false);
};
resetForm()
emit('update:visible', false)
}
// ===== 监听 visible 变化 =====
watch(
@@ -313,16 +246,16 @@ watch(
if (newVal) {
// 打开弹窗时,如果有 record 则设置数据
if (props.record) {
setData(props.record);
setData(props.record)
} else {
resetForm();
resetForm()
// 新增时设置 dictionary_id
form.value.dictionary_id = props.dictionaryId;
form.value.dictionary_id = props.dictionaryId
}
}
},
{ immediate: true },
);
)
</script>
<style scoped lang="scss">
@@ -0,0 +1,629 @@
<template>
<div class="pages-sidebar-layout dictionary-page">
<!-- 左侧:字典类型列表 -->
<div class="left-box">
<div class="header">
<a-input v-model:value="dictionaryKeyword" placeholder="搜索字典..." allow-clear @change="handleDictionarySearch">
<template #prefix>
<SearchOutlined style="color: rgba(0, 0, 0, 0.45)" />
</template>
</a-input>
<a-button type="primary" size="small" style="margin-top: 12px; width: 100%" @click="handleAddDictionary"> <PlusOutlined /> 新增字典 </a-button>
</div>
<div class="body">
<!-- 字典列表 -->
<div v-if="filteredDictionaries.length > 0" class="dictionary-list">
<div v-for="item in filteredDictionaries" :key="item.id" :class="['dictionary-item', { active: selectedDictionaryId === item.id }]" @click="handleSelectDictionary(item)">
<div class="item-main">
<div class="item-name">{{ item.name }}</div>
<div class="item-code">{{ item.code }}</div>
</div>
<div class="item-meta">
<a-tag :color="item.status ? 'success' : 'default'" size="small">
{{ item.status ? '启用' : '禁用' }}
</a-tag>
<span class="item-count">{{ item.items_count || 0 }} 项</span>
</div>
<div class="item-actions" @click.stop>
<a-dropdown>
<a-button type="text" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEditDictionary(item)"> <EditOutlined />编辑 </a-menu-item>
<a-menu-item @click="handleDeleteDictionary(item)" danger> <DeleteOutlined />删除 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
<!-- 空状态 -->
<a-empty v-else-if="!dictionaryLoading" description="暂无字典类型" :image-size="80">
<a-button type="primary" @click="handleAddDictionary"> 创建第一个字典 </a-button>
</a-empty>
</div>
</div>
<!-- 右侧:字典项表格 -->
<div class="right-box">
<!-- 工具栏 -->
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input v-model:value="searchForm.label" placeholder="标签名称" allow-clear style="width: 140px" />
<a-input v-model:value="searchForm.value" placeholder="数据值" allow-clear style="width: 140px" />
<a-select v-model:value="searchForm.status" placeholder="状态" allow-clear style="width: 100px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
</a-select>
<a-button type="primary" @click="handleItemSearch">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button @click="handleItemReset">
<template #icon><RedoOutlined /></template>
重置
</a-button>
</a-space>
</div>
<div class="right-panel">
<a-dropdown :disabled="selectedRows.length === 0">
<a-button :disabled="selectedRows.length === 0">
批量操作
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchStatus"> <CheckCircleOutlined />批量启用/禁用 </a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleBatchDelete" danger> <DeleteOutlined />批量删除 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
<a-button type="primary" :disabled="!selectedDictionaryId" @click="handleAddItem">
<template #icon><PlusOutlined /></template>
新增
</a-button>
</div>
</div>
<!-- 表格内容 -->
<div class="table-content">
<!-- 空状态:未选择字典 -->
<div v-if="!selectedDictionaryId" class="empty-state">
<a-empty description="请选择左侧字典类型后操作" :image-size="120" />
</div>
<!-- 字典项表格 -->
<scTable
v-else
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
:row-selection="rowSelection"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
@select="handleSelectChange"
@selectAll="handleSelectAll"
>
<template #color="{ record }">
<span v-if="record.color" class="color-cell" :style="{ backgroundColor: record.color }"></span>
<span v-else>-</span>
</template>
<template #is_default="{ record }">
<a-tag v-if="record.is_default" color="orange"> <StarOutlined />默认 </a-tag>
<span v-else>-</span>
</template>
<template #status="{ record }">
<a-tag :color="record.status ? 'success' : 'error'">
{{ record.status ? '启用' : '禁用' }}
</a-tag>
</template>
<template #action="{ record }">
<a-space>
<a-button type="link" size="small" @click="handleEditItem(record)"> 编辑 </a-button>
<a-popconfirm title="确定删除该字典项吗?" @confirm="handleDeleteItem(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</scTable>
</div>
</div>
</div>
<!-- 字典类型弹窗 -->
<DictionaryDialog v-if="dialog.dictionary" v-model:visible="dialog.dictionary" :record="currentDictionary" :dictionary-list="dictionaryList" @success="handleDictionarySuccess" />
<!-- 字典项弹窗 -->
<ItemDialog v-if="dialog.item" v-model:visible="dialog.item" :record="currentItem" :dictionary-id="selectedDictionaryId" :item-list="tableData" @success="handleItemSuccess" />
</template>
<script setup>
import { ref, reactive, onMounted, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, RedoOutlined, PlusOutlined, DeleteOutlined, EditOutlined, MoreOutlined, CheckCircleOutlined, StarOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import DictionaryDialog from './components/DictionaryDialog.vue'
import ItemDialog from './components/ItemDialog.vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
// ===== 字典列表相关 =====
const dictionaryList = ref([])
const filteredDictionaries = ref([])
const selectedDictionary = ref(null)
const selectedDictionaryId = ref(null)
const dictionaryKeyword = ref('')
const dictionaryLoading = ref(false)
// ===== 字典项相关(使用 useTable Hook)=====
const { tableRef, searchForm, tableData, loading, pagination, selectedRows, rowSelection, handleSearch, handleReset, handlePaginationChange, handleSelectChange, handleSelectAll, refreshTable } = useTable({
api: systemApi.dictionaryItem.list.get,
searchForm: {
dictionary_id: null,
label: '',
value: '',
status: undefined,
},
columns: [],
needPagination: true,
needSelection: true,
immediateLoad: false, // 不自动加载,等待选择字典
})
// 表格列配置
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{
title: '标签名称',
dataIndex: 'label',
key: 'label',
width: 150,
ellipsis: true,
},
{
title: '数据值',
dataIndex: 'value',
key: 'value',
width: 120,
ellipsis: true,
},
{
title: '颜色',
dataIndex: 'color',
key: 'color',
width: 100,
align: 'center',
slot: 'color',
},
{
title: '默认项',
dataIndex: 'is_default',
key: 'is_default',
width: 100,
align: 'center',
slot: 'is_default',
},
{
title: '排序',
dataIndex: 'sort',
key: 'sort',
width: 80,
align: 'center',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: 'center',
slot: 'status',
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 150,
align: 'center',
fixed: 'right',
slot: 'action',
},
]
const rowKey = 'id'
// ===== 弹窗状态 =====
const dialog = reactive({
dictionary: false,
item: false,
})
// ===== 当前操作的数据 =====
const currentDictionary = ref(null)
const currentItem = ref(null)
// ===== 方法:加载字典列表 =====
const loadDictionaryList = async () => {
try {
dictionaryLoading.value = true
const res = await systemApi.dictionary.all.get()
if (res.code === 200) {
dictionaryList.value = res.data || []
filteredDictionaries.value = res.data || []
// 建立字典ID到Code的映射
dictionaryStore.buildIdToCodeMap(res.data || [])
} else {
message.error(res.message || '加载字典列表失败')
}
} catch (error) {
console.error('加载字典列表失败:', error)
message.error('加载字典列表失败')
} finally {
dictionaryLoading.value = false
}
}
// ===== 方法:搜索过滤字典 =====
const handleDictionarySearch = (e) => {
const keyword = e.target?.value || ''
dictionaryKeyword.value = keyword
if (!keyword) {
filteredDictionaries.value = dictionaryList.value
return
}
// 过滤字典列表(支持搜索名称和编码)
filteredDictionaries.value = dictionaryList.value.filter((dict) => {
return dict.name.toLowerCase().includes(keyword.toLowerCase()) || dict.code.toLowerCase().includes(keyword.toLowerCase())
})
}
// ===== 方法:选择字典 =====
const handleSelectDictionary = (dictionary) => {
selectedDictionary.value = dictionary
selectedDictionaryId.value = dictionary.id
// 重置右侧搜索条件
searchForm.label = ''
searchForm.value = ''
searchForm.status = undefined
// 更新 dictionary_id
searchForm.dictionary_id = dictionary.id
// 加载字典项列表
handleItemSearch()
}
// ===== 方法:字典项搜索 =====
const handleItemSearch = () => {
if (!selectedDictionaryId.value) {
message.warning('请先选择字典类型')
return
}
searchForm.dictionary_id = selectedDictionaryId.value
handleSearch()
}
// ===== 方法:字典项重置 =====
const handleItemReset = () => {
searchForm.label = ''
searchForm.value = ''
searchForm.status = undefined
searchForm.dictionary_id = selectedDictionaryId.value
handleSearch()
}
// ===== 方法:新增字典 =====
const handleAddDictionary = () => {
currentDictionary.value = null
dialog.dictionary = true
}
// ===== 方法:编辑字典 =====
const handleEditDictionary = (dictionary) => {
currentDictionary.value = { ...dictionary }
dialog.dictionary = true
}
// ===== 方法:删除字典 =====
const handleDeleteDictionary = (dictionary) => {
const itemCount = dictionary.items_count || 0
Modal.confirm({
title: '确认删除',
content: itemCount > 0 ? `确定删除字典类型"${dictionary.name}"吗?删除后该字典下的 ${itemCount} 个字典项也会被删除!` : `确定删除字典类型"${dictionary.name}"吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const res = await systemApi.dictionary.delete.delete(dictionary.id)
if (res.code === 200) {
message.success('删除成功')
// 如果删除的是当前选中的字典,清空右侧
if (selectedDictionaryId.value === dictionary.id) {
selectedDictionary.value = null
selectedDictionaryId.value = null
tableData.value = []
}
// 刷新字典列表
await loadDictionaryList()
} else {
message.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除字典失败:', error)
message.error('删除失败')
}
},
})
}
// ===== 方法:新增字典项 =====
const handleAddItem = () => {
if (!selectedDictionaryId.value) {
message.warning('请先选择字典类型')
return
}
currentItem.value = null
dialog.item = true
}
// ===== 方法:编辑字典项 =====
const handleEditItem = (record) => {
currentItem.value = { ...record }
dialog.item = true
}
// ===== 方法:删除字典项 =====
const handleDeleteItem = async (record) => {
try {
const res = await systemApi.dictionaryItem.delete.delete(record.id)
if (res.code === 200) {
message.success('删除成功')
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
refreshTable()
// 刷新字典列表以更新项数量
loadDictionaryList()
} else {
message.error(res.message || '删除失败')
}
} catch (error) {
console.error('删除字典项失败:', error)
message.error('删除失败')
}
}
// ===== 方法:批量删除字典项 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要删除的字典项')
return
}
Modal.confirm({
title: '确认删除',
content: `确定删除选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: '删除',
okType: 'danger',
cancelText: '取消',
icon: h(ExclamationCircleOutlined),
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id)
const res = await systemApi.dictionaryItem.batchDelete.post({
ids,
})
if (res.code === 200) {
message.success('删除成功')
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
selectedRows.value = []
refreshTable()
loadDictionaryList()
} else {
message.error(res.message || '删除失败')
}
} catch (error) {
console.error('批量删除失败:', error)
message.error('删除失败')
}
},
})
}
// ===== 方法:批量启用/禁用字典项 =====
const handleBatchStatus = () => {
if (selectedRows.value.length === 0) {
message.warning('请选择要操作的字典项')
return
}
const newStatus = selectedRows.value[0].status ? false : true
const statusText = newStatus === 1 ? '启用' : '禁用'
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个字典项吗?`,
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id)
const res = await systemApi.dictionaryItem.batchStatus.post({
ids,
status: newStatus,
})
if (res.code === 200) {
message.success(`${statusText}成功`)
// 清除字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || '操作失败')
}
} catch (error) {
console.error('批量操作失败:', error)
message.error('操作失败')
}
},
})
}
// 初始化字典 store
const dictionaryStore = useDictionaryStore()
// ===== 方法:字典操作成功回调 =====
const handleDictionarySuccess = () => {
dialog.dictionary = false
// 清理字典缓存
dictionaryStore.clearCache()
loadDictionaryList()
}
// ===== 方法:字典项操作成功回调 =====
const handleItemSuccess = () => {
dialog.item = false
// 清理字典缓存
dictionaryStore.clearDictionary(selectedDictionaryId.value)
refreshTable()
loadDictionaryList()
}
// ===== 生命周期 =====
onMounted(() => {
// 加载字典列表
loadDictionaryList()
})
</script>
<style scoped lang="scss">
.dictionary-page {
// 左侧字典列表样式
.left-box {
.body {
padding: 16px;
position: relative;
.dictionary-list {
.dictionary-item {
display: flex;
align-items: center;
padding: 12px;
margin-bottom: 8px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid #f0f0f0;
background: #fff;
&:hover {
background-color: #fafafa;
border-color: #d9d9d9;
.item-actions {
opacity: 1;
}
}
&.active {
background-color: #e6f7ff;
border-color: #1890ff;
}
.item-main {
flex: 1;
min-width: 0;
.item-name {
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.item-code {
font-size: 12px;
color: #8c8c8c;
font-family: 'Consolas', 'Monaco', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.item-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #8c8c8c;
margin-right: 8px;
.item-count {
font-size: 12px;
}
}
.item-actions {
opacity: 0;
transition: opacity 0.2s;
}
}
}
}
}
// 右侧空状态
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: #fff;
border-radius: 4px;
}
}
// 表格自定义列样式
:deep(.sc-table) {
.color-cell {
display: inline-block;
width: 20px;
height: 20px;
border-radius: 2px;
border: 1px solid #d9d9d9;
vertical-align: middle;
}
}
</style>
@@ -1,22 +1,17 @@
<template>
<a-modal
v-model:open="visible"
title="日志详情"
width="800px"
:footer="null"
>
<a-modal v-model:open="visible" title="日志详情" width="800px" :footer="null">
<a-descriptions v-if="logData" bordered :column="2" size="small">
<a-descriptions-item label="日志ID" :span="1">
{{ logData.id }}
</a-descriptions-item>
<a-descriptions-item label="用户名" :span="1">
{{ logData.username || "-" }}
{{ logData.username || '-' }}
</a-descriptions-item>
<a-descriptions-item label="模块" :span="1">
{{ logData.module || "-" }}
{{ logData.module || '-' }}
</a-descriptions-item>
<a-descriptions-item label="操作" :span="1">
{{ logData.action || "-" }}
{{ logData.action || '-' }}
</a-descriptions-item>
<a-descriptions-item label="请求方式" :span="1">
<a-tag :color="getMethodColor(logData.method)">
@@ -24,19 +19,17 @@
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="URL" :span="1">
{{ logData.url || "-" }}
{{ logData.url || '-' }}
</a-descriptions-item>
<a-descriptions-item label="IP地址" :span="1">
{{ logData.ip || "-" }}
{{ logData.ip || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态码" :span="1">
{{ logData.status_code || "-" }}
{{ logData.status_code || '-' }}
</a-descriptions-item>
<a-descriptions-item label="状态" :span="1">
<a-tag
:color="logData.status === 'success' ? 'success' : 'error'"
>
{{ logData.status === "success" ? "成功" : "失败" }}
<a-tag :color="logData.status === 'success' ? 'success' : 'error'">
{{ logData.status === 'success' ? '成功' : '失败' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="执行时间" :span="1">
@@ -49,57 +42,27 @@
</span>
</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">
{{ logData.created_at || "-" }}
{{ logData.created_at || '-' }}
</a-descriptions-item>
<a-descriptions-item label="User Agent" :span="2">
<div style="max-height: 60px; overflow-y: auto">
{{ logData.user_agent || "-" }}
{{ logData.user_agent || '-' }}
</div>
</a-descriptions-item>
<a-descriptions-item
v-if="logData.error_message"
label="错误信息"
:span="2"
>
<a-alert
:message="logData.error_message"
type="error"
show-icon
/>
<a-descriptions-item v-if="logData.error_message" label="错误信息" :span="2">
<a-alert :message="logData.error_message" type="error" show-icon />
</a-descriptions-item>
<a-descriptions-item
v-if="logData.params && Object.keys(logData.params).length > 0"
label="请求参数"
:span="2"
>
<a-descriptions-item v-if="logData.params && Object.keys(logData.params).length > 0" label="请求参数" :span="2">
<a-collapse>
<a-collapse-panel key="params" header="查看请求参数">
<pre
style="
max-height: 300px;
overflow-y: auto;
margin: 0;
"
>{{ formatJson(logData.params) }}</pre
>
<pre style="max-height: 300px; overflow-y: auto; margin: 0">{{ formatJson(logData.params) }}</pre>
</a-collapse-panel>
</a-collapse>
</a-descriptions-item>
<a-descriptions-item
v-if="logData.result"
label="返回结果"
:span="2"
>
<a-descriptions-item v-if="logData.result" label="返回结果" :span="2">
<a-collapse>
<a-collapse-panel key="result" header="查看返回结果">
<pre
style="
max-height: 300px;
overflow-y: auto;
margin: 0;
"
>{{ formatJson(logData.result) }}</pre
>
<pre style="max-height: 300px; overflow-y: auto; margin: 0">{{ formatJson(logData.result) }}</pre>
</a-collapse-panel>
</a-collapse>
</a-descriptions-item>
@@ -111,87 +74,87 @@
</template>
<script setup>
import { ref } from "vue";
import systemApi from "@/api/system";
import { ref } from 'vue'
import systemApi from '@/api/system'
defineOptions({
name: "LogDetailDialog",
});
name: 'LogDetailDialog',
})
const visible = ref(false);
const logData = ref(null);
const visible = ref(false)
const logData = ref(null)
// 获取请求方式颜色
const getMethodColor = (method) => {
const colors = {
GET: "blue",
POST: "green",
PUT: "orange",
DELETE: "red",
PATCH: "purple",
};
return colors[method] || "default";
};
GET: 'blue',
POST: 'green',
PUT: 'orange',
DELETE: 'red',
PATCH: 'purple',
}
return colors[method] || 'default'
}
// 获取执行时间颜色
const getExecutionTimeColor = (time) => {
if (time > 1000) return "#ff4d4f";
if (time > 500) return "#faad14";
return "#52c41a";
};
if (time > 1000) return '#ff4d4f'
if (time > 500) return '#faad14'
return '#52c41a'
}
// 格式化JSON
const formatJson = (data) => {
if (typeof data === "string") {
if (typeof data === 'string') {
try {
return JSON.stringify(JSON.parse(data), null, 2);
return JSON.stringify(JSON.parse(data), null, 2)
} catch (e) {
return data;
return data
}
}
return JSON.stringify(data, null, 2);
};
return JSON.stringify(data, null, 2)
}
// 打开弹窗
const open = async () => {
visible.value = true;
visible.value = true
return {
setData,
};
};
}
}
// 设置数据
const setData = async (data) => {
if (data && data.id) {
// 如果传入了完整数据,直接使用
if (data.params || data.result) {
logData.value = data;
logData.value = data
} else {
// 否则从服务器获取完整数据
try {
const res = await systemApi.logs.detail.get(data.id);
const res = await systemApi.logs.detail.get(data.id)
if (res.code === 200) {
logData.value = res.data;
logData.value = res.data
}
} catch (error) {
console.error("获取日志详情失败:", error);
console.error('获取日志详情失败:', error)
}
}
} else {
logData.value = data;
logData.value = data
}
};
}
// 关闭弹窗
const handleClose = () => {
visible.value = false;
logData.value = null;
};
visible.value = false
logData.value = null
}
// 暴露方法
defineExpose({
open,
});
})
</script>
<style scoped lang="scss">
+190 -297
View File
@@ -16,18 +16,8 @@
>
<template #icon="{ dataRef }">
<ApiOutlined v-if="dataRef.type === 'method'" />
<CheckCircleOutlined
v-else-if="
dataRef.type === 'status' &&
dataRef.key === 'success'
"
/>
<CloseCircleOutlined
v-else-if="
dataRef.type === 'status' &&
dataRef.key === 'error'
"
/>
<CheckCircleOutlined v-else-if="dataRef.type === 'status' && dataRef.key === 'success'" />
<CloseCircleOutlined v-else-if="dataRef.type === 'status' && dataRef.key === 'error'" />
</template>
</a-tree>
</div>
@@ -38,10 +28,7 @@
<a-row :gutter="16">
<a-col :span="6">
<a-card size="small" class="stat-card">
<a-statistic
title="总日志数"
:value="statistics.total || 0"
>
<a-statistic title="总日志数" :value="statistics.total || 0">
<template #prefix>
<FileTextOutlined style="color: #1890ff" />
</template>
@@ -50,43 +37,27 @@
</a-col>
<a-col :span="6">
<a-card size="small" class="stat-card">
<a-statistic
title="成功数"
:value="statistics.success || 0"
>
<a-statistic title="成功数" :value="statistics.success || 0">
<template #prefix>
<CheckCircleOutlined
style="color: #52c41a"
/>
<CheckCircleOutlined style="color: #52c41a" />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small" class="stat-card">
<a-statistic
title="失败数"
:value="statistics.error || 0"
>
<a-statistic title="失败数" :value="statistics.error || 0">
<template #prefix>
<CloseCircleOutlined
style="color: #ff4d4f"
/>
<CloseCircleOutlined style="color: #ff4d4f" />
</template>
</a-statistic>
</a-card>
</a-col>
<a-col :span="6">
<a-card size="small" class="stat-card">
<a-statistic
title="平均响应时间"
:value="statistics.avg_time || 0"
suffix="ms"
>
<a-statistic title="平均响应时间" :value="statistics.avg_time || 0" suffix="ms">
<template #prefix>
<ClockCircleOutlined
style="color: #faad14"
/>
<ClockCircleOutlined style="color: #faad14" />
</template>
</a-statistic>
</a-card>
@@ -98,12 +69,7 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-range-picker
v-model:value="dateRange"
:placeholder="['开始时间', '结束时间']"
style="width: 260px"
@change="handleDateChange"
/>
<a-range-picker v-model:value="dateRange" :placeholder="['开始时间', '结束时间']" style="width: 260px" @change="handleDateChange" />
<a-button type="primary" @click="handleSearch">
<template #icon><SearchOutlined /></template>
搜索
@@ -116,10 +82,7 @@
</div>
<div class="right-panel">
<a-space>
<a-button
@click="handleExport"
:loading="exportLoading"
>
<a-button @click="handleExport" :loading="exportLoading">
<template #icon><DownloadOutlined /></template>
导出
</a-button>
@@ -130,12 +93,8 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleClearLogs">
<DeleteOutlined />清理日志
</a-menu-item>
<a-menu-item @click="handleRefreshStats">
<ReloadOutlined />刷新统计
</a-menu-item>
<a-menu-item @click="handleClearLogs"> <DeleteOutlined />清理日志 </a-menu-item>
<a-menu-item @click="handleRefreshStats"> <ReloadOutlined />刷新统计 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -143,25 +102,10 @@
</div>
</div>
<div class="table-content">
<sc-table
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-key="rowKey"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<sc-table ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" :row-key="rowKey" @refresh="refreshTable" @paginationChange="handlePaginationChange">
<template #status="{ record }">
<a-tag
:color="
record.status === 'success'
? 'success'
: 'error'
"
>
{{ record.status === "success" ? "成功" : "失败" }}
<a-tag :color="record.status === 'success' ? 'success' : 'error'">
{{ record.status === 'success' ? '成功' : '失败' }}
</a-tag>
</template>
<template #method="{ record }">
@@ -172,12 +116,7 @@
<template #execution_time="{ record }">
<span
:style="{
color:
record.execution_time > 1000
? '#ff4d4f'
: record.execution_time > 500
? '#faad14'
: '#52c41a',
color: record.execution_time > 1000 ? '#ff4d4f' : record.execution_time > 500 ? '#faad14' : '#52c41a',
}"
>
{{ record.execution_time }}ms
@@ -185,19 +124,9 @@
</template>
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleView(record)"
>查看详情</a-button
>
<a-popconfirm
title="确定删除该日志吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger
>删除</a-button
>
<a-button type="link" size="small" @click="handleView(record)">查看详情</a-button>
<a-popconfirm title="确定删除该日志吗?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
@@ -210,55 +139,28 @@
<detail-dialog ref="detailDialogRef" />
<!-- 清理日志确认弹窗 -->
<a-modal
v-model:open="dialog.clear"
title="清理日志"
:confirm-loading="clearLoading"
@ok="handleConfirmClear"
>
<a-modal v-model:open="dialog.clear" title="清理日志" :confirm-loading="clearLoading" @ok="handleConfirmClear">
<a-form layout="vertical">
<a-form-item label="清理天数">
<a-input-number
v-model:value="clearDays"
:min="1"
:max="365"
style="width: 100%"
placeholder="请输入天数"
/>
<a-input-number v-model:value="clearDays" :min="1" :max="365" style="width: 100%" placeholder="请输入天数" />
</a-form-item>
<a-alert
message="删除指定天数之前的日志记录,此操作不可恢复"
type="warning"
show-icon
/>
<a-alert message="删除指定天数之前的日志记录,此操作不可恢复" type="warning" show-icon />
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, onMounted, computed } from "vue";
import { message } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
DeleteOutlined,
DownOutlined,
ApiOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
FileTextOutlined,
ClockCircleOutlined,
DownloadOutlined,
ReloadOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import detailDialog from "./components/DetailDialog.vue";
import systemApi from "@/api/system";
import { useTable } from "@/hooks/useTable";
import { ref, reactive, onMounted, computed } from 'vue'
import { message } from 'ant-design-vue'
import { SearchOutlined, RedoOutlined, DeleteOutlined, DownOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined, FileTextOutlined, ClockCircleOutlined, DownloadOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import detailDialog from './components/DetailDialog.vue'
import systemApi from '@/api/system'
import { useTable } from '@/hooks/useTable'
defineOptions({
name: "systemLog",
});
name: 'systemLog',
})
// 统计数据
const statistics = ref({
@@ -266,326 +168,317 @@ const statistics = ref({
success: 0,
error: 0,
avg_time: 0,
});
})
// 使用useTable hooks
const {
tableRef,
searchForm,
tableData,
loading,
pagination,
handleSearch,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.logs.list.get,
const { tableRef, searchForm, tableData, loading, pagination, handleSearch, handlePaginationChange, refreshTable } = useTable({
api: systemApi.log.list.get,
searchForm: {
method: "",
status: "",
start_date: "",
end_date: "",
method: '',
status: '',
start_date: '',
end_date: '',
},
columns: [],
needPagination: true,
});
})
// 对话框状态
const dialog = reactive({
detail: false,
clear: false,
});
})
// 弹窗引用
const detailDialogRef = ref(null);
const detailDialogRef = ref(null)
// 清理日志相关
const clearDays = ref(30);
const clearLoading = ref(false);
const clearDays = ref(30)
const clearLoading = ref(false)
// 导出相关
const exportLoading = ref(false);
const exportLoading = ref(false)
// 日期范围
const dateRange = ref([]);
const dateRange = ref([])
// 树形筛选数据
const treeData = ref([
{
title: "请求方式",
key: "method-root",
type: "root",
title: '请求方式',
key: 'method-root',
type: 'root',
children: [
{ title: "GET", key: "GET", type: "method" },
{ title: "POST", key: "POST", type: "method" },
{ title: "PUT", key: "PUT", type: "method" },
{ title: "DELETE", key: "DELETE", type: "method" },
{ title: "PATCH", key: "PATCH", type: "method" },
{ title: 'GET', key: 'GET', type: 'method' },
{ title: 'POST', key: 'POST', type: 'method' },
{ title: 'PUT', key: 'PUT', type: 'method' },
{ title: 'DELETE', key: 'DELETE', type: 'method' },
{ title: 'PATCH', key: 'PATCH', type: 'method' },
],
},
{
title: "返回状态",
key: "status-root",
type: "root",
title: '返回状态',
key: 'status-root',
type: 'root',
children: [
{ title: "成功", key: "success", type: "status" },
{ title: "失败", key: "error", type: "status" },
{ title: '成功', key: 'success', type: 'status' },
{ title: '失败', key: 'error', type: 'status' },
],
},
]);
])
// 选中的树节点
const selectedTreeKeys = ref([]);
const selectedTreeKeys = ref([])
// 行key
const rowKey = "id";
const rowKey = 'id'
// 过滤后的树数据
const filterTreeData = computed(() => {
return treeData.value;
});
return treeData.value
})
// 获取请求方式颜色
const getMethodColor = (method) => {
const colors = {
GET: "blue",
POST: "green",
PUT: "orange",
DELETE: "red",
PATCH: "purple",
};
return colors[method] || "default";
};
GET: 'blue',
POST: 'green',
PUT: 'orange',
DELETE: 'red',
PATCH: 'purple',
}
return colors[method] || 'default'
}
// 表格列配置
const columns = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "用户名", dataIndex: "username", key: "username", width: 120 },
{ title: "模块", dataIndex: "module", key: "module", width: 120 },
{ title: "操作", dataIndex: "action", key: "action", width: 150 },
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username', key: 'username', width: 120 },
{ title: '模块', dataIndex: 'module', key: 'module', width: 120 },
{ title: '操作', dataIndex: 'action', key: 'action', width: 150 },
{
title: "请求方式",
dataIndex: "method",
key: "method",
title: '请求方式',
dataIndex: 'method',
key: 'method',
width: 100,
align: "center",
slot: "method",
align: 'center',
slot: 'method',
},
{ title: "URL", dataIndex: "url", key: "url", ellipsis: true },
{ title: "IP地址", dataIndex: "ip", key: "ip", width: 140 },
{ title: 'URL', dataIndex: 'url', key: 'url', ellipsis: true },
{ title: 'IP地址', dataIndex: 'ip', key: 'ip', width: 140 },
{
title: "状态码",
dataIndex: "status_code",
key: "status_code",
title: '状态码',
dataIndex: 'status_code',
key: 'status_code',
width: 100,
align: "center",
align: 'center',
},
{
title: "状态",
dataIndex: "status",
key: "status",
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
align: "center",
slot: "status",
align: 'center',
slot: 'status',
},
{
title: "执行时间",
dataIndex: "execution_time",
key: "execution_time",
title: '执行时间',
dataIndex: 'execution_time',
key: 'execution_time',
width: 120,
align: "center",
slot: "execution_time",
align: 'center',
slot: 'execution_time',
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: "操作",
dataIndex: "table_action",
key: "table_action",
title: '操作',
dataIndex: 'table_action',
key: 'table_action',
width: 150,
align: "center",
slot: "action",
fixed: "right",
align: 'center',
slot: 'action',
fixed: 'right',
},
];
]
// 加载统计数据
const loadStatistics = async () => {
try {
const res = await systemApi.logs.statistics.get(searchForm);
const res = await systemApi.log.statistics.get(searchForm)
if (res.code === 200) {
statistics.value = res.data;
statistics.value = res.data
}
} catch (error) {
console.error("加载统计数据失败:", error);
console.error('加载统计数据失败:', error)
}
};
}
// 树形选择事件
const onTreeSelect = (selectedKeys, { selectedNodes }) => {
if (selectedKeys.length > 0) {
const key = selectedKeys[0];
const node = selectedNodes[0];
const key = selectedKeys[0]
const node = selectedNodes[0]
// 重置搜索表单
searchForm.method = "";
searchForm.status = "";
searchForm.method = ''
searchForm.status = ''
// 根据节点类型设置对应的筛选条件
if (node.type === "method") {
searchForm.method = key;
} else if (node.type === "status") {
searchForm.status = key;
if (node.type === 'method') {
searchForm.method = key
} else if (node.type === 'status') {
searchForm.status = key
}
// 触发搜索
handleSearch();
handleSearch()
// 同时刷新统计数据
loadStatistics();
loadStatistics()
} else {
// 取消选择时清空筛选条件
searchForm.method = "";
searchForm.status = "";
handleSearch();
searchForm.method = ''
searchForm.status = ''
handleSearch()
// 同时刷新统计数据
loadStatistics();
loadStatistics()
}
};
}
// 日期变化
const handleDateChange = (dates) => {
if (dates && dates.length === 2) {
searchForm.start_date = dates[0].format("YYYY-MM-DD HH:mm:ss");
searchForm.end_date = dates[1].format("YYYY-MM-DD HH:mm:ss");
searchForm.start_date = dates[0].format('YYYY-MM-DD HH:mm:ss')
searchForm.end_date = dates[1].format('YYYY-MM-DD HH:mm:ss')
} else {
searchForm.start_date = "";
searchForm.end_date = "";
searchForm.start_date = ''
searchForm.end_date = ''
}
// 自动触发搜索
handleSearch();
handleSearch()
// 同时刷新统计数据
loadStatistics();
};
loadStatistics()
}
// 重置
const handleLogReset = () => {
// 重置日期范围
dateRange.value = [];
searchForm.start_date = "";
searchForm.end_date = "";
dateRange.value = []
searchForm.start_date = ''
searchForm.end_date = ''
// 重置树形选择
selectedTreeKeys.value = [];
searchForm.method = "";
searchForm.status = "";
selectedTreeKeys.value = []
searchForm.method = ''
searchForm.status = ''
// 重置分页到第一页
pagination.current = 1;
pagination.current = 1
// 触发搜索
handleSearch();
handleSearch()
// 同时刷新统计数据
loadStatistics();
};
loadStatistics()
}
// 查看详情
const handleView = async (record) => {
const dialog = await detailDialogRef.value?.open();
dialog?.setData(record);
};
const dialog = await detailDialogRef.value?.open()
dialog?.setData(record)
}
// 删除日志
const handleDelete = async (record) => {
try {
const res = await systemApi.logs.delete.delete(record.id);
const res = await systemApi.log.delete.delete(record.id)
if (res.code === 200) {
message.success("删除成功");
refreshTable();
message.success('删除成功')
refreshTable()
// 同时刷新统计数据
loadStatistics();
loadStatistics()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
console.error("删除日志失败:", error);
message.error("删除失败");
console.error('删除日志失败:', error)
message.error('删除失败')
}
};
}
// 清理日志
const handleClearLogs = () => {
dialog.clear = true;
};
dialog.clear = true
}
// 确认清理日志
const handleConfirmClear = async () => {
if (!clearDays.value || clearDays.value < 1) {
message.warning("请输入有效的天数");
return;
message.warning('请输入有效的天数')
return
}
clearLoading.value = true;
clearLoading.value = true
try {
const res = await systemApi.logs.clear.post({ days: clearDays.value });
const res = await systemApi.log.clear.post({ days: clearDays.value })
if (res.code === 200) {
message.success("清理成功");
dialog.clear = false;
clearDays.value = 30;
refreshTable();
message.success('清理成功')
dialog.clear = false
clearDays.value = 30
refreshTable()
// 同时刷新统计数据
loadStatistics();
loadStatistics()
} else {
message.error(res.message || "清理失败");
message.error(res.message || '清理失败')
}
} catch (error) {
console.error("清理日志失败:", error);
message.error("清理失败");
console.error('清理日志失败:', error)
message.error('清理失败')
} finally {
clearLoading.value = false;
clearLoading.value = false
}
};
}
// 刷新统计
const handleRefreshStats = () => {
loadStatistics();
};
loadStatistics()
}
// 导出日志
const handleExport = async () => {
exportLoading.value = true;
exportLoading.value = true
try {
const blob = await systemApi.logs.export.get(searchForm);
const blob = await systemApi.log.export.get(searchForm)
// 创建下载链接
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `系统日志_${new Date().getTime()}.xlsx`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `系统日志_${new Date().getTime()}.xlsx`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
message.success("导出成功");
message.success('导出成功')
} catch (error) {
console.error("导出失败:", error);
message.error("导出失败");
console.error('导出失败:', error)
message.error('导出失败')
} finally {
exportLoading.value = false;
exportLoading.value = false
}
};
}
// 初始化
onMounted(() => {
// 页面加载时自动搜索一次
handleSearch();
handleSearch()
// 加载统计数据
loadStatistics();
});
loadStatistics()
})
</script>
<style scoped lang="scss">
@@ -3,35 +3,13 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="通知标题/内容"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.is_read"
placeholder="阅读状态"
allow-clear
style="width: 120px"
>
<a-input v-model:value="searchForm.keyword" placeholder="通知标题/内容" allow-clear style="width: 180px" />
<a-select v-model:value="searchForm.is_read" placeholder="阅读状态" allow-clear style="width: 120px">
<a-select-option :value="true">已读</a-select-option>
<a-select-option :value="false">未读</a-select-option>
</a-select>
<a-select
v-model:value="searchForm.type"
placeholder="通知类型"
allow-clear
style="width: 120px"
:options="typeOptions"
/>
<a-select
v-model:value="searchForm.category"
placeholder="通知分类"
allow-clear
style="width: 120px"
:options="categoryOptions"
/>
<a-select v-model:value="searchForm.type" placeholder="通知类型" allow-clear style="width: 120px" :options="typeOptions" />
<a-select v-model:value="searchForm.category" placeholder="通知分类" allow-clear style="width: 120px" :options="categoryOptions" />
<a-button type="primary" @click="handleSearch">
<template #icon><search-outlined /></template>
搜索
@@ -44,11 +22,7 @@
</div>
<div class="right-panel">
<a-space>
<a-badge
:count="unreadCount"
:offset="[-5, 5]"
:number-style="{ backgroundColor: '#f5222d' }"
>
<a-badge :count="unreadCount" :offset="[-5, 5]" :number-style="{ backgroundColor: '#f5222d' }">
<a-button @click="handleMarkAllRead">
<template #icon><check-outlined /></template>
全部已读
@@ -81,79 +55,40 @@
</div>
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="(record) => record.id"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" :row-selection="rowSelection" :row-key="(record) => record.id" @refresh="refreshTable" @paginationChange="handlePaginationChange">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'title'">
<div
class="notification-title"
:class="{ unread: !record.is_read }"
@click="handleViewDetail(record)"
>
<WarningOutlined
v-if="!record.is_read"
class="unread-icon"
/>
<div class="notification-title" :class="{ unread: !record.is_read }" @click="handleViewDetail(record)">
<WarningOutlined v-if="!record.is_read" class="unread-icon" />
<span>{{ record.title }}</span>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">
<component
:is="getTypeIcon(record.type)"
class="type-icon"
/>
<component :is="getTypeIcon(record.type)" class="type-icon" />
{{ getTypeText(record.type) }}
</a-tag>
</template>
<template v-if="column.key === 'category'">
<a-tag color="blue">{{
getCategoryText(record.category)
}}</a-tag>
<a-tag color="blue">{{ getCategoryText(record.category) }}</a-tag>
</template>
<template v-if="column.key === 'is_read'">
<a-badge
:status="record.is_read ? 'default' : 'processing'"
:text="record.is_read ? '已读' : '未读'"
/>
<a-badge :status="record.is_read ? 'default' : 'processing'" :text="record.is_read ? '已读' : '未读'" />
</template>
<template v-if="column.key === 'created_at'">
<span>{{ formatTime(record.created_at) }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleViewDetail(record)"
>
<a-button type="link" size="small" @click="handleViewDetail(record)">
<eye-outlined />
查看
</a-button>
<a-button
v-if="!record.is_read"
type="link"
size="small"
@click="handleMarkRead(record)"
>
<a-button v-if="!record.is_read" type="link" size="small" @click="handleMarkRead(record)">
<check-outlined />
标为已读
</a-button>
<a-button
type="link"
size="small"
danger
@click="handleDelete(record)"
>
<a-button type="link" size="small" danger @click="handleDelete(record)">
<delete-outlined />
删除
</a-button>
@@ -164,12 +99,7 @@
</div>
<!-- 通知详情弹窗 -->
<a-drawer
v-model:open="showDetailDrawer"
title="通知详情"
placement="right"
width="600"
>
<a-drawer v-model:open="showDetailDrawer" title="通知详情" placement="right" width="600">
<template v-if="currentNotification">
<a-descriptions :column="1" bordered>
<a-descriptions-item label="标题">
@@ -177,37 +107,20 @@
</a-descriptions-item>
<a-descriptions-item label="类型">
<a-tag :color="getTypeColor(currentNotification.type)">
<component
:is="getTypeIcon(currentNotification.type)"
class="type-icon"
/>
<component :is="getTypeIcon(currentNotification.type)" class="type-icon" />
{{ getTypeText(currentNotification.type) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="分类">
<a-tag color="blue">{{
getCategoryText(currentNotification.category)
}}</a-tag>
<a-tag color="blue">{{ getCategoryText(currentNotification.category) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="状态">
<a-badge
:status="
currentNotification.is_read
? 'default'
: 'processing'
"
:text="
currentNotification.is_read ? '已读' : '未读'
"
/>
<a-badge :status="currentNotification.is_read ? 'default' : 'processing'" :text="currentNotification.is_read ? '已读' : '未读'" />
</a-descriptions-item>
<a-descriptions-item label="创建时间">
{{ formatTime(currentNotification.created_at) }}
</a-descriptions-item>
<a-descriptions-item
v-if="currentNotification.read_at"
label="阅读时间"
>
<a-descriptions-item v-if="currentNotification.read_at" label="阅读时间">
{{ formatTime(currentNotification.read_at) }}
</a-descriptions-item>
</a-descriptions>
@@ -219,26 +132,12 @@
</div>
</div>
<div
v-if="
currentNotification.data &&
Object.keys(currentNotification.data).length > 0
"
class="notification-data"
>
<div v-if="currentNotification.data && Object.keys(currentNotification.data).length > 0" class="notification-data">
<div class="data-label">附加数据:</div>
<pre class="data-text">{{
JSON.stringify(currentNotification.data, null, 2)
}}</pre>
<pre class="data-text">{{ JSON.stringify(currentNotification.data, null, 2) }}</pre>
</div>
<div
v-if="
currentNotification.action_type &&
currentNotification.action_type !== 'none'
"
class="notification-action"
>
<div v-if="currentNotification.action_type && currentNotification.action_type !== 'none'" class="notification-action">
<a-button type="primary" @click="handleAction">
<template #icon><arrow-right-outlined /></template>
{{ getActionText(currentNotification.action_type) }}
@@ -250,8 +149,8 @@
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted, computed, h } from "vue";
import { message, Modal } from "ant-design-vue";
import { ref, reactive, onMounted, onUnmounted, computed, h } from 'vue'
import { message, Modal } from 'ant-design-vue'
import {
SearchOutlined,
RedoOutlined,
@@ -269,126 +168,117 @@ import {
ClockCircleOutlined,
BulbOutlined,
ArrowRightOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
} from '@ant-design/icons-vue'
import scTable from '@/components/scTable/index.vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
// 表格引用
const tableRef = ref(null);
const tableRef = ref(null)
// 注意:WebSocket 已在 App.vue 中统一初始化,这里不需要重复连接
// 通知会通过 notification store 自动更新
let unreadCountInterval = null;
let unreadCountInterval = null
// 搜索表单
const searchForm = reactive({
keyword: "",
keyword: '',
is_read: undefined,
type: undefined,
category: undefined,
});
})
// 未读数量
const unreadCount = ref(0);
const unreadCount = ref(0)
// 当前通知
const currentNotification = ref(null);
const currentNotification = ref(null)
// 显示详情抽屉
const showDetailDrawer = ref(false);
const showDetailDrawer = ref(false)
// 通知类型选项
const typeOptions = [
{ label: "信息", value: "info" },
{ label: "成功", value: "success" },
{ label: "警告", value: "warning" },
{ label: "错误", value: "error" },
{ label: "任务", value: "task" },
{ label: "系统", value: "system" },
];
{ label: '信息', value: 'info' },
{ label: '成功', value: 'success' },
{ label: '警告', value: 'warning' },
{ label: '错误', value: 'error' },
{ label: '任务', value: 'task' },
{ label: '系统', value: 'system' },
]
// 通知分类选项
const categoryOptions = [
{ label: "系统通知", value: "system" },
{ label: "任务通知", value: "task" },
{ label: "消息通知", value: "message" },
{ label: "提醒通知", value: "reminder" },
{ label: "公告通知", value: "announcement" },
];
{ label: '系统通知', value: 'system' },
{ label: '任务通知', value: 'task' },
{ label: '消息通知', value: 'message' },
{ label: '提醒通知', value: 'reminder' },
{ label: '公告通知', value: 'announcement' },
]
// 使用 useTable Hook
const {
tableData,
loading,
pagination,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
const { tableData, loading, pagination, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } = useTable({
api: systemApi.notifications.list.get,
searchForm,
needPagination: true,
});
})
// 表格列配置
const columns = [
{
title: "通知标题",
dataIndex: "title",
key: "title",
title: '通知标题',
dataIndex: 'title',
key: 'title',
width: 300,
ellipsis: true,
},
{
title: "类型",
dataIndex: "type",
key: "type",
title: '类型',
dataIndex: 'type',
key: 'type',
width: 120,
align: "center",
align: 'center',
},
{
title: "分类",
dataIndex: "category",
key: "category",
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
align: "center",
align: 'center',
},
{
title: "状态",
dataIndex: "is_read",
key: "is_read",
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 100,
align: "center",
align: 'center',
},
{
title: "创建时间",
dataIndex: "created_at",
key: "created_at",
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
},
{
title: "操作",
key: "action",
title: '操作',
key: 'action',
width: 180,
fixed: "right",
fixed: 'right',
},
];
]
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
info: "blue",
success: "green",
warning: "orange",
error: "red",
task: "purple",
system: "cyan",
};
return colors[type] || "default";
};
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan',
}
return colors[type] || 'default'
}
// 获取类型图标
const getTypeIcon = (type) => {
@@ -399,254 +289,251 @@ const getTypeIcon = (type) => {
error: CloseCircleOutlined,
task: BellOutlined,
system: MessageOutlined,
};
return icons[type] || InfoCircleOutlined;
};
}
return icons[type] || InfoCircleOutlined
}
// 获取类型文本
const getTypeText = (type) => {
const texts = {
info: "信息",
success: "成功",
warning: "警告",
error: "错误",
task: "任务",
system: "系统",
};
return texts[type] || type;
};
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统',
}
return texts[type] || type
}
// 获取分类文本
const getCategoryText = (category) => {
const texts = {
system: "系统通知",
task: "任务通知",
message: "消息通知",
reminder: "提醒通知",
announcement: "公告通知",
};
return texts[category] || category;
};
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知',
}
return texts[category] || category
}
// 获取操作文本
const getActionText = (actionType) => {
const texts = {
link: "查看详情",
modal: "打开弹窗",
none: "",
};
return texts[actionType] || "查看";
};
link: '查看详情',
modal: '打开弹窗',
none: '',
}
return texts[actionType] || '查看'
}
// 格式化时间
const formatTime = (time) => {
if (!time) return "-";
return new Date(time).toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
};
if (!time) return '-'
return new Date(time).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
// 获取未读数量
const loadUnreadCount = async () => {
try {
const res = await systemApi.notifications.unreadCount.get();
unreadCount.value = res.data.count;
const res = await systemApi.notifications.unreadCount.get()
unreadCount.value = res.data.count
} catch (error) {
console.error("获取未读数量失败:", error);
console.error('获取未读数量失败:', error)
}
};
}
// 查看详情
const handleViewDetail = async (record) => {
currentNotification.value = { ...record };
showDetailDrawer.value = true;
currentNotification.value = { ...record }
showDetailDrawer.value = true
// 自动标记为已读
if (!record.is_read) {
await handleMarkRead(record);
await handleMarkRead(record)
}
};
}
// 标记已读
const handleMarkRead = async (record) => {
try {
await systemApi.notifications.markAsRead.post(record.id);
message.success("已标记为已读");
await systemApi.notifications.markAsRead.post(record.id)
message.success('已标记为已读')
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1);
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
refreshTable();
refreshTable()
} catch (error) {
message.error(error.message || "操作失败");
message.error(error.message || '操作失败')
}
};
}
// 批量标记已读
const handleBatchMarkRead = () => {
const selectedRowKeys = rowSelection.selectedRowKeys;
const selectedRowKeys = rowSelection.selectedRowKeys
if (selectedRowKeys.length === 0) {
message.warning("请先选择要操作的通知");
return;
message.warning('请先选择要操作的通知')
return
}
Modal.confirm({
title: "确认标记为已读",
title: '确认标记为已读',
content: `确定要将选中的 ${selectedRowKeys.length} 条通知标记为已读吗?`,
okText: "确定",
cancelText: "取消",
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({
ids: selectedRowKeys,
});
message.success("批量标记成功");
rowSelection.selectedRowKeys = [];
unreadCount.value = Math.max(
0,
unreadCount.value - res.data.count,
);
refreshTable();
})
message.success('批量标记成功')
rowSelection.selectedRowKeys = []
unreadCount.value = Math.max(0, unreadCount.value - res.data.count)
refreshTable()
} catch (error) {
message.error(error.message || "操作失败");
message.error(error.message || '操作失败')
}
},
});
};
})
}
// 标记全部已读
const handleMarkAllRead = () => {
Modal.confirm({
title: "确认全部已读",
content: "确定要将所有未读通知标记为已读吗?",
okText: "确定",
cancelText: "取消",
title: '确认全部已读',
content: '确定要将所有未读通知标记为已读吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.markAllAsRead.post();
message.success("已标记全部为已读");
unreadCount.value = 0;
refreshTable();
const res = await systemApi.notifications.markAllAsRead.post()
message.success('已标记全部为已读')
unreadCount.value = 0
refreshTable()
} catch (error) {
message.error(error.message || "操作失败");
message.error(error.message || '操作失败')
}
},
});
};
})
}
// 删除
const handleDelete = (record) => {
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定要删除通知"${record.title}"吗?`,
okText: "确定",
cancelText: "取消",
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await systemApi.notifications.delete.delete(record.id);
message.success("删除成功");
await systemApi.notifications.delete.delete(record.id)
message.success('删除成功')
if (!record.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1);
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
refreshTable();
refreshTable()
} catch (error) {
message.error(error.message || "删除失败");
message.error(error.message || '删除失败')
}
},
});
};
})
}
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys;
const selectedRowKeys = rowSelection.selectedRowKeys
if (selectedRowKeys.length === 0) {
message.warning("请先选择要删除的通知");
return;
message.warning('请先选择要删除的通知')
return
}
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定要删除选中的 ${selectedRowKeys.length} 条通知吗?`,
okText: "确定",
cancelText: "取消",
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
const res = await systemApi.notifications.batchDelete.post({
ids: selectedRowKeys,
});
message.success("批量删除成功");
rowSelection.selectedRowKeys = [];
loadUnreadCount();
refreshTable();
})
message.success('批量删除成功')
rowSelection.selectedRowKeys = []
loadUnreadCount()
refreshTable()
} catch (error) {
message.error(error.message || "批量删除失败");
message.error(error.message || '批量删除失败')
}
},
});
};
})
}
// 清空已读
const handleClearRead = () => {
Modal.confirm({
title: "确认清空",
content: "确定要清空所有已读通知吗?此操作不可恢复。",
okText: "确定",
cancelText: "取消",
title: '确认清空',
content: '确定要清空所有已读通知吗?此操作不可恢复。',
okText: '确定',
cancelText: '取消',
onOk: async () => {
try {
await systemApi.notifications.clearRead.post();
message.success("已清空已读通知");
refreshTable();
await systemApi.notifications.clearRead.post()
message.success('已清空已读通知')
refreshTable()
} catch (error) {
message.error(error.message || "操作失败");
message.error(error.message || '操作失败')
}
},
});
};
})
}
// 处理操作
const handleAction = () => {
const notification = currentNotification.value;
if (!notification) return;
const notification = currentNotification.value
if (!notification) return
if (notification.action_type === "link" && notification.action_data?.url) {
window.open(notification.action_data.url, "_blank");
} else if (notification.action_type === "modal") {
if (notification.action_type === 'link' && notification.action_data?.url) {
window.open(notification.action_data.url, '_blank')
} else if (notification.action_type === 'modal') {
// 打开弹窗的逻辑
message.info("打开弹窗功能");
message.info('打开弹窗功能')
}
};
}
// WebSocket 消息处理
const handleWebSocketMessage = (msg) => {
if (msg.type === "notification") {
if (msg.type === 'notification') {
// 收到新通知
const notification = msg.data;
message.info(`新通知: ${notification.title}`);
unreadCount.value++;
refreshTable();
const notification = msg.data
message.info(`新通知: ${notification.title}`)
unreadCount.value++
refreshTable()
}
};
}
// 初始化
onMounted(() => {
loadUnreadCount();
loadUnreadCount()
// 定时刷新未读数量(每30秒)
// 注意:通知也会通过 WebSocket 实时推送,这是作为备用刷新机制
unreadCountInterval = setInterval(() => {
loadUnreadCount();
}, 30000);
});
loadUnreadCount()
}, 30000)
})
// 清理
onUnmounted(() => {
if (unreadCountInterval) {
clearInterval(unreadCountInterval);
clearInterval(unreadCountInterval)
}
});
})
</script>
<style scoped lang="scss">
@@ -1,401 +0,0 @@
<template>
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<!-- 配置名称 -->
<a-form-item label="配置名称" name="name" required>
<a-input
v-model:value="form.name"
placeholder="如:网站名称"
allow-clear
/>
</a-form-item>
<!-- 配置键名 -->
<a-form-item label="配置键名" name="key" required>
<a-input
v-model:value="form.key"
placeholder="如:site_name"
allow-clear
:disabled="isEdit"
/>
<div class="form-tip">
系统唯一标识,只能包含字母、数字、下划线
</div>
</a-form-item>
<!-- 配置分组 -->
<a-form-item label="配置分组" name="group" required>
<a-select
v-model:value="form.group"
placeholder="请选择配置分组"
:options="groupOptions"
/>
</a-form-item>
<!-- 配置类型 -->
<a-form-item label="配置类型" name="type" required>
<a-select
v-model:value="form.type"
placeholder="请选择配置类型"
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="text">文本</a-select-option>
<a-select-option value="file">文件</a-select-option>
<a-select-option value="image">图片</a-select-option>
<a-select-option value="select">选择框</a-select-option>
</a-select>
</a-form-item>
<!-- 配置值 -->
<a-form-item label="配置值" name="value">
<!-- 字符串/文本 -->
<template v-if="['string', 'text'].includes(form.type)">
<a-input
v-if="form.type === 'string'"
v-model:value="form.value"
placeholder="请输入配置值"
allow-clear
/>
<a-textarea
v-else
v-model:value="form.value"
placeholder="请输入配置值"
:rows="4"
/>
</template>
<!-- 数字 -->
<a-input-number
v-else-if="form.type === 'number'"
v-model:value="form.value"
:min="0"
style="width: 100%"
/>
<!-- 布尔值 -->
<a-switch
v-else-if="form.type === 'boolean'"
v-model:checked="valueChecked"
checked-children="启用"
un-checked-children="禁用"
/>
<!-- 文件/图片 -->
<a-input
v-else-if="['file', 'image'].includes(form.type)"
v-model:value="form.value"
placeholder="请输入文件地址"
/>
<!-- 选择框 -->
<a-input
v-else
v-model:value="form.optionsText"
placeholder="选项值,用逗号分隔,如:选项1,选项2,选项3"
/>
</a-form-item>
<!-- 默认值 -->
<a-form-item label="默认值" name="default_value">
<a-input
v-model:value="form.default_value"
placeholder="默认值(可选)"
allow-clear
/>
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
</a-form-item>
<!-- 状态 -->
<a-form-item label="状态" name="status">
<a-switch
v-model:checked="statusChecked"
checked-children="启用"
un-checked-children="禁用"
/>
</a-form-item>
<!-- 描述 -->
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入配置描述"
:rows="3"
maxlength="200"
show-count
/>
</a-form-item>
</a-form>
<!-- 底部按钮 -->
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
import { useDictionaryStore } from "@/stores/modules/dictionary";
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
record: {
type: Object,
default: null,
},
});
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const title = computed(() => {
return isEdit.value ? "编辑配置" : "新增配置";
});
// 配置分组选项
const groupOptions = ref([]);
// 表单数据
const form = ref({
id: "",
name: "",
key: "",
group: "",
type: "string",
value: "",
default_value: "",
description: "",
status: true,
sort: 0,
options: [],
});
// 选项文本(用于选择框类型)
const optionsText = ref("");
// 计算属性:状态开关
const statusChecked = computed({
get: () => form.value.status === true,
set: (val) => {
form.value.status = val ? true : false;
},
});
// 计算属性:值开关(布尔类型)
const valueChecked = computed({
get: () => form.value.value === "1" || form.value.value === true,
set: (val) => {
form.value.value = val ? "1" : "0";
},
});
// 初始化字典 store
const dictionaryStore = useDictionaryStore();
// 加载配置分组
const loadGroups = async () => {
const groups = await dictionaryStore.getDictionary("config_group");
groupOptions.value = groups.map((item) => ({
label: item.label,
value: item.value,
}));
};
// 验证规则
const rules = {
name: [
{ required: true, message: "请输入配置名称", trigger: "blur" },
{ min: 2, max: 50, message: "长度在 2 到 50 个字符", trigger: "blur" },
],
key: [
{ required: true, message: "请输入配置键名", trigger: "blur" },
{
pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/,
message: "格式不正确",
trigger: "blur",
},
],
group: [{ required: true, message: "请选择配置分组", trigger: "change" }],
type: [{ required: true, message: "请选择配置类型", trigger: "change" }],
};
// 重置表单
const resetForm = () => {
form.value = {
id: "",
name: "",
key: "",
group: "",
type: "string",
value: "",
default_value: "",
description: "",
status: true,
sort: 0,
options: [],
};
optionsText.value = "";
formRef.value?.clearValidate();
};
// 设置数据
const setData = (data) => {
if (data) {
form.value = {
id: data.id || "",
name: data.name || "",
key: data.key || "",
group: data.group || "",
type: data.type || "string",
value: data.value || "",
default_value: data.default_value || "",
description: data.description || "",
status: data.status !== undefined ? data.status : true,
sort: data.sort !== undefined ? data.sort : 0,
options: data.options || [],
};
// 如果是选择框类型,设置选项文本
if (data.type === "select" && data.options) {
optionsText.value = Array.isArray(data.options)
? data.options.join(",")
: data.options;
}
}
};
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate();
isSaving.value = true;
// 处理选项
if (form.value.type === "select") {
form.value.options = optionsText.value
? optionsText.value.split(",").map((s) => s.trim())
: [];
}
// 处理值
let submitValue = form.value.value;
if (form.value.type === "boolean") {
submitValue = valueChecked.value ? "1" : "0";
}
const submitData = {
...form.value,
value: submitValue,
};
let res = {};
if (isEdit.value) {
res = await systemApi.config.update.put(form.value.id, submitData);
} else {
res = await systemApi.config.add.post(submitData);
}
if (res.code === 200) {
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
} else {
message.error(res.message || "操作失败");
}
} catch (error) {
if (error.errorFields) {
console.log("表单验证失败:", error);
} else {
console.error("提交失败:", error);
message.error("操作失败");
}
} finally {
isSaving.value = false;
}
};
// 取消
const handleCancel = () => {
resetForm();
emit("update:visible", false);
};
// 监听 visible 变化
watch(
() => props.visible,
(newVal) => {
if (newVal) {
loadGroups();
if (props.record) {
setData(props.record);
} else {
resetForm();
}
}
},
{ immediate: true },
);
</script>
<style scoped lang="scss">
.form-tip {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
line-height: 1.5;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
margin-top: 16px;
}
:deep(.ant-modal-body) {
max-height: 60vh;
overflow-y: auto;
}
</style>
@@ -1,291 +0,0 @@
<template>
<div class="config-form-container">
<a-form
ref="formRef"
:model="formData"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
>
<!-- 根据配置类型动态渲染表单项 -->
<template v-for="config in configs" :key="config.id">
<!-- 字符串类型 -->
<a-form-item
v-if="config.type === 'string'"
:label="config.name"
:name="config.key"
>
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}`"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 数字类型 -->
<a-form-item
v-else-if="config.type === 'number'"
:label="config.name"
:name="config.key"
>
<a-input-number
v-model:value="formData[config.key]"
:min="config.options?.min || 0"
:max="config.options?.max || 100000"
style="width: 100%"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 布尔类型 -->
<a-form-item
v-else-if="config.type === 'boolean'"
:label="config.name"
:name="config.key"
>
<a-switch
v-model:checked="formData[config.key]"
checked-children="启用"
un-checked-children="禁用"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 文件类型 -->
<a-form-item
v-else-if="config.type === 'file'"
:label="config.name"
:name="config.key"
>
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}地址`"
>
<template #suffix>
<a-button
type="link"
size="small"
@click="handleUpload(config)"
>上传</a-button
>
</template>
</a-input>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 图片类型 -->
<a-form-item
v-else-if="config.type === 'image'"
:label="config.name"
:name="config.key"
>
<div class="image-upload-wrapper">
<a-input
v-model:value="formData[config.key]"
:placeholder="`请输入${config.name}地址`"
/>
<div v-if="formData[config.key]" class="image-preview">
<img
:src="formData[config.key]"
:alt="config.name"
/>
</div>
<a-button type="link" @click="handleUpload(config)"
>选择图片</a-button
>
</div>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 文本类型 -->
<a-form-item
v-else-if="config.type === 'text'"
:label="config.name"
:name="config.key"
>
<a-textarea
v-model:value="formData[config.key]"
:rows="4"
:placeholder="`请输入${config.name}`"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
<!-- 选择框类型 -->
<a-form-item
v-else-if="config.type === 'select'"
:label="config.name"
:name="config.key"
>
<a-select
v-model:value="formData[config.key]"
:placeholder="`请选择${config.name}`"
:options="config.options || []"
/>
<div v-if="config.description" class="form-tip">
{{ config.description }}
</div>
</a-form-item>
</template>
</a-form>
<div class="form-actions">
<a-space>
<a-button type="primary" :loading="saving" @click="handleSave">
<template #icon><SaveOutlined /></template>
保存配置
</a-button>
<a-button @click="handleReset">
<template #icon><RedoOutlined /></template>
重置
</a-button>
</a-space>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { message } from "ant-design-vue";
import { SaveOutlined, RedoOutlined } from "@ant-design/icons-vue";
import systemApi from "@/api/system";
const props = defineProps({
group: {
type: String,
required: true,
},
});
const emit = defineEmits(["refresh"]);
const formRef = ref(null);
const saving = ref(false);
const configs = ref([]);
const formData = reactive({});
// 加载配置
const loadConfigs = async () => {
try {
const res = await systemApi.config.list.get({ group: props.group });
if (res.code === 200) {
configs.value = res.data.list || [];
// 初始化表单数据
configs.value.forEach((config) => {
// 根据类型转换值
if (config.type === "boolean") {
formData[config.key] =
config.value === "1" || config.value === true;
} else if (config.type === "number") {
formData[config.key] = Number(config.value) || 0;
} else {
formData[config.key] = config.value || "";
}
});
}
} catch (error) {
console.error("加载配置失败:", error);
message.error("加载配置失败");
}
};
// 保存配置
const handleSave = async () => {
try {
saving.value = true;
// 准备提交数据
const updates = [];
configs.value.forEach((config) => {
let value = formData[config.key];
// 根据类型转换值
if (config.type === "boolean") {
value = value ? "1" : "0";
} else if (config.type === "number") {
value = String(value);
}
updates.push({
key: config.key,
value: value,
});
});
const res = await systemApi.config.batchUpdate.post({
configs: updates,
});
if (res.code === 200) {
message.success("保存成功");
emit("refresh");
} else {
message.error(res.message || "保存失败");
}
} catch (error) {
console.error("保存配置失败:", error);
message.error("保存失败");
} finally {
saving.value = false;
}
};
// 重置配置
const handleReset = () => {
loadConfigs();
message.info("已重置");
};
// 上传处理(占位)
const handleUpload = (config) => {
message.info(`${config.name}上传功能待实现`);
};
onMounted(() => {
loadConfigs();
});
</script>
<style scoped lang="scss">
.config-form-container {
padding: 24px;
background: #fff;
border-radius: 4px;
.form-tip {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
line-height: 1.5;
}
.image-upload-wrapper {
.image-preview {
margin-top: 8px;
img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
}
}
.form-actions {
margin-top: 32px;
padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
}
</style>
@@ -0,0 +1,374 @@
<template>
<a-modal :open="dialogVisible" :title="isEdit ? '编辑配置项' : '新增配置项'" @cancel="handleCancel" :width="600" :confirm-loading="loading" @ok="handleOk">
<a-form ref="formRef" :model="formData" :rules="formRules" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
<a-form-item label="配置名称" name="name">
<a-input v-model:value="formData.name" placeholder="请输入配置名称" :disabled="record?.is_system" />
</a-form-item>
<a-form-item label="配置键" name="key">
<a-input v-model:value="formData.key" :placeholder="isEdit ? '' : '请输入配置键,如: site_name'" :disabled="record?.is_system">
<template #prefix>
<key-outlined />
</template>
</a-input>
<div v-if="!isEdit" class="form-tip">建议使用小写字母、下划线命名</div>
</a-form-item>
<a-form-item label="分组" name="group">
<a-select v-model:value="formData.group" placeholder="请选择分组" :options="groupOptions" :disabled="record?.is_system" show-search :filter-option="filterOption" />
</a-form-item>
<a-form-item label="配置类型" name="type">
<a-select v-model:value="formData.type" placeholder="请选择配置类型" :disabled="record?.is_system" @change="handleTypeChange">
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="text">文本</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="select">下拉选择</a-select-option>
<a-select-option value="radio">单选</a-select-option>
<a-select-option value="checkbox">多选</a-select-option>
<a-select-option value="file">文件</a-select-option>
<a-select-option value="json">JSON</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="['select', 'radio', 'checkbox'].includes(formData.type)" label="选项配置" name="options">
<div class="options-editor">
<div v-for="(option, index) in formData.options" :key="index" class="option-item">
<a-input v-model:value="option.label" placeholder="选项标签" style="flex: 1; margin-right: 8px" />
<a-input v-model:value="option.value" placeholder="选项值" style="flex: 1; margin-right: 8px" />
<a-button type="text" danger :disabled="formData.options.length <= 1" @click="removeOption(index)">
<delete-outlined />
</a-button>
</div>
<a-button type="dashed" block @click="addOption" style="margin-top: 8px"> <plus-outlined /> 添加选项 </a-button>
</div>
</a-form-item>
<a-form-item label="配置值" name="value">
<a-textarea v-if="['string', 'text', 'json'].includes(formData.type)" v-model:value="formData.value" :placeholder="getValuePlaceholder(formData.type)" :rows="formData.type === 'json' ? 6 : 3" />
<a-input-number v-else-if="formData.type === 'number'" v-model:value="formData.value" placeholder="请输入数字" style="width: 100%" />
<a-switch v-else-if="formData.type === 'boolean'" v-model:checked="formData.value" checked-children="是" un-checked-children="否" />
<span v-else-if="formData.type === 'file'">{{ formData.value || '未选择文件' }}</span>
<span v-else>{{ getDisplayValue(formData.value) }}</span>
</a-form-item>
<a-form-item label="默认值" name="default_value">
<a-textarea v-if="['string', 'text', 'json'].includes(formData.type)" v-model:value="formData.default_value" :placeholder="getValuePlaceholder(formData.type)" :rows="formData.type === 'json' ? 6 : 3" />
<a-input-number v-else-if="formData.type === 'number'" v-model:value="formData.default_value" placeholder="请输入默认值" style="width: 100%" />
<a-switch v-else-if="formData.type === 'boolean'" v-model:checked="formData.default_value" checked-children="是" un-checked-children="否" />
<span v-else>{{ getDisplayValue(formData.default_value) }}</span>
</a-form-item>
<a-form-item label="验证规则" name="validation">
<a-input v-model:value="formData.validation" placeholder="如: required|email|max:100" :disabled="record?.is_system" />
<div v-if="!isEdit" class="form-tip">可选,使用 Laravel 验证规则语法</div>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea v-model:value="formData.description" placeholder="请输入配置描述" :rows="2" />
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number v-model:value="formData.sort" :min="0" :max="9999" style="width: 100%" />
<div v-if="!isEdit" class="form-tip">数字越小排序越靠前</div>
</a-form-item>
<a-form-item label="状态" name="status">
<a-switch v-model:checked="formData.status" checked-children="启用" un-checked-children="禁用" :disabled="record?.is_system" />
</a-form-item>
<a-form-item v-if="record?.is_system" label="系统配置">
<a-tag color="orange">系统配置项</a-tag>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import { KeyOutlined, PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
import systemApi from '@/api/system'
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false,
},
record: {
type: Object,
default: null,
},
defaultGroup: {
type: String,
default: '',
},
groups: {
type: Array,
default: () => [],
},
})
// Emits
const emit = defineEmits(['update:visible', 'success'])
// Refs
const formRef = ref(null)
const loading = ref(false)
// 字典 store
const dictionaryStore = useDictionaryStore()
// 对话框可见性
const dialogVisible = computed({
get: () => props.visible,
set: (value) => emit('update:visible', value),
})
// 是否为编辑模式
const isEdit = computed(() => !!props.record)
// 表单数据
const formData = reactive({
name: '',
key: '',
group: '',
type: 'string',
options: [],
value: '',
default_value: '',
validation: '',
description: '',
sort: 0,
status: true,
})
// 获取值的占位符
const getValuePlaceholder = (type) => {
if (type === 'json') {
return '{\"key\": \"value\"}'
}
return '请输入配置值'
}
// 表单验证规则
const formRules = computed(() => ({
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
key: [
{
required: !isEdit.value,
message: '请输入配置键',
trigger: 'blur',
},
{
pattern: /^[a-z][a-z0-9_]*$/i,
message: '只能包含字母、数字、下划线,且以字母开头',
trigger: 'blur',
},
],
group: [{ required: true, message: '请选择分组', trigger: 'change' }],
type: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
}))
// 分组选项
const groupOptions = computed(() => {
return props.groups.map((item) => ({
label: item.label,
value: item.value,
}))
})
// 过滤选项
const filterOption = (input, option) => {
return option.label.toLowerCase().includes(input.toLowerCase())
}
// 监听 visible 变化
watch(
() => props.visible,
(val) => {
if (val) {
if (isEdit.value && props.record) {
// 编辑模式:填充表单数据
Object.keys(formData).forEach((key) => {
if (props.record[key] !== undefined) {
formData[key] = props.record[key]
}
})
// 处理 options
if (typeof formData.options === 'string' && formData.options) {
try {
formData.options = JSON.parse(formData.options)
} catch (e) {
formData.options = []
}
}
// 确保 options 是数组
if (!Array.isArray(formData.options)) {
formData.options = []
}
// 如果 options 为空,初始化一个默认选项
if (['select', 'radio', 'checkbox'].includes(formData.type) && formData.options.length === 0) {
formData.options = [{ label: '选项1', value: 'option1' }]
}
} else {
// 新增模式:重置表单数据
Object.keys(formData).forEach((key) => {
if (key === 'status') {
formData[key] = true
} else if (key === 'sort') {
formData[key] = 0
} else if (key === 'options') {
formData[key] = []
} else {
formData[key] = ''
}
})
// 设置默认分组
if (props.defaultGroup) {
formData.group = props.defaultGroup
}
}
}
},
)
// 类型改变时处理
const handleTypeChange = (type) => {
// 重置值和默认值
formData.value = ''
formData.default_value = ''
// 如果是选择类型,初始化选项
if (['select', 'radio', 'checkbox'].includes(type)) {
if (!formData.options || formData.options.length === 0) {
formData.options = [{ label: '选项1', value: 'option1' }]
}
} else {
formData.options = []
}
}
// 添加选项
const addOption = () => {
const index = formData.options.length + 1
formData.options.push({
label: `选项${index}`,
value: `option${index}`,
})
}
// 移除选项
const removeOption = (index) => {
formData.options.splice(index, 1)
}
// 获取显示值
const getDisplayValue = (value) => {
if (['select', 'radio'].includes(formData.type)) {
const option = formData.options.find((opt) => opt.value === value)
return option ? option.label : value
}
if (formData.type === 'checkbox') {
if (typeof value === 'string') {
try {
const values = JSON.parse(value)
return values
.map((v) => {
const option = formData.options.find((opt) => opt.value === v)
return option ? option.label : v
})
.join(', ')
} catch (e) {
return value
}
}
return value
}
return value || '-'
}
// 确认
const handleOk = async () => {
try {
await formRef.value.validate()
// 验证选项配置
if (['select', 'radio', 'checkbox'].includes(formData.type) && formData.options.length === 0) {
message.error('请至少添加一个选项')
return
}
// 处理 options
const submitData = { ...formData }
if (['select', 'radio', 'checkbox'].includes(formData.type)) {
submitData.options = JSON.stringify(formData.options)
} else {
submitData.options = null
}
// 处理值
if (formData.type === 'checkbox') {
if (Array.isArray(submitData.value)) {
submitData.value = JSON.stringify(submitData.value)
}
if (Array.isArray(submitData.default_value)) {
submitData.default_value = JSON.stringify(submitData.default_value)
}
}
loading.value = true
let res
if (isEdit.value) {
// 编辑
res = await systemApi.config.edit.put(props.record.id, submitData)
} else {
// 新增
res = await systemApi.config.add.post(submitData)
}
if (res.code === 200) {
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
} else {
message.error(res.message || (isEdit.value ? '编辑失败' : '新增失败'))
}
} catch (error) {
console.error(isEdit.value ? '编辑配置项失败:' : '新增配置项失败:', error)
message.error(isEdit.value ? '编辑失败' : '新增失败')
} finally {
loading.value = false
}
}
// 取消
const handleCancel = () => {
emit('update:visible', false)
}
</script>
<style scoped lang="scss">
.options-editor {
.option-item {
display: flex;
align-items: center;
margin-bottom: 8px;
}
}
.form-tip {
font-size: 12px;
color: #8c8c8c;
margin-top: 4px;
}
</style>
@@ -1,424 +0,0 @@
<template>
<a-modal
:open="visible"
:title="isEdit ? '编辑配置' : '新增配置'"
:width="600"
:confirm-loading="loading"
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="formData"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-form-item label="配置分组" name="group">
<a-select
v-model:value="formData.group"
placeholder="请选择配置分组"
allow-show-search
>
<a-select-option value="system">系统设置</a-select-option>
<a-select-option value="site">站点配置</a-select-option>
<a-select-option value="upload">上传配置</a-select-option>
<a-select-option value="email">邮件配置</a-select-option>
<a-select-option value="sms">短信配置</a-select-option>
<a-select-option value="other">其他</a-select-option>
<template #notFoundContent>
<div style="text-align: center">
<a-input
v-model:value="customGroup"
placeholder="输入新分组"
style="margin-bottom: 8px"
/>
<a-button
type="primary"
size="small"
@click="handleAddCustomGroup"
>添加</a-button
>
</div>
</template>
</a-select>
</a-form-item>
<a-form-item label="配置键" name="key">
<a-input
v-model:value="formData.key"
placeholder="请输入配置键,如:site_name"
:disabled="isEdit && formData.is_system"
/>
<div style="color: #999; font-size: 12px; margin-top: 4px">
唯一标识,建议使用英文下划线命名
</div>
</a-form-item>
<a-form-item label="配置名称" name="name">
<a-input
v-model:value="formData.name"
placeholder="请输入配置名称"
/>
</a-form-item>
<a-form-item label="数据类型" name="type">
<a-select
v-model:value="formData.type"
placeholder="请选择数据类型"
>
<a-select-option value="string">字符串</a-select-option>
<a-select-option value="text">文本</a-select-option>
<a-select-option value="number">数字</a-select-option>
<a-select-option value="boolean">布尔值</a-select-option>
<a-select-option value="select">下拉框</a-select-option>
<a-select-option value="radio">单选框</a-select-option>
<a-select-option value="checkbox">多选框</a-select-option>
<a-select-option value="file">文件</a-select-option>
<a-select-option value="json">JSON</a-select-option>
</a-select>
</a-form-item>
<a-form-item
v-if="['select', 'radio', 'checkbox'].includes(formData.type)"
label="可选值"
name="options"
>
<a-textarea
v-model:value="optionsText"
placeholder="每行一个选项,格式:label:value&#10;例如:&#10;正常:1&#10;禁用:0"
:rows="4"
/>
</a-form-item>
<a-form-item label="配置值" name="value">
<!-- 字符串输入 -->
<a-input
v-if="formData.type === 'string'"
v-model:value="formData.value"
placeholder="请输入配置值"
/>
<!-- 文本域 -->
<a-textarea
v-else-if="formData.type === 'text'"
v-model:value="formData.value"
placeholder="请输入配置值"
:rows="4"
/>
<!-- 数字输入 -->
<a-input-number
v-else-if="formData.type === 'number'"
v-model:value="formData.value"
placeholder="请输入数字"
style="width: 100%"
/>
<!-- 布尔值 -->
<a-switch
v-else-if="formData.type === 'boolean'"
v-model:checked="formData.value"
checked-children="是"
un-checked-children="否"
/>
<!-- 下拉框/单选框/多选框 -->
<a-select
v-else-if="['select', 'radio'].includes(formData.type)"
v-model:value="formData.value"
placeholder="请选择配置值"
allow-clear
>
<a-select-option
v-for="opt in parsedOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-select-option>
</a-select>
<a-checkbox-group
v-else-if="formData.type === 'checkbox'"
v-model:value="formData.value"
>
<a-checkbox
v-for="opt in parsedOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
<!-- 文件上传 -->
<sc-upload
v-else-if="formData.type === 'file'"
v-model="formData.value"
:limit="1"
accept="*/*"
list-type="picture-card"
/>
<!-- JSON编辑器 -->
<a-textarea
v-else-if="formData.type === 'json'"
v-model:value="formData.value"
placeholder='请输入JSON格式数据,例如:{"key": "value"}'
:rows="4"
/>
</a-form-item>
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="formData.sort"
placeholder="请输入排序"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">启用</a-radio>
<a-radio :value="0">禁用</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="描述" name="description">
<a-textarea
v-model:value="formData.description"
placeholder="请输入配置描述"
:rows="2"
/>
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { message } from "ant-design-vue";
import systemApi from "@/api/system";
const props = defineProps({
visible: Boolean,
record: {
type: Object,
default: null,
},
});
const emit = defineEmits(["update:visible", "success"]);
const formRef = ref(null);
const loading = ref(false);
const customGroup = ref("");
const optionsText = ref("");
// 是否编辑模式
const isEdit = computed(() => !!props.record?.id);
// 表单数据
const formData = reactive({
group: "system",
key: "",
name: "",
type: "string",
value: "",
options: null,
sort: 0,
status: 1,
description: "",
is_system: false,
});
// 表单验证规则
const rules = {
group: [{ required: true, message: "请选择配置分组", trigger: "change" }],
key: [{ required: true, message: "请输入配置键", trigger: "blur" }],
name: [{ required: true, message: "请输入配置名称", trigger: "blur" }],
type: [{ required: true, message: "请选择数据类型", trigger: "change" }],
value: [{ required: true, message: "请输入配置值", trigger: "change" }],
};
// 解析选项
const parsedOptions = computed(() => {
if (!optionsText.value) return [];
try {
return optionsText.value
.split("\n")
.filter((line) => line.trim())
.map((line) => {
const [label, value] = line.split(":").map((s) => s.trim());
return { label, value: value || label };
});
} catch {
return [];
}
});
// 根据类型解析值
const parseValueByType = (value, type) => {
if (!value) return value;
switch (type) {
case "number":
return Number(value);
case "boolean":
return value === "true" || value === true || value === 1;
case "checkbox":
if (typeof value === "string") {
try {
return JSON.parse(value);
} catch {
return value.split(",");
}
}
return value;
default:
return value;
}
};
// 重置表单
const resetForm = () => {
Object.assign(formData, {
group: "system",
key: "",
name: "",
type: "string",
value: "",
options: null,
sort: 0,
status: 1,
description: "",
is_system: false,
});
optionsText.value = "";
formRef.value?.clearValidate();
};
// 监听record变化,初始化表单
watch(
() => props.record,
(newRecord) => {
if (newRecord) {
// 编辑模式
Object.assign(formData, {
group: newRecord.group || "system",
key: newRecord.key || "",
name: newRecord.name || "",
type: newRecord.type || "string",
value: parseValueByType(newRecord.value, newRecord.type),
options: newRecord.options,
sort: newRecord.sort || 0,
status: newRecord.status ?? 1,
description: newRecord.description || "",
is_system: newRecord.is_system || false,
});
// 解析选项
if (newRecord.options && typeof newRecord.options === "object") {
optionsText.value = newRecord.options
.map((opt) => {
if (typeof opt === "object") {
return `${opt.label}:${opt.value}`;
}
return opt;
})
.join("\n");
}
} else {
// 新增模式,重置表单
resetForm();
}
},
{ immediate: true },
);
// 添加自定义分组
const handleAddCustomGroup = () => {
if (!customGroup.value) {
message.warning("请输入分组名称");
return;
}
formData.group = customGroup.value;
customGroup.value = "";
message.success("分组已添加");
};
// 处理确定按钮
const handleOk = async () => {
try {
await formRef.value.validate();
loading.value = true;
const submitData = { ...formData };
// 处理选项
if (
["select", "radio", "checkbox"].includes(formData.type) &&
optionsText.value
) {
submitData.options = parsedOptions.value;
}
// 处理值类型转换
if (formData.type === "number") {
submitData.value = Number(submitData.value);
} else if (formData.type === "boolean") {
submitData.value = submitData.value ? "1" : "0";
} else if (
formData.type === "checkbox" &&
Array.isArray(submitData.value)
) {
submitData.value = submitData.value.join(",");
} else if (formData.type === "json") {
// 验证JSON格式
try {
JSON.parse(submitData.value);
} catch (e) {
throw new Error("JSON格式不正确");
}
}
if (isEdit.value) {
await systemApi.configs.edit.put(formData.id, submitData);
message.success("更新成功");
} else {
await systemApi.configs.add.post(submitData);
message.success("创建成功");
}
emit("success");
emit("update:visible", false);
resetForm();
} catch (error) {
if (error.message) {
message.error(error.message || "操作失败");
}
} finally {
loading.value = false;
}
};
// 处理取消按钮
const handleCancel = () => {
resetForm();
emit("update:visible", false);
};
</script>
<style scoped lang="scss">
:deep(.ant-modal-body) {
max-height: 60vh;
overflow-y: auto;
}
:deep(.ant-select-dropdown) {
.ant-input {
margin-bottom: 8px;
}
}
</style>
+414 -424
View File
@@ -1,472 +1,462 @@
<template>
<div class="pages-base-layout system-configs-page">
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="配置名称/键名"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.group"
placeholder="配置分组"
allow-clear
style="width: 140px"
:options="groupOptions"
/>
<a-button type="primary" @click="handleSearch">
<template #icon><search-outlined /></template>
搜索
</a-button>
<a-button @click="handleReset">
<template #icon><redo-outlined /></template>
重置
</a-button>
</a-space>
</div>
<div class="right-panel">
<a-button type="primary" @click="handleAdd">
<template #icon><plus-outlined /></template>
新增
</a-button>
<a-dropdown>
<a-button>
批量操作
<down-outlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchDelete">
<delete-outlined />
批量删除
</a-menu-item>
<a-menu-item @click="handleBatchStatus(1)">
<check-outlined />
批量启用
</a-menu-item>
<a-menu-item @click="handleBatchStatus(0)">
<stop-outlined />
批量禁用
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="(record) => record.id"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="getTypeColor(record.type)">{{
getTypeText(record.type)
}}</a-tag>
</template>
<template v-if="column.key === 'value'">
<span
v-if="
['string', 'number', 'boolean'].includes(
record.type,
)
"
class="value-text"
>
{{ formatValue(record.value, record.type) }}
</span>
<span
v-else-if="record.type === 'file'"
class="file-value"
>
<file-outlined />
{{ record.value }}
</span>
<a
v-else-if="record.type === 'image'"
:href="record.value"
target="_blank"
class="image-value"
>
<img
:src="record.value"
alt="预览"
class="config-image"
/>
</a>
<span v-else class="json-value">{{
record.value
}}</span>
</template>
<template v-if="column.key === 'status'">
<a-badge
:status="record.status ? 'success' : 'default'"
:text="record.status ? '启用' : '禁用'"
/>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<edit-outlined />
编辑
</a-button>
<a-button
type="link"
size="small"
danger
:disabled="record.is_system"
@click="handleDelete(record)"
>
<delete-outlined />
删除
</a-button>
</a-space>
</template>
<div class="setting-page">
<!-- 主内容区域 -->
<div class="content-wrapper">
<a-tabs v-model:activeKey="activeGroup" class="config-tabs">
<template #rightExtra>
<a-button type="primary" size="small" @click="handleAddConfig"> <PlusOutlined /> 新增配置项 </a-button>
</template>
</scTable>
<a-tab-pane v-for="group in groups" :key="group.value" :tab="group.label">
<a-form :model="formData">
<a-form-item :label="config.name" v-for="config in configs[group.value]" :key="config.id" :span="getColumnSpan(config.type)">
<div class="config-input-wrapper" @mouseenter="showEditIcon(config.id)" @mouseleave="hideEditIcon">
<!-- 不同类型的配置项 -->
<!-- string/text 类型 -->
<a-input v-if="['string', 'text'].includes(config.type)" v-model:value="formData[config.key]" :placeholder="config.description" :rows="config.type === 'text' ? 3 : 1" :type="config.type === 'text' ? 'textarea' : 'text'" />
<!-- number 类型 -->
<a-input-number v-else-if="config.type === 'number'" v-model:value="formData[config.key]" :placeholder="config.description" :style="{ width: '100%' }" />
<!-- boolean 类型 -->
<a-switch v-else-if="config.type === 'boolean'" v-model:checked="formData[config.key]" />
<!-- select 类型 -->
<a-select v-else-if="config.type === 'select'" v-model:value="formData[config.key]" :placeholder="config.description" :options="config.options" :style="{ width: '100%' }" allow-clear />
<!-- radio 类型 -->
<a-radio-group v-else-if="config.type === 'radio'" v-model:value="formData[config.key]">
<a-radio v-for="opt in config.options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-radio>
</a-radio-group>
<!-- checkbox 类型 -->
<a-checkbox-group v-else-if="config.type === 'checkbox'" v-model:value="formData[config.key]">
<a-checkbox v-for="opt in config.options" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-checkbox>
</a-checkbox-group>
<!-- file 类型 -->
<a-upload v-else-if="config.type === 'file'" v-model:file-list="formData[config.key]" :max-count="1" list-type="picture-card" class="config-upload" :before-upload="() => false">
<PlusOutlined />
</a-upload>
<!-- json 类型 -->
<div v-else-if="config.type === 'json'" class="json-editor-wrapper">
<a-textarea v-model:value="jsonStrings[config.key]" placeholder='{"key": "value"}' :rows="4" />
<div v-if="jsonErrors[config.key]" class="json-error">
{{ jsonErrors[config.key] }}
</div>
</div>
<!-- 编辑图标 -->
<a-button v-show="visibleEditIcon === config.id" type="link" size="small" class="edit-btn" @click="handleEditConfig(config)">
<EditOutlined />
编辑
</a-button>
</div>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</div>
<!-- 新增/编辑弹窗 -->
<SaveDialog
v-model:visible="showSaveDialog"
:record="currentRecord"
@success="handleSaveSuccess"
/>
<!-- 底部操作栏 -->
<div class="footer-bar">
<a-space>
<a-button @click="handleReset">重置</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">
<SaveOutlined />
保存配置
</a-button>
</a-space>
</div>
</div>
<!-- 保存配置项弹窗 -->
<SaveConfigDialog v-if="dialog.save" v-model:visible="dialog.save" :record="currentConfig" :default-group="activeGroup" :groups="groups" @success="handleSaveSuccess" />
</template>
<script setup>
import { ref, reactive, onMounted, h } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
PlusOutlined,
DownOutlined,
DeleteOutlined,
CheckOutlined,
StopOutlined,
EditOutlined,
FileOutlined,
} from "@ant-design/icons-vue";
import scTable from "@/components/scTable/index.vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import SaveDialog from "./components/SaveDialog.vue";
import { ref, reactive, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { PlusOutlined, EditOutlined, SaveOutlined } from '@ant-design/icons-vue'
import systemApi from '@/api/system'
import SaveConfigDialog from './components/SaveConfigDialog.vue'
import { useDictionaryStore } from '@/stores/modules/dictionary'
// 表格引用
const tableRef = ref(null);
// ===== 数据定义 =====
const loading = ref(false)
const saving = ref(false)
const activeGroup = ref('')
const groups = ref([])
const configs = ref({})
const formData = reactive({})
const jsonStrings = reactive({})
const jsonErrors = reactive({})
const visibleEditIcon = ref(null)
// 搜索表单
const searchForm = reactive({
keyword: "",
group: undefined,
});
// 是否可以编辑系统配置
const canEditSystem = ref(false)
// 分组选项
const groupOptions = ref([]);
// 弹窗状态
const dialog = reactive({
save: false,
})
// 当前记录
const currentRecord = ref(null);
// 当前编辑的配置项
const currentConfig = ref(null)
// 显示新增/编辑弹窗
const showSaveDialog = ref(false);
// 字典 store
const dictionaryStore = useDictionaryStore()
// 使用 useTable Hook
const {
tableData,
loading,
pagination,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.configs.list.get,
searchForm,
needPagination: true,
});
// 表格列配置
const columns = [
{
title: "ID",
dataIndex: "id",
key: "id",
width: 80,
},
{
title: "配置分组",
dataIndex: "group",
key: "group",
width: 120,
},
{
title: "配置键",
dataIndex: "key",
key: "key",
width: 200,
ellipsis: true,
},
{
title: "配置名称",
dataIndex: "name",
key: "name",
width: 180,
},
{
title: "配置值",
dataIndex: "value",
key: "value",
ellipsis: true,
width: 250,
},
{
title: "类型",
dataIndex: "type",
key: "type",
width: 100,
align: "center",
},
{
title: "排序",
dataIndex: "sort",
key: "sort",
width: 80,
align: "center",
},
{
title: "状态",
dataIndex: "status",
key: "status",
width: 100,
align: "center",
},
{
title: "描述",
dataIndex: "description",
key: "description",
ellipsis: true,
},
{
title: "操作",
key: "action",
width: 150,
fixed: "right",
},
];
// 获取类型颜色
const getTypeColor = (type) => {
const colors = {
string: "blue",
text: "cyan",
number: "green",
boolean: "orange",
select: "purple",
radio: "purple",
checkbox: "purple",
file: "pink",
json: "geekblue",
};
return colors[type] || "default";
};
// 获取类型文本
const getTypeText = (type) => {
const texts = {
string: "字符串",
text: "文本",
number: "数字",
boolean: "布尔值",
select: "下拉框",
radio: "单选框",
checkbox: "多选框",
file: "文件",
json: "JSON",
};
return texts[type] || type;
};
// 格式化值
const formatValue = (value, type) => {
if (type === "boolean") {
return value === "true" || value === true ? "是" : "否";
// ===== 获取列跨度 =====
const getColumnSpan = (type) => {
if (['text', 'json', 'file'].includes(type)) {
return 24
}
if (type === "number") {
return Number(value);
if (['radio', 'checkbox'].includes(type)) {
return 12
}
return value;
};
return 8
}
// 获取分组列表
// ===== 显示编辑图标 =====
const showEditIcon = (id) => {
visibleEditIcon.value = id
}
// ===== 隐藏编辑图标 =====
const hideEditIcon = () => {
visibleEditIcon.value = null
}
// ===== 加载配置分组 =====
const loadGroups = async () => {
try {
const res = await systemApi.configs.groups.get();
groupOptions.value = res.data.map((item) => ({
label: item,
value: item,
}));
loading.value = true
const res = await systemApi.config.groups.get()
if (res.code === 200) {
const groupList = res.data || []
// 从字典获取分组标题
const dictConfig = await dictionaryStore.getDictionary('config_group')
// 确保 dictConfig 是数组
const dictItems = Array.isArray(dictConfig) ? dictConfig : []
const dictMap = dictItems.reduce((acc, item) => {
acc[item.value] = item.label
return acc
}, {})
// 构建分组列表
groups.value = groupList.map((group) => ({
value: group,
label: dictMap[group] || group,
}))
// 设置默认选中第一个分组
if (groups.value.length > 0 && !activeGroup.value) {
activeGroup.value = groups.value[0].value
}
} else {
message.error(res.message || '加载配置分组失败')
}
} catch (error) {
console.error("获取分组列表失败:", error);
console.error('加载配置分组失败:', error)
message.error('加载配置分组失败')
} finally {
loading.value = false
}
};
}
// 新增
const handleAdd = () => {
currentRecord.value = null;
showSaveDialog.value = true;
};
// ===== 加载配置项 =====
const loadConfigs = async () => {
try {
loading.value = true
const res = await systemApi.config.all.get()
if (res.code === 200) {
const configList = res.data || []
// 编辑
const handleEdit = (record) => {
currentRecord.value = { ...record };
showSaveDialog.value = true;
};
// 按分组整理配置项
configs.value = configList.reduce((acc, config) => {
if (!acc[config.group]) {
acc[config.group] = []
}
acc[config.group].push(config)
return acc
}, {})
// 删除
const handleDelete = (record) => {
if (record.is_system) {
message.warning("系统配置不能删除");
return;
// 初始化表单数据
configList.forEach((config) => {
const value = config.value ?? config.default_value ?? ''
// 根据类型转换值
if (config.type === 'boolean') {
formData[config.key] = value === true || value === '1'
} else if (config.type === 'checkbox') {
formData[config.key] = typeof value === 'string' ? JSON.parse(value) : value
} else if (config.type === 'json') {
jsonStrings[config.key] = typeof value === 'string' ? value : JSON.stringify(value)
delete jsonErrors[config.key]
} else if (config.type === 'file') {
// file 类型:将字符串转换为文件列表格式
if (typeof value === 'string' && value) {
try {
const fileInfo = JSON.parse(value)
formData[config.key] = Array.isArray(fileInfo) ? fileInfo : [fileInfo]
} catch {
formData[config.key] = []
}
} else {
formData[config.key] = []
}
} else {
formData[config.key] = value
}
})
} else {
message.error(res.message || '加载配置项失败')
}
} catch (error) {
console.error('加载配置项失败:', error)
message.error('加载配置项失败')
} finally {
loading.value = false
}
Modal.confirm({
title: "确认删除",
content: `确定要删除配置"${record.name}"吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.delete.delete(record.id);
message.success("删除成功");
refreshTable();
} catch (error) {
message.error(error.message || "删除失败");
}
// ===== 验证 JSON 格式 =====
const validateJson = (key) => {
const jsonStr = jsonStrings[key]
if (!jsonStr || jsonStr.trim() === '') {
delete jsonErrors[key]
return true
}
try {
const parsed = JSON.parse(jsonStr)
formData[key] = parsed
delete jsonErrors[key]
return true
} catch (e) {
jsonErrors[key] = 'JSON 格式错误: ' + e.message
return false
}
}
// ===== 保存配置 =====
const handleSave = async () => {
// 验证 JSON 类型
const hasJsonError = Object.keys(jsonErrors).some((key) => jsonErrors[key])
if (hasJsonError) {
message.error('请先修正 JSON 格式错误')
return
}
// 验证所有 JSON 字段
const allJsonValid = Object.keys(jsonStrings).every((key) => validateJson(key))
if (!allJsonValid) {
message.error('JSON 格式错误')
return
}
// 构建更新数据
const updates = {}
Object.keys(formData).forEach((key) => {
const config = Object.values(configs.value)
.flat()
.find((c) => c.key === key)
if (config) {
let value = formData[key]
// 转换 checkbox 类型为数组字符串
if (config.type === 'checkbox') {
value = JSON.stringify(value)
}
},
});
};
updates[key] = value
}
})
// 批量删除
const handleBatchDelete = () => {
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning("请先选择要删除的配置");
return;
}
Modal.confirm({
title: "确认删除",
content: `确定要删除选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.batchDelete.post({
ids: selectedRowKeys,
});
message.success("批量删除成功");
rowSelection.selectedRowKeys = [];
refreshTable();
} catch (error) {
message.error(error.message || "批量删除失败");
// 批量更新
try {
saving.value = true
const promises = Object.entries(updates).map(([key, value]) => {
const config = Object.values(configs.value)
.flat()
.find((c) => c.key === key)
if (!config || (config.is_system && !canEditSystem.value)) {
return Promise.resolve()
}
},
});
};
return systemApi.config.edit.put(config.id, { value })
})
// 批量更新状态
const handleBatchStatus = (status) => {
const selectedRowKeys = rowSelection.selectedRowKeys;
if (selectedRowKeys.length === 0) {
message.warning("请先选择要操作的配置");
return;
await Promise.all(promises)
message.success('保存成功')
// 重新加载配置
await loadConfigs()
} catch (error) {
console.error('保存配置失败:', error)
message.error('保存配置失败')
} finally {
saving.value = false
}
Modal.confirm({
title: status === 1 ? "确认启用" : "确认禁用",
content: `确定要${status === 1 ? "启用" : "禁用"}选中的 ${selectedRowKeys.length} 条配置吗?`,
okText: "确定",
cancelText: "取消",
onOk: async () => {
try {
await systemApi.configs.batchStatus.post({
ids: selectedRowKeys,
status,
});
message.success(`${status === 1 ? "启用" : "禁用"}成功`);
rowSelection.selectedRowKeys = [];
refreshTable();
} catch (error) {
message.error(error.message || "操作失败");
}
},
});
};
}
// 保存成功
// ===== 重置配置 =====
const handleReset = () => {
Modal.confirm({
title: '确认重置',
content: '确定要重置当前分组的所有配置项吗?',
okText: '确定',
cancelText: '取消',
onOk: async () => {
// 重新加载配置
await loadConfigs()
message.success('已重置')
},
})
}
// ===== 编辑配置项 =====
const handleEditConfig = (config) => {
currentConfig.value = { ...config }
dialog.save = true
}
// ===== 新增配置项 =====
const handleAddConfig = () => {
currentConfig.value = null
dialog.save = true
}
// ===== 保存成功回调 =====
const handleSaveSuccess = () => {
showSaveDialog.value = false;
refreshTable();
};
dialog.save = false
// 刷新分组列表
loadGroups()
// 刷新配置项
loadConfigs()
}
// 初始化
onMounted(() => {
loadGroups();
});
// ===== 生命周期 =====
onMounted(async () => {
// 先加载字典缓存
await dictionaryStore.getDictionary('config_group')
// 加载配置分组
await loadGroups()
// 加载配置项
await loadConfigs()
})
</script>
<style scoped lang="scss">
.system-configs-page {
.value-text {
color: #666;
font-family: monospace;
}
.setting-page {
display: flex;
flex-direction: column;
height: 100%;
padding: 0;
.file-value {
display: flex;
align-items: center;
gap: 4px;
color: #1890ff;
}
.image-value {
display: inline-block;
width: 40px;
height: 40px;
.content-wrapper {
flex: 1;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
display: flex;
background: #fff;
margin: 16px;
padding: 10px;
.config-image {
.config-tabs {
width: 100%;
height: 100%;
object-fit: cover;
display: flex;
:deep(.ant-tabs-content-holder) {
flex: 1;
overflow-y: auto;
}
:deep(.ant-tabs-tab) {
text-align: left;
padding: 12px 16px;
}
:deep(.ant-tabs-left > .ant-tabs-nav .ant-tabs-tab-active) {
background-color: #e6f7ff;
}
}
&:hover .config-image {
transform: scale(1.1);
transition: transform 0.3s;
.config-content {
padding: 24px;
}
}
.json-value {
color: #999;
font-family: monospace;
font-size: 12px;
.footer-bar {
padding: 12px 24px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
display: flex;
justify-content: flex-end;
}
.config-item {
margin-bottom: 16px;
.config-item-wrapper {
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 6px;
transition: all 0.2s;
&:hover {
border-color: #d9d9d9;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.config-label {
margin-bottom: 8px;
.label-text {
display: block;
font-size: 14px;
font-weight: 500;
color: #262626;
margin-bottom: 4px;
}
.label-desc {
display: block;
font-size: 12px;
color: #8c8c8c;
}
}
.config-input-wrapper {
position: relative;
.edit-btn {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
padding: 4px 8px;
font-size: 12px;
}
.config-upload {
:deep(.ant-upload-list) {
margin-top: 8px;
}
}
.json-editor-wrapper {
position: relative;
.json-error {
margin-top: 4px;
color: #ff4d4f;
font-size: 12px;
}
}
}
}
}
}
</style>
@@ -1,19 +1,9 @@
<template>
<a-modal
title="任务详情"
:open="visible"
:footer="null"
@cancel="handleCancel"
width="800px"
>
<a-modal title="任务详情" :open="visible" :footer="null" @cancel="handleCancel" width="800px">
<a-descriptions bordered :column="2" v-if="task">
<a-descriptions-item label="任务名称">{{
task.name
}}</a-descriptions-item>
<a-descriptions-item label="任务名称">{{ task.name }}</a-descriptions-item>
<a-descriptions-item label="任务类型">
<a-tag :color="getTypeColor(task.type)">{{
getTypeText(task.type)
}}</a-tag>
<a-tag :color="getTypeColor(task.type)">{{ getTypeText(task.type) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="命令/类" :span="2">
<code class="command-code">{{ task.command }}</code>
@@ -21,33 +11,29 @@
<a-descriptions-item label="Cron表达式">
<code class="cron-code">{{ task.expression }}</code>
</a-descriptions-item>
<a-descriptions-item label="时区">{{
task.timezone
}}</a-descriptions-item>
<a-descriptions-item label="时区">{{ task.timezone }}</a-descriptions-item>
<a-descriptions-item label="状态" :span="2">
<a-tag :color="task.is_active ? 'success' : 'error'">
{{ task.is_active ? "启用" : "禁用" }}
{{ task.is_active ? '启用' : '禁用' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="上次运行时间" :span="2">
{{ task.last_run_at ? formatDate(task.last_run_at) : "未运行" }}
{{ task.last_run_at ? formatDate(task.last_run_at) : '未运行' }}
</a-descriptions-item>
<a-descriptions-item label="下次运行时间" :span="2">
{{ task.next_run_at ? formatDate(task.next_run_at) : "-" }}
{{ task.next_run_at ? formatDate(task.next_run_at) : '-' }}
</a-descriptions-item>
<a-descriptions-item label="运行次数">
<a-tag color="success">成功: {{ task.run_count || 0 }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="失败次数">
<a-tag :color="task.failed_count > 0 ? 'error' : 'default'">
失败: {{ task.failed_count || 0 }}
</a-tag>
<a-tag :color="task.failed_count > 0 ? 'error' : 'default'"> 失败: {{ task.failed_count || 0 }} </a-tag>
</a-descriptions-item>
<a-descriptions-item label="后台运行" :span="2">
{{ task.run_in_background ? "是" : "否" }}
{{ task.run_in_background ? '是' : '否' }}
</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">
{{ task.description || "-" }}
{{ task.description || '-' }}
</a-descriptions-item>
</a-descriptions>
@@ -71,10 +57,10 @@
</template>
<script setup>
import { ref, computed } from "vue";
import { message } from "ant-design-vue";
import { PlayCircleOutlined } from "@ant-design/icons-vue";
import systemApi from "@/api/system";
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import { PlayCircleOutlined } from '@ant-design/icons-vue'
import systemApi from '@/api/system'
const props = defineProps({
visible: {
@@ -85,61 +71,61 @@ const props = defineProps({
type: Object,
default: null,
},
});
})
const emit = defineEmits(["update:visible", "refresh"]);
const emit = defineEmits(['update:visible', 'refresh'])
const task = computed(() => props.record);
const task = computed(() => props.record)
// 获取任务类型文本
const getTypeText = (type) => {
const typeMap = {
command: "命令",
job: "任务",
closure: "闭包",
};
return typeMap[type] || type;
};
command: '命令',
job: '任务',
closure: '闭包',
}
return typeMap[type] || type
}
// 获取任务类型颜色
const getTypeColor = (type) => {
const colorMap = {
command: "blue",
job: "green",
closure: "orange",
};
return colorMap[type] || "default";
};
command: 'blue',
job: 'green',
closure: 'orange',
}
return colorMap[type] || 'default'
}
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN");
};
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
// 执行任务
const handleRun = async () => {
if (!props.record) return;
if (!props.record) return
try {
const res = await systemApi.tasks.run.post(props.record.id);
const res = await systemApi.task.run.post(props.record.id)
if (res.code === 200) {
message.success("任务执行成功");
emit("refresh");
handleCancel();
message.success('任务执行成功')
emit('refresh')
handleCancel()
} else {
message.error(res.message || "任务执行失败");
message.error(res.message || '任务执行失败')
}
} catch (error) {
message.error("任务执行失败");
message.error('任务执行失败')
}
};
}
// 取消
const handleCancel = () => {
emit("update:visible", false);
};
emit('update:visible', false)
}
</script>
<style scoped lang="scss">
@@ -147,7 +133,7 @@ const handleCancel = () => {
padding: 4px 8px;
background: #f5f5f5;
border-radius: 3px;
font-family: "Consolas", "Monaco", monospace;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
word-break: break-all;
}
@@ -156,7 +142,7 @@ const handleCancel = () => {
padding: 2px 6px;
background: #f5f5f5;
border-radius: 3px;
font-family: "Consolas", "Monaco", monospace;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
@@ -169,7 +155,7 @@ const handleCancel = () => {
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
font-family: "Consolas", "Monaco", monospace;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
@@ -1,34 +1,14 @@
<template>
<a-modal
:title="title"
:open="visible"
:confirm-loading="isSaving"
:footer="null"
@cancel="handleCancel"
width="700px"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 5 }"
:wrapper-col="{ span: 18 }"
>
<a-modal :title="title" :open="visible" :confirm-loading="isSaving" :footer="null" @cancel="handleCancel" width="700px">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<!-- 任务名称 -->
<a-form-item label="任务名称" name="name" required>
<a-input
v-model:value="form.name"
placeholder="如:清理日志"
allow-clear
/>
<a-input v-model:value="form.name" placeholder="如:清理日志" allow-clear />
</a-form-item>
<!-- 任务类型 -->
<a-form-item label="任务类型" name="type" required>
<a-select
v-model:value="form.type"
placeholder="请选择任务类型"
>
<a-select v-model:value="form.type" placeholder="请选择任务类型">
<a-select-option value="command">命令</a-select-option>
<a-select-option value="job">任务</a-select-option>
<a-select-option value="closure">闭包</a-select-option>
@@ -37,74 +17,36 @@
<!-- 命令/类名 -->
<a-form-item label="命令/类" name="command" required>
<a-textarea
v-if="form.type === 'command'"
v-model:value="form.command"
placeholder="如:php artisan schedule:run"
:rows="3"
/>
<a-input
v-else
v-model:value="form.command"
placeholder="如:App\Jobs\SendEmailJob"
allow-clear
/>
<a-textarea v-if="form.type === 'command'" v-model:value="form.command" placeholder="如:php artisan schedule:run" :rows="3" />
<a-input v-else v-model:value="form.command" placeholder="如:App\Jobs\SendEmailJob" allow-clear />
<div class="form-tip">
<span v-if="form.type === 'command'"
>Shell命令或Artisan命令</span
>
<span v-else-if="form.type === 'job'"
>任务类的完整命名空间</span
>
<span v-if="form.type === 'command'">Shell命令或Artisan命令</span>
<span v-else-if="form.type === 'job'">任务类的完整命名空间</span>
<span v-else>闭包函数的代码</span>
</div>
</a-form-item>
<!-- Cron表达式 -->
<a-form-item label="Cron表达式" name="expression" required>
<a-input
v-model:value="form.expression"
placeholder="* * * * *"
allow-clear
/>
<a-input v-model:value="form.expression" placeholder="* * * * *" allow-clear />
<div class="form-tip">
分 时 日 月 周,如:0 0 * * * (每天0点执行)
<a-link
href="https://crontab.guru/"
target="_blank"
style="color: #1890ff"
>在线生成工具</a-link
>
<a-link href="https://crontab.guru/" target="_blank" style="color: #1890ff">在线生成工具</a-link>
</div>
</a-form-item>
<!-- 时区 -->
<a-form-item label="时区" name="timezone">
<a-select
v-model:value="form.timezone"
placeholder="请选择时区"
show-search
:filter-option="filterOption"
>
<a-select-option value="Asia/Shanghai"
>Asia/Shanghai (中国)</a-select-option
>
<a-select v-model:value="form.timezone" placeholder="请选择时区" show-search :filter-option="filterOption">
<a-select-option value="Asia/Shanghai">Asia/Shanghai (中国)</a-select-option>
<a-select-option value="UTC">UTC</a-select-option>
<a-select-option value="America/New_York"
>America/New_York</a-select-option
>
<a-select-option value="America/New_York">America/New_York</a-select-option>
</a-select>
</a-form-item>
<!-- 描述 -->
<a-form-item label="任务描述" name="description">
<a-textarea
v-model:value="form.description"
placeholder="请输入任务描述"
:rows="3"
maxlength="200"
show-count
/>
<a-textarea v-model:value="form.description" placeholder="请输入任务描述" :rows="3" maxlength="200" show-count />
</a-form-item>
<!-- 高级选项 -->
@@ -127,45 +69,29 @@
<!-- 启用状态 -->
<a-form-item label="启用状态" name="is_active">
<sc-select
v-model:value="form.is_active"
source-type="dictionary"
dictionary-code="yes_no"
placeholder="请选择状态"
allow-clear
/>
<sc-select v-model:value="form.is_active" source-type="dictionary" dictionary-code="yes_no" placeholder="请选择状态" allow-clear />
</a-form-item>
<!-- 排序 -->
<a-form-item label="排序" name="sort">
<a-input-number
v-model:value="form.sort"
:min="0"
:max="10000"
style="width: 100%"
/>
<a-input-number v-model:value="form.sort" :min="0" :max="10000" style="width: 100%" />
</a-form-item>
</a-form>
<!-- 底部按钮 -->
<div class="dialog-footer">
<a-space>
<a-button @click="handleCancel">取消</a-button>
<a-button
type="primary"
:loading="isSaving"
@click="handleSubmit"
>保存</a-button
>
<a-button type="primary" :loading="isSaving" @click="handleSubmit">保存</a-button>
</a-space>
</div>
</a-modal>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { message } from "ant-design-vue";
import scSelect from "@/components/scSelect/index.vue";
import systemApi from "@/api/system";
import { ref, computed, watch } from 'vue'
import { message } from 'ant-design-vue'
import scSelect from '@/components/scSelect/index.vue'
import systemApi from '@/api/system'
const props = defineProps({
visible: {
@@ -176,262 +102,247 @@ const props = defineProps({
type: Object,
default: null,
},
});
})
const emit = defineEmits(["update:visible", "success"]);
const emit = defineEmits(['update:visible', 'success'])
const formRef = ref(null);
const isSaving = ref(false);
const isEdit = computed(() => !!props.record?.id);
const formRef = ref(null)
const isSaving = ref(false)
const isEdit = computed(() => !!props.record?.id)
const title = computed(() => {
return isEdit.value ? "编辑定时任务" : "新增定时任务";
});
return isEdit.value ? '编辑定时任务' : '新增定时任务'
})
// 表单数据
const form = ref({
id: "",
name: "",
command: "",
type: "command",
expression: "* * * * *",
timezone: "Asia/Shanghai",
description: "",
id: '',
name: '',
command: '',
type: 'command',
expression: '* * * * *',
timezone: 'Asia/Shanghai',
description: '',
is_active: null,
run_in_background: false,
without_overlapping: false,
only_one: false,
sort: 0,
});
})
// Cron 表达式验证函数
const validateCronExpression = (rule, value) => {
if (!value || !value.trim()) {
return Promise.reject("请输入Cron表达式");
return Promise.reject('请输入Cron表达式')
}
const parts = value.trim().split(/\s+/);
const parts = value.trim().split(/\s+/)
if (parts.length !== 5) {
return Promise.reject("Cron表达式应由5部分组成:分 时 日 月 周");
return Promise.reject('Cron表达式应由5部分组成:分 时 日 月 周')
}
const [minute, hour, day, month, weekday] = parts;
const [minute, hour, day, month, weekday] = parts
// 验证分钟 (0-59)
if (!validateCronPart(minute, 0, 59)) {
return Promise.reject("分钟部分格式不正确 (0-59)");
return Promise.reject('分钟部分格式不正确 (0-59)')
}
// 验证小时 (0-23)
if (!validateCronPart(hour, 0, 23)) {
return Promise.reject("小时部分格式不正确 (0-23)");
return Promise.reject('小时部分格式不正确 (0-23)')
}
// 验证日 (1-31)
if (!validateCronPart(day, 1, 31)) {
return Promise.reject("日部分格式不正确 (1-31)");
return Promise.reject('日部分格式不正确 (1-31)')
}
// 验证月 (1-12)
if (!validateCronPart(month, 1, 12)) {
return Promise.reject("月部分格式不正确 (1-12)");
return Promise.reject('月部分格式不正确 (1-12)')
}
// 验证周 (0-6 或 SUN-SAT)
if (!validateCronPart(weekday, 0, 6, true)) {
return Promise.reject("周部分格式不正确 (0-6 或 SUN-SAT)");
return Promise.reject('周部分格式不正确 (0-6 或 SUN-SAT)')
}
return Promise.resolve();
};
return Promise.resolve()
}
// 验证 Cron 单个部分
const validateCronPart = (part, min, max, allowDayNames = false) => {
// 支持 * (所有值)
if (part === "*") return true;
if (part === '*') return true
// 支持逗号分隔的列表
const listItems = part.split(",");
const listItems = part.split(',')
for (const item of listItems) {
// 支持步长 (step),如 */5 或 0-10/2
if (item.includes("/")) {
const [range, step] = item.split("/");
if (!range || !step) return false;
if (!validateCronRange(range, min, max, allowDayNames))
return false;
if (!/^\d+$/.test(step)) return false;
continue;
if (item.includes('/')) {
const [range, step] = item.split('/')
if (!range || !step) return false
if (!validateCronRange(range, min, max, allowDayNames)) return false
if (!/^\d+$/.test(step)) return false
continue
}
// 支持范围,如 1-5
if (item.includes("-")) {
if (!validateCronRange(item, min, max, allowDayNames)) return false;
continue;
if (item.includes('-')) {
if (!validateCronRange(item, min, max, allowDayNames)) return false
continue
}
// 支持星期名称
if (allowDayNames) {
const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const dayNamesLower = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
];
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const dayNamesLower = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
if (dayNames.includes(item) || dayNamesLower.includes(item)) {
continue;
continue
}
}
// 检查是否为有效数字
if (!/^\d+$/.test(item)) return false;
const num = parseInt(item, 10);
if (num < min || num > max) return false;
if (!/^\d+$/.test(item)) return false
const num = parseInt(item, 10)
if (num < min || num > max) return false
}
return true;
};
return true
}
// 验证 Cron 范围
const validateCronRange = (range, min, max, allowDayNames = false) => {
// 处理 * 范围
if (range === "*") return true;
if (range === '*') return true
const parts = range.split("-");
if (parts.length !== 2) return false;
const parts = range.split('-')
if (parts.length !== 2) return false
const [start, end] = parts;
const [start, end] = parts
// 支持星期名称范围
if (allowDayNames) {
const dayNames = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
const startIdx = dayNames.indexOf(start);
const endIdx = dayNames.indexOf(end);
const dayNames = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']
const startIdx = dayNames.indexOf(start)
const endIdx = dayNames.indexOf(end)
if (startIdx !== -1 && endIdx !== -1) {
return startIdx <= endIdx;
return startIdx <= endIdx
}
}
// 数字范围验证
if (!/^\d+$/.test(start) || !/^\d+$/.test(end)) return false;
const startNum = parseInt(start, 10);
const endNum = parseInt(end, 10);
if (!/^\d+$/.test(start) || !/^\d+$/.test(end)) return false
const startNum = parseInt(start, 10)
const endNum = parseInt(end, 10)
return (
startNum >= min &&
startNum <= max &&
endNum >= min &&
endNum <= max &&
startNum <= endNum
);
};
return startNum >= min && startNum <= max && endNum >= min && endNum <= max && startNum <= endNum
}
// 验证规则
const rules = {
name: [
{ required: true, message: "请输入任务名称", trigger: "blur" },
{ required: true, message: '请输入任务名称', trigger: 'blur' },
{
min: 2,
max: 100,
message: "长度在 2 到 100 个字符",
trigger: "blur",
message: '长度在 2 到 100 个字符',
trigger: 'blur',
},
],
type: [{ required: true, message: "请选择任务类型", trigger: "change" }],
command: [{ required: true, message: "请输入命令或类名", trigger: "blur" }],
type: [{ required: true, message: '请选择任务类型', trigger: 'change' }],
command: [{ required: true, message: '请输入命令或类名', trigger: 'blur' }],
expression: [
{ required: true, message: "请输入Cron表达式", trigger: "blur" },
{ validator: validateCronExpression, trigger: "blur" },
{ required: true, message: '请输入Cron表达式', trigger: 'blur' },
{ validator: validateCronExpression, trigger: 'blur' },
],
};
}
// 下拉筛选
const filterOption = (input, option) => {
return option.value.toLowerCase().includes(input.toLowerCase());
};
return option.value.toLowerCase().includes(input.toLowerCase())
}
// 重置表单
const resetForm = () => {
form.value = {
id: "",
name: "",
command: "",
type: "command",
expression: "* * * * *",
timezone: "Asia/Shanghai",
description: "",
id: '',
name: '',
command: '',
type: 'command',
expression: '* * * * *',
timezone: 'Asia/Shanghai',
description: '',
is_active: null,
run_in_background: false,
without_overlapping: false,
only_one: false,
sort: 0,
};
formRef.value?.clearValidate();
};
}
formRef.value?.clearValidate()
}
// 设置数据
const setData = (data) => {
if (data) {
form.value = {
id: data.id || "",
name: data.name || "",
command: data.command || "",
type: data.type || "command",
expression: data.expression || "* * * * *",
timezone: data.timezone || "Asia/Shanghai",
description: data.description || "",
id: data.id || '',
name: data.name || '',
command: data.command || '',
type: data.type || 'command',
expression: data.expression || '* * * * *',
timezone: data.timezone || 'Asia/Shanghai',
description: data.description || '',
is_active: data.is_active !== undefined ? data.is_active : null,
run_in_background: data.run_in_background || false,
without_overlapping: data.without_overlapping || false,
only_one: data.only_one || false,
sort: data.sort !== undefined ? data.sort : 0,
};
}
}
};
}
// 提交表单
const handleSubmit = async () => {
try {
await formRef.value.validate();
await formRef.value.validate()
isSaving.value = true;
isSaving.value = true
const submitData = { ...form.value };
const submitData = { ...form.value }
let res = {};
let res = {}
if (isEdit.value) {
res = await systemApi.tasks.edit.put(form.value.id, submitData);
res = await systemApi.task.edit.put(form.value.id, submitData)
} else {
res = await systemApi.tasks.add.post(submitData);
res = await systemApi.task.add.post(submitData)
}
if (res.code === 200) {
message.success(isEdit.value ? "编辑成功" : "新增成功");
emit("success");
handleCancel();
message.success(isEdit.value ? '编辑成功' : '新增成功')
emit('success')
handleCancel()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
if (error.errorFields) {
console.log("表单验证失败:", error);
console.log('表单验证失败:', error)
} else {
console.error("提交失败:", error);
message.error("操作失败");
console.error('提交失败:', error)
message.error('操作失败')
}
} finally {
isSaving.value = false;
isSaving.value = false
}
};
}
// 取消
const handleCancel = () => {
resetForm();
emit("update:visible", false);
};
resetForm()
emit('update:visible', false)
}
// 监听 visible 变化
watch(
@@ -439,14 +350,14 @@ watch(
(newVal) => {
if (newVal) {
if (props.record) {
setData(props.record);
setData(props.record)
} else {
resetForm();
resetForm()
}
}
},
{ immediate: true },
);
)
</script>
<style scoped lang="scss">
+150 -253
View File
@@ -3,28 +3,13 @@
<div class="tool-bar">
<div class="left-panel">
<a-space>
<a-input
v-model:value="searchForm.keyword"
placeholder="任务名称/命令"
allow-clear
style="width: 180px"
/>
<a-select
v-model:value="searchForm.type"
placeholder="任务类型"
allow-clear
style="width: 120px"
>
<a-input v-model:value="searchForm.keyword" placeholder="任务名称/命令" allow-clear style="width: 180px" />
<a-select v-model:value="searchForm.type" placeholder="任务类型" allow-clear style="width: 120px">
<a-select-option value="command">命令</a-select-option>
<a-select-option value="job">任务</a-select-option>
<a-select-option value="closure">闭包</a-select-option>
</a-select>
<a-select
v-model:value="searchForm.is_active"
placeholder="状态"
allow-clear
style="width: 100px"
>
<a-select v-model:value="searchForm.is_active" placeholder="状态" allow-clear style="width: 100px">
<a-select-option :value="true">启用</a-select-option>
<a-select-option :value="false">禁用</a-select-option>
</a-select>
@@ -46,16 +31,10 @@
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleBatchStatus(true)">
<CheckCircleOutlined />批量启用
</a-menu-item>
<a-menu-item @click="handleBatchStatus(false)">
<StopOutlined />批量禁用
</a-menu-item>
<a-menu-item @click="handleBatchStatus(true)"> <CheckCircleOutlined />批量启用 </a-menu-item>
<a-menu-item @click="handleBatchStatus(false)"> <StopOutlined />批量禁用 </a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleBatchDelete" danger>
<DeleteOutlined />批量删除
</a-menu-item>
<a-menu-item @click="handleBatchDelete" danger> <DeleteOutlined />批量删除 </a-menu-item>
</a-menu>
</template>
</a-dropdown>
@@ -67,29 +46,13 @@
</div>
<div class="table-content">
<scTable
ref="tableRef"
:columns="columns"
:data-source="tableData"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
:row-key="rowKey"
@refresh="refreshTable"
@paginationChange="handlePaginationChange"
>
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="pagination" :row-selection="rowSelection" :row-key="rowKey" @refresh="refreshTable" @paginationChange="handlePaginationChange">
<template #type="{ record }">
<a-tag :color="getTypeColor(record.type)">{{
getTypeText(record.type)
}}</a-tag>
<a-tag :color="getTypeColor(record.type)">{{ getTypeText(record.type) }}</a-tag>
</template>
<template #is_active="{ record }">
<a-switch
:checked="record.is_active"
:disabled="!canEdit"
@change="handleToggleStatus(record)"
/>
<a-switch :checked="record.is_active" :disabled="!canEdit" @change="handleToggleStatus(record)" />
</template>
<template #expression="{ record }">
@@ -97,11 +60,7 @@
</template>
<template #last_run_at="{ record }">
{{
record.last_run_at
? formatDate(record.last_run_at)
: "-"
}}
{{ record.last_run_at ? formatDate(record.last_run_at) : '-' }}
</template>
<template #next_run_at="{ record }">
@@ -121,13 +80,7 @@
</a-tag>
</a-tooltip>
<a-tooltip title="失败次数">
<a-tag
:color="
record.failed_count > 0
? 'error'
: 'default'
"
>
<a-tag :color="record.failed_count > 0 ? 'error' : 'default'">
<CloseCircleOutlined />
{{ record.failed_count || 0 }}
</a-tag>
@@ -137,35 +90,13 @@
<template #action="{ record }">
<a-space>
<a-button
type="link"
size="small"
@click="handleView(record)"
>
<EyeOutlined />查看
</a-button>
<a-button
type="link"
size="small"
@click="handleEdit(record)"
>
<EditOutlined />编辑
</a-button>
<a-popconfirm
title="确定立即执行该任务吗?"
@confirm="handleRun(record)"
>
<a-button type="link" size="small">
<PlayCircleOutlined />执行
</a-button>
<a-button type="link" size="small" @click="handleView(record)"> <EyeOutlined />查看 </a-button>
<a-button type="link" size="small" @click="handleEdit(record)"> <EditOutlined />编辑 </a-button>
<a-popconfirm title="确定立即执行该任务吗?" @confirm="handleRun(record)">
<a-button type="link" size="small"> <PlayCircleOutlined />执行 </a-button>
</a-popconfirm>
<a-popconfirm
title="确定删除该任务吗?"
@confirm="handleDelete(record)"
>
<a-button type="link" size="small" danger>
<DeleteOutlined />删除
</a-button>
<a-popconfirm title="确定删除该任务吗?" @confirm="handleDelete(record)">
<a-button type="link" size="small" danger> <DeleteOutlined />删除 </a-button>
</a-popconfirm>
</a-space>
</template>
@@ -174,311 +105,277 @@
</div>
<!-- 新增/编辑弹窗 -->
<TaskDialog
v-if="dialog.save"
v-model:visible="dialog.save"
:record="currentRecord"
@success="handleSaveSuccess"
/>
<TaskDialog v-if="dialog.save" v-model:visible="dialog.save" :record="currentRecord" @success="handleSaveSuccess" />
<!-- 查看详情弹窗 -->
<TaskDetailDialog
v-if="dialog.detail"
v-model:visible="dialog.detail"
:record="currentRecord"
/>
<TaskDetailDialog v-if="dialog.detail" v-model:visible="dialog.detail" :record="currentRecord" />
</template>
<script setup>
import { ref, reactive } from "vue";
import { message, Modal } from "ant-design-vue";
import {
SearchOutlined,
RedoOutlined,
PlusOutlined,
DeleteOutlined,
EditOutlined,
DownOutlined,
CheckCircleOutlined,
StopOutlined,
EyeOutlined,
PlayCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
} from "@ant-design/icons-vue";
import { useTable } from "@/hooks/useTable";
import systemApi from "@/api/system";
import scTable from "@/components/scTable/index.vue";
import TaskDialog from "./components/TaskDialog.vue";
import TaskDetailDialog from "./components/TaskDetailDialog.vue";
import { ref, reactive } from 'vue'
import { message, Modal } from 'ant-design-vue'
import { SearchOutlined, RedoOutlined, PlusOutlined, DeleteOutlined, EditOutlined, DownOutlined, CheckCircleOutlined, StopOutlined, EyeOutlined, PlayCircleOutlined, ClockCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import { useTable } from '@/hooks/useTable'
import systemApi from '@/api/system'
import scTable from '@/components/scTable/index.vue'
import TaskDialog from './components/TaskDialog.vue'
import TaskDetailDialog from './components/TaskDetailDialog.vue'
// ===== useTable Hook =====
const {
tableRef,
searchForm,
tableData,
loading,
pagination,
selectedRows,
rowSelection,
handleSearch,
handleReset,
handlePaginationChange,
refreshTable,
} = useTable({
api: systemApi.tasks.list.get,
const { tableRef, searchForm, tableData, loading, pagination, selectedRows, rowSelection, handleSearch, handleReset, handlePaginationChange, refreshTable } = useTable({
api: systemApi.task.list.get,
searchForm: {
keyword: "",
keyword: '',
type: undefined,
is_active: undefined,
},
columns: [],
needPagination: true,
needSelection: true,
});
})
// ===== 表格列配置 =====
const rowKey = "id";
const rowKey = 'id'
const columns = [
{ title: "ID", dataIndex: "id", key: "id", width: 80, align: "center" },
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
{
title: "任务名称",
dataIndex: "name",
key: "name",
title: '任务名称',
dataIndex: 'name',
key: 'name',
width: 150,
ellipsis: true,
},
{
title: "任务类型",
dataIndex: "type",
key: "type",
title: '任务类型',
dataIndex: 'type',
key: 'type',
width: 100,
align: "center",
slot: "type",
align: 'center',
slot: 'type',
},
{ title: "命令/类", dataIndex: "command", key: "command", ellipsis: true },
{ title: '命令/类', dataIndex: 'command', key: 'command', ellipsis: true },
{
title: "Cron表达式",
dataIndex: "expression",
key: "expression",
title: 'Cron表达式',
dataIndex: 'expression',
key: 'expression',
width: 120,
align: "center",
slot: "expression",
align: 'center',
slot: 'expression',
},
{
title: "状态",
dataIndex: "is_active",
key: "is_active",
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 80,
align: "center",
slot: "is_active",
align: 'center',
slot: 'is_active',
},
{
title: "上次运行",
dataIndex: "last_run_at",
key: "last_run_at",
title: '上次运行',
dataIndex: 'last_run_at',
key: 'last_run_at',
width: 160,
align: "center",
slot: "last_run_at",
align: 'center',
slot: 'last_run_at',
},
{
title: "下次运行",
dataIndex: "next_run_at",
key: "next_run_at",
title: '下次运行',
dataIndex: 'next_run_at',
key: 'next_run_at',
width: 160,
align: "center",
slot: "next_run_at",
align: 'center',
slot: 'next_run_at',
},
{
title: "统计",
dataIndex: "statistics",
key: "statistics",
title: '统计',
dataIndex: 'statistics',
key: 'statistics',
width: 120,
align: "center",
slot: "statistics",
align: 'center',
slot: 'statistics',
},
{
title: "操作",
dataIndex: "action",
key: "action",
title: '操作',
dataIndex: 'action',
key: 'action',
width: 200,
align: "center",
fixed: "right",
slot: "action",
align: 'center',
fixed: 'right',
slot: 'action',
},
];
]
// ===== 弹窗状态 =====
const dialog = reactive({
save: false,
detail: false,
});
})
const currentRecord = ref(null);
const currentRecord = ref(null)
// ===== 权限控制 =====
const canEdit = ref(true);
const canEdit = ref(true)
// ===== 方法:获取任务类型文本 =====
const getTypeText = (type) => {
const typeMap = {
command: "命令",
job: "任务",
closure: "闭包",
};
return typeMap[type] || type;
};
command: '命令',
job: '任务',
closure: '闭包',
}
return typeMap[type] || type
}
// ===== 方法:获取任务类型颜色 =====
const getTypeColor = (type) => {
const colorMap = {
command: "blue",
job: "green",
closure: "orange",
};
return colorMap[type] || "default";
};
command: 'blue',
job: 'green',
closure: 'orange',
}
return colorMap[type] || 'default'
}
// ===== 方法:格式化日期 =====
const formatDate = (dateStr) => {
if (!dateStr) return "-";
const date = new Date(dateStr);
return date.toLocaleString("zh-CN");
};
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleString('zh-CN')
}
// ===== 方法:切换状态 =====
const handleToggleStatus = async (record) => {
try {
const res = await systemApi.tasks.batchStatus.post({
const res = await systemApi.task.batchStatus.post({
ids: [record.id],
status: record.is_active,
});
})
if (res.code === 200) {
message.success(record.is_active ? "已启用" : "已禁用");
refreshTable();
message.success(record.is_active ? '已启用' : '已禁用')
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
message.error("操作失败");
message.error('操作失败')
}
};
}
// ===== 方法:新增 =====
const handleAdd = () => {
currentRecord.value = null;
dialog.save = true;
};
currentRecord.value = null
dialog.save = true
}
// ===== 方法:编辑 =====
const handleEdit = (record) => {
currentRecord.value = { ...record };
dialog.save = true;
};
currentRecord.value = { ...record }
dialog.save = true
}
// ===== 方法:查看 =====
const handleView = (record) => {
currentRecord.value = { ...record };
dialog.detail = true;
};
currentRecord.value = { ...record }
dialog.detail = true
}
// ===== 方法:删除 =====
const handleDelete = async (record) => {
try {
const res = await systemApi.tasks.delete.delete(record.id);
const res = await systemApi.task.delete.delete(record.id)
if (res.code === 200) {
message.success("删除成功");
refreshTable();
message.success('删除成功')
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
message.error("删除失败");
message.error('删除失败')
}
};
}
// ===== 方法:执行任务 =====
const handleRun = async (record) => {
try {
const res = await systemApi.tasks.run.post(record.id);
const res = await systemApi.task.run.post(record.id)
if (res.code === 200) {
message.success("任务执行成功");
refreshTable();
message.success('任务执行成功')
refreshTable()
} else {
message.error(res.message || "任务执行失败");
message.error(res.message || '任务执行失败')
}
} catch (error) {
message.error("任务执行失败");
message.error('任务执行失败')
}
};
}
// ===== 方法:批量删除 =====
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
message.warning("请选择要删除的任务");
return;
message.warning('请选择要删除的任务')
return
}
Modal.confirm({
title: "确认删除",
title: '确认删除',
content: `确定删除选中的 ${selectedRows.value.length} 个任务吗?`,
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.tasks.batchDelete.post({ ids });
const ids = selectedRows.value.map((item) => item.id)
const res = await systemApi.task.batchDelete.post({ ids })
if (res.code === 200) {
message.success("删除成功");
selectedRows.value = [];
refreshTable();
message.success('删除成功')
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "删除失败");
message.error(res.message || '删除失败')
}
} catch (error) {
message.error("删除失败");
message.error('删除失败')
}
},
});
};
})
}
// ===== 方法:批量更新状态 =====
const handleBatchStatus = (status) => {
if (selectedRows.value.length === 0) {
message.warning("请选择要操作的任务");
return;
message.warning('请选择要操作的任务')
return
}
const statusText = status ? "启用" : "禁用";
const statusText = status ? '启用' : '禁用'
Modal.confirm({
title: `确认${statusText}`,
content: `确定要${statusText}选中的 ${selectedRows.value.length} 个任务吗?`,
onOk: async () => {
try {
const ids = selectedRows.value.map((item) => item.id);
const res = await systemApi.tasks.batchStatus.post({
const ids = selectedRows.value.map((item) => item.id)
const res = await systemApi.task.batchStatus.post({
ids,
status,
});
})
if (res.code === 200) {
message.success(`${statusText}成功`);
selectedRows.value = [];
refreshTable();
message.success(`${statusText}成功`)
selectedRows.value = []
refreshTable()
} else {
message.error(res.message || "操作失败");
message.error(res.message || '操作失败')
}
} catch (error) {
message.error("操作失败");
message.error('操作失败')
}
},
});
};
})
}
// ===== 方法:保存成功回调 =====
const handleSaveSuccess = () => {
dialog.save = false;
refreshTable();
};
dialog.save = false
refreshTable()
}
</script>
<style scoped lang="scss">
@@ -487,7 +384,7 @@ const handleSaveSuccess = () => {
padding: 2px 6px;
background: #f5f5f5;
border-radius: 3px;
font-family: "Consolas", "Monaco", monospace;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
}
@@ -1,136 +1,128 @@
<template>
<scForm
:form-items="formItems"
:initial-values="initialValues"
:loading="loading"
@finish="handleFinish"
@reset="handleReset"
/>
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" @finish="handleFinish" @reset="handleReset" />
</template>
<script setup>
import { ref, computed } from "vue";
import { message } from "ant-design-vue";
import scForm from "@/components/scForm/index.vue";
import api from "@/api/auth";
import { useUserStore } from "@/stores/modules/user";
import { ref, computed } from 'vue'
import { message } from 'ant-design-vue'
import scForm from '@/components/scForm/index.vue'
import api from '@/api/auth'
import { useUserStore } from '@/stores/modules/user'
const props = defineProps({
userInfo: {
type: Object,
default: () => ({}),
},
});
})
const emit = defineEmits(["update"]);
const emit = defineEmits(['update'])
const userStore = useUserStore();
const loading = ref(false);
const userStore = useUserStore()
const loading = ref(false)
// 表单初始值
const initialValues = computed(() => ({
username: props.userInfo.username || "",
real_name: props.userInfo.real_name || "",
phone: props.userInfo.phone || "",
email: props.userInfo.email || "",
}));
username: props.userInfo.username || '',
real_name: props.userInfo.real_name || '',
phone: props.userInfo.phone || '',
email: props.userInfo.email || '',
}))
// 表单项配置
const formItems = [
{
field: "username",
label: "用户名",
type: "input",
field: 'username',
label: '用户名',
type: 'input',
rules: [
{ required: true, message: "请输入用户名", trigger: "blur" },
{ required: true, message: '请输入用户名', trigger: 'blur' },
{
min: 3,
max: 20,
message: "用户名长度在 3 到 20 个字符",
trigger: "blur",
message: '用户名长度在 3 到 20 个字符',
trigger: 'blur',
},
],
},
{
field: "real_name",
label: "真实姓名",
type: "input",
field: 'real_name',
label: '真实姓名',
type: 'input',
required: true,
rules: [
{ required: true, message: "请输入真实姓名", trigger: "blur" },
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{
min: 2,
max: 20,
message: "姓名长度在 2 到 20 个字符",
trigger: "blur",
message: '姓名长度在 2 到 20 个字符',
trigger: 'blur',
},
],
},
{
field: "phone",
label: "手机号",
type: "input",
field: 'phone',
label: '手机号',
type: 'input',
rules: [
{
pattern: /^1[3-9]\d{9}$/,
message: "请输入正确的手机号",
trigger: "blur",
message: '请输入正确的手机号',
trigger: 'blur',
},
],
},
{
field: "email",
label: "邮箱",
type: "input",
rules: [
{ type: "email", message: "请输入正确的邮箱地址", trigger: "blur" },
],
field: 'email',
label: '邮箱',
type: 'input',
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
},
];
]
// 表单提交
const handleFinish = async (values) => {
try {
loading.value = true;
loading.value = true
// 获取当前用户ID
const userId = userStore.userInfo?.id;
const userId = userStore.userInfo?.id
if (!userId) {
throw new Error("用户信息不存在,请重新登录");
throw new Error('用户信息不存在,请重新登录')
}
// 调用更新用户信息接口
const res = await api.users.edit.put(userId, {
const res = await api.user.edit.put(userId, {
username: values.username,
real_name: values.real_name,
phone: values.phone,
email: values.email,
});
})
if (!res || res.code !== 200) {
throw new Error(res.message || "保存失败,请重试");
throw new Error(res.message || '保存失败,请重试')
}
// 重新获取用户信息
const response = await api.me.get();
const response = await api.me.get()
if (response && response.data) {
userStore.setUserInfo(response.data);
emit("update", response.data);
userStore.setUserInfo(response.data)
emit('update', response.data)
}
message.success("保存成功");
message.success('保存成功')
} catch (error) {
message.error(error.message || "保存失败,请重试");
message.error(error.message || '保存失败,请重试')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 重置表单
const handleReset = () => {
message.info("已重置");
};
message.info('已重置')
}
</script>
<style scoped lang="scss"></style>
@@ -1,125 +1,118 @@
<template>
<scForm
:form-items="formItems"
:initial-values="initialValues"
:loading="loading"
submit-text="修改密码"
@finish="handleFinish"
@reset="handleReset"
/>
<scForm :form-items="formItems" :initial-values="initialValues" :loading="loading" submit-text="修改密码" @finish="handleFinish" @reset="handleReset" />
</template>
<script setup>
import { ref } from "vue";
import { message } from "ant-design-vue";
import { useRouter } from "vue-router";
import { useUserStore } from "@/stores/modules/user";
import scForm from "@/components/scForm/index.vue";
import api from "@/api/auth";
import { ref } from 'vue'
import { message } from 'ant-design-vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import scForm from '@/components/scForm/index.vue'
import api from '@/api/auth'
const emit = defineEmits(["success"]);
const router = useRouter();
const userStore = useUserStore();
const loading = ref(false);
const emit = defineEmits(['success'])
const router = useRouter()
const userStore = useUserStore()
const loading = ref(false)
// 表单初始值
const initialValues = ref({
old_password: "",
password: "",
password_confirmation: "",
});
old_password: '',
password: '',
password_confirmation: '',
})
// 表单项配置
const formItems = [
{
field: "old_password",
label: "原密码",
type: "password",
field: 'old_password',
label: '原密码',
type: 'password',
required: true,
rules: [{ required: true, message: "请输入原密码", trigger: "blur" }],
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
},
{
field: "password",
label: "新密码",
type: "password",
field: 'password',
label: '新密码',
type: 'password',
required: true,
rules: [
{ required: true, message: "请输入新密码", trigger: "blur" },
{ required: true, message: '请输入新密码', trigger: 'blur' },
{
min: 6,
max: 20,
message: "密码长度在 6 到 20 个字符",
trigger: "blur",
message: '密码长度在 6 到 20 个字符',
trigger: 'blur',
},
],
},
{
field: "password_confirmation",
label: "确认密码",
type: "password",
field: 'password_confirmation',
label: '确认密码',
type: 'password',
required: true,
dependencies: ["password"],
dependencies: ['password'],
rules: [
{ required: true, message: "请再次输入新密码", trigger: "blur" },
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
{
validator: (_, value) => {
if (!value || !initialValues.value.password) {
return Promise.resolve();
return Promise.resolve()
}
if (value !== initialValues.value.password) {
return Promise.reject("两次输入的密码不一致");
return Promise.reject('两次输入的密码不一致')
}
return Promise.resolve();
return Promise.resolve()
},
trigger: "blur",
trigger: 'blur',
},
],
},
];
]
// 表单提交
const handleFinish = async (values) => {
try {
loading.value = true;
loading.value = true
// 调用修改密码接口
const res = await api.changePassword.post({
old_password: values.old_password,
password: values.password,
password_confirmation: values.password_confirmation,
});
})
if (!res || res.code !== 200) {
throw new Error(res.message || "密码修改失败,请重试");
throw new Error(res.message || '密码修改失败,请重试')
}
message.success("密码修改成功,请重新登录");
message.success('密码修改成功,请重新登录')
// 清除用户信息和token
userStore.logout();
userStore.logout()
// 延迟跳转到登录页
setTimeout(() => {
router.push("/login");
}, 1500);
router.push('/login')
}, 1500)
emit("success");
handleReset();
emit('success')
handleReset()
} catch (error) {
message.error(error.message || "密码修改失败,请重试");
message.error(error.message || '密码修改失败,请重试')
} finally {
loading.value = false;
loading.value = false
}
};
}
// 重置表单
const handleReset = () => {
initialValues.value = {
old_password: "",
password: "",
password_confirmation: "",
};
};
old_password: '',
password: '',
password_confirmation: '',
}
}
</script>
<style scoped lang="scss"></style>
@@ -1,11 +1,7 @@
<template>
<div class="profile-info">
<div class="avatar-wrapper">
<a-avatar
:size="100"
:src="userInfo.avatar"
@click="handleAvatarClick"
>
<a-avatar :size="100" :src="userInfo.avatar" @click="handleAvatarClick">
{{ userInfo.nickname?.charAt(0) }}
</a-avatar>
</div>
@@ -13,7 +9,7 @@
{{ userInfo.nickname || userInfo.username }}
</div>
<a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
{{ userInfo.status === 1 ? "正常" : "禁用" }}
{{ userInfo.status === 1 ? '正常' : '禁用' }}
</a-tag>
</div>
</template>
@@ -24,13 +20,13 @@ const props = defineProps({
type: Object,
default: () => ({}),
},
});
})
const emit = defineEmits(["avatar-click"]);
const emit = defineEmits(['avatar-click'])
const handleAvatarClick = () => {
emit("avatar-click");
};
emit('avatar-click')
}
</script>
<style scoped lang="scss">
@@ -11,11 +11,7 @@
</template>
</a-list-item-meta>
<template #actions>
<a-button
type="primary"
size="small"
@click="handleAction(item.action)"
>
<a-button type="primary" size="small" @click="handleAction(item.action)">
{{ item.buttonText }}
</a-button>
</template>
@@ -25,56 +21,56 @@
</template>
<script setup>
import { ref } from "vue";
import { message } from "ant-design-vue";
import { ref } from 'vue'
import { message } from 'ant-design-vue'
const emit = defineEmits(["change-password"]);
const emit = defineEmits(['change-password'])
const securityList = ref([
{
title: "登录密码",
description: "用于登录系统的密码,建议定期更换",
buttonText: "修改",
action: "password",
title: '登录密码',
description: '用于登录系统的密码,建议定期更换',
buttonText: '修改',
action: 'password',
},
{
title: "手机验证",
description: "用于接收重要通知和安全验证",
buttonText: "已绑定",
action: "phone",
title: '手机验证',
description: '用于接收重要通知和安全验证',
buttonText: '已绑定',
action: 'phone',
},
{
title: "邮箱验证",
description: "用于接收重要通知和账号找回",
buttonText: "已绑定",
action: "email",
title: '邮箱验证',
description: '用于接收重要通知和账号找回',
buttonText: '已绑定',
action: 'email',
},
{
title: "登录设备",
description: "查看和管理已登录的设备",
buttonText: "查看",
action: "device",
title: '登录设备',
description: '查看和管理已登录的设备',
buttonText: '查看',
action: 'device',
},
]);
])
const handleAction = (action) => {
switch (action) {
case "password":
emit("change-password");
break;
case "phone":
message.info("手机绑定功能开发中");
break;
case "email":
message.info("邮箱绑定功能开发中");
break;
case "device":
message.info("登录设备管理功能开发中");
break;
case 'password':
emit('change-password')
break
case 'phone':
message.info('手机绑定功能开发中')
break
case 'email':
message.info('邮箱绑定功能开发中')
break
case 'device':
message.info('登录设备管理功能开发中')
break
default:
break;
break
}
};
}
</script>
<style scoped lang="scss">
+72 -108
View File
@@ -3,15 +3,8 @@
<a-card>
<a-row :gutter="24">
<a-col :span="6">
<ProfileInfo
:user-info="userInfo"
@avatar-click="showAvatarModal = true"
/>
<a-menu
v-model:selectedKeys="selectedKeys"
mode="inline"
class="menu"
>
<ProfileInfo :user-info="userInfo" @avatar-click="showAvatarModal = true" />
<a-menu v-model:selectedKeys="selectedKeys" mode="inline" class="menu">
<a-menu-item key="basic">
<UserOutlined />
基本信息
@@ -28,49 +21,25 @@
</a-col>
<a-col :span="18">
<div class="content-wrapper">
<BasicInfo
v-if="selectedKeys[0] === 'basic'"
:user-info="userInfo"
@update="handleUpdateUserInfo"
/>
<Password
v-else-if="selectedKeys[0] === 'password'"
@success="handlePasswordSuccess"
/>
<Security
v-else-if="selectedKeys[0] === 'security'"
@change-password="handleChangePassword"
/>
<BasicInfo v-if="selectedKeys[0] === 'basic'" :user-info="userInfo" @update="handleUpdateUserInfo" />
<Password v-else-if="selectedKeys[0] === 'password'" @success="handlePasswordSuccess" />
<Security v-else-if="selectedKeys[0] === 'security'" @change-password="handleChangePassword" />
</div>
</a-col>
</a-row>
</a-card>
<!-- 头像上传弹窗 -->
<a-modal
v-model:open="showAvatarModal"
title="更换头像"
:confirm-loading="loading"
@ok="handleAvatarUpload"
@cancel="showAvatarModal = false"
>
<a-modal v-model:open="showAvatarModal" title="更换头像" :confirm-loading="loading" @ok="handleAvatarUpload" @cancel="showAvatarModal = false">
<div class="avatar-upload">
<a-upload
list-type="picture-card"
:max-count="1"
:before-upload="beforeUpload"
@change="handleAvatarChange"
:file-list="avatarFileList"
>
<a-upload list-type="picture-card" :max-count="1" :before-upload="beforeUpload" @change="handleAvatarChange" :file-list="avatarFileList">
<div v-if="avatarFileList.length === 0">
<PlusOutlined />
<div class="ant-upload-text">上传头像</div>
</div>
</a-upload>
<div class="upload-tip">
<a-typography-text type="secondary">
支持 JPG、PNG 格式,文件大小不超过 2MB
</a-typography-text>
<a-typography-text type="secondary"> 支持 JPG、PNG 格式,文件大小不超过 2MB </a-typography-text>
</div>
</div>
</a-modal>
@@ -78,152 +47,147 @@
</template>
<script setup>
import { ref, onMounted } from "vue";
import { message } from "ant-design-vue";
import {
PlusOutlined,
UserOutlined,
LockOutlined,
SafetyOutlined,
} from "@ant-design/icons-vue";
import dayjs from "dayjs";
import ProfileInfo from "./components/ProfileInfo.vue";
import BasicInfo from "./components/BasicInfo.vue";
import Password from "./components/Password.vue";
import Security from "./components/Security.vue";
import { useUserStore } from "@/stores/modules/user";
import api from "@/api/auth";
import { ref, onMounted } from 'vue'
import { message } from 'ant-design-vue'
import { PlusOutlined, UserOutlined, LockOutlined, SafetyOutlined } from '@ant-design/icons-vue'
import dayjs from 'dayjs'
import ProfileInfo from './components/ProfileInfo.vue'
import BasicInfo from './components/BasicInfo.vue'
import Password from './components/Password.vue'
import Security from './components/Security.vue'
import { useUserStore } from '@/stores/modules/user'
import api from '@/api/auth'
defineOptions({
name: "UserCenter",
});
name: 'UserCenter',
})
const userStore = useUserStore();
const userStore = useUserStore()
// 用户信息
const userInfo = ref({});
const userInfo = ref({})
// 选中的菜单
const selectedKeys = ref(["basic"]);
const selectedKeys = ref(['basic'])
// 头像上传
const showAvatarModal = ref(false);
const avatarFileList = ref([]);
const loading = ref(false);
const showAvatarModal = ref(false)
const avatarFileList = ref([])
const loading = ref(false)
// 初始化用户信息
const initUserInfo = async () => {
try {
// 从 store 获取用户信息
const storeUserInfo = userStore.userInfo;
const storeUserInfo = userStore.userInfo
if (storeUserInfo) {
userInfo.value = storeUserInfo;
userInfo.value = storeUserInfo
} else {
// 如果 store 中没有用户信息,则从接口获取
const response = await api.me.get();
const response = await api.me.get()
if (response && response.data) {
userStore.setUserInfo(response.data);
userInfo.value = response.data;
userStore.setUserInfo(response.data)
userInfo.value = response.data
}
}
} catch (err) {
message.error(err.message || "获取用户信息失败");
message.error(err.message || '获取用户信息失败')
}
};
}
// 更新用户信息
const handleUpdateUserInfo = (data) => {
// 更新本地用户信息
Object.assign(userInfo.value, data);
Object.assign(userInfo.value, data)
// 如果 birthday 有值,转换为 dayjs 对象
if (data.birthday && typeof data.birthday === "string") {
userInfo.value.birthday = dayjs(data.birthday);
if (data.birthday && typeof data.birthday === 'string') {
userInfo.value.birthday = dayjs(data.birthday)
}
};
}
// 密码修改成功
const handlePasswordSuccess = () => {
// 密码修改成功后的处理
};
}
// 切换到密码修改页面
const handleChangePassword = () => {
selectedKeys.value = ["password"];
};
selectedKeys.value = ['password']
}
// 头像上传前校验
const beforeUpload = (file) => {
const isJpgOrPng = file.type === "image/jpeg" || file.type === "image/png";
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
if (!isJpgOrPng) {
message.error("只能上传 JPG/PNG 格式的文件!");
return false;
message.error('只能上传 JPG/PNG 格式的文件!')
return false
}
const isLt2M = file.size / 1024 / 1024 < 2;
const isLt2M = file.size / 1024 / 1024 < 2
if (!isLt2M) {
message.error("图片大小不能超过 2MB!");
return false;
message.error('图片大小不能超过 2MB!')
return false
}
return false; // 阻止自动上传
};
return false // 阻止自动上传
}
// 头像文件变化
const handleAvatarChange = ({ fileList }) => {
avatarFileList.value = fileList;
};
avatarFileList.value = fileList
}
// 上传头像
const handleAvatarUpload = async () => {
if (avatarFileList.value.length === 0) {
message.warning("请先选择头像");
return;
message.warning('请先选择头像')
return
}
try {
loading.value = true;
const file = avatarFileList.value[0].originFileObj;
loading.value = true
const file = avatarFileList.value[0].originFileObj
// 调用上传接口
const uploadRes = await api.upload.post(file);
const uploadRes = await api.upload.post(file)
if (!uploadRes || uploadRes.code !== 200) {
throw new Error(uploadRes.message || "头像上传失败");
throw new Error(uploadRes.message || '头像上传失败')
}
// 获取当前用户ID
const userId = userStore.userInfo?.id;
const userId = userStore.userInfo?.id
if (!userId) {
throw new Error("用户信息不存在,请重新登录");
throw new Error('用户信息不存在,请重新登录')
}
// 更新用户头像
const updateRes = await api.users.edit.put(userId, {
const updateRes = await api.user.edit.put(userId, {
avatar: uploadRes.data.url,
});
})
if (!updateRes || updateRes.code !== 200) {
throw new Error(updateRes.message || "头像更新失败");
throw new Error(updateRes.message || '头像更新失败')
}
// 重新获取用户信息
const response = await api.me.get();
const response = await api.me.get()
if (response && response.data) {
userStore.setUserInfo(response.data);
userInfo.value = response.data;
userStore.setUserInfo(response.data)
userInfo.value = response.data
}
message.success("头像更新成功");
showAvatarModal.value = false;
avatarFileList.value = [];
message.success('头像更新成功')
showAvatarModal.value = false
avatarFileList.value = []
} catch (error) {
message.error(error.message || "头像上传失败,请重试");
message.error(error.message || '头像上传失败,请重试')
} finally {
loading.value = false;
loading.value = false
}
};
}
onMounted(() => {
initUserInfo();
});
initUserInfo()
})
</script>
<style scoped lang="scss">
+63 -65
View File
@@ -1,40 +1,40 @@
import { createRouter, createWebHashHistory } from "vue-router";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import config from "../config";
import { useUserStore } from "../stores/modules/user";
import systemRoutes from "./systemRoutes";
import { createRouter, createWebHashHistory } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import config from '../config'
import { useUserStore } from '../stores/modules/user'
import systemRoutes from './systemRoutes'
// 配置 NProgress
NProgress.configure({
showSpinner: false,
trickleSpeed: 200,
minimum: 0.3,
});
})
/**
* 404 路由
*/
const notFoundRoute = {
path: "/:pathMatch(.*)*",
name: "NotFound",
component: () => import("../layouts/other/404.vue"),
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import('../layouts/other/404.vue'),
meta: {
title: "404",
title: '404',
hidden: true,
},
};
}
// 创建路由实例
const router = createRouter({
history: createWebHashHistory(),
routes: systemRoutes,
});
})
/**
* 组件导入映射
*/
const modules = import.meta.glob("../pages/**/*.vue");
const modules = import.meta.glob('../pages/**/*.vue')
/**
* 动态加载组件
@@ -43,16 +43,16 @@ const modules = import.meta.glob("../pages/**/*.vue");
*/
function loadComponent(componentPath) {
// 如果组件路径以 'views/' 或 'pages/' 开头,则从相应目录加载
if (componentPath.startsWith("views/")) {
const path = componentPath.replace("views/", "../pages/");
return modules[`${path}.vue`];
if (componentPath.startsWith('views/')) {
const path = componentPath.replace('views/', '../pages/')
return modules[`${path}.vue`]
}
// 如果是简单的组件名称,从 pages 目录加载
if (componentPath.endsWith("index")) {
return modules[`../pages/${componentPath}.vue`];
if (componentPath.endsWith('index')) {
return modules[`../pages/${componentPath}.vue`]
} else {
return modules[`../pages/${componentPath}/index.vue`];
return modules[`../pages/${componentPath}/index.vue`]
}
}
@@ -63,7 +63,7 @@ function loadComponent(componentPath) {
*/
function transformMenusToRoutes(menus) {
if (!menus || !Array.isArray(menus)) {
return [];
return []
}
return menus
@@ -71,7 +71,7 @@ function transformMenusToRoutes(menus) {
.map((menu) => {
const route = {
path: menu.path,
name: menu.name || menu.path.replace(/\//g, "-"),
name: menu.name || menu.path.replace(/\//g, '-'),
meta: {
title: menu.meta?.title || menu.title,
icon: menu.meta?.icon || menu.icon,
@@ -80,130 +80,128 @@ function transformMenusToRoutes(menus) {
affix: menu.meta?.affix || 0,
role: menu.meta?.role || [],
},
};
}
// 处理组件
if (menu.component) {
route.component = loadComponent(menu.component);
route.component = loadComponent(menu.component)
}
// 处理子路由
if (menu.children && menu.children.length > 0) {
route.children = transformMenusToRoutes(menu.children);
route.children = transformMenusToRoutes(menu.children)
}
// 处理重定向
if (menu.redirect) {
route.redirect = menu.redirect;
route.redirect = menu.redirect
}
return route;
});
return route
})
}
/**
* 路由守卫
*/
let isDynamicRouteLoaded = false;
let isDynamicRouteLoaded = false
router.beforeEach(async (to, from, next) => {
// 开始进度条
NProgress.start();
NProgress.start()
// 设置页面标题
document.title = to.meta.title
? `${to.meta.title} - ${config.APP_NAME}`
: config.APP_NAME;
document.title = to.meta.title ? `${to.meta.title} - ${config.APP_NAME}` : config.APP_NAME
const userStore = useUserStore();
const isLoggedIn = userStore.isLoggedIn();
const whiteList = config.whiteList || [];
const userStore = useUserStore()
const isLoggedIn = userStore.isLoggedIn()
const whiteList = config.whiteList || []
// 1. 如果在白名单中,直接放行
if (whiteList.includes(to.path)) {
next();
return;
next()
return
}
// 2. 如果未登录,跳转到登录页
if (!isLoggedIn) {
// 保存目标路由,登录后跳转
next({
path: "/login",
path: '/login',
query: { redirect: to.fullPath },
});
return;
})
return
}
// 3. 已登录情况
// 如果访问登录页,重定向到首页
if (to.path === "/login") {
next({ path: config.DASHBOARD_URL });
return;
if (to.path === '/login') {
next({ path: config.DASHBOARD_URL })
return
}
// 4. 动态路由加载
if (!isDynamicRouteLoaded) {
try {
// 获取后端返回的用户菜单
const mergedMenus = userStore.getMenu();
const mergedMenus = userStore.getMenu()
if (mergedMenus && mergedMenus.length > 0) {
// 将合并后的菜单转换为路由
const dynamicRoutes = transformMenusToRoutes(mergedMenus);
const dynamicRoutes = transformMenusToRoutes(mergedMenus)
// 添加动态路由到 Layout 的子路由
dynamicRoutes.forEach((route) => {
router.addRoute("Layout", route);
});
router.addRoute('Layout', route)
})
// 添加 404 路由(必须在最后添加)
router.addRoute(notFoundRoute);
router.addRoute(notFoundRoute)
isDynamicRouteLoaded = true;
isDynamicRouteLoaded = true
// 重新导航,确保新添加的路由被正确匹配
next({ ...to, replace: true });
next({ ...to, replace: true })
} else {
// 如果没有菜单数据,重置并跳转到登录页
userStore.logout();
next({ path: "/login", query: { redirect: to.fullPath } });
userStore.logout()
next({ path: '/login', query: { redirect: to.fullPath } })
}
} catch (error) {
console.error("动态路由加载失败:", error);
console.error('动态路由加载失败:', error)
// 加载失败,清除用户信息并跳转到登录页
userStore.logout();
userStore.logout()
next({
path: "/login",
path: '/login',
query: { redirect: to.fullPath },
});
})
}
} else {
// 动态路由已加载,直接放行
next();
next()
}
});
})
router.afterEach(() => {
// 结束进度条
NProgress.done();
});
NProgress.done()
})
/**
* 重置路由(用于登出时)
*/
export function resetRouter() {
// 移除所有动态添加的路由
isDynamicRouteLoaded = false;
isDynamicRouteLoaded = false
// 重置为初始路由
const newRouter = createRouter({
history: createWebHashHistory(),
routes: systemRoutes,
});
})
router.matcher = newRouter.matcher;
router.matcher = newRouter.matcher
}
export default router;
export default router
+18 -18
View File
@@ -1,43 +1,43 @@
import config from "@/config";
import config from '@/config'
/**
* 基础路由(不需要登录)
*/
const systemRoutes = [
{
path: "/login",
name: "Login",
component: () => import("../pages/login/index.vue"),
path: '/login',
name: 'Login',
component: () => import('../pages/login/index.vue'),
meta: {
title: "login",
title: 'login',
hidden: true,
},
},
{
path: "/register",
name: "Register",
component: () => import("../pages/login/userRegister.vue"),
path: '/register',
name: 'Register',
component: () => import('../pages/login/userRegister.vue'),
meta: {
title: "register",
title: 'register',
hidden: true,
},
},
{
path: "/reset-password",
name: "ResetPassword",
component: () => import("../pages/login/resetPassword.vue"),
path: '/reset-password',
name: 'ResetPassword',
component: () => import('../pages/login/resetPassword.vue'),
meta: {
title: "resetPassword",
title: 'resetPassword',
hidden: true,
},
},
{
path: "/",
name: "Layout",
component: () => import("@/layouts/index.vue"),
path: '/',
name: 'Layout',
component: () => import('@/layouts/index.vue'),
redirect: config.DASHBOARD_URL,
children: [],
},
];
]
export default systemRoutes;
export default systemRoutes
+5 -5
View File
@@ -1,9 +1,9 @@
import { createPinia } from "pinia";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
const pinia = createPinia();
const pinia = createPinia()
// 注册持久化插件
pinia.use(piniaPluginPersistedstate);
pinia.use(piniaPluginPersistedstate)
export default pinia;
export default pinia
@@ -1,21 +1,21 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { customStorage } from "../persist";
import systemApi from "@/api/system";
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import systemApi from '@/api/system'
export const useDictionaryStore = defineStore(
"dictionary",
'dictionary',
() => {
// 字典数据缓存(按 code 缓存字典项列表)
const dictionaries = ref({});
const dictionaries = ref({})
// 字典元数据缓存(按 code 缓存字典信息)
const dictionaryMeta = ref({});
const dictionaryMeta = ref({})
// 字典ID到Code的映射(用于通过ID清除缓存)
const dictionaryIdToCodeMap = ref({});
const dictionaryIdToCodeMap = ref({})
// 字典数据加载状态
const loading = ref(false);
const loading = ref(false)
// 最后加载时间
const lastLoadTime = ref(null);
const lastLoadTime = ref(null)
/**
* 加载所有字典数据
@@ -23,41 +23,41 @@ export const useDictionaryStore = defineStore(
async function loadAllDictionaries(forceRefresh = false) {
// 如果已加载且不是强制刷新,直接返回
if (!forceRefresh && Object.keys(dictionaries.value).length > 0) {
return;
return
}
if (loading.value) return;
if (loading.value) return
loading.value = true;
loading.value = true
try {
const res = await systemApi.dictionaryItems.all.get();
const res = await systemApi.dictionaryItem.all.get()
if (res.code === 200 && res.data) {
// 将字典数据按 code 缓存
const dictMap = {};
const metaMap = {};
const dictMap = {}
const metaMap = {}
res.data.forEach((dict) => {
if (dict.code) {
// 缓存字典项列表
dictMap[dict.code] = dict.items || [];
dictMap[dict.code] = dict.items || []
// 缓存字典元数据(包含id以便通过id清除缓存)
metaMap[dict.code] = {
id: dict.code, // 使用code作为唯一标识
name: dict.name,
code: dict.code,
description: dict.description,
};
}
}
});
})
dictionaries.value = dictMap;
dictionaryMeta.value = metaMap;
lastLoadTime.value = new Date().getTime();
dictionaries.value = dictMap
dictionaryMeta.value = metaMap
lastLoadTime.value = new Date().getTime()
}
} catch (error) {
console.error("加载字典数据失败:", error);
console.error('加载字典数据失败:', error)
} finally {
loading.value = false;
loading.value = false
}
}
@@ -69,15 +69,11 @@ export const useDictionaryStore = defineStore(
*/
async function getDictionary(code, forceRefresh = false) {
// 如果缓存为空或强制刷新,则重新加载所有字典
if (
!dictionaries.value ||
Object.keys(dictionaries.value).length === 0 ||
forceRefresh
) {
await loadAllDictionaries();
if (!dictionaries.value || Object.keys(dictionaries.value).length === 0 || forceRefresh) {
await loadAllDictionaries()
}
return dictionaries.value[code] || [];
return dictionaries.value[code] || []
}
/**
@@ -88,19 +84,15 @@ export const useDictionaryStore = defineStore(
*/
async function getDictionaries(codes, forceRefresh = false) {
// 如果缓存为空或强制刷新,则重新加载所有字典
if (
!dictionaries.value ||
Object.keys(dictionaries.value).length === 0 ||
forceRefresh
) {
await loadAllDictionaries();
if (!dictionaries.value || Object.keys(dictionaries.value).length === 0 || forceRefresh) {
await loadAllDictionaries()
}
const result = {};
const result = {}
codes.forEach((code) => {
result[code] = dictionaries.value[code] || [];
});
return result;
result[code] = dictionaries.value[code] || []
})
return result
}
/**
@@ -110,21 +102,21 @@ export const useDictionaryStore = defineStore(
* @returns {string} 字典标签
*/
function getLabelByValue(code, value) {
const dict = dictionaries.value[code];
if (!dict) return value;
const dict = dictionaries.value[code]
if (!dict) return value
const item = dict.find((item) => item.value === value);
return item ? item.label : value;
const item = dict.find((item) => item.value === value)
return item ? item.label : value
}
/**
* 清空字典缓存
*/
function clearCache() {
dictionaries.value = {};
dictionaryMeta.value = {};
dictionaryIdToCodeMap.value = {};
lastLoadTime.value = null;
dictionaries.value = {}
dictionaryMeta.value = {}
dictionaryIdToCodeMap.value = {}
lastLoadTime.value = null
}
/**
@@ -133,22 +125,19 @@ export const useDictionaryStore = defineStore(
*/
function clearDictionary(dictionaryIdOrCode) {
// 如果传入的是字典ID,需要先找到对应的code
let code = dictionaryIdOrCode;
let code = dictionaryIdOrCode
if (typeof dictionaryIdOrCode === "number") {
if (typeof dictionaryIdOrCode === 'number') {
// 直接从ID映射表中查找
code = dictionaryIdToCodeMap.value[dictionaryIdOrCode];
code = dictionaryIdToCodeMap.value[dictionaryIdOrCode]
}
// 删除对应字典的缓存
if (code && dictionaries.value[code]) {
delete dictionaries.value[code];
delete dictionaryMeta.value[code];
if (
dictionaryIdOrCode &&
typeof dictionaryIdOrCode === "number"
) {
delete dictionaryIdToCodeMap.value[dictionaryIdOrCode];
delete dictionaries.value[code]
delete dictionaryMeta.value[code]
if (dictionaryIdOrCode && typeof dictionaryIdOrCode === 'number') {
delete dictionaryIdToCodeMap.value[dictionaryIdOrCode]
}
}
}
@@ -160,9 +149,9 @@ export const useDictionaryStore = defineStore(
function buildIdToCodeMap(dictionaryList) {
dictionaryList.forEach((dict) => {
if (dict.id && dict.code) {
dictionaryIdToCodeMap.value[dict.id] = dict.code;
dictionaryIdToCodeMap.value[dict.id] = dict.code
}
});
})
}
/**
@@ -170,7 +159,7 @@ export const useDictionaryStore = defineStore(
* @param {boolean} force 是否强制刷新
*/
async function refresh(force = true) {
await loadAllDictionaries(force);
await loadAllDictionaries(force)
}
/**
@@ -180,7 +169,7 @@ export const useDictionaryStore = defineStore(
return {
count: Object.keys(dictionaries.value).length,
lastLoadTime: lastLoadTime.value,
};
}
}
return {
@@ -198,13 +187,13 @@ export const useDictionaryStore = defineStore(
buildIdToCodeMap,
refresh,
getCacheInfo,
};
}
},
{
persist: {
key: "dictionary-store",
key: 'dictionary-store',
storage: customStorage,
pick: ["dictionaries", "lastLoadTime"],
pick: ['dictionaries', 'lastLoadTime'],
},
},
);
)
+14 -16
View File
@@ -1,35 +1,33 @@
import { defineStore } from "pinia";
import i18n from "@/i18n";
import { customStorage } from "../persist";
import { defineStore } from 'pinia'
import i18n from '@/i18n'
import { customStorage } from '../persist'
export const useI18nStore = defineStore("i18n", {
export const useI18nStore = defineStore('i18n', {
state: () => ({
currentLocale: "zh-CN",
currentLocale: 'zh-CN',
availableLocales: [
{ label: "简体中文", value: "zh-CN" },
{ label: "English", value: "en-US" },
{ label: '简体中文', value: 'zh-CN' },
{ label: 'English', value: 'en-US' },
],
}),
getters: {
localeLabel: (state) => {
const locale = state.availableLocales.find(
(item) => item.value === state.currentLocale,
);
return locale ? locale.label : "";
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
return locale ? locale.label : ''
},
},
actions: {
setLocale(locale) {
this.currentLocale = locale;
i18n.global.locale.value = locale;
this.currentLocale = locale
i18n.global.locale.value = locale
},
},
persist: {
key: "i18n-store",
key: 'i18n-store',
storage: customStorage,
pick: ["currentLocale"],
pick: ['currentLocale'],
},
});
})
+47 -67
View File
@@ -1,115 +1,102 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { customStorage } from "../persist";
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { customStorage } from '../persist'
export const useLayoutStore = defineStore(
"layout",
'layout',
() => {
// 布局模式:'default', 'menu', 'top'
const layoutMode = ref("default");
const layoutMode = ref('default')
// 侧边栏折叠状态
const sidebarCollapsed = ref(false);
const sidebarCollapsed = ref(false)
// 主题颜色
const themeColor = ref("#1890ff");
const themeColor = ref('#1890ff')
// 显示标签栏
const showTags = ref(true);
const showTags = ref(true)
// 显示面包屑
const showBreadcrumb = ref(true);
const showBreadcrumb = ref(true)
// 当前选中的父菜单(用于双栏布局)
const selectedParentMenu = ref(null);
const selectedParentMenu = ref(null)
// 视图标签页(用于记录页面滚动位置)
const viewTags = ref([]);
const viewTags = ref([])
// 刷新标签的 key,用于触发组件刷新
const refreshKey = ref(0);
const refreshKey = ref(0)
// 切换侧边栏折叠
const toggleSidebar = () => {
sidebarCollapsed.value = !sidebarCollapsed.value;
};
sidebarCollapsed.value = !sidebarCollapsed.value
}
// 设置选中的父菜单
const setSelectedParentMenu = (menu) => {
selectedParentMenu.value = menu;
};
selectedParentMenu.value = menu
}
// 设置布局模式
const setLayoutMode = (mode) => {
layoutMode.value = mode;
};
layoutMode.value = mode
}
// 更新视图标签
const updateViewTags = (tag) => {
const index = viewTags.value.findIndex(
(item) => item.fullPath === tag.fullPath,
);
const index = viewTags.value.findIndex((item) => item.fullPath === tag.fullPath)
if (index !== -1) {
viewTags.value[index] = tag;
viewTags.value[index] = tag
} else {
viewTags.value.push(tag);
viewTags.value.push(tag)
}
};
}
// 移除视图标签
const removeViewTags = (fullPath) => {
const index = viewTags.value.findIndex(
(item) => item.fullPath === fullPath,
);
const index = viewTags.value.findIndex((item) => item.fullPath === fullPath)
if (index !== -1) {
viewTags.value.splice(index, 1);
viewTags.value.splice(index, 1)
}
};
}
// 清空视图标签
const clearViewTags = () => {
viewTags.value = [];
};
viewTags.value = []
}
// 设置主题颜色
const setThemeColor = (color) => {
themeColor.value = color;
document.documentElement.style.setProperty(
"--primary-color",
color,
);
};
themeColor.value = color
document.documentElement.style.setProperty('--primary-color', color)
}
// 设置标签栏显示
const setShowTags = (show) => {
showTags.value = show;
document.documentElement.style.setProperty(
"--show-tags",
show ? "block" : "none",
);
};
showTags.value = show
document.documentElement.style.setProperty('--show-tags', show ? 'block' : 'none')
}
// 设置面包屑显示
const setShowBreadcrumb = (show) => {
showBreadcrumb.value = show;
};
showBreadcrumb.value = show
}
// 刷新标签
const refreshTag = () => {
refreshKey.value++;
};
refreshKey.value++
}
// 重置主题设置
const resetTheme = () => {
themeColor.value = "#1890ff";
showTags.value = true;
showBreadcrumb.value = true;
document.documentElement.style.setProperty(
"--primary-color",
"#1890ff",
);
document.documentElement.style.setProperty("--show-tags", "block");
};
themeColor.value = '#1890ff'
showTags.value = true
showBreadcrumb.value = true
document.documentElement.style.setProperty('--primary-color', '#1890ff')
document.documentElement.style.setProperty('--show-tags', 'block')
}
return {
layoutMode,
@@ -131,20 +118,13 @@ export const useLayoutStore = defineStore(
setShowBreadcrumb,
resetTheme,
refreshTag,
};
}
},
{
persist: {
key: "layout-store",
key: 'layout-store',
storage: customStorage,
pick: [
"layoutMode",
"sidebarCollapsed",
"themeColor",
"showTags",
"showBreadcrumb",
"viewTags",
],
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'],
},
},
);
)
+97 -105
View File
@@ -1,52 +1,52 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { customStorage } from "../persist";
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
/**
* 消息类型枚举
*/
export const MessageType = {
NOTIFICATION: "notification", // 系统通知
TASK: "task", // 任务提醒
WARNING: "warning", // 警告
ERROR: "error", // 错误
SUCCESS: "success", // 成功
INFO: "info", // 信息
};
NOTIFICATION: 'notification', // 系统通知
TASK: 'task', // 任务提醒
WARNING: 'warning', // 警告
ERROR: 'error', // 错误
SUCCESS: 'success', // 成功
INFO: 'info', // 信息
}
/**
* 消息优先级枚举
*/
export const MessagePriority = {
LOW: "low",
MEDIUM: "medium",
HIGH: "high",
URGENT: "urgent",
};
LOW: 'low',
MEDIUM: 'medium',
HIGH: 'high',
URGENT: 'urgent',
}
export const useMessageStore = defineStore(
"message",
'message',
() => {
// 消息列表
const messages = ref([]);
const messages = ref([])
// 最大消息数量
const maxMessages = 100;
const maxMessages = 100
// 获取未读消息数量
const unreadCount = computed(() => {
return messages.value.filter((m) => !m.read).length;
});
return messages.value.filter((m) => !m.read).length
})
// 获取所有消息数量
const totalCount = computed(() => {
return messages.value.length;
});
return messages.value.length
})
// 根据类型获取消息数量
const getCountByType = (type) => {
return messages.value.filter((m) => m.type === type).length;
};
return messages.value.filter((m) => m.type === type).length
}
// 添加消息
function addMessage(message) {
@@ -54,193 +54,185 @@ export const useMessageStore = defineStore(
id: Date.now() + Math.random(),
type: MessageType.NOTIFICATION,
priority: MessagePriority.MEDIUM,
title: "",
content: "",
title: '',
content: '',
read: false,
timestamp: Date.now(),
...message,
};
}
// 添加到列表开头
messages.value.unshift(newMessage);
messages.value.unshift(newMessage)
// 限制消息数量
if (messages.value.length > maxMessages) {
messages.value = messages.value.slice(0, maxMessages);
messages.value = messages.value.slice(0, maxMessages)
}
// 持久化到 localStorage
persistMessages();
persistMessages()
return newMessage;
return newMessage
}
// 批量添加消息
function addMessages(newMessages) {
newMessages.forEach((msg) => addMessage(msg));
newMessages.forEach((msg) => addMessage(msg))
}
// 标记消息为已读
function markAsRead(messageId) {
const message = messages.value.find((m) => m.id === messageId);
const message = messages.value.find((m) => m.id === messageId)
if (message) {
message.read = true;
persistMessages();
message.read = true
persistMessages()
}
}
// 标记所有消息为已读
function markAllAsRead() {
messages.value.forEach((m) => {
m.read = true;
});
persistMessages();
m.read = true
})
persistMessages()
}
// 删除消息
function removeMessage(messageId) {
const index = messages.value.findIndex((m) => m.id === messageId);
const index = messages.value.findIndex((m) => m.id === messageId)
if (index !== -1) {
messages.value.splice(index, 1);
persistMessages();
messages.value.splice(index, 1)
persistMessages()
}
}
// 清空所有消息
function clearAll() {
messages.value = [];
persistMessages();
messages.value = []
persistMessages()
}
// 清空已读消息
function clearRead() {
messages.value = messages.value.filter((m) => !m.read);
persistMessages();
messages.value = messages.value.filter((m) => !m.read)
persistMessages()
}
// 根据类型清空消息
function clearByType(type) {
messages.value = messages.value.filter((m) => m.type !== type);
persistMessages();
messages.value = messages.value.filter((m) => m.type !== type)
persistMessages()
}
// 格式化消息时间
function formatMessageTime(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const now = Date.now()
const diff = now - timestamp
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return "刚刚";
return '刚刚'
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`;
return `${Math.floor(diff / minute)}分钟前`
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`;
return `${Math.floor(diff / hour)}小时前`
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`;
return `${Math.floor(diff / day)}天前`
} else {
const date = new Date(timestamp);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
}
// 持久化消息到 localStorage
function persistMessages() {
try {
localStorage.setItem(
"message-store",
JSON.stringify(messages.value),
);
localStorage.setItem('message-store', JSON.stringify(messages.value))
} catch (error) {
console.error("持久化消息失败:", error);
console.error('持久化消息失败:', error)
}
}
// 从 localStorage 恢复消息
function restoreMessages() {
try {
const stored = localStorage.getItem("message-store");
const stored = localStorage.getItem('message-store')
if (stored) {
messages.value = JSON.parse(stored);
messages.value = JSON.parse(stored)
}
} catch (error) {
console.error("恢复消息失败:", error);
messages.value = [];
console.error('恢复消息失败:', error)
messages.value = []
}
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data) {
const { type, title, message, content, ...extra } = data;
const { type, title, message, content, ...extra } = data
addMessage({
type: type || MessageType.NOTIFICATION,
title: title || "系统通知",
content: message || content || "",
title: title || '系统通知',
content: message || content || '',
...extra,
});
})
}
// 处理数据更新消息
function handleDataUpdate(data) {
const { resource_type, action } = data;
const { resource_type, action } = data
let title = "数据更新";
let content = "";
let title = '数据更新'
let content = ''
switch (action) {
case "create":
title = "新建成功";
content = `新的${resource_type}已创建`;
break;
case "update":
title = "更新成功";
content = `${resource_type}数据已更新`;
break;
case "delete":
title = "删除成功";
content = `${resource_type}数据已删除`;
break;
case 'create':
title = '新建成功'
content = `新的${resource_type}已创建`
break
case 'update':
title = '更新成功'
content = `${resource_type}数据已更新`
break
case 'delete':
title = '删除成功'
content = `${resource_type}数据已删除`
break
default:
content = `${resource_type}数据已${action}`;
content = `${resource_type}数据已${action}`
}
addMessage({
type: MessageType.SUCCESS,
title,
content,
});
})
}
// 获取消息列表(带分页)
function getMessages(options = {}) {
const {
page = 1,
pageSize = 20,
type = null,
read = null,
} = options;
const { page = 1, pageSize = 20, type = null, read = null } = options
let filtered = [...messages.value];
let filtered = [...messages.value]
// 按类型过滤
if (type) {
filtered = filtered.filter((m) => m.type === type);
filtered = filtered.filter((m) => m.type === type)
}
// 按已读状态过滤
if (read !== null) {
filtered = filtered.filter((m) => m.read === read);
filtered = filtered.filter((m) => m.read === read)
}
// 分页
const start = (page - 1) * pageSize;
const end = start + pageSize;
const list = filtered.slice(start, end);
const total = filtered.length;
const start = (page - 1) * pageSize
const end = start + pageSize
const list = filtered.slice(start, end)
const total = filtered.length
return {
list,
@@ -248,7 +240,7 @@ export const useMessageStore = defineStore(
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
};
}
}
return {
@@ -271,13 +263,13 @@ export const useMessageStore = defineStore(
getMessages,
getCountByType,
restoreMessages,
};
}
},
{
persist: {
key: "message-store",
key: 'message-store',
storage: customStorage,
pick: [], // 不自动持久化,使用自定义 persistMessages 方法
},
},
);
)
+181 -193
View File
@@ -1,414 +1,402 @@
import { ref, computed } from "vue";
import { defineStore } from "pinia";
import { customStorage } from "../persist";
import systemApi from "@/api/system";
import { message } from "ant-design-vue";
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { customStorage } from '../persist'
import systemApi from '@/api/system'
import { message } from 'ant-design-vue'
/**
* 通知类型枚举
*/
export const NotificationType = {
INFO: "info", // 信息
SUCCESS: "success", // 成功
WARNING: "warning", // 警告
ERROR: "error", // 错误
TASK: "task", // 任务
SYSTEM: "system", // 系统
};
INFO: 'info', // 信息
SUCCESS: 'success', // 成功
WARNING: 'warning', // 警告
ERROR: 'error', // 错误
TASK: 'task', // 任务
SYSTEM: 'system', // 系统
}
/**
* 通知分类枚举
*/
export const NotificationCategory = {
SYSTEM: "system", // 系统通知
TASK: "task", // 任务通知
MESSAGE: "message", // 消息通知
REMINDER: "reminder", // 提醒通知
ANNOUNCEMENT: "announcement", // 公告通知
};
SYSTEM: 'system', // 系统通知
TASK: 'task', // 任务通知
MESSAGE: 'message', // 消息通知
REMINDER: 'reminder', // 提醒通知
ANNOUNCEMENT: 'announcement', // 公告通知
}
/**
* 通知操作类型枚举
*/
export const NotificationActionType = {
NONE: "none", // 无操作
LINK: "link", // 跳转链接
MODAL: "modal", // 打开弹窗
};
NONE: 'none', // 无操作
LINK: 'link', // 跳转链接
MODAL: 'modal', // 打开弹窗
}
export const useNotificationStore = defineStore(
"notification",
'notification',
() => {
// 通知列表
const notifications = ref([]);
const notification = ref([])
// 未读数量
const unreadCount = ref(0);
const unreadCount = ref(0)
// 加载状态
const loading = ref(false);
const loading = ref(false)
// 当前页码
const currentPage = ref(1);
const currentPage = ref(1)
// 每页数量
const pageSize = ref(20);
const pageSize = ref(20)
// 总数量
const total = ref(0);
const total = ref(0)
// 获取未读数量(计算属性)
const unreadCountComputed = computed(() => unreadCount.value);
const unreadCountComputed = computed(() => unreadCount.value)
// 获取已读数量
const readCount = computed(() => total.value - unreadCount.value);
const readCount = computed(() => total.value - unreadCount.value)
// 获取通知列表
async function fetchNotifications(params = {}) {
try {
loading.value = true;
const res = await systemApi.notifications.list.get({
loading.value = true
const res = await systemApi.notification.list.get({
page: params.page || currentPage.value,
page_size: params.page_size || pageSize.value,
...params,
});
})
notifications.value = res.data.list || [];
total.value = res.data.total || 0;
currentPage.value = res.data.page || 1;
pageSize.value = res.data.page_size || 20;
notification.value = res.data.list || []
total.value = res.data.total || 0
currentPage.value = res.data.page || 1
pageSize.value = res.data.page_size || 20
return res.data;
return res.data
} catch (error) {
message.error(error.message || "获取通知列表失败");
throw error;
message.error(error.message || '获取通知列表失败')
throw error
} finally {
loading.value = false;
loading.value = false
}
}
// 获取未读通知列表
async function fetchUnreadNotifications(params = {}) {
try {
loading.value = true;
const res = await systemApi.notifications.unread.get({
loading.value = true
const res = await systemApi.notification.unread.get({
page: params.page || 1,
page_size: params.page_size || 10,
...params,
});
})
return res.data;
return res.data
} catch (error) {
message.error(error.message || "获取未读通知失败");
throw error;
message.error(error.message || '获取未读通知失败')
throw error
} finally {
loading.value = false;
loading.value = false
}
}
// 获取未读数量
async function fetchUnreadCount() {
try {
const res = await systemApi.notifications.unreadCount.get();
unreadCount.value = res.data.count || 0;
return unreadCount.value;
const res = await systemApi.notification.unreadCount.get()
// 安全访问 count 字段,处理 res.data 可能为 undefined 的情况
const count = res?.data?.count ?? 0
unreadCount.value = count
return unreadCount.value
} catch (error) {
console.error("获取未读数量失败:", error);
return 0;
console.error('获取未读数量失败:', error)
unreadCount.value = 0
return 0
}
}
// 标记为已读
async function markAsRead(id) {
try {
await systemApi.notifications.markAsRead.post(id);
await systemApi.notification.markAsRead.post(id)
// 更新本地状态
const notification = notifications.value.find(
(n) => n.id === id,
);
const notification = notification.value.find((n) => n.id === id)
if (notification && !notification.is_read) {
notification.is_read = true;
notification.read_at = new Date().toISOString();
unreadCount.value = Math.max(0, unreadCount.value - 1);
notification.is_read = true
notification.read_at = new Date().toISOString()
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
return true;
return true
} catch (error) {
message.error(error.message || "标记已读失败");
throw error;
message.error(error.message || '标记已读失败')
throw error
}
}
// 批量标记为已读
async function batchMarkAsRead(ids) {
try {
const res = await systemApi.notifications.batchMarkAsRead.post({
const res = await systemApi.notification.batchMarkAsRead.post({
ids,
});
})
// 更新本地状态
ids.forEach((id) => {
const notification = notifications.value.find(
(n) => n.id === id,
);
const notification = notification.value.find((n) => n.id === id)
if (notification && !notification.is_read) {
notification.is_read = true;
notification.read_at = new Date().toISOString();
notification.is_read = true
notification.read_at = new Date().toISOString()
}
});
})
// 更新未读数量
unreadCount.value = Math.max(
0,
unreadCount.value - (res.data.count || ids.length),
);
unreadCount.value = Math.max(0, unreadCount.value - (res.data.count || ids.length))
return res.data;
return res.data
} catch (error) {
message.error(error.message || "批量标记已读失败");
throw error;
message.error(error.message || '批量标记已读失败')
throw error
}
}
// 标记全部为已读
async function markAllAsRead() {
try {
await systemApi.notifications.markAllAsRead.post();
await systemApi.notification.markAllAsRead.post()
// 更新本地状态
notifications.value.forEach((n) => {
n.is_read = true;
n.read_at = n.read_at || new Date().toISOString();
});
unreadCount.value = 0;
notification.value.forEach((n) => {
n.is_read = true
n.read_at = n.read_at || new Date().toISOString()
})
unreadCount.value = 0
return true;
return true
} catch (error) {
message.error(error.message || "标记全部已读失败");
throw error;
message.error(error.message || '标记全部已读失败')
throw error
}
}
// 删除通知
async function deleteNotification(id) {
try {
await systemApi.notifications.delete.delete(id);
await systemApi.notification.delete.delete(id)
// 更新本地状态
const index = notifications.value.findIndex((n) => n.id === id);
const index = notification.value.findIndex((n) => n.id === id)
if (index !== -1) {
const notification = notifications.value[index];
const notification = notification.value[index]
if (!notification.is_read) {
unreadCount.value = Math.max(0, unreadCount.value - 1);
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1);
total.value = Math.max(0, total.value - 1);
notification.value.splice(index, 1)
total.value = Math.max(0, total.value - 1)
}
return true;
return true
} catch (error) {
message.error(error.message || "删除通知失败");
throw error;
message.error(error.message || '删除通知失败')
throw error
}
}
// 批量删除通知
async function batchDeleteNotification(ids) {
try {
const res = await systemApi.notifications.batchDelete.post({
const res = await systemApi.notification.batchDelete.post({
ids,
});
})
// 更新本地状态
const deletedCount = 0;
const deletedCount = 0
ids.forEach((id) => {
const index = notifications.value.findIndex(
(n) => n.id === id,
);
const index = notification.value.findIndex((n) => n.id === id)
if (index !== -1) {
const notification = notifications.value[index];
const notification = notification.value[index]
if (!notification.is_read) {
unreadCount.value = Math.max(
0,
unreadCount.value - 1,
);
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
notifications.value.splice(index, 1);
notification.value.splice(index, 1)
}
});
total.value = Math.max(0, total.value - ids.length);
})
total.value = Math.max(0, total.value - ids.length)
return res.data;
return res.data
} catch (error) {
message.error(error.message || "批量删除通知失败");
throw error;
message.error(error.message || '批量删除通知失败')
throw error
}
}
// 清空已读通知
async function clearReadNotifications() {
try {
await systemApi.notifications.clearRead.post();
await systemApi.notification.clearRead.post()
// 更新本地状态
notifications.value = notifications.value.filter(
(n) => !n.is_read,
);
total.value = notifications.value.length;
notification.value = notification.value.filter((n) => !n.is_read)
total.value = notification.value.length
return true;
return true
} catch (error) {
message.error(error.message || "清空已读通知失败");
throw error;
message.error(error.message || '清空已读通知失败')
throw error
}
}
// 获取通知详情
async function getNotificationDetail(id) {
try {
const res = await systemApi.notifications.detail.get(id);
return res.data;
const res = await systemApi.notification.detail.get(id)
return res.data
} catch (error) {
message.error(error.message || "获取通知详情失败");
throw error;
message.error(error.message || '获取通知详情失败')
throw error
}
}
// 发送通知(管理员功能)
async function sendNotification(params) {
try {
const res = await systemApi.notifications.send.post(params);
message.success("发送通知成功");
return res.data;
const res = await systemApi.notification.send.post(params)
message.success('发送通知成功')
return res.data
} catch (error) {
message.error(error.message || "发送通知失败");
throw error;
message.error(error.message || '发送通知失败')
throw error
}
}
// 重试发送失败的通知(管理员功能)
async function retryUnsentNotifications(params = {}) {
try {
const res =
await systemApi.notifications.retryUnsent.post(params);
message.success("重试发送成功");
return res.data;
const res = await systemApi.notification.retryUnsent.post(params)
message.success('重试发送成功')
return res.data
} catch (error) {
message.error(error.message || "重试发送失败");
throw error;
message.error(error.message || '重试发送失败')
throw error
}
}
// 获取通知统计信息
async function fetchStatistics() {
try {
const res = await systemApi.notifications.statistics.get();
return res.data;
const res = await systemApi.notification.statistics.get()
return res.data
} catch (error) {
message.error(error.message || "获取统计信息失败");
throw error;
message.error(error.message || '获取统计信息失败')
throw error
}
}
// 处理 WebSocket 消息
function handleWebSocketMessage(data) {
if (data.type === "notification") {
const notification = data.data;
if (data.type === 'notification') {
const notification = data.data
// 添加到通知列表顶部
notifications.value.unshift(notification);
total.value++;
notification.value.unshift(notification)
total.value++
// 如果是未读,增加未读数量
if (!notification.is_read) {
unreadCount.value++;
unreadCount.value++
}
// 限制本地存储的通知数量
if (notifications.value.length > 100) {
notifications.value = notifications.value.slice(0, 100);
if (notification.value.length > 100) {
notification.value = notification.value.slice(0, 100)
}
// 显示通知提示
message.info(`新通知: ${notification.title}`);
message.info(`新通知: ${notification.title}`)
}
}
// 格式化通知时间
function formatNotificationTime(timestamp) {
if (!timestamp) return "-";
if (!timestamp) return '-'
const now = Date.now();
const diff = now - new Date(timestamp).getTime();
const now = Date.now()
const diff = now - new Date(timestamp).getTime()
const minute = 60 * 1000;
const hour = 60 * minute;
const day = 24 * hour;
const minute = 60 * 1000
const hour = 60 * minute
const day = 24 * hour
if (diff < minute) {
return "刚刚";
return '刚刚'
} else if (diff < hour) {
return `${Math.floor(diff / minute)}分钟前`;
return `${Math.floor(diff / minute)}分钟前`
} else if (diff < day) {
return `${Math.floor(diff / hour)}小时前`;
return `${Math.floor(diff / hour)}小时前`
} else if (diff < 7 * day) {
return `${Math.floor(diff / day)}天前`;
return `${Math.floor(diff / day)}天前`
} else {
const date = new Date(timestamp);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
const date = new Date(timestamp)
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
}
// 获取通知类型文本
function getNotificationTypeText(type) {
const texts = {
info: "信息",
success: "成功",
warning: "警告",
error: "错误",
task: "任务",
system: "系统",
};
return texts[type] || type;
info: '信息',
success: '成功',
warning: '警告',
error: '错误',
task: '任务',
system: '系统',
}
return texts[type] || type
}
// 获取通知类型颜色
function getNotificationTypeColor(type) {
const colors = {
info: "blue",
success: "green",
warning: "orange",
error: "red",
task: "purple",
system: "cyan",
};
return colors[type] || "default";
info: 'blue',
success: 'green',
warning: 'orange',
error: 'red',
task: 'purple',
system: 'cyan',
}
return colors[type] || 'default'
}
// 获取通知分类文本
function getNotificationCategoryText(category) {
const texts = {
system: "系统通知",
task: "任务通知",
message: "消息通知",
reminder: "提醒通知",
announcement: "公告通知",
};
return texts[category] || category;
system: '系统通知',
task: '任务通知',
message: '消息通知',
reminder: '提醒通知',
announcement: '公告通知',
}
return texts[category] || category
}
// 重置状态
function reset() {
notifications.value = [];
unreadCount.value = 0;
currentPage.value = 1;
total.value = 0;
loading.value = false;
notification.value = []
unreadCount.value = 0
currentPage.value = 1
total.value = 0
loading.value = false
}
return {
notifications,
notification,
unreadCount,
loading,
currentPage,
@@ -438,13 +426,13 @@ export const useNotificationStore = defineStore(
NotificationType,
NotificationCategory,
NotificationActionType,
};
}
},
{
persist: {
key: "notification-store",
key: 'notification-store',
storage: customStorage,
pick: [], // 不自动持久化,通知数据从服务器获取
},
},
);
)
+34 -34
View File
@@ -1,93 +1,93 @@
import { ref } from "vue";
import { defineStore } from "pinia";
import { resetRouter } from "../../router";
import { customStorage } from "../persist";
import userRoutes from "@/config/routes";
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { resetRouter } from '../../router'
import { customStorage } from '../persist'
import userRoutes from '@/config/routes'
export const useUserStore = defineStore(
"user",
'user',
() => {
const token = ref("");
const userInfo = ref(null);
const menu = ref([]);
const permissions = ref([]);
const token = ref('')
const userInfo = ref(null)
const menu = ref([])
const permissions = ref([])
// 设置 token
function setToken(newToken) {
token.value = newToken;
token.value = newToken
}
// 设置用户信息
function setUserInfo(info) {
userInfo.value = info;
userInfo.value = info
}
// 设置菜单
function setMenu(newMenu) {
const staticMenus = userRoutes || [];
const staticMenus = userRoutes || []
// 合并静态菜单和后端菜单
// 如果后端菜单为空,只使用静态菜单
// 如果后端菜单不为空,合并两个菜单,后端菜单优先
let mergedMenus = [...staticMenus];
let mergedMenus = [...staticMenus]
if (newMenu && newMenu.length > 0) {
// 创建菜单映射,用于去重(以路径为唯一标识)
const menuMap = new Map();
const menuMap = new Map()
// 先添加静态菜单
staticMenus.forEach((menu) => {
if (menu.path) {
menuMap.set(menu.path, menu);
menuMap.set(menu.path, menu)
}
});
})
// 添加后端菜单,如果路径重复则覆盖
newMenu.forEach((menu) => {
if (menu.path) {
menuMap.set(menu.path, menu);
menuMap.set(menu.path, menu)
}
});
})
// 转换为数组
mergedMenus = Array.from(menuMap.values());
mergedMenus = Array.from(menuMap.values())
}
menu.value = mergedMenus;
menu.value = mergedMenus
}
// 获取菜单
function getMenu() {
return menu.value;
return menu.value
}
// 清除菜单
function clearMenu() {
menu.value = [];
menu.value = []
}
// 设置权限
function setPermissions(data) {
permissions.value = data;
permissions.value = data
}
// 登出
function logout() {
token.value = "";
userInfo.value = null;
menu.value = [];
token.value = ''
userInfo.value = null
menu.value = []
// 重置路由
resetRouter();
resetRouter()
}
// 检查是否已登录
function isLoggedIn() {
return !!token.value;
return !!token.value
}
// 检查用户信息是否完整(用于 WebSocket 初始化)
function isUserInfoComplete() {
return !!(token.value && userInfo.value && userInfo.value.id);
return !!(token.value && userInfo.value && userInfo.value.id)
}
return {
@@ -103,13 +103,13 @@ export const useUserStore = defineStore(
logout,
isLoggedIn,
isUserInfoComplete,
};
}
},
{
persist: {
key: "user-store",
key: 'user-store',
storage: customStorage,
pick: ["token", "userInfo", "menu"],
pick: ['token', 'userInfo', 'menu'],
},
},
);
)
+6 -6
View File
@@ -3,7 +3,7 @@
* @version: 1.0
*/
import tool from "@/utils/tool";
import tool from '@/utils/tool'
/**
* 自定义存储适配器
@@ -16,7 +16,7 @@ export const customStorage = {
* @returns {any} - 存储的数据
*/
getItem: (key) => {
return tool.data.get(key);
return tool.data.get(key)
},
/**
@@ -25,7 +25,7 @@ export const customStorage = {
* @param {any} value - 要存储的值
*/
setItem: (key, value) => {
tool.data.set(key, value);
tool.data.set(key, value)
},
/**
@@ -33,9 +33,9 @@ export const customStorage = {
* @param {string} key - 存储键
*/
removeItem: (key) => {
tool.data.remove(key);
tool.data.remove(key)
},
};
}
/**
* 默认持久化配置
@@ -47,4 +47,4 @@ export const defaultPersistConfig = {
// serialize: (state) => JSON.stringify(state),
// deserialize: (value) => JSON.parse(value)
// }
};
}
+36 -36
View File
@@ -1,92 +1,92 @@
import axios from "axios";
import config from "@/config";
import { useUserStore } from "@/stores/modules/user";
import { message } from "ant-design-vue";
import router from "@/router";
import axios from 'axios'
import config from '@/config'
import { useUserStore } from '@/stores/modules/user'
import { message } from 'ant-design-vue'
import router from '@/router'
const request = axios.create({
timeout: 30000,
baseURL: config.API_URL,
});
})
// 请求拦截器
request.interceptors.request.use(
(config) => {
const userStore = useUserStore();
const token = userStore.token;
const userStore = useUserStore()
const token = userStore.token
// 如果有 token,添加到请求头
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
config.headers['Authorization'] = `Bearer ${token}`
}
return config;
return config
},
(error) => {
return Promise.reject(error);
return Promise.reject(error)
},
);
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
// 根据后端返回的数据结构进行处理
// 后端返回格式为 { code, message, data }
const { code, data, message: msg } = response.data;
const { code, data, message: msg } = response.data
// 请求成功
if (code === 200 || code === 1) {
return { code, data, message: msg };
return { code, data, message: msg }
}
// 其他错误码处理
message.error(msg || "请求失败");
return Promise.reject(new Error(msg || "请求失败"));
message.error(msg || '请求失败')
return Promise.reject(new Error(msg || '请求失败'))
},
async (error) => {
const userStore = useUserStore();
const { response } = error;
const userStore = useUserStore()
const { response } = error
// 无响应(网络错误、超时等)
if (!response) {
message.error("网络错误,请检查网络连接");
return Promise.reject(error);
message.error('网络错误,请检查网络连接')
return Promise.reject(error)
}
const { status, data } = response;
const { status, data } = response
// 401 未授权 - token 过期或无效
if (status === 401) {
// 直接登出并跳转到登录页
userStore.logout();
router.push("/login");
message.error("登录已过期,请重新登录");
return Promise.reject(error);
userStore.logout()
router.push('/login')
message.error('登录已过期,请重新登录')
return Promise.reject(error)
}
// 403 禁止访问
if (status === 403) {
message.error("没有权限访问该资源");
return Promise.reject(error);
message.error('没有权限访问该资源')
return Promise.reject(error)
}
// 404 资源不存在
if (status === 404) {
message.error("请求的资源不存在");
return Promise.reject(error);
message.error('请求的资源不存在')
return Promise.reject(error)
}
// 500 服务器错误
if (status >= 500) {
message.error("服务器错误,请稍后重试");
return Promise.reject(error);
message.error('服务器错误,请稍后重试')
return Promise.reject(error)
}
// 其他错误
const errorMessage = data?.message || error.message || "请求失败";
message.error(errorMessage);
return Promise.reject(error);
const errorMessage = data?.message || error.message || '请求失败'
message.error(errorMessage)
return Promise.reject(error)
},
);
)
export default request;
export default request
+175 -217
View File
@@ -5,10 +5,10 @@
* @LastEditTime: 2026年1月15日
*/
import CryptoJS from "crypto-js";
import sysConfig from "@/config";
import CryptoJS from 'crypto-js'
import sysConfig from '@/config'
const tool = {};
const tool = {}
/**
* 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象)
@@ -17,19 +17,19 @@ const tool = {};
*/
tool.isValid = function (value) {
if (value === null || value === undefined) {
return false;
return false
}
if (typeof value === "string" && value.trim() === "") {
return false;
if (typeof value === 'string' && value.trim() === '') {
return false
}
if (Array.isArray(value) && value.length === 0) {
return false;
return false
}
if (typeof value === "object" && Object.keys(value).length === 0) {
return false;
if (typeof value === 'object' && Object.keys(value).length === 0) {
return false
}
return true;
};
return true
}
/**
* 防抖函数
@@ -39,21 +39,21 @@ tool.isValid = function (value) {
* @returns {Function}
*/
tool.debounce = function (func, wait = 300, immediate = false) {
let timeout;
let timeout
return function (...args) {
const context = this;
clearTimeout(timeout);
const context = this
clearTimeout(timeout)
if (immediate && !timeout) {
func.apply(context, args);
func.apply(context, args)
}
timeout = setTimeout(() => {
timeout = null;
timeout = null
if (!immediate) {
func.apply(context, args);
func.apply(context, args)
}
}, wait);
};
};
}, wait)
}
}
/**
* 节流函数
@@ -63,36 +63,36 @@ tool.debounce = function (func, wait = 300, immediate = false) {
* @returns {Function}
*/
tool.throttle = function (func, wait = 300, options = {}) {
let timeout;
let previous = 0;
const { leading = true, trailing = true } = options;
let timeout
let previous = 0
const { leading = true, trailing = true } = options
return function (...args) {
const context = this;
const now = Date.now();
const context = this
const now = Date.now()
if (!previous && !leading) {
previous = now;
previous = now
}
const remaining = wait - (now - previous);
const remaining = wait - (now - previous)
if (remaining <= 0 || remaining > wait) {
if (timeout) {
clearTimeout(timeout);
timeout = null;
clearTimeout(timeout)
timeout = null
}
previous = now;
func.apply(context, args);
previous = now
func.apply(context, args)
} else if (!timeout && trailing) {
timeout = setTimeout(() => {
previous = leading ? Date.now() : 0;
timeout = null;
func.apply(context, args);
}, remaining);
previous = leading ? Date.now() : 0
timeout = null
func.apply(context, args)
}, remaining)
}
};
};
}
}
/**
* 深拷贝对象(支持循环引用)
@@ -101,99 +101,88 @@ tool.throttle = function (func, wait = 300, options = {}) {
* @returns {*}
*/
tool.deepClone = function (obj, hash = new WeakMap()) {
if (obj === null || typeof obj !== "object") {
return obj;
if (obj === null || typeof obj !== 'object') {
return obj
}
if (hash.has(obj)) {
return hash.get(obj);
return hash.get(obj)
}
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
const clone = Array.isArray(obj) ? [] : {}
hash.set(obj, clone)
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
clone[key] = tool.deepClone(obj[key], hash);
clone[key] = tool.deepClone(obj[key], hash)
}
}
return clone;
};
return clone
}
/* localStorage */
tool.data = {
set(key, data, datetime = 0) {
//加密
if (sysConfig.LS_ENCRYPTION == "AES") {
data = tool.crypto.AES.encrypt(
JSON.stringify(data),
sysConfig.LS_ENCRYPTION_key,
);
if (sysConfig.LS_ENCRYPTION == 'AES') {
data = tool.crypto.AES.encrypt(JSON.stringify(data), sysConfig.LS_ENCRYPTION_key)
}
let cacheValue = {
content: data,
datetime:
parseInt(datetime) === 0
? 0
: new Date().getTime() + parseInt(datetime) * 1000,
};
return localStorage.setItem(key, JSON.stringify(cacheValue));
datetime: parseInt(datetime) === 0 ? 0 : new Date().getTime() + parseInt(datetime) * 1000,
}
return localStorage.setItem(key, JSON.stringify(cacheValue))
},
get(key) {
try {
const value = JSON.parse(localStorage.getItem(key));
const value = JSON.parse(localStorage.getItem(key))
if (value) {
let nowTime = new Date().getTime();
let nowTime = new Date().getTime()
if (nowTime > value.datetime && value.datetime != 0) {
localStorage.removeItem(key);
return null;
localStorage.removeItem(key)
return null
}
//解密
if (sysConfig.LS_ENCRYPTION == "AES") {
value.content = JSON.parse(
tool.crypto.AES.decrypt(
value.content,
sysConfig.LS_ENCRYPTION_key,
),
);
if (sysConfig.LS_ENCRYPTION == 'AES') {
value.content = JSON.parse(tool.crypto.AES.decrypt(value.content, sysConfig.LS_ENCRYPTION_key))
}
return value.content;
return value.content
}
return null;
return null
} catch {
return null;
return null
}
},
remove(key) {
return localStorage.removeItem(key);
return localStorage.removeItem(key)
},
clear() {
return localStorage.clear();
return localStorage.clear()
},
};
}
/*sessionStorage*/
tool.session = {
set(table, settings) {
const _set = JSON.stringify(settings);
return sessionStorage.setItem(table, _set);
const _set = JSON.stringify(settings)
return sessionStorage.setItem(table, _set)
},
get(table) {
const data = sessionStorage.getItem(table);
const data = sessionStorage.getItem(table)
try {
return JSON.parse(data);
return JSON.parse(data)
} catch {
return null;
return null
}
},
remove(table) {
return sessionStorage.removeItem(table);
return sessionStorage.removeItem(table)
},
clear() {
return sessionStorage.clear();
return sessionStorage.clear()
},
};
}
/*cookie*/
tool.cookie = {
@@ -210,28 +199,28 @@ tool.cookie = {
domain: null,
secure: false,
httpOnly: false,
sameSite: "Lax",
sameSite: 'Lax',
...config,
};
let cookieStr = `${name}=${encodeURIComponent(value)}`;
}
let cookieStr = `${name}=${encodeURIComponent(value)}`
if (cfg.expires) {
const exp = new Date();
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000);
cookieStr += `;expires=${exp.toUTCString()}`;
const exp = new Date()
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000)
cookieStr += `;expires=${exp.toUTCString()}`
}
if (cfg.path) {
cookieStr += `;path=${cfg.path}`;
cookieStr += `;path=${cfg.path}`
}
if (cfg.domain) {
cookieStr += `;domain=${cfg.domain}`;
cookieStr += `;domain=${cfg.domain}`
}
if (cfg.secure) {
cookieStr += `;secure`;
cookieStr += `;secure`
}
if (cfg.sameSite) {
cookieStr += `;SameSite=${cfg.sameSite}`;
cookieStr += `;SameSite=${cfg.sameSite}`
}
document.cookie = cookieStr;
document.cookie = cookieStr
},
/**
* 获取cookie
@@ -239,24 +228,22 @@ tool.cookie = {
* @returns {string|null}
*/
get(name) {
const arr = document.cookie.match(
new RegExp("(^| )" + name + "=([^;]*)(;|$)"),
);
const arr = document.cookie.match(new RegExp('(^| )' + name + '=([^;]*)(;|$)'))
if (arr != null) {
return decodeURIComponent(arr[2]);
return decodeURIComponent(arr[2])
}
return null;
return null
},
/**
* 删除cookie
* @param {string} name - cookie名称
*/
remove(name) {
const exp = new Date();
exp.setTime(exp.getTime() - 1);
document.cookie = `${name}=;expires=${exp.toUTCString()}`;
const exp = new Date()
exp.setTime(exp.getTime() - 1)
document.cookie = `${name}=;expires=${exp.toUTCString()}`
},
};
}
/* Fullscreen */
/**
@@ -264,34 +251,29 @@ tool.cookie = {
* @param {HTMLElement} element - 要全屏的元素
*/
tool.screen = function (element) {
const isFull = !!(
document.webkitIsFullScreen ||
document.mozFullScreen ||
document.msFullscreenElement ||
document.fullscreenElement
);
const isFull = !!(document.webkitIsFullScreen || document.mozFullScreen || document.msFullscreenElement || document.fullscreenElement)
if (isFull) {
if (document.exitFullscreen) {
document.exitFullscreen();
document.exitFullscreen()
} else if (document.msExitFullscreen) {
document.msExitFullscreen();
document.msExitFullscreen()
} else if (document.mozCancelFullScreen) {
document.mozCancelFullScreen();
document.mozCancelFullScreen()
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
document.webkitExitFullscreen()
}
} else {
if (element.requestFullscreen) {
element.requestFullscreen();
element.requestFullscreen()
} else if (element.msRequestFullscreen) {
element.msRequestFullscreen();
element.msRequestFullscreen()
} else if (element.mozRequestFullScreen) {
element.mozRequestFullScreen();
element.mozRequestFullScreen()
} else if (element.webkitRequestFullscreen) {
element.webkitRequestFullscreen();
element.webkitRequestFullscreen()
}
}
};
}
/* 复制对象(浅拷贝) */
/**
@@ -300,11 +282,11 @@ tool.screen = function (element) {
* @returns {*} - 拷贝后的对象
*/
tool.objCopy = function (obj) {
if (obj === null || typeof obj !== "object") {
return obj;
if (obj === null || typeof obj !== 'object') {
return obj
}
return JSON.parse(JSON.stringify(obj));
};
return JSON.parse(JSON.stringify(obj))
}
/* 日期格式化 */
/**
@@ -313,38 +295,30 @@ tool.objCopy = function (obj) {
* @param {string} fmt - 格式化字符串,默认 "yyyy-MM-dd hh:mm:ss"
* @returns {string} - 格式化后的日期字符串
*/
tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") {
if (!date) return "";
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) return "";
tool.dateFormat = function (date, fmt = 'yyyy-MM-dd hh:mm:ss') {
if (!date) return ''
const dateObj = new Date(date)
if (isNaN(dateObj.getTime())) return ''
const o = {
"M+": dateObj.getMonth() + 1, // 月份
"d+": dateObj.getDate(), // 日
"h+": dateObj.getHours(), // 小时
"m+": dateObj.getMinutes(), // 分
"s+": dateObj.getSeconds(), // 秒
"q+": Math.floor((dateObj.getMonth() + 3) / 3), // 季度
'M+': dateObj.getMonth() + 1, // 月份
'd+': dateObj.getDate(), // 日
'h+': dateObj.getHours(), // 小时
'm+': dateObj.getMinutes(), // 分
's+': dateObj.getSeconds(), // 秒
'q+': Math.floor((dateObj.getMonth() + 3) / 3), // 季度
S: dateObj.getMilliseconds(), // 毫秒
};
}
if (/(y+)/.test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
(dateObj.getFullYear() + "").substr(4 - RegExp.$1.length),
);
fmt = fmt.replace(RegExp.$1, (dateObj.getFullYear() + '').substr(4 - RegExp.$1.length))
}
for (const k in o) {
if (new RegExp("(" + k + ")").test(fmt)) {
fmt = fmt.replace(
RegExp.$1,
RegExp.$1.length == 1
? o[k]
: ("00" + o[k]).substr(("" + o[k]).length),
);
if (new RegExp('(' + k + ')').test(fmt)) {
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
}
}
return fmt;
};
return fmt
}
/* 千分符 */
/**
@@ -354,63 +328,51 @@ tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") {
* @returns {string} - 格式化后的字符串
*/
tool.groupSeparator = function (num, decimals = 0) {
if (num === null || num === undefined || num === "") return "";
const numStr = Number(num).toFixed(decimals);
const parts = numStr.split(".");
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
return parts.join(".");
};
if (num === null || num === undefined || num === '') return ''
const numStr = Number(num).toFixed(decimals)
const parts = numStr.split('.')
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return parts.join('.')
}
/* 常用加解密 */
tool.crypto = {
//MD5加密
MD5(data) {
return CryptoJS.MD5(data).toString();
return CryptoJS.MD5(data).toString()
},
//BASE64加解密
BASE64: {
encrypt(data) {
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data));
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data))
},
decrypt(cipher) {
return CryptoJS.enc.Base64.parse(cipher).toString(
CryptoJS.enc.Utf8,
);
return CryptoJS.enc.Base64.parse(cipher).toString(CryptoJS.enc.Utf8)
},
},
//AES加解密
AES: {
encrypt(data, secretKey, config = {}) {
if (secretKey.length % 8 != 0) {
console.warn(
"[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。",
);
console.warn('[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。')
}
const result = CryptoJS.AES.encrypt(
data,
CryptoJS.enc.Utf8.parse(secretKey),
{
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
mode: CryptoJS.mode[config.mode || "ECB"],
padding: CryptoJS.pad[config.padding || "Pkcs7"],
},
);
return result.toString();
const result = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(secretKey), {
iv: CryptoJS.enc.Utf8.parse(config.iv || ''),
mode: CryptoJS.mode[config.mode || 'ECB'],
padding: CryptoJS.pad[config.padding || 'Pkcs7'],
})
return result.toString()
},
decrypt(cipher, secretKey, config = {}) {
const result = CryptoJS.AES.decrypt(
cipher,
CryptoJS.enc.Utf8.parse(secretKey),
{
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
mode: CryptoJS.mode[config.mode || "ECB"],
padding: CryptoJS.pad[config.padding || "Pkcs7"],
},
);
return CryptoJS.enc.Utf8.stringify(result);
const result = CryptoJS.AES.decrypt(cipher, CryptoJS.enc.Utf8.parse(secretKey), {
iv: CryptoJS.enc.Utf8.parse(config.iv || ''),
mode: CryptoJS.mode[config.mode || 'ECB'],
padding: CryptoJS.pad[config.padding || 'Pkcs7'],
})
return CryptoJS.enc.Utf8.stringify(result)
},
},
};
}
/* 树形数据转扁平数组 */
/**
@@ -419,22 +381,22 @@ tool.crypto = {
* @param {Object} config - 配置项 { children: "children" }
* @returns {Array} - 扁平化后的数组
*/
tool.treeToList = function (tree, config = { children: "children" }) {
const result = [];
tool.treeToList = function (tree, config = { children: 'children' }) {
const result = []
tree.forEach((item) => {
const tmp = { ...item };
const childrenKey = config.children || "children";
const tmp = { ...item }
const childrenKey = config.children || 'children'
if (tmp[childrenKey] && tmp[childrenKey].length > 0) {
result.push({ ...item });
const childrenRoutes = tool.treeToList(tmp[childrenKey], config);
result.push(...childrenRoutes);
result.push({ ...item })
const childrenRoutes = tool.treeToList(tmp[childrenKey], config)
result.push(...childrenRoutes)
} else {
result.push(tmp);
result.push(tmp)
}
});
return result;
};
})
return result
}
/* 获取父节点数据(保留原有函数名) */
/**
@@ -444,28 +406,24 @@ tool.treeToList = function (tree, config = { children: "children" }) {
* @param {Object} config - 配置项 { pid: "parent_id", idField: "id", field: [] }
* @returns {*} - 父节点数据或指定字段
*/
tool.get_parents = function (
list,
targetId = 0,
config = { pid: "parent_id", idField: "id", field: [] },
) {
let res = null;
tool.get_parents = function (list, targetId = 0, config = { pid: 'parent_id', idField: 'id', field: [] }) {
let res = null
list.forEach((item) => {
if (item[config.idField || "id"] === targetId) {
if (item[config.idField || 'id'] === targetId) {
if (config.field && config.field.length > 1) {
res = {};
res = {}
config.field.forEach((field) => {
res[field] = item[field];
});
res[field] = item[field]
})
} else if (config.field && config.field.length === 1) {
res = item[config.field[0]];
res = item[config.field[0]]
} else {
res = item;
res = item
}
}
});
return res;
};
})
return res
}
/* 获取数据字段 */
/**
@@ -475,25 +433,25 @@ tool.get_parents = function (
* @returns {*} - 提取的字段数据
*/
tool.getDataField = function (data, fields = []) {
if (!data || typeof data !== "object") {
return data;
if (!data || typeof data !== 'object') {
return data
}
if (fields.length === 0) {
return data;
return data
}
if (fields.length === 1) {
return data[fields[0]];
return data[fields[0]]
} else {
const result = {};
const result = {}
fields.forEach((field) => {
result[field] = data[field];
});
return result;
result[field] = data[field]
})
return result
}
};
}
// 兼容旧函数名
tool.tree_to_list = tool.treeToList;
tool.get_data_field = tool.getDataField;
tool.tree_to_list = tool.treeToList
tool.get_data_field = tool.getDataField
export default tool;
export default tool
+146 -160
View File
@@ -6,43 +6,43 @@
class WebSocketClient {
constructor(url, options = {}) {
this.url = url;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = options.maxReconnectAttempts || 5;
this.reconnectInterval = options.reconnectInterval || 3000;
this.reconnectDelay = options.reconnectDelay || 1000;
this.heartbeatInterval = options.heartbeatInterval || 30000;
this.heartbeatTimer = null;
this.isManualClose = false;
this.isConnecting = false;
this.url = url
this.ws = null
this.reconnectAttempts = 0
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
this.reconnectInterval = options.reconnectInterval || 3000
this.reconnectDelay = options.reconnectDelay || 1000
this.heartbeatInterval = options.heartbeatInterval || 30000
this.heartbeatTimer = null
this.isManualClose = false
this.isConnecting = false
// Event handlers
this.onOpen = options.onOpen || null;
this.onMessage = options.onMessage || null;
this.onError = options.onError || null;
this.onClose = options.onClose || null;
this.onOpen = options.onOpen || null
this.onMessage = options.onMessage || null
this.onError = options.onError || null
this.onClose = options.onClose || null
// Message handlers
this.messageHandlers = new Map();
this.messageHandlers = new Map()
}
/**
* Get connection state description
*/
getConnectionState() {
if (!this.ws) return "CLOSED";
if (!this.ws) return 'CLOSED'
switch (this.ws.readyState) {
case WebSocket.CONNECTING:
return "CONNECTING";
return 'CONNECTING'
case WebSocket.OPEN:
return "OPEN";
return 'OPEN'
case WebSocket.CLOSING:
return "CLOSING";
return 'CLOSING'
case WebSocket.CLOSED:
return "CLOSED";
return 'CLOSED'
default:
return "UNKNOWN";
return 'UNKNOWN'
}
}
@@ -50,90 +50,84 @@ class WebSocketClient {
* Connect to WebSocket server
*/
connect() {
if (
this.isConnecting ||
(this.ws && this.ws.readyState === WebSocket.OPEN)
) {
return;
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
return
}
this.isConnecting = true;
this.isManualClose = false;
this.isConnecting = true
this.isManualClose = false
try {
this.ws = new WebSocket(this.url);
this.ws = new WebSocket(this.url)
this.ws.onopen = (event) => {
console.log("WebSocket connected", event);
this.isConnecting = false;
this.reconnectAttempts = 0;
console.log('WebSocket connected', event)
this.isConnecting = false
this.reconnectAttempts = 0
// Start heartbeat
this.startHeartbeat();
this.startHeartbeat()
// Call onOpen handler
if (this.onOpen) {
this.onOpen(event);
this.onOpen(event)
}
};
}
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
console.log("WebSocket message received", message);
const message = JSON.parse(event.data)
console.log('WebSocket message received', message)
// Handle different message types
this.handleMessage(message);
this.handleMessage(message)
// Call onMessage handler
if (this.onMessage) {
this.onMessage(message, event);
this.onMessage(message, event)
}
} catch (error) {
console.error("Failed to parse WebSocket message", error);
console.error('Failed to parse WebSocket message', error)
}
};
}
this.ws.onerror = (error) => {
console.error("WebSocket error", error);
this.isConnecting = false;
console.error('WebSocket error', error)
this.isConnecting = false
// Stop heartbeat
this.stopHeartbeat();
this.stopHeartbeat()
// Call onError handler
if (this.onError) {
this.onError(error);
this.onError(error)
}
};
}
this.ws.onclose = (event) => {
console.log("WebSocket closed", event);
this.isConnecting = false;
console.log('WebSocket closed', event)
this.isConnecting = false
// Stop heartbeat
this.stopHeartbeat();
this.stopHeartbeat()
// Call onClose handler
if (this.onClose) {
this.onClose(event);
this.onClose(event)
}
// Attempt to reconnect if not manually closed
if (
!this.isManualClose &&
this.reconnectAttempts < this.maxReconnectAttempts
) {
this.reconnect();
if (!this.isManualClose && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnect()
}
};
}
} catch (error) {
console.error("Failed to create WebSocket connection", error);
this.isConnecting = false;
console.error('Failed to create WebSocket connection', error)
this.isConnecting = false
// Call onError handler
if (this.onError) {
this.onError(error);
this.onError(error)
}
}
}
@@ -142,37 +136,31 @@ class WebSocketClient {
* Reconnect to WebSocket server
*/
reconnect() {
if (
this.isConnecting ||
this.reconnectAttempts >= this.maxReconnectAttempts
) {
console.log("Max reconnection attempts reached");
return;
if (this.isConnecting || this.reconnectAttempts >= this.maxReconnectAttempts) {
console.log('Max reconnection attempts reached')
return
}
this.reconnectAttempts++;
const delay =
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
this.reconnectAttempts++
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
console.log(
`Reconnecting attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`,
);
console.log(`Reconnecting attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms`)
setTimeout(() => {
this.connect();
}, delay);
this.connect()
}, delay)
}
/**
* Disconnect from WebSocket server
*/
disconnect() {
this.isManualClose = true;
this.stopHeartbeat();
this.isManualClose = true
this.stopHeartbeat()
if (this.ws) {
this.ws.close();
this.ws = null;
this.ws.close()
this.ws = null
}
}
@@ -184,11 +172,11 @@ class WebSocketClient {
const message = JSON.stringify({
type,
data,
});
this.ws.send(message);
console.log("WebSocket message sent", { type, data });
})
this.ws.send(message)
console.log('WebSocket message sent', { type, data })
} else {
console.warn("WebSocket is not connected");
console.warn('WebSocket is not connected')
}
}
@@ -196,12 +184,12 @@ class WebSocketClient {
* Handle incoming messages
*/
handleMessage(message) {
const { type, data } = message;
const { type, data } = message
// Get handler for this message type
const handler = this.messageHandlers.get(type);
const handler = this.messageHandlers.get(type)
if (handler) {
handler(data);
handler(data)
}
}
@@ -209,24 +197,24 @@ class WebSocketClient {
* Register message handler
*/
on(messageType, handler) {
this.messageHandlers.set(messageType, handler);
this.messageHandlers.set(messageType, handler)
}
/**
* Unregister message handler
*/
off(messageType) {
this.messageHandlers.delete(messageType);
this.messageHandlers.delete(messageType)
}
/**
* Start heartbeat
*/
startHeartbeat() {
this.stopHeartbeat();
this.stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
this.send("heartbeat", { timestamp: Date.now() });
}, this.heartbeatInterval);
this.send('heartbeat', { timestamp: Date.now() })
}, this.heartbeatInterval)
}
/**
@@ -234,8 +222,8 @@ class WebSocketClient {
*/
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
}
@@ -243,15 +231,15 @@ class WebSocketClient {
* Get connection state
*/
get readyState() {
if (!this.ws) return WebSocket.CLOSED;
return this.ws.readyState;
if (!this.ws) return WebSocket.CLOSED
return this.ws.readyState
}
/**
* Check if connected
*/
get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
return this.ws && this.ws.readyState === WebSocket.OPEN
}
}
@@ -259,61 +247,59 @@ class WebSocketClient {
* Create WebSocket connection
*/
export function createWebSocket(userId, token, options = {}) {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
// 优先使用配置的 WS_URL,否则使用当前域名
const host = options.wsUrl || window.location.host;
const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}`;
const host = options.wsUrl || window.location.host
const url = `${protocol}//${host}/ws?user_id=${userId}&token=${token}`
return new WebSocketClient(url, options);
return new WebSocketClient(url, options)
}
/**
* WebSocket singleton instance with connection management
*/
let wsClient = null;
let currentUserId = null;
let currentToken = null;
let pendingConnection = null;
let wsClient = null
let currentUserId = null
let currentToken = null
let pendingConnection = null
export function getWebSocket(userId, token, options = {}) {
// Check if we have a connection for the same user
if (wsClient) {
// If connection exists and user/token matches
if (currentUserId === userId && currentToken === token) {
const state = wsClient.getConnectionState();
const state = wsClient.getConnectionState()
// If already connected or connecting, return existing instance
if (state === "OPEN" || state === "CONNECTING") {
console.log("WebSocket: Reusing existing connection", {
if (state === 'OPEN' || state === 'CONNECTING') {
console.log('WebSocket: Reusing existing connection', {
state,
userId: currentUserId,
});
return wsClient;
})
return wsClient
}
// If connection is closed or closing, clean up
if (state === "CLOSED" || state === "CLOSING") {
console.log("WebSocket: Cleaning up closed connection");
if (state === 'CLOSED' || state === 'CLOSING') {
console.log('WebSocket: Cleaning up closed connection')
if (wsClient.ws) {
wsClient.ws.onopen = null;
wsClient.ws.onmessage = null;
wsClient.ws.onerror = null;
wsClient.ws.onclose = null;
wsClient.ws = null;
wsClient.ws.onopen = null
wsClient.ws.onmessage = null
wsClient.ws.onerror = null
wsClient.ws.onclose = null
wsClient.ws = null
}
}
}
// Different user/token, disconnect old connection
if (currentUserId !== userId || currentToken !== token) {
console.log(
"WebSocket: User changed, disconnecting old connection",
);
console.log('WebSocket: User changed, disconnecting old connection')
if (wsClient) {
wsClient.disconnect();
wsClient.disconnect()
}
wsClient = null;
wsClient = null
}
}
@@ -322,74 +308,74 @@ export function getWebSocket(userId, token, options = {}) {
return new Promise((resolve) => {
const checkPending = setInterval(() => {
if (!pendingConnection) {
clearInterval(checkPending);
console.log("WebSocket: Pending connection completed");
resolve(getWebSocket(userId, token, options));
clearInterval(checkPending)
console.log('WebSocket: Pending connection completed')
resolve(getWebSocket(userId, token, options))
}
}, 100);
});
}, 100)
})
}
// Create new connection
console.log("WebSocket: Creating new connection", {
console.log('WebSocket: Creating new connection', {
userId,
token: token ? "***" : null,
});
currentUserId = userId;
currentToken = token;
token: token ? '***' : null,
})
currentUserId = userId
currentToken = token
pendingConnection = true;
wsClient = createWebSocket(userId, token, options);
pendingConnection = true
wsClient = createWebSocket(userId, token, options)
// Mark connection as no longer pending when connected or failed
const originalOnOpen = wsClient.onOpen;
const originalOnError = wsClient.onError;
const originalOnClose = wsClient.onClose;
const originalOnOpen = wsClient.onOpen
const originalOnError = wsClient.onError
const originalOnClose = wsClient.onClose
wsClient.onOpen = (event) => {
pendingConnection = null;
console.log("WebSocket: Connection established", { userId });
if (originalOnOpen) originalOnOpen(event);
};
pendingConnection = null
console.log('WebSocket: Connection established', { userId })
if (originalOnOpen) originalOnOpen(event)
}
wsClient.onError = (error) => {
pendingConnection = null;
console.error("WebSocket: Connection error", { userId, error });
if (originalOnError) originalOnError(error);
};
pendingConnection = null
console.error('WebSocket: Connection error', { userId, error })
if (originalOnError) originalOnError(error)
}
wsClient.onClose = (event) => {
pendingConnection = null;
console.log("WebSocket: Connection closed", {
pendingConnection = null
console.log('WebSocket: Connection closed', {
userId,
code: event.code,
reason: event.reason,
});
if (originalOnClose) originalOnClose(event);
};
})
if (originalOnClose) originalOnClose(event)
}
return wsClient;
return wsClient
}
export function closeWebSocket() {
if (wsClient) {
console.log("WebSocket: Closing connection manually");
wsClient.disconnect();
console.log('WebSocket: Closing connection manually')
wsClient.disconnect()
// Clean up event handlers
if (wsClient.ws) {
wsClient.ws.onopen = null;
wsClient.ws.onmessage = null;
wsClient.ws.onerror = null;
wsClient.ws.onclose = null;
wsClient.ws = null;
wsClient.ws.onopen = null
wsClient.ws.onmessage = null
wsClient.ws.onerror = null
wsClient.ws.onclose = null
wsClient.ws = null
}
wsClient = null;
currentUserId = null;
currentToken = null;
pendingConnection = null;
wsClient = null
currentUserId = null
currentToken = null
pendingConnection = null
}
}
export default WebSocketClient;
export default WebSocketClient