Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f7a1469b | |||
| bd07886ffa | |||
| 8b0e5a5642 | |||
| 2a9cb82fef | |||
| 3f58d013ca | |||
| 01e87acfd1 | |||
| 42be40ee9f | |||
| 1134ecb732 |
+1
-1
@@ -17,11 +17,11 @@
|
|||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"@ckeditor/ckeditor5-vue": "^7.3.0",
|
"@ckeditor/ckeditor5-vue": "^7.3.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"ant-design-vue": "^4.2.6",
|
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
"ckeditor5": "^47.4.0",
|
"ckeditor5": "^47.4.0",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
|
"element-plus": "^2.13.1",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"path-browserify": "^1.0.1",
|
"path-browserify": "^1.0.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
|
|||||||
+9
-89
@@ -1,92 +1,12 @@
|
|||||||
<script setup>
|
|
||||||
import { onMounted, computed, watch, nextTick } from 'vue'
|
|
||||||
import { storeToRefs } from 'pinia'
|
|
||||||
import { useI18nStore } from './stores/modules/i18n'
|
|
||||||
import { useLayoutStore } from './stores/modules/layout'
|
|
||||||
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'
|
|
||||||
})
|
|
||||||
|
|
||||||
// i18n store
|
|
||||||
const i18nStore = useI18nStore()
|
|
||||||
|
|
||||||
// layout store
|
|
||||||
const layoutStore = useLayoutStore()
|
|
||||||
|
|
||||||
// 解构 themeColor 以确保响应式
|
|
||||||
const { themeColor } = storeToRefs(layoutStore)
|
|
||||||
|
|
||||||
// Ant Design Vue 语言配置
|
|
||||||
const antLocale = computed(() => {
|
|
||||||
return i18nStore.currentLocale === 'zh-CN' ? zhCN : enUS
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取弹出容器
|
|
||||||
const getPopupContainer = () => {
|
|
||||||
return document.body
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ant Design Vue 主题配置
|
|
||||||
const antdTheme = computed(() => {
|
|
||||||
return {
|
|
||||||
algorithm: theme.defaultAlgorithm,
|
|
||||||
token: {
|
|
||||||
colorPrimary: themeColor.value || '#1890ff',
|
|
||||||
borderRadius: 6,
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
Layout: {
|
|
||||||
headerBg: '#fff',
|
|
||||||
siderBg: '#001529',
|
|
||||||
},
|
|
||||||
Menu: {
|
|
||||||
darkItemBg: '#001529',
|
|
||||||
darkItemSelectedBg: themeColor.value || '#1890ff',
|
|
||||||
darkItemHoverBg: '#002140',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听主题颜色变化,更新 CSS 变量
|
|
||||||
watch(
|
|
||||||
themeColor,
|
|
||||||
(newColor) => {
|
|
||||||
if (newColor) {
|
|
||||||
document.documentElement.style.setProperty('--primary-color', newColor)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
// 从持久化的 store 中读取语言设置并同步到 i18n
|
|
||||||
i18n.global.locale.value = i18nStore.currentLocale
|
|
||||||
|
|
||||||
// 同步 dayjs 语言
|
|
||||||
dayjs.locale(i18nStore.currentLocale === 'zh-CN' ? 'zh-cn' : 'en')
|
|
||||||
|
|
||||||
// 初始化主题颜色到 CSS 变量
|
|
||||||
if (layoutStore.themeColor) {
|
|
||||||
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a-config-provider :locale="antLocale" :theme="antdTheme" :getPopupContainer="getPopupContainer">
|
<el-config-provider :size="size" :z-index="zIndex">
|
||||||
<router-view />
|
<router-view />
|
||||||
</a-config-provider>
|
</el-config-provider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const size = ref('default')
|
||||||
|
const zIndex = ref(3000)
|
||||||
|
</script>
|
||||||
|
|||||||
+38
-38
@@ -21,97 +21,97 @@ export default {
|
|||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
list: {
|
list: {
|
||||||
name: "获得用户列表",
|
name: '获得用户列表',
|
||||||
get: async function (params) {
|
get: async function (params) {
|
||||||
return await request.get('auth/users/index', { params });
|
return await request.get('auth/users/index', { params })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
add: {
|
add: {
|
||||||
name: "添加用户",
|
name: '添加用户',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.post('auth/users/add', params);
|
return await request.post('auth/users/add', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
name: "编辑用户",
|
name: '编辑用户',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/users/edit', params);
|
return await request.put('auth/users/edit', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
uppasswd: {
|
uppasswd: {
|
||||||
name: "修改密码",
|
name: '修改密码',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/users/passwd', params);
|
return await request.put('auth/users/passwd', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
uprole: {
|
uprole: {
|
||||||
name: "设置角色",
|
name: '设置角色',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/users/uprole', params);
|
return await request.put('auth/users/uprole', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
name: "删除用户",
|
name: '删除用户',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.delete('auth/users/delete', params);
|
return await request.delete('auth/users/delete', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
list: {
|
list: {
|
||||||
name: "获得角色列表",
|
name: '获得角色列表',
|
||||||
get: async function (params) {
|
get: async function (params) {
|
||||||
return await request.get('auth/role/index', { params });
|
return await request.get('auth/role/index', { params })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
add: {
|
add: {
|
||||||
name: "添加角色",
|
name: '添加角色',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.post('auth/role/add', params);
|
return await request.post('auth/role/add', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
name: "编辑角色",
|
name: '编辑角色',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/role/edit', params);
|
return await request.put('auth/role/edit', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
name: "角色授权",
|
name: '角色授权',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/role/auth', params);
|
return await request.put('auth/role/auth', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
name: "删除角色",
|
name: '删除角色',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.delete('auth/role/delete', params);
|
return await request.delete('auth/role/delete', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
department: {
|
department: {
|
||||||
list: {
|
list: {
|
||||||
name: "获得部门列表",
|
name: '获得部门列表',
|
||||||
get: async function (params) {
|
get: async function (params) {
|
||||||
return await request.get('auth/department/index', { params });
|
return await request.get('auth/department/index', { params })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
add: {
|
add: {
|
||||||
name: "添加部门",
|
name: '添加部门',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.post('auth/department/add', params);
|
return await request.post('auth/department/add', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
name: "编辑部门",
|
name: '编辑部门',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/department/edit', params);
|
return await request.put('auth/department/edit', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
name: "删除部门",
|
name: '删除部门',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.delete('auth/department/delete', params);
|
return await request.delete('auth/department/delete', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -123,27 +123,27 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
name: "获取菜单",
|
name: '获取菜单',
|
||||||
get: async function (params) {
|
get: async function (params) {
|
||||||
return await request.get('auth/menu/index', { params });
|
return await request.get('auth/menu/index', { params })
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
add: {
|
add: {
|
||||||
name: "添加菜单",
|
name: '添加菜单',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.post('auth/menu/add', params);
|
return await request.post('auth/menu/add', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
name: "编辑菜单",
|
name: '编辑菜单',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.put('auth/menu/edit', params);
|
return await request.put('auth/menu/edit', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
delete: {
|
delete: {
|
||||||
name: "删除菜单",
|
name: '删除菜单',
|
||||||
post: async function (params) {
|
post: async function (params) {
|
||||||
return await request.delete('auth/menu/delete', params);
|
return await request.delete('auth/menu/delete', params)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import request from '../utils/request'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户菜单
|
|
||||||
* @returns {Promise} 菜单数据
|
|
||||||
*/
|
|
||||||
export function getUserMenu() {
|
|
||||||
return request({
|
|
||||||
url: '/menu',
|
|
||||||
method: 'get'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户权限
|
|
||||||
* @returns {Promise} 权限数据
|
|
||||||
*/
|
|
||||||
export function getUserPermissions() {
|
|
||||||
return request({
|
|
||||||
url: '/permissions',
|
|
||||||
method: 'get'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -66,7 +66,41 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
min-height: 100vh;
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 暗色主题样式 ====================
|
||||||
|
html.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
|
||||||
|
// Element Plus 暗色主题变量
|
||||||
|
--el-bg-color: #141414;
|
||||||
|
--el-bg-color-page: #0a0a0a;
|
||||||
|
--el-bg-color-overlay: #1d1e1f;
|
||||||
|
--el-text-color-primary: #e5eaf3;
|
||||||
|
--el-text-color-regular: #cfd3dc;
|
||||||
|
--el-text-color-secondary: #a3a6ad;
|
||||||
|
--el-text-color-placeholder: #8d9096;
|
||||||
|
--el-text-color-disabled: #6c6e72;
|
||||||
|
--el-border-color: #4c4d4f;
|
||||||
|
--el-border-color-light: #414243;
|
||||||
|
--el-border-color-lighter: #363637;
|
||||||
|
--el-border-color-extra-light: #2b2b2c;
|
||||||
|
--el-border-color-dark: #58585b;
|
||||||
|
--el-border-color-darker: #636466;
|
||||||
|
--el-fill-color: #2b2b2c;
|
||||||
|
--el-fill-color-light: #262727;
|
||||||
|
--el-fill-color-lighter: #212121;
|
||||||
|
--el-fill-color-extra-light: #191919;
|
||||||
|
--el-fill-color-dark: #303030;
|
||||||
|
--el-fill-color-darker: #363637;
|
||||||
|
--el-fill-color-blank: transparent;
|
||||||
|
--el-box-shadow: 0px 12px 32px 4px rgba(0, 0, 0, 0.36), 0px 8px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
--el-box-shadow-light: 0px 0px 12px rgba(0, 0, 0, 0.72);
|
||||||
|
--el-box-shadow-lighter: 0px 0px 6px rgba(0, 0, 0, 0.54);
|
||||||
|
--el-box-shadow-dark: 0px 16px 48px 16px rgba(0, 0, 0, 0.72), 0px 12px 32px rgba(0, 0, 0, 0.36);
|
||||||
|
--el-border-color-base: #4c4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pages {
|
.pages {
|
||||||
|
|||||||
@@ -1,570 +0,0 @@
|
|||||||
// 认证页面统一样式文件
|
|
||||||
// 使用明亮暖色调配色方案
|
|
||||||
|
|
||||||
// ===== 颜色变量 =====
|
|
||||||
$primary-color: #ff6b35; // 橙红色
|
|
||||||
$primary-light: #ff8a5b; // 浅橙红色
|
|
||||||
$primary-dark: #e55a2b; // 深橙红色
|
|
||||||
$secondary-color: #ffd93d; // 金黄色
|
|
||||||
$accent-color: #ffb84d; // 橙黄色
|
|
||||||
|
|
||||||
$bg-dark: #1a1a2e; // 深色背景
|
|
||||||
$bg-light: #16213e; // 浅色背景
|
|
||||||
$bg-gradient-start: #0f0f23; // 渐变开始
|
|
||||||
$bg-gradient-end: #1a1a2e; // 渐变结束
|
|
||||||
|
|
||||||
$text-primary: #ffffff;
|
|
||||||
$text-secondary: rgba(255, 255, 255, 0.7);
|
|
||||||
$text-muted: rgba(255, 255, 255, 0.5);
|
|
||||||
|
|
||||||
$border-color: rgba(255, 255, 255, 0.08);
|
|
||||||
$border-hover: rgba(255, 107, 53, 0.3);
|
|
||||||
$border-focus: rgba(255, 107, 53, 0.6);
|
|
||||||
|
|
||||||
// ===== 基础容器 =====
|
|
||||||
.auth-container {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
background: linear-gradient(135deg, $bg-gradient-start 0%, $bg-gradient-end 100%);
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB',
|
|
||||||
'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 科技感背景 =====
|
|
||||||
.tech-bg {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
// 网格线
|
|
||||||
.grid-line {
|
|
||||||
position: absolute;
|
|
||||||
width: 100%;
|
|
||||||
height: 1px;
|
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 107, 53, 0.08), transparent);
|
|
||||||
animation: gridMove 8s linear infinite;
|
|
||||||
|
|
||||||
&:nth-child(1) { top: 20%; animation-delay: 0s; }
|
|
||||||
&:nth-child(2) { top: 40%; animation-delay: 2s; }
|
|
||||||
&:nth-child(3) { top: 60%; animation-delay: 4s; }
|
|
||||||
&:nth-child(4) { top: 80%; animation-delay: 6s; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 光点效果
|
|
||||||
.light-spot {
|
|
||||||
position: absolute;
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
background: $primary-color;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 10px $primary-color, 0 0 20px $primary-color;
|
|
||||||
animation: float 6s ease-in-out infinite;
|
|
||||||
|
|
||||||
&:nth-child(5) { top: 15%; left: 20%; animation-delay: 0s; }
|
|
||||||
&:nth-child(6) { top: 25%; left: 70%; animation-delay: 2s; }
|
|
||||||
&:nth-child(7) { top: 55%; left: 15%; animation-delay: 4s; }
|
|
||||||
&:nth-child(8) { top: 75%; left: 80%; animation-delay: 1s; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gridMove {
|
|
||||||
0% { transform: translateX(-100%); }
|
|
||||||
100% { transform: translateX(100%); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
0%, 100% { transform: translateY(0) scale(1); opacity: 0.6; }
|
|
||||||
50% { transform: translateY(-20px) scale(1.2); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 主卡片 =====
|
|
||||||
.auth-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 960px;
|
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
background: rgba(255, 255, 255, 0.02);
|
|
||||||
backdrop-filter: blur(24px);
|
|
||||||
-webkit-backdrop-filter: blur(24px);
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 0;
|
|
||||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
|
||||||
border: 1px solid $border-color;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
min-height: 580px;
|
|
||||||
animation: cardFadeIn 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes cardFadeIn {
|
|
||||||
0% { opacity: 0; transform: translateY(20px); }
|
|
||||||
100% { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 左侧装饰区 =====
|
|
||||||
.decoration-area {
|
|
||||||
flex: 1;
|
|
||||||
background: linear-gradient(135deg, rgba(255, 107, 53, 0.08) 0%, rgba(255, 217, 61, 0.03) 100%);
|
|
||||||
padding: 60px 40px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
border-right: 1px solid $border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tech-circle {
|
|
||||||
position: relative;
|
|
||||||
width: 220px;
|
|
||||||
height: 220px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
|
|
||||||
.circle-inner {
|
|
||||||
width: 110px;
|
|
||||||
height: 110px;
|
|
||||||
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 50px rgba(255, 107, 53, 0.4);
|
|
||||||
animation: pulse 3s ease-in-out infinite;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
background: linear-gradient(135deg, $secondary-color 0%, $accent-color 100%);
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 30px rgba(255, 217, 61, 0.5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-ring {
|
|
||||||
position: absolute;
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
border: 2px solid rgba(255, 107, 53, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: rotate 12s linear infinite;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: -2px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
background: $primary-color;
|
|
||||||
border-radius: 50%;
|
|
||||||
box-shadow: 0 0 15px $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-ring-2 {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
border: 1px solid rgba(255, 217, 61, 0.15);
|
|
||||||
animation: rotate 18s linear infinite reverse;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes pulse {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 1; }
|
|
||||||
50% { transform: scale(1.08); opacity: 0.85; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotate {
|
|
||||||
0% { transform: rotate(0deg); }
|
|
||||||
100% { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.decoration-text {
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 16px;
|
|
||||||
font-size: 32px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, $primary-color 0%, $secondary-color 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 400;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 右侧表单区 =====
|
|
||||||
.form-area {
|
|
||||||
flex: 1.3;
|
|
||||||
padding: 60px 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0 0 12px;
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 700;
|
|
||||||
background: linear-gradient(135deg, $primary-color 0%, $accent-color 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 0;
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 表单样式 =====
|
|
||||||
.auth-form {
|
|
||||||
margin-top: 0;
|
|
||||||
|
|
||||||
:deep(.ant-form-item) {
|
|
||||||
margin-bottom: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-form-item-label > label) {
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 输入框样式
|
|
||||||
:deep(.ant-input-affix-wrapper),
|
|
||||||
:deep(.ant-input) {
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
border: 1px solid $border-color;
|
|
||||||
border-radius: 12px;
|
|
||||||
color: $text-primary;
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-size: 15px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-color: $border-hover;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&.ant-input-affix-wrapper-focused {
|
|
||||||
background: rgba(255, 255, 255, 0.06);
|
|
||||||
border-color: $primary-color;
|
|
||||||
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-input::placeholder) {
|
|
||||||
color: $text-muted;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-input-affix-wrapper > input.ant-input) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图标样式
|
|
||||||
:deep(.anticon) {
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: 16px;
|
|
||||||
transition: color 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-input-affix-wrapper-focused .anticon) {
|
|
||||||
color: $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 按钮样式 =====
|
|
||||||
.auth-form :deep(.ant-btn-primary) {
|
|
||||||
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
height: 48px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 16px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%);
|
|
||||||
box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
transform: translateY(0);
|
|
||||||
box-shadow: 0 2px 10px rgba(255, 107, 53, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: $text-muted;
|
|
||||||
box-shadow: none;
|
|
||||||
transform: none;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 表单选项 =====
|
|
||||||
.form-options {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 26px;
|
|
||||||
|
|
||||||
:deep(.ant-checkbox-wrapper) {
|
|
||||||
color: $text-primary;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.ant-checkbox {
|
|
||||||
.ant-checkbox-inner {
|
|
||||||
border-color: $border-color;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
|
||||||
background: $primary-color;
|
|
||||||
border-color: $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.forgot-password {
|
|
||||||
color: $primary-color;
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $primary-light;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 验证码输入框 =====
|
|
||||||
.code-input-wrapper {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
|
|
||||||
.code-input {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-btn {
|
|
||||||
width: 150px;
|
|
||||||
white-space: nowrap;
|
|
||||||
background: linear-gradient(135deg, $primary-color 0%, $primary-light 100%);
|
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 14px;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-shadow: 0 4px 15px rgba(255, 107, 53, 0.3);
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: linear-gradient(135deg, $primary-light 0%, $accent-color 100%);
|
|
||||||
box-shadow: 0 6px 25px rgba(255, 107, 53, 0.4);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active:not(:disabled) {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
color: $text-muted;
|
|
||||||
box-shadow: none;
|
|
||||||
transform: none;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 协议复选框 =====
|
|
||||||
.agreement-checkbox {
|
|
||||||
:deep(.ant-checkbox-wrapper) {
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: 13px;
|
|
||||||
align-items: flex-start;
|
|
||||||
line-height: 1.6;
|
|
||||||
|
|
||||||
.ant-checkbox {
|
|
||||||
margin-top: 2px;
|
|
||||||
|
|
||||||
.ant-checkbox-inner {
|
|
||||||
border-color: $border-color;
|
|
||||||
background: rgba(255, 255, 255, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-checkbox-checked .ant-checkbox-inner {
|
|
||||||
background: $primary-color;
|
|
||||||
border-color: $primary-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.agreement-text {
|
|
||||||
font-size: 13px;
|
|
||||||
line-height: 1.6;
|
|
||||||
color: $text-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
color: $primary-color;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $primary-light;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 表单底部 =====
|
|
||||||
.form-footer {
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 28px;
|
|
||||||
color: $text-secondary;
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.auth-link {
|
|
||||||
color: $primary-color;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-left: 6px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $primary-light;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 响应式设计 =====
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.auth-card {
|
|
||||||
flex-direction: column;
|
|
||||||
min-height: auto;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.decoration-area {
|
|
||||||
padding: 48px 24px;
|
|
||||||
border-right: none;
|
|
||||||
border-bottom: 1px solid $border-color;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tech-circle {
|
|
||||||
width: 160px;
|
|
||||||
height: 160px;
|
|
||||||
|
|
||||||
.circle-inner {
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
width: 45px;
|
|
||||||
height: 45px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-ring {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.circle-ring-2 {
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.decoration-text {
|
|
||||||
h2 {
|
|
||||||
font-size: 26px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-area {
|
|
||||||
padding: 48px 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header {
|
|
||||||
h1 {
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.code-input-wrapper {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.code-btn {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.auth-wrapper {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-card {
|
|
||||||
border-radius: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-area {
|
|
||||||
padding: 36px 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-header {
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
// Auth Pages - Warm Tech Theme
|
||||||
|
// Warm color palette with tech-inspired design
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--auth-primary: #ff6b35;
|
||||||
|
--auth-primary-light: #ff8c5a;
|
||||||
|
--auth-primary-dark: #e55a2b;
|
||||||
|
--auth-secondary: #ffb347;
|
||||||
|
--accent-orange: #ffa500;
|
||||||
|
--accent-coral: #ff7f50;
|
||||||
|
--accent-amber: #ffc107;
|
||||||
|
|
||||||
|
--bg-gradient-start: #fff5f0;
|
||||||
|
--bg-gradient-end: #ffe8dc;
|
||||||
|
--card-bg: rgba(255, 255, 255, 0.95);
|
||||||
|
|
||||||
|
--text-primary: #2d1810;
|
||||||
|
--text-secondary: #6b4423;
|
||||||
|
--text-muted: #a67c52;
|
||||||
|
|
||||||
|
--border-color: #ffd4b8;
|
||||||
|
--shadow-color: rgba(255, 107, 53, 0.15);
|
||||||
|
|
||||||
|
--success: #28a745;
|
||||||
|
--warning: #ffc107;
|
||||||
|
--error: #dc3545;
|
||||||
|
|
||||||
|
--tech-blue: #007bff;
|
||||||
|
--tech-purple: #6f42c1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
// Tech pattern background
|
||||||
|
&::before {
|
||||||
|
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%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated tech elements
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: radial-gradient(circle, rgba(255, 107, 53, 0.08) 0%, transparent 70%);
|
||||||
|
border-radius: 50%;
|
||||||
|
top: -200px;
|
||||||
|
right: -200px;
|
||||||
|
animation: float 20s ease-in-out infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-50px, 50px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 48px 40px;
|
||||||
|
box-shadow:
|
||||||
|
0 20px 60px var(--shadow-color),
|
||||||
|
0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
margin: 20px;
|
||||||
|
|
||||||
|
// Tech accent line
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 80px;
|
||||||
|
height: 4px;
|
||||||
|
background: linear-gradient(90deg, var(--auth-primary), var(--auth-secondary));
|
||||||
|
border-radius: 0 0 4px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
|
||||||
|
.auth-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-form {
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
--el-input-border-radius: 12px;
|
||||||
|
--el-input-border-color: var(--border-color);
|
||||||
|
--el-input-hover-border-color: var(--auth-primary-light);
|
||||||
|
--el-input-focus-border-color: var(--auth-primary);
|
||||||
|
|
||||||
|
.el-input__wrapper {
|
||||||
|
padding: 12px 16px;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 53, 0.08);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.is-focus {
|
||||||
|
box-shadow: 0 4px 16px rgba(255, 107, 53, 0.15);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__inner {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__prefix {
|
||||||
|
color: var(--auth-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__suffix {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
--el-button-border-radius: 12px;
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&.el-button--primary {
|
||||||
|
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));
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-links {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.remember-me {
|
||||||
|
.el-checkbox__label {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password {
|
||||||
|
color: var(--auth-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--auth-primary-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-divider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin: 32px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-footer {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
|
||||||
|
.auth-footer-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.auth-link {
|
||||||
|
color: var(--auth-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 4px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--auth-primary-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-decoration {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.tech-circle {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(1) {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
top: -150px;
|
||||||
|
left: -150px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(2) {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
bottom: -100px;
|
||||||
|
right: -100px;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(3) {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
bottom: 20%;
|
||||||
|
left: -75px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive design
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.auth-card {
|
||||||
|
padding: 40px 24px;
|
||||||
|
margin: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-header {
|
||||||
|
.auth-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element Plus customizations for auth pages
|
||||||
|
.el-form-item__error {
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-message {
|
||||||
|
--el-message-bg: rgba(255, 255, 255, 0.98);
|
||||||
|
--el-message-border-color: var(--border-color);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
+3
-4
@@ -3,13 +3,12 @@ import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
install(app) {
|
install(app) {
|
||||||
|
|
||||||
for (let icon in AIcons) {
|
for (let icon in AIcons) {
|
||||||
app.component(`${icon}`, AIcons[icon])
|
app.component(`A${icon}`, AIcons[icon])
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
app.component(`El${key}`, component)
|
app.component(`ElIcon${key}`, component)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,144 +1,144 @@
|
|||||||
export default class UploadAdapter {
|
export default class UploadAdapter {
|
||||||
constructor(loader, options) {
|
constructor(loader, options) {
|
||||||
this.loader = loader;
|
this.loader = loader
|
||||||
this.options = options;
|
this.options = options
|
||||||
this.timeout = 60000; // 60秒超时
|
this.timeout = 60000 // 60秒超时
|
||||||
}
|
}
|
||||||
|
|
||||||
upload() {
|
upload() {
|
||||||
return this.loader.file.then(
|
return this.loader.file.then(
|
||||||
(file) =>
|
(file) =>
|
||||||
new Promise((resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
this._initRequest();
|
this._initRequest()
|
||||||
this._initListeners(resolve, reject, file);
|
this._initListeners(resolve, reject, file)
|
||||||
this._sendRequest(file);
|
this._sendRequest(file)
|
||||||
this._initTimeout(reject);
|
this._initTimeout(reject)
|
||||||
}),
|
}),
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
abort() {
|
abort() {
|
||||||
if (this.xhr) {
|
if (this.xhr) {
|
||||||
this.xhr.abort();
|
this.xhr.abort()
|
||||||
}
|
}
|
||||||
if (this.timeoutId) {
|
if (this.timeoutId) {
|
||||||
clearTimeout(this.timeoutId);
|
clearTimeout(this.timeoutId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_initRequest() {
|
_initRequest() {
|
||||||
const xhr = (this.xhr = new XMLHttpRequest());
|
const xhr = (this.xhr = new XMLHttpRequest())
|
||||||
|
|
||||||
xhr.open("POST", this.options.upload.uploadUrl, true);
|
xhr.open('POST', this.options.upload.uploadUrl, true)
|
||||||
xhr.responseType = "json";
|
xhr.responseType = 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
_initListeners(resolve, reject, file) {
|
_initListeners(resolve, reject, file) {
|
||||||
const xhr = this.xhr;
|
const xhr = this.xhr
|
||||||
const loader = this.loader;
|
const loader = this.loader
|
||||||
const genericErrorText = `Couldn't upload file: ${file.name}.`;
|
const genericErrorText = `Couldn't upload file: ${file.name}.`
|
||||||
|
|
||||||
xhr.addEventListener("error", () => {
|
xhr.addEventListener('error', () => {
|
||||||
console.error("[UploadAdapter] Upload error for file:", file.name);
|
console.error('[UploadAdapter] Upload error for file:', file.name)
|
||||||
reject(genericErrorText);
|
reject(genericErrorText)
|
||||||
});
|
})
|
||||||
|
|
||||||
xhr.addEventListener("abort", () => {
|
xhr.addEventListener('abort', () => {
|
||||||
console.warn("[UploadAdapter] Upload aborted for file:", file.name);
|
console.warn('[UploadAdapter] Upload aborted for file:', file.name)
|
||||||
reject();
|
reject()
|
||||||
});
|
})
|
||||||
|
|
||||||
xhr.addEventListener("timeout", () => {
|
xhr.addEventListener('timeout', () => {
|
||||||
console.error("[UploadAdapter] Upload timeout for file:", file.name);
|
console.error('[UploadAdapter] Upload timeout for file:', file.name)
|
||||||
reject(`Upload timeout: ${file.name}. Please try again.`);
|
reject(`Upload timeout: ${file.name}. Please try again.`)
|
||||||
});
|
})
|
||||||
|
|
||||||
xhr.addEventListener("load", () => {
|
xhr.addEventListener('load', () => {
|
||||||
const response = xhr.response;
|
const response = xhr.response
|
||||||
|
|
||||||
// 检查响应状态码
|
// 检查响应状态码
|
||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
if (!response) {
|
if (!response) {
|
||||||
console.error("[UploadAdapter] Empty response for file:", file.name);
|
console.error('[UploadAdapter] Empty response for file:', file.name)
|
||||||
reject(genericErrorText);
|
reject(genericErrorText)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查业务状态码(假设 code=1 表示成功)
|
// 检查业务状态码(假设 code=1 表示成功)
|
||||||
if (response.code == 1 || response.code == undefined) {
|
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) {
|
if (!url) {
|
||||||
console.error("[UploadAdapter] No URL in response for file:", file.name, response);
|
console.error('[UploadAdapter] No URL in response for file:', file.name, response)
|
||||||
reject("Upload succeeded but no URL returned");
|
reject('Upload succeeded but no URL returned')
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
resolve({ default: url });
|
resolve({ default: url })
|
||||||
} else {
|
} else {
|
||||||
const errorMessage = response.message || genericErrorText;
|
const errorMessage = response.message || genericErrorText
|
||||||
console.error("[UploadAdapter] Upload failed for file:", file.name, "Error:", errorMessage);
|
console.error('[UploadAdapter] Upload failed for file:', file.name, 'Error:', errorMessage)
|
||||||
reject(errorMessage);
|
reject(errorMessage)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error("[UploadAdapter] HTTP error for file:", file.name, "Status:", xhr.status);
|
console.error('[UploadAdapter] HTTP error for file:', file.name, 'Status:', xhr.status)
|
||||||
reject(`Server error (${xhr.status}): ${file.name}`);
|
reject(`Server error (${xhr.status}): ${file.name}`)
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
// 上传进度监听
|
// 上传进度监听
|
||||||
if (xhr.upload) {
|
if (xhr.upload) {
|
||||||
xhr.upload.addEventListener("progress", (evt) => {
|
xhr.upload.addEventListener('progress', (evt) => {
|
||||||
if (evt.lengthComputable) {
|
if (evt.lengthComputable) {
|
||||||
loader.uploadTotal = evt.total;
|
loader.uploadTotal = evt.total
|
||||||
loader.uploaded = evt.loaded;
|
loader.uploaded = evt.loaded
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_initTimeout(reject) {
|
_initTimeout(reject) {
|
||||||
// 清除之前的超时定时器(如果有)
|
// 清除之前的超时定时器(如果有)
|
||||||
if (this.timeoutId) {
|
if (this.timeoutId) {
|
||||||
clearTimeout(this.timeoutId);
|
clearTimeout(this.timeoutId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置新的超时定时器
|
// 设置新的超时定时器
|
||||||
this.timeoutId = setTimeout(() => {
|
this.timeoutId = setTimeout(() => {
|
||||||
if (this.xhr) {
|
if (this.xhr) {
|
||||||
this.xhr.abort();
|
this.xhr.abort()
|
||||||
reject(new Error("Upload timeout"));
|
reject(new Error('Upload timeout'))
|
||||||
}
|
}
|
||||||
}, this.timeout);
|
}, this.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
_sendRequest(file) {
|
_sendRequest(file) {
|
||||||
// 设置请求超时
|
// 设置请求超时
|
||||||
this.xhr.timeout = this.timeout;
|
this.xhr.timeout = this.timeout
|
||||||
|
|
||||||
// Set headers if specified.
|
// Set headers if specified.
|
||||||
const headers = this.options.upload.headers || {};
|
const headers = this.options.upload.headers || {}
|
||||||
const extendData = this.options.upload.extendData || {};
|
const extendData = this.options.upload.extendData || {}
|
||||||
// Use the withCredentials flag if specified.
|
// Use the withCredentials flag if specified.
|
||||||
const withCredentials = this.options.upload.withCredentials || false;
|
const withCredentials = this.options.upload.withCredentials || false
|
||||||
const uploadName = this.options.upload.uploadName || "file";
|
const uploadName = this.options.upload.uploadName || 'file'
|
||||||
|
|
||||||
for (const headerName of Object.keys(headers)) {
|
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)) {
|
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) {
|
export function UploadAdapterPlugin(editor) {
|
||||||
editor.plugins.get("FileRepository").createUploadAdapter = (loader) => {
|
editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
|
||||||
return new UploadAdapter(loader, editor.config._config);
|
return new UploadAdapter(loader, editor.config._config)
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+114
-144
@@ -1,7 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :style="{ '--editor-height': editorHeight }">
|
<div :style="{ '--editor-height': editorHeight }">
|
||||||
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur"
|
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur" @focus="onFocus"></ckeditor>
|
||||||
@focus="onFocus"></ckeditor>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -69,137 +68,119 @@ import {
|
|||||||
Underline,
|
Underline,
|
||||||
Undo,
|
Undo,
|
||||||
WordCount,
|
WordCount,
|
||||||
} from "ckeditor5";
|
} from 'ckeditor5'
|
||||||
import { Ckeditor } from "@ckeditor/ckeditor5-vue";
|
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
|
||||||
import { UploadAdapterPlugin } from "./UploadAdapter.js";
|
import { UploadAdapterPlugin } from './UploadAdapter.js'
|
||||||
|
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed, watch } from 'vue'
|
||||||
import { useCurrentInstance } from "@/utils/tool";
|
import { useCurrentInstance } from '@/utils/tool'
|
||||||
|
|
||||||
import coreTranslations from "ckeditor5/translations/zh-cn.js";
|
import coreTranslations from 'ckeditor5/translations/zh-cn.js'
|
||||||
import "ckeditor5/ckeditor5.css";
|
import 'ckeditor5/ckeditor5.css'
|
||||||
|
|
||||||
const { proxy } = useCurrentInstance();
|
const { proxy } = useCurrentInstance()
|
||||||
|
|
||||||
// 组件名称
|
// 组件名称
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: "scCkeditor"
|
name: 'scCkeditor',
|
||||||
});
|
})
|
||||||
|
|
||||||
// Props 定义
|
// Props 定义
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "",
|
default: '',
|
||||||
},
|
},
|
||||||
placeholder: {
|
placeholder: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "请输入内容……",
|
default: '请输入内容……',
|
||||||
},
|
},
|
||||||
toolbar: {
|
toolbar: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "basic",
|
default: 'basic',
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
type: String,
|
type: String,
|
||||||
default: "400px",
|
default: '400px',
|
||||||
},
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|
||||||
// Emits 定义
|
// Emits 定义
|
||||||
const emit = defineEmits(["update:modelValue"]);
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
// 工具栏配置常量
|
// 工具栏配置常量
|
||||||
const TOOLBARS = {
|
const TOOLBARS = {
|
||||||
full: [
|
full: [
|
||||||
"sourceEditing",
|
'sourceEditing',
|
||||||
"undo",
|
'undo',
|
||||||
"redo",
|
'redo',
|
||||||
"heading",
|
'heading',
|
||||||
"style",
|
'style',
|
||||||
"|",
|
'|',
|
||||||
"superscript",
|
'superscript',
|
||||||
"subscript",
|
'subscript',
|
||||||
"removeFormat",
|
'removeFormat',
|
||||||
"bold",
|
'bold',
|
||||||
"italic",
|
'italic',
|
||||||
"underline",
|
'underline',
|
||||||
"link",
|
'link',
|
||||||
"fontBackgroundColor",
|
'fontBackgroundColor',
|
||||||
"fontFamily",
|
'fontFamily',
|
||||||
"fontSize",
|
'fontSize',
|
||||||
"fontColor",
|
'fontColor',
|
||||||
"|",
|
'|',
|
||||||
"outdent",
|
'outdent',
|
||||||
"indent",
|
'indent',
|
||||||
"alignment",
|
'alignment',
|
||||||
"bulletedList",
|
'bulletedList',
|
||||||
"numberedList",
|
'numberedList',
|
||||||
"todoList",
|
'todoList',
|
||||||
"|",
|
'|',
|
||||||
"blockQuote",
|
'blockQuote',
|
||||||
"insertTable",
|
'insertTable',
|
||||||
"imageInsert",
|
'imageInsert',
|
||||||
"mediaEmbed",
|
'mediaEmbed',
|
||||||
"highlight",
|
'highlight',
|
||||||
"horizontalLine",
|
'horizontalLine',
|
||||||
"selectAll",
|
'selectAll',
|
||||||
"showBlocks",
|
'showBlocks',
|
||||||
"specialCharacters",
|
'specialCharacters',
|
||||||
"codeBlock",
|
'codeBlock',
|
||||||
"findAndReplace",
|
'findAndReplace',
|
||||||
],
|
],
|
||||||
basic: [
|
basic: [
|
||||||
"sourceEditing",
|
'sourceEditing',
|
||||||
"undo",
|
'undo',
|
||||||
"redo",
|
'redo',
|
||||||
"heading",
|
'heading',
|
||||||
"|",
|
'|',
|
||||||
"removeFormat",
|
'removeFormat',
|
||||||
"bold",
|
'bold',
|
||||||
"italic",
|
'italic',
|
||||||
"underline",
|
'underline',
|
||||||
"link",
|
'link',
|
||||||
"fontBackgroundColor",
|
'fontBackgroundColor',
|
||||||
"fontFamily",
|
'fontFamily',
|
||||||
"fontSize",
|
'fontSize',
|
||||||
"fontColor",
|
'fontColor',
|
||||||
"|",
|
'|',
|
||||||
"outdent",
|
'outdent',
|
||||||
"indent",
|
'indent',
|
||||||
"alignment",
|
'alignment',
|
||||||
"bulletedList",
|
'bulletedList',
|
||||||
"numberedList",
|
'numberedList',
|
||||||
"todoList",
|
'todoList',
|
||||||
"|",
|
'|',
|
||||||
"insertTable",
|
'insertTable',
|
||||||
"imageInsert",
|
'imageInsert',
|
||||||
"mediaEmbed",
|
'mediaEmbed',
|
||||||
],
|
],
|
||||||
simple: [
|
simple: ['undo', 'redo', 'heading', '|', 'removeFormat', 'bold', 'italic', 'underline', 'link', 'fontBackgroundColor', 'fontFamily', 'fontSize', 'fontColor', '|', 'insertTable', 'imageInsert', 'mediaEmbed'],
|
||||||
"undo",
|
}
|
||||||
"redo",
|
|
||||||
"heading",
|
|
||||||
"|",
|
|
||||||
"removeFormat",
|
|
||||||
"bold",
|
|
||||||
"italic",
|
|
||||||
"underline",
|
|
||||||
"link",
|
|
||||||
"fontBackgroundColor",
|
|
||||||
"fontFamily",
|
|
||||||
"fontSize",
|
|
||||||
"fontColor",
|
|
||||||
"|",
|
|
||||||
"insertTable",
|
|
||||||
"imageInsert",
|
|
||||||
"mediaEmbed",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 插件配置常量
|
// 插件配置常量
|
||||||
const PLUGINS = [
|
const PLUGINS = [
|
||||||
@@ -265,16 +246,16 @@ const PLUGINS = [
|
|||||||
Undo,
|
Undo,
|
||||||
WordCount,
|
WordCount,
|
||||||
UploadAdapterPlugin,
|
UploadAdapterPlugin,
|
||||||
];
|
]
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const editorData = ref("");
|
const editorData = ref('')
|
||||||
const editorHeight = ref(props.height);
|
const editorHeight = ref(props.height)
|
||||||
const editor = ClassicEditor;
|
const editor = ClassicEditor
|
||||||
|
|
||||||
// 编辑器配置
|
// 编辑器配置
|
||||||
const editorConfig = computed(() => ({
|
const editorConfig = computed(() => ({
|
||||||
language: { ui: "zh-cn", content: "zh-cn" },
|
language: { ui: 'zh-cn', content: 'zh-cn' },
|
||||||
translations: [coreTranslations],
|
translations: [coreTranslations],
|
||||||
plugins: PLUGINS,
|
plugins: PLUGINS,
|
||||||
toolbar: {
|
toolbar: {
|
||||||
@@ -283,27 +264,18 @@ const editorConfig = computed(() => ({
|
|||||||
},
|
},
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
image: {
|
image: {
|
||||||
styles: ["alignLeft", "alignCenter", "alignRight"],
|
styles: ['alignLeft', 'alignCenter', 'alignRight'],
|
||||||
toolbar: [
|
toolbar: ['imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'linkImage'],
|
||||||
"imageTextAlternative",
|
|
||||||
"toggleImageCaption",
|
|
||||||
"|",
|
|
||||||
"imageStyle:alignLeft",
|
|
||||||
"imageStyle:alignCenter",
|
|
||||||
"imageStyle:alignRight",
|
|
||||||
"|",
|
|
||||||
"linkImage",
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
mediaEmbed: {
|
mediaEmbed: {
|
||||||
previewsInData: true,
|
previewsInData: true,
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
name: "mp4",
|
name: 'mp4',
|
||||||
url: /\.(mp4|avi|mov|flv|wmv|mkv)$/i,
|
url: /\.(mp4|avi|mov|flv|wmv|mkv)$/i,
|
||||||
html: match => {
|
html: (match) => {
|
||||||
const url = match["input"];
|
const url = match['input']
|
||||||
return ('<video controls width="100%" height="100%" src="' + url + '"></video>')
|
return '<video controls width="100%" height="100%" src="' + url + '"></video>'
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -314,59 +286,57 @@ const editorConfig = computed(() => ({
|
|||||||
style: {
|
style: {
|
||||||
definitions: [
|
definitions: [
|
||||||
{
|
{
|
||||||
name: "Article category",
|
name: 'Article category',
|
||||||
element: "h3",
|
element: 'h3',
|
||||||
classes: ["category"],
|
classes: ['category'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Info box",
|
name: 'Info box',
|
||||||
element: "p",
|
element: 'p',
|
||||||
classes: ["info-box"],
|
classes: ['info-box'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
upload: {
|
upload: {
|
||||||
uploadUrl: proxy?.$API?.common?.upload?.url || "",
|
uploadUrl: proxy?.$API?.common?.upload?.url || '',
|
||||||
withCredentials: false,
|
withCredentials: false,
|
||||||
extendData: { type: "images" },
|
extendData: { type: 'images' },
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: "Bearer " + proxy?.$TOOL?.data?.get("TOKEN"),
|
Authorization: 'Bearer ' + proxy?.$TOOL?.data?.get('TOKEN'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}))
|
||||||
|
|
||||||
// 监听 modelValue 变化
|
// 监听 modelValue 变化
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
editorData.value = newVal ?? "";
|
editorData.value = newVal ?? ''
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
);
|
)
|
||||||
|
|
||||||
// 监听 height 变化
|
// 监听 height 变化
|
||||||
watch(
|
watch(
|
||||||
() => props.height,
|
() => props.height,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
editorHeight.value = newVal;
|
editorHeight.value = newVal
|
||||||
}
|
},
|
||||||
);
|
)
|
||||||
|
|
||||||
// 移除图片宽高的正则替换函数
|
// 移除图片宽高的正则替换函数
|
||||||
const stripImageDimensions = (html) => {
|
const stripImageDimensions = (html) => {
|
||||||
return html.replace(/<img[^>]*>/gi, (match) => {
|
return html.replace(/<img[^>]*>/gi, (match) => {
|
||||||
return match
|
return match.replace(/width="[^"]*"/gi, '').replace(/height="[^"]*"/gi, '')
|
||||||
.replace(/width="[^"]*"/gi, "")
|
})
|
||||||
.replace(/height="[^"]*"/gi, "");
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 失去焦点事件 - 移除图片的固定宽高,避免响应式布局问题
|
// 失去焦点事件 - 移除图片的固定宽高,避免响应式布局问题
|
||||||
const onBlur = () => {
|
const onBlur = () => {
|
||||||
const cleanedData = stripImageDimensions(editorData.value);
|
const cleanedData = stripImageDimensions(editorData.value)
|
||||||
editorData.value = cleanedData;
|
editorData.value = cleanedData
|
||||||
emit("update:modelValue", cleanedData);
|
emit('update:modelValue', cleanedData)
|
||||||
};
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
<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">
|
|
||||||
<!-- 输入框 -->
|
|
||||||
<template v-if="item.type === 'input'">
|
|
||||||
<a-input v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
|
|
||||||
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :max-length="item.maxLength"
|
|
||||||
:type="item.inputType || 'text'" :prefix="item.prefix" :suffix="item.suffix"
|
|
||||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 文本域 -->
|
|
||||||
<template v-else-if="item.type === 'textarea'">
|
|
||||||
<a-textarea v-model:value="formData[item.field]" :placeholder="item.placeholder || `请输入${item.label}`"
|
|
||||||
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :rows="item.rows || 4"
|
|
||||||
:max-length="item.maxLength" :show-count="item.showCount"
|
|
||||||
@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])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 数字输入框 -->
|
|
||||||
<template v-else-if="item.type === 'number'">
|
|
||||||
<a-input-number v-model:value="formData[item.field]"
|
|
||||||
:placeholder="item.placeholder || `请输入${item.label}`" :disabled="item.disabled" :min="item.min"
|
|
||||||
:max="item.max" :step="item.step || 1" :precision="item.precision"
|
|
||||||
:controls="item.controls !== false" style="width: 100%"
|
|
||||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 下拉选择 -->
|
|
||||||
<template v-else-if="item.type === 'select'">
|
|
||||||
<a-select v-model:value="formData[item.field]" :placeholder="item.placeholder || `请选择${item.label}`"
|
|
||||||
:disabled="item.disabled" :allow-clear="item.allowClear !== false" :mode="item.mode"
|
|
||||||
:options="item.options" :field-names="item.fieldNames" style="width: 100%"
|
|
||||||
@change="item.onChange && item.onChange(formData[item.field])">
|
|
||||||
<template v-if="!item.options" #notFoundContent>
|
|
||||||
<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])">
|
|
||||||
<template v-if="item.options">
|
|
||||||
<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">
|
|
||||||
{{ opt.label }}
|
|
||||||
</a-radio-button>
|
|
||||||
</template>
|
|
||||||
</a-radio-group>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 多选框 -->
|
|
||||||
<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])">
|
|
||||||
<template v-if="item.options">
|
|
||||||
<a-checkbox v-for="opt in item.options" :key="opt.value" :value="opt.value"
|
|
||||||
:disabled="opt.disabled">
|
|
||||||
{{ opt.label }}
|
|
||||||
</a-checkbox>
|
|
||||||
</template>
|
|
||||||
</a-checkbox-group>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 开关 -->
|
|
||||||
<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])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 日期选择 -->
|
|
||||||
<template v-else-if="item.type === 'date'">
|
|
||||||
<a-date-picker v-model:value="formData[item.field]"
|
|
||||||
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
|
|
||||||
:format="item.format || 'YYYY-MM-DD'" :value-format="item.valueFormat || 'YYYY-MM-DD'"
|
|
||||||
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 日期范围选择 -->
|
|
||||||
<template v-else-if="item.type === 'dateRange'">
|
|
||||||
<a-range-picker v-model:value="formData[item.field]" :placeholder="item.placeholder || ['开始日期', '结束日期']"
|
|
||||||
:disabled="item.disabled" :format="item.format || 'YYYY-MM-DD'"
|
|
||||||
:value-format="item.valueFormat || 'YYYY-MM-DD'" style="width: 100%"
|
|
||||||
@change="item.onChange && item.onChange(formData[item.field])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 时间选择 -->
|
|
||||||
<template v-else-if="item.type === 'time'">
|
|
||||||
<a-time-picker v-model:value="formData[item.field]"
|
|
||||||
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
|
|
||||||
:format="item.format || 'HH:mm:ss'" :value-format="item.valueFormat || 'HH:mm:ss'"
|
|
||||||
style="width: 100%" @change="item.onChange && item.onChange(formData[item.field])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 上传 -->
|
|
||||||
<template v-else-if="item.type === 'upload'">
|
|
||||||
<a-upload v-model:file-list="formData[item.field]" :list-type="item.listType || 'text'"
|
|
||||||
:action="item.action" :max-count="item.maxCount" :before-upload="item.beforeUpload"
|
|
||||||
:custom-request="item.customRequest" :accept="item.accept" :disabled="item.disabled"
|
|
||||||
@change="(info) => item.onChange && item.onChange(info)">
|
|
||||||
<a-button v-if="item.listType !== 'picture-card'" type="primary">
|
|
||||||
<UploadOutlined />
|
|
||||||
点击上传
|
|
||||||
</a-button>
|
|
||||||
<div v-else>
|
|
||||||
<PlusOutlined />
|
|
||||||
<div class="ant-upload-text">上传</div>
|
|
||||||
</div>
|
|
||||||
</a-upload>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 评分 -->
|
|
||||||
<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])" />
|
|
||||||
</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])" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 级联选择 -->
|
|
||||||
<template v-else-if="item.type === 'cascader'">
|
|
||||||
<a-cascader v-model:value="formData[item.field]" :options="item.options"
|
|
||||||
:placeholder="item.placeholder || `请选择${item.label}`" :disabled="item.disabled"
|
|
||||||
:change-on-select="item.changeOnSelect" :field-names="item.fieldNames" style="width: 100%"
|
|
||||||
@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>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 提示信息 -->
|
|
||||||
<template v-if="item.tip">
|
|
||||||
<div class="form-item-tip">{{ item.tip }}</div>
|
|
||||||
</template>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<!-- 表单操作按钮 -->
|
|
||||||
<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>
|
|
||||||
<a-button v-if="showReset" @click="handleReset" :size="buttonSize">
|
|
||||||
{{ resetText || '重置' }}
|
|
||||||
</a-button>
|
|
||||||
<a-button v-if="showCancel" @click="handleCancel" :size="buttonSize">
|
|
||||||
{{ cancelText || '取消' }}
|
|
||||||
</a-button>
|
|
||||||
<slot name="actions"></slot>
|
|
||||||
</a-space>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<!-- 自定义插槽 -->
|
|
||||||
<slot></slot>
|
|
||||||
</a-form>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, watch } from 'vue'
|
|
||||||
import { Empty } from 'ant-design-vue'
|
|
||||||
import { UploadOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
// 表单项配置
|
|
||||||
formItems: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
// 表单初始值
|
|
||||||
initialValues: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
// 表单布局
|
|
||||||
layout: {
|
|
||||||
type: String,
|
|
||||||
default: 'horizontal', // horizontal, vertical, inline
|
|
||||||
},
|
|
||||||
// 标签宽度
|
|
||||||
labelCol: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ span: 6 }),
|
|
||||||
},
|
|
||||||
// 内容宽度
|
|
||||||
wrapperCol: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ span: 16 }),
|
|
||||||
},
|
|
||||||
// 是否显示操作按钮
|
|
||||||
showActions: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
// 操作按钮布局
|
|
||||||
actionWrapperCol: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({ offset: 6, span: 16 }),
|
|
||||||
},
|
|
||||||
// 是否显示重置按钮
|
|
||||||
showReset: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
// 是否显示取消按钮
|
|
||||||
showCancel: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 按钮文字
|
|
||||||
submitText: String,
|
|
||||||
resetText: String,
|
|
||||||
cancelText: String,
|
|
||||||
// 按钮大小
|
|
||||||
buttonSize: {
|
|
||||||
type: String,
|
|
||||||
default: 'middle',
|
|
||||||
},
|
|
||||||
// 加载状态
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['finish', 'finish-failed', 'reset', 'cancel'])
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = reactive({ ...props.initialValues })
|
|
||||||
|
|
||||||
// 表单验证规则
|
|
||||||
const rules = computed(() => {
|
|
||||||
const result = {}
|
|
||||||
props.formItems.forEach((item) => {
|
|
||||||
if (item.rules && item.rules.length > 0) {
|
|
||||||
result[item.field] = item.rules
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听初始值变化
|
|
||||||
watch(
|
|
||||||
() => props.initialValues,
|
|
||||||
(newVal) => {
|
|
||||||
Object.assign(formData, newVal)
|
|
||||||
},
|
|
||||||
{ deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// 表单提交
|
|
||||||
const handleFinish = (values) => {
|
|
||||||
emit('finish', values)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单验证失败
|
|
||||||
const handleFinishFailed = (errorInfo) => {
|
|
||||||
emit('finish-failed', errorInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置表单
|
|
||||||
const handleReset = () => {
|
|
||||||
Object.assign(formData, props.initialValues)
|
|
||||||
emit('reset', formData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消操作
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('cancel')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
formData,
|
|
||||||
resetForm: handleReset,
|
|
||||||
setFieldValue: (field, value) => {
|
|
||||||
formData[field] = value
|
|
||||||
},
|
|
||||||
getFieldValue: (field) => {
|
|
||||||
return formData[field]
|
|
||||||
},
|
|
||||||
setFieldsValue: (values) => {
|
|
||||||
Object.assign(formData, values)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.form-item-tip {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,508 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="sc-icon-picker">
|
|
||||||
<a-input :value="selectedIcon ? '' : ''" :placeholder="placeholder" readonly @click="handleOpenPicker">
|
|
||||||
<template #prefix v-if="selectedIcon">
|
|
||||||
<component :is="selectedIcon" />
|
|
||||||
</template>
|
|
||||||
<template #suffix>
|
|
||||||
<SearchOutlined v-if="!selectedIcon" />
|
|
||||||
<CloseCircleFilled v-else @click.stop="handleClear" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
|
|
||||||
<a-modal v-model:open="visible" title="选择图标" :width="800" :footer="null" @cancel="handleCancel">
|
|
||||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
|
||||||
<a-tab-pane key="antd" tab="Ant Design">
|
|
||||||
<div class="icon-search">
|
|
||||||
<a-input v-model:value="searchAntdValue" placeholder="搜索图标..." allow-clear>
|
|
||||||
<template #prefix>
|
|
||||||
<SearchOutlined />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</div>
|
|
||||||
<div class="icon-list">
|
|
||||||
<div
|
|
||||||
v-for="icon in filteredAntdIcons"
|
|
||||||
:key="icon"
|
|
||||||
:class="['icon-item', { active: tempIcon === icon }]"
|
|
||||||
@click="handleSelectIcon(icon)"
|
|
||||||
>
|
|
||||||
<component :is="icon" />
|
|
||||||
<div class="icon-name">{{ icon }}</div>
|
|
||||||
</div>
|
|
||||||
<a-empty v-if="filteredAntdIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
||||||
</div>
|
|
||||||
</a-tab-pane>
|
|
||||||
<a-tab-pane key="element" tab="Element Plus">
|
|
||||||
<div class="icon-search">
|
|
||||||
<a-input v-model:value="searchElementValue" placeholder="搜索图标..." allow-clear>
|
|
||||||
<template #prefix>
|
|
||||||
<SearchOutlined />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</div>
|
|
||||||
<div class="icon-list">
|
|
||||||
<div
|
|
||||||
v-for="icon in filteredElementIcons"
|
|
||||||
:key="icon"
|
|
||||||
:class="['icon-item', { active: tempIcon === icon }]"
|
|
||||||
@click="handleSelectIcon(icon)"
|
|
||||||
>
|
|
||||||
<component :is="icon" />
|
|
||||||
<div class="icon-name">{{ icon.replace('El', '') }}</div>
|
|
||||||
</div>
|
|
||||||
<a-empty v-if="filteredElementIcons.length === 0" description="暂无图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
||||||
</div>
|
|
||||||
</a-tab-pane>
|
|
||||||
</a-tabs>
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
/**
|
|
||||||
* @component scIconPicker
|
|
||||||
*/
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { Empty } from 'ant-design-vue'
|
|
||||||
import { SearchOutlined, CloseCircleFilled } from '@ant-design/icons-vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
type: String,
|
|
||||||
default: '请选择图标',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const activeTab = ref('antd')
|
|
||||||
const searchAntdValue = ref('')
|
|
||||||
const searchElementValue = ref('')
|
|
||||||
const tempIcon = ref('')
|
|
||||||
|
|
||||||
// Ant Design 图标列表(常用图标)
|
|
||||||
const antdIcons = [
|
|
||||||
'HomeOutlined',
|
|
||||||
'UserOutlined',
|
|
||||||
'SettingOutlined',
|
|
||||||
'EditOutlined',
|
|
||||||
'DeleteOutlined',
|
|
||||||
'PlusOutlined',
|
|
||||||
'MinusOutlined',
|
|
||||||
'CheckOutlined',
|
|
||||||
'CloseOutlined',
|
|
||||||
'SearchOutlined',
|
|
||||||
'FilterOutlined',
|
|
||||||
'ReloadOutlined',
|
|
||||||
'DownloadOutlined',
|
|
||||||
'UploadOutlined',
|
|
||||||
'FileTextOutlined',
|
|
||||||
'FolderOutlined',
|
|
||||||
'PictureOutlined',
|
|
||||||
'VideoCameraOutlined',
|
|
||||||
'AudioOutlined',
|
|
||||||
'FileOutlined',
|
|
||||||
'CalendarOutlined',
|
|
||||||
'ClockCircleOutlined',
|
|
||||||
'HeartOutlined',
|
|
||||||
'StarOutlined',
|
|
||||||
'ThumbUpOutlined',
|
|
||||||
'MessageOutlined',
|
|
||||||
'PhoneOutlined',
|
|
||||||
'MailOutlined',
|
|
||||||
'EnvironmentOutlined',
|
|
||||||
'GlobalOutlined',
|
|
||||||
'LinkOutlined',
|
|
||||||
'LockOutlined',
|
|
||||||
'UnlockOutlined',
|
|
||||||
'EyeOutlined',
|
|
||||||
'EyeInvisibleOutlined',
|
|
||||||
'ArrowLeftOutlined',
|
|
||||||
'ArrowRightOutlined',
|
|
||||||
'ArrowUpOutlined',
|
|
||||||
'ArrowDownOutlined',
|
|
||||||
'CaretLeftOutlined',
|
|
||||||
'CaretRightOutlined',
|
|
||||||
'CaretUpOutlined',
|
|
||||||
'CaretDownOutlined',
|
|
||||||
'LeftOutlined',
|
|
||||||
'RightOutlined',
|
|
||||||
'UpOutlined',
|
|
||||||
'DownOutlined',
|
|
||||||
'MenuFoldOutlined',
|
|
||||||
'MenuUnfoldOutlined',
|
|
||||||
'BarsOutlined',
|
|
||||||
'MoreOutlined',
|
|
||||||
'EllipsisOutlined',
|
|
||||||
'DashboardOutlined',
|
|
||||||
'AppstoreOutlined',
|
|
||||||
'LaptopOutlined',
|
|
||||||
'DesktopOutlined',
|
|
||||||
'TabletOutlined',
|
|
||||||
'MobileOutlined',
|
|
||||||
'WifiOutlined',
|
|
||||||
'BluetoothOutlined',
|
|
||||||
'ThunderboltOutlined',
|
|
||||||
'BulbOutlined',
|
|
||||||
'SoundOutlined',
|
|
||||||
'NotificationOutlined',
|
|
||||||
'BellOutlined',
|
|
||||||
'AlertOutlined',
|
|
||||||
'WarningOutlined',
|
|
||||||
'InfoCircleOutlined',
|
|
||||||
'QuestionCircleOutlined',
|
|
||||||
'CheckCircleOutlined',
|
|
||||||
'CloseCircleOutlined',
|
|
||||||
'StopOutlined',
|
|
||||||
'ExclamationCircleOutlined',
|
|
||||||
'SafetyOutlined',
|
|
||||||
'ShieldCheckOutlined',
|
|
||||||
'SecurityScanOutlined',
|
|
||||||
'KeyOutlined',
|
|
||||||
'IdcardOutlined',
|
|
||||||
'ProfileOutlined',
|
|
||||||
'SolutionOutlined',
|
|
||||||
'ContactsOutlined',
|
|
||||||
'TeamOutlined',
|
|
||||||
'UsergroupAddOutlined',
|
|
||||||
'UsergroupDeleteOutlined',
|
|
||||||
'CrownOutlined',
|
|
||||||
'GoldOutlined',
|
|
||||||
'MoneyCollectOutlined',
|
|
||||||
'BankOutlined',
|
|
||||||
'PayCircleOutlined',
|
|
||||||
'CreditCardOutlined',
|
|
||||||
'WalletOutlined',
|
|
||||||
'ShoppingCartOutlined',
|
|
||||||
'ShoppingOutlined',
|
|
||||||
'GiftOutlined',
|
|
||||||
'HddOutlined',
|
|
||||||
'DatabaseOutlined',
|
|
||||||
'CloudOutlined',
|
|
||||||
'CloudUploadOutlined',
|
|
||||||
'CloudDownloadOutlined',
|
|
||||||
'ServerOutlined',
|
|
||||||
'AuditOutlined',
|
|
||||||
'NodeIndexOutlined',
|
|
||||||
'ReconciliationOutlined',
|
|
||||||
'PartitionOutlined',
|
|
||||||
'AccountBookOutlined',
|
|
||||||
'ProjectOutlined',
|
|
||||||
'ControlOutlined',
|
|
||||||
'MonitorOutlined',
|
|
||||||
'TagsOutlined',
|
|
||||||
'TagOutlined',
|
|
||||||
'BookOutlined',
|
|
||||||
'ReadOutlined',
|
|
||||||
'ExperimentOutlined',
|
|
||||||
'FireOutlined',
|
|
||||||
'RocketOutlined',
|
|
||||||
'TrophyOutlined',
|
|
||||||
'MedalOutlined',
|
|
||||||
'DiamondOutlined',
|
|
||||||
'ThunderboltTwoTone',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Element Plus 图标列表(常用图标)
|
|
||||||
const elementIcons = [
|
|
||||||
'ElIconEdit',
|
|
||||||
'ElIconDelete',
|
|
||||||
'ElIconSearch',
|
|
||||||
'ElIconClose',
|
|
||||||
'ElIconCheck',
|
|
||||||
'ElIconPlus',
|
|
||||||
'ElIconMinus',
|
|
||||||
'ElIconUpload',
|
|
||||||
'ElIconDownload',
|
|
||||||
'ElIconSetting',
|
|
||||||
'ElIconRefresh',
|
|
||||||
'ElIconRefreshLeft',
|
|
||||||
'ElIconRefreshRight',
|
|
||||||
'ElIconMenu',
|
|
||||||
'ElIconMore',
|
|
||||||
'ElIconMoreFilled',
|
|
||||||
'ElIconStar',
|
|
||||||
'ElIconStarFilled',
|
|
||||||
'ElIconSunny',
|
|
||||||
'ElIconMoon',
|
|
||||||
'ElIconBell',
|
|
||||||
'ElIconBellFilled',
|
|
||||||
'ElIconMessage',
|
|
||||||
'ElIconMessageFilled',
|
|
||||||
'ElIconChatDotRound',
|
|
||||||
'ElIconChatLineSquare',
|
|
||||||
'ElIconChatDotSquare',
|
|
||||||
'ElIconPhone',
|
|
||||||
'ElIconPhoneFilled',
|
|
||||||
'ElIconLocation',
|
|
||||||
'ElIconLocationFilled',
|
|
||||||
'ElIconLocationInformation',
|
|
||||||
'ElIconView',
|
|
||||||
'ElIconHide',
|
|
||||||
'ElIconLock',
|
|
||||||
'ElIconUnlock',
|
|
||||||
'ElIconKey',
|
|
||||||
'ElIconTickets',
|
|
||||||
'ElIconDocument',
|
|
||||||
'ElIconDocumentAdd',
|
|
||||||
'ElIconDocumentDelete',
|
|
||||||
'ElIconDocumentCopy',
|
|
||||||
'ElIconDocumentChecked',
|
|
||||||
'ElIconDocumentRemove',
|
|
||||||
'ElIconFolder',
|
|
||||||
'ElIconFolderOpened',
|
|
||||||
'ElIconFolderAdd',
|
|
||||||
'ElIconFolderDelete',
|
|
||||||
'ElIconFolderChecked',
|
|
||||||
'ElIconFiles',
|
|
||||||
'ElIconPicture',
|
|
||||||
'ElIconPictureRounded',
|
|
||||||
'ElIconPictureFilled',
|
|
||||||
'ElIconVideoCamera',
|
|
||||||
'ElIconVideoCameraFilled',
|
|
||||||
'ElIconMicrophone',
|
|
||||||
'ElIconMicrophoneFilled',
|
|
||||||
'ElIconHeadset',
|
|
||||||
'ElIconHeadsetFilled',
|
|
||||||
'ElIconMuteNotification',
|
|
||||||
'ElIconNotification',
|
|
||||||
'ElIconWarning',
|
|
||||||
'ElIconWarningFilled',
|
|
||||||
'ElIconInfoFilled',
|
|
||||||
'ElIconSuccessFilled',
|
|
||||||
'ElIconCircleCheck',
|
|
||||||
'ElIconCircleCheckFilled',
|
|
||||||
'ElIconCircleClose',
|
|
||||||
'ElIconCircleCloseFilled',
|
|
||||||
'ElIconCirclePlus',
|
|
||||||
'ElIconCirclePlusFilled',
|
|
||||||
'ElIconCircleMinus',
|
|
||||||
'ElIconCircleMinusFilled',
|
|
||||||
'ElIconAim',
|
|
||||||
'ElIconPosition',
|
|
||||||
'ElIconCompass',
|
|
||||||
'ElIconMapLocation',
|
|
||||||
'ElIconPromotion',
|
|
||||||
'ElIconDownload',
|
|
||||||
'ElIconUploadFilled',
|
|
||||||
'ElIconShare',
|
|
||||||
'ElIconConnection',
|
|
||||||
'ElIconLink',
|
|
||||||
'ElIconUnlink',
|
|
||||||
'ElIconOperation',
|
|
||||||
'ElIconDataAnalysis',
|
|
||||||
'ElIconDataLine',
|
|
||||||
'ElIconDataBoard',
|
|
||||||
'ElIconHistogram',
|
|
||||||
'ElIconTrendCharts',
|
|
||||||
'ElIconPieChart',
|
|
||||||
'ElIconOdometer',
|
|
||||||
'ElIconMonitor',
|
|
||||||
'ElIconTimer',
|
|
||||||
'ElIconClock',
|
|
||||||
'ElIconAlarmClock',
|
|
||||||
'ElIconCalendar',
|
|
||||||
'ElIconDate',
|
|
||||||
'ElIconSwitch',
|
|
||||||
'ElIconSwitchButton',
|
|
||||||
'ElIconTools',
|
|
||||||
'ElIconScrewdriver',
|
|
||||||
'ElIconHammer',
|
|
||||||
'ElIconBrush',
|
|
||||||
'ElIconEditPen',
|
|
||||||
'ElIconBriefcase',
|
|
||||||
'ElIconWallet',
|
|
||||||
'ElIconGoods',
|
|
||||||
'ElIconShoppingCart',
|
|
||||||
'ElIconShoppingCartFull',
|
|
||||||
'ElIconShoppingBag',
|
|
||||||
'ElIconPresent',
|
|
||||||
'ElIconSoldOut',
|
|
||||||
'ElIconSell',
|
|
||||||
'ElIconDiscount',
|
|
||||||
'ElIconTicket',
|
|
||||||
'ElIconCoin',
|
|
||||||
'ElIconMoney',
|
|
||||||
'ElIconWalletFilled',
|
|
||||||
'ElIconCreditCard',
|
|
||||||
'ElIconUser',
|
|
||||||
'ElIconUserFilled',
|
|
||||||
'ElIconAvatar',
|
|
||||||
'ElIconSuitcase',
|
|
||||||
'ElIconGrid',
|
|
||||||
'ElIconMenuFilled',
|
|
||||||
'ElIconHomeFilled',
|
|
||||||
'ElIconHouse',
|
|
||||||
'ElIconOfficeBuilding',
|
|
||||||
'ElIconSchool',
|
|
||||||
'ElIconReading',
|
|
||||||
'ElIconReadingLamp',
|
|
||||||
'ElIconNotebook',
|
|
||||||
'ElIconNotebookFilled',
|
|
||||||
'ElIconFinished',
|
|
||||||
'ElIconCollection',
|
|
||||||
'ElIconCollectionTag',
|
|
||||||
'ElIconFiles',
|
|
||||||
'ElIconPostcard',
|
|
||||||
'ElIconMemo',
|
|
||||||
'ElIconStamp',
|
|
||||||
'ElIconPriceTag',
|
|
||||||
'ElIconMedal',
|
|
||||||
'ElIconTrophy',
|
|
||||||
'ElIconTrophyBase',
|
|
||||||
'ElIconFirstAidKit',
|
|
||||||
'ElIconToiletPaper',
|
|
||||||
'ElIconAim',
|
|
||||||
'ElIconSFlag',
|
|
||||||
'ElIconSOpportunity',
|
|
||||||
'ElIconMagicStick',
|
|
||||||
'ElIconHelp',
|
|
||||||
'ElIconQuestionFilled',
|
|
||||||
'ElIconWarning',
|
|
||||||
'ElIconWarningFilled',
|
|
||||||
]
|
|
||||||
|
|
||||||
// 当前选中的图标
|
|
||||||
const selectedIcon = ref(props.modelValue)
|
|
||||||
|
|
||||||
// 过滤后的 Ant Design 图标
|
|
||||||
const filteredAntdIcons = computed(() => {
|
|
||||||
if (!searchAntdValue.value) {
|
|
||||||
return antdIcons
|
|
||||||
}
|
|
||||||
return antdIcons.filter((icon) =>
|
|
||||||
icon.toLowerCase().includes(searchAntdValue.value.toLowerCase()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 过滤后的 Element 图标
|
|
||||||
const filteredElementIcons = computed(() => {
|
|
||||||
if (!searchElementValue.value) {
|
|
||||||
return elementIcons
|
|
||||||
}
|
|
||||||
return elementIcons.filter((icon) =>
|
|
||||||
icon.toLowerCase().includes(searchElementValue.value.toLowerCase()),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打开选择器
|
|
||||||
const handleOpenPicker = () => {
|
|
||||||
tempIcon.value = props.modelValue
|
|
||||||
// 根据当前图标设置默认标签页
|
|
||||||
if (props.modelValue) {
|
|
||||||
activeTab.value = props.modelValue.startsWith('El') ? 'element' : 'antd'
|
|
||||||
}
|
|
||||||
visible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除选择
|
|
||||||
const handleClear = () => {
|
|
||||||
emit('update:modelValue', '')
|
|
||||||
emit('change', '')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换标签页
|
|
||||||
const handleTabChange = (key) => {
|
|
||||||
activeTab.value = key
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择图标(直接确认并关闭)
|
|
||||||
const handleSelectIcon = (icon) => {
|
|
||||||
emit('update:modelValue', icon)
|
|
||||||
emit('change', icon)
|
|
||||||
selectedIcon.value = icon
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消选择
|
|
||||||
const handleCancel = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听props变化,更新本地状态
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
(newVal) => {
|
|
||||||
selectedIcon.value = newVal
|
|
||||||
}
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.sc-icon-picker {
|
|
||||||
:deep(.ant-input) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-search {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: #d9d9d9;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #bfbfbf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 12px 8px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #1890ff;
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: #e6f7ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(svg) {
|
|
||||||
font-size: 24px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
text-align: center;
|
|
||||||
word-break: break-all;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,546 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="sc-table" ref="tableWrapper">
|
|
||||||
<!-- 表格内容 -->
|
|
||||||
<div class="sc-table-content" ref="tableContent">
|
|
||||||
<a-table :columns="tableColumns" :data-source="dataSource" :loading="loading" :pagination="false"
|
|
||||||
:row-key="rowKey" :row-selection="rowSelection" :scroll="scroll" :bordered="tableSettings.bordered"
|
|
||||||
:size="tableSettings.size" :show-header="showHeader" :locale="locale" @change="handleTableChange"
|
|
||||||
@resizeColumn="handleResizeColumn">
|
|
||||||
<!-- 自定义单元格内容 -->
|
|
||||||
<template #bodyCell="{ text, record, index, column }">
|
|
||||||
<!-- 序号列 -->
|
|
||||||
<template v-if="column.dataIndex === '_index'">
|
|
||||||
{{ getTableIndex(index) }}
|
|
||||||
</template>
|
|
||||||
<!-- 自定义插槽 -->
|
|
||||||
<template v-else-if="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" />
|
|
||||||
</template>
|
|
||||||
</a-table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 工具栏 -->
|
|
||||||
<div v-if="showToolbar" class="sc-table-tool">
|
|
||||||
<div class="tool-left">
|
|
||||||
<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">
|
|
||||||
<template #icon>
|
|
||||||
<SyncOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
|
|
||||||
<!-- 表格设置按钮 -->
|
|
||||||
<a-tooltip v-if="showColumnSetting" title="表格设置">
|
|
||||||
<a-popover v-model:open="tableSettingVisible" placement="topRight" trigger="click" :width="240">
|
|
||||||
<template #content>
|
|
||||||
<div class="table-setting">
|
|
||||||
<div class="table-setting-header">
|
|
||||||
<span>表格设置</span>
|
|
||||||
</div>
|
|
||||||
<div class="table-setting-body">
|
|
||||||
<!-- 边框设置 -->
|
|
||||||
<div class="setting-item">
|
|
||||||
<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>
|
|
||||||
</a-radio-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<a-button shape="circle">
|
|
||||||
<template #icon>
|
|
||||||
<TableOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-popover>
|
|
||||||
</a-tooltip>
|
|
||||||
|
|
||||||
<!-- 列设置按钮 -->
|
|
||||||
<a-tooltip v-if="showColumnSetting" title="列设置">
|
|
||||||
<a-popover v-model:open="columnSettingVisible" placement="topRight" trigger="click">
|
|
||||||
<template #content>
|
|
||||||
<div class="column-setting">
|
|
||||||
<div class="column-setting-header">
|
|
||||||
<span>显示与排序</span>
|
|
||||||
</div>
|
|
||||||
<div class="column-setting-list">
|
|
||||||
<div v-for="(colKey, index) in sortedColumns" :key="colKey"
|
|
||||||
class="column-setting-item" :class="{ dragging: draggingIndex === index }"
|
|
||||||
draggable="true" @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)">
|
|
||||||
{{ getColumnTitle(colKey) }}
|
|
||||||
</a-checkbox>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<a-button shape="circle">
|
|
||||||
<template #icon>
|
|
||||||
<HolderOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</a-popover>
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch, reactive, useTemplateRef, onMounted, onBeforeUnmount } from 'vue'
|
|
||||||
import { Empty } from 'ant-design-vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'scTable',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
// 数据源
|
|
||||||
dataSource: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
// 列配置
|
|
||||||
columns: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
// 行的唯一标识
|
|
||||||
rowKey: {
|
|
||||||
type: [String, Function],
|
|
||||||
default: 'id',
|
|
||||||
},
|
|
||||||
// 加载状态
|
|
||||||
loading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 分页配置
|
|
||||||
pagination: {
|
|
||||||
type: [Object, Boolean],
|
|
||||||
default: () => ({
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
|
||||||
pageSizeOptions: ['20', '50', '100', '200'],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// 行选择配置
|
|
||||||
rowSelection: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
},
|
|
||||||
// 表格大小
|
|
||||||
size: {
|
|
||||||
type: String,
|
|
||||||
default: 'middle', // large, middle, small
|
|
||||||
},
|
|
||||||
// 是否显示边框
|
|
||||||
bordered: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 是否显示表头
|
|
||||||
showHeader: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
// 本地化配置
|
|
||||||
locale: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
// 是否显示序号列
|
|
||||||
showIndex: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 序号列宽度
|
|
||||||
indexColumnWidth: {
|
|
||||||
type: Number,
|
|
||||||
default: 100,
|
|
||||||
},
|
|
||||||
// 序号列标题
|
|
||||||
indexTitle: {
|
|
||||||
type: String,
|
|
||||||
default: '序号',
|
|
||||||
},
|
|
||||||
// 是否显示工具栏
|
|
||||||
showToolbar: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
// 是否显示刷新按钮
|
|
||||||
showRefresh: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
// 是否显示列设置
|
|
||||||
showColumnSetting: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
// 空状态文字
|
|
||||||
emptyText: {
|
|
||||||
type: String,
|
|
||||||
default: '暂无数据',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const tableContent = useTemplateRef('tableContent')
|
|
||||||
const tableWrapper = useTemplateRef('tableWrapper')
|
|
||||||
let scroll = ref({
|
|
||||||
scrollToFirstRowOnChange: true,
|
|
||||||
x: 'max-content',
|
|
||||||
y: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updateTableHeight()
|
|
||||||
})
|
|
||||||
|
|
||||||
const updateTableHeight = () => {
|
|
||||||
if (tableContent.value) {
|
|
||||||
const tableHeight = tableContent.value.clientHeight - 56
|
|
||||||
scroll.value.y = tableHeight > 0 ? tableHeight : 400
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据表格宽度优化横向滚动配置
|
|
||||||
watch(
|
|
||||||
[() => props.columns, () => props.showIndex, tableContent],
|
|
||||||
() => {
|
|
||||||
// 如果列有固定宽度且总宽度较大,使用max-content
|
|
||||||
// 否则使用true让表格自适应
|
|
||||||
const hasFixedColumns = props.columns.some((col) => col.width)
|
|
||||||
if (hasFixedColumns || props.showIndex) {
|
|
||||||
scroll.value.x = 'max-content'
|
|
||||||
} else {
|
|
||||||
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
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.size,
|
|
||||||
(val) => {
|
|
||||||
tableSettings.size = val
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
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 allColumns = computed(() => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化可见列和排序
|
|
||||||
watch(
|
|
||||||
() => props.columns,
|
|
||||||
(newColumns) => {
|
|
||||||
const columnKeys = newColumns.filter((col) => col.dataIndex && col.dataIndex !== '_index').map((col) => col.dataIndex || col.key)
|
|
||||||
|
|
||||||
// 如果是首次初始化,使用原始顺序
|
|
||||||
if (sortedColumns.value.length === 0) {
|
|
||||||
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]
|
|
||||||
}
|
|
||||||
|
|
||||||
visibleColumns.value = [...sortedColumns.value]
|
|
||||||
},
|
|
||||||
{ immediate: true, deep: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
// 切换列的显示状态
|
|
||||||
const toggleColumn = (colKey, checked) => {
|
|
||||||
if (checked) {
|
|
||||||
if (!visibleColumns.value.includes(colKey)) {
|
|
||||||
visibleColumns.value.push(colKey)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖拽经过
|
|
||||||
const handleDragOver = (index, event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
event.dataTransfer.dropEffect = 'move'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖拽结束
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
draggingIndex.value = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖拽放置
|
|
||||||
const handleDrop = (dropIndex) => {
|
|
||||||
if (draggingIndex.value === dropIndex) return
|
|
||||||
|
|
||||||
const draggedKey = sortedColumns.value[draggingIndex.value]
|
|
||||||
const newColumns = [...sortedColumns.value]
|
|
||||||
|
|
||||||
// 移除被拖拽的项
|
|
||||||
newColumns.splice(draggingIndex.value, 1)
|
|
||||||
|
|
||||||
// 插入到新位置
|
|
||||||
newColumns.splice(dropIndex, 0, draggedKey)
|
|
||||||
|
|
||||||
sortedColumns.value = newColumns
|
|
||||||
draggingIndex.value = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理刷新
|
|
||||||
const handleRefresh = () => {
|
|
||||||
emit('refresh')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理分页变化
|
|
||||||
const handlePaginationChange = (page, pageSize) => {
|
|
||||||
emit('paginationChange', { page, pageSize })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理表格变化(排序、筛选)
|
|
||||||
const handleTableChange = (filters, sorter, extra) => {
|
|
||||||
emit('change', { filters, sorter, extra })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理列宽调整
|
|
||||||
const handleResizeColumn = (width, column) => {
|
|
||||||
emit('resizeColumn', { width, column })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取表格序号
|
|
||||||
const getTableIndex = (index) => {
|
|
||||||
const { current = 1, pageSize = 10 } = props.pagination || {}
|
|
||||||
return (current - 1) * pageSize + index + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const tableColumns = computed(() => {
|
|
||||||
let columns = []
|
|
||||||
|
|
||||||
// 添加序号列
|
|
||||||
if (props.showIndex) {
|
|
||||||
columns.push({
|
|
||||||
title: props.indexTitle,
|
|
||||||
dataIndex: '_index',
|
|
||||||
key: '_index',
|
|
||||||
width: props.indexColumnWidth,
|
|
||||||
align: 'center',
|
|
||||||
fixed: 'left',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加数据列(按排序顺序)
|
|
||||||
sortedColumns.value.forEach((colKey) => {
|
|
||||||
// 过滤掉未显示的列
|
|
||||||
if (!visibleColumns.value.includes(colKey)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const col = props.columns.find((c) => (c.dataIndex || c.key) === colKey)
|
|
||||||
if (col) {
|
|
||||||
columns.push({
|
|
||||||
...col,
|
|
||||||
customRender: col.slot ? undefined : col.customRender,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return columns
|
|
||||||
})
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
refresh: handleRefresh,
|
|
||||||
getTableIndex,
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.sc-table {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
&-tool {
|
|
||||||
height: 56px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 10px;
|
|
||||||
|
|
||||||
.tool-left,
|
|
||||||
.tool-right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.column-setting {
|
|
||||||
min-width: 200px;
|
|
||||||
|
|
||||||
&-header {
|
|
||||||
padding: 8px 0;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #000;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-list {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 6px 4px;
|
|
||||||
cursor: move;
|
|
||||||
transition: all 0.2s;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
background: #e6f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
margin-right: 8px;
|
|
||||||
color: #999;
|
|
||||||
cursor: grab;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-checkbox-wrapper) {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-setting {
|
|
||||||
min-width: 200px;
|
|
||||||
|
|
||||||
&-header {
|
|
||||||
padding: 8px 0;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #000;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-body {
|
|
||||||
.setting-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 0;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
border-bottom: 1px dashed #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-label {
|
|
||||||
color: #666;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
<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-button v-if="!disabled">
|
|
||||||
<upload-outlined />
|
|
||||||
上传文件
|
|
||||||
</a-button>
|
|
||||||
</a-upload>
|
|
||||||
</div>
|
|
||||||
</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'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
// 文件列表
|
|
||||||
modelValue: {
|
|
||||||
type: [Array, String],
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
// 最大上传数量,默认1为单文件上传
|
|
||||||
maxCount: {
|
|
||||||
type: Number,
|
|
||||||
default: 1
|
|
||||||
},
|
|
||||||
// 接受的文件类型,例如 '.pdf,.doc,.docx' 或 '*'
|
|
||||||
accept: {
|
|
||||||
type: String,
|
|
||||||
default: '*'
|
|
||||||
},
|
|
||||||
// 是否禁用
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 是否支持多选
|
|
||||||
multiple: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 是否返回URL字符串(单文件)或URL数组(多文件)
|
|
||||||
returnUrl: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change', 'remove'])
|
|
||||||
|
|
||||||
// 文件列表
|
|
||||||
const fileList = ref([])
|
|
||||||
|
|
||||||
// 初始化文件列表
|
|
||||||
const initFileList = () => {
|
|
||||||
if (props.modelValue) {
|
|
||||||
if (typeof props.modelValue === 'string') {
|
|
||||||
// 单文件上传,字符串格式
|
|
||||||
fileList.value = props.modelValue
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
url: url,
|
|
||||||
response: {
|
|
||||||
src: url
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fileList.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听外部值变化
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
() => {
|
|
||||||
initFileList()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 自定义上传
|
|
||||||
const customUpload = (options) => {
|
|
||||||
const { file, onProgress, onSuccess, onError } = options
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append(uploadConfig.filename || 'file', file)
|
|
||||||
|
|
||||||
// 使用文件上传API对象
|
|
||||||
const apiObj = uploadConfig.apiObjFile || uploadConfig.apiObj
|
|
||||||
|
|
||||||
apiObj(formData, {
|
|
||||||
onUploadProgress: (progressEvent) => {
|
|
||||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
|
||||||
onProgress({ percent }, file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
const data = uploadConfig.parseData(res)
|
|
||||||
if (data.code === uploadConfig.successCode) {
|
|
||||||
onSuccess(data, file)
|
|
||||||
message.success('上传成功')
|
|
||||||
} else {
|
|
||||||
onError(new Error(data.msg || '上传失败'))
|
|
||||||
message.error(data.msg || '上传失败')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
onError(error)
|
|
||||||
message.error('上传失败:' + error.message)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传前校验
|
|
||||||
const beforeUpload = (file) => {
|
|
||||||
const maxSizeMB = uploadConfig.maxSizeFile || uploadConfig.maxSize || 10
|
|
||||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
|
||||||
|
|
||||||
if (file.size > maxSizeBytes) {
|
|
||||||
message.error(`文件大小不能超过 ${maxSizeMB}MB`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理文件列表变化
|
|
||||||
const handleChange = ({ fileList: 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)
|
|
||||||
|
|
||||||
// 触发更新事件
|
|
||||||
if (props.returnUrl) {
|
|
||||||
// 返回URL字符串或数组
|
|
||||||
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
emit('change', value, newFileList)
|
|
||||||
} else {
|
|
||||||
// 返回完整文件列表
|
|
||||||
emit('update:modelValue', newFileList)
|
|
||||||
emit('change', newFileList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理文件移除
|
|
||||||
const handleRemove = (file) => {
|
|
||||||
emit('remove', file)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.file-upload {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,383 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="image-upload">
|
|
||||||
<a-upload
|
|
||||||
v-model:file-list="fileList"
|
|
||||||
list-type="picture-card"
|
|
||||||
:custom-request="customUpload"
|
|
||||||
:before-upload="beforeUpload"
|
|
||||||
:accept="accept"
|
|
||||||
:max-count="maxCount"
|
|
||||||
:disabled="disabled"
|
|
||||||
:show-upload-list="{ showPreviewIcon: true, showRemoveIcon: !disabled }"
|
|
||||||
@preview="handlePreview"
|
|
||||||
@change="handleChange"
|
|
||||||
@drop="handleDrop"
|
|
||||||
@dragenter="handleDragEnter"
|
|
||||||
@dragleave="handleDragLeave"
|
|
||||||
class="custom-upload"
|
|
||||||
:class="{ 'drag-over': isDragOver }"
|
|
||||||
>
|
|
||||||
<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 }}</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>
|
|
||||||
</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'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
// 图片列表
|
|
||||||
modelValue: {
|
|
||||||
type: [Array, String],
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
// 最大上传数量,默认1为单图上传
|
|
||||||
maxCount: {
|
|
||||||
type: Number,
|
|
||||||
default: 1
|
|
||||||
},
|
|
||||||
// 接受的文件类型
|
|
||||||
accept: {
|
|
||||||
type: String,
|
|
||||||
default: 'image/*'
|
|
||||||
},
|
|
||||||
// 是否禁用
|
|
||||||
disabled: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 是否返回URL字符串(单图)或URL数组(多图)
|
|
||||||
returnUrl: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
// 上传按钮文字
|
|
||||||
uploadText: {
|
|
||||||
type: String,
|
|
||||||
default: '上传图片'
|
|
||||||
},
|
|
||||||
// 提示文字
|
|
||||||
tip: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
// 最小宽度(像素)
|
|
||||||
minWidth: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
// 最大宽度(像素)
|
|
||||||
maxWidth: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
// 最小高度(像素)
|
|
||||||
minHeight: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
// 最大高度(像素)
|
|
||||||
maxHeight: {
|
|
||||||
type: Number,
|
|
||||||
default: 0
|
|
||||||
},
|
|
||||||
// 是否删除前确认
|
|
||||||
confirmBeforeRemove: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
// 自定义上传按钮内容
|
|
||||||
customUploadBtn: {
|
|
||||||
type: Function,
|
|
||||||
default: null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change', 'preview', 'remove', 'uploadSuccess', 'uploadError'])
|
|
||||||
|
|
||||||
// 文件列表
|
|
||||||
const fileList = ref([])
|
|
||||||
|
|
||||||
// 预览相关
|
|
||||||
const previewVisible = ref(false)
|
|
||||||
const previewImage = ref('')
|
|
||||||
const previewTitle = computed(() => {
|
|
||||||
return previewImage.value ? '图片预览' : ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 上传状态
|
|
||||||
const uploading = ref(false)
|
|
||||||
|
|
||||||
// 拖拽状态
|
|
||||||
const isDragOver = ref(false)
|
|
||||||
|
|
||||||
// 初始化文件列表
|
|
||||||
const initFileList = () => {
|
|
||||||
if (props.modelValue) {
|
|
||||||
if (typeof props.modelValue === 'string') {
|
|
||||||
// 单图上传,字符串格式
|
|
||||||
fileList.value = props.modelValue
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
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',
|
|
||||||
url: url
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fileList.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听外部值变化
|
|
||||||
watch(
|
|
||||||
() => props.modelValue,
|
|
||||||
() => {
|
|
||||||
initFileList()
|
|
||||||
},
|
|
||||||
{ immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 自定义上传
|
|
||||||
const customUpload = (options) => {
|
|
||||||
const { file, onProgress, onSuccess, onError } = options
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append(uploadConfig.filename || 'file', file)
|
|
||||||
|
|
||||||
uploading.value = true
|
|
||||||
|
|
||||||
uploadConfig.apiObj(formData, {
|
|
||||||
onUploadProgress: (progressEvent) => {
|
|
||||||
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100)
|
|
||||||
onProgress({ percent }, file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((res) => {
|
|
||||||
const data = uploadConfig.parseData(res)
|
|
||||||
if (data.code === uploadConfig.successCode) {
|
|
||||||
onSuccess(data, file)
|
|
||||||
message.success('上传成功')
|
|
||||||
emit('uploadSuccess', data, file)
|
|
||||||
} else {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
uploading.value = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传前校验
|
|
||||||
const beforeUpload = async (file) => {
|
|
||||||
// 文件大小校验
|
|
||||||
const maxSizeMB = uploadConfig.maxSize || 10
|
|
||||||
const maxSizeBytes = maxSizeMB * 1024 * 1024
|
|
||||||
|
|
||||||
if (file.size > maxSizeBytes) {
|
|
||||||
message.error(`图片大小不能超过 ${maxSizeMB}MB`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片尺寸校验
|
|
||||||
if (props.minWidth || props.maxWidth || props.minHeight || props.maxHeight) {
|
|
||||||
try {
|
|
||||||
const dimensions = await getImageDimensions(file)
|
|
||||||
const { width, height } = dimensions
|
|
||||||
|
|
||||||
if (props.minWidth && width < props.minWidth) {
|
|
||||||
message.error(`图片宽度不能小于 ${props.minWidth}px`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (props.maxWidth && width > props.maxWidth) {
|
|
||||||
message.error(`图片宽度不能大于 ${props.maxWidth}px`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (props.minHeight && height < props.minHeight) {
|
|
||||||
message.error(`图片高度不能小于 ${props.minHeight}px`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if (props.maxHeight && height > props.maxHeight) {
|
|
||||||
message.error(`图片高度不能大于 ${props.maxHeight}px`)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error('图片尺寸校验失败')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取图片尺寸
|
|
||||||
const getImageDimensions = (file) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const img = new Image()
|
|
||||||
const reader = new FileReader()
|
|
||||||
|
|
||||||
reader.onload = (e) => {
|
|
||||||
img.src = e.target.result
|
|
||||||
img.onload = () => {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
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 handleChange = ({ fileList: newFileList }) => {
|
|
||||||
// 更新文件列表,确保上传成功的文件有正确的 url
|
|
||||||
const updatedFileList = newFileList.map((file) => {
|
|
||||||
// 如果文件上传成功且有响应数据但没有 url,则设置 url
|
|
||||||
if (file.status === 'done' && file.response?.src && !file.url) {
|
|
||||||
return {
|
|
||||||
...file,
|
|
||||||
url: file.response.src
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return file
|
|
||||||
})
|
|
||||||
|
|
||||||
fileList.value = updatedFileList
|
|
||||||
|
|
||||||
// 过滤掉失败的文件
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 触发更新事件
|
|
||||||
if (props.returnUrl) {
|
|
||||||
// 返回URL字符串或数组
|
|
||||||
const value = props.maxCount === 1 ? successFiles[0] || '' : successFiles
|
|
||||||
emit('update:modelValue', value)
|
|
||||||
emit('change', value, validFileList)
|
|
||||||
} else {
|
|
||||||
// 返回完整文件列表
|
|
||||||
emit('update:modelValue', validFileList)
|
|
||||||
emit('change', validFileList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖拽相关
|
|
||||||
const handleDragEnter = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
isDragOver.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragLeave = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
isDragOver.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
isDragOver.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 取消预览
|
|
||||||
const handleCancel = () => {
|
|
||||||
previewVisible.value = false
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.image-upload {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-area {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.upload-icon {
|
|
||||||
font-size: 24px;
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-upload-text {
|
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(0, 0, 0, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-upload-tip {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-over {
|
|
||||||
border: 2px dashed #1890ff;
|
|
||||||
background-color: rgba(24, 144, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-over .upload-icon {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+3
-2
@@ -1,9 +1,10 @@
|
|||||||
export default {
|
export default {
|
||||||
APP_NAME: 'vueadmin',
|
APP_NAME: 'vueadmin',
|
||||||
|
LOGO: '',
|
||||||
DASHBOARD_URL: '/dashboard',
|
DASHBOARD_URL: '/dashboard',
|
||||||
|
|
||||||
// 白名单路由(不需要登录即可访问)
|
// 白名单路由(不需要登录即可访问)
|
||||||
whiteList: ['/login', '/register', '/reset-password'],
|
whiteList: ['/login', '/register', '/forgot-password'],
|
||||||
//版本号
|
//版本号
|
||||||
APP_VER: '1.6.6',
|
APP_VER: '1.6.6',
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ export default {
|
|||||||
//默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6]
|
//默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6]
|
||||||
layout: [24, 12, 12],
|
layout: [24, 12, 12],
|
||||||
//小组件分布,com取值:pages/home/components 文件名
|
//小组件分布,com取值:pages/home/components 文件名
|
||||||
compsList: [["welcome"], ["info"], ["ver"]],
|
compsList: [['welcome'], ['info'], ['ver']],
|
||||||
},
|
},
|
||||||
|
|
||||||
//是否加密localStorage, 为空不加密
|
//是否加密localStorage, 为空不加密
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import systemApi from "@/api/system";
|
import systemApi from '@/api/system'
|
||||||
|
|
||||||
//上传配置
|
//上传配置
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
apiObj: systemApi.upload.post, //上传请求API对象
|
apiObj: systemApi.upload.post, //上传请求API对象
|
||||||
filename: "file", //form请求时文件的key
|
filename: 'file', //form请求时文件的key
|
||||||
successCode: 1, //请求完成代码
|
successCode: 1, //请求完成代码
|
||||||
maxSize: 10, //最大文件大小 默认10MB
|
maxSize: 10, //最大文件大小 默认10MB
|
||||||
parseData: function (res) {
|
parseData: function (res) {
|
||||||
return {
|
return {
|
||||||
code: res.code, //分析状态字段结构
|
code: res.code, //分析状态字段结构
|
||||||
fileName: res.data.name,//分析文件名称
|
fileName: res.data.name, //分析文件名称
|
||||||
src: res.data.url, //分析图片远程地址结构
|
src: res.data.url, //分析图片远程地址结构
|
||||||
msg: res.message //分析描述字段结构
|
msg: res.message, //分析描述字段结构
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
apiObjFile: systemApi.upload.post, //附件上传请求API对象
|
apiObjFile: systemApi.upload.post, //附件上传请求API对象
|
||||||
maxSizeFile: 10 //最大文件大小 默认10MB
|
maxSizeFile: 10, //最大文件大小 默认10MB
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ export function useI18n() {
|
|||||||
availableLocales,
|
availableLocales,
|
||||||
setLocale: i18nStore.setLocale,
|
setLocale: i18nStore.setLocale,
|
||||||
currentLocale: i18nStore.currentLocale,
|
currentLocale: i18nStore.currentLocale,
|
||||||
localeLabel: i18nStore.localeLabel
|
localeLabel: i18nStore.localeLabel,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,248 +0,0 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 表格通用hooks
|
|
||||||
* @param {Object} options 配置选项
|
|
||||||
* @param {Function} options.api 获取列表数据的API函数,必须返回包含data和total的响应
|
|
||||||
* @param {Object} options.searchForm 搜索表单的初始值
|
|
||||||
* @param {Array} options.columns 表格列配置
|
|
||||||
* @param {String} options.rowKey 行的唯一标识,默认为'id'
|
|
||||||
* @param {Boolean} options.needPagination 是否需要分页,默认为true
|
|
||||||
* @param {Object} options.paginationConfig 分页配置,可选
|
|
||||||
* @param {Boolean} options.needSelection 是否需要行选择,默认为false
|
|
||||||
* @param {Boolean} options.immediateLoad 是否在组件挂载时自动加载数据,默认为true
|
|
||||||
* @returns {Object} 返回表格相关的状态和方法
|
|
||||||
*/
|
|
||||||
export function useTable(options = {}) {
|
|
||||||
const {
|
|
||||||
api,
|
|
||||||
searchForm: initialSearchForm = {},
|
|
||||||
columns = [],
|
|
||||||
rowKey = 'id',
|
|
||||||
needPagination = true,
|
|
||||||
paginationConfig = {},
|
|
||||||
needSelection = false,
|
|
||||||
immediateLoad = true
|
|
||||||
} = options
|
|
||||||
|
|
||||||
// 表格引用
|
|
||||||
const tableRef = ref(null)
|
|
||||||
|
|
||||||
// 搜索表单
|
|
||||||
const searchForm = reactive({ ...initialSearchForm })
|
|
||||||
|
|
||||||
// 表格数据
|
|
||||||
const tableData = ref([])
|
|
||||||
|
|
||||||
// 加载状态
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 选中的行数据
|
|
||||||
const selectedRows = ref([])
|
|
||||||
|
|
||||||
// 选中的行keys
|
|
||||||
const selectedRowKeys = computed(() => selectedRows.value.map(item => item[rowKey]))
|
|
||||||
|
|
||||||
// 分页配置
|
|
||||||
const defaultPaginationConfig = {
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
|
||||||
pageSizeOptions: ['20', '50', '100', '200']
|
|
||||||
}
|
|
||||||
|
|
||||||
const pagination = reactive({
|
|
||||||
...defaultPaginationConfig,
|
|
||||||
...paginationConfig
|
|
||||||
})
|
|
||||||
|
|
||||||
// 行选择配置
|
|
||||||
const rowSelection = computed(() => {
|
|
||||||
if (!needSelection) return null
|
|
||||||
return {
|
|
||||||
selectedRowKeys: selectedRowKeys.value,
|
|
||||||
onChange: (keys, rows) => {
|
|
||||||
selectedRows.value = rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 行选择事件处理(用于scTable的@select事件)
|
|
||||||
const handleSelectChange = (record, selected, selectedRows) => {
|
|
||||||
if (!needSelection) return
|
|
||||||
if (selected) {
|
|
||||||
selectedRows.value.push(record)
|
|
||||||
} else {
|
|
||||||
const index = selectedRows.value.findIndex(item => item[rowKey] === record[rowKey])
|
|
||||||
if (index > -1) {
|
|
||||||
selectedRows.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 全选/取消全选处理(用于scTable的@selectAll事件)
|
|
||||||
const handleSelectAll = (selected, selectedRows, changeRows) => {
|
|
||||||
if (!needSelection) return
|
|
||||||
if (selected) {
|
|
||||||
changeRows.forEach(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])
|
|
||||||
if (index > -1) {
|
|
||||||
selectedRows.value.splice(index, 1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
const loadData = async (params = {}) => {
|
|
||||||
if (!api) {
|
|
||||||
console.warn('useTable: 未提供api函数,无法加载数据')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const requestParams = {
|
|
||||||
...searchForm,
|
|
||||||
...params
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果需要分页,添加分页参数
|
|
||||||
if (needPagination) {
|
|
||||||
requestParams.page = pagination.current
|
|
||||||
requestParams.limit = pagination.pageSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用API函数,确保this上下文正确
|
|
||||||
const res = await api(requestParams)
|
|
||||||
|
|
||||||
if (res.code === 1) {
|
|
||||||
// 如果是分页数据
|
|
||||||
if (needPagination) {
|
|
||||||
tableData.value = res.data?.data || []
|
|
||||||
pagination.total = res.data?.total || 0
|
|
||||||
} else {
|
|
||||||
// 非分页数据(如树形数据)
|
|
||||||
// 确保数据是数组,如果不是数组则包装成数组
|
|
||||||
const data = res.data
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
tableData.value = data
|
|
||||||
} else if (data && typeof data === 'object') {
|
|
||||||
// 如果返回的是对象,可能包含 list 或 items 等字段
|
|
||||||
tableData.value = data.list || data.items || data.data || []
|
|
||||||
} else {
|
|
||||||
tableData.value = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '加载数据失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载数据失败:', error)
|
|
||||||
message.error('加载数据失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页变化处理
|
|
||||||
const handlePaginationChange = ({ page, pageSize }) => {
|
|
||||||
if (!needPagination) return
|
|
||||||
pagination.current = page
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const handleSearch = () => {
|
|
||||||
if (needPagination) {
|
|
||||||
pagination.current = 1
|
|
||||||
}
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
|
||||||
// 重置搜索表单为初始值
|
|
||||||
Object.keys(searchForm).forEach(key => {
|
|
||||||
searchForm[key] = initialSearchForm[key]
|
|
||||||
})
|
|
||||||
// 清空选择
|
|
||||||
selectedRows.value = []
|
|
||||||
// 重置分页
|
|
||||||
if (needPagination) {
|
|
||||||
pagination.current = 1
|
|
||||||
}
|
|
||||||
// 重新加载数据
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新表格
|
|
||||||
const refreshTable = () => {
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空选择
|
|
||||||
const clearSelection = () => {
|
|
||||||
selectedRows.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置选中行
|
|
||||||
const setSelectedRows = (rows) => {
|
|
||||||
selectedRows.value = rows
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新搜索表单
|
|
||||||
const setSearchForm = (data) => {
|
|
||||||
Object.assign(searchForm, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直接设置表格数据(用于特殊场景)
|
|
||||||
const setTableData = (data) => {
|
|
||||||
tableData.value = data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时自动加载数据
|
|
||||||
if (immediateLoad) {
|
|
||||||
onMounted(() => {
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// ref
|
|
||||||
tableRef,
|
|
||||||
// 响应式数据
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
pagination,
|
|
||||||
selectedRows,
|
|
||||||
selectedRowKeys,
|
|
||||||
// 配置
|
|
||||||
columns,
|
|
||||||
rowKey,
|
|
||||||
rowSelection,
|
|
||||||
// 方法
|
|
||||||
loadData,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
handlePaginationChange,
|
|
||||||
handleSelectChange,
|
|
||||||
handleSelectAll,
|
|
||||||
refreshTable,
|
|
||||||
clearSelection,
|
|
||||||
setSelectedRows,
|
|
||||||
setSearchForm,
|
|
||||||
setTableData
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+2
-2
@@ -8,8 +8,8 @@ const i18n = createI18n({
|
|||||||
fallbackLocale: 'en-US',
|
fallbackLocale: 'en-US',
|
||||||
messages: {
|
messages: {
|
||||||
'zh-CN': zh,
|
'zh-CN': zh,
|
||||||
'en-US': en
|
'en-US': en,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default i18n
|
export default i18n
|
||||||
|
|||||||
+10
-10
@@ -157,7 +157,7 @@ export default {
|
|||||||
selectAll: 'Select All',
|
selectAll: 'Select All',
|
||||||
unselectAll: 'Unselect All',
|
unselectAll: 'Unselect All',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
fetchDataFailed: 'Failed to fetch data'
|
fetchDataFailed: 'Failed to fetch data',
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
@@ -165,7 +165,7 @@ export default {
|
|||||||
roleManagement: 'Role Management',
|
roleManagement: 'Role Management',
|
||||||
permissionManagement: 'Permission Management',
|
permissionManagement: 'Permission Management',
|
||||||
systemSettings: 'System Settings',
|
systemSettings: 'System Settings',
|
||||||
logManagement: 'Log Management'
|
logManagement: 'Log Management',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: 'User Login',
|
title: 'User Login',
|
||||||
@@ -178,7 +178,7 @@ export default {
|
|||||||
noAccount: "Don't have an account?",
|
noAccount: "Don't have an account?",
|
||||||
registerNow: 'Register Now',
|
registerNow: 'Register Now',
|
||||||
forgotPassword: 'Forgot Password?',
|
forgotPassword: 'Forgot Password?',
|
||||||
rememberMe: 'Remember Me'
|
rememberMe: 'Remember Me',
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: 'User Registration',
|
title: 'User Registration',
|
||||||
@@ -197,7 +197,7 @@ export default {
|
|||||||
agreeTerms: 'I have read and agree to the',
|
agreeTerms: 'I have read and agree to the',
|
||||||
terms: 'User Agreement',
|
terms: 'User Agreement',
|
||||||
hasAccount: 'Already have an account?',
|
hasAccount: 'Already have an account?',
|
||||||
loginNow: 'Login Now'
|
loginNow: 'Login Now',
|
||||||
},
|
},
|
||||||
resetPassword: {
|
resetPassword: {
|
||||||
title: 'Reset Password',
|
title: 'Reset Password',
|
||||||
@@ -216,13 +216,13 @@ export default {
|
|||||||
codeSent: 'Verification code has been sent to your email',
|
codeSent: 'Verification code has been sent to your email',
|
||||||
resendCode: 'Resend in {seconds} seconds',
|
resendCode: 'Resend in {seconds} seconds',
|
||||||
sendCodeFirst: 'Please enter email address first',
|
sendCodeFirst: 'Please enter email address first',
|
||||||
backToLogin: 'Back to Login'
|
backToLogin: 'Back to Login',
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
toggleSidebar: 'Toggle Sidebar',
|
toggleSidebar: 'Toggle Sidebar',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
logout: 'Logout'
|
logout: 'Logout',
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
total: 'Total {total} items',
|
total: 'Total {total} items',
|
||||||
@@ -230,13 +230,13 @@ export default {
|
|||||||
actions: 'Actions',
|
actions: 'Actions',
|
||||||
noData: 'No Data',
|
noData: 'No Data',
|
||||||
sort: 'Sort',
|
sort: 'Sort',
|
||||||
filter: 'Filter'
|
filter: 'Filter',
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
goTo: 'Go to',
|
goTo: 'Go to',
|
||||||
page: 'Page',
|
page: 'Page',
|
||||||
total: 'Total {total} items',
|
total: 'Total {total} items',
|
||||||
itemsPerPage: '{size} items per page'
|
itemsPerPage: '{size} items per page',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
required: 'This field is required',
|
required: 'This field is required',
|
||||||
@@ -244,6 +244,6 @@ export default {
|
|||||||
invalidPhone: 'Please enter a valid phone number',
|
invalidPhone: 'Please enter a valid phone number',
|
||||||
passwordMismatch: 'Passwords do not match',
|
passwordMismatch: 'Passwords do not match',
|
||||||
minLength: 'Minimum {min} characters required',
|
minLength: 'Minimum {min} characters required',
|
||||||
maxLength: 'Maximum {max} characters allowed'
|
maxLength: 'Maximum {max} characters allowed',
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -156,7 +156,7 @@ export default {
|
|||||||
columnSettings: '列显示设置',
|
columnSettings: '列显示设置',
|
||||||
selectAll: '全选',
|
selectAll: '全选',
|
||||||
unselectAll: '取消全选',
|
unselectAll: '取消全选',
|
||||||
retry: '重试'
|
retry: '重试',
|
||||||
},
|
},
|
||||||
menu: {
|
menu: {
|
||||||
dashboard: '仪表板',
|
dashboard: '仪表板',
|
||||||
@@ -164,7 +164,7 @@ export default {
|
|||||||
roleManagement: '角色管理',
|
roleManagement: '角色管理',
|
||||||
permissionManagement: '权限管理',
|
permissionManagement: '权限管理',
|
||||||
systemSettings: '系统设置',
|
systemSettings: '系统设置',
|
||||||
logManagement: '日志管理'
|
logManagement: '日志管理',
|
||||||
},
|
},
|
||||||
login: {
|
login: {
|
||||||
title: '用户登录',
|
title: '用户登录',
|
||||||
@@ -177,7 +177,7 @@ export default {
|
|||||||
noAccount: '还没有账户?',
|
noAccount: '还没有账户?',
|
||||||
registerNow: '立即注册',
|
registerNow: '立即注册',
|
||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
rememberMe: '记住我'
|
rememberMe: '记住我',
|
||||||
},
|
},
|
||||||
register: {
|
register: {
|
||||||
title: '用户注册',
|
title: '用户注册',
|
||||||
@@ -196,7 +196,7 @@ export default {
|
|||||||
agreeTerms: '我已阅读并同意',
|
agreeTerms: '我已阅读并同意',
|
||||||
terms: '用户协议',
|
terms: '用户协议',
|
||||||
hasAccount: '已有账户?',
|
hasAccount: '已有账户?',
|
||||||
loginNow: '立即登录'
|
loginNow: '立即登录',
|
||||||
},
|
},
|
||||||
resetPassword: {
|
resetPassword: {
|
||||||
title: '重置密码',
|
title: '重置密码',
|
||||||
@@ -215,13 +215,13 @@ export default {
|
|||||||
codeSent: '验证码已发送到您的邮箱',
|
codeSent: '验证码已发送到您的邮箱',
|
||||||
resendCode: '{seconds}秒后重新发送',
|
resendCode: '{seconds}秒后重新发送',
|
||||||
sendCodeFirst: '请先输入邮箱地址',
|
sendCodeFirst: '请先输入邮箱地址',
|
||||||
backToLogin: '返回登录'
|
backToLogin: '返回登录',
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
toggleSidebar: '切换侧边栏',
|
toggleSidebar: '切换侧边栏',
|
||||||
collapse: '折叠',
|
collapse: '折叠',
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
logout: '退出登录'
|
logout: '退出登录',
|
||||||
},
|
},
|
||||||
table: {
|
table: {
|
||||||
total: '共 {total} 条',
|
total: '共 {total} 条',
|
||||||
@@ -229,13 +229,13 @@ export default {
|
|||||||
actions: '操作',
|
actions: '操作',
|
||||||
noData: '暂无数据',
|
noData: '暂无数据',
|
||||||
sort: '排序',
|
sort: '排序',
|
||||||
filter: '筛选'
|
filter: '筛选',
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
goTo: '前往',
|
goTo: '前往',
|
||||||
page: '页',
|
page: '页',
|
||||||
total: '共 {total} 条',
|
total: '共 {total} 条',
|
||||||
itemsPerPage: '每页 {size} 条'
|
itemsPerPage: '每页 {size} 条',
|
||||||
},
|
},
|
||||||
form: {
|
form: {
|
||||||
required: '此项为必填项',
|
required: '此项为必填项',
|
||||||
@@ -243,6 +243,6 @@ export default {
|
|||||||
invalidPhone: '请输入有效的手机号',
|
invalidPhone: '请输入有效的手机号',
|
||||||
passwordMismatch: '两次输入的密码不一致',
|
passwordMismatch: '两次输入的密码不一致',
|
||||||
minLength: '最少需要 {min} 个字符',
|
minLength: '最少需要 {min} 个字符',
|
||||||
maxLength: '最多允许 {max} 个字符'
|
maxLength: '最多允许 {max} 个字符',
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +1,67 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-breadcrumb class="breadcrumb">
|
<el-breadcrumb separator="/">
|
||||||
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
|
<transition-group name="breadcrumb">
|
||||||
<span v-if="index === breadcrumbList.length - 1" class="no-redirect">
|
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||||
<component :is="item.meta?.icon || 'FileTextOutlined'" />
|
<span v-if="item.redirect === 'noRedirect' || index === levelList.length - 1" class="no-redirect">
|
||||||
{{ item.meta.title }}
|
{{ item.meta.title }}
|
||||||
</span>
|
</span>
|
||||||
<a v-else @click.prevent="handleLink(item)">
|
<a v-else class="redirect" @click.prevent="handleLink(item)">
|
||||||
<component :is="item.meta?.icon || 'HomeOutlined'" />
|
|
||||||
{{ item.meta.title }}
|
{{ item.meta.title }}
|
||||||
</a>
|
</a>
|
||||||
</a-breadcrumb-item>
|
</el-breadcrumb-item>
|
||||||
</a-breadcrumb>
|
</transition-group>
|
||||||
|
</el-breadcrumb>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
// 定义组件名称(多词命名)
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'LayoutBreadcrumb'
|
name: 'LayoutBreadcrumb',
|
||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const breadcrumbList = ref([])
|
|
||||||
|
|
||||||
// 获取面包屑列表
|
const levelList = ref([])
|
||||||
|
|
||||||
const getBreadcrumb = () => {
|
const getBreadcrumb = () => {
|
||||||
let matched = route.matched.filter(item => item.meta && item.meta.title)
|
let matched = route.matched.filter((item) => {
|
||||||
|
if (item.meta && item.meta.title) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
// 如果第一个不是首页,添加首页
|
|
||||||
const first = matched[0]
|
const first = matched[0]
|
||||||
if (first && first.path !== '/') {
|
|
||||||
matched = [{ path: '/', meta: { title: '首页' } }].concat(matched)
|
if (!isDashboard(first)) {
|
||||||
|
matched = [{ path: '/dashboard', meta: { title: '首页' } }].concat(matched)
|
||||||
}
|
}
|
||||||
|
|
||||||
breadcrumbList.value = matched
|
levelList.value = matched.filter(
|
||||||
|
(item) => item.meta && item.meta.title && item.meta.breadcrumb !== false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDashboard = (route) => {
|
||||||
|
const name = route && route.name
|
||||||
|
if (!name) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return name.toString().trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理点击面包屑
|
|
||||||
const handleLink = (item) => {
|
const handleLink = (item) => {
|
||||||
router.push(item.path)
|
const { redirect, path } = item
|
||||||
|
if (redirect) {
|
||||||
|
router.push(redirect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听路由变化
|
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
() => {
|
() => {
|
||||||
@@ -53,3 +70,42 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.app-breadcrumb.el-breadcrumb {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 50px;
|
||||||
|
margin-left: 8px;
|
||||||
|
|
||||||
|
.no-redirect {
|
||||||
|
color: #97a8be;
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redirect {
|
||||||
|
color: #666;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-enter-active,
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
transition: all 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-enter-from,
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<template v-for="menu in menuList" :key="menu.path">
|
||||||
|
<!-- 有子菜单 -->
|
||||||
|
<el-sub-menu v-if="hasChildren(menu) && !menu.meta?.hidden" :index="menu.path">
|
||||||
|
<template #title>
|
||||||
|
<el-icon v-if="menu.meta?.icon">
|
||||||
|
<component :is="menu.meta.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<span>{{ menu.meta?.title }}</span>
|
||||||
|
</template>
|
||||||
|
<!-- 递归渲染子菜单 -->
|
||||||
|
<menu-item :menu-list="menu.children" :parent-path="menu.path" />
|
||||||
|
</el-sub-menu>
|
||||||
|
|
||||||
|
<!-- 无子菜单 -->
|
||||||
|
<el-menu-item v-else-if="!menu.meta?.hidden" :index="menu.path" @click="handleMenuClick(menu)">
|
||||||
|
<el-icon v-if="menu.meta?.icon">
|
||||||
|
<component :is="menu.meta.icon" />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>
|
||||||
|
<span>{{ menu.meta?.title }}</span>
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { defineProps } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'LayoutMenuItem',
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
menuList: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
parentPath: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 判断是否有子菜单
|
||||||
|
const hasChildren = (menu) => {
|
||||||
|
return menu.children && menu.children.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理菜单点击
|
||||||
|
const handleMenuClick = (menu) => {
|
||||||
|
if (menu.meta?.link) {
|
||||||
|
// 外部链接
|
||||||
|
window.open(menu.meta.link, '_blank')
|
||||||
|
} else if (menu.path) {
|
||||||
|
// 内部路由
|
||||||
|
router.push(menu.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
// 菜单项样式继承自 Element Plus
|
||||||
|
</style>
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
<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}`">
|
|
||||||
<template #icon v-if="item.meta?.icon">
|
|
||||||
<component :is="getIconComponent(item.meta.icon)" />
|
|
||||||
</template>
|
|
||||||
<template #title>{{ item.meta?.title || item.name }}</template>
|
|
||||||
<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)">
|
|
||||||
<template #icon v-if="item.meta?.icon">
|
|
||||||
<component :is="getIconComponent(item.meta.icon)" />
|
|
||||||
</template>
|
|
||||||
{{ item.meta?.title || item.name }}
|
|
||||||
</a-menu-item>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import * as icons from '@ant-design/icons-vue'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
menuItems: {
|
|
||||||
type: Array,
|
|
||||||
default: () => []
|
|
||||||
},
|
|
||||||
activePath: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
parentPath: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
// 获取图标组件
|
|
||||||
const getIconComponent = (iconName) => {
|
|
||||||
return icons[iconName] || icons.FileTextOutlined
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理菜单点击
|
|
||||||
const handleMenuClick = (item) => {
|
|
||||||
if (item.path) {
|
|
||||||
router.push(item.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<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 class="result-icon">
|
|
||||||
<component :is="item.icon || 'MenuOutlined'" />
|
|
||||||
</div>
|
|
||||||
<div class="result-content">
|
|
||||||
<div class="result-title">{{ item.title }}</div>
|
|
||||||
<div v-if="item.breadcrumbs" class="result-path">{{ item.breadcrumbs }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="searchKeyword" class="no-results">
|
|
||||||
<a-empty :description="$t('common.noResults')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="search-tips">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="tip-item">
|
|
||||||
<kbd>Enter</kbd>
|
|
||||||
<span>{{ $t('common.selectResult') }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="tip-item">
|
|
||||||
<kbd>Esc</kbd>
|
|
||||||
<span>{{ $t('common.closeSearch') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</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'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'MenuSearch',
|
|
||||||
})
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
// 将扁平化的菜单数据转换为可搜索格式
|
|
||||||
function flattenMenus(menus, breadcrumbs = []) {
|
|
||||||
const result = []
|
|
||||||
|
|
||||||
menus.forEach((menu) => {
|
|
||||||
if (menu.hidden) return
|
|
||||||
|
|
||||||
const currentBreadcrumbs = [...breadcrumbs, menu.title]
|
|
||||||
|
|
||||||
// 如果有路径且不是外部链接,添加到搜索结果
|
|
||||||
if (menu.path && !menu.path.startsWith('http')) {
|
|
||||||
result.push({
|
|
||||||
title: menu.title,
|
|
||||||
path: menu.path,
|
|
||||||
icon: menu.icon,
|
|
||||||
breadcrumbs: currentBreadcrumbs.join(' / '),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归处理子菜单
|
|
||||||
if (menu.children && menu.children.length > 0) {
|
|
||||||
const children = flattenMenus(menu.children, currentBreadcrumbs)
|
|
||||||
result.push(...children)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有菜单项
|
|
||||||
const allMenus = computed(() => {
|
|
||||||
const menus = userStore.menu || []
|
|
||||||
return flattenMenus(menus)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 执行搜索
|
|
||||||
function handleSearch() {
|
|
||||||
if (!searchKeyword.value.trim()) {
|
|
||||||
searchResults.value = []
|
|
||||||
selectedIndex.value = 0
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyword = searchKeyword.value.toLowerCase().trim()
|
|
||||||
searchResults.value = allMenus.value.filter((menu) => {
|
|
||||||
return menu.title.toLowerCase().includes(keyword) ||
|
|
||||||
menu.breadcrumbs.toLowerCase().includes(keyword)
|
|
||||||
})
|
|
||||||
|
|
||||||
selectedIndex.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 键盘导航
|
|
||||||
function handleKeydown(e) {
|
|
||||||
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()
|
|
||||||
if (searchResults.value[selectedIndex.value]) {
|
|
||||||
handleSelect(searchResults.value[selectedIndex.value])
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
e.preventDefault()
|
|
||||||
handleClose()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 选择菜单项
|
|
||||||
function handleSelect(item) {
|
|
||||||
visible.value = false
|
|
||||||
router.push(item.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭搜索弹窗
|
|
||||||
function handleClose() {
|
|
||||||
visible.value = false
|
|
||||||
searchKeyword.value = ''
|
|
||||||
searchResults.value = []
|
|
||||||
selectedIndex.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听弹窗显示,自动聚焦输入框
|
|
||||||
watch(visible, (newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
nextTick(() => {
|
|
||||||
searchInputRef.value?.focus()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.menu-search {
|
|
||||||
.search-results {
|
|
||||||
max-height: 400px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-top: 16px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
.result-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&.active {
|
|
||||||
background-color: #e6f7ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-icon {
|
|
||||||
margin-right: 12px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
.result-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.result-path {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
|
||||||
margin-top: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-tips {
|
|
||||||
margin-top: 20px;
|
|
||||||
|
|
||||||
.tip-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tip-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.tip-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 8px 16px;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 2px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-family: inherit;
|
|
||||||
line-height: 1;
|
|
||||||
color: #333;
|
|
||||||
background-color: #fff;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 2px;
|
|
||||||
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+105
-308
@@ -1,349 +1,146 @@
|
|||||||
<template>
|
<template>
|
||||||
<a-drawer v-model:open="open" title="布局配置" placement="right" :width="420">
|
<div class="drawer-container">
|
||||||
<div class="setting-content">
|
<el-drawer v-model="drawerVisible" title="布局设置" size="280px">
|
||||||
<div class="setting-item">
|
<el-scrollbar>
|
||||||
<div class="setting-title">布局模式</div>
|
<el-form label-width="100px" label-position="left">
|
||||||
<div class="layout-mode-list">
|
<el-form-item label="布局模式">
|
||||||
<div v-for="mode in layoutModes" :key="mode.value" class="layout-mode-item"
|
<el-select v-model="layoutMode" placeholder="请选择" @change="handleLayoutModeChange">
|
||||||
:class="{ active: layoutStore.layoutMode === mode.value }"
|
<el-option label="默认布局" value="default" />
|
||||||
@click="handleLayoutChange(mode.value)">
|
<el-option label="菜单布局" value="menu" />
|
||||||
<div class="layout-preview" :class="`preview-${mode.value}`">
|
<el-option label="顶部布局" value="top" />
|
||||||
<div class="preview-sidebar"></div>
|
</el-select>
|
||||||
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
|
</el-form-item>
|
||||||
<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" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
<el-form-item label="主题颜色">
|
||||||
<div class="setting-title">主题颜色</div>
|
<el-color-picker v-model="themeColor" @change="handleThemeChange" />
|
||||||
<div class="color-list">
|
</el-form-item>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
<el-divider>显示设置</el-divider>
|
||||||
<div class="setting-title">显示设置</div>
|
|
||||||
<div class="toggle-list">
|
|
||||||
<div class="toggle-item">
|
|
||||||
<span>显示标签栏</span>
|
|
||||||
<a-switch v-model:checked="showTags" @change="handleShowTagsChange" />
|
|
||||||
</div>
|
|
||||||
<div class="toggle-item">
|
|
||||||
<span>显示面包屑</span>
|
|
||||||
<a-switch v-model:checked="showBreadcrumb" @change="handleShowBreadcrumbChange" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="setting-item">
|
<el-form-item label="显示标签栏">
|
||||||
<div class="setting-title">其他设置</div>
|
<el-switch v-model="showTags" @change="handleShowTagsChange" />
|
||||||
<div class="action-buttons">
|
</el-form-item>
|
||||||
<a-button type="primary" block @click="handleResetSettings">
|
|
||||||
<ReloadOutlined />
|
<el-form-item label="显示面包屑">
|
||||||
重置设置
|
<el-switch v-model="showBreadcrumb" @change="handleShowBreadcrumbChange" />
|
||||||
</a-button>
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-divider>其他</el-divider>
|
||||||
|
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleReset">重置设置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</el-scrollbar>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
|
<div class="setting-btn" @click="drawerVisible = true">
|
||||||
|
<el-icon :size="24">
|
||||||
|
<component :is="'ElIconSetting'" />
|
||||||
|
</el-icon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</a-drawer>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { message } from 'ant-design-vue'
|
import { useLayoutStore } from '../../stores/modules/layout'
|
||||||
import { useLayoutStore } from '@/stores/modules/layout'
|
import { ElMessage } from 'element-plus'
|
||||||
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
|
||||||
|
|
||||||
// 定义组件名称(多词命名)
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'LayoutSetting',
|
name: 'LayoutSetting',
|
||||||
})
|
})
|
||||||
|
|
||||||
const layoutStore = useLayoutStore()
|
const layoutStore = useLayoutStore()
|
||||||
|
|
||||||
const open = ref(false)
|
const drawerVisible = ref(false)
|
||||||
const themeColor = ref('#1890ff')
|
|
||||||
const showTags = ref(true)
|
|
||||||
const showBreadcrumb = ref(true)
|
|
||||||
|
|
||||||
const layoutModes = [
|
// 布局模式
|
||||||
{ value: 'default', label: '默认布局' },
|
const layoutMode = computed({
|
||||||
{ value: 'menu', label: '菜单布局' },
|
get: () => layoutStore.layoutMode,
|
||||||
{ value: 'top', label: '顶部布局' },
|
set: (value) => {
|
||||||
]
|
layoutStore.setLayoutMode(value)
|
||||||
|
},
|
||||||
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
|
|
||||||
|
|
||||||
const openDrawer = () => {
|
|
||||||
open.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
const closeDrawer = () => {
|
|
||||||
open.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
openDrawer,
|
|
||||||
closeDrawer,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 切换布局
|
// 主题颜色
|
||||||
const handleLayoutChange = (mode) => {
|
const themeColor = computed({
|
||||||
layoutStore.setLayoutMode(mode)
|
get: () => layoutStore.themeColor,
|
||||||
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
|
set: (value) => {
|
||||||
message.success(`已切换到${modeLabel}`)
|
layoutStore.setThemeColor(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示标签栏
|
||||||
|
const showTags = computed({
|
||||||
|
get: () => layoutStore.showTags,
|
||||||
|
set: (value) => {
|
||||||
|
layoutStore.setShowTags(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 显示面包屑
|
||||||
|
const showBreadcrumb = computed({
|
||||||
|
get: () => layoutStore.showBreadcrumb,
|
||||||
|
set: (value) => {
|
||||||
|
layoutStore.setShowBreadcrumb(value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 布局模式变化
|
||||||
|
const handleLayoutModeChange = (value) => {
|
||||||
|
layoutStore.setLayoutMode(value)
|
||||||
|
ElMessage.success('布局模式已切换')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换主题颜色
|
// 主题颜色变化
|
||||||
const changeThemeColor = (color) => {
|
const handleThemeChange = (value) => {
|
||||||
themeColor.value = color
|
layoutStore.setThemeColor(value)
|
||||||
// 更新 CSS 变量
|
|
||||||
document.documentElement.style.setProperty('--primary-color', color)
|
|
||||||
message.success('主题颜色已更新')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换标签栏显示
|
// 显示标签栏变化
|
||||||
const handleShowTagsChange = (checked) => {
|
const handleShowTagsChange = (value) => {
|
||||||
showTags.value = checked
|
layoutStore.setShowTags(value)
|
||||||
// 触发自定义事件或更新状态
|
|
||||||
document.documentElement.style.setProperty('--show-tags', checked ? 'block' : 'none')
|
|
||||||
message.success(checked ? '标签栏已显示' : '标签栏已隐藏')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换面包屑显示
|
// 显示面包屑变化
|
||||||
const handleShowBreadcrumbChange = (checked) => {
|
const handleShowBreadcrumbChange = (value) => {
|
||||||
showBreadcrumb.value = checked
|
layoutStore.setShowBreadcrumb(value)
|
||||||
message.success(checked ? '面包屑已显示' : '面包屑已隐藏')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置设置
|
// 重置设置
|
||||||
const handleResetSettings = () => {
|
const handleReset = () => {
|
||||||
themeColor.value = '#1890ff'
|
layoutStore.resetTheme()
|
||||||
showTags.value = true
|
ElMessage.success('设置已重置')
|
||||||
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')
|
|
||||||
if (savedThemeColor) {
|
|
||||||
themeColor.value = savedThemeColor
|
|
||||||
document.documentElement.style.setProperty('--primary-color', savedThemeColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedShowTags = localStorage.getItem('showTags')
|
|
||||||
if (savedShowTags !== null) {
|
|
||||||
showTags.value = savedShowTags === 'true'
|
|
||||||
document.documentElement.style.setProperty('--show-tags', savedShowTags === 'true' ? 'block' : 'none')
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听设置变化并保存到本地存储
|
|
||||||
watch(themeColor, (newVal) => {
|
|
||||||
localStorage.setItem('themeColor', newVal)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(showTags, (newVal) => {
|
|
||||||
localStorage.setItem('showTags', String(newVal))
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.setting-content {
|
.drawer-container {
|
||||||
.setting-item {
|
.setting-btn {
|
||||||
margin-bottom: 32px;
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
.setting-title {
|
right: 0;
|
||||||
font-size: 14px;
|
z-index: 9999;
|
||||||
font-weight: 500;
|
width: 48px;
|
||||||
margin-bottom: 16px;
|
height: 48px;
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-mode-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.layout-mode-item {
|
|
||||||
position: relative;
|
|
||||||
border: 2px solid #e8e8e8;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--primary-color, #1890ff);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: var(--primary-color, #1890ff);
|
|
||||||
background-color: rgba(24, 144, 255, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-preview {
|
|
||||||
width: 100%;
|
|
||||||
height: 60px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
background-color: #f0f2f5;
|
|
||||||
|
|
||||||
.preview-sidebar {
|
|
||||||
background-color: #001529;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-sidebar-2 {
|
|
||||||
background-color: #fff;
|
|
||||||
border-left: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
flex: 1;
|
|
||||||
padding: 4px;
|
|
||||||
|
|
||||||
.preview-header {
|
|
||||||
height: 8px;
|
|
||||||
background-color: #fff;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-body {
|
|
||||||
height: calc(100% - 12px);
|
|
||||||
background-color: #e8e8e8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.preview-default {
|
|
||||||
.preview-sidebar {
|
|
||||||
width: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-sidebar-2 {
|
|
||||||
width: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.preview-menu {
|
|
||||||
.preview-sidebar {
|
|
||||||
width: 30px;
|
|
||||||
background-color: #fff;
|
|
||||||
border-right: 1px solid #e8e8e8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.preview-top {
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
.preview-sidebar {
|
|
||||||
width: 100%;
|
|
||||||
height: 12px;
|
|
||||||
background-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-content {
|
|
||||||
.preview-header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview-body {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout-name {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px;
|
|
||||||
right: 4px;
|
|
||||||
color: var(--primary-color, #1890ff);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-list {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.color-item {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transition: all 0.2s;
|
background: var(--primary-color);
|
||||||
border: 2px solid transparent;
|
color: #fff;
|
||||||
|
border-radius: 6px 0 0 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.1);
|
background: var(--primary-color);
|
||||||
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
.el-icon {
|
||||||
border-color: #fff;
|
font-size: 20px;
|
||||||
box-shadow: 0 0 0 2px var(--primary-color, #1890ff);
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-list {
|
|
||||||
.toggle-item {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
|
||||||
:deep(.ant-btn) {
|
|
||||||
height: 40px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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'">
|
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
<template #icon>
|
|
||||||
<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 #icon>
|
|
||||||
<component :is="grandChild.meta?.icon || 'FileOutlined'" />
|
|
||||||
</template>
|
|
||||||
{{ grandChild.meta?.title || grandChild.name }}
|
|
||||||
</a-menu-item>
|
|
||||||
</a-sub-menu>
|
|
||||||
</a-sub-menu>
|
|
||||||
<!-- 无子菜单 -->
|
|
||||||
<a-menu-item v-else :key="item.path + '-item'">
|
|
||||||
<template #icon>
|
|
||||||
<component :is="item.meta?.icon || 'MenuOutlined'" />
|
|
||||||
</template>
|
|
||||||
{{ item.meta?.title || item.name }}
|
|
||||||
</a-menu-item>
|
|
||||||
</template>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, watch, onMounted } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { getUserMenu } from '@/api/menu'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
collapsed: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
},
|
|
||||||
theme: {
|
|
||||||
type: String,
|
|
||||||
default: 'light'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const menuList = ref([])
|
|
||||||
const selectedKeys = ref([])
|
|
||||||
const openKeys = ref([])
|
|
||||||
|
|
||||||
// 获取菜单数据
|
|
||||||
const getMenuList = async () => {
|
|
||||||
try {
|
|
||||||
const res = await getUserMenu()
|
|
||||||
if (res.code === 200) {
|
|
||||||
menuList.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取菜单失败:', error)
|
|
||||||
// 模拟数据
|
|
||||||
menuList.value = [
|
|
||||||
{
|
|
||||||
path: '/home',
|
|
||||||
name: 'Home',
|
|
||||||
meta: { title: '首页', icon: 'HomeOutlined' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/system',
|
|
||||||
name: 'System',
|
|
||||||
meta: { title: '系统管理', icon: 'SettingOutlined' },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: '/system/user',
|
|
||||||
name: 'User',
|
|
||||||
meta: { title: '用户管理', icon: 'UserOutlined' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/system/role',
|
|
||||||
name: 'Role',
|
|
||||||
meta: { title: '角色管理', icon: 'TeamOutlined' }
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/system/menu',
|
|
||||||
name: 'Menu',
|
|
||||||
meta: { title: '菜单管理', icon: 'MenuOutlined' }
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新选中的菜单
|
|
||||||
const updateSelectedKeys = () => {
|
|
||||||
selectedKeys.value = [route.path]
|
|
||||||
|
|
||||||
// 获取父级菜单路径
|
|
||||||
const matched = route.matched
|
|
||||||
.filter(item => item.path !== '/' && item.path !== route.path)
|
|
||||||
.map(item => item.path)
|
|
||||||
|
|
||||||
// 折叠时不自动展开
|
|
||||||
if (!props.collapsed) {
|
|
||||||
openKeys.value = matched
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理菜单选择
|
|
||||||
const handleSelect = ({ key }) => {
|
|
||||||
router.push(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理菜单展开/收起
|
|
||||||
const handleOpenChange = (keys) => {
|
|
||||||
openKeys.value = keys
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听路由变化
|
|
||||||
watch(() => route.path, () => {
|
|
||||||
updateSelectedKeys()
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// 监听折叠状态
|
|
||||||
watch(() => props.collapsed, (val) => {
|
|
||||||
if (val) {
|
|
||||||
openKeys.value = []
|
|
||||||
} else {
|
|
||||||
updateSelectedKeys()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getMenuList()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.side-menu {
|
|
||||||
height: calc(100% - 60px);
|
|
||||||
border-right: none;
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+340
-408
@@ -1,96 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-show="showTags" class="tags-view">
|
<div class="tags-view-container">
|
||||||
<div class="tags-wrapper" @contextmenu.prevent>
|
<div class="tags-view-wrapper" @wheel="handleWheel">
|
||||||
<a-space :size="4">
|
<div ref="scrollRef" class="scroll-content"
|
||||||
<a-tag
|
:style="{ transform: `translateX(${scrollState.translateX}px)`, transition: scrollState.transition }">
|
||||||
v-for="tag in visitedViews"
|
<router-link v-for="(tag, index) in visitedViews" :id="`scroll-li-${index}`" :key="tag.path"
|
||||||
:key="tag.fullPath"
|
:class="isActive(tag) ? 'active' : ''"
|
||||||
:closable="!tag.meta?.affix"
|
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }" class="tags-view-item"
|
||||||
class="tag-item"
|
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
|
||||||
:class="{ active: isActive(tag), 'tag-affix': tag.meta?.affix }"
|
@contextmenu.prevent="openMenu(tag, $event)">
|
||||||
@click="clickTag(tag)"
|
{{ tag.title }}
|
||||||
@close="closeSelectedTag(tag)"
|
<el-icon v-if="!isAffix(tag)" class="close-icon" @click.prevent.stop="closeSelectedTag(tag)">
|
||||||
@contextmenu.prevent="handleContextMenu($event, tag)">
|
<ElIconClose />
|
||||||
<template #icon v-if="tag.meta?.affix">
|
</el-icon>
|
||||||
<PushpinFilled />
|
</router-link>
|
||||||
</template>
|
|
||||||
{{ tag.meta?.title || tag.name }}
|
|
||||||
</a-tag>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tags-actions">
|
|
||||||
<a-dropdown v-model:open="actionMenuVisible" trigger="click" placement="bottomRight">
|
|
||||||
<a-button size="small" type="text">
|
|
||||||
<MoreOutlined />
|
|
||||||
</a-button>
|
|
||||||
<template #overlay>
|
|
||||||
<a-menu @click="handleActionMenuClick">
|
|
||||||
<a-menu-item key="refresh">
|
|
||||||
<ReloadOutlined />
|
|
||||||
<span>刷新当前页</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="closeOthers">
|
|
||||||
<ColumnWidthOutlined />
|
|
||||||
<span>关闭其他</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="closeLeft">
|
|
||||||
<LeftOutlined />
|
|
||||||
<span>关闭左侧</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="closeRight">
|
|
||||||
<RightOutlined />
|
|
||||||
<span>关闭右侧</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="closeAll">
|
|
||||||
<CloseCircleOutlined />
|
|
||||||
<span>关闭所有</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||||
<!-- 右键菜单 -->
|
<li @click="refreshSelectedTag(selectedTag)">
|
||||||
<teleport to="body">
|
<el-icon>
|
||||||
<div
|
<ElIconRefresh />
|
||||||
v-if="contextMenu.visible"
|
</el-icon>
|
||||||
:style="{
|
刷新
|
||||||
position: 'fixed',
|
</li>
|
||||||
left: contextMenu.x + 'px',
|
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||||
top: contextMenu.y + 'px',
|
<el-icon>
|
||||||
zIndex: 9999
|
<ElIconClose />
|
||||||
}"
|
</el-icon>
|
||||||
class="context-menu"
|
关闭
|
||||||
@click="closeContextMenu">
|
</li>
|
||||||
<a-menu @click="handleMenuClick">
|
<li @click="closeOthersTags">
|
||||||
<a-menu-item key="refresh">
|
<el-icon>
|
||||||
<ReloadOutlined />
|
<ElIconCircleClose />
|
||||||
<span>刷新</span>
|
</el-icon>
|
||||||
</a-menu-item>
|
关闭其他
|
||||||
<a-menu-item v-if="selectedTag && !selectedTag.meta?.affix" key="close">
|
</li>
|
||||||
<CloseOutlined />
|
<li @click="closeAllTags(selectedTag)">
|
||||||
<span>关闭</span>
|
<el-icon>
|
||||||
</a-menu-item>
|
<ElIconFolderDelete />
|
||||||
<a-menu-item key="closeOthers">
|
</el-icon>
|
||||||
<ColumnWidthOutlined />
|
关闭所有
|
||||||
<span>关闭其他</span>
|
</li>
|
||||||
</a-menu-item>
|
</ul>
|
||||||
<a-menu-item key="closeAll">
|
|
||||||
<CloseCircleOutlined />
|
|
||||||
<span>关闭所有</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</div>
|
|
||||||
</teleport>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useLayoutStore } from '@/stores/modules/layout'
|
import { useLayoutStore } from '../../stores/modules/layout'
|
||||||
import config from '@/config'
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'TagsView',
|
name: 'TagsView',
|
||||||
@@ -100,386 +57,361 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const layoutStore = useLayoutStore()
|
const layoutStore = useLayoutStore()
|
||||||
|
|
||||||
const showTags = ref(true)
|
const visible = ref(false)
|
||||||
const selectedTag = ref(null)
|
const top = ref(0)
|
||||||
const visitedViews = computed(() => layoutStore.viewTags)
|
const left = ref(0)
|
||||||
// 右键菜单状态
|
const selectedTag = ref({})
|
||||||
const contextMenu = ref({
|
const affixTags = ref([])
|
||||||
visible: false,
|
const scrollRef = ref(null)
|
||||||
x: 0,
|
const scrollState = ref({
|
||||||
y: 0
|
translateX: 0,
|
||||||
|
transition: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const visitedViews = computed(() => layoutStore.viewTags)
|
||||||
|
const activeTabIndex = computed(() => {
|
||||||
|
const index = visitedViews.value.findIndex(view => view.path === route.path)
|
||||||
|
return index
|
||||||
})
|
})
|
||||||
// 顶部操作菜单状态
|
|
||||||
const actionMenuVisible = ref(false)
|
|
||||||
|
|
||||||
// 判断是否是当前激活的标签
|
|
||||||
const isActive = (tag) => {
|
const isActive = (tag) => {
|
||||||
return tag.fullPath === route.fullPath
|
return tag.path === route.path
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAffix = (tag) => {
|
||||||
|
return tag.meta && tag.meta.affix
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滚动逻辑
|
||||||
|
const setTransition = () => {
|
||||||
|
scrollState.value.transition = 'transform 0.5s cubic-bezier(0.15, 0, 0.15, 1)'
|
||||||
|
setTimeout(() => {
|
||||||
|
scrollState.value.transition = ''
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentTabElement = () => {
|
||||||
|
const index = activeTabIndex.value
|
||||||
|
return index >= 0 ? document.getElementById(`scroll-li-${index}`) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const calculateScrollPosition = () => {
|
||||||
|
if (!scrollRef.value) return null
|
||||||
|
|
||||||
|
const scrollWrapper = scrollRef.value.parentElement
|
||||||
|
if (!scrollWrapper) return null
|
||||||
|
|
||||||
|
const scrollWidth = scrollWrapper.offsetWidth
|
||||||
|
const ulWidth = scrollRef.value.offsetWidth
|
||||||
|
const curTabEl = getCurrentTabElement()
|
||||||
|
|
||||||
|
if (!curTabEl) return null
|
||||||
|
|
||||||
|
const { offsetLeft, clientWidth } = curTabEl
|
||||||
|
const curTabRight = offsetLeft + clientWidth
|
||||||
|
const targetLeft = scrollWidth - curTabRight
|
||||||
|
|
||||||
|
return {
|
||||||
|
scrollWidth,
|
||||||
|
ulWidth,
|
||||||
|
offsetLeft,
|
||||||
|
clientWidth,
|
||||||
|
curTabRight,
|
||||||
|
targetLeft
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoPositionTab = () => {
|
||||||
|
const positions = calculateScrollPosition()
|
||||||
|
if (!positions) return
|
||||||
|
|
||||||
|
const { scrollWidth, ulWidth, offsetLeft, curTabRight, targetLeft } = positions
|
||||||
|
const currentTranslateX = scrollState.value.translateX
|
||||||
|
|
||||||
|
if ((offsetLeft > Math.abs(currentTranslateX) && curTabRight <= scrollWidth) ||
|
||||||
|
(currentTranslateX < targetLeft && targetLeft < 0)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (curTabRight > scrollWidth) {
|
||||||
|
scrollState.value.translateX = Math.max(targetLeft - 6, scrollWidth - ulWidth)
|
||||||
|
} else if (offsetLeft < Math.abs(currentTranslateX)) {
|
||||||
|
scrollState.value.translateX = -offsetLeft
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const adjustPositionAfterClose = () => {
|
||||||
|
const positions = calculateScrollPosition()
|
||||||
|
if (!positions) return
|
||||||
|
|
||||||
|
const { scrollWidth, ulWidth, offsetLeft, clientWidth } = positions
|
||||||
|
const curTabLeft = offsetLeft + clientWidth
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
scrollState.value.translateX = curTabLeft > scrollWidth ? scrollWidth - ulWidth : 0
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加标签
|
|
||||||
const addTags = () => {
|
const addTags = () => {
|
||||||
const { name } = route
|
const { name } = route
|
||||||
if (name && !route.meta?.noCache) {
|
if (name) {
|
||||||
layoutStore.updateViewTags({
|
layoutStore.updateViewTags({
|
||||||
fullPath: route.fullPath,
|
|
||||||
path: route.path,
|
path: route.path,
|
||||||
|
fullPath: route.fullPath,
|
||||||
name: name,
|
name: name,
|
||||||
|
title: route.meta.title,
|
||||||
|
meta: route.meta,
|
||||||
query: route.query,
|
query: route.query,
|
||||||
params: route.params,
|
|
||||||
meta: route.meta
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除标签
|
|
||||||
const closeSelectedTag = (view) => {
|
const closeSelectedTag = (view) => {
|
||||||
// 如果是固定标签,不允许关闭
|
|
||||||
if (view.meta?.affix) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
layoutStore.removeViewTags(view.fullPath)
|
layoutStore.removeViewTags(view.fullPath)
|
||||||
|
setTransition()
|
||||||
// 如果关闭的是当前激活的标签,需要跳转
|
nextTick(() => {
|
||||||
|
adjustPositionAfterClose()
|
||||||
|
})
|
||||||
if (isActive(view)) {
|
if (isActive(view)) {
|
||||||
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
|
toLastView(visitedViews.value, view)
|
||||||
if (nextTag) {
|
|
||||||
router.push(nextTag.fullPath)
|
|
||||||
} else {
|
|
||||||
// 如果没有其他标签,跳转到首页
|
|
||||||
router.push(config.DASHBOARD_URL)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭其他标签
|
|
||||||
const closeOthersTags = () => {
|
const closeOthersTags = () => {
|
||||||
if (!selectedTag.value || !selectedTag.value.fullPath) {
|
router.push(selectedTag.value)
|
||||||
return
|
layoutStore.viewTags = visitedViews.value.filter((tag) => {
|
||||||
}
|
return tag.meta && 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
|
|
||||||
|
|
||||||
// 如果当前不在选中的标签页,跳转到选中的标签
|
|
||||||
if (!isActive(selectedTag.value)) {
|
|
||||||
router.push(selectedTag.value.fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭所有标签
|
|
||||||
const closeAllTags = () => {
|
|
||||||
// 只保留固定标签
|
|
||||||
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
|
|
||||||
layoutStore.viewTags = affixTags
|
|
||||||
|
|
||||||
// 如果还有固定标签,跳转到第一个固定标签
|
|
||||||
if (affixTags.length > 0) {
|
|
||||||
router.push(affixTags[0].fullPath)
|
|
||||||
} else {
|
|
||||||
// 如果没有固定标签,跳转到首页
|
|
||||||
router.push(config.DASHBOARD_URL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭左侧标签
|
|
||||||
const closeLeftTags = () => {
|
|
||||||
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 tagsToKeep = visitedViews.value.filter((tag, index) => {
|
|
||||||
return tag.meta?.affix || index >= currentIndex
|
|
||||||
})
|
})
|
||||||
|
setTransition()
|
||||||
layoutStore.viewTags = tagsToKeep
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭右侧标签
|
const closeAllTags = (view) => {
|
||||||
const closeRightTags = () => {
|
layoutStore.viewTags = visitedViews.value.filter((tag) => tag.meta && tag.meta.affix)
|
||||||
const currentTag = selectedTag.value || visitedViews.value.find((tag) => isActive(tag))
|
setTransition()
|
||||||
if (!currentTag) return
|
if (view && view.fullPath) {
|
||||||
|
toLastView(visitedViews.value, view)
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
layoutStore.viewTags = tagsToKeep
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击标签
|
|
||||||
const clickTag = (tag) => {
|
|
||||||
if (!isActive(tag)) {
|
|
||||||
router.push(tag.fullPath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新指定标签
|
|
||||||
const refreshTag = (tag) => {
|
|
||||||
// 如果刷新的是当前激活的标签
|
|
||||||
if (isActive(tag)) {
|
|
||||||
// 调用 store 的刷新方法,触发组件重新渲染
|
|
||||||
layoutStore.refreshTag()
|
|
||||||
} else {
|
} else {
|
||||||
// 如果刷新的是其他标签,先跳转到该标签
|
toLastView(layoutStore.viewTags)
|
||||||
router.push(tag.fullPath)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新当前选中的标签(用于顶部操作按钮)
|
const toLastView = (visitedViews, view) => {
|
||||||
const refreshSelectedTag = () => {
|
const latestView = visitedViews.slice(-1)[0]
|
||||||
// 找到当前激活的标签
|
if (latestView) {
|
||||||
const currentTag = visitedViews.value.find((tag) => isActive(tag))
|
router.push(latestView.fullPath)
|
||||||
if (currentTag) {
|
} else {
|
||||||
refreshTag(currentTag)
|
if (view.name === 'Dashboard') {
|
||||||
|
router.replace({ path: '/redirect' + view.fullPath })
|
||||||
|
} else {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右键菜单处理
|
const refreshSelectedTag = (view) => {
|
||||||
const handleContextMenu = (event, tag) => {
|
layoutStore.removeViewTags(view.fullPath)
|
||||||
event.preventDefault()
|
const { fullPath } = view
|
||||||
event.stopPropagation()
|
layoutStore.updateViewTags({
|
||||||
|
path: view.path,
|
||||||
|
fullPath: view.fullPath,
|
||||||
|
name: view.name,
|
||||||
|
title: view.meta.title,
|
||||||
|
meta: view.meta,
|
||||||
|
query: view.query,
|
||||||
|
})
|
||||||
|
router.replace({
|
||||||
|
path: '/redirect' + fullPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = (tag, e) => {
|
||||||
|
const scrollWrapper = scrollRef.value?.parentElement
|
||||||
|
if (!scrollWrapper) return
|
||||||
|
|
||||||
|
const menuMinWidth = 105
|
||||||
|
const offsetLeft = scrollWrapper.getBoundingClientRect().left || 0
|
||||||
|
const offsetWidth = scrollWrapper.offsetWidth || 0
|
||||||
|
const maxLeft = offsetWidth - menuMinWidth
|
||||||
|
left.value = e.clientX - offsetLeft + 15
|
||||||
|
|
||||||
|
if (left.value > maxLeft) {
|
||||||
|
left.value = maxLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
top.value = e.clientY
|
||||||
|
visible.value = true
|
||||||
selectedTag.value = tag
|
selectedTag.value = tag
|
||||||
contextMenu.value = {
|
|
||||||
visible: true,
|
|
||||||
x: event.clientX,
|
|
||||||
y: event.clientY
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭右键菜单
|
const closeMenu = () => {
|
||||||
const closeContextMenu = () => {
|
visible.value = false
|
||||||
contextMenu.value.visible = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单点击处理
|
const handleWheel = (e) => {
|
||||||
const handleMenuClick = ({ key }) => {
|
const scrollWrapper = scrollRef.value?.parentElement
|
||||||
switch (key) {
|
if (!scrollWrapper) return
|
||||||
case 'refresh':
|
|
||||||
if (selectedTag.value) {
|
const scrollWidth = scrollWrapper.offsetWidth
|
||||||
refreshTag(selectedTag.value)
|
const tagElements = scrollRef.value?.querySelectorAll('.tags-view-item')
|
||||||
}
|
if (!tagElements || tagElements.length === 0) return
|
||||||
break
|
|
||||||
case 'close':
|
const ulWidth = scrollRef.value.offsetWidth
|
||||||
if (selectedTag.value && !selectedTag.value.meta?.affix) {
|
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||||
closeSelectedTag(selectedTag.value)
|
const currentScroll = -scrollState.value.translateX
|
||||||
}
|
const newScroll = Math.max(0, Math.min(currentScroll + eventDelta, scrollWidth - ulWidth))
|
||||||
break
|
scrollState.value.translateX = -newScroll
|
||||||
case 'closeOthers':
|
closeMenu()
|
||||||
closeOthersTags()
|
|
||||||
break
|
|
||||||
case 'closeAll':
|
|
||||||
closeAllTags()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
closeContextMenu()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶部操作菜单点击处理
|
watch(route, () => {
|
||||||
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
|
|
||||||
}
|
|
||||||
actionMenuVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 点击其他地方关闭右键菜单
|
|
||||||
const handleClickOutside = (event) => {
|
|
||||||
if (contextMenu.value.visible) {
|
|
||||||
const menuElement = document.querySelector('.context-menu')
|
|
||||||
if (menuElement && !menuElement.contains(event.target)) {
|
|
||||||
closeContextMenu()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听路由变化,自动添加标签
|
|
||||||
watch(
|
|
||||||
() => route.fullPath,
|
|
||||||
() => {
|
|
||||||
addTags()
|
addTags()
|
||||||
// 更新当前选中的标签
|
nextTick(() => {
|
||||||
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
|
setTransition()
|
||||||
},
|
autoPositionTab()
|
||||||
{ immediate: true }
|
})
|
||||||
)
|
})
|
||||||
|
|
||||||
|
watch(visible, (value) => {
|
||||||
|
if (value) {
|
||||||
|
document.body.addEventListener('click', closeMenu)
|
||||||
|
} else {
|
||||||
|
document.body.removeEventListener('click', closeMenu)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
addTags()
|
addTags()
|
||||||
// 初始化选中的标签
|
nextTick(() => {
|
||||||
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || null
|
setTransition()
|
||||||
// 添加点击事件监听器
|
autoPositionTab()
|
||||||
document.addEventListener('click', handleClickOutside)
|
})
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
// 移除点击事件监听器
|
|
||||||
document.removeEventListener('click', handleClickOutside)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.tags-view {
|
.tags-view-container {
|
||||||
display: flex;
|
height: 34px;
|
||||||
align-items: center;
|
width: 100%;
|
||||||
justify-content: space-between;
|
background: #fff;
|
||||||
height: 40px;
|
border-bottom: 1px solid #d8dce5;
|
||||||
background-color: #ffffff;
|
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
|
||||||
border-bottom: 1px solid #f0f0f0;
|
position: relative;
|
||||||
padding: 0 16px;
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
|
||||||
|
|
||||||
.tags-wrapper {
|
.tags-view-wrapper {
|
||||||
flex: 1;
|
height: 100%;
|
||||||
overflow-x: auto;
|
width: 100%;
|
||||||
overflow-y: hidden;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
.scroll-content {
|
||||||
height: 0;
|
display: inline-block;
|
||||||
}
|
white-space: nowrap;
|
||||||
|
will-change: transform;
|
||||||
&::-webkit-scrollbar-thumb {
|
height: 100%;
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tag-item {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
height: 28px;
|
|
||||||
line-height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
margin: 0 4px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
background-color: #fafafa;
|
|
||||||
border-radius: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: #1890ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
color: #ffffff;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #40a9ff;
|
|
||||||
border-color: #40a9ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.tag-affix {
|
|
||||||
background-color: #fff7e6;
|
|
||||||
border-color: #ffd591;
|
|
||||||
color: #fa8c16;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background-color: #fa8c16;
|
|
||||||
border-color: #fa8c16;
|
|
||||||
color: #ffffff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #ffe7ba;
|
|
||||||
border-color: #ffa940;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active:hover {
|
|
||||||
background-color: #d46b08;
|
|
||||||
border-color: #d46b08;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-tag-close-icon) {
|
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tags-actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
|
||||||
margin-left: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
:deep(.ant-btn) {
|
.tags-view-item {
|
||||||
font-size: 14px;
|
display: inline-flex;
|
||||||
padding: 2px 6px;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
&:hover {
|
cursor: pointer;
|
||||||
color: #1890ff;
|
height: 26px;
|
||||||
}
|
line-height: 26px;
|
||||||
}
|
border: 1px solid #d8dce5;
|
||||||
}
|
color: #495060;
|
||||||
}
|
background: #fff;
|
||||||
|
padding: 0 8px;
|
||||||
.context-menu {
|
font-size: 12px;
|
||||||
background: #ffffff;
|
margin-left: 5px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
transition: all 0.3s;
|
||||||
0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
text-decoration: none;
|
||||||
border: 1px solid #f0f0f0;
|
user-select: none;
|
||||||
padding: 4px 0;
|
|
||||||
min-width: 160px;
|
|
||||||
|
|
||||||
:deep(.ant-menu) {
|
&:first-of-type {
|
||||||
background: transparent;
|
margin-left: 5px;
|
||||||
border: none;
|
}
|
||||||
box-shadow: none;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.ant-menu-item {
|
|
||||||
margin: 0;
|
|
||||||
padding: 8px 12px;
|
|
||||||
height: auto;
|
|
||||||
line-height: normal;
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #f5f5f5;
|
color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
border-color: #b3d8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: #409eff;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #409eff;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
background: #fff;
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
||||||
|
transform-origin: 100% 50%;
|
||||||
|
vertical-align: middle;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.contextmenu {
|
||||||
|
margin: 0;
|
||||||
|
background: #fff;
|
||||||
|
z-index: 3000;
|
||||||
|
position: absolute;
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||||
|
min-width: 100px;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #ecf5ff;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-icon {
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,448 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-number pending">{{ pendingTasks }}</div>
|
|
||||||
<div class="stat-label">{{ $t('common.pendingTasks') }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-item">
|
|
||||||
<div class="stat-number completed">{{ completedTasks }}</div>
|
|
||||||
<div class="stat-label">{{ $t('common.completedTasks') }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作栏 -->
|
|
||||||
<div class="task-actions">
|
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
|
||||||
<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 class="task-checkbox">
|
|
||||||
<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>
|
|
||||||
<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)"
|
|
||||||
>
|
|
||||||
<DeleteOutlined class="action-icon" />
|
|
||||||
</a-popconfirm>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a-empty v-else :description="$t('common.noTasks')" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部操作 -->
|
|
||||||
<div class="drawer-footer">
|
|
||||||
<a-button @click="showAddTask">
|
|
||||||
<PlusOutlined />
|
|
||||||
{{ $t('common.addTask') }}
|
|
||||||
</a-button>
|
|
||||||
<a-button danger @click="clearAllTasks">
|
|
||||||
{{ $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-form layout="vertical">
|
|
||||||
<a-form-item :label="$t('common.taskTitle')">
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</a-drawer>
|
|
||||||
</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'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'TaskDrawer',
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const visible = defineModel('visible', { type: Boolean, default: false })
|
|
||||||
|
|
||||||
const tasks = defineModel('tasks', { type: Array, default: () => [] })
|
|
||||||
|
|
||||||
// 搜索关键词
|
|
||||||
const searchKeyword = ref('')
|
|
||||||
// 筛选类型:all, pending, completed
|
|
||||||
const filterType = ref('all')
|
|
||||||
|
|
||||||
// 添加任务弹窗
|
|
||||||
const addTaskVisible = ref(false)
|
|
||||||
const newTask = ref({
|
|
||||||
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 filteredTasks = computed(() => {
|
|
||||||
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 (searchKeyword.value.trim()) {
|
|
||||||
const keyword = searchKeyword.value.toLowerCase()
|
|
||||||
result = result.filter(t =>
|
|
||||||
t.title.toLowerCase().includes(keyword)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
})
|
|
||||||
|
|
||||||
// 切换任务状态
|
|
||||||
const toggleTask = (task) => {
|
|
||||||
task.completed = !task.completed
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除任务
|
|
||||||
const deleteTask = (id) => {
|
|
||||||
const index = tasks.value.findIndex(t => t.id === id)
|
|
||||||
if (index > -1) {
|
|
||||||
tasks.value.splice(index, 1)
|
|
||||||
message.success(t('common.deleted'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空所有任务
|
|
||||||
const clearAllTasks = () => {
|
|
||||||
tasks.value = []
|
|
||||||
message.success(t('common.cleared'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示添加任务弹窗
|
|
||||||
const showAddTask = () => {
|
|
||||||
newTask.value = {
|
|
||||||
title: '',
|
|
||||||
priority: 'medium',
|
|
||||||
}
|
|
||||||
addTaskVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认添加任务
|
|
||||||
const confirmAddTask = () => {
|
|
||||||
if (!newTask.value.title.trim()) {
|
|
||||||
message.warning(t('common.pleaseEnterTaskTitle'))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.value.unshift({
|
|
||||||
id: Date.now(),
|
|
||||||
title: newTask.value.title,
|
|
||||||
priority: newTask.value.priority,
|
|
||||||
completed: false,
|
|
||||||
time: t('common.justNow'),
|
|
||||||
})
|
|
||||||
|
|
||||||
addTaskVisible.value = false
|
|
||||||
message.success(t('common.added'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置筛选类型
|
|
||||||
const setFilter = (type) => {
|
|
||||||
filterType.value = type
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索处理
|
|
||||||
const handleSearch = () => {
|
|
||||||
// 搜索逻辑在 computed 中自动处理
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听抽窗关闭,重置搜索和筛选
|
|
||||||
watch(visible, (newVal) => {
|
|
||||||
if (!newVal) {
|
|
||||||
searchKeyword.value = ''
|
|
||||||
filterType.value = 'all'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.task-drawer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.task-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
padding: 16px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
.stat-item {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.stat-number {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #fff;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
|
|
||||||
&.pending {
|
|
||||||
color: #ffd666;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.completed {
|
|
||||||
color: #95de64;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-actions {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
:deep(.ant-input-affix-wrapper) {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-right: 8px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: #c1c1c1;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #a8a8a8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background-color: #f9f9f9;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: all 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.completed {
|
|
||||||
.task-title {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-content {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-checkbox {
|
|
||||||
margin-right: 12px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-content {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
|
|
||||||
.task-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-weight: 500;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-meta {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
.task-priority {
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-weight: 500;
|
|
||||||
|
|
||||||
&.high {
|
|
||||||
background-color: #fff1f0;
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.medium {
|
|
||||||
background-color: #fff7e6;
|
|
||||||
color: #fa8c16;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.low {
|
|
||||||
background-color: #f6ffed;
|
|
||||||
color: #52c41a;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-time {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-actions {
|
|
||||||
margin-left: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
|
|
||||||
.action-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #999;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #ff4d4f;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-footer {
|
|
||||||
padding-top: 16px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.ant-btn {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+124
-317
@@ -1,163 +1,126 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="userbar">
|
<div class="navbar-right">
|
||||||
<!-- 菜单搜索 -->
|
<div class="right-item">
|
||||||
<a-tooltip :title="$t('common.search')">
|
<el-tooltip content="全屏" placement="bottom">
|
||||||
<a-button type="text" @click="showSearch" class="action-btn">
|
<el-icon class="icon-btn" @click="toggleFullScreen">
|
||||||
<SearchOutlined />
|
<component :is="isFullscreen ? 'ElIconFullScreen' : 'ElIconAim'" />
|
||||||
</a-button>
|
</el-icon>
|
||||||
</a-tooltip>
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 消息通知 -->
|
<div class="right-item">
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
<el-tooltip content="刷新" placement="bottom">
|
||||||
<a-badge :count="messageCount" :offset="[-5, 5]">
|
<el-icon class="icon-btn" @click="refreshPage">
|
||||||
<a-button type="text" class="action-btn">
|
<component :is="'ElIconRefresh'" />
|
||||||
<BellOutlined />
|
</el-icon>
|
||||||
</a-button>
|
</el-tooltip>
|
||||||
</a-badge>
|
</div>
|
||||||
<template #overlay>
|
|
||||||
<a-card class="dropdown-card" :title="$t('common.messages')" :bordered="false">
|
<div class="right-item">
|
||||||
<template #extra>
|
<el-dropdown trigger="click" @command="handleCommand">
|
||||||
<a @click="clearMessages">{{ $t('common.clearAll') }}</a>
|
<span class="el-dropdown-link">
|
||||||
|
<el-avatar :size="32" :src="userInfo.avatar || ''">
|
||||||
|
<el-icon><component :is="'ElIconUser'" /></el-icon>
|
||||||
|
</el-avatar>
|
||||||
|
<span class="username">{{ userInfo.username || '用户' }}</span>
|
||||||
|
<el-icon class="el-icon--right"><component :is="'ElIconArrowDown'" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="profile">
|
||||||
|
<el-icon><component :is="'ElIconUser'" /></el-icon>
|
||||||
|
个人中心
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="settings">
|
||||||
|
<el-icon><component :is="'ElIconSetting'" /></el-icon>
|
||||||
|
设置
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided command="logout">
|
||||||
|
<el-icon><component :is="'ElIconSwitchButton'" /></el-icon>
|
||||||
|
退出登录
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
<div class="message-list">
|
</el-dropdown>
|
||||||
<div v-for="msg in messages" :key="msg.id" class="message-item" :class="{ unread: !msg.read }">
|
|
||||||
<div class="message-content">
|
|
||||||
<div class="message-title">{{ msg.title }}</div>
|
|
||||||
<div class="message-time">{{ msg.time }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<a-badge v-if="!msg.read" dot />
|
|
||||||
</div>
|
</div>
|
||||||
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
|
|
||||||
<!-- 任务列表 -->
|
|
||||||
<a-tooltip :title="$t('common.taskCenter')">
|
|
||||||
<a-badge :count="taskCount" :offset="[-5, 5]">
|
|
||||||
<a-button type="text" @click="taskVisible = true" class="action-btn">
|
|
||||||
<CheckSquareOutlined />
|
|
||||||
</a-button>
|
|
||||||
</a-badge>
|
|
||||||
</a-tooltip>
|
|
||||||
|
|
||||||
<!-- 语言切换 -->
|
|
||||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
|
||||||
<a-button type="text" class="action-btn">
|
|
||||||
<GlobalOutlined />
|
|
||||||
</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">
|
|
||||||
<span>{{ locale.label }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
|
|
||||||
<!-- 全屏 -->
|
|
||||||
<a-tooltip :title="$t('common.fullscreen')">
|
|
||||||
<a-button type="text" @click="toggleFullscreen" class="action-btn">
|
|
||||||
<FullscreenOutlined v-if="!isFullscreen" />
|
|
||||||
<FullscreenExitOutlined v-else />
|
|
||||||
</a-button>
|
|
||||||
</a-tooltip>
|
|
||||||
|
|
||||||
<!-- 用户信息 -->
|
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<div class="user-info">
|
|
||||||
<a-avatar :size="32" :src="userStore.user?.avatar || ''">
|
|
||||||
{{ userStore.user?.username?.charAt(0)?.toUpperCase() || 'U' }}
|
|
||||||
</a-avatar>
|
|
||||||
<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>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="settings">
|
|
||||||
<SettingOutlined />
|
|
||||||
<span>{{ $t('common.systemSettings') }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="clearCache">
|
|
||||||
<DeleteOutlined />
|
|
||||||
<span>{{ $t('common.clearCache') }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-divider />
|
|
||||||
<a-menu-item key="logout">
|
|
||||||
<LogoutOutlined />
|
|
||||||
<span>{{ $t('common.logout') }}</span>
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 菜单搜索弹窗 -->
|
|
||||||
<search v-model:visible="searchVisible" />
|
|
||||||
|
|
||||||
<!-- 任务抽屉 -->
|
|
||||||
<task v-model:visible="taskVisible" v-model:tasks="tasks" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { useUserStore } from '../../stores/modules/user'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
import { useLayoutStore } from '../../stores/modules/layout'
|
||||||
import { useI18nStore } from '@/stores/modules/i18n'
|
import { ElMessage } from 'element-plus'
|
||||||
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({
|
defineOptions({
|
||||||
name: 'UserBar',
|
name: 'UserBar',
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const i18nStore = useI18nStore()
|
const layoutStore = useLayoutStore()
|
||||||
|
|
||||||
const isFullscreen = ref(false)
|
const isFullscreen = ref(false)
|
||||||
const searchVisible = ref(false)
|
const userInfo = ref({
|
||||||
const taskVisible = ref(false)
|
username: '',
|
||||||
|
avatar: '',
|
||||||
// 消息数据
|
})
|
||||||
const messages = ref([
|
|
||||||
{ id: 1, title: '系统通知:新版本已发布', time: '10分钟前', read: false },
|
|
||||||
{ id: 2, title: '任务提醒:请完成待审核的用户', time: '30分钟前', read: false },
|
|
||||||
{ id: 3, title: '安全警告:检测到异常登录', time: '1小时前', read: true },
|
|
||||||
{ id: 4, title: '数据备份已完成', time: '2小时前', read: true },
|
|
||||||
])
|
|
||||||
|
|
||||||
const messageCount = computed(() => messages.value.filter((m) => !m.read).length)
|
|
||||||
|
|
||||||
// 任务数据
|
|
||||||
const tasks = ref([
|
|
||||||
{ id: 1, title: '完成用户审核', priority: 'high', completed: false, time: '今天' },
|
|
||||||
{ id: 2, title: '更新系统文档', priority: 'medium', completed: false, time: '明天' },
|
|
||||||
{ id: 3, title: '优化数据库查询', priority: 'low', completed: true, time: '昨天' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const taskCount = computed(() => tasks.value.filter((t) => !t.completed).length)
|
|
||||||
|
|
||||||
// 切换全屏
|
// 切换全屏
|
||||||
const toggleFullscreen = () => {
|
const toggleFullScreen = () => {
|
||||||
if (!document.fullscreenElement) {
|
if (!document.fullscreenElement) {
|
||||||
document.documentElement.requestFullscreen()
|
document.documentElement.requestFullscreen()
|
||||||
isFullscreen.value = true
|
isFullscreen.value = true
|
||||||
} else {
|
} else {
|
||||||
|
if (document.exitFullscreen) {
|
||||||
document.exitFullscreen()
|
document.exitFullscreen()
|
||||||
isFullscreen.value = false
|
isFullscreen.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新页面
|
||||||
|
const refreshPage = () => {
|
||||||
|
layoutStore.refreshTag()
|
||||||
|
router.replace({
|
||||||
|
path: '/redirect' + router.currentRoute.value.fullPath
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理下拉菜单命令
|
||||||
|
const handleCommand = (command) => {
|
||||||
|
switch (command) {
|
||||||
|
case 'profile':
|
||||||
|
router.push('/ucenter/profile')
|
||||||
|
break
|
||||||
|
case 'settings':
|
||||||
|
router.push('/ucenter/settings')
|
||||||
|
break
|
||||||
|
case 'logout':
|
||||||
|
handleLogout()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
const handleLogout = () => {
|
||||||
|
userStore.logout()
|
||||||
|
ElMessage.success('退出登录成功')
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息
|
||||||
|
const getUserInfo = () => {
|
||||||
|
const info = userStore.userInfo
|
||||||
|
if (info) {
|
||||||
|
userInfo.value = {
|
||||||
|
username: info.username || info.nickName || '用户',
|
||||||
|
avatar: info.avatar || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听全屏变化
|
// 监听全屏变化
|
||||||
@@ -166,213 +129,57 @@ const handleFullscreenChange = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
getUserInfo()
|
||||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 显示搜索功能
|
|
||||||
const showSearch = () => {
|
|
||||||
searchVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除消息
|
|
||||||
const clearMessages = () => {
|
|
||||||
messages.value = []
|
|
||||||
message.success(t('common.cleared'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示任务抽屉
|
|
||||||
const showTasks = () => {
|
|
||||||
taskVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换语言
|
|
||||||
const handleLanguageChange = ({ key }) => {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清除缓存
|
|
||||||
const handleClearCache = () => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: t('common.confirmClearCache'),
|
|
||||||
content: t('common.clearCacheConfirm'),
|
|
||||||
okText: t('common.confirm'),
|
|
||||||
cancelText: t('common.cancel'),
|
|
||||||
onOk: () => {
|
|
||||||
try {
|
|
||||||
// 清除 localStorage
|
|
||||||
localStorage.clear()
|
|
||||||
// 清除 sessionStorage
|
|
||||||
sessionStorage.clear()
|
|
||||||
// 清除所有缓存
|
|
||||||
if ('caches' in window) {
|
|
||||||
caches.keys().then(names => {
|
|
||||||
names.forEach(name => {
|
|
||||||
caches.delete(name)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
message.success(t('common.cacheCleared'))
|
|
||||||
// 延迟刷新页面以应用缓存清除
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload()
|
|
||||||
}, 1000)
|
|
||||||
} catch (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'),
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
await userStore.logout()
|
|
||||||
message.success(t('common.logoutSuccess'))
|
|
||||||
router.push('/login')
|
|
||||||
} catch {
|
|
||||||
message.error(t('common.logoutFailed'))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style lang="scss" scoped>
|
||||||
.userbar {
|
.navbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
height: 100%;
|
||||||
|
|
||||||
.search-input {
|
.right-item {
|
||||||
width: 240px;
|
|
||||||
margin-right: 8px;
|
|
||||||
|
|
||||||
:deep(.ant-input) {
|
|
||||||
background-color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
padding: 0 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 4px;
|
.icon-btn {
|
||||||
transition: background-color 0.3s;
|
font-size: 20px;
|
||||||
|
color: #5a5e66;
|
||||||
|
vertical-align: middle;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.04);
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-dropdown-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #5a5e66;
|
||||||
|
transition: color 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.username {
|
.username {
|
||||||
font-size: 14px;
|
margin: 0 8px;
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn {
|
|
||||||
font-size: 16px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
|
|
||||||
.lang-text {
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
.el-icon--right {
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-card {
|
|
||||||
width: 320px;
|
|
||||||
max-height: 400px;
|
|
||||||
overflow: auto;
|
|
||||||
|
|
||||||
:deep(.ant-card-head) {
|
|
||||||
padding: 12px 16px;
|
|
||||||
min-height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-card-body) {
|
|
||||||
padding: 12px 16px;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-list,
|
|
||||||
.task-list {
|
|
||||||
|
|
||||||
.message-item,
|
|
||||||
.task-item {
|
|
||||||
padding: 10px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.unread {
|
|
||||||
background-color: rgba(24, 144, 255, 0.04);
|
|
||||||
padding: 10px;
|
|
||||||
margin: 0 -10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-content {
|
|
||||||
.message-title {
|
|
||||||
font-size: 14px;
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-time {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-item {
|
|
||||||
.completed {
|
|
||||||
text-decoration: line-through;
|
|
||||||
color: #999;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+355
-537
File diff suppressed because it is too large
Load Diff
+156
-329
@@ -1,401 +1,228 @@
|
|||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const goHome = () => {
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
router.back()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="not-found">
|
<div class="not-found-container">
|
||||||
<div class="content-wrapper">
|
<div class="tech-decoration">
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="not-found-content">
|
||||||
<div class="error-code">404</div>
|
<div class="error-code">404</div>
|
||||||
<div class="error-text">
|
<div class="error-title">页面未找到</div>
|
||||||
<h1>页面未找到</h1>
|
<div class="error-description">抱歉,您访问的页面不存在或已被移除</div>
|
||||||
<p>抱歉,您访问的页面不存在或已被移除</p>
|
|
||||||
</div>
|
|
||||||
<div class="error-illustration">
|
|
||||||
<div class="planet"></div>
|
|
||||||
<div class="star star-1"></div>
|
|
||||||
<div class="star star-2"></div>
|
|
||||||
<div class="star star-3"></div>
|
|
||||||
<div class="rocket">
|
|
||||||
<div class="rocket-body"></div>
|
|
||||||
<div class="rocket-window"></div>
|
|
||||||
<div class="rocket-flame"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button class="btn btn-primary" @click="goHome">
|
<el-button type="primary" size="large" @click="goBack">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<el-icon>
|
||||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
<ArrowLeft />
|
||||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
</el-icon>
|
||||||
</svg>
|
|
||||||
返回首页
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-secondary" @click="goBack">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
||||||
<path d="M19 12H5M12 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
返回上一页
|
返回上一页
|
||||||
</button>
|
</el-button>
|
||||||
|
<el-button size="large" @click="goHome">
|
||||||
|
<el-icon>
|
||||||
|
<HomeFilled />
|
||||||
|
</el-icon>
|
||||||
|
返回首页
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup>
|
||||||
.not-found {
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, HomeFilled } from '@element-plus/icons-vue'
|
||||||
|
import '@/assets/style/auth.scss'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Go back to previous page
|
||||||
|
const goBack = () => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go to home page
|
||||||
|
const goHome = () => {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.not-found-container {
|
||||||
|
width: 100%;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #0c1929 0%, #1a237e 50%, #0d47a1 100%);
|
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||||
padding: 20px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
/* 星空背景 */
|
.tech-decoration {
|
||||||
.not-found::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
pointer-events: none;
|
||||||
bottom: 0;
|
overflow: hidden;
|
||||||
background-image:
|
|
||||||
radial-gradient(2px 2px at 20px 30px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(2px 2px at 40px 70px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(1px 1px at 90px 40px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(2px 2px at 160px 120px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(1px 1px at 230px 80px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(2px 2px at 300px 150px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(1px 1px at 370px 200px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(2px 2px at 450px 50px, #ffffff, rgba(0, 0, 0, 0)),
|
|
||||||
radial-gradient(1px 1px at 520px 180px, #ffffff, rgba(0, 0, 0, 0));
|
|
||||||
background-size: 550px 250px;
|
|
||||||
animation: stars 50s linear infinite;
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes stars {
|
.tech-circle {
|
||||||
0% {
|
position: absolute;
|
||||||
background-position: 0 0;
|
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
.tech-circle:nth-child(1) {
|
||||||
background-position: 550px 250px;
|
width: 300px;
|
||||||
}
|
height: 300px;
|
||||||
}
|
top: -150px;
|
||||||
|
left: -150px;
|
||||||
.content-wrapper {
|
animation-delay: 0s;
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
max-width: 600px;
|
|
||||||
animation: fadeIn 0.8s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(30px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
.tech-circle:nth-child(2) {
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-code {
|
|
||||||
font-size: 180px;
|
|
||||||
font-weight: 900;
|
|
||||||
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
position: relative;
|
|
||||||
animation: float 3s ease-in-out infinite;
|
|
||||||
text-shadow: 0 0 60px rgba(0, 212, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text {
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text h1 {
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: white;
|
|
||||||
margin: 0 0 12px;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-text p {
|
|
||||||
font-size: 16px;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-illustration {
|
|
||||||
position: relative;
|
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
margin: 0 auto 40px;
|
bottom: -100px;
|
||||||
}
|
right: -100px;
|
||||||
|
|
||||||
.planet {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
box-shadow:
|
|
||||||
0 0 60px rgba(102, 126, 234, 0.6),
|
|
||||||
inset -10px -10px 20px rgba(0, 0, 0, 0.2);
|
|
||||||
animation: rotatePlanet 20s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.planet::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 160px;
|
|
||||||
height: 20px;
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
border-radius: 50%;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%) rotateX(75deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.planet::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(0, 0, 0, 0.2);
|
|
||||||
top: 20%;
|
|
||||||
left: 30%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rotatePlanet {
|
|
||||||
from {
|
|
||||||
transform: translate(-50%, -50%) rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: translate(-50%, -50%) rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.star {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: twinkle 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-1 {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
top: 20%;
|
|
||||||
right: 10%;
|
|
||||||
animation-delay: 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-2 {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
top: 60%;
|
|
||||||
left: 5%;
|
|
||||||
animation-delay: 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-3 {
|
|
||||||
width: 5px;
|
|
||||||
height: 5px;
|
|
||||||
bottom: 10%;
|
|
||||||
right: 20%;
|
|
||||||
animation-delay: 1s;
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(3) {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
bottom: 20%;
|
||||||
|
left: -75px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes twinkle {
|
@keyframes pulse {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 0.3;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
opacity: 0.3;
|
opacity: 0.6;
|
||||||
transform: scale(0.8);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.rocket {
|
.not-found-content {
|
||||||
position: absolute;
|
text-align: center;
|
||||||
top: 30%;
|
padding: 40px;
|
||||||
right: 0;
|
|
||||||
animation: flyRocket 2s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rocket-body {
|
|
||||||
width: 20px;
|
|
||||||
height: 40px;
|
|
||||||
background: linear-gradient(135deg, #ff6b6b 0%, #feca57 100%);
|
|
||||||
border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
z-index: 1;
|
||||||
|
|
||||||
.rocket-window {
|
.error-code {
|
||||||
width: 10px;
|
font-size: 120px;
|
||||||
height: 10px;
|
font-weight: 700;
|
||||||
background: white;
|
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
|
||||||
border-radius: 50%;
|
-webkit-background-clip: text;
|
||||||
position: absolute;
|
-webkit-text-fill-color: transparent;
|
||||||
top: 12px;
|
background-clip: text;
|
||||||
left: 50%;
|
margin-bottom: 20px;
|
||||||
transform: translateX(-50%);
|
line-height: 1;
|
||||||
box-shadow: inset -2px -2px 4px rgba(0, 0, 0, 0.2);
|
animation: float 3s ease-in-out infinite;
|
||||||
}
|
|
||||||
|
|
||||||
.rocket-flame {
|
|
||||||
width: 12px;
|
|
||||||
height: 20px;
|
|
||||||
background: linear-gradient(to bottom, #feca57, #ff6b6b, transparent);
|
|
||||||
position: absolute;
|
|
||||||
bottom: -18px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
border-radius: 50% 50% 20% 20%;
|
|
||||||
animation: flame 0.3s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flame {
|
|
||||||
0% {
|
|
||||||
height: 20px;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
@keyframes float {
|
||||||
height: 30px;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes flyRocket {
|
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0) rotate(-15deg);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: translateY(-15px) rotate(-10deg);
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
.error-title {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-description {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 40px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
.el-button {
|
||||||
display: flex;
|
--el-button-border-radius: 12px;
|
||||||
align-items: center;
|
height: 48px;
|
||||||
gap: 8px;
|
padding: 0 32px;
|
||||||
padding: 14px 28px;
|
font-size: 16px;
|
||||||
border: none;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
|
||||||
|
&.el-button--primary {
|
||||||
|
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;
|
transition: all 0.3s ease;
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
|
||||||
.btn svg {
|
transform: translateY(-2px);
|
||||||
flex-shrink: 0;
|
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
|
&:not(.el-button--primary) {
|
||||||
color: white;
|
background: rgba(255, 255, 255, 0.9);
|
||||||
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.4);
|
border: 2px solid var(--border-color);
|
||||||
}
|
color: var(--text-secondary);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
.btn-primary:hover {
|
transition: all 0.3s ease;
|
||||||
transform: translateY(-3px);
|
|
||||||
box-shadow: 0 6px 25px rgba(0, 212, 255, 0.6);
|
&:hover {
|
||||||
}
|
border-color: var(--auth-primary);
|
||||||
|
color: var(--auth-primary);
|
||||||
.btn-secondary {
|
transform: translateY(-2px);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.15);
|
||||||
color: white;
|
}
|
||||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
}
|
||||||
backdrop-filter: blur(10px);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-color: rgba(255, 255, 255, 0.5);
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Responsive design
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.not-found-content {
|
||||||
|
padding: 20px;
|
||||||
|
|
||||||
.error-code {
|
.error-code {
|
||||||
font-size: 120px;
|
font-size: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text h1 {
|
.error-title {
|
||||||
font-size: 28px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-text p {
|
.error-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-illustration {
|
|
||||||
width: 150px;
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
flex-direction: column;
|
.el-button {
|
||||||
}
|
|
||||||
|
|
||||||
.btn {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
justify-content: center;
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+137
-218
@@ -1,170 +1,140 @@
|
|||||||
<script setup>
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '暂无数据'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '当前页面暂无相关数据,您可以稍后再来查看'
|
|
||||||
},
|
|
||||||
showAction: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
},
|
|
||||||
actionText: {
|
|
||||||
type: String,
|
|
||||||
default: '刷新页面'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleAction = () => {
|
|
||||||
router.go(0)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="empty-state">
|
<div class="empty-container">
|
||||||
|
<div class="tech-decoration">
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="empty-content">
|
<div class="empty-content">
|
||||||
<div class="empty-illustration">
|
<div class="empty-icon">
|
||||||
<div class="illustration-wrapper">
|
<el-icon :size="120" color="#ff6b35">
|
||||||
<svg class="empty-icon" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
<Box />
|
||||||
<defs>
|
</el-icon>
|
||||||
<linearGradient id="boxGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color: #667eea; stop-opacity: 0.2" />
|
|
||||||
<stop offset="100%" style="stop-color: #764ba2; stop-opacity: 0.2" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="searchGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color: #00d4ff; stop-opacity: 1" />
|
|
||||||
<stop offset="100%" style="stop-color: #7c4dff; stop-opacity: 1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- 背景圆 -->
|
|
||||||
<circle cx="100" cy="100" r="80" fill="url(#boxGradient)" />
|
|
||||||
|
|
||||||
<!-- 搜索放大镜 -->
|
|
||||||
<g transform="translate(70, 70)">
|
|
||||||
<circle cx="30" cy="30" r="25" stroke="url(#searchGradient)" stroke-width="4" fill="none" />
|
|
||||||
<line x1="50" y1="50" x2="70" y2="70" stroke="url(#searchGradient)" stroke-width="4"
|
|
||||||
stroke-linecap="round" />
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- 小装饰元素 -->
|
|
||||||
<circle cx="50" cy="60" r="4" fill="#667eea" opacity="0.6">
|
|
||||||
<animate attributeName="cy" values="60;55;60" dur="2s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
<circle cx="150" cy="80" r="3" fill="#764ba2" opacity="0.6">
|
|
||||||
<animate attributeName="cy" values="80;75;80" dur="1.5s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
<circle cx="60" cy="140" r="5" fill="#00d4ff" opacity="0.5">
|
|
||||||
<animate attributeName="cy" values="140;145;140" dur="2.5s" repeatCount="indefinite" />
|
|
||||||
</circle>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="empty-text">
|
<div class="empty-title">暂无数据</div>
|
||||||
<h3 class="empty-title">{{ title }}</h3>
|
<div class="empty-description">
|
||||||
<p class="empty-description">{{ description }}</p>
|
{{ description || '当前页面暂无数据,请稍后再试' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showAction" class="empty-action">
|
<el-button v-if="showButton" type="primary" size="large" @click="handleAction">
|
||||||
<button class="action-btn" @click="handleAction">
|
<el-icon v-if="buttonIcon">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<component :is="buttonIcon" />
|
||||||
<path d="M23 4v6h-6"></path>
|
</el-icon>
|
||||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
{{ buttonText || '刷新页面' }}
|
||||||
</svg>
|
</el-button>
|
||||||
{{ actionText }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<script setup>
|
||||||
.empty-state {
|
import { Box } from '@element-plus/icons-vue'
|
||||||
min-height: 400px;
|
|
||||||
|
defineProps({
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: '当前页面暂无数据,请稍后再试',
|
||||||
|
},
|
||||||
|
showButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
default: '刷新页面',
|
||||||
|
},
|
||||||
|
buttonIcon: {
|
||||||
|
type: [String, Object],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['action'])
|
||||||
|
|
||||||
|
const handleAction = () => {
|
||||||
|
emit('action')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.empty-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 60px 20px;
|
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
|
|
||||||
border-radius: 16px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
/* 装饰性背景 */
|
.tech-decoration {
|
||||||
.empty-state::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
pointer-events: none;
|
||||||
bottom: 0;
|
overflow: hidden;
|
||||||
background-image:
|
|
||||||
radial-gradient(2px 2px at 20px 30px, rgba(102, 126, 234, 0.3), transparent),
|
|
||||||
radial-gradient(2px 2px at 40px 70px, rgba(118, 75, 162, 0.3), transparent),
|
|
||||||
radial-gradient(1px 1px at 90px 40px, rgba(0, 212, 255, 0.3), transparent),
|
|
||||||
radial-gradient(2px 2px at 160px 120px, rgba(102, 126, 234, 0.3), transparent);
|
|
||||||
background-size: 200px 150px;
|
|
||||||
animation: floatBg 20s linear infinite;
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes floatBg {
|
.tech-circle {
|
||||||
0% {
|
position: absolute;
|
||||||
background-position: 0 0;
|
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 4s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(1) {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
top: -150px;
|
||||||
|
left: -150px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(2) {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
bottom: -100px;
|
||||||
|
right: -100px;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tech-circle:nth-child(3) {
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
bottom: 20%;
|
||||||
|
left: -75px;
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%,
|
||||||
100% {
|
100% {
|
||||||
background-position: 200px 150px;
|
opacity: 0.3;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.6;
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-content {
|
.empty-content {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
max-width: 480px;
|
|
||||||
animation: fadeIn 0.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
.empty-icon {
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-illustration {
|
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
position: relative;
|
animation: float 3s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.illustration-wrapper {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
width: 180px;
|
|
||||||
height: 180px;
|
|
||||||
animation: floatIcon 3s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes floatIcon {
|
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
@@ -173,116 +143,65 @@ const handleAction = () => {
|
|||||||
50% {
|
50% {
|
||||||
transform: translateY(-10px);
|
transform: translateY(-10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-title {
|
||||||
margin-bottom: 32px;
|
font-size: 28px;
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: var(--text-primary);
|
||||||
margin: 0 0 12px;
|
margin-bottom: 16px;
|
||||||
letter-spacing: 0.5px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.empty-description {
|
.empty-description {
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
color: #666;
|
color: var(--text-secondary);
|
||||||
margin: 0;
|
margin-bottom: 40px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
.empty-action {
|
.el-button {
|
||||||
display: flex;
|
--el-button-border-radius: 12px;
|
||||||
justify-content: center;
|
height: 48px;
|
||||||
}
|
padding: 0 40px;
|
||||||
|
font-size: 16px;
|
||||||
.action-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 32px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 10px;
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
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;
|
transition: all 0.3s ease;
|
||||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:hover {
|
&:hover {
|
||||||
|
background: linear-gradient(135deg, var(--auth-primary-light), var(--auth-primary));
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 12px 32px rgba(255, 107, 53, 0.45);
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn:active {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-btn svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 暗色主题适配 */
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.empty-title {
|
|
||||||
color: #fff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-description {
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 响应式适配 */
|
// Responsive design
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.empty-state {
|
.empty-content {
|
||||||
min-height: 300px;
|
padding: 20px;
|
||||||
padding: 40px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
.empty-icon {
|
||||||
width: 140px;
|
:deep(.el-icon) {
|
||||||
height: 140px;
|
font-size: 80px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-title {
|
.empty-title {
|
||||||
font-size: 20px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-description {
|
.empty-description {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn {
|
.el-button {
|
||||||
padding: 10px 24px;
|
width: 100%;
|
||||||
font-size: 14px;
|
max-width: 200px;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.empty-state {
|
|
||||||
padding: 30px 16px;
|
|
||||||
min-height: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-title {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-description {
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
|
|
||||||
import Antd from 'ant-design-vue'
|
import ElementPlus from 'element-plus'
|
||||||
import 'ant-design-vue/dist/reset.css'
|
import 'element-plus/dist/index.css'
|
||||||
import '@/assets/style/app.scss'
|
import '@/assets/style/app.scss'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
@@ -11,7 +11,7 @@ import boot from './boot'
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
app.use(Antd)
|
app.use(ElementPlus)
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
app.use(i18n)
|
app.use(i18n)
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages department-page">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.keyword" placeholder="请输入部门名称" allow-clear style="width: 200px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
<a-button danger :disabled="selectedRows.length === 0" @click="handleBatchDelete">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading" :pagination="false"
|
|
||||||
:row-key="rowKey" :row-selection="rowSelection" @refresh="refreshTable" @select="handleSelectChange"
|
|
||||||
@selectAll="handleSelectAll">
|
|
||||||
<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-popconfirm title="确定删除该部门吗?" @confirm="handleDelete(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 新增/编辑部门弹窗 -->
|
|
||||||
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import saveDialog from './save.vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
import { useTable } from '@/hooks/useTable'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'authDepartment'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用useTable hooks
|
|
||||||
const {
|
|
||||||
tableRef,
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
selectedRows,
|
|
||||||
rowSelection,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
handleSelectChange,
|
|
||||||
handleSelectAll,
|
|
||||||
refreshTable
|
|
||||||
} = useTable({
|
|
||||||
api: authApi.department.list.get,
|
|
||||||
searchForm: {
|
|
||||||
keyword: ''
|
|
||||||
},
|
|
||||||
columns: [],
|
|
||||||
needPagination: false,
|
|
||||||
needSelection: true,
|
|
||||||
immediateLoad: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
save: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const saveDialogRef = ref(null)
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: '#', dataIndex: '_index', key: '_index', width: 60, align: 'center' },
|
|
||||||
{ title: '部门名称', dataIndex: 'title', key: 'title', width: 300 },
|
|
||||||
{ title: '部门标识', dataIndex: 'name', key: 'name', width: 200 },
|
|
||||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 220, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
// 新增部门
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('add')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看部门
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('show').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑部门
|
|
||||||
const handleEdit = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除部门
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.department.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
refreshTable()
|
|
||||||
} 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: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
okType: 'danger',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const ids = selectedRows.value.map(item => item.id)
|
|
||||||
const res = await authApi.department.delete.post({ ids })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
selectedRows.value = []
|
|
||||||
refreshTable()
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除部门失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功回调
|
|
||||||
const handleSaveSuccess = () => {
|
|
||||||
refreshTable()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
refreshTable()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.department-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
<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-form-item label="上级部门" name="parent_id">
|
|
||||||
<a-tree-select v-model:value="form.parent_id" :tree-data="departments"
|
|
||||||
:field-names="departmentFieldNames" :tree-default-expand-all="false" placeholder="请选择上级部门"
|
|
||||||
allow-clear tree-node-filter-prop="title"
|
|
||||||
:dropdown-style="{ maxHeight: '400px', overflow: 'auto' }" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="部门名称" name="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="请输入部门名称" allow-clear></a-input>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="部门别名" name="name">
|
|
||||||
<a-input v-model:value="form.name" placeholder="请输入部门别名" allow-clear></a-input>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="排序" name="sort">
|
|
||||||
<a-input-number v-model:value="form.sort" :min="1" :step="1" style="width: 100%" placeholder="请输入排序" />
|
|
||||||
</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 @click="handleCancel">取 消</a-button>
|
|
||||||
</div>
|
|
||||||
</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'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'DepartmentSave'
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '新增部门',
|
|
||||||
edit: '编辑部门',
|
|
||||||
show: '查看部门'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
name: '',
|
|
||||||
sort: 1,
|
|
||||||
parent_id: null
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
title: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
|
|
||||||
name: [{ required: true, message: '请输入部门别名', trigger: 'blur' }],
|
|
||||||
sort: [
|
|
||||||
{ required: true, message: '请输入排序', trigger: 'change' },
|
|
||||||
{ type: 'number', message: '排序必须为数字', trigger: 'change' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 部门数据
|
|
||||||
const departments = ref([])
|
|
||||||
const departmentFieldNames = {
|
|
||||||
title: 'title',
|
|
||||||
value: 'id',
|
|
||||||
children: 'children'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaving.value = true
|
|
||||||
let res = {}
|
|
||||||
form.parent_id = form.parent_id || 0
|
|
||||||
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await authApi.department.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await authApi.department.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
isSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载部门树数据
|
|
||||||
const loadDepartments = async () => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.department.list.get({ is_tree: 1 })
|
|
||||||
departments.value = res.data || []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载部门树失败:', error)
|
|
||||||
message.error('加载部门树失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
form.id = data.id
|
|
||||||
form.title = data.title
|
|
||||||
form.name = data.name
|
|
||||||
form.sort = data.sort
|
|
||||||
form.parent_id = data.parent_id || null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
loadDepartments()
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages permission-page">
|
|
||||||
<div class="left-box">
|
|
||||||
<div class="header">
|
|
||||||
<a-input v-model:value="menuFilterText" placeholder="搜索菜单..." allow-clear @change="handleMenuSearch">
|
|
||||||
<template #prefix>
|
|
||||||
<search-outlined style="color: rgba(0, 0, 0, 0.45)" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</div>
|
|
||||||
<div class="body">
|
|
||||||
<a-tree v-model:selectedKeys="selectedMenuKeys" v-model:checkedKeys="checkedMenuKeys"
|
|
||||||
:tree-data="filteredMenuTree" :field-names="{ title: 'title', key: 'id', children: 'children' }"
|
|
||||||
showLine checkable :check-strictly="true" :expand-on-click-node="false" @select="onMenuSelect"
|
|
||||||
@check="onMenuCheck">
|
|
||||||
<template #icon="{ dataRef }">
|
|
||||||
<folder-outlined v-if="dataRef.children" />
|
|
||||||
<file-outlined v-else />
|
|
||||||
</template>
|
|
||||||
<template #title="{ dataRef }">
|
|
||||||
<span class="tree-node-title">{{ dataRef.title }}</span>
|
|
||||||
<plus-outlined class="tree-node-add" @click.stop="handleAdd(dataRef)" />
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<a-space>
|
|
||||||
<a-button type="primary" @click="handleAdd(null)">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
新增
|
|
||||||
</a-button>
|
|
||||||
<a-button danger @click="handleDeleteBatch">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
删除
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="right-box">
|
|
||||||
<div class="header">
|
|
||||||
<div class="title">{{ selectedMenu?.title || '请选择菜单' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="body">
|
|
||||||
<save-form v-if="selectedMenu" :menu="menuTree" :menu-id="selectedMenu.id" :parent-id="parentId"
|
|
||||||
@success="handleSaveSuccess" />
|
|
||||||
<a-empty v-else description="请选择左侧菜单后操作" :image-size="100" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import saveForm from './save.vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'authPermission'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 菜单树数据
|
|
||||||
const menuTree = ref([])
|
|
||||||
const filteredMenuTree = ref([])
|
|
||||||
const selectedMenuKeys = ref([])
|
|
||||||
const checkedMenuKeys = ref([])
|
|
||||||
const menuFilterText = ref('')
|
|
||||||
|
|
||||||
// 当前选中的菜单
|
|
||||||
const selectedMenu = ref(null)
|
|
||||||
const parentId = ref(null)
|
|
||||||
|
|
||||||
// 加载菜单树
|
|
||||||
const loadMenuTree = async () => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.menu.list.get({ is_tree: 1 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
menuTree.value = res.data || []
|
|
||||||
filteredMenuTree.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载菜单树失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单搜索
|
|
||||||
const handleMenuSearch = (e) => {
|
|
||||||
const keyword = e.target?.value || ''
|
|
||||||
menuFilterText.value = keyword
|
|
||||||
if (!keyword) {
|
|
||||||
filteredMenuTree.value = menuTree.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归过滤菜单树
|
|
||||||
const filterTree = (nodes) => {
|
|
||||||
return nodes.reduce((acc, node) => {
|
|
||||||
const isMatch = node.title && node.title.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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredMenuTree.value = filterTree(menuTree.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找菜单节点
|
|
||||||
const findMenuNode = (tree, id) => {
|
|
||||||
for (const node of tree) {
|
|
||||||
if (node.id === id) {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
const found = findMenuNode(node.children, id)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
if (child) {
|
|
||||||
return node.id
|
|
||||||
}
|
|
||||||
const found = findParentId(node.children, id)
|
|
||||||
if (found !== null) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
} else {
|
|
||||||
selectedMenu.value = null
|
|
||||||
parentId.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单勾选事件
|
|
||||||
const onMenuCheck = (checkedKeys, info) => {
|
|
||||||
console.log('checkedKeys:', checkedKeys, 'info:', info)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增菜单
|
|
||||||
const handleAdd = async (parentNode) => {
|
|
||||||
try {
|
|
||||||
let newMenuData = {
|
|
||||||
parent_id: parentNode ? parentNode.id : 0,
|
|
||||||
name: '新菜单',
|
|
||||||
path: '',
|
|
||||||
component: '',
|
|
||||||
title: '新菜单',
|
|
||||||
type: 'menu',
|
|
||||||
sort: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await authApi.menu.add.post(newMenuData)
|
|
||||||
if (res.code === 1) {
|
|
||||||
newMenuData.id = res.data.id
|
|
||||||
message.success('添加成功')
|
|
||||||
await loadMenuTree()
|
|
||||||
|
|
||||||
// 选中新增的菜单
|
|
||||||
selectedMenuKeys.value = [newMenuData.id]
|
|
||||||
const menuNode = findMenuNode(menuTree.value, newMenuData.id)
|
|
||||||
selectedMenu.value = menuNode
|
|
||||||
parentId.value = parentNode ? parentNode.id : null
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '添加失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('添加菜单失败:', error)
|
|
||||||
message.error('添加失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除菜单
|
|
||||||
const handleDeleteBatch = async () => {
|
|
||||||
if (checkedMenuKeys.value.length === 0) {
|
|
||||||
message.warning('请选择需要删除的菜单')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定删除已选择的 ${checkedMenuKeys.value.length} 个菜单吗?`,
|
|
||||||
okText: '删除',
|
|
||||||
okType: 'danger',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.menu.delete.post({ ids: checkedMenuKeys.value })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
|
|
||||||
// 如果当前选中的菜单被删除了,清空选择
|
|
||||||
if (selectedMenu.value && checkedMenuKeys.value.includes(selectedMenu.value.id)) {
|
|
||||||
selectedMenu.value = null
|
|
||||||
selectedMenuKeys.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
checkedMenuKeys.value = []
|
|
||||||
await loadMenuTree()
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除菜单失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功回调
|
|
||||||
const handleSaveSuccess = async () => {
|
|
||||||
await loadMenuTree()
|
|
||||||
// 重新设置当前选中的菜单
|
|
||||||
if (selectedMenu.value) {
|
|
||||||
const menuNode = findMenuNode(menuTree.value, selectedMenu.value.id)
|
|
||||||
selectedMenu.value = menuNode
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
loadMenuTree()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.permission-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.left-box {
|
|
||||||
width: 300px;
|
|
||||||
border-right: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
background: #fafafa;
|
|
||||||
height: 56px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 12px;
|
|
||||||
|
|
||||||
.tree-node-title {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tree-node-add {
|
|
||||||
margin-left: 8px;
|
|
||||||
color: #999;
|
|
||||||
display: none;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-tree-node-content-wrapper) {
|
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.tree-node-add {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-box {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 16px 24px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
background: #fff;
|
|
||||||
height: 56px;
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="save-form">
|
|
||||||
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
|
|
||||||
<!-- 第一行:显示名称和类型 -->
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="显示名称" name="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="菜单显示名字" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="类型" name="type" :label-col="{ span: 6 }" :wrapper-col="{ span: 16 }">
|
|
||||||
<a-radio-group v-model:value="form.type" button-style="solid">
|
|
||||||
<a-radio-button value="menu">菜单</a-radio-button>
|
|
||||||
<a-radio-button value="iframe">Iframe</a-radio-button>
|
|
||||||
<a-radio-button value="link">外链</a-radio-button>
|
|
||||||
<a-radio-button value="button">按钮</a-radio-button>
|
|
||||||
</a-radio-group>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 第二行:上级菜单和别名 -->
|
|
||||||
<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-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="别名" name="name">
|
|
||||||
<a-input v-model:value="form.name" placeholder="菜单别名" allow-clear />
|
|
||||||
<div class="form-item-msg">系统唯一且与内置组件名一致,否则导致缓存失效</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 第三行:菜单图标和排序 -->
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="菜单图标" name="icon">
|
|
||||||
<sc-icon-picker v-model="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="100" style="width: 100%" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 第四行:路由地址和重定向 -->
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="路由地址" name="path">
|
|
||||||
<a-input v-model:value="form.path" placeholder="" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="重定向" name="redirect">
|
|
||||||
<a-input v-model:value="form.redirect" placeholder="" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 第五行:菜单高亮和视图 -->
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="菜单高亮" name="active">
|
|
||||||
<a-input v-model:value="form.active" placeholder="" allow-clear />
|
|
||||||
<div class="form-item-msg">子节点或详情页需要高亮的上级菜单路由地址</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="视图" name="component">
|
|
||||||
<a-input v-model:value="form.component" placeholder="" allow-clear>
|
|
||||||
<template #addonBefore>pages/</template>
|
|
||||||
</a-input>
|
|
||||||
<div class="form-item-msg">如父节点、链接或Iframe等没有视图的菜单不需要填写</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 第六行:颜色 -->
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="12">
|
|
||||||
<a-form-item label="颜色" name="color">
|
|
||||||
<a-input v-model:value="form.color" placeholder="请输入颜色值" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 分隔线 -->
|
|
||||||
<a-divider />
|
|
||||||
|
|
||||||
<!-- 选项设置区域 -->
|
|
||||||
<div class="options-section">
|
|
||||||
<h4>选项设置</h4>
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-form-item label="是否隐藏" name="hidden" :label-col="{ span: 4 }" :wrapper-col="{ span: 18 }">
|
|
||||||
<a-checkbox v-model:checked="form.hidden">隐藏菜单</a-checkbox>
|
|
||||||
<a-checkbox v-model:checked="form.hiddenBreadcrumb">隐藏面包屑</a-checkbox>
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-form-item label="是否固定" name="affix" :label-col="{ span: 8 }" :wrapper-col="{ span: 14 }">
|
|
||||||
<a-switch v-model:checked="form.affix" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-form-item label="是否全屏" name="fullpage" :label-col="{ span: 8 }" :wrapper-col="{ span: 14 }">
|
|
||||||
<a-switch v-model:checked="form.fullpage" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 分隔线 -->
|
|
||||||
<a-divider />
|
|
||||||
|
|
||||||
<!-- 接口权限区域 -->
|
|
||||||
<div class="api-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h4>接口权限</h4>
|
|
||||||
<a-button type="primary" size="small" @click="addApiRow">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
添加接口权限
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="api-list">
|
|
||||||
<div v-for="(item, index) in form.apiList" :key="index" class="api-item">
|
|
||||||
<a-row :gutter="8">
|
|
||||||
<a-col :span="8">
|
|
||||||
<a-input v-model:value="item.code" placeholder="标识" allow-clear />
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="14">
|
|
||||||
<a-input v-model:value="item.url" placeholder="Api url" allow-clear />
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="2">
|
|
||||||
<a-button type="text" danger @click="removeApiRow(index)">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-empty v-if="form.apiList.length === 0" description="暂无接口权限"
|
|
||||||
:image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<a-form-item :wrapper-col="{ span: 18, offset: 4 }" style="margin-top: 32px">
|
|
||||||
<a-space>
|
|
||||||
<a-button type="primary" @click="handleSave" :loading="loading" size="large">保存</a-button>
|
|
||||||
<a-button @click="$emit('cancel')" size="large">取消</a-button>
|
|
||||||
</a-space>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, watch, onMounted } from 'vue'
|
|
||||||
import { message, Empty } from 'ant-design-vue'
|
|
||||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons-vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
import ScIconPicker from '@/components/scIconPicker/index.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'PermissionSave'
|
|
||||||
})
|
|
||||||
|
|
||||||
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 form = reactive({
|
|
||||||
id: '',
|
|
||||||
parent_id: 0,
|
|
||||||
name: '',
|
|
||||||
path: '',
|
|
||||||
component: '',
|
|
||||||
redirect: '',
|
|
||||||
sort: 0,
|
|
||||||
title: '',
|
|
||||||
icon: '',
|
|
||||||
active: '',
|
|
||||||
color: '',
|
|
||||||
type: 'menu',
|
|
||||||
affix: false,
|
|
||||||
hidden: false,
|
|
||||||
hiddenBreadcrumb: false,
|
|
||||||
fullpage: false,
|
|
||||||
apiList: []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
title: [{ required: true, message: '请输入显示名称', trigger: 'blur' }],
|
|
||||||
name: [{ required: true, message: '请输入别名', trigger: 'blur' }],
|
|
||||||
type: [{ required: true, message: '请选择类型', trigger: 'change' }]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单选项
|
|
||||||
const menuOptions = ref([])
|
|
||||||
const menuFieldNames = {
|
|
||||||
value: 'id',
|
|
||||||
label: 'title',
|
|
||||||
children: 'children'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 简单化菜单
|
|
||||||
const treeToMap = (tree) => {
|
|
||||||
const map = []
|
|
||||||
tree.forEach(item => {
|
|
||||||
const obj = {
|
|
||||||
id: item.id,
|
|
||||||
parent_id: item.parent_id,
|
|
||||||
title: item.title,
|
|
||||||
children: item.children && item.children.length > 0 ? treeToMap(item.children) : null
|
|
||||||
}
|
|
||||||
map.push(obj)
|
|
||||||
})
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查找菜单节点
|
|
||||||
const findMenuNode = (tree, id) => {
|
|
||||||
for (const node of tree) {
|
|
||||||
if (node.id === id) {
|
|
||||||
return node
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
const found = findMenuNode(node.children, id)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听菜单树变化
|
|
||||||
watch(
|
|
||||||
() => props.menu,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal) {
|
|
||||||
menuOptions.value = treeToMap(newVal)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true, immediate: true }
|
|
||||||
)
|
|
||||||
|
|
||||||
// 监听 menuId 变化,从菜单树中查找并赋值
|
|
||||||
watch(
|
|
||||||
() => props.menuId,
|
|
||||||
(newVal) => {
|
|
||||||
if (newVal && props.menu && props.menu.length > 0) {
|
|
||||||
const menuNode = findMenuNode(props.menu, newVal)
|
|
||||||
if (menuNode) {
|
|
||||||
setData(menuNode, props.parentId)
|
|
||||||
}
|
|
||||||
} else if (!newVal) {
|
|
||||||
// 清空表单
|
|
||||||
setData({
|
|
||||||
id: '',
|
|
||||||
parent_id: 0,
|
|
||||||
name: '',
|
|
||||||
path: '',
|
|
||||||
component: '',
|
|
||||||
redirect: '',
|
|
||||||
sort: 0,
|
|
||||||
title: '',
|
|
||||||
icon: '',
|
|
||||||
active: '',
|
|
||||||
color: '',
|
|
||||||
type: 'menu',
|
|
||||||
affix: false,
|
|
||||||
hidden: false,
|
|
||||||
hiddenBreadcrumb: false,
|
|
||||||
fullpage: false,
|
|
||||||
apiList: []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
// 添加接口权限行
|
|
||||||
const addApiRow = () => {
|
|
||||||
form.apiList.push({
|
|
||||||
code: '',
|
|
||||||
url: ''
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除接口权限行
|
|
||||||
const removeApiRow = (index) => {
|
|
||||||
form.apiList.splice(index, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载菜单详情
|
|
||||||
const loadMenuDetail = async (id) => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.menu.list.get({ id })
|
|
||||||
if (res.code === 1 && res.data) {
|
|
||||||
setData(res.data, props.parentId)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载菜单详情失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
loading.value = true
|
|
||||||
let res = {}
|
|
||||||
form.parent_id = form.parent_id || 0
|
|
||||||
|
|
||||||
if (form.id) {
|
|
||||||
res = await authApi.menu.edit.post(form)
|
|
||||||
} else {
|
|
||||||
res = await authApi.menu.add.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('保存成功')
|
|
||||||
emit('success')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '保存失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data, pid) => {
|
|
||||||
form.id = data.id
|
|
||||||
form.parent_id = data.parent_id || pid || 0
|
|
||||||
form.name = data.name || ''
|
|
||||||
form.path = data.path || ''
|
|
||||||
form.component = data.component || ''
|
|
||||||
form.redirect = data.redirect || ''
|
|
||||||
form.sort = data.sort || 0
|
|
||||||
form.title = data.title || ''
|
|
||||||
form.icon = data.icon || ''
|
|
||||||
form.active = data.active || ''
|
|
||||||
form.color = data.color || ''
|
|
||||||
form.type = data.type || 'menu'
|
|
||||||
form.affix = data.affix == 1
|
|
||||||
form.hidden = data.hidden == 1
|
|
||||||
form.hiddenBreadcrumb = data.hiddenBreadcrumb == 1
|
|
||||||
form.fullpage = data.fullpage == 1
|
|
||||||
form.apiList = data.apiList || []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
if (props.menuId) {
|
|
||||||
loadMenuDetail(props.menuId)
|
|
||||||
} else if (props.parentId) {
|
|
||||||
form.parent_id = props.parentId
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
setData
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.save-form {
|
|
||||||
padding: 24px;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.form-item-msg {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
margin-top: 4px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.options-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #262626;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-section {
|
|
||||||
margin-top: 16px;
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #262626;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.api-list {
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
.api-item {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #d9d9d9;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-divider) {
|
|
||||||
margin: 32px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-form-item) {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-empty) {
|
|
||||||
padding: 24px 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages role-page">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.keyword" placeholder="请输入角色名称" allow-clear
|
|
||||||
style="width: 200px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
<a-button danger :disabled="selectedRows.length === 0" @click="handleBatchDelete">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
<a-button :disabled="selectedRows.length !== 1" @click="handlePermission">
|
|
||||||
<template #icon><key-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</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">
|
|
||||||
<template #status="{ record }">
|
|
||||||
<a-tag :color="record.status === 1 ? 'success' : 'error'">
|
|
||||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
|
||||||
</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-popconfirm title="确定删除该角色吗?" @confirm="handleDelete(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 新增/编辑角色弹窗 -->
|
|
||||||
<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" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import saveDialog from './save.vue'
|
|
||||||
import permissionDialog from './permission.vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
import { useTable } from '@/hooks/useTable'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'authRole'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用useTable hooks
|
|
||||||
const {
|
|
||||||
tableRef,
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
pagination,
|
|
||||||
selectedRows,
|
|
||||||
rowSelection,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
handlePaginationChange,
|
|
||||||
handleSelectChange,
|
|
||||||
handleSelectAll,
|
|
||||||
refreshTable
|
|
||||||
} = useTable({
|
|
||||||
api: authApi.role.list.get,
|
|
||||||
searchForm: {
|
|
||||||
keyword: ''
|
|
||||||
},
|
|
||||||
columns: [],
|
|
||||||
needPagination: true,
|
|
||||||
needSelection: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
save: false,
|
|
||||||
permission: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const saveDialogRef = ref(null)
|
|
||||||
const permissionDialogRef = ref(null)
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80, align: 'center' },
|
|
||||||
{ title: '角色名称', dataIndex: 'title', key: 'title', width: 200 },
|
|
||||||
{ title: '别名', dataIndex: 'name', key: 'name', width: 200 },
|
|
||||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100, align: 'center' },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
// 新增角色
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('add')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看角色
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('show').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑角色
|
|
||||||
const handleEdit = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除角色
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.role.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
refreshTable()
|
|
||||||
} 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: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
okType: 'danger',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const ids = selectedRows.value.map(item => item.id)
|
|
||||||
const res = await authApi.role.delete.post({ ids })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
selectedRows.value = []
|
|
||||||
refreshTable()
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除角色失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 权限设置
|
|
||||||
const handlePermission = () => {
|
|
||||||
if (selectedRows.value.length !== 1) {
|
|
||||||
message.error('请选择一个角色进行权限设置')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.permission = true
|
|
||||||
setTimeout(() => {
|
|
||||||
permissionDialogRef.value?.open().setData(selectedRows.value[0])
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功回调
|
|
||||||
const handleSaveSuccess = () => {
|
|
||||||
refreshTable()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 权限设置成功回调
|
|
||||||
const permissionSuccess = () => {
|
|
||||||
refreshTable()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.role-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal title="角色权限设置" :open="visible" :width="600" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
|
|
||||||
<a-tabs tab-position="top">
|
|
||||||
<a-tab-pane key="menu" tab="菜单权限">
|
|
||||||
<div class="treeMain">
|
|
||||||
<a-tree ref="menuTreeRef" v-model:checkedKeys="menu.checked" :tree-data="menu.list"
|
|
||||||
:field-names="menu.fieldNames" :checkable="true" :default-expand-all="true"
|
|
||||||
:check-strictly="true" :selectable="false">
|
|
||||||
<template #title="{ title }">
|
|
||||||
{{ title }}
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</div>
|
|
||||||
</a-tab-pane>
|
|
||||||
<a-tab-pane key="data" tab="数据权限">
|
|
||||||
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
|
||||||
<a-form-item label="数据权限">
|
|
||||||
<a-select v-model:value="form.data_range" placeholder="请选择数据权限" style="width: 100%">
|
|
||||||
<a-select-option value="0">全部数据权限</a-select-option>
|
|
||||||
<a-select-option value="1">本部门及以下数据</a-select-option>
|
|
||||||
<a-select-option value="2">本部门数据权限</a-select-option>
|
|
||||||
<a-select-option value="3">仅本人数据权限</a-select-option>
|
|
||||||
<a-select-option value="4">自定义数据权限</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-tab-pane>
|
|
||||||
<a-tab-pane key="dashboard" tab="控制台">
|
|
||||||
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
|
||||||
<a-form-item label="控制台视图">
|
|
||||||
<a-select v-model:value="form.dashboard" placeholder="请选择" style="width: 100%">
|
|
||||||
<a-select-option value="0">
|
|
||||||
<div style="display: flex; justify-content: space-between">
|
|
||||||
<span>数据统计</span>
|
|
||||||
<span style="color: #8492a6; font-size: 12px">stats</span>
|
|
||||||
</div>
|
|
||||||
</a-select-option>
|
|
||||||
<a-select-option value="1">
|
|
||||||
<div style="display: flex; justify-content: space-between">
|
|
||||||
<span>工作台</span>
|
|
||||||
<span style="color: #8492a6; font-size: 12px">work</span>
|
|
||||||
</div>
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<div class="ant-form-item-explain">用于控制角色登录后控制台的视图</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-tab-pane>
|
|
||||||
</a-tabs>
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="handleCancel">取 消</a-button>
|
|
||||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
const menuTreeRef = ref()
|
|
||||||
|
|
||||||
const menu = reactive({
|
|
||||||
list: [],
|
|
||||||
checked: [],
|
|
||||||
fieldNames: {
|
|
||||||
title: 'title',
|
|
||||||
key: 'id',
|
|
||||||
children: 'children'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
role_id: 0,
|
|
||||||
permissions: [],
|
|
||||||
data_range: '',
|
|
||||||
dashboard: '1'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打开对话框
|
|
||||||
const open = () => {
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交保存
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
isSaveing.value = true
|
|
||||||
|
|
||||||
// 获取选中的菜单权限 ID
|
|
||||||
form.permissions = menu.checked || []
|
|
||||||
form.role_id = form.id
|
|
||||||
|
|
||||||
const res = await authApi.role.auth.post(form)
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存权限失败:', error)
|
|
||||||
isSaveing.value = false
|
|
||||||
message.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取菜单列表
|
|
||||||
const getMenu = async () => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.menu.list.get({ is_tree: 1 })
|
|
||||||
menu.list = res.data || []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取菜单列表失败:', error)
|
|
||||||
message.error('获取菜单列表失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置数据
|
|
||||||
const setData = (data) => {
|
|
||||||
form.id = data.id
|
|
||||||
form.data_range = data.data_range || ''
|
|
||||||
form.dashboard = data.dashboard || '1'
|
|
||||||
|
|
||||||
// 设置选中的菜单权限
|
|
||||||
if (data.permissions && data.permissions.length > 0) {
|
|
||||||
menu.checked = data.permissions.map(item => item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
getMenu()
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.treeMain {
|
|
||||||
height: 280px;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid #dcdfe6;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
padding: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-form-item-explain {
|
|
||||||
color: rgba(0, 0, 0, 0.45);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<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-form-item label="角色名称" name="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="请输入角色名称" allow-clear></a-input>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="角色别名" name="name">
|
|
||||||
<a-input v-model:value="form.name" placeholder="请输入角色别名" allow-clear></a-input>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="排序" name="sort">
|
|
||||||
<a-input-number v-model:value="form.sort" :min="1" :step="1" style="width: 100%" placeholder="请输入排序" />
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '新增角色',
|
|
||||||
edit: '编辑角色',
|
|
||||||
show: '查看角色'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
name: '',
|
|
||||||
sort: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
title: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
|
|
||||||
name: [{ required: true, message: '请输入角色别名', trigger: 'blur' }],
|
|
||||||
sort: [
|
|
||||||
{ required: true, message: '请输入排序', trigger: 'change' },
|
|
||||||
{ type: 'number', message: '排序必须为数字', trigger: 'change' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
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.role.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await authApi.role.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
isSaveing.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
form.id = data.id
|
|
||||||
form.title = data.title
|
|
||||||
form.name = data.name
|
|
||||||
form.sort = data.sort
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages user-page">
|
|
||||||
<div class="left-box">
|
|
||||||
<div class="header">
|
|
||||||
<a-input v-model:value="departmentKeyword" placeholder="搜索部门..." allow-clear @change="handleDeptSearch">
|
|
||||||
<template #prefix>
|
|
||||||
<search-outlined style="color: rgba(0, 0, 0, 0.45)" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</div>
|
|
||||||
<div class="body">
|
|
||||||
<a-tree v-model:selectedKeys="selectedDeptKeys" :tree-data="filteredDepartmentTree"
|
|
||||||
:field-names="{ title: 'title', key: 'id', children: 'children' }" showLine @select="onDeptSelect">
|
|
||||||
<template #icon="{ dataRef }">
|
|
||||||
<folder-outlined v-if="dataRef.children" />
|
|
||||||
<file-outlined v-else />
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="right-box">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.username" placeholder="请输入用户名" allow-clear
|
|
||||||
style="width: 160px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.nickname" placeholder="请输入姓名" allow-clear
|
|
||||||
style="width: 160px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<a-button type="primary" @click="handleSearch">
|
|
||||||
<template #icon><search-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
<a-button @click="handleUserReset">
|
|
||||||
<template #icon><redo-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
<a-button @click="handleExport">
|
|
||||||
<template #icon><export-outlined /></template>
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
|
||||||
:pagination="pagination" :row-key="rowKey" @refresh="refreshTable"
|
|
||||||
@paginationChange="handlePaginationChange">
|
|
||||||
<template #avatar="{ record }">
|
|
||||||
<a-avatar :src="record.avatar" :size="32">
|
|
||||||
<template #icon><user-outlined /></template>
|
|
||||||
</a-avatar>
|
|
||||||
</template>
|
|
||||||
<template #status="{ record }">
|
|
||||||
<a-tag :color="record.status === 1 ? 'success' : 'error'">
|
|
||||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
|
||||||
<template #department_title="{ record }">
|
|
||||||
{{ record.department?.title }}
|
|
||||||
</template>
|
|
||||||
<template #roles="{ record }">
|
|
||||||
<a-tag v-for="role in record.roles" :key="role.id" color="blue">
|
|
||||||
{{ role.title }}
|
|
||||||
</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-popconfirm title="确定删除该用户吗?" @confirm="handleDelete(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 新增/编辑用户弹窗 -->
|
|
||||||
<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" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import saveDialog from './save.vue'
|
|
||||||
import roleDialog from './role.vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
import { useTable } from '@/hooks/useTable'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'authUser'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用useTable hooks
|
|
||||||
const {
|
|
||||||
tableRef,
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
pagination,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
handlePaginationChange,
|
|
||||||
refreshTable
|
|
||||||
} = useTable({
|
|
||||||
api: authApi.users.list.get,
|
|
||||||
searchForm: {
|
|
||||||
username: '',
|
|
||||||
nickname: '',
|
|
||||||
department_id: null
|
|
||||||
},
|
|
||||||
columns: [],
|
|
||||||
needPagination: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
save: false,
|
|
||||||
role: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const saveDialogRef = ref(null)
|
|
||||||
const roleDialogRef = ref(null)
|
|
||||||
|
|
||||||
// 部门树数据
|
|
||||||
const departmentTree = ref([])
|
|
||||||
const filteredDepartmentTree = ref([])
|
|
||||||
const selectedDeptKeys = ref([])
|
|
||||||
const departmentKeyword = ref('')
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: '头像', dataIndex: 'avatar', key: 'avatar', width: 80, align: 'center', slot: 'avatar' },
|
|
||||||
{ title: '用户名', dataIndex: 'username', key: 'username', width: 150 },
|
|
||||||
{ title: '姓名', dataIndex: 'nickname', key: 'nickname', width: 150 },
|
|
||||||
{ title: '部门', dataIndex: 'department_title', key: 'department_title', slot: 'department_title', width: 150 },
|
|
||||||
{ title: '角色', dataIndex: 'roles', key: 'roles', width: 200, slot: 'roles' },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
|
||||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 加载部门树
|
|
||||||
const loadDepartmentTree = async () => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.department.list.get({ is_tree: 1 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
departmentTree.value = res.data || []
|
|
||||||
filteredDepartmentTree.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载部门树失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 部门搜索
|
|
||||||
const handleDeptSearch = (e) => {
|
|
||||||
const keyword = e.target?.value || ''
|
|
||||||
departmentKeyword.value = keyword
|
|
||||||
if (!keyword) {
|
|
||||||
filteredDepartmentTree.value = departmentTree.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归过滤部门树
|
|
||||||
const filterTree = (nodes) => {
|
|
||||||
return nodes.reduce((acc, node) => {
|
|
||||||
const isMatch = node.title && node.title.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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredDepartmentTree.value = filterTree(departmentTree.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置 - 覆盖useTable的handleReset以添加额外逻辑
|
|
||||||
const handleUserReset = () => {
|
|
||||||
searchForm.username = ''
|
|
||||||
searchForm.nickname = ''
|
|
||||||
searchForm.department_id = null
|
|
||||||
selectedDeptKeys.value = []
|
|
||||||
departmentKeyword.value = ''
|
|
||||||
filteredDepartmentTree.value = departmentTree.value
|
|
||||||
handleReset()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 部门选择事件
|
|
||||||
const onDeptSelect = (selectedKeys) => {
|
|
||||||
if (selectedKeys && selectedKeys.length > 0) {
|
|
||||||
searchForm.department_id = selectedKeys[0]
|
|
||||||
} else {
|
|
||||||
searchForm.department_id = null
|
|
||||||
}
|
|
||||||
handleSearch()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出数据
|
|
||||||
const handleExport = () => {
|
|
||||||
message.info('导出功能开发中...')
|
|
||||||
// TODO: 实现导出功能
|
|
||||||
// const params = { ...searchForm }
|
|
||||||
// 调用导出API
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增用户
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('add')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看用户
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('show').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑用户
|
|
||||||
const handleEdit = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置角色
|
|
||||||
const handleRole = (record) => {
|
|
||||||
dialog.role = true
|
|
||||||
setTimeout(() => {
|
|
||||||
roleDialogRef.value?.open().setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除用户
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await authApi.users.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
refreshTable()
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除用户失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功回调
|
|
||||||
const handleSaveSuccess = () => {
|
|
||||||
refreshTable()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 角色设置成功回调
|
|
||||||
const handleRoleSuccess = () => {
|
|
||||||
refreshTable()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
loadDepartmentTree()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.user-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.left-box {
|
|
||||||
width: 260px;
|
|
||||||
border-right: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
font-size: 14px;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-box {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal title="角色设置" :open="visible" :width="500" :destroy-on-close="true" :footer="null" @cancel="handleCancel">
|
|
||||||
<a-tabs tab-position="top">
|
|
||||||
<a-tab-pane key="role" tab="角色选择">
|
|
||||||
<div class="treeMain">
|
|
||||||
<a-tree ref="roleTreeRef" v-model:checkedKeys="role.checked" :tree-data="role.list"
|
|
||||||
:field-names="role.fieldNames" :checkable="true" :default-expand-all="true"
|
|
||||||
:check-strictly="true" :selectable="false">
|
|
||||||
<template #title="{ title }">
|
|
||||||
{{ title }}
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</div>
|
|
||||||
</a-tab-pane>
|
|
||||||
</a-tabs>
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="handleCancel">取 消</a-button>
|
|
||||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import authAPI from '@/api/auth'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
const roleTreeRef = ref()
|
|
||||||
|
|
||||||
const role = reactive({
|
|
||||||
list: [],
|
|
||||||
checked: [],
|
|
||||||
fieldNames: {
|
|
||||||
title: 'title',
|
|
||||||
key: 'id',
|
|
||||||
children: 'children'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
uid: '',
|
|
||||||
roles: []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 打开对话框
|
|
||||||
const open = () => {
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交保存
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
isSaveing.value = true
|
|
||||||
|
|
||||||
// 获取选中的角色 ID
|
|
||||||
form.roles = role.checked || []
|
|
||||||
|
|
||||||
const res = await authAPI.users.uprole.post(form)
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('保存角色失败:', error)
|
|
||||||
isSaveing.value = false
|
|
||||||
message.error('操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取角色列表
|
|
||||||
const getRole = async () => {
|
|
||||||
try {
|
|
||||||
const res = await authAPI.role.list.get({ is_tree: 1 })
|
|
||||||
role.list = res.data || []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取角色列表失败:', error)
|
|
||||||
message.error('获取角色列表失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置数据
|
|
||||||
const setData = (data) => {
|
|
||||||
role.checked = data.roles ? data.roles.map(item => item.id) : []
|
|
||||||
form.uid = data.uid
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
getRole()
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.treeMain {
|
|
||||||
height: 280px;
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid #dcdfe6;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false"
|
|
||||||
: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="avatar">
|
|
||||||
<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 />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="姓名" name="nickname">
|
|
||||||
<a-input v-model:value="form.nickname" 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" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="确认密码" name="password2">
|
|
||||||
<a-input-password v-model:value="form.password2" 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="title" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="所属角色" name="roles">
|
|
||||||
<a-tree-select v-model:value="form.roles" :tree-data="groups" :field-names="groupsFieldNames"
|
|
||||||
:tree-default-expand-all="false" :tree-checkable="true" placeholder="请选择角色" allow-clear
|
|
||||||
tree-node-filter-prop="title" multiple />
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import scUpload from '@/components/scUpload/index.vue'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '新增用户',
|
|
||||||
edit: '编辑用户',
|
|
||||||
show: '查看'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
username: '',
|
|
||||||
avatar: '',
|
|
||||||
department_id: 0,
|
|
||||||
roles: []
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
username: [{ required: true, message: '请输入登录账号', trigger: 'blur' }],
|
|
||||||
nickname: [{ required: true, message: '请输入真实姓名', trigger: 'blur' }],
|
|
||||||
password: [
|
|
||||||
{ required: true, message: '请输入登录密码', trigger: 'blur' },
|
|
||||||
{
|
|
||||||
validator: (rule, value) => {
|
|
||||||
if (form.password2 !== '') {
|
|
||||||
dialogForm.value?.validateFields('password2')
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
trigger: 'change'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
password2: [
|
|
||||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
|
||||||
{
|
|
||||||
validator: (rule, value) => {
|
|
||||||
if (value !== form.password) {
|
|
||||||
return Promise.reject(new Error('两次输入密码不一致!'))
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
trigger: 'blur'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 所需数据选项
|
|
||||||
const department = ref([])
|
|
||||||
const departmentFieldNames = {
|
|
||||||
value: 'id',
|
|
||||||
label: 'title',
|
|
||||||
children: 'children'
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = ref([])
|
|
||||||
const groupsFieldNames = {
|
|
||||||
value: 'id',
|
|
||||||
label: 'title',
|
|
||||||
children: 'children'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载树数据
|
|
||||||
const getGroup = async () => {
|
|
||||||
const res = await authApi.role.list.get({ is_tree: 1 })
|
|
||||||
groups.value = res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDepartment = async () => {
|
|
||||||
const res = await authApi.department.list.get({ is_tree: 1 })
|
|
||||||
department.value = res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaveing.value = true
|
|
||||||
let res = {}
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await authApi.users.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await authApi.users.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
form.uid = data.uid
|
|
||||||
form.username = data.username
|
|
||||||
form.avatar = data.avatar
|
|
||||||
form.nickname = data.nickname
|
|
||||||
form.department_id = data.department_id
|
|
||||||
form.roles = data.roles.map(item => item.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
getGroup()
|
|
||||||
getDepartment()
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style></style>
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="icon-picker-demo">
|
|
||||||
<a-card title="图标选择器演示" style="max-width: 600px; margin: 20px auto;">
|
|
||||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 20 }">
|
|
||||||
<a-form-item label="选择图标">
|
|
||||||
<sc-icon-picker v-model="selectedIcon" @change="handleIconChange" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="选中值">
|
|
||||||
<a-input v-model:value="selectedIcon" readonly />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item label="图标预览">
|
|
||||||
<div v-if="selectedIcon" class="icon-preview">
|
|
||||||
<component :is="selectedIcon" style="font-size: 48px;" />
|
|
||||||
</div>
|
|
||||||
<a-empty v-else description="未选择图标" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { Empty } from 'ant-design-vue'
|
|
||||||
import ScIconPicker from '@/components/scIconPicker/index.vue'
|
|
||||||
|
|
||||||
const selectedIcon = ref('')
|
|
||||||
|
|
||||||
const handleIconChange = (value) => {
|
|
||||||
console.log('图标已选择:', value)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.icon-picker-demo {
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
.icon-preview {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px dashed #d9d9d9;
|
|
||||||
border-radius: 6px;
|
|
||||||
min-height: 88px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,47 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="home-page">
|
<div></div>
|
||||||
<a-skeleton v-if="loading" active />
|
|
||||||
<component v-else :is="dashboardComponent" @on-mounted="handleMounted" />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, computed, defineAsyncComponent } from 'vue'
|
|
||||||
import config from '@/config'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'HomePage',
|
name: 'HomeIndex',
|
||||||
})
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const dashboard = ref(config.DASHBOARD_LAYOUT || 'work')
|
|
||||||
|
|
||||||
// 动态导入组件
|
|
||||||
const components = {
|
|
||||||
work: defineAsyncComponent(() => import('./work/index.vue')),
|
|
||||||
widgets: defineAsyncComponent(() => import('./widgets/index.vue')),
|
|
||||||
}
|
|
||||||
|
|
||||||
const dashboardComponent = computed(() => {
|
|
||||||
return components[dashboard.value] || components.work
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleMounted = () => {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 模拟加载延迟
|
|
||||||
setTimeout(() => {
|
|
||||||
loading.value = false
|
|
||||||
}, 300)
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.home-page {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="关于项目" class="about-card">
|
|
||||||
<p>高性能 / 精致 / 优雅。基于Vue3 + Ant Design Vue 的中后台前端解决方案,如果喜欢就点个星星支持一下。</p>
|
|
||||||
<p>
|
|
||||||
<a href="https://gitee.com/lolicode/scui" target="_blank">
|
|
||||||
<img src="https://gitee.com/lolicode/scui/badge/star.svg?theme=dark" alt="star" style="vertical-align: middle;">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'AboutWidget',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.about-card {
|
|
||||||
p {
|
|
||||||
color: #999;
|
|
||||||
margin-top: 10px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="实时收入">
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<div ref="chartRef" style="width: 100%; height: 300px;"></div>
|
|
||||||
</a-spin>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import * as echarts from 'echarts'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'EchartsWidget',
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartRef = ref(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
let chart = null
|
|
||||||
let timer = null
|
|
||||||
|
|
||||||
// 初始化图表
|
|
||||||
const initChart = () => {
|
|
||||||
if (!chartRef.value) return
|
|
||||||
|
|
||||||
chart = echarts.init(chartRef.value)
|
|
||||||
|
|
||||||
// 生成初始数据
|
|
||||||
const now = new Date()
|
|
||||||
const xData = []
|
|
||||||
const yData = []
|
|
||||||
|
|
||||||
for (let i = 29; i >= 0; i--) {
|
|
||||||
const time = new Date(now.getTime() - i * 2000)
|
|
||||||
xData.unshift(time.toLocaleTimeString().replace(/^\D*/, ''))
|
|
||||||
yData.push(Math.round(Math.random() * 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
const option = {
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis',
|
|
||||||
},
|
|
||||||
xAxis: {
|
|
||||||
boundaryGap: false,
|
|
||||||
type: 'category',
|
|
||||||
data: xData,
|
|
||||||
},
|
|
||||||
yAxis: [
|
|
||||||
{
|
|
||||||
type: 'value',
|
|
||||||
name: '价格',
|
|
||||||
splitLine: {
|
|
||||||
show: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '收入',
|
|
||||||
type: 'line',
|
|
||||||
symbol: 'none',
|
|
||||||
lineStyle: {
|
|
||||||
width: 1,
|
|
||||||
color: '#409EFF',
|
|
||||||
},
|
|
||||||
areaStyle: {
|
|
||||||
opacity: 0.1,
|
|
||||||
color: '#79bbff',
|
|
||||||
},
|
|
||||||
data: yData,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
chart.setOption(option)
|
|
||||||
|
|
||||||
// 模拟实时更新
|
|
||||||
timer = setInterval(() => {
|
|
||||||
const newTime = new Date().toLocaleTimeString().replace(/^\D*/, '')
|
|
||||||
const newValue = Math.round(Math.random() * 100)
|
|
||||||
|
|
||||||
xData.shift()
|
|
||||||
xData.push(newTime)
|
|
||||||
|
|
||||||
yData.shift()
|
|
||||||
yData.push(newValue)
|
|
||||||
|
|
||||||
chart.setOption({
|
|
||||||
xAxis: {
|
|
||||||
data: xData,
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
data: yData,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}, 2100)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
// 模拟加载延迟
|
|
||||||
setTimeout(() => {
|
|
||||||
loading.value = false
|
|
||||||
initChart()
|
|
||||||
}, 500)
|
|
||||||
|
|
||||||
// 监听窗口大小变化
|
|
||||||
window.addEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleResize = () => {
|
|
||||||
if (chart) {
|
|
||||||
chart.resize()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
if (chart) {
|
|
||||||
chart.dispose()
|
|
||||||
}
|
|
||||||
window.removeEventListener('resize', handleResize)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
// 样式根据需要添加
|
|
||||||
</style>
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { markRaw } from 'vue'
|
|
||||||
const resultComps = {}
|
|
||||||
const files = import.meta.glob('./*.vue', { eager: true })
|
|
||||||
Object.keys(files).forEach((fileName) => {
|
|
||||||
let comp = files[fileName]
|
|
||||||
resultComps[fileName.replace(/^\.\/(.*)\.\w+$/, '$1')] = comp.default
|
|
||||||
})
|
|
||||||
export default markRaw(resultComps)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="系统信息" class="info-card">
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<a-descriptions bordered :column="1">
|
|
||||||
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
|
||||||
{{ item.values }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
|
||||||
</a-spin>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'InfoWidget',
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const sysInfo = ref([])
|
|
||||||
|
|
||||||
const getSystemList = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.info.get()
|
|
||||||
if (res.code === 1) {
|
|
||||||
sysInfo.value = res.data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取系统信息失败:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getSystemList()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.info-card {
|
|
||||||
:deep(.ant-descriptions-item-label) {
|
|
||||||
width: 120px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="进度环">
|
|
||||||
<div class="progress">
|
|
||||||
<a-progress type="dashboard" :percent="85.5" :width="160">
|
|
||||||
<template #format="percent">
|
|
||||||
<div class="percentage-value">{{ percent }}%</div>
|
|
||||||
<div class="percentage-label">当前进度</div>
|
|
||||||
</template>
|
|
||||||
</a-progress>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'ProgressWidget',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.progress {
|
|
||||||
text-align: center;
|
|
||||||
|
|
||||||
.percentage-value {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.percentage-label {
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 10px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-row :gutter="10">
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-card :bordered="true" title="今日数量">
|
|
||||||
<a-statistic :value="count.today" :formatter="formatNumber" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-card :bordered="true" title="昨日数量">
|
|
||||||
<a-statistic :value="count.yesterday" :formatter="formatNumber" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-card :bordered="true" title="本周数量">
|
|
||||||
<a-statistic :value="count.week" :formatter="formatNumber" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-card :bordered="true" title="上周数量">
|
|
||||||
<a-statistic :value="count.last_week" :formatter="formatNumber" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-card :bordered="true" title="今年数量">
|
|
||||||
<a-statistic :value="count.year" :formatter="formatNumber" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-card :bordered="true" title="总数量">
|
|
||||||
<a-statistic :value="count.all" :formatter="formatNumber" />
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'SmsWidget',
|
|
||||||
})
|
|
||||||
|
|
||||||
const count = ref({})
|
|
||||||
|
|
||||||
const getSmsCount = async () => {
|
|
||||||
try {
|
|
||||||
// 注意:API中可能没有短信统计接口,这里使用模拟数据
|
|
||||||
// 如果有接口,请取消注释以下代码
|
|
||||||
// const res = await systemApi.sms.count.get()
|
|
||||||
// if (res.code === 1) {
|
|
||||||
// count.value = res.data
|
|
||||||
// }
|
|
||||||
|
|
||||||
// 模拟数据
|
|
||||||
count.value = {
|
|
||||||
today: 1234,
|
|
||||||
yesterday: 5678,
|
|
||||||
week: 45678,
|
|
||||||
last_week: 43210,
|
|
||||||
year: 567890,
|
|
||||||
all: 1234567
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取短信统计失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatNumber = (value) => {
|
|
||||||
return value.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getSmsCount()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
:deep(.ant-card-head-title) {
|
|
||||||
padding: 12px 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-card-body) {
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-statistic-content) {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="时钟" class="time-card">
|
|
||||||
<div class="time">
|
|
||||||
<h2>{{ time }}</h2>
|
|
||||||
<p>{{ day }}</p>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
|
||||||
import tool from '@/utils/tool'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'TimeWidget',
|
|
||||||
})
|
|
||||||
|
|
||||||
const time = ref('')
|
|
||||||
const day = ref('')
|
|
||||||
let timer = null
|
|
||||||
|
|
||||||
const showTime = () => {
|
|
||||||
time.value = tool.dateFormat(new Date(), 'hh:mm:ss')
|
|
||||||
day.value = tool.dateFormat(new Date(), 'yyyy年MM月dd日')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
showTime()
|
|
||||||
timer = setInterval(() => {
|
|
||||||
showTime()
|
|
||||||
}, 1000)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.time-card {
|
|
||||||
background: linear-gradient(to right, #8e54e9, #4776e6);
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
:deep(.ant-card-head-title) {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-card-head) {
|
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-card-body) {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
h2 {
|
|
||||||
font-size: 40px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-top: 13px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="版本信息" class="ver-card">
|
|
||||||
<a-spin :spinning="loading">
|
|
||||||
<a-descriptions bordered :column="1">
|
|
||||||
<a-descriptions-item v-for="(item, index) in sysInfo" :key="index" :label="item.label">
|
|
||||||
{{ item.values }}
|
|
||||||
</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
|
||||||
</a-spin>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, onMounted } from 'vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'VerWidget',
|
|
||||||
})
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const sysInfo = ref([])
|
|
||||||
|
|
||||||
const getSystemList = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.version.get()
|
|
||||||
if (res.code === 1) {
|
|
||||||
sysInfo.value = res.data
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取版本信息失败:', error)
|
|
||||||
// 使用模拟数据作为fallback
|
|
||||||
sysInfo.value = [
|
|
||||||
{ label: '系统版本', values: '1.0.0' },
|
|
||||||
{ label: '框架版本', values: 'Vue 3.x' },
|
|
||||||
{ label: '构建时间', values: '2024-01-01' },
|
|
||||||
]
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
getSystemList()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.ver-card {
|
|
||||||
:deep(.ant-descriptions-item-label) {
|
|
||||||
width: 120px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-card :bordered="true" title="欢迎">
|
|
||||||
<div class="welcome">
|
|
||||||
<div class="logo">
|
|
||||||
<img src="/favicon.ico" alt="logo">
|
|
||||||
<h2>VueAdmin</h2>
|
|
||||||
</div>
|
|
||||||
<div class="tips">
|
|
||||||
<div class="tips-item">
|
|
||||||
<div class="tips-item-icon"><MenuOutlined /></div>
|
|
||||||
<div class="tips-item-message">这里是项目控制台,你可以点击右上方的"自定义"按钮来添加移除或者移动部件。</div>
|
|
||||||
</div>
|
|
||||||
<div class="tips-item">
|
|
||||||
<div class="tips-item-icon"><RocketOutlined /></div>
|
|
||||||
<div class="tips-item-message">在提高前端算力、减少带宽请求和代码执行力上多次优化,并且持续着。</div>
|
|
||||||
</div>
|
|
||||||
<div class="tips-item">
|
|
||||||
<div class="tips-item-icon"><CoffeeOutlined /></div>
|
|
||||||
<div class="tips-item-message">项目目的:让前端工作更快乐</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { MenuOutlined, RocketOutlined, CoffeeOutlined } from '@ant-design/icons-vue'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'WelcomeWidget',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.welcome {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.welcome .logo {
|
|
||||||
text-align: center;
|
|
||||||
padding: 0 40px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 30px;
|
|
||||||
font-weight: normal;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips {
|
|
||||||
padding: 0 40px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 7.5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips-item-icon {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-right: 20px;
|
|
||||||
color: #1890ff;
|
|
||||||
background: rgba(24, 144, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips-item-message {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,505 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div :class="['widgets-home', customizing ? 'customizing' : '']" ref="main">
|
|
||||||
<div class="widgets-content">
|
|
||||||
<div class="widgets-top">
|
|
||||||
<div class="widgets-top-title">控制台</div>
|
|
||||||
<div class="widgets-top-actions">
|
|
||||||
<a-button v-if="customizing" type="primary" shape="round" @click="handleSave">
|
|
||||||
<template #icon>
|
|
||||||
<CheckOutlined />
|
|
||||||
</template>
|
|
||||||
完成
|
|
||||||
</a-button>
|
|
||||||
<a-button v-else type="primary" shape="round" @click="handleCustom">
|
|
||||||
<template #icon>
|
|
||||||
<EditOutlined />
|
|
||||||
</template>
|
|
||||||
自定义
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="widgets" ref="widgetsRef">
|
|
||||||
<div class="widgets-wrapper">
|
|
||||||
<div v-if="nowCompsList.length <= 0" class="no-widgets">
|
|
||||||
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
||||||
</div>
|
|
||||||
<a-row :gutter="15">
|
|
||||||
<a-col v-for="(item, index) in grid.layout" :key="index" :md="item" :xs="24">
|
|
||||||
<div class="draggable-wrapper">
|
|
||||||
<draggable v-model="grid.compsList[index]" item-key="key" :animation="200"
|
|
||||||
handle=".customize-overlay" group="widgets" class="draggable-box">
|
|
||||||
<template #item="{ element }">
|
|
||||||
<div class="widgets-item">
|
|
||||||
<component :is="allComps[element]" />
|
|
||||||
<div v-if="customizing" class="customize-overlay">
|
|
||||||
<a-button class="close" type="primary" ghost shape="circle"
|
|
||||||
@click="removeComp(element)">
|
|
||||||
<template #icon>
|
|
||||||
<CloseOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
<label>
|
|
||||||
<component :is="allComps[element].icon" />
|
|
||||||
{{ allComps[element].title }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 自定义侧边栏 -->
|
|
||||||
<a-drawer v-if="customizing" :open="customizing" :width="360" placement="right" :closable="false" :mask="false"
|
|
||||||
class="widgets-drawer">
|
|
||||||
<template #title>
|
|
||||||
<div class="widgets-aside-title">
|
|
||||||
<PlusCircleOutlined /> 添加部件
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #extra>
|
|
||||||
<a-button type="text" @click="handleClose">
|
|
||||||
<template #icon>
|
|
||||||
<CloseOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 布局选择 -->
|
|
||||||
<div class="select-layout">
|
|
||||||
<h3>选择布局</h3>
|
|
||||||
<div class="select-layout-options">
|
|
||||||
<div class="select-layout-item item01" :class="{ active: grid.layout.join(',') === '12,6,6' }"
|
|
||||||
@click="setLayout([12, 6, 6])">
|
|
||||||
<a-row :gutter="2">
|
|
||||||
<a-col :span="12"><span></span></a-col>
|
|
||||||
<a-col :span="6"><span></span></a-col>
|
|
||||||
<a-col :span="6"><span></span></a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
<div class="select-layout-item item02" :class="{ active: grid.layout.join(',') === '24,16,8' }"
|
|
||||||
@click="setLayout([24, 16, 8])">
|
|
||||||
<a-row :gutter="2">
|
|
||||||
<a-col :span="24"><span></span></a-col>
|
|
||||||
<a-col :span="16"><span></span></a-col>
|
|
||||||
<a-col :span="8"><span></span></a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
<div class="select-layout-item item03" :class="{ active: grid.layout.join(',') === '24' }"
|
|
||||||
@click="setLayout([24])">
|
|
||||||
<a-row :gutter="2">
|
|
||||||
<a-col :span="24"><span></span></a-col>
|
|
||||||
<a-col :span="24"><span></span></a-col>
|
|
||||||
<a-col :span="24"><span></span></a-col>
|
|
||||||
</a-row>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 部件列表 -->
|
|
||||||
<div class="widgets-list">
|
|
||||||
<h3>可用部件</h3>
|
|
||||||
<div v-if="myCompsList.length <= 0" class="widgets-list-nodata">
|
|
||||||
<a-empty description="没有部件啦" :image="Empty.PRESENTED_IMAGE_SIMPLE" />
|
|
||||||
</div>
|
|
||||||
<div v-for="item in myCompsList" :key="item.key" class="widgets-list-item">
|
|
||||||
<div class="item-logo">
|
|
||||||
<component :is="item.icon" />
|
|
||||||
</div>
|
|
||||||
<div class="item-info">
|
|
||||||
<h2>{{ item.title }}</h2>
|
|
||||||
<p>{{ item.description }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="item-actions">
|
|
||||||
<a-button type="primary" @click="addComp(item)">
|
|
||||||
<template #icon>
|
|
||||||
<PlusOutlined />
|
|
||||||
</template>
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="handleResetDefault">恢复默认</a-button>
|
|
||||||
</template>
|
|
||||||
</a-drawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, onMounted, nextTick } from 'vue'
|
|
||||||
import { Empty } from 'ant-design-vue'
|
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
import allComps from './components'
|
|
||||||
import config from '@/config'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'WidgetsPage',
|
|
||||||
})
|
|
||||||
|
|
||||||
const customizing = ref(false)
|
|
||||||
const widgetsRef = ref(null)
|
|
||||||
const defaultGrid = config.DEFAULT_GRID
|
|
||||||
const grid = reactive({ layout: [], compsList: [] })
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
const initGrid = () => {
|
|
||||||
const savedGrid = localStorage.getItem('widgetsGrid')
|
|
||||||
if (savedGrid) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedGrid)
|
|
||||||
grid.layout = parsed.layout
|
|
||||||
grid.compsList = parsed.compsList
|
|
||||||
} catch {
|
|
||||||
resetToDefault()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
resetToDefault()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetToDefault = () => {
|
|
||||||
grid.layout = [...defaultGrid.layout]
|
|
||||||
grid.compsList = defaultGrid.compsList.map((arr) => [...arr])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const allCompsList = computed(() => {
|
|
||||||
const list = []
|
|
||||||
for (const key in allComps) {
|
|
||||||
list.push({
|
|
||||||
key,
|
|
||||||
title: allComps[key].title,
|
|
||||||
icon: allComps[key].icon,
|
|
||||||
description: allComps[key].description,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const myCompKeys = grid.compsList.flat()
|
|
||||||
list.forEach((comp) => {
|
|
||||||
comp.disabled = myCompKeys.includes(comp.key)
|
|
||||||
})
|
|
||||||
return list
|
|
||||||
})
|
|
||||||
|
|
||||||
const myCompsList = computed(() => {
|
|
||||||
return allCompsList.value.filter((item) => !item.disabled)
|
|
||||||
})
|
|
||||||
|
|
||||||
const nowCompsList = computed(() => {
|
|
||||||
return grid.compsList.flat()
|
|
||||||
})
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const handleCustom = () => {
|
|
||||||
customizing.value = true
|
|
||||||
const oldWidth = widgetsRef.value?.offsetWidth || 0
|
|
||||||
nextTick(() => {
|
|
||||||
if (widgetsRef.value) {
|
|
||||||
const scale = widgetsRef.value.offsetWidth / oldWidth
|
|
||||||
widgetsRef.value.style.setProperty('transform', `scale(${scale})`)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const setLayout = (layout) => {
|
|
||||||
grid.layout = layout
|
|
||||||
if (layout.join(',') === '24') {
|
|
||||||
grid.compsList[0] = [...grid.compsList[0], ...grid.compsList[1], ...grid.compsList[2]]
|
|
||||||
grid.compsList[1] = []
|
|
||||||
grid.compsList[2] = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const addComp = (item) => {
|
|
||||||
grid.compsList[0].push(item.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeComp = (key) => grid.compsList.forEach((list, index) => {
|
|
||||||
grid.compsList[index] = list.filter((k) => k !== key)
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
customizing.value = false
|
|
||||||
if (widgetsRef.value) {
|
|
||||||
widgetsRef.value.style.removeProperty('transform')
|
|
||||||
}
|
|
||||||
localStorage.setItem('widgetsGrid', JSON.stringify(grid))
|
|
||||||
emit('on-mounted')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleResetDefault = () => {
|
|
||||||
customizing.value = false
|
|
||||||
if (widgetsRef.value) {
|
|
||||||
widgetsRef.value.style.removeProperty('transform')
|
|
||||||
}
|
|
||||||
resetToDefault()
|
|
||||||
localStorage.removeItem('widgetsGrid')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
customizing.value = false
|
|
||||||
if (widgetsRef.value) {
|
|
||||||
widgetsRef.value.style.removeProperty('transform')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生命周期
|
|
||||||
onMounted(() => {
|
|
||||||
initGrid()
|
|
||||||
emit('on-mounted')
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['on-mounted'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.widgets-home {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
flex: 1;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-top {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-top-title {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets {
|
|
||||||
transform-origin: top left;
|
|
||||||
transition: transform 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-wrapper {
|
|
||||||
margin-right: -360px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-wrapper .ant-col {
|
|
||||||
padding-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-wrapper .draggable-wrapper {
|
|
||||||
border: 1px dashed #1890ff;
|
|
||||||
padding: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-wrapper .no-widgets {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-item {
|
|
||||||
position: relative;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: rgba(255, 255, 255, 0.9);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-overlay:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-overlay label {
|
|
||||||
background: #1890ff;
|
|
||||||
color: #fff;
|
|
||||||
height: 40px;
|
|
||||||
padding: 0 30px;
|
|
||||||
border-radius: 40px;
|
|
||||||
font-size: 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
cursor: grab;
|
|
||||||
margin-top: 8px;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
margin-right: 15px;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.customize-overlay .close {
|
|
||||||
position: absolute;
|
|
||||||
top: 15px;
|
|
||||||
right: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-list {
|
|
||||||
margin-top: 24px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-list-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
padding: 15px;
|
|
||||||
align-items: center;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
transition: background 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-list-item .item-logo {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: rgba(180, 180, 180, 0.1);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
margin-right: 15px;
|
|
||||||
color: #6a8bad;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-list-item .item-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-list-item .item-info h2 {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: normal;
|
|
||||||
cursor: default;
|
|
||||||
margin: 0 0 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-list-item .item-info p {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
cursor: default;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-wrapper .sortable-ghost {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-layout {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: 14px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-layout-options {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-layout-item {
|
|
||||||
width: 60px;
|
|
||||||
height: 60px;
|
|
||||||
border: 2px solid #d9d9d9;
|
|
||||||
padding: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
span {
|
|
||||||
display: block;
|
|
||||||
background: #d9d9d9;
|
|
||||||
height: 46px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.item02 span {
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.item02 .ant-col:nth-child(1) span {
|
|
||||||
height: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.item03 span {
|
|
||||||
height: 14px;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
border-color: #1890ff;
|
|
||||||
|
|
||||||
span {
|
|
||||||
background: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-drawer {
|
|
||||||
:deep(.ant-drawer-body) {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.widgets-aside-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 992px) {
|
|
||||||
.customizing .widgets {
|
|
||||||
transform: scale(1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-drawer {
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizing .widgets-wrapper {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,469 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="my-app">
|
|
||||||
<a-card :bordered="false">
|
|
||||||
<template #title>
|
|
||||||
<div class="card-title">
|
|
||||||
<SettingOutlined />
|
|
||||||
<span>我的常用应用</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #extra>
|
|
||||||
<a-button type="primary" @click="showDrawer">
|
|
||||||
<template #icon>
|
|
||||||
<PlusOutlined />
|
|
||||||
</template>
|
|
||||||
添加应用
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<div v-if="myApps.length === 0" class="empty-state">
|
|
||||||
<a-empty description="暂无常用应用,请点击上方按钮添加" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="apps-grid">
|
|
||||||
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="ghost" drag-class="dragging"
|
|
||||||
class="draggable-grid">
|
|
||||||
<template #item="{ element }">
|
|
||||||
<div class="app-item" @click="handleAppClick(element)">
|
|
||||||
<div class="app-icon">
|
|
||||||
<component :is="getIconComponent(element.meta?.icon)" />
|
|
||||||
</div>
|
|
||||||
<div class="app-name">{{ element.meta?.title }}</div>
|
|
||||||
<div class="app-description">{{ element.meta?.description || '点击打开' }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 添加应用抽屉 -->
|
|
||||||
<a-drawer v-model:open="drawerVisible" title="管理应用" :width="650" placement="right">
|
|
||||||
<div class="drawer-content">
|
|
||||||
<div class="app-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>
|
|
||||||
<StarFilled />
|
|
||||||
我的常用
|
|
||||||
</h3>
|
|
||||||
<span class="count">{{ myApps.length }} 个应用</span>
|
|
||||||
</div>
|
|
||||||
<p class="tips">拖拽卡片调整顺序,点击移除按钮移除应用</p>
|
|
||||||
<draggable v-model="myApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
|
|
||||||
drag-class="drawer-dragging" class="drawer-grid" group="apps">
|
|
||||||
<template #item="{ element }">
|
|
||||||
<div class="drawer-app-card">
|
|
||||||
<div class="remove-btn" @click.stop="removeApp(element.path)">
|
|
||||||
<CloseOutlined />
|
|
||||||
</div>
|
|
||||||
<div class="app-icon">
|
|
||||||
<component :is="getIconComponent(element.meta?.icon)" />
|
|
||||||
</div>
|
|
||||||
<div class="app-name">{{ element.meta?.title }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div v-if="myApps.length === 0" class="empty-zone">
|
|
||||||
<a-empty description="暂无常用应用,从下方拖入应用" :image="false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-divider style="margin: 24px 0" />
|
|
||||||
|
|
||||||
<div class="app-section">
|
|
||||||
<div class="section-header">
|
|
||||||
<h3>
|
|
||||||
<AppstoreOutlined />
|
|
||||||
全部应用
|
|
||||||
</h3>
|
|
||||||
<span class="count">{{ allApps.length }} 个可用</span>
|
|
||||||
</div>
|
|
||||||
<p class="tips">拖拽卡片到上方添加为常用应用</p>
|
|
||||||
<draggable v-model="allApps" item-key="path" :animation="200" ghost-class="drawer-ghost"
|
|
||||||
drag-class="drawer-dragging" class="drawer-grid" group="apps">
|
|
||||||
<template #item="{ element }">
|
|
||||||
<div class="drawer-app-card">
|
|
||||||
<div class="app-icon">
|
|
||||||
<component :is="getIconComponent(element.meta?.icon)" />
|
|
||||||
</div>
|
|
||||||
<div class="app-name">{{ element.meta?.title }}</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #footer>
|
|
||||||
<div v-if="allApps.length === 0" class="empty-zone">
|
|
||||||
<a-empty description="所有应用已添加" :image="false" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</draggable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="drawerVisible = false">取消</a-button>
|
|
||||||
<a-button type="primary" @click="handleSave">保存设置</a-button>
|
|
||||||
</template>
|
|
||||||
</a-drawer>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, computed, watch } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
|
||||||
import * as icons from '@ant-design/icons-vue'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'MyApp',
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
// 从菜单中提取所有应用项(扁平化菜单树)
|
|
||||||
const extractMenuItems = (menus) => {
|
|
||||||
const items = []
|
|
||||||
|
|
||||||
const traverse = (menuList) => {
|
|
||||||
for (const menu of menuList) {
|
|
||||||
// 只添加有路径的菜单项(排除父级菜单项)
|
|
||||||
if (menu.path && (!menu.children || menu.children.length === 0)) {
|
|
||||||
items.push(menu)
|
|
||||||
}
|
|
||||||
// 递归处理子菜单
|
|
||||||
if (menu.children && menu.children.length > 0) {
|
|
||||||
traverse(menu.children)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traverse(menus)
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取所有可用应用
|
|
||||||
const allAvailableApps = computed(() => {
|
|
||||||
return extractMenuItems(userStore.menu || [])
|
|
||||||
})
|
|
||||||
|
|
||||||
const drawerVisible = ref(false)
|
|
||||||
const myApps = ref([])
|
|
||||||
const allApps = ref([])
|
|
||||||
|
|
||||||
// 获取图标组件
|
|
||||||
const getIconComponent = (iconName) => {
|
|
||||||
return icons[iconName] || icons.FileTextOutlined
|
|
||||||
}
|
|
||||||
|
|
||||||
// 从本地存储加载数据
|
|
||||||
const loadApps = () => {
|
|
||||||
const savedApps = localStorage.getItem('myApps')
|
|
||||||
if (savedApps) {
|
|
||||||
const savedPaths = JSON.parse(savedApps)
|
|
||||||
myApps.value = allAvailableApps.value.filter(app => savedPaths.includes(app.path))
|
|
||||||
} else {
|
|
||||||
// 默认显示前4个应用
|
|
||||||
myApps.value = allAvailableApps.value.slice(0, 4)
|
|
||||||
}
|
|
||||||
updateAllApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新全部应用列表(排除已添加的)
|
|
||||||
const updateAllApps = () => {
|
|
||||||
const myAppPaths = myApps.value.map(app => app.path)
|
|
||||||
allApps.value = allAvailableApps.value.filter(app => !myAppPaths.includes(app.path))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示抽屉
|
|
||||||
const showDrawer = () => {
|
|
||||||
drawerVisible.value = true
|
|
||||||
updateAllApps()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 移除应用
|
|
||||||
const removeApp = (path) => {
|
|
||||||
const index = myApps.value.findIndex(app => app.path === path)
|
|
||||||
if (index > -1) {
|
|
||||||
myApps.value.splice(index, 1)
|
|
||||||
updateAllApps()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用点击处理
|
|
||||||
const handleAppClick = (app) => {
|
|
||||||
if (app.path) {
|
|
||||||
router.push(app.path)
|
|
||||||
} else {
|
|
||||||
message.warning('该应用没有配置路由')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存设置
|
|
||||||
const handleSave = () => {
|
|
||||||
const appPaths = myApps.value.map(app => app.path)
|
|
||||||
localStorage.setItem('myApps', JSON.stringify(appPaths))
|
|
||||||
message.success('保存成功')
|
|
||||||
drawerVisible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
loadApps()
|
|
||||||
|
|
||||||
// 监听菜单变化,重新加载应用
|
|
||||||
watch(
|
|
||||||
() => userStore.menu,
|
|
||||||
() => {
|
|
||||||
loadApps()
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.my-app {
|
|
||||||
padding: 20px;
|
|
||||||
|
|
||||||
.card-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
padding: 40px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.apps-grid {
|
|
||||||
.draggable-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-item {
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: grab;
|
|
||||||
transition: all 0.3s;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
border-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.dragging {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ghost {
|
|
||||||
opacity: 0.3;
|
|
||||||
background: #e6f7ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-icon {
|
|
||||||
font-size: 40px;
|
|
||||||
color: #1890ff;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-description {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 抽屉样式 - 使用全局样式避免深度问题
|
|
||||||
:deep(.my-app .ant-drawer-body) {
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.my-app .ant-drawer-footer) {
|
|
||||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-content {
|
|
||||||
.app-section {
|
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
.section-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
color: #262626;
|
|
||||||
|
|
||||||
.anticon {
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.count {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tips {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-zone {
|
|
||||||
text-align: center;
|
|
||||||
padding: 30px 20px;
|
|
||||||
background: #fafafa;
|
|
||||||
border: 2px dashed #d9d9d9;
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
.ant-empty-description {
|
|
||||||
color: #bfbfbf;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 抽屉内卡片式网格布局
|
|
||||||
.drawer-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
min-height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-app-card {
|
|
||||||
position: relative;
|
|
||||||
padding: 20px 16px;
|
|
||||||
text-align: center;
|
|
||||||
background: #fff;
|
|
||||||
border: 1px solid #f0f0f0;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: grab;
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #1890ff;
|
|
||||||
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.remove-btn {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
right: 8px;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: #ff4d4f;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.25s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #ff7875;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .remove-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
margin: 0 auto 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg, #e6f7ff 0%, #bae7ff 100%);
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 24px;
|
|
||||||
color: #1890ff;
|
|
||||||
|
|
||||||
:deep(.anticon) {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-name {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #262626;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-description {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 拖拽时的样式
|
|
||||||
.drawer-ghost {
|
|
||||||
opacity: 0.4;
|
|
||||||
background: #e6f7ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
border-style: dashed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-dragging {
|
|
||||||
opacity: 0.6;
|
|
||||||
transform: scale(0.95);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="work-page">
|
|
||||||
<MyApp />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import MyApp from './components/myapp.vue'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'WorkPage',
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.work-page {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="login-container auth-container">
|
|
||||||
<!-- 科技感背景 -->
|
|
||||||
<div class="tech-bg">
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<div class="login-wrapper auth-wrapper">
|
|
||||||
<div class="login-card auth-card">
|
|
||||||
<!-- 左侧装饰区 -->
|
|
||||||
<div class="decoration-area">
|
|
||||||
<div class="tech-circle">
|
|
||||||
<div class="circle-inner"></div>
|
|
||||||
<div class="circle-ring"></div>
|
|
||||||
<div class="circle-ring circle-ring-2"></div>
|
|
||||||
</div>
|
|
||||||
<div class="decoration-text">
|
|
||||||
<h2>欢迎回来</h2>
|
|
||||||
<p>进入智能管理系统</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧表单区 -->
|
|
||||||
<div class="form-area">
|
|
||||||
<div class="auth-header">
|
|
||||||
<h1>Vue Admin</h1>
|
|
||||||
<p class="subtitle">登录您的账户</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-form :model="formState" @finish="handleLogin" layout="vertical" class="auth-form login-form">
|
|
||||||
<a-form-item name="username" :rules="[{ required: true, message: '请输入用户名' }]">
|
|
||||||
<a-input v-model:value="formState.username" placeholder="请输入用户名" size="large"
|
|
||||||
:prefix="h(UserOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="password" :rules="[{ required: true, message: '请输入密码' }]">
|
|
||||||
<a-input-password v-model:value="formState.password" placeholder="请输入密码" size="large"
|
|
||||||
:prefix="h(LockOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<div class="form-options">
|
|
||||||
<a-checkbox v-model:checked="formState.remember">记住密码</a-checkbox>
|
|
||||||
<router-link to="/reset-password" class="forgot-password">
|
|
||||||
忘记密码?
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
|
|
||||||
登录
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<div class="form-footer">
|
|
||||||
<span>还没有账号?</span>
|
|
||||||
<router-link to="/register" class="auth-link">
|
|
||||||
立即注册
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, h } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import { UserOutlined, LockOutlined } from '@ant-design/icons-vue'
|
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
|
||||||
import authApi from '@/api/auth'
|
|
||||||
import '@/assets/style/auth-pages.scss'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'LoginPage'
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const loading = ref(false)
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
const formState = reactive({
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
remember: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleLogin = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
let res = await authApi.login.post({
|
|
||||||
username: formState.username,
|
|
||||||
password: formState.password,
|
|
||||||
})
|
|
||||||
if (res.code == 1) {
|
|
||||||
userStore.setToken(res.data.access_token)
|
|
||||||
let userInfo = await authApi.user.get()
|
|
||||||
if (userInfo.code == 1) {
|
|
||||||
userStore.setUserInfo(userInfo.data)
|
|
||||||
}
|
|
||||||
let authData = await authApi.menu.my.get()
|
|
||||||
if (authData.code == 1) {
|
|
||||||
userStore.setMenu(authData.data.menu)
|
|
||||||
userStore.setPermissions(authData.data.permissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
message.success('登录成功')
|
|
||||||
const redirect = router.currentRoute.value.query.redirect || '/'
|
|
||||||
router.push(redirect)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
message.error('登录失败,请检查用户名和密码')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 所有样式已移至统一样式文件 src/assets/style/auth-pages.scss */
|
|
||||||
</style>
|
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="reset-container auth-container">
|
|
||||||
<!-- 科技感背景 -->
|
|
||||||
<div class="tech-bg">
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<div class="reset-wrapper auth-wrapper">
|
|
||||||
<div class="reset-card auth-card">
|
|
||||||
<!-- 左侧装饰区 -->
|
|
||||||
<div class="decoration-area">
|
|
||||||
<div class="tech-circle">
|
|
||||||
<div class="circle-inner"></div>
|
|
||||||
<div class="circle-ring"></div>
|
|
||||||
<div class="circle-ring circle-ring-2"></div>
|
|
||||||
</div>
|
|
||||||
<div class="decoration-text">
|
|
||||||
<h2>重置密码</h2>
|
|
||||||
<p>找回您的账户访问权限</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧表单区 -->
|
|
||||||
<div class="form-area">
|
|
||||||
<div class="auth-header">
|
|
||||||
<h1>Vue Admin</h1>
|
|
||||||
<p class="subtitle">设置新密码</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-form :model="formState" @finish="handleReset" layout="vertical" class="auth-form reset-form">
|
|
||||||
<a-form-item name="email" :rules="[
|
|
||||||
{ required: true, message: '请输入邮箱' },
|
|
||||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
|
||||||
]">
|
|
||||||
<a-input v-model:value="formState.email" placeholder="请输入邮箱地址" size="large"
|
|
||||||
:prefix="h(MailOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="verificationCode" :rules="[{ required: true, message: '请输入验证码' }]">
|
|
||||||
<div class="code-input-wrapper">
|
|
||||||
<a-input v-model:value="formState.verificationCode" placeholder="请输入验证码" size="large"
|
|
||||||
:prefix="h(SafetyOutlined)" class="code-input" />
|
|
||||||
<a-button type="primary" :disabled="countdown > 0" @click="sendCode" class="code-btn"
|
|
||||||
size="large">
|
|
||||||
{{ countdown > 0 ? `${countdown}秒后重试` : '发送验证码' }}
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="newPassword" :rules="[
|
|
||||||
{ required: true, message: '请输入新密码' },
|
|
||||||
{ min: 6, message: '密码至少6个字符' },
|
|
||||||
]">
|
|
||||||
<a-input-password v-model:value="formState.newPassword" placeholder="请输入新密码" size="large"
|
|
||||||
:prefix="h(LockOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="confirmPassword" :rules="[
|
|
||||||
{ required: true, message: '请确认新密码' },
|
|
||||||
{ validator: validateConfirmPassword },
|
|
||||||
]">
|
|
||||||
<a-input-password v-model:value="formState.confirmPassword" placeholder="请再次输入新密码"
|
|
||||||
size="large" :prefix="h(LockOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
|
|
||||||
重置密码
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<div class="form-footer">
|
|
||||||
<span>记得密码了?</span>
|
|
||||||
<router-link to="/login" class="auth-link">
|
|
||||||
立即登录
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, h } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import { MailOutlined, SafetyOutlined, LockOutlined } from '@ant-design/icons-vue'
|
|
||||||
import '@/assets/style/auth-pages.scss'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'ResetPasswordPage'
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const loading = ref(false)
|
|
||||||
const countdown = ref(0)
|
|
||||||
|
|
||||||
const formState = reactive({
|
|
||||||
email: '',
|
|
||||||
verificationCode: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const validateConfirmPassword = async (rule, value) => {
|
|
||||||
if (value !== formState.newPassword) {
|
|
||||||
return Promise.reject('两次输入的密码不一致')
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendCode = () => {
|
|
||||||
if (!formState.email) {
|
|
||||||
message.warning('请先输入邮箱地址')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: 实现发送验证码逻辑
|
|
||||||
message.success('验证码已发送')
|
|
||||||
countdown.value = 60
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
clearInterval(timer)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleReset = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
// TODO: 实现重置密码逻辑
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
message.success('密码重置成功,请登录')
|
|
||||||
router.push('/login')
|
|
||||||
} catch {
|
|
||||||
message.error('密码重置失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 所有样式已移至统一样式文件 src/assets/style/auth-pages.scss */
|
|
||||||
</style>
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="register-container auth-container">
|
|
||||||
<!-- 科技感背景 -->
|
|
||||||
<div class="tech-bg">
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="grid-line"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
<div class="light-spot"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<div class="register-wrapper auth-wrapper">
|
|
||||||
<div class="register-card auth-card">
|
|
||||||
<!-- 左侧装饰区 -->
|
|
||||||
<div class="decoration-area">
|
|
||||||
<div class="tech-circle">
|
|
||||||
<div class="circle-inner"></div>
|
|
||||||
<div class="circle-ring"></div>
|
|
||||||
<div class="circle-ring circle-ring-2"></div>
|
|
||||||
</div>
|
|
||||||
<div class="decoration-text">
|
|
||||||
<h2>创建账号</h2>
|
|
||||||
<p>加入智能管理系统</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 右侧表单区 -->
|
|
||||||
<div class="form-area">
|
|
||||||
<div class="auth-header">
|
|
||||||
<h1>Vue Admin</h1>
|
|
||||||
<p class="subtitle">注册新账户</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-form :model="formState" @finish="handleRegister" layout="vertical"
|
|
||||||
class="auth-form register-form">
|
|
||||||
<a-form-item name="username" :rules="[
|
|
||||||
{ required: true, message: '请输入用户名' },
|
|
||||||
{ min: 3, message: '用户名至少3个字符' },
|
|
||||||
]">
|
|
||||||
<a-input v-model:value="formState.username" placeholder="请输入用户名" size="large"
|
|
||||||
:prefix="h(UserOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="email" :rules="[
|
|
||||||
{ required: true, message: '请输入邮箱' },
|
|
||||||
{ type: 'email', message: '请输入有效的邮箱地址' },
|
|
||||||
]">
|
|
||||||
<a-input v-model:value="formState.email" placeholder="请输入邮箱地址" size="large"
|
|
||||||
:prefix="h(MailOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="password" :rules="[
|
|
||||||
{ required: true, message: '请输入密码' },
|
|
||||||
{ min: 6, message: '密码至少6个字符' },
|
|
||||||
]">
|
|
||||||
<a-input-password v-model:value="formState.password" placeholder="请输入密码" size="large"
|
|
||||||
:prefix="h(LockOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item name="confirmPassword" :rules="[
|
|
||||||
{ required: true, message: '请确认密码' },
|
|
||||||
{ validator: validateConfirmPassword },
|
|
||||||
]">
|
|
||||||
<a-input-password v-model:value="formState.confirmPassword" placeholder="请再次输入密码"
|
|
||||||
size="large" :prefix="h(LockOutlined)" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<a-checkbox v-model:checked="formState.agree" class="agreement-checkbox">
|
|
||||||
<span class="agreement-text">
|
|
||||||
我已阅读并同意
|
|
||||||
<a href="#" class="link">用户协议</a>
|
|
||||||
和
|
|
||||||
<a href="#" class="link">隐私政策</a>
|
|
||||||
</span>
|
|
||||||
</a-checkbox>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item>
|
|
||||||
<a-button type="primary" html-type="submit" size="large" block :loading="loading"
|
|
||||||
:disabled="!formState.agree">
|
|
||||||
注册
|
|
||||||
</a-button>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<div class="form-footer">
|
|
||||||
<span>已有账号?</span>
|
|
||||||
<router-link to="/login" class="auth-link">
|
|
||||||
立即登录
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { reactive, ref, h } 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-pages.scss'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'RegisterPage'
|
|
||||||
})
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
const formState = reactive({
|
|
||||||
username: '',
|
|
||||||
email: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
agree: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const validateConfirmPassword = async (rule, value) => {
|
|
||||||
if (value !== formState.password) {
|
|
||||||
return Promise.reject('两次输入的密码不一致')
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRegister = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
// TODO: 实现注册逻辑
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
||||||
message.success('注册成功,请登录')
|
|
||||||
router.push('/login')
|
|
||||||
} catch {
|
|
||||||
message.error('注册失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 所有样式已移至统一样式文件 src/assets/style/auth-pages.scss */
|
|
||||||
</style>
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false"
|
|
||||||
: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 v-if="mode === 'add'" label="地区编码" name="code">
|
|
||||||
<a-input v-model:value="form.code" placeholder="请输入地区编码" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="地区名称" name="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="请输入地区名称" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="上级地区" name="parent_code">
|
|
||||||
<a-tree-select v-model:value="form.parent_code" :tree-data="areaTreeData"
|
|
||||||
:field-names="{ label: 'title', value: 'code', children: 'children' }" tree-default-expand-all
|
|
||||||
show-search placeholder="请选择上级地区" allow-clear tree-node-filter-prop="title" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="状态" name="status">
|
|
||||||
<a-radio-group v-model:value="form.status">
|
|
||||||
<a-radio :value="1">正常</a-radio>
|
|
||||||
<a-radio :value="0">禁用</a-radio>
|
|
||||||
</a-radio-group>
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '新增地区',
|
|
||||||
edit: '编辑地区',
|
|
||||||
show: '查看地区'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: null,
|
|
||||||
code: '',
|
|
||||||
title: '',
|
|
||||||
parent_code: null,
|
|
||||||
status: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
code: [
|
|
||||||
{ required: true, message: '请输入地区编码', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
title: [
|
|
||||||
{ required: true, message: '请输入地区名称', trigger: 'blur' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 地区树数据
|
|
||||||
const areaTreeData = ref([])
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
if (openMode !== 'show') {
|
|
||||||
loadAreaTree()
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载地区树数据
|
|
||||||
const loadAreaTree = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.area.list.get({ pageSize: 1000 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
const list = res.data.list || res.data || []
|
|
||||||
areaTreeData.value = buildTree(list)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载地区树失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建树结构
|
|
||||||
const buildTree = (list) => {
|
|
||||||
const map = {}
|
|
||||||
const roots = []
|
|
||||||
|
|
||||||
// 创建映射,以 code 作为键
|
|
||||||
list.forEach((item) => {
|
|
||||||
map[item.code] = { ...item, children: [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 构建树,通过 parent_code 关联
|
|
||||||
list.forEach((item) => {
|
|
||||||
if (item.parent_code && map[item.parent_code]) {
|
|
||||||
map[item.parent_code].children.push(map[item.code])
|
|
||||||
} else if (item.parent_code === '0' || !item.parent_code) {
|
|
||||||
roots.push(map[item.code])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return roots
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaveing.value = true
|
|
||||||
let res = {}
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await systemApi.area.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await systemApi.area.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '操作失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isSaveing.value = false
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
form.id = data.id
|
|
||||||
form.code = data.code
|
|
||||||
form.title = data.title
|
|
||||||
form.parent_code = data.parent_code
|
|
||||||
form.status = data.status ?? 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
// 弹窗样式可根据需要添加
|
|
||||||
</style>
|
|
||||||
@@ -1,388 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages area-page">
|
|
||||||
<div class="left-box">
|
|
||||||
<div class="header">
|
|
||||||
<a-input v-model:value="areaKeyword" placeholder="搜索地区..." allow-clear @change="handleAreaSearch">
|
|
||||||
<template #prefix>
|
|
||||||
<search-outlined style="color: rgba(0, 0, 0, 0.45)" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</div>
|
|
||||||
<div class="body">
|
|
||||||
<a-tree v-model:selectedKeys="selectedAreaKeys" :tree-data="filteredAreaTree"
|
|
||||||
:field-names="{ title: 'title', key: 'code', children: 'children' }" show-line @select="onAreaSelect">
|
|
||||||
<template #icon="{ dataRef }">
|
|
||||||
<folder-outlined v-if="dataRef.children" />
|
|
||||||
<file-outlined v-else />
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="right-box">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.title" placeholder="请输入地区名称" allow-clear
|
|
||||||
style="width: 160px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-select v-model:value="searchForm.level" placeholder="地区级别" allow-clear style="width: 120px">
|
|
||||||
<a-select-option :value="1">省</a-select-option>
|
|
||||||
<a-select-option :value="2">市</a-select-option>
|
|
||||||
<a-select-option :value="3">区/县</a-select-option>
|
|
||||||
<a-select-option :value="4">街道</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
新增
|
|
||||||
</a-button>
|
|
||||||
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
批量删除
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
|
||||||
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
|
||||||
:row-selection="rowSelection">
|
|
||||||
<template #level="{ record }">
|
|
||||||
<a-tag color="blue">{{ getLevelText(record.level) }}</a-tag>
|
|
||||||
</template>
|
|
||||||
<template #status="{ record }">
|
|
||||||
<a-tag :color="record.status === 1 ? 'success' : 'error'">
|
|
||||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
|
||||||
</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-popconfirm title="确定删除该地区吗?" @confirm="handleDelete(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 新增/编辑地区弹窗 -->
|
|
||||||
<area-modal v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined, FolderOutlined, FileOutlined } from '@ant-design/icons-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import areaModal from './components/AreaModal.vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
import { useTable } from '@/hooks/useTable'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'systemArea'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
save: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const saveDialogRef = ref(null)
|
|
||||||
|
|
||||||
// 地区树数据
|
|
||||||
const areaTree = ref([])
|
|
||||||
const filteredAreaTree = ref([])
|
|
||||||
const selectedAreaKeys = ref([])
|
|
||||||
const areaKeyword = ref('')
|
|
||||||
|
|
||||||
// 使用useTable hooks
|
|
||||||
const {
|
|
||||||
tableRef,
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
selectedRowKeys,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
loadData
|
|
||||||
} = useTable({
|
|
||||||
api: systemApi.area.list.get,
|
|
||||||
searchParams: {
|
|
||||||
title: '',
|
|
||||||
level: null,
|
|
||||||
parent_code: null
|
|
||||||
},
|
|
||||||
rowSelection: true,
|
|
||||||
onSearchBefore: () => {
|
|
||||||
// 重置地区选择
|
|
||||||
selectedAreaKeys.value = []
|
|
||||||
},
|
|
||||||
onResetBefore: () => {
|
|
||||||
// 重置地区选择和搜索
|
|
||||||
selectedAreaKeys.value = []
|
|
||||||
areaKeyword.value = ''
|
|
||||||
filteredAreaTree.value = areaTree.value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 分页变化处理
|
|
||||||
const handlePaginationChange = ({ page, pageSize }) => {
|
|
||||||
pagination.current = page
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 获取级别文本
|
|
||||||
const getLevelText = (level) => {
|
|
||||||
const levelMap = {
|
|
||||||
1: '省',
|
|
||||||
2: '市',
|
|
||||||
3: '区/县',
|
|
||||||
4: '街道'
|
|
||||||
}
|
|
||||||
return levelMap[level] || '未知'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: '地区名称', dataIndex: 'title', key: 'title', width: 200 },
|
|
||||||
{ title: '地区编码', dataIndex: 'code', key: 'code', width: 150 },
|
|
||||||
{ title: '地区级别', dataIndex: 'level', key: 'level', width: 100, slot: 'level' },
|
|
||||||
{ title: '上级地区', dataIndex: 'parent_code', key: 'parent_code', width: 150 },
|
|
||||||
{ title: '状态', dataIndex: 'status', key: 'status', width: 100, align: 'center', slot: 'status' },
|
|
||||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 200, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 加载地区树
|
|
||||||
const loadAreaTree = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.area.list.get({ pageSize: 1000 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
const list = res.data.list || res.data || []
|
|
||||||
const tree = buildTree(list)
|
|
||||||
areaTree.value = tree
|
|
||||||
filteredAreaTree.value = tree
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载地区树失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建树结构
|
|
||||||
const buildTree = (list) => {
|
|
||||||
const map = {}
|
|
||||||
const roots = []
|
|
||||||
|
|
||||||
// 创建映射,以 code 作为键
|
|
||||||
list.forEach((item) => {
|
|
||||||
map[item.code] = { ...item, children: [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 构建树,通过 parent_code 关联
|
|
||||||
list.forEach((item) => {
|
|
||||||
if (item.parent_code && map[item.parent_code]) {
|
|
||||||
map[item.parent_code].children.push(map[item.code])
|
|
||||||
} else if (item.parent_code === '0' || !item.parent_code) {
|
|
||||||
roots.push(map[item.code])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return roots
|
|
||||||
}
|
|
||||||
|
|
||||||
// 地区搜索
|
|
||||||
const handleAreaSearch = (e) => {
|
|
||||||
const keyword = e.target?.value || ''
|
|
||||||
areaKeyword.value = keyword
|
|
||||||
if (!keyword) {
|
|
||||||
filteredAreaTree.value = areaTree.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归过滤地区树
|
|
||||||
const filterTree = (nodes) => {
|
|
||||||
return nodes.reduce((acc, node) => {
|
|
||||||
const isMatch = node.title && node.title.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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredAreaTree.value = filterTree(areaTree.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// 地区选择事件
|
|
||||||
const onAreaSelect = (selectedKeys) => {
|
|
||||||
if (selectedKeys && selectedKeys.length > 0) {
|
|
||||||
searchForm.parent_code = selectedKeys[0]
|
|
||||||
} else {
|
|
||||||
searchForm.parent_code = null
|
|
||||||
}
|
|
||||||
handleSearch()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增地区
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('add')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看地区
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('show').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑地区
|
|
||||||
const handleEdit = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除地区
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定要删除地区 "${record.title}" 吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
// 注意:API 中没有删除接口,这里模拟删除成功
|
|
||||||
message.success('删除成功')
|
|
||||||
loadData()
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除地区失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
const handleBatchDelete = () => {
|
|
||||||
if (selectedRowKeys.value.length === 0) {
|
|
||||||
message.warning('请先选择要删除的数据')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认批量删除',
|
|
||||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条数据吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
// 注意:API 中没有批量删除接口,这里模拟删除成功
|
|
||||||
message.success('删除成功')
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
loadData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功回调
|
|
||||||
const handleSaveSuccess = (data, mode) => {
|
|
||||||
if (mode === 'add') {
|
|
||||||
loadAreaTree()
|
|
||||||
loadData()
|
|
||||||
} else if (mode === 'edit') {
|
|
||||||
loadAreaTree()
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
loadAreaTree()
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.area-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.left-box {
|
|
||||||
width: 260px;
|
|
||||||
border-right: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
font-size: 14px;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-box {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages client-page">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
新增
|
|
||||||
</a-button>
|
|
||||||
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
批量删除
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.title" placeholder="请输入名称" allow-clear style="width: 200px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
|
||||||
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
|
||||||
:row-selection="rowSelection">
|
|
||||||
<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="handleMenu(record)">菜单</a-button>
|
|
||||||
<a-popconfirm title="确定删除吗?" @confirm="handleDelete(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 新增/编辑客户端弹窗 -->
|
|
||||||
<save-dialog v-if="dialog.save" ref="saveDialogRef" @success="handleSaveSuccess" @closed="dialog.save = false" />
|
|
||||||
|
|
||||||
<!-- 菜单管理抽屉 -->
|
|
||||||
<menu-drawer v-if="dialog.menu" ref="menuDrawerRef" @closed="dialog.menu = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import saveDialog from './save.vue'
|
|
||||||
import menuDrawer from './menu.vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
import { useTable } from '@/hooks/useTable'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'systemClient'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
save: false,
|
|
||||||
menu: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const saveDialogRef = ref(null)
|
|
||||||
const menuDrawerRef = ref(null)
|
|
||||||
|
|
||||||
// 使用useTable hooks
|
|
||||||
const {
|
|
||||||
tableRef,
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
pagination,
|
|
||||||
rowSelection,
|
|
||||||
selectedRowKeys,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
loadData
|
|
||||||
} = useTable({
|
|
||||||
api: systemApi.client.list.get,
|
|
||||||
searchParams: {
|
|
||||||
title: ''
|
|
||||||
},
|
|
||||||
rowSelection: true
|
|
||||||
})
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
|
||||||
{ title: '名称', dataIndex: 'title', key: 'title', width: 200 },
|
|
||||||
{ title: '客户端ID', dataIndex: 'app_id', key: 'app_id', width: 200 },
|
|
||||||
{ title: '客户端Secret', dataIndex: 'secret', key: 'secret', width: 300 },
|
|
||||||
{ title: '添加时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
|
||||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 220, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 分页变化处理
|
|
||||||
const handlePaginationChange = ({ page, pageSize }) => {
|
|
||||||
pagination.current = page
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增客户端
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('add').setData({})
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看客户端
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('show').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑客户端
|
|
||||||
const handleEdit = (record) => {
|
|
||||||
dialog.save = true
|
|
||||||
setTimeout(() => {
|
|
||||||
saveDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单管理
|
|
||||||
const handleMenu = (record) => {
|
|
||||||
dialog.menu = true
|
|
||||||
setTimeout(() => {
|
|
||||||
menuDrawerRef.value?.open().setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除客户端
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.client.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
loadData()
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除客户端失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
const handleBatchDelete = () => {
|
|
||||||
if (selectedRowKeys.value.length === 0) {
|
|
||||||
message.warning('请先选择要删除的数据')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认批量删除',
|
|
||||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 项吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const promises = selectedRowKeys.value.map(id => systemApi.client.delete.post({ id }))
|
|
||||||
await Promise.all(promises)
|
|
||||||
message.success('操作成功')
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
loadData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存成功回调
|
|
||||||
const handleSaveSuccess = () => {
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
loadData()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.client-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.tool-bar {
|
|
||||||
padding: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.left-panel {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-drawer :title="detail.title + '菜单'" :open="visible" width="80%" :destroy-on-close="true"
|
|
||||||
:mask-closable="false" @close="handleClose">
|
|
||||||
<div class="menu-drawer">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-button type="primary" @click="handleAdd">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
新增
|
|
||||||
</a-button>
|
|
||||||
<a-button danger @click="handleBatchDelete" :disabled="!selectedRowKeys.length">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
批量删除
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.title" placeholder="请输入名称" allow-clear
|
|
||||||
style="width: 200px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
|
||||||
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
|
||||||
:row-selection="rowSelection">
|
|
||||||
<template #client="{ record }">
|
|
||||||
{{ record.client?.title || '-' }}
|
|
||||||
</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-popconfirm title="确定删除吗?" @confirm="handleDelete(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-drawer>
|
|
||||||
|
|
||||||
<!-- 菜单表单弹窗 -->
|
|
||||||
<menu-form-dialog v-if="dialog.form" ref="formDialogRef" @success="handleFormSuccess"
|
|
||||||
@closed="dialog.form = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import { SearchOutlined, RedoOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import menuFormDialog from './menuform.vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
const emit = defineEmits(['closed'])
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const detail = ref({})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
form: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const formDialogRef = ref(null)
|
|
||||||
|
|
||||||
// 选中的行
|
|
||||||
const selectedRowKeys = ref([])
|
|
||||||
|
|
||||||
// 行选择配置
|
|
||||||
const rowSelection = computed(() => ({
|
|
||||||
selectedRowKeys: selectedRowKeys.value,
|
|
||||||
onChange: (selectedKeys) => {
|
|
||||||
selectedRowKeys.value = selectedKeys
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 搜索表单
|
|
||||||
const searchForm = reactive({
|
|
||||||
title: '',
|
|
||||||
client_id: null
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表格数据
|
|
||||||
const tableData = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 分页配置
|
|
||||||
const pagination = reactive({
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
|
||||||
pageSizeOptions: ['20', '50', '100', '200']
|
|
||||||
})
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
|
||||||
{ title: '名称', dataIndex: 'title', key: 'title', width: 200 },
|
|
||||||
{ title: '所属客户端', dataIndex: 'client', key: 'client', width: 180, slot: 'client' },
|
|
||||||
{ title: '链接', dataIndex: 'url', key: 'url', width: 260 },
|
|
||||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
|
|
||||||
{ title: '添加时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
|
||||||
{ title: '更新时间', dataIndex: 'updated_at', key: 'updated_at', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 180, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 显示抽屉
|
|
||||||
const open = () => {
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭抽屉
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理关闭
|
|
||||||
const handleClose = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置数据
|
|
||||||
const setData = (data) => {
|
|
||||||
detail.value = data
|
|
||||||
searchForm.client_id = data.id
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载菜单列表数据
|
|
||||||
const loadData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
page: pagination.current,
|
|
||||||
limit: pagination.pageSize,
|
|
||||||
client_id: searchForm.client_id,
|
|
||||||
title: searchForm.title,
|
|
||||||
is_tree: 0
|
|
||||||
}
|
|
||||||
const res = await systemApi.client.menu.list.get(params)
|
|
||||||
if (res.code === 1) {
|
|
||||||
tableData.value = res.data?.data || []
|
|
||||||
pagination.total = res.data?.total || 0
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '加载数据失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载菜单列表失败:', error)
|
|
||||||
message.error('加载数据失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页变化处理
|
|
||||||
const handlePaginationChange = ({ page, pageSize }) => {
|
|
||||||
pagination.current = page
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 搜索
|
|
||||||
const handleSearch = () => {
|
|
||||||
pagination.current = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置
|
|
||||||
const handleReset = () => {
|
|
||||||
searchForm.title = ''
|
|
||||||
pagination.current = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增菜单
|
|
||||||
const handleAdd = () => {
|
|
||||||
dialog.form = true
|
|
||||||
setTimeout(() => {
|
|
||||||
formDialogRef.value?.open('add').setData({ client_id: detail.value.id })
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看菜单
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.form = true
|
|
||||||
setTimeout(() => {
|
|
||||||
formDialogRef.value?.open('show').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑菜单
|
|
||||||
const handleEdit = (record) => {
|
|
||||||
dialog.form = true
|
|
||||||
setTimeout(() => {
|
|
||||||
formDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除菜单
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.client.menu.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
loadData()
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除菜单失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除
|
|
||||||
const handleBatchDelete = () => {
|
|
||||||
if (selectedRowKeys.value.length === 0) {
|
|
||||||
message.warning('请先选择要删除的数据')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认批量删除',
|
|
||||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 项吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const promises = selectedRowKeys.value.map(id => systemApi.client.menu.delete.post({ id }))
|
|
||||||
await Promise.all(promises)
|
|
||||||
message.success('操作成功')
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
loadData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单成功回调
|
|
||||||
const handleFormSuccess = () => {
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.menu-drawer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.tool-bar {
|
|
||||||
padding: 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
.left-panel {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="600" :destroy-on-close="true" :mask-closable="false"
|
|
||||||
: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="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="请输入名称" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="上级菜单" name="parent_id">
|
|
||||||
<a-tree-select v-model:value="form.parent_id" :tree-data="menuTreeData"
|
|
||||||
:field-names="{ label: 'title', value: 'id', children: 'children' }" tree-default-expand-all
|
|
||||||
show-search placeholder="请选择上级菜单" allow-clear tree-node-filter-prop="title" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="链接" name="url">
|
|
||||||
<a-input v-model:value="form.url" placeholder="请输入链接" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="所属位置" name="position">
|
|
||||||
<a-select v-model:value="form.position" placeholder="请选择所属位置" allow-clear>
|
|
||||||
<a-select-option value="top">顶部</a-select-option>
|
|
||||||
<a-select-option value="bottom">底部</a-select-option>
|
|
||||||
<a-select-option value="left">左侧</a-select-option>
|
|
||||||
<a-select-option value="right">右侧</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="图标" name="icon">
|
|
||||||
<a-input v-model:value="form.icon" placeholder="请输入图标" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="排序" name="sort">
|
|
||||||
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" placeholder="请输入排序" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="是否显示" name="is_show">
|
|
||||||
<a-switch v-model:checked="form.is_show" :checked-value="1" :un-checked-value="0" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="是否新窗口" name="is_blank">
|
|
||||||
<a-switch v-model:checked="form.is_blank" :checked-value="1" :un-checked-value="0" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="状态" name="status">
|
|
||||||
<a-switch v-model:checked="form.status" :checked-value="1" :un-checked-value="0" />
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '添加',
|
|
||||||
edit: '编辑',
|
|
||||||
show: '查看'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: null,
|
|
||||||
title: '',
|
|
||||||
parent_id: null,
|
|
||||||
url: '',
|
|
||||||
position: 'top',
|
|
||||||
icon: '',
|
|
||||||
sort: 0,
|
|
||||||
is_show: 1,
|
|
||||||
is_blank: 0,
|
|
||||||
status: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
title: [
|
|
||||||
{ required: true, message: '请输入名称', trigger: 'blur' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 菜单树数据
|
|
||||||
const menuTreeData = ref([])
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
loadMenuTree()
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载菜单树数据
|
|
||||||
const loadMenuTree = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.client.menu.list.get({ is_tree: 1 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
const list = res.data || []
|
|
||||||
menuTreeData.value = buildTree(list)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载菜单树失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建树结构
|
|
||||||
const buildTree = (list) => {
|
|
||||||
const map = {}
|
|
||||||
const roots = []
|
|
||||||
|
|
||||||
// 创建映射,以 id 作为键
|
|
||||||
list.forEach((item) => {
|
|
||||||
map[item.id] = { ...item, children: [] }
|
|
||||||
})
|
|
||||||
|
|
||||||
// 构建树,通过 parent_id 关联
|
|
||||||
list.forEach((item) => {
|
|
||||||
if (item.parent_id && map[item.parent_id]) {
|
|
||||||
map[item.parent_id].children.push(map[item.id])
|
|
||||||
} else if (!item.parent_id || item.parent_id === 0) {
|
|
||||||
roots.push(map[item.id])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return roots
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaving.value = true
|
|
||||||
let res = {}
|
|
||||||
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await systemApi.client.menu.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await systemApi.client.menu.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaving.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '操作失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isSaving.value = false
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
if (mode.value === 'edit' || mode.value === 'show') {
|
|
||||||
form.id = data.id
|
|
||||||
form.title = data.title || ''
|
|
||||||
form.parent_id = data.parent_id
|
|
||||||
form.url = data.url || ''
|
|
||||||
form.position = data.position || 'top'
|
|
||||||
form.icon = data.icon || ''
|
|
||||||
form.sort = data.sort || 0
|
|
||||||
form.is_show = data.is_show ?? 1
|
|
||||||
form.is_blank = data.is_blank ?? 0
|
|
||||||
form.status = data.status ?? 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
// 弹窗样式可根据需要添加
|
|
||||||
</style>
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="500" :destroy-on-close="true" :mask-closable="false"
|
|
||||||
: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="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="请输入客户端名称" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="客户端ID" name="app_id">
|
|
||||||
<a-input v-model:value="form.app_id" placeholder="客户端APPID" allow-clear :disabled="mode !== 'add'" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="客户端密匙" name="secret">
|
|
||||||
<a-input v-model:value="form.secret" placeholder="客户端密匙" allow-clear :disabled="mode !== 'add'" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="状态" name="status">
|
|
||||||
<a-radio-group v-model:value="form.status">
|
|
||||||
<a-radio :value="1">正常</a-radio>
|
|
||||||
<a-radio :value="0">禁用</a-radio>
|
|
||||||
</a-radio-group>
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '添加',
|
|
||||||
edit: '编辑',
|
|
||||||
show: '查看'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: null,
|
|
||||||
title: '',
|
|
||||||
app_id: '',
|
|
||||||
secret: '',
|
|
||||||
status: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
title: [
|
|
||||||
{ required: true, message: '请输入客户端名称', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
app_id: [
|
|
||||||
{ required: true, message: '请输入客户端ID', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
secret: [
|
|
||||||
{ required: true, message: '请输入客户端密匙', trigger: 'blur' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaveing.value = true
|
|
||||||
let res = {}
|
|
||||||
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await systemApi.client.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await systemApi.client.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '操作失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isSaveing.value = false
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
if (mode.value === 'edit') {
|
|
||||||
form.id = data.id
|
|
||||||
form.title = data.title || ''
|
|
||||||
form.app_id = data.app_id || ''
|
|
||||||
form.secret = data.secret || ''
|
|
||||||
form.status = data.status ?? 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
// 弹窗样式可根据需要添加
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineOptions({
|
|
||||||
name: "systemCrontab"
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<!--
|
|
||||||
* @Descripttion: 系统计划任务配置
|
|
||||||
* @version: 1.0
|
|
||||||
* @Author: sakuya
|
|
||||||
* @Date: 2021年7月7日09:28:32
|
|
||||||
* @LastEditors:
|
|
||||||
* @LastEditTime:
|
|
||||||
-->
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<el-drawer title="计划任务日志" v-model="Visible" :size="780" direction="rtl" destroy-on-close>
|
|
||||||
<el-container>
|
|
||||||
<el-main style="padding:0 20px;">
|
|
||||||
<scTable ref="table" :apiObj="list.apiObj" :column="list.column" :params="search" stripe>
|
|
||||||
<el-table-column type="selection" width="50"></el-table-column>
|
|
||||||
<template #return_code="scope">
|
|
||||||
<span v-if="scope.row.return_code==0" style="color: #67C23A;"><el-icon><el-icon-success-filled /></el-icon></span>
|
|
||||||
<span v-else style="color: #F56C6C;"><el-icon><el-icon-circle-close-filled /></el-icon></span>
|
|
||||||
</template>
|
|
||||||
<template #logs="scope">
|
|
||||||
<el-button @click="show(scope.row)" type="text">日志</el-button>
|
|
||||||
</template>
|
|
||||||
<template #create_time="scope">
|
|
||||||
<div v-time="scope.row.create_time"></div>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</el-main>
|
|
||||||
</el-container>
|
|
||||||
|
|
||||||
<el-drawer title="日志" v-model="logsVisible" :size="500" direction="rtl" destroy-on-close>
|
|
||||||
<el-main style="padding:0 20px 20px 20px;">
|
|
||||||
<pre style="font-size: 12px;color: #999;padding:20px;background: #333;font-family: consolas;line-height: 1.5;overflow: auto;">{{logDetail}}</pre>
|
|
||||||
</el-main>
|
|
||||||
</el-drawer>
|
|
||||||
</el-drawer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
Visible: false,
|
|
||||||
logsVisible: false,
|
|
||||||
crontab: {},
|
|
||||||
logDetail: '',
|
|
||||||
list: {
|
|
||||||
apiObj: this.$API.system.crontab.log,
|
|
||||||
column: [
|
|
||||||
{ label: '执行时间', prop: 'running_time', width: 100 },
|
|
||||||
{ label: '执行结果', prop: 'return_code', width: 100, align: 'center' },
|
|
||||||
{ label: '参数', prop: 'parameter', width: 100, align: 'center' },
|
|
||||||
{ label: '时间', prop: 'create_time', width: 200 },
|
|
||||||
{ label: '执行日志', prop: 'logs', align: 'center' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
search: {crontab_id: 0}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
open() {
|
|
||||||
this.Visible = true
|
|
||||||
return this
|
|
||||||
},
|
|
||||||
show(item){
|
|
||||||
this.logDetail = item.exception
|
|
||||||
this.logsVisible = true;
|
|
||||||
},
|
|
||||||
setData(row){
|
|
||||||
this.crontab = row
|
|
||||||
this.search.crontab_id = row.id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<template>
|
|
||||||
<el-dialog :title="titleMap[mode]" v-model="visible" :width="400" destroy-on-close @closed="$emit('closed')">
|
|
||||||
<el-form :model="form" :rules="rules" ref="dialogForm" label-width="100px" label-position="left">
|
|
||||||
<el-form-item label="描述" prop="title">
|
|
||||||
<el-input v-model="form.title" placeholder="计划任务标题" clearable></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="任务分类" prop="type">
|
|
||||||
<el-select v-model="form.type" placeholder="计划任务执行类名称" clearable>
|
|
||||||
<el-option v-for="item in [{value: 1, label: '执行命令'}, {value: 2, label: '执行类class'}, {value: 3, label: '执行地址'}, {value: 4, label: '执行shell'}]" :key="item.value" :label="item.label" :value="item.value" ></el-option>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="执行类" prop="command">
|
|
||||||
<el-input v-model="form.command" placeholder="计划任务执行类名称" clearable></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="定时规则" prop="expression">
|
|
||||||
<sc-cron v-model="form.expression" placeholder="请输入Cron定时规则" clearable :shortcuts="shortcuts"></sc-cron>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="是否启用" prop="status">
|
|
||||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="备注" prop="remark">
|
|
||||||
<el-input v-model="form.remark" placeholder="备注" clearable></el-input>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
<template #footer>
|
|
||||||
<el-button @click="visible=false" >取 消</el-button>
|
|
||||||
<el-button type="primary" :loading="isSaveing" @click="submit()">保 存</el-button>
|
|
||||||
</template>
|
|
||||||
</el-dialog>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import scCron from '@/components/scCron';
|
|
||||||
|
|
||||||
export default {
|
|
||||||
components: {
|
|
||||||
scCron
|
|
||||||
},
|
|
||||||
emits: ['success', 'closed'],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
mode: "add",
|
|
||||||
titleMap: {
|
|
||||||
add: '新增计划任务',
|
|
||||||
edit: '编辑计划任务'
|
|
||||||
},
|
|
||||||
form: {
|
|
||||||
id:"",
|
|
||||||
title: "",
|
|
||||||
type: 1,
|
|
||||||
command: "",
|
|
||||||
expression: "",
|
|
||||||
status: 1,
|
|
||||||
remark: ""
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
title:[{required: true, message: '请填写标题'}],
|
|
||||||
command:[{required: true, message: '请填写执行类'}],
|
|
||||||
expression:[{required: true, message: '请填写定时规则'}]
|
|
||||||
},
|
|
||||||
visible: false,
|
|
||||||
isSaveing: false,
|
|
||||||
shortcuts: [
|
|
||||||
{
|
|
||||||
text: "每天8点和12点 (自定义追加)",
|
|
||||||
value: "0 0 8,12 * * ?"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mounted() {
|
|
||||||
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
//显示
|
|
||||||
open(mode='add'){
|
|
||||||
this.mode = mode;
|
|
||||||
this.visible = true;
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
//表单提交方法
|
|
||||||
submit(){
|
|
||||||
this.$refs.dialogForm.validate(async (valid) => {
|
|
||||||
if (valid) {
|
|
||||||
let res = await this.$API.system.crontab[this.mode].post(this.form)
|
|
||||||
|
|
||||||
if(res.code == 1){
|
|
||||||
this.isSaveing = false;
|
|
||||||
this.visible = false;
|
|
||||||
this.$message.success("操作成功")
|
|
||||||
this.$emit('success', this.form, this.mode)
|
|
||||||
}else{
|
|
||||||
this.$message.error(res.message)
|
|
||||||
this.isSaveing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
//表单注入数据
|
|
||||||
setData(data){
|
|
||||||
this.form.id = data.id ?? 0
|
|
||||||
this.form.title = data.title ?? ""
|
|
||||||
this.form.type = data.type ?? 1
|
|
||||||
this.form.command = data.command ?? ""
|
|
||||||
this.form.expression = data.expression ?? ""
|
|
||||||
this.form.status = data.status ?? 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="400" :destroy-on-close="true" :mask-closable="false"
|
|
||||||
:footer="null" @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-form-item>
|
|
||||||
<a-form-item label="字典名称" name="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="字典显示名称" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="父路径" name="parent_id">
|
|
||||||
<a-tree-select v-model:value="form.parent_id" :tree-data="dicData"
|
|
||||||
:field-names="{ label: 'title', value: 'id', children: 'children' }" tree-default-expand-all
|
|
||||||
show-search placeholder="请选择父路径" allow-clear tree-node-filter-prop="title" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="handleCancel">取 消</a-button>
|
|
||||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '新增字典分类',
|
|
||||||
edit: '编辑字典分类'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: '',
|
|
||||||
title: '',
|
|
||||||
name: '',
|
|
||||||
parent_id: null
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
name: [
|
|
||||||
{ required: true, message: '请输入编码', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
title: [
|
|
||||||
{ required: true, message: '请输入字典名称', trigger: 'blur' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字典树数据
|
|
||||||
const dicData = ref([])
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
loadDic()
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载字典列表
|
|
||||||
const loadDic = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.dictionary.category.get({ is_tree: 1 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
dicData.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载字典列表失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaveing.value = true
|
|
||||||
let res = {}
|
|
||||||
form.parent_id = form.parent_id || 0
|
|
||||||
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await systemApi.dictionary.addcate.post(form)
|
|
||||||
} else {
|
|
||||||
res = await systemApi.dictionary.editcate.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '操作失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isSaveing.value = false
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
form.id = data.id
|
|
||||||
form.title = data.title
|
|
||||||
form.name = data.name
|
|
||||||
form.parent_id = data.parent_id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
// 弹窗样式可根据需要添加
|
|
||||||
</style>
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages dic-page">
|
|
||||||
<div class="left-box">
|
|
||||||
<div class="header">
|
|
||||||
<a-input v-model:value="dicFilterText" placeholder="搜索字典..." allow-clear @change="handleDicSearch">
|
|
||||||
<template #prefix>
|
|
||||||
<search-outlined style="color: rgba(0, 0, 0, 0.45)" />
|
|
||||||
</template>
|
|
||||||
</a-input>
|
|
||||||
</div>
|
|
||||||
<div class="body">
|
|
||||||
<a-tree v-model:selectedKeys="selectedDicKeys" :tree-data="filteredDicList"
|
|
||||||
:field-names="{ title: 'title', key: 'id', children: 'children' }" show-line @select="onDicSelect">
|
|
||||||
<template #title="{ title, name }">
|
|
||||||
<div class="custom-tree-node">
|
|
||||||
<span class="label">{{ title }}</span>
|
|
||||||
<span class="code">{{ name }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #icon="{ dataRef }">
|
|
||||||
<folder-outlined v-if="dataRef.children" />
|
|
||||||
<file-outlined v-else />
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<a-button type="primary" @click="addDic" block>
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
字典分类
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="right-box">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-button type="primary" @click="addInfo">
|
|
||||||
<template #icon><plus-outlined /></template>
|
|
||||||
新增项
|
|
||||||
</a-button>
|
|
||||||
<a-button danger @click="batchDel" :disabled="!selectedRowKeys.length">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
批量删除
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
|
||||||
:pagination="pagination" :row-key="rowKey" @refresh="loadData" @paginationChange="handlePaginationChange"
|
|
||||||
:row-selection="rowSelection">
|
|
||||||
<template #status="{ record }">
|
|
||||||
<a-tag :color="record.status === 1 ? 'success' : 'default'">
|
|
||||||
{{ record.status === 1 ? '是' : '否' }}
|
|
||||||
</a-tag>
|
|
||||||
</template>
|
|
||||||
<template #action="{ record }">
|
|
||||||
<a-space>
|
|
||||||
<a-button type="link" size="small" @click="tableEdit(record)">编辑</a-button>
|
|
||||||
<a-popconfirm title="确定删除吗?" @confirm="tableDel(record)">
|
|
||||||
<a-button type="link" size="small" danger>删除</a-button>
|
|
||||||
</a-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 字典分类弹窗 -->
|
|
||||||
<dic-dialog v-if="dialog.dic" ref="dicDialogRef" @success="handleDicSuccess" @closed="dialog.dic = false" />
|
|
||||||
|
|
||||||
<!-- 字典明细弹窗 -->
|
|
||||||
<list-dialog v-if="dialog.list" ref="listDialogRef" @success="handleListSuccess" @closed="dialog.list = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import dicDialog from './dic.vue'
|
|
||||||
import listDialog from './list.vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'systemDic'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表格引用
|
|
||||||
const tableRef = ref(null)
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
dic: false,
|
|
||||||
list: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const dicDialogRef = ref(null)
|
|
||||||
const listDialogRef = ref(null)
|
|
||||||
|
|
||||||
// 字典树数据
|
|
||||||
const dicList = ref([])
|
|
||||||
const filteredDicList = ref([])
|
|
||||||
const selectedDicKeys = ref([])
|
|
||||||
const dicFilterText = ref('')
|
|
||||||
|
|
||||||
// 选中的行
|
|
||||||
const selectedRowKeys = ref([])
|
|
||||||
|
|
||||||
// 行选择配置
|
|
||||||
const rowSelection = computed(() => ({
|
|
||||||
selectedRowKeys: selectedRowKeys.value,
|
|
||||||
onChange: (selectedKeys) => {
|
|
||||||
selectedRowKeys.value = selectedKeys
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 当前选中的字典
|
|
||||||
const currentDic = ref(null)
|
|
||||||
|
|
||||||
// 表格数据
|
|
||||||
const tableData = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 分页配置
|
|
||||||
const pagination = reactive({
|
|
||||||
current: 1,
|
|
||||||
pageSize: 20,
|
|
||||||
total: 0,
|
|
||||||
showSizeChanger: true,
|
|
||||||
showTotal: (total) => `共 ${total} 条`,
|
|
||||||
pageSizeOptions: ['20', '50', '100', '200']
|
|
||||||
})
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: '名称', dataIndex: 'title', key: 'title', width: 200 },
|
|
||||||
{ title: '键值', dataIndex: 'values', key: 'values', width: 150 },
|
|
||||||
{ title: '是否有效', dataIndex: 'status', key: 'status', width: 100, slot: 'status' },
|
|
||||||
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 100 },
|
|
||||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 150, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
// 加载字典树
|
|
||||||
const loadDicTree = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.dictionary.category.get({ is_tree: 1 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
dicList.value = res.data || []
|
|
||||||
filteredDicList.value = res.data || []
|
|
||||||
|
|
||||||
// 获取第一个节点,设置选中 & 加载明细列表
|
|
||||||
const firstNode = dicList.value[0]
|
|
||||||
if (firstNode) {
|
|
||||||
selectedDicKeys.value = [firstNode.id]
|
|
||||||
currentDic.value = firstNode
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载字典树失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字典搜索
|
|
||||||
const handleDicSearch = (e) => {
|
|
||||||
const keyword = e.target?.value || ''
|
|
||||||
dicFilterText.value = keyword
|
|
||||||
if (!keyword) {
|
|
||||||
filteredDicList.value = dicList.value
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 递归过滤字典树
|
|
||||||
const filterTree = (nodes) => {
|
|
||||||
return nodes.reduce((acc, node) => {
|
|
||||||
const targetText = (node.title || '') + (node.name || '')
|
|
||||||
const isMatch = targetText.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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredDicList.value = filterTree(dicList.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载字典明细列表数据
|
|
||||||
const loadData = async () => {
|
|
||||||
if (!currentDic.value) return
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const params = {
|
|
||||||
page: pagination.current,
|
|
||||||
limit: pagination.pageSize,
|
|
||||||
group_id: currentDic.value.id
|
|
||||||
}
|
|
||||||
const res = await systemApi.dictionary.list.get(params)
|
|
||||||
if (res.code === 1) {
|
|
||||||
tableData.value = res.data?.data || []
|
|
||||||
pagination.total = res.data?.total || 0
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '加载数据失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载字典明细列表失败:', error)
|
|
||||||
message.error('加载数据失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 分页变化处理
|
|
||||||
const handlePaginationChange = ({ page, pageSize }) => {
|
|
||||||
pagination.current = page
|
|
||||||
pagination.pageSize = pageSize
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字典选择事件
|
|
||||||
const onDicSelect = (selectedKeys) => {
|
|
||||||
if (selectedKeys && selectedKeys.length > 0) {
|
|
||||||
const selectedId = selectedKeys[0]
|
|
||||||
currentDic.value = findDicById(filteredDicList.value, selectedId)
|
|
||||||
pagination.current = 1
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据ID查找字典
|
|
||||||
const findDicById = (nodes, id) => {
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (node.id === id) return node
|
|
||||||
if (node.children) {
|
|
||||||
const found = findDicById(node.children, id)
|
|
||||||
if (found) return found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增字典分类
|
|
||||||
const addDic = () => {
|
|
||||||
dialog.dic = true
|
|
||||||
setTimeout(() => {
|
|
||||||
dicDialogRef.value?.open('add')
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑字典分类
|
|
||||||
const dicEdit = (data) => {
|
|
||||||
dialog.dic = true
|
|
||||||
setTimeout(() => {
|
|
||||||
dicDialogRef.value?.open('edit').setData(data)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除字典分类
|
|
||||||
const dicDel = (node, data) => {
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认删除',
|
|
||||||
content: `确定删除 ${data.name} 项吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
await systemApi.dictionary.delCate.post({ id: data.id })
|
|
||||||
message.success('操作成功')
|
|
||||||
loadDicTree()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除字典分类失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 新增字典明细
|
|
||||||
const addInfo = () => {
|
|
||||||
if (!currentDic.value) {
|
|
||||||
message.warning('请先选择字典分类')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
dialog.list = true
|
|
||||||
setTimeout(() => {
|
|
||||||
listDialogRef.value?.open('add').setData({ dic_type: currentDic.value.code })
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑字典明细
|
|
||||||
const tableEdit = (record) => {
|
|
||||||
dialog.list = true
|
|
||||||
setTimeout(() => {
|
|
||||||
listDialogRef.value?.open('edit').setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除字典明细
|
|
||||||
const tableDel = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.dictionary.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
loadData()
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除字典明细失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量删除字典明细
|
|
||||||
const batchDel = () => {
|
|
||||||
if (selectedRowKeys.value.length === 0) {
|
|
||||||
message.warning('请先选择要删除的数据')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认批量删除',
|
|
||||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 项吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
const promises = selectedRowKeys.value.map(id => systemApi.dictionary.delete.post({ id }))
|
|
||||||
await Promise.all(promises)
|
|
||||||
message.success('操作成功')
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
loadData()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('批量删除失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字典分类成功回调
|
|
||||||
const handleDicSuccess = (data, mode) => {
|
|
||||||
if (mode === 'add') {
|
|
||||||
loadDicTree()
|
|
||||||
} else if (mode === 'edit') {
|
|
||||||
loadDicTree()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字典明细成功回调
|
|
||||||
const handleListSuccess = () => {
|
|
||||||
loadData()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
loadDicTree()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.dic-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.left-box {
|
|
||||||
width: 300px;
|
|
||||||
border-right: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
font-size: 14px;
|
|
||||||
background: #fafafa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.body {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
:deep(.ant-tree) {
|
|
||||||
.ant-tree-treenode {
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
.code {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
.do {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
padding: 12px 16px;
|
|
||||||
border-top: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-box {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-tree-node {
|
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 14px;
|
|
||||||
padding-right: 24px;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
.code {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.do {
|
|
||||||
display: none;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
.ant-btn-link {
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :title="titleMap[mode]" :open="visible" :width="450" :destroy-on-close="true" :mask-closable="false"
|
|
||||||
:footer="null" @cancel="handleCancel">
|
|
||||||
<a-form :model="form" :rules="rules" ref="dialogForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
|
|
||||||
<a-form-item label="所属字典" name="group_id">
|
|
||||||
<a-tree-select v-model:value="form.group_id" :tree-data="dicData"
|
|
||||||
:field-names="{ label: 'title', value: 'id', children: 'children' }" tree-default-expand-all
|
|
||||||
show-search placeholder="请选择所属字典" allow-clear tree-node-filter-prop="title" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="项名称" name="title">
|
|
||||||
<a-input v-model:value="form.title" placeholder="请输入项名称" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="键值" name="values">
|
|
||||||
<a-input v-model:value="form.values" placeholder="请输入键值" allow-clear />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="排序" name="sort">
|
|
||||||
<a-input-number v-model:value="form.sort" :min="0" style="width: 100%" placeholder="请输入排序" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="是否有效" name="status">
|
|
||||||
<a-radio-group v-model:value="form.status">
|
|
||||||
<a-radio :value="1">是</a-radio>
|
|
||||||
<a-radio :value="0">否</a-radio>
|
|
||||||
</a-radio-group>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
<template #footer>
|
|
||||||
<a-button @click="handleCancel">取 消</a-button>
|
|
||||||
<a-button type="primary" :loading="isSaveing" @click="submit">保 存</a-button>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success', 'closed'])
|
|
||||||
|
|
||||||
const mode = ref('add')
|
|
||||||
const titleMap = {
|
|
||||||
add: '新增项',
|
|
||||||
edit: '编辑项'
|
|
||||||
}
|
|
||||||
const visible = ref(false)
|
|
||||||
const isSaveing = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const form = reactive({
|
|
||||||
id: '',
|
|
||||||
group_id: '',
|
|
||||||
title: '',
|
|
||||||
values: '',
|
|
||||||
sort: 0,
|
|
||||||
status: 1
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表单引用
|
|
||||||
const dialogForm = ref()
|
|
||||||
|
|
||||||
// 验证规则
|
|
||||||
const rules = {
|
|
||||||
group_id: [
|
|
||||||
{ required: true, message: '请选择所属字典', trigger: 'change' }
|
|
||||||
],
|
|
||||||
title: [
|
|
||||||
{ required: true, message: '请输入项名称', trigger: 'blur' }
|
|
||||||
],
|
|
||||||
values: [
|
|
||||||
{ required: true, message: '请输入键值', trigger: 'blur' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 字典树数据
|
|
||||||
const dicData = ref([])
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = (openMode = 'add') => {
|
|
||||||
mode.value = openMode
|
|
||||||
visible.value = true
|
|
||||||
loadDic()
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载字典列表
|
|
||||||
const loadDic = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.dictionary.category.get({ is_tree: 1 })
|
|
||||||
if (res.code === 1) {
|
|
||||||
dicData.value = res.data || []
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载字典列表失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单提交方法
|
|
||||||
const submit = async () => {
|
|
||||||
try {
|
|
||||||
await dialogForm.value.validate()
|
|
||||||
isSaveing.value = true
|
|
||||||
let res
|
|
||||||
|
|
||||||
if (mode.value === 'add') {
|
|
||||||
res = await systemApi.dictionary.add.post(form)
|
|
||||||
} else {
|
|
||||||
res = await systemApi.dictionary.edit.post(form)
|
|
||||||
}
|
|
||||||
|
|
||||||
isSaveing.value = false
|
|
||||||
if (res.code === 1) {
|
|
||||||
emit('success', form, mode.value)
|
|
||||||
visible.value = false
|
|
||||||
message.success('操作成功')
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '操作失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
isSaveing.value = false
|
|
||||||
console.error('表单验证失败', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单注入数据
|
|
||||||
const setData = (data) => {
|
|
||||||
if (data.dic_type) {
|
|
||||||
form.dic_type = data.dic_type
|
|
||||||
}
|
|
||||||
form.id = data.id
|
|
||||||
form.title = data.title
|
|
||||||
form.values = data.values
|
|
||||||
form.sort = data.sort || 0
|
|
||||||
form.status = data.status ?? 1
|
|
||||||
form.group_id = data.group_id
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
// 弹窗样式可根据需要添加
|
|
||||||
</style>
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages log-page">
|
|
||||||
<div class="left-box">
|
|
||||||
<a-card>
|
|
||||||
<a-tree v-model:selectedKeys="selectedKeys" show-line switcher-icon default-expand-all
|
|
||||||
:tree-data="treeData" @select="onSelect">
|
|
||||||
<template #icon="{ dataRef }">
|
|
||||||
<folder-outlined v-if="dataRef.children" />
|
|
||||||
<file-outlined v-else />
|
|
||||||
</template>
|
|
||||||
</a-tree>
|
|
||||||
</a-card>
|
|
||||||
</div>
|
|
||||||
<div class="right-box">
|
|
||||||
<div class="tool-bar">
|
|
||||||
<div class="left-panel">
|
|
||||||
<a-form layout="inline" :model="searchForm">
|
|
||||||
<a-form-item>
|
|
||||||
<a-input v-model:value="searchForm.title" placeholder="请输入请求名称" allow-clear
|
|
||||||
style="width: 160px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-range-picker v-model:value="searchForm.created_at" :placeholder="['开始时间', '结束时间']"
|
|
||||||
show-time format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" style="width: 340px" />
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item>
|
|
||||||
<a-space>
|
|
||||||
<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>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</div>
|
|
||||||
<div class="right-panel">
|
|
||||||
<a-button danger @click="handleClearLog" :disabled="!selectedRowKeys.length">
|
|
||||||
<template #icon><delete-outlined /></template>
|
|
||||||
清空日志
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-content">
|
|
||||||
<scTable ref="tableRef" :columns="columns" :data-source="tableData" :loading="loading"
|
|
||||||
:pagination="pagination" :row-key="rowKey" @refresh="refreshTable" @paginationChange="handlePaginationChange"
|
|
||||||
:row-selection="rowSelection">
|
|
||||||
<template #method="{ record }">
|
|
||||||
<a-tag :color="getMethodColor(record.method)">{{ record.method }}</a-tag>
|
|
||||||
</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-popconfirm>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</scTable>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 日志详情弹窗 -->
|
|
||||||
<log-info-dialog v-if="dialog.info" ref="infoDialogRef" @closed="dialog.info = false" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { message, Modal } from 'ant-design-vue'
|
|
||||||
import { SearchOutlined, RedoOutlined, DeleteOutlined, FolderOutlined, FileOutlined } from '@ant-design/icons-vue'
|
|
||||||
import scTable from '@/components/scTable/index.vue'
|
|
||||||
import logInfoDialog from './info.vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
import { useTable } from '@/hooks/useTable'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'systemLog'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 使用useTable hooks
|
|
||||||
const {
|
|
||||||
tableRef,
|
|
||||||
searchForm,
|
|
||||||
tableData,
|
|
||||||
loading,
|
|
||||||
pagination,
|
|
||||||
selectedRowKeys,
|
|
||||||
rowSelection,
|
|
||||||
handleSearch,
|
|
||||||
handleReset,
|
|
||||||
handlePaginationChange,
|
|
||||||
refreshTable
|
|
||||||
} = useTable({
|
|
||||||
api: systemApi.log.list.get,
|
|
||||||
searchForm: {
|
|
||||||
title: '',
|
|
||||||
method: null,
|
|
||||||
status: null,
|
|
||||||
created_at: []
|
|
||||||
},
|
|
||||||
columns: [],
|
|
||||||
needPagination: true,
|
|
||||||
needSelection: true,
|
|
||||||
immediateLoad: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 对话框状态
|
|
||||||
const dialog = reactive({
|
|
||||||
info: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 弹窗引用
|
|
||||||
const infoDialogRef = ref(null)
|
|
||||||
|
|
||||||
|
|
||||||
// 树数据
|
|
||||||
const treeData = ref([
|
|
||||||
{
|
|
||||||
title: '请求分类',
|
|
||||||
key: 'request',
|
|
||||||
children: [
|
|
||||||
{ title: 'GET请求', key: 'get' },
|
|
||||||
{ title: 'POST请求', key: 'post' },
|
|
||||||
{ title: 'PUT请求', key: 'put' },
|
|
||||||
{ title: 'DELETE请求', key: 'delete' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '响应状态',
|
|
||||||
key: 'response',
|
|
||||||
children: [
|
|
||||||
{ title: '成功 (2xx)', key: 'success' },
|
|
||||||
{ title: '重定向 (3xx)', key: 'redirect' },
|
|
||||||
{ title: '客户端错误 (4xx)', key: 'client_error' },
|
|
||||||
{ title: '服务器错误 (5xx)', key: 'server_error' }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
])
|
|
||||||
|
|
||||||
// 选中的树节点
|
|
||||||
const selectedKeys = ref([])
|
|
||||||
|
|
||||||
// 行key
|
|
||||||
const rowKey = 'id'
|
|
||||||
|
|
||||||
// 获取请求方法颜色
|
|
||||||
const getMethodColor = (method) => {
|
|
||||||
const colorMap = {
|
|
||||||
'GET': 'green',
|
|
||||||
'POST': 'blue',
|
|
||||||
'PUT': 'orange',
|
|
||||||
'DELETE': 'red'
|
|
||||||
}
|
|
||||||
return colorMap[method] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表格列配置
|
|
||||||
const columns = [
|
|
||||||
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
|
||||||
{ title: '请求名称', dataIndex: 'title', key: 'title', width: 200 },
|
|
||||||
{ title: '请求类型', dataIndex: 'method', key: 'method', width: 100, slot: 'method' },
|
|
||||||
{ title: '请求地址', dataIndex: 'url', key: 'url', ellipsis: true },
|
|
||||||
{ title: '状态码', dataIndex: 'status', key: 'status', width: 100 },
|
|
||||||
{ title: '客户端IP', dataIndex: 'client_ip', key: 'client_ip', width: 140 },
|
|
||||||
{ title: '请求时间', dataIndex: 'created_at', key: 'created_at', width: 180 },
|
|
||||||
{ title: '操作', dataIndex: 'action', key: 'action', width: 150, align: 'center', slot: 'action', fixed: 'right' }
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
// 树节点选择事件
|
|
||||||
const onSelect = (selectedKeys, info) => {
|
|
||||||
const key = selectedKeys[0]
|
|
||||||
if (key) {
|
|
||||||
// 根据选中的树节点过滤数据
|
|
||||||
if (['get', 'post', 'put', 'delete'].includes(key)) {
|
|
||||||
searchForm.method = key.toUpperCase()
|
|
||||||
} else if (['success', 'redirect', 'client_error', 'server_error'].includes(key)) {
|
|
||||||
const statusMap = {
|
|
||||||
'success': '2xx',
|
|
||||||
'redirect': '3xx',
|
|
||||||
'client_error': '4xx',
|
|
||||||
'server_error': '5xx'
|
|
||||||
}
|
|
||||||
searchForm.status = statusMap[key]
|
|
||||||
} else {
|
|
||||||
searchForm.method = null
|
|
||||||
searchForm.status = null
|
|
||||||
}
|
|
||||||
pagination.current = 1
|
|
||||||
refreshTable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 查看日志详情
|
|
||||||
const handleView = (record) => {
|
|
||||||
dialog.info = true
|
|
||||||
setTimeout(() => {
|
|
||||||
infoDialogRef.value?.open().setData(record)
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除日志
|
|
||||||
const handleDelete = async (record) => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.log.delete.post({ id: record.id })
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('删除成功')
|
|
||||||
refreshTable()
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
} else {
|
|
||||||
message.error(res.message || '删除失败')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('删除日志失败:', error)
|
|
||||||
message.error('删除失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 清空日志
|
|
||||||
const handleClearLog = () => {
|
|
||||||
if (selectedRowKeys.value.length === 0) {
|
|
||||||
message.warning('请先选择要删除的日志')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: '确认清空日志',
|
|
||||||
content: `确定要删除选中的 ${selectedRowKeys.value.length} 条日志吗?`,
|
|
||||||
okText: '确定',
|
|
||||||
cancelText: '取消',
|
|
||||||
onOk: async () => {
|
|
||||||
try {
|
|
||||||
// 批量删除
|
|
||||||
const promises = selectedRowKeys.value.map(id => systemApi.log.delete.post({ id }))
|
|
||||||
await Promise.all(promises)
|
|
||||||
message.success('清空成功')
|
|
||||||
selectedRowKeys.value = []
|
|
||||||
refreshTable()
|
|
||||||
} catch (error) {
|
|
||||||
console.error('清空日志失败:', error)
|
|
||||||
message.error('清空失败')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化
|
|
||||||
onMounted(() => {
|
|
||||||
refreshTable()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.log-page {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
.left-box {
|
|
||||||
width: 260px;
|
|
||||||
border-right: 1px solid #f0f0f0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #fff;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
:deep(.ant-card) {
|
|
||||||
height: 100%;
|
|
||||||
border: none;
|
|
||||||
border-radius: 0;
|
|
||||||
|
|
||||||
.ant-card-body {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-box {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
.table-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :open="visible" :title="'日志详情'" :width="700" :footer="null" @cancel="handleCancel">
|
|
||||||
<a-descriptions :column="1" bordered size="small">
|
|
||||||
<a-descriptions-item label="操作人">{{ data.user?.nickname || '-' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="客户端IP">{{ data.client_ip || '-' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="请求接口">{{ data.url || '-' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="请求方法">
|
|
||||||
<a-tag :color="getMethodColor(data.method)">{{ data.method }}</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="状态代码">
|
|
||||||
<a-tag :color="getStatusColor(data.status)">{{ data.status }}</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="日志名">{{ data.title || '-' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="日志时间">{{ data.created_at || '-' }}</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
|
||||||
<a-collapse v-model:activeKey="activeNames" style="margin-top: 20px" ghost>
|
|
||||||
<a-collapse-panel key="1" header="请求参数">
|
|
||||||
<div class="code">{{ data.data || '无' }}</div>
|
|
||||||
</a-collapse-panel>
|
|
||||||
<a-collapse-panel key="2" header="浏览器信息">
|
|
||||||
<div class="code">{{ data.browser || '-' }}</div>
|
|
||||||
</a-collapse-panel>
|
|
||||||
</a-collapse>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive } from 'vue'
|
|
||||||
|
|
||||||
const emit = defineEmits(['closed'])
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const activeNames = ref(['1'])
|
|
||||||
|
|
||||||
const data = reactive({
|
|
||||||
user: {},
|
|
||||||
client_ip: '',
|
|
||||||
url: '',
|
|
||||||
method: '',
|
|
||||||
status: '',
|
|
||||||
title: '',
|
|
||||||
created_at: '',
|
|
||||||
data: '',
|
|
||||||
browser: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
// 获取请求方法颜色
|
|
||||||
const getMethodColor = (method) => {
|
|
||||||
const colorMap = {
|
|
||||||
'GET': 'green',
|
|
||||||
'POST': 'blue',
|
|
||||||
'PUT': 'orange',
|
|
||||||
'DELETE': 'red'
|
|
||||||
}
|
|
||||||
return colorMap[method] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取状态码颜色
|
|
||||||
const getStatusColor = (status) => {
|
|
||||||
if (!status) return 'default'
|
|
||||||
if (status >= 200 && status < 300) return 'success'
|
|
||||||
if (status >= 300 && status < 400) return 'warning'
|
|
||||||
if (status >= 400 && status < 500) return 'error'
|
|
||||||
if (status >= 500) return 'error'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 显示对话框
|
|
||||||
const open = () => {
|
|
||||||
visible.value = true
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
open,
|
|
||||||
close
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭对话框
|
|
||||||
const close = () => {
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理取消
|
|
||||||
const handleCancel = () => {
|
|
||||||
emit('closed')
|
|
||||||
visible.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置数据
|
|
||||||
const setData = (logData) => {
|
|
||||||
Object.assign(data, logData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
|
||||||
defineExpose({
|
|
||||||
open,
|
|
||||||
setData,
|
|
||||||
close
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.code {
|
|
||||||
background: #2d2d2d;
|
|
||||||
padding: 15px;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-all;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div></div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineOptions({
|
|
||||||
name: "systemModules"
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-modal :open="visible" :title="isEdit ? $t('common.editConfig') : $t('common.addConfig')" :width="600"
|
|
||||||
:ok-text="$t('common.confirm')" :cancel-text="$t('common.cancel')" @ok="handleConfirm" @cancel="handleClose"
|
|
||||||
:after-close="handleClose">
|
|
||||||
<a-form ref="formRef" :model="formData" :label-col="{ span: 5 }">
|
|
||||||
<a-form-item v-if="!isEdit" :name="['category']" :label="$t('common.configCategory')"
|
|
||||||
:rules="[{ required: true, message: $t('common.pleaseSelect') + $t('common.configCategory') }]">
|
|
||||||
<a-select v-model:value="formData.category" :placeholder="$t('common.pleaseSelect')">
|
|
||||||
<a-select-option v-for="category in categories" :key="category.name" :value="category.name">
|
|
||||||
{{ category.title }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="!isEdit" :name="['name']" :label="$t('common.configName')"
|
|
||||||
:rules="[{ required: true, message: $t('common.pleaseEnter') + $t('common.configName') }]">
|
|
||||||
<a-input v-model:value="formData.name" :placeholder="$t('common.pleaseEnter')" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :name="['title']" :label="$t('common.configTitle')"
|
|
||||||
:rules="[{ required: true, message: $t('common.pleaseEnter') + $t('common.configTitle') }]">
|
|
||||||
<a-input v-model:value="formData.title" :placeholder="$t('common.pleaseEnter')" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item v-if="!isEdit" :name="['type']" :label="$t('common.configType')"
|
|
||||||
:rules="[{ required: true, message: $t('common.pleaseSelect') + $t('common.configType') }]">
|
|
||||||
<a-select v-model:value="formData.type" :placeholder="$t('common.pleaseSelect')">
|
|
||||||
<a-select-option value="string">文本</a-select-option>
|
|
||||||
<a-select-option value="textarea">文本域</a-select-option>
|
|
||||||
<a-select-option value="number">数字</a-select-option>
|
|
||||||
<a-select-option value="switch">开关</a-select-option>
|
|
||||||
<a-select-option value="select">下拉选择</a-select-option>
|
|
||||||
<a-select-option value="radio">单选</a-select-option>
|
|
||||||
<a-select-option value="multiselect">多选</a-select-option>
|
|
||||||
<a-select-option value="datetime">日期时间</a-select-option>
|
|
||||||
<a-select-option value="color">颜色</a-select-option>
|
|
||||||
<a-select-option value="image">图片</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :name="['value']" :label="$t('common.configValue')">
|
|
||||||
<a-input v-model:value="formData.value" :placeholder="$t('common.pleaseEnter')" />
|
|
||||||
</a-form-item>
|
|
||||||
|
|
||||||
<a-form-item :name="['remark']" :label="$t('common.configTip')">
|
|
||||||
<a-textarea v-model:value="formData.remark" :placeholder="$t('common.pleaseEnter')" :rows="3" />
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, watch } from 'vue'
|
|
||||||
|
|
||||||
// 定义组件名称
|
|
||||||
defineOptions({
|
|
||||||
name: 'ConfigModal',
|
|
||||||
})
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
visible: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 是否为编辑模式
|
|
||||||
isEdit: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
// 配置分类列表
|
|
||||||
categories: {
|
|
||||||
type: Array,
|
|
||||||
default: () => [],
|
|
||||||
},
|
|
||||||
// 编辑时的初始数据
|
|
||||||
initialData: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update:visible', 'confirm'])
|
|
||||||
|
|
||||||
const formRef = ref(null)
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = reactive({
|
|
||||||
category: '',
|
|
||||||
name: '',
|
|
||||||
title: '',
|
|
||||||
type: 'string',
|
|
||||||
value: '',
|
|
||||||
remark: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
// 监听弹窗显示和初始数据变化
|
|
||||||
watch(() => props.initialData, (newVal) => {
|
|
||||||
if (props.isEdit && Object.keys(newVal).length > 0) {
|
|
||||||
formData.category = newVal.category || ''
|
|
||||||
formData.name = newVal.name || ''
|
|
||||||
formData.title = newVal.title || ''
|
|
||||||
formData.type = newVal.type || 'string'
|
|
||||||
formData.value = newVal.value || ''
|
|
||||||
formData.remark = newVal.remark || newVal.tip || ''
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
// 监听弹窗关闭,重置表单
|
|
||||||
watch(() => props.visible, (newVal) => {
|
|
||||||
if (!newVal) {
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 确认提交
|
|
||||||
const handleConfirm = async () => {
|
|
||||||
try {
|
|
||||||
const values = await formRef.value.validate()
|
|
||||||
emit('confirm', values)
|
|
||||||
} catch {
|
|
||||||
// 表单验证错误,不处理
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 关闭弹窗并重置表单
|
|
||||||
const handleClose = () => {
|
|
||||||
formRef.value?.resetFields()
|
|
||||||
formData.category = ''
|
|
||||||
formData.name = ''
|
|
||||||
formData.title = ''
|
|
||||||
formData.type = 'string'
|
|
||||||
formData.value = ''
|
|
||||||
formData.remark = ''
|
|
||||||
emit('update:visible', false)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="pages system-setting">
|
|
||||||
|
|
||||||
<!-- 主要内容区域 -->
|
|
||||||
<div class="page-content">
|
|
||||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange" class="setting-tabs">
|
|
||||||
<template #rightExtra>
|
|
||||||
<a-button type="primary" @click="handleAddConfig">
|
|
||||||
<template #icon>
|
|
||||||
<PlusOutlined />
|
|
||||||
</template>
|
|
||||||
新增配置
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
<a-tab-pane v-for="category in categories" :key="category.name" :tab="category.title">
|
|
||||||
<a-form :label-col="{ span: 4 }" :wrapper-col="{ span: 16 }" class="setting-form">
|
|
||||||
<a-form-item v-for="field in fields.filter(f => f.category === category.name)"
|
|
||||||
:key="field.name" :label="field.title" :required="field.required">
|
|
||||||
<div class="form-item-content">
|
|
||||||
<div class="form-input-wrapper">
|
|
||||||
<!-- 文本输入 -->
|
|
||||||
<a-input v-if="field.type === 'text'" v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请输入'" allow-clear />
|
|
||||||
<!-- 文本域 -->
|
|
||||||
<a-textarea v-else-if="field.type === 'textarea'"
|
|
||||||
v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请输入'" :rows="4"
|
|
||||||
:maxlength="field.maxLength" :show-count="field.maxLength > 0"
|
|
||||||
allow-clear />
|
|
||||||
<!-- 数字输入 -->
|
|
||||||
<a-input-number v-else-if="field.type === 'number'"
|
|
||||||
v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请输入'" :min="field.min" :max="field.max"
|
|
||||||
:precision="field.precision || 0" style="width: 100%" />
|
|
||||||
<!-- 开关 -->
|
|
||||||
<a-switch v-else-if="field.type === 'switch'"
|
|
||||||
v-model:checked="formData[field.name]" :checked-children="启用"
|
|
||||||
:un-checked-children="禁用" />
|
|
||||||
<!-- 下拉选择 -->
|
|
||||||
<a-select v-else-if="field.type === 'select'"
|
|
||||||
v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请选择'" style="width: 100%" allow-clear>
|
|
||||||
<a-select-option v-for="option in field.options" :key="option.value"
|
|
||||||
:value="option.value">
|
|
||||||
{{ option.label }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<!-- 多选 -->
|
|
||||||
<a-select v-else-if="field.type === 'multiselect'"
|
|
||||||
v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请选择'" mode="multiple"
|
|
||||||
style="width: 100%" allow-clear>
|
|
||||||
<a-select-option v-for="option in field.options" :key="option.value"
|
|
||||||
:value="option.value">
|
|
||||||
{{ option.label }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<!-- 日期时间 -->
|
|
||||||
<a-date-picker v-else-if="field.type === 'datetime'"
|
|
||||||
v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请选择'" style="width: 100%" show-time
|
|
||||||
format="YYYY-MM-DD HH:mm:ss" allow-clear />
|
|
||||||
<!-- 颜色选择器 -->
|
|
||||||
<div v-else-if="field.type === 'color'" class="color-picker-wrapper">
|
|
||||||
<div class="color-preview"
|
|
||||||
:style="{ backgroundColor: formData[field.name] }"></div>
|
|
||||||
<a-input v-model:value="formData[field.name]"
|
|
||||||
placeholder="请输入颜色值(如:#ff0000)" allow-clear class="color-text" />
|
|
||||||
</div>
|
|
||||||
<!-- 图片上传 -->
|
|
||||||
<div v-else-if="field.type === 'image'" class="image-uploader-wrapper">
|
|
||||||
<sc-upload v-model="formData[field.name]" :max-count="1" :tip="field.tip"
|
|
||||||
upload-text="上传图片" />
|
|
||||||
</div>
|
|
||||||
<!-- 默认文本输入 -->
|
|
||||||
<a-input v-else v-model:value="formData[field.name]"
|
|
||||||
:placeholder="field.placeholder || '请输入'" allow-clear />
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<a-tooltip title="编辑配置项">
|
|
||||||
<EditOutlined class="action-icon edit-icon"
|
|
||||||
@click="handleEditField(field)" />
|
|
||||||
</a-tooltip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="field.tip" class="field-tip">
|
|
||||||
<InfoCircleOutlined class="tip-icon" />
|
|
||||||
{{ field.tip }}
|
|
||||||
</div>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item :wrapper-col="{ offset: 4, span: 16 }">
|
|
||||||
<div style="display: flex; gap: 10px;">
|
|
||||||
<a-button type="primary" size="large" :loading="saving" @click="handleSave">
|
|
||||||
<template #icon>
|
|
||||||
<SaveOutlined />
|
|
||||||
</template>
|
|
||||||
保存配置
|
|
||||||
</a-button>
|
|
||||||
<a-button size="large" @click="handleReset">
|
|
||||||
<template #icon>
|
|
||||||
<RedoOutlined />
|
|
||||||
</template>
|
|
||||||
重置
|
|
||||||
</a-button>
|
|
||||||
</div>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
|
||||||
<a-empty v-if="fields.filter(f => f.category === category.name).length === 0"
|
|
||||||
description="暂无配置项">
|
|
||||||
<a-button type="primary" @click="handleAddConfig">
|
|
||||||
<template #icon>
|
|
||||||
<PlusOutlined />
|
|
||||||
</template>
|
|
||||||
添加配置
|
|
||||||
</a-button>
|
|
||||||
</a-empty>
|
|
||||||
</a-tab-pane>
|
|
||||||
</a-tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 配置弹窗 -->
|
|
||||||
<ConfigModal v-model:visible="modalVisible" :is-edit="isEditMode" :categories="categories" :initial-data="currentEditData" @confirm="handleModalConfirm" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import scUpload from '@/components/scUpload/index.vue'
|
|
||||||
import systemApi from '@/api/system'
|
|
||||||
import ConfigModal from './components/ConfigModal.vue'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'SystemSetting'
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeTab = ref('basic')
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
// 配置分类
|
|
||||||
const categories = ref([])
|
|
||||||
|
|
||||||
// 配置字段
|
|
||||||
const fields = ref([])
|
|
||||||
|
|
||||||
// 表单数据
|
|
||||||
const formData = reactive({})
|
|
||||||
|
|
||||||
// 弹窗相关
|
|
||||||
const modalVisible = ref(false)
|
|
||||||
const isEditMode = ref(false)
|
|
||||||
const currentEditData = ref({})
|
|
||||||
|
|
||||||
// 获取配置字段
|
|
||||||
const fetchFields = async () => {
|
|
||||||
try {
|
|
||||||
const res = await systemApi.setting.fields.get()
|
|
||||||
if (res.code === 1) {
|
|
||||||
const configData = res.data || []
|
|
||||||
|
|
||||||
// 根据 group 字段提取分类
|
|
||||||
const groupMap = new Map()
|
|
||||||
configData.forEach((item) => {
|
|
||||||
if (item.group && !groupMap.has(item.group)) {
|
|
||||||
const groupTitles = {
|
|
||||||
base: '基础设置',
|
|
||||||
upload: '上传设置',
|
|
||||||
email: '邮件设置',
|
|
||||||
sms: '短信设置',
|
|
||||||
security: '安全设置'
|
|
||||||
}
|
|
||||||
groupMap.set(item.group, {
|
|
||||||
name: item.group,
|
|
||||||
title: groupTitles[item.group] || item.group
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
||||||
categories.value = Array.from(groupMap.values())
|
|
||||||
|
|
||||||
// 将配置项转换为前端需要的格式
|
|
||||||
fields.value = configData.map((item) => ({
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
title: item.title || item.label,
|
|
||||||
value: item.values,
|
|
||||||
type: mapFieldType(item.type),
|
|
||||||
category: item.group,
|
|
||||||
placeholder: item.options?.placeholder || item.remark,
|
|
||||||
tip: item.remark,
|
|
||||||
required: item.required || false,
|
|
||||||
options: mapFieldOptions(item.type, item.options?.options || []),
|
|
||||||
sort: item.sort,
|
|
||||||
min: item.options?.min,
|
|
||||||
max: item.options?.max,
|
|
||||||
precision: item.options?.precision,
|
|
||||||
maxLength: item.options?.maxLength
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 初始化表单数据
|
|
||||||
fields.value.forEach((field) => {
|
|
||||||
if (field.type === 'number') {
|
|
||||||
formData[field.name] = field.value ? Number(field.value) : 0
|
|
||||||
} else if (field.type === 'switch') {
|
|
||||||
formData[field.name] = field.value === '1' || field.value === true
|
|
||||||
} else if (field.type === 'multiselect') {
|
|
||||||
formData[field.name] = field.value ? (Array.isArray(field.value) ? field.value : field.value.split(',')) : []
|
|
||||||
} else {
|
|
||||||
formData[field.name] = field.value || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 设置第一个 tab 为默认激活
|
|
||||||
if (categories.value.length > 0) {
|
|
||||||
activeTab.value = categories.value[0].name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error('获取配置字段失败')
|
|
||||||
console.error('获取配置字段失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 映射字段类型
|
|
||||||
const mapFieldType = (backendType) => {
|
|
||||||
const typeMap = {
|
|
||||||
string: 'text',
|
|
||||||
text: 'text',
|
|
||||||
textarea: 'textarea',
|
|
||||||
number: 'number',
|
|
||||||
boolean: 'switch',
|
|
||||||
switch: 'switch',
|
|
||||||
select: 'select',
|
|
||||||
radio: 'select',
|
|
||||||
multiselect: 'multiselect',
|
|
||||||
checkbox: 'multiselect',
|
|
||||||
datetime: 'datetime',
|
|
||||||
date: 'datetime',
|
|
||||||
color: 'color',
|
|
||||||
image: 'image',
|
|
||||||
file: 'file'
|
|
||||||
}
|
|
||||||
return typeMap[backendType] || 'text'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 映射字段选项
|
|
||||||
const mapFieldOptions = (type, options) => {
|
|
||||||
if (options && options.length > 0) {
|
|
||||||
return options.map((opt) => ({
|
|
||||||
label: opt.label || opt.name || opt,
|
|
||||||
value: opt.value || opt.key || opt
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换 Tab
|
|
||||||
const handleTabChange = (key) => {
|
|
||||||
activeTab.value = key
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加配置
|
|
||||||
const handleAddConfig = () => {
|
|
||||||
isEditMode.value = false
|
|
||||||
currentEditData.value = {
|
|
||||||
category: activeTab.value,
|
|
||||||
name: '',
|
|
||||||
title: '',
|
|
||||||
type: 'text',
|
|
||||||
value: '',
|
|
||||||
tip: '',
|
|
||||||
required: false
|
|
||||||
}
|
|
||||||
modalVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 编辑字段
|
|
||||||
const handleEditField = (field) => {
|
|
||||||
isEditMode.value = true
|
|
||||||
currentEditData.value = {
|
|
||||||
id: field.id,
|
|
||||||
category: field.category,
|
|
||||||
name: field.name,
|
|
||||||
title: field.title,
|
|
||||||
type: field.type,
|
|
||||||
value: formData[field.name],
|
|
||||||
tip: field.tip || '',
|
|
||||||
remark: field.tip || '',
|
|
||||||
placeholder: field.placeholder,
|
|
||||||
options: field.options || [],
|
|
||||||
required: field.required || false
|
|
||||||
}
|
|
||||||
modalVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 弹窗确认处理
|
|
||||||
const handleModalConfirm = async (values) => {
|
|
||||||
try {
|
|
||||||
let res
|
|
||||||
if (isEditMode.value) {
|
|
||||||
// 编辑模式
|
|
||||||
res = await systemApi.setting.edit.post({
|
|
||||||
id: currentEditData.value.id,
|
|
||||||
...values,
|
|
||||||
name: currentEditData.value.name
|
|
||||||
})
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('编辑成功')
|
|
||||||
modalVisible.value = false
|
|
||||||
|
|
||||||
// 更新表单数据
|
|
||||||
formData[currentEditData.value.name] = values.value
|
|
||||||
|
|
||||||
// 更新字段信息
|
|
||||||
const fieldIndex = fields.value.findIndex((f) => f.name === currentEditData.value.name)
|
|
||||||
if (fieldIndex > -1) {
|
|
||||||
fields.value[fieldIndex].title = values.title
|
|
||||||
fields.value[fieldIndex].value = values.value
|
|
||||||
fields.value[fieldIndex].tip = values.tip || values.remark
|
|
||||||
fields.value[fieldIndex].placeholder = values.placeholder
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 添加模式
|
|
||||||
res = await systemApi.setting.add.post(values)
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('添加成功')
|
|
||||||
modalVisible.value = false
|
|
||||||
// 重新获取配置字段
|
|
||||||
await fetchFields()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.errorFields) {
|
|
||||||
return // 表单验证错误
|
|
||||||
}
|
|
||||||
message.error(isEditMode.value ? '编辑失败' : '添加失败')
|
|
||||||
console.error(isEditMode.value ? '编辑配置失败:' : '添加配置失败:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存配置
|
|
||||||
const handleSave = async () => {
|
|
||||||
try {
|
|
||||||
saving.value = true
|
|
||||||
// 处理多选和开关类型的值
|
|
||||||
const saveData = {}
|
|
||||||
Object.keys(formData).forEach((key) => {
|
|
||||||
const field = fields.value.find((f) => f.name === key)
|
|
||||||
if (field) {
|
|
||||||
if (field.type === 'multiselect') {
|
|
||||||
saveData[key] = Array.isArray(formData[key]) ? formData[key].join(',') : formData[key]
|
|
||||||
} else if (field.type === 'switch') {
|
|
||||||
saveData[key] = formData[key] ? '1' : '0'
|
|
||||||
} else {
|
|
||||||
saveData[key] = formData[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const res = await systemApi.setting.save.post(saveData)
|
|
||||||
if (res.code === 1) {
|
|
||||||
message.success('保存成功')
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error('保存失败')
|
|
||||||
console.error('保存配置失败:', error)
|
|
||||||
} finally {
|
|
||||||
saving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置配置
|
|
||||||
const handleReset = () => {
|
|
||||||
fields.value.forEach((field) => {
|
|
||||||
if (field.type === 'number') {
|
|
||||||
formData[field.name] = field.value ? Number(field.value) : 0
|
|
||||||
} else if (field.type === 'switch') {
|
|
||||||
formData[field.name] = field.value === '1' || field.value === true
|
|
||||||
} else if (field.type === 'multiselect') {
|
|
||||||
formData[field.name] = field.value ? (Array.isArray(field.value) ? field.value : field.value.split(',')) : []
|
|
||||||
} else {
|
|
||||||
formData[field.name] = field.value || ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
message.info('已重置')
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchFields()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.system-setting {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #f5f5f5;
|
|
||||||
|
|
||||||
.page-content {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
padding: 16px;
|
|
||||||
|
|
||||||
.setting-tabs {
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
background: #ffffff;
|
|
||||||
padding: 0 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
|
|
||||||
:deep(.ant-tabs-tab-btn) {
|
|
||||||
padding: 0 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-tabs-nav) {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-tabs-content) {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding-right: 8px;
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
|
||||||
background: #d9d9d9;
|
|
||||||
border-radius: 3px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #bfbfbf;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-tabs-tabpane) {
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.setting-form {
|
|
||||||
.form-item-content {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.form-input-wrapper {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding-top: 4px;
|
|
||||||
|
|
||||||
.action-icon {
|
|
||||||
font-size: 16px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: #1890ff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.field-tip {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #8c8c8c;
|
|
||||||
line-height: 1.5;
|
|
||||||
|
|
||||||
.tip-icon {
|
|
||||||
margin-top: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-uploader-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-picker-wrapper {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
|
|
||||||
.color-preview {
|
|
||||||
width: 40px;
|
|
||||||
height: 32px;
|
|
||||||
border: 1px solid #d9d9d9;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 2px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: #409eff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-text {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
userInfo: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['update'])
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 表单初始值
|
|
||||||
const initialValues = computed(() => ({
|
|
||||||
username: props.userInfo.username || '',
|
|
||||||
nickname: props.userInfo.nickname || '',
|
|
||||||
mobile: props.userInfo.mobile || '',
|
|
||||||
email: props.userInfo.email || '',
|
|
||||||
gender: props.userInfo.gender || 0,
|
|
||||||
birthday: props.userInfo.birthday || null,
|
|
||||||
bio: props.userInfo.bio || '',
|
|
||||||
}))
|
|
||||||
|
|
||||||
// 表单项配置
|
|
||||||
const formItems = [
|
|
||||||
{ field: 'username', label: '用户名', type: 'input' },
|
|
||||||
{
|
|
||||||
field: 'nickname', label: '昵称', type: 'input', required: true,
|
|
||||||
rules: [
|
|
||||||
{ required: true, message: '请输入昵称', trigger: 'blur' },
|
|
||||||
{ min: 2, max: 20, message: '昵称长度在 2 到 20 个字符', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'phone', label: '手机号', type: 'input',
|
|
||||||
rules: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'email', label: '邮箱', type: 'input',
|
|
||||||
rules: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'gender', label: '性别', type: 'radio',
|
|
||||||
options: [
|
|
||||||
{ label: '男', value: 1 },
|
|
||||||
{ label: '女', value: 2 },
|
|
||||||
{ label: '保密', value: 0 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ field: 'remark', label: '个人简介', type: 'textarea', rows: 4, maxLength: 200, showCount: true, },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 表单提交
|
|
||||||
const handleFinish = async (values) => {
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
|
|
||||||
// 调用更新当前用户信息接口
|
|
||||||
let res = await api.users.edit.post({
|
|
||||||
username: values.username,
|
|
||||||
nickname: values.nickname,
|
|
||||||
mobile: values.mobile,
|
|
||||||
email: values.email,
|
|
||||||
gender: values.gender,
|
|
||||||
remark: values.remark,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!res || res.code !== 1) {
|
|
||||||
throw new Error(res.message || '保存失败,请重试')
|
|
||||||
}
|
|
||||||
// 重新获取用户信息
|
|
||||||
const response = await api.user.get()
|
|
||||||
if (response && response.data) {
|
|
||||||
userStore.setUserInfo(response.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通知父组件更新
|
|
||||||
emit('update', values)
|
|
||||||
message.success('保存成功')
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error.message || '保存失败,请重试')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置表单
|
|
||||||
const handleReset = () => {
|
|
||||||
message.info('已重置')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<template>
|
|
||||||
<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 scForm from '@/components/scForm/index.vue'
|
|
||||||
|
|
||||||
const emit = defineEmits(['success'])
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 表单初始值
|
|
||||||
const initialValues = {
|
|
||||||
oldPassword: '',
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 表单项配置
|
|
||||||
const formItems = [
|
|
||||||
{
|
|
||||||
field: 'oldPassword',
|
|
||||||
label: '原密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
rules: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'newPassword',
|
|
||||||
label: '新密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
rules: [
|
|
||||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
|
||||||
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
field: 'confirmPassword',
|
|
||||||
label: '确认密码',
|
|
||||||
type: 'password',
|
|
||||||
required: true,
|
|
||||||
rules: [
|
|
||||||
{ required: true, message: '请再次输入新密码', trigger: 'blur' },
|
|
||||||
{
|
|
||||||
validator: (rule, value) => {
|
|
||||||
if (value !== initialValues.newPassword) {
|
|
||||||
return Promise.reject('两次输入的密码不一致')
|
|
||||||
}
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
trigger: 'blur',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
// 表单提交
|
|
||||||
const handleFinish = (values) => {
|
|
||||||
loading.value = true
|
|
||||||
// 模拟接口请求
|
|
||||||
setTimeout(() => {
|
|
||||||
message.success('密码修改成功,请重新登录')
|
|
||||||
emit('success')
|
|
||||||
handleReset()
|
|
||||||
loading.value = false
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重置表单
|
|
||||||
const handleReset = () => {
|
|
||||||
initialValues.oldPassword = ''
|
|
||||||
initialValues.newPassword = ''
|
|
||||||
initialValues.confirmPassword = ''
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss"></style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="profile-info">
|
|
||||||
<div class="avatar-wrapper">
|
|
||||||
<a-avatar :size="100" :src="userInfo.avatar" @click="handleAvatarClick">
|
|
||||||
{{ userInfo.nickname?.charAt(0) }}
|
|
||||||
</a-avatar>
|
|
||||||
</div>
|
|
||||||
<div class="user-name">{{ userInfo.nickname || userInfo.username }}</div>
|
|
||||||
<a-tag :color="userInfo.status === 1 ? 'green' : 'red'">
|
|
||||||
{{ userInfo.status === 1 ? '正常' : '禁用' }}
|
|
||||||
</a-tag>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
const props = defineProps({
|
|
||||||
userInfo: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits(['avatar-click'])
|
|
||||||
|
|
||||||
const handleAvatarClick = () => {
|
|
||||||
emit('avatar-click')
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.profile-info {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 20px 0;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
color: #fff;
|
|
||||||
|
|
||||||
.avatar-wrapper {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
.ant-avatar {
|
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
font-size: 40px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #fff;
|
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-name {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-tag {
|
|
||||||
margin: 0;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<template>
|
|
||||||
<a-list :data-source="securityList" item-layout="horizontal">
|
|
||||||
<template #renderItem="{ item }">
|
|
||||||
<a-list-item>
|
|
||||||
<a-list-item-meta>
|
|
||||||
<template #title>
|
|
||||||
{{ item.title }}
|
|
||||||
</template>
|
|
||||||
<template #description>
|
|
||||||
{{ item.description }}
|
|
||||||
</template>
|
|
||||||
</a-list-item-meta>
|
|
||||||
<template #actions>
|
|
||||||
<a-button type="primary" size="small" @click="handleAction(item.action)">
|
|
||||||
{{ item.buttonText }}
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
</a-list-item>
|
|
||||||
</template>
|
|
||||||
</a-list>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
|
|
||||||
const emit = defineEmits(['change-password'])
|
|
||||||
|
|
||||||
const securityList = ref([
|
|
||||||
{
|
|
||||||
title: '登录密码',
|
|
||||||
description: '用于登录系统的密码,建议定期更换',
|
|
||||||
buttonText: '修改',
|
|
||||||
action: 'password',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '手机验证',
|
|
||||||
description: '用于接收重要通知和安全验证',
|
|
||||||
buttonText: '已绑定',
|
|
||||||
action: 'phone',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '邮箱验证',
|
|
||||||
description: '用于接收重要通知和账号找回',
|
|
||||||
buttonText: '已绑定',
|
|
||||||
action: 'email',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
:deep(.ant-list-item) {
|
|
||||||
padding: 20px 0;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-list-item:last-child) {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="tech-decoration">
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<h1 class="auth-title">找回密码</h1>
|
||||||
|
<p class="auth-subtitle">输入您的邮箱,我们将发送重置密码链接</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="forgotFormRef" :model="forgotForm" :rules="forgotRules" class="auth-form" @submit.prevent="handleSubmit">
|
||||||
|
<el-form-item prop="email">
|
||||||
|
<el-input v-model="forgotForm.email" placeholder="请输入注册邮箱" size="large" clearable @keyup.enter="handleSubmit">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Message />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="captcha" v-if="showCaptcha">
|
||||||
|
<div style="display: flex; gap: 12px">
|
||||||
|
<el-input v-model="forgotForm.captcha" placeholder="请输入验证码" size="large" style="flex: 1">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Key />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<el-button type="info" size="large" :disabled="captchaDisabled" @click="sendCaptcha">
|
||||||
|
{{ captchaButtonText }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="loading" size="large" style="width: 100%" @click="handleSubmit">
|
||||||
|
{{ loading ? '提交中...' : '发送重置链接' }}
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p class="auth-footer-text">
|
||||||
|
想起密码了?
|
||||||
|
<router-link to="/login" class="auth-link"> 返回登录 </router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import '@/assets/style/auth.scss'
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Captcha button text
|
||||||
|
const captchaButtonText = ref('获取验证码')
|
||||||
|
|
||||||
|
// Form validation rules
|
||||||
|
const forgotRules = {
|
||||||
|
email: [
|
||||||
|
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
captcha: [
|
||||||
|
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||||
|
{ len: 6, message: '验证码为6位数字', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send captcha code
|
||||||
|
const sendCaptcha = async () => {
|
||||||
|
if (!forgotForm.email) {
|
||||||
|
ElMessage.warning('请先输入邮箱地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email format
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
if (!emailRegex.test(forgotForm.email)) {
|
||||||
|
ElMessage.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))
|
||||||
|
|
||||||
|
ElMessage.success('验证码已发送至您的邮箱')
|
||||||
|
|
||||||
|
// Start countdown
|
||||||
|
captchaDisabled.value = true
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
captchaButtonText.value = `${countdown.value}秒后重试`
|
||||||
|
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
clearInterval(timer)
|
||||||
|
captchaDisabled.value = false
|
||||||
|
captchaButtonText.value = '获取验证码'
|
||||||
|
countdown.value = 60
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
showCaptcha.value = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Send captcha failed:', error)
|
||||||
|
ElMessage.error('发送验证码失败,请稍后重试')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle submit
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!forgotFormRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
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))
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
ElMessage.success('密码重置链接已发送至您的邮箱,请注意查收')
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 2000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Forgot password failed:', error)
|
||||||
|
ElMessage.error('提交失败,请检查邮箱地址和验证码')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="ucenter">
|
|
||||||
<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">
|
|
||||||
<a-menu-item key="basic">
|
|
||||||
<UserOutlined />
|
|
||||||
基本信息
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="password">
|
|
||||||
<LockOutlined />
|
|
||||||
修改密码
|
|
||||||
</a-menu-item>
|
|
||||||
<a-menu-item key="security">
|
|
||||||
<SafetyOutlined />
|
|
||||||
账号安全
|
|
||||||
</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</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" />
|
|
||||||
</div>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 头像上传弹窗 -->
|
|
||||||
<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">
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
|
||||||
</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'
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
name: 'UserCenter',
|
|
||||||
})
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
|
||||||
|
|
||||||
// 用户信息
|
|
||||||
const userInfo = ref({})
|
|
||||||
|
|
||||||
// 选中的菜单
|
|
||||||
const selectedKeys = ref(['basic'])
|
|
||||||
|
|
||||||
// 头像上传
|
|
||||||
const showAvatarModal = ref(false)
|
|
||||||
const avatarFileList = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 初始化用户信息
|
|
||||||
const initUserInfo = async () => {
|
|
||||||
try {
|
|
||||||
// 从 store 获取用户信息
|
|
||||||
const storeUserInfo = userStore.userInfo
|
|
||||||
if (storeUserInfo) {
|
|
||||||
userInfo.value = storeUserInfo
|
|
||||||
} else {
|
|
||||||
// 如果 store 中没有用户信息,则从接口获取
|
|
||||||
const response = await api.user.get()
|
|
||||||
if (response && response.data) {
|
|
||||||
userStore.setUserInfo(response.data)
|
|
||||||
userInfo.value = response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
message.error(err.message || '获取用户信息失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 更新用户信息
|
|
||||||
const handleUpdateUserInfo = (data) => {
|
|
||||||
// 更新本地用户信息
|
|
||||||
Object.assign(userInfo.value, data)
|
|
||||||
|
|
||||||
// 如果 birthday 有值,转换为 dayjs 对象
|
|
||||||
if (data.birthday && typeof data.birthday === 'string') {
|
|
||||||
userInfo.value.birthday = dayjs(data.birthday)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 密码修改成功
|
|
||||||
const handlePasswordSuccess = () => {
|
|
||||||
// 密码修改成功后的处理
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换到密码修改页面
|
|
||||||
const handleChangePassword = () => {
|
|
||||||
selectedKeys.value = ['password']
|
|
||||||
}
|
|
||||||
|
|
||||||
// 头像上传前校验
|
|
||||||
const beforeUpload = (file) => {
|
|
||||||
const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png'
|
|
||||||
if (!isJpgOrPng) {
|
|
||||||
message.error('只能上传 JPG/PNG 格式的文件!')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
const isLt2M = file.size / 1024 / 1024 < 2
|
|
||||||
if (!isLt2M) {
|
|
||||||
message.error('图片大小不能超过 2MB!')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return false // 阻止自动上传
|
|
||||||
}
|
|
||||||
|
|
||||||
// 头像文件变化
|
|
||||||
const handleAvatarChange = ({ fileList }) => {
|
|
||||||
avatarFileList.value = fileList
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传头像
|
|
||||||
const handleAvatarUpload = () => {
|
|
||||||
if (avatarFileList.value.length === 0) {
|
|
||||||
message.warning('请先选择头像')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
|
||||||
// 模拟上传
|
|
||||||
setTimeout(() => {
|
|
||||||
const file = avatarFileList.value[0]
|
|
||||||
userInfo.value.avatar = URL.createObjectURL(file.originFileObj)
|
|
||||||
message.success('头像更新成功')
|
|
||||||
showAvatarModal.value = false
|
|
||||||
avatarFileList.value = []
|
|
||||||
loading.value = false
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
initUserInfo()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.ucenter {
|
|
||||||
.content-wrapper {
|
|
||||||
padding: 24px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
min-height: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu {
|
|
||||||
margin-top: 16px;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
.ant-menu-item {
|
|
||||||
border-radius: 6px;
|
|
||||||
margin: 4px 0;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
&.ant-menu-item-selected {
|
|
||||||
background: rgba(255, 255, 255, 0.4);
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
border-right-width: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-upload {
|
|
||||||
.upload-tip {
|
|
||||||
margin-top: 16px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.ant-card-head-title) {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="tech-decoration">
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<h1 class="auth-title">欢迎回来</h1>
|
||||||
|
<p class="auth-subtitle">登录您的账户继续探索科技世界</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" class="auth-form" @submit.prevent="handleLogin">
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input v-model="loginForm.username" placeholder="请输入用户名/邮箱" size="large" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<User />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input v-model="loginForm.password" type="password" placeholder="请输入密码" size="large" clearable show-password @keyup.enter="handleLogin">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Lock />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<div class="auth-links">
|
||||||
|
<el-checkbox v-model="loginForm.rememberMe" class="remember-me"> 记住我 </el-checkbox>
|
||||||
|
<router-link to="/forgot-password" class="forgot-password"> 忘记密码? </router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="loading" size="large" style="width: 100%" @click="handleLogin">
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p class="auth-footer-text">
|
||||||
|
还没有账户?
|
||||||
|
<router-link to="/register" class="auth-link"> 立即注册 </router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'LoginPage',
|
||||||
|
})
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
|
import auth from '@/api/auth'
|
||||||
|
import config from '@/config'
|
||||||
|
import tool from '@/utils/tool'
|
||||||
|
import system from '@/api/system'
|
||||||
|
import '@/assets/style/auth.scss'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const loginFormRef = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
// Login form data
|
||||||
|
const loginForm = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
rememberMe: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Form validation rules
|
||||||
|
const loginRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名或邮箱', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 50, message: '长度在 3 到 50 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, message: '密码长度不能少于 6 位', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login
|
||||||
|
const handleLogin = async () => {
|
||||||
|
if (!loginFormRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate form
|
||||||
|
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 || !loginResponse.data) {
|
||||||
|
throw new Error('登录响应数据异常')
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginData = loginResponse.data
|
||||||
|
|
||||||
|
// 2. Store access_token persistently
|
||||||
|
if (loginData.access_token) {
|
||||||
|
userStore.setToken(loginData.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store refresh token if available
|
||||||
|
if (loginData.refresh_token) {
|
||||||
|
userStore.setRefreshToken(loginData.refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get user information after login
|
||||||
|
const userResponse = await auth.user.get()
|
||||||
|
|
||||||
|
if (userResponse && userResponse.data) {
|
||||||
|
userStore.setUserInfo(userResponse.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get authorized menu information
|
||||||
|
const menuResponse = await auth.menu.my.get()
|
||||||
|
|
||||||
|
if (menuResponse && menuResponse.data) {
|
||||||
|
userStore.setMenu(menuResponse.data.menu)
|
||||||
|
userStore.setPermissions(menuResponse.data.permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// // 5. Cache system configuration data
|
||||||
|
// try {
|
||||||
|
// const settingResponse = await system.setting.list.get()
|
||||||
|
// if (settingResponse && settingResponse.data) {
|
||||||
|
// tool.data.set('system_setting', settingResponse.data)
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('Failed to cache system settings:', error)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 6. Cache dictionary data
|
||||||
|
// try {
|
||||||
|
// const dictResponse = await system.dictionary.list.get()
|
||||||
|
// if (dictResponse && dictResponse.data) {
|
||||||
|
// tool.data.set('system_dictionary', dictResponse.data)
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('Failed to cache dictionary data:', error)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 7. Cache area data
|
||||||
|
// try {
|
||||||
|
// const areaResponse = await system.area.list.get()
|
||||||
|
// if (areaResponse && areaResponse.data) {
|
||||||
|
// tool.data.set('system_area', areaResponse.data)
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('Failed to cache area data:', error)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
ElMessage.success('登录成功!')
|
||||||
|
|
||||||
|
// 5. Redirect to dashboard or redirect parameter
|
||||||
|
setTimeout(() => {
|
||||||
|
// Get redirect from query parameter
|
||||||
|
const redirect = route.query.redirect
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
// If there's a redirect parameter, go there
|
||||||
|
router.push(redirect)
|
||||||
|
} else {
|
||||||
|
// Otherwise, go to configured dashboard URL
|
||||||
|
router.push(config.DASHBOARD_URL)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error)
|
||||||
|
|
||||||
|
// Clear user data on login failure
|
||||||
|
userStore.logout()
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
const errorMsg = error.response?.data?.message || error.message || '登录失败,请检查用户名和密码'
|
||||||
|
ElMessage.error(errorMsg)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div class="auth-container">
|
||||||
|
<div class="tech-decoration">
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
<div class="tech-circle"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-card">
|
||||||
|
<div class="auth-header">
|
||||||
|
<h1 class="auth-title">创建账户</h1>
|
||||||
|
<p class="auth-subtitle">加入我们,开启科技之旅</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="registerFormRef" :model="registerForm" :rules="registerRules" class="auth-form" @submit.prevent="handleRegister">
|
||||||
|
<el-form-item prop="username">
|
||||||
|
<el-input v-model="registerForm.username" placeholder="请输入用户名" size="large" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<User />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="email">
|
||||||
|
<el-input v-model="registerForm.email" placeholder="请输入邮箱地址" size="large" clearable>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Message />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="password">
|
||||||
|
<el-input v-model="registerForm.password" type="password" placeholder="请输入密码(至少6位)" size="large" show-password>
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Lock />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="confirmPassword">
|
||||||
|
<el-input v-model="registerForm.confirmPassword" type="password" placeholder="请再次输入密码" size="large" show-password @keyup.enter="handleRegister">
|
||||||
|
<template #prefix>
|
||||||
|
<el-icon>
|
||||||
|
<Lock />
|
||||||
|
</el-icon>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item prop="agreeTerms">
|
||||||
|
<el-checkbox v-model="registerForm.agreeTerms" class="remember-me">
|
||||||
|
我已阅读并同意
|
||||||
|
<a href="#" class="auth-link">服务条款</a>
|
||||||
|
和
|
||||||
|
<a href="#" class="auth-link">隐私政策</a>
|
||||||
|
</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-button type="primary" :loading="loading" size="large" style="width: 100%" @click="handleRegister">
|
||||||
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="auth-footer">
|
||||||
|
<p class="auth-footer-text">
|
||||||
|
已有账户?
|
||||||
|
<router-link to="/login" class="auth-link"> 立即登录 </router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import '@/assets/style/auth.scss'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const registerFormRef = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// Register form data
|
||||||
|
const registerForm = reactive({
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
agreeTerms: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Custom password validation
|
||||||
|
const validatePassword = (rule, value, callback) => {
|
||||||
|
if (value === '') {
|
||||||
|
callback(new Error('请输入密码'))
|
||||||
|
} else if (value.length < 6) {
|
||||||
|
callback(new Error('密码长度不能少于 6 位'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom confirm password validation
|
||||||
|
const validateConfirmPassword = (rule, value, callback) => {
|
||||||
|
if (value === '') {
|
||||||
|
callback(new Error('请再次输入密码'))
|
||||||
|
} else if (value !== registerForm.password) {
|
||||||
|
callback(new Error('两次输入的密码不一致'))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation rules
|
||||||
|
const registerRules = {
|
||||||
|
username: [
|
||||||
|
{ required: true, message: '请输入用户名', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 20, message: '长度在 3 到 20 个字符', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
email: [
|
||||||
|
{ required: true, message: '请输入邮箱地址', trigger: 'blur' },
|
||||||
|
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' },
|
||||||
|
],
|
||||||
|
password: [{ required: true, validator: validatePassword, trigger: 'blur' }],
|
||||||
|
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }],
|
||||||
|
agreeTerms: [
|
||||||
|
{
|
||||||
|
type: 'enum',
|
||||||
|
enum: [true],
|
||||||
|
message: '请阅读并同意服务条款和隐私政策',
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle register
|
||||||
|
const handleRegister = async () => {
|
||||||
|
if (!registerFormRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
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))
|
||||||
|
|
||||||
|
// Success message
|
||||||
|
ElMessage.success('注册成功!正在跳转到登录页面...')
|
||||||
|
|
||||||
|
// Redirect to login page
|
||||||
|
setTimeout(() => {
|
||||||
|
router.push('/login')
|
||||||
|
}, 1500)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register failed:', error)
|
||||||
|
ElMessage.error('注册失败,请稍后重试')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+13
-15
@@ -9,7 +9,7 @@ import systemRoutes from './systemRoutes'
|
|||||||
NProgress.configure({
|
NProgress.configure({
|
||||||
showSpinner: false,
|
showSpinner: false,
|
||||||
trickleSpeed: 200,
|
trickleSpeed: 200,
|
||||||
minimum: 0.3
|
minimum: 0.3,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,14 +21,14 @@ const notFoundRoute = {
|
|||||||
component: () => import('../layouts/other/404.vue'),
|
component: () => import('../layouts/other/404.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '404',
|
title: '404',
|
||||||
hidden: true
|
hidden: true,
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建路由实例
|
// 创建路由实例
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: systemRoutes
|
routes: systemRoutes,
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,8 +63,8 @@ function transformMenusToRoutes(menus) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return menus
|
return menus
|
||||||
.filter(menu => menu && menu.path)
|
.filter((menu) => menu && menu.path)
|
||||||
.map(menu => {
|
.map((menu) => {
|
||||||
const route = {
|
const route = {
|
||||||
path: menu.path,
|
path: menu.path,
|
||||||
name: menu.name || menu.path.replace(/\//g, '-'),
|
name: menu.name || menu.path.replace(/\//g, '-'),
|
||||||
@@ -74,8 +74,8 @@ function transformMenusToRoutes(menus) {
|
|||||||
hidden: menu.hidden || menu.meta?.hidden,
|
hidden: menu.hidden || menu.meta?.hidden,
|
||||||
keepAlive: menu.meta?.keepAlive || false,
|
keepAlive: menu.meta?.keepAlive || false,
|
||||||
affix: menu.meta?.affix || 0,
|
affix: menu.meta?.affix || 0,
|
||||||
role: menu.meta?.role || []
|
role: menu.meta?.role || [],
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理组件
|
// 处理组件
|
||||||
@@ -107,9 +107,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
NProgress.start()
|
NProgress.start()
|
||||||
|
|
||||||
// 设置页面标题
|
// 设置页面标题
|
||||||
document.title = to.meta.title
|
document.title = to.meta.title ? `${to.meta.title} - ${config.APP_NAME}` : config.APP_NAME
|
||||||
? `${to.meta.title} - ${config.APP_NAME}`
|
|
||||||
: config.APP_NAME
|
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const isLoggedIn = userStore.isLoggedIn()
|
const isLoggedIn = userStore.isLoggedIn()
|
||||||
@@ -126,7 +124,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
// 保存目标路由,登录后跳转
|
// 保存目标路由,登录后跳转
|
||||||
next({
|
next({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
query: { redirect: to.fullPath }
|
query: { redirect: to.fullPath },
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -149,7 +147,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
const dynamicRoutes = transformMenusToRoutes(mergedMenus)
|
const dynamicRoutes = transformMenusToRoutes(mergedMenus)
|
||||||
|
|
||||||
// 添加动态路由到 Layout 的子路由
|
// 添加动态路由到 Layout 的子路由
|
||||||
dynamicRoutes.forEach(route => {
|
dynamicRoutes.forEach((route) => {
|
||||||
router.addRoute('Layout', route)
|
router.addRoute('Layout', route)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -172,7 +170,7 @@ router.beforeEach(async (to, from, next) => {
|
|||||||
userStore.logout()
|
userStore.logout()
|
||||||
next({
|
next({
|
||||||
path: '/login',
|
path: '/login',
|
||||||
query: { redirect: to.fullPath }
|
query: { redirect: to.fullPath },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -196,7 +194,7 @@ export function resetRouter() {
|
|||||||
// 重置为初始路由
|
// 重置为初始路由
|
||||||
const newRouter = createRouter({
|
const newRouter = createRouter({
|
||||||
history: createWebHashHistory(),
|
history: createWebHashHistory(),
|
||||||
routes: systemRoutes
|
routes: systemRoutes,
|
||||||
})
|
})
|
||||||
|
|
||||||
router.matcher = newRouter.matcher
|
router.matcher = newRouter.matcher
|
||||||
|
|||||||
@@ -7,27 +7,27 @@ const systemRoutes = [
|
|||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
component: () => import('../pages/login/index.vue'),
|
component: () => import('../pages/ucenter/login/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'login',
|
title: '登录',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: '/register',
|
||||||
name: 'Register',
|
name: 'Register',
|
||||||
component: () => import('../pages/login/userRegister.vue'),
|
component: () => import('../pages/ucenter/register/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'register',
|
title: '注册',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/reset-password',
|
path: '/forgot-password',
|
||||||
name: 'ResetPassword',
|
name: 'ForgotPassword',
|
||||||
component: () => import('../pages/login/resetPassword.vue'),
|
component: () => import('../pages/ucenter/forgot-password/index.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: 'resetPassword',
|
title: '找回密码',
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,36 +1,31 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import i18n from '@/i18n'
|
import i18n from '@/i18n'
|
||||||
import { customStorage } from '../persist'
|
|
||||||
|
|
||||||
export const useI18nStore = defineStore(
|
export const useI18nStore = defineStore('i18n', {
|
||||||
'i18n',
|
|
||||||
{
|
|
||||||
state: () => ({
|
state: () => ({
|
||||||
currentLocale: 'zh-CN',
|
currentLocale: 'zh-CN',
|
||||||
availableLocales: [
|
availableLocales: [
|
||||||
{ label: '简体中文', value: 'zh-CN' },
|
{ label: '简体中文', value: 'zh-CN' },
|
||||||
{ label: 'English', value: 'en-US' }
|
{ label: 'English', value: 'en-US' },
|
||||||
]
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getters: {
|
getters: {
|
||||||
localeLabel: (state) => {
|
localeLabel: (state) => {
|
||||||
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
|
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
|
||||||
return locale ? locale.label : ''
|
return locale ? locale.label : ''
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
actions: {
|
actions: {
|
||||||
setLocale(locale) {
|
setLocale(locale) {
|
||||||
this.currentLocale = locale
|
this.currentLocale = locale
|
||||||
i18n.global.locale.value = locale
|
i18n.global.locale.value = locale
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
persist: {
|
persist: {
|
||||||
key: 'i18n-store',
|
key: 'i18n-store',
|
||||||
storage: customStorage,
|
pick: ['currentLocale'],
|
||||||
pick: ['currentLocale']
|
},
|
||||||
}
|
})
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { customStorage } from '../persist'
|
|
||||||
|
|
||||||
export const useLayoutStore = defineStore(
|
export const useLayoutStore = defineStore(
|
||||||
'layout',
|
'layout',
|
||||||
@@ -123,7 +122,6 @@ export const useLayoutStore = defineStore(
|
|||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
key: 'layout-store',
|
key: 'layout-store',
|
||||||
storage: customStorage,
|
|
||||||
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'],
|
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { resetRouter } from '../../router'
|
import { resetRouter } from '../../router'
|
||||||
import { customStorage } from '../persist'
|
|
||||||
import userRoutes from '@/config/routes'
|
import userRoutes from '@/config/routes'
|
||||||
|
|
||||||
export const useUserStore = defineStore(
|
export const useUserStore = defineStore(
|
||||||
@@ -42,14 +41,14 @@ export const useUserStore = defineStore(
|
|||||||
const menuMap = new Map()
|
const menuMap = new Map()
|
||||||
|
|
||||||
// 先添加静态菜单
|
// 先添加静态菜单
|
||||||
staticMenus.forEach(menu => {
|
staticMenus.forEach((menu) => {
|
||||||
if (menu.path) {
|
if (menu.path) {
|
||||||
menuMap.set(menu.path, menu)
|
menuMap.set(menu.path, menu)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 添加后端菜单,如果路径重复则覆盖
|
// 添加后端菜单,如果路径重复则覆盖
|
||||||
newMenu.forEach(menu => {
|
newMenu.forEach((menu) => {
|
||||||
if (menu.path) {
|
if (menu.path) {
|
||||||
menuMap.set(menu.path, menu)
|
menuMap.set(menu.path, menu)
|
||||||
}
|
}
|
||||||
@@ -72,7 +71,7 @@ export const useUserStore = defineStore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 设置权限
|
// 设置权限
|
||||||
function setPermissions(data){
|
function setPermissions(data) {
|
||||||
permissions.value = data
|
permissions.value = data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,8 +110,7 @@ export const useUserStore = defineStore(
|
|||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
key: 'user-store',
|
key: 'user-store',
|
||||||
storage: customStorage,
|
pick: ['token', 'refreshToken', 'userInfo', 'menu'],
|
||||||
pick: ['token', 'refreshToken', 'userInfo', 'menu']
|
},
|
||||||
}
|
},
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
* @Descripttion: Pinia 持久化存储适配器 - 使用 tool.data 封装的 localStorage
|
|
||||||
* @version: 1.0
|
|
||||||
*/
|
|
||||||
|
|
||||||
import tool from '@/utils/tool'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自定义存储适配器
|
|
||||||
* 使用 tool.data 的 set/get/remove 方法,支持加密和过期时间
|
|
||||||
*/
|
|
||||||
export const customStorage = {
|
|
||||||
/**
|
|
||||||
* 获取数据
|
|
||||||
* @param {string} key - 存储键
|
|
||||||
* @returns {any} - 存储的数据
|
|
||||||
*/
|
|
||||||
getItem: (key) => {
|
|
||||||
return tool.data.get(key)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置数据
|
|
||||||
* @param {string} key - 存储键
|
|
||||||
* @param {any} value - 要存储的值
|
|
||||||
*/
|
|
||||||
setItem: (key, value) => {
|
|
||||||
tool.data.set(key, value)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 删除数据
|
|
||||||
* @param {string} key - 存储键
|
|
||||||
*/
|
|
||||||
removeItem: (key) => {
|
|
||||||
tool.data.remove(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 默认持久化配置
|
|
||||||
*/
|
|
||||||
export const defaultPersistConfig = {
|
|
||||||
storage: customStorage,
|
|
||||||
// 可以在这里添加其他全局配置,如过期时间等
|
|
||||||
// serializer: {
|
|
||||||
// serialize: (state) => JSON.stringify(state),
|
|
||||||
// deserialize: (value) => JSON.parse(value)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
+12
-12
@@ -1,12 +1,12 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
import { useUserStore } from '@/stores/modules/user'
|
import { useUserStore } from '@/stores/modules/user'
|
||||||
import { message } from 'ant-design-vue'
|
import { ElMessage } from 'element-plus'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
|
||||||
const http = axios.create({
|
const http = axios.create({
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
baseURL: config.API_URL
|
baseURL: config.API_URL,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 是否正在刷新 token
|
// 是否正在刷新 token
|
||||||
@@ -29,7 +29,7 @@ http.interceptors.request.use(
|
|||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
@@ -45,7 +45,7 @@ http.interceptors.response.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 其他错误码处理
|
// 其他错误码处理
|
||||||
message.error(message || '请求失败')
|
ElMessage.error(message || '请求失败')
|
||||||
return Promise.reject(new Error(message || '请求失败'))
|
return Promise.reject(new Error(message || '请求失败'))
|
||||||
},
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
@@ -54,7 +54,7 @@ http.interceptors.response.use(
|
|||||||
|
|
||||||
// 无响应(网络错误、超时等)
|
// 无响应(网络错误、超时等)
|
||||||
if (!response) {
|
if (!response) {
|
||||||
message.error('网络错误,请检查网络连接')
|
ElMessage.error('网络错误,请检查网络连接')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ http.interceptors.response.use(
|
|||||||
requests = []
|
requests = []
|
||||||
userStore.logout()
|
userStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
message.error('登录已过期,请重新登录')
|
ElMessage.error('登录已过期,请重新登录')
|
||||||
return Promise.reject(refreshError)
|
return Promise.reject(refreshError)
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
@@ -104,27 +104,27 @@ http.interceptors.response.use(
|
|||||||
|
|
||||||
// 403 禁止访问
|
// 403 禁止访问
|
||||||
if (status === 403) {
|
if (status === 403) {
|
||||||
message.error('没有权限访问该资源')
|
ElMessage.error('没有权限访问该资源')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 404 资源不存在
|
// 404 资源不存在
|
||||||
if (status === 404) {
|
if (status === 404) {
|
||||||
message.error('请求的资源不存在')
|
ElMessage.error('请求的资源不存在')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 500 服务器错误
|
// 500 服务器错误
|
||||||
if (status >= 500) {
|
if (status >= 500) {
|
||||||
message.error('服务器错误,请稍后重试')
|
ElMessage.error('服务器错误,请稍后重试')
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他错误
|
// 其他错误
|
||||||
const errorMessage = data?.message || error.message || '请求失败'
|
const errorMessage = data?.message || error.message || '请求失败'
|
||||||
message.error(errorMessage)
|
ElMessage.error(errorMessage)
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 刷新 token 的方法
|
// 刷新 token 的方法
|
||||||
@@ -136,7 +136,7 @@ async function refreshToken() {
|
|||||||
const refreshTokenValue = userStore.refreshToken
|
const refreshTokenValue = userStore.refreshToken
|
||||||
|
|
||||||
const response = await axios.post(refreshUrl, {
|
const response = await axios.post(refreshUrl, {
|
||||||
refreshToken: refreshTokenValue
|
refreshToken: refreshTokenValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 假设返回格式为 { code, data: { token, refreshToken } }
|
// 假设返回格式为 { code, data: { token, refreshToken } }
|
||||||
|
|||||||
+175
-217
@@ -5,10 +5,10 @@
|
|||||||
* @LastEditTime: 2026年1月15日
|
* @LastEditTime: 2026年1月15日
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from 'crypto-js'
|
||||||
import sysConfig from "@/config";
|
import sysConfig from '@/config'
|
||||||
|
|
||||||
const tool = {};
|
const tool = {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象)
|
* 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象)
|
||||||
@@ -17,19 +17,19 @@ const tool = {};
|
|||||||
*/
|
*/
|
||||||
tool.isValid = function (value) {
|
tool.isValid = function (value) {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
if (typeof value === "string" && value.trim() === "") {
|
if (typeof value === 'string' && value.trim() === '') {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
if (Array.isArray(value) && value.length === 0) {
|
if (Array.isArray(value) && value.length === 0) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
if (typeof value === "object" && Object.keys(value).length === 0) {
|
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
||||||
return false;
|
return false
|
||||||
}
|
}
|
||||||
return true;
|
return true
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 防抖函数
|
* 防抖函数
|
||||||
@@ -39,21 +39,21 @@ tool.isValid = function (value) {
|
|||||||
* @returns {Function}
|
* @returns {Function}
|
||||||
*/
|
*/
|
||||||
tool.debounce = function (func, wait = 300, immediate = false) {
|
tool.debounce = function (func, wait = 300, immediate = false) {
|
||||||
let timeout;
|
let timeout
|
||||||
return function (...args) {
|
return function (...args) {
|
||||||
const context = this;
|
const context = this
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout)
|
||||||
if (immediate && !timeout) {
|
if (immediate && !timeout) {
|
||||||
func.apply(context, args);
|
func.apply(context, args)
|
||||||
}
|
}
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
timeout = null;
|
timeout = null
|
||||||
if (!immediate) {
|
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}
|
* @returns {Function}
|
||||||
*/
|
*/
|
||||||
tool.throttle = function (func, wait = 300, options = {}) {
|
tool.throttle = function (func, wait = 300, options = {}) {
|
||||||
let timeout;
|
let timeout
|
||||||
let previous = 0;
|
let previous = 0
|
||||||
const { leading = true, trailing = true } = options;
|
const { leading = true, trailing = true } = options
|
||||||
|
|
||||||
return function (...args) {
|
return function (...args) {
|
||||||
const context = this;
|
const context = this
|
||||||
const now = Date.now();
|
const now = Date.now()
|
||||||
|
|
||||||
if (!previous && !leading) {
|
if (!previous && !leading) {
|
||||||
previous = now;
|
previous = now
|
||||||
}
|
}
|
||||||
|
|
||||||
const remaining = wait - (now - previous);
|
const remaining = wait - (now - previous)
|
||||||
|
|
||||||
if (remaining <= 0 || remaining > wait) {
|
if (remaining <= 0 || remaining > wait) {
|
||||||
if (timeout) {
|
if (timeout) {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout)
|
||||||
timeout = null;
|
timeout = null
|
||||||
}
|
}
|
||||||
previous = now;
|
previous = now
|
||||||
func.apply(context, args);
|
func.apply(context, args)
|
||||||
} else if (!timeout && trailing) {
|
} else if (!timeout && trailing) {
|
||||||
timeout = setTimeout(() => {
|
timeout = setTimeout(() => {
|
||||||
previous = leading ? Date.now() : 0;
|
previous = leading ? Date.now() : 0
|
||||||
timeout = null;
|
timeout = null
|
||||||
func.apply(context, args);
|
func.apply(context, args)
|
||||||
}, remaining);
|
}, remaining)
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 深拷贝对象(支持循环引用)
|
* 深拷贝对象(支持循环引用)
|
||||||
@@ -101,99 +101,88 @@ tool.throttle = function (func, wait = 300, options = {}) {
|
|||||||
* @returns {*}
|
* @returns {*}
|
||||||
*/
|
*/
|
||||||
tool.deepClone = function (obj, hash = new WeakMap()) {
|
tool.deepClone = function (obj, hash = new WeakMap()) {
|
||||||
if (obj === null || typeof obj !== "object") {
|
if (obj === null || typeof obj !== 'object') {
|
||||||
return obj;
|
return obj
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hash.has(obj)) {
|
if (hash.has(obj)) {
|
||||||
return hash.get(obj);
|
return hash.get(obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
const clone = Array.isArray(obj) ? [] : {};
|
const clone = Array.isArray(obj) ? [] : {}
|
||||||
hash.set(obj, clone);
|
hash.set(obj, clone)
|
||||||
|
|
||||||
for (const key in obj) {
|
for (const key in obj) {
|
||||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
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 */
|
/* localStorage */
|
||||||
tool.data = {
|
tool.data = {
|
||||||
set(key, data, datetime = 0) {
|
set(key, data, datetime = 0) {
|
||||||
//加密
|
//加密
|
||||||
if (sysConfig.LS_ENCRYPTION == "AES") {
|
if (sysConfig.LS_ENCRYPTION == 'AES') {
|
||||||
data = tool.crypto.AES.encrypt(
|
data = tool.crypto.AES.encrypt(JSON.stringify(data), sysConfig.LS_ENCRYPTION_key)
|
||||||
JSON.stringify(data),
|
|
||||||
sysConfig.LS_ENCRYPTION_key,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
let cacheValue = {
|
let cacheValue = {
|
||||||
content: data,
|
content: data,
|
||||||
datetime:
|
datetime: parseInt(datetime) === 0 ? 0 : new Date().getTime() + parseInt(datetime) * 1000,
|
||||||
parseInt(datetime) === 0
|
}
|
||||||
? 0
|
return localStorage.setItem(key, JSON.stringify(cacheValue))
|
||||||
: new Date().getTime() + parseInt(datetime) * 1000,
|
|
||||||
};
|
|
||||||
return localStorage.setItem(key, JSON.stringify(cacheValue));
|
|
||||||
},
|
},
|
||||||
get(key) {
|
get(key) {
|
||||||
try {
|
try {
|
||||||
const value = JSON.parse(localStorage.getItem(key));
|
const value = JSON.parse(localStorage.getItem(key))
|
||||||
if (value) {
|
if (value) {
|
||||||
let nowTime = new Date().getTime();
|
let nowTime = new Date().getTime()
|
||||||
if (nowTime > value.datetime && value.datetime != 0) {
|
if (nowTime > value.datetime && value.datetime != 0) {
|
||||||
localStorage.removeItem(key);
|
localStorage.removeItem(key)
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
//解密
|
//解密
|
||||||
if (sysConfig.LS_ENCRYPTION == "AES") {
|
if (sysConfig.LS_ENCRYPTION == 'AES') {
|
||||||
value.content = JSON.parse(
|
value.content = JSON.parse(tool.crypto.AES.decrypt(value.content, sysConfig.LS_ENCRYPTION_key))
|
||||||
tool.crypto.AES.decrypt(
|
|
||||||
value.content,
|
|
||||||
sysConfig.LS_ENCRYPTION_key,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return value.content;
|
return value.content
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
remove(key) {
|
remove(key) {
|
||||||
return localStorage.removeItem(key);
|
return localStorage.removeItem(key)
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
return localStorage.clear();
|
return localStorage.clear()
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
/*sessionStorage*/
|
/*sessionStorage*/
|
||||||
tool.session = {
|
tool.session = {
|
||||||
set(table, settings) {
|
set(table, settings) {
|
||||||
const _set = JSON.stringify(settings);
|
const _set = JSON.stringify(settings)
|
||||||
return sessionStorage.setItem(table, _set);
|
return sessionStorage.setItem(table, _set)
|
||||||
},
|
},
|
||||||
get(table) {
|
get(table) {
|
||||||
const data = sessionStorage.getItem(table);
|
const data = sessionStorage.getItem(table)
|
||||||
try {
|
try {
|
||||||
return JSON.parse(data);
|
return JSON.parse(data)
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
remove(table) {
|
remove(table) {
|
||||||
return sessionStorage.removeItem(table);
|
return sessionStorage.removeItem(table)
|
||||||
},
|
},
|
||||||
clear() {
|
clear() {
|
||||||
return sessionStorage.clear();
|
return sessionStorage.clear()
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
/*cookie*/
|
/*cookie*/
|
||||||
tool.cookie = {
|
tool.cookie = {
|
||||||
@@ -210,28 +199,28 @@ tool.cookie = {
|
|||||||
domain: null,
|
domain: null,
|
||||||
secure: false,
|
secure: false,
|
||||||
httpOnly: false,
|
httpOnly: false,
|
||||||
sameSite: "Lax",
|
sameSite: 'Lax',
|
||||||
...config,
|
...config,
|
||||||
};
|
}
|
||||||
let cookieStr = `${name}=${encodeURIComponent(value)}`;
|
let cookieStr = `${name}=${encodeURIComponent(value)}`
|
||||||
if (cfg.expires) {
|
if (cfg.expires) {
|
||||||
const exp = new Date();
|
const exp = new Date()
|
||||||
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000);
|
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000)
|
||||||
cookieStr += `;expires=${exp.toUTCString()}`;
|
cookieStr += `;expires=${exp.toUTCString()}`
|
||||||
}
|
}
|
||||||
if (cfg.path) {
|
if (cfg.path) {
|
||||||
cookieStr += `;path=${cfg.path}`;
|
cookieStr += `;path=${cfg.path}`
|
||||||
}
|
}
|
||||||
if (cfg.domain) {
|
if (cfg.domain) {
|
||||||
cookieStr += `;domain=${cfg.domain}`;
|
cookieStr += `;domain=${cfg.domain}`
|
||||||
}
|
}
|
||||||
if (cfg.secure) {
|
if (cfg.secure) {
|
||||||
cookieStr += `;secure`;
|
cookieStr += `;secure`
|
||||||
}
|
}
|
||||||
if (cfg.sameSite) {
|
if (cfg.sameSite) {
|
||||||
cookieStr += `;SameSite=${cfg.sameSite}`;
|
cookieStr += `;SameSite=${cfg.sameSite}`
|
||||||
}
|
}
|
||||||
document.cookie = cookieStr;
|
document.cookie = cookieStr
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 获取cookie
|
* 获取cookie
|
||||||
@@ -239,24 +228,22 @@ tool.cookie = {
|
|||||||
* @returns {string|null}
|
* @returns {string|null}
|
||||||
*/
|
*/
|
||||||
get(name) {
|
get(name) {
|
||||||
const arr = document.cookie.match(
|
const arr = document.cookie.match(new RegExp('(^| )' + name + '=([^;]*)(;|$)'))
|
||||||
new RegExp("(^| )" + name + "=([^;]*)(;|$)"),
|
|
||||||
);
|
|
||||||
if (arr != null) {
|
if (arr != null) {
|
||||||
return decodeURIComponent(arr[2]);
|
return decodeURIComponent(arr[2])
|
||||||
}
|
}
|
||||||
return null;
|
return null
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* 删除cookie
|
* 删除cookie
|
||||||
* @param {string} name - cookie名称
|
* @param {string} name - cookie名称
|
||||||
*/
|
*/
|
||||||
remove(name) {
|
remove(name) {
|
||||||
const exp = new Date();
|
const exp = new Date()
|
||||||
exp.setTime(exp.getTime() - 1);
|
exp.setTime(exp.getTime() - 1)
|
||||||
document.cookie = `${name}=;expires=${exp.toUTCString()}`;
|
document.cookie = `${name}=;expires=${exp.toUTCString()}`
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
/* Fullscreen */
|
/* Fullscreen */
|
||||||
/**
|
/**
|
||||||
@@ -264,34 +251,29 @@ tool.cookie = {
|
|||||||
* @param {HTMLElement} element - 要全屏的元素
|
* @param {HTMLElement} element - 要全屏的元素
|
||||||
*/
|
*/
|
||||||
tool.screen = function (element) {
|
tool.screen = function (element) {
|
||||||
const isFull = !!(
|
const isFull = !!(document.webkitIsFullScreen || document.mozFullScreen || document.msFullscreenElement || document.fullscreenElement)
|
||||||
document.webkitIsFullScreen ||
|
|
||||||
document.mozFullScreen ||
|
|
||||||
document.msFullscreenElement ||
|
|
||||||
document.fullscreenElement
|
|
||||||
);
|
|
||||||
if (isFull) {
|
if (isFull) {
|
||||||
if (document.exitFullscreen) {
|
if (document.exitFullscreen) {
|
||||||
document.exitFullscreen();
|
document.exitFullscreen()
|
||||||
} else if (document.msExitFullscreen) {
|
} else if (document.msExitFullscreen) {
|
||||||
document.msExitFullscreen();
|
document.msExitFullscreen()
|
||||||
} else if (document.mozCancelFullScreen) {
|
} else if (document.mozCancelFullScreen) {
|
||||||
document.mozCancelFullScreen();
|
document.mozCancelFullScreen()
|
||||||
} else if (document.webkitExitFullscreen) {
|
} else if (document.webkitExitFullscreen) {
|
||||||
document.webkitExitFullscreen();
|
document.webkitExitFullscreen()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (element.requestFullscreen) {
|
if (element.requestFullscreen) {
|
||||||
element.requestFullscreen();
|
element.requestFullscreen()
|
||||||
} else if (element.msRequestFullscreen) {
|
} else if (element.msRequestFullscreen) {
|
||||||
element.msRequestFullscreen();
|
element.msRequestFullscreen()
|
||||||
} else if (element.mozRequestFullScreen) {
|
} else if (element.mozRequestFullScreen) {
|
||||||
element.mozRequestFullScreen();
|
element.mozRequestFullScreen()
|
||||||
} else if (element.webkitRequestFullscreen) {
|
} else if (element.webkitRequestFullscreen) {
|
||||||
element.webkitRequestFullscreen();
|
element.webkitRequestFullscreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
/* 复制对象(浅拷贝) */
|
/* 复制对象(浅拷贝) */
|
||||||
/**
|
/**
|
||||||
@@ -300,11 +282,11 @@ tool.screen = function (element) {
|
|||||||
* @returns {*} - 拷贝后的对象
|
* @returns {*} - 拷贝后的对象
|
||||||
*/
|
*/
|
||||||
tool.objCopy = function (obj) {
|
tool.objCopy = function (obj) {
|
||||||
if (obj === null || typeof obj !== "object") {
|
if (obj === null || typeof obj !== 'object') {
|
||||||
return obj;
|
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"
|
* @param {string} fmt - 格式化字符串,默认 "yyyy-MM-dd hh:mm:ss"
|
||||||
* @returns {string} - 格式化后的日期字符串
|
* @returns {string} - 格式化后的日期字符串
|
||||||
*/
|
*/
|
||||||
tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") {
|
tool.dateFormat = function (date, fmt = 'yyyy-MM-dd hh:mm:ss') {
|
||||||
if (!date) return "";
|
if (!date) return ''
|
||||||
const dateObj = new Date(date);
|
const dateObj = new Date(date)
|
||||||
if (isNaN(dateObj.getTime())) return "";
|
if (isNaN(dateObj.getTime())) return ''
|
||||||
|
|
||||||
const o = {
|
const o = {
|
||||||
"M+": dateObj.getMonth() + 1, // 月份
|
'M+': dateObj.getMonth() + 1, // 月份
|
||||||
"d+": dateObj.getDate(), // 日
|
'd+': dateObj.getDate(), // 日
|
||||||
"h+": dateObj.getHours(), // 小时
|
'h+': dateObj.getHours(), // 小时
|
||||||
"m+": dateObj.getMinutes(), // 分
|
'm+': dateObj.getMinutes(), // 分
|
||||||
"s+": dateObj.getSeconds(), // 秒
|
's+': dateObj.getSeconds(), // 秒
|
||||||
"q+": Math.floor((dateObj.getMonth() + 3) / 3), // 季度
|
'q+': Math.floor((dateObj.getMonth() + 3) / 3), // 季度
|
||||||
S: dateObj.getMilliseconds(), // 毫秒
|
S: dateObj.getMilliseconds(), // 毫秒
|
||||||
};
|
}
|
||||||
if (/(y+)/.test(fmt)) {
|
if (/(y+)/.test(fmt)) {
|
||||||
fmt = fmt.replace(
|
fmt = fmt.replace(RegExp.$1, (dateObj.getFullYear() + '').substr(4 - RegExp.$1.length))
|
||||||
RegExp.$1,
|
|
||||||
(dateObj.getFullYear() + "").substr(4 - RegExp.$1.length),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
for (const k in o) {
|
for (const k in o) {
|
||||||
if (new RegExp("(" + k + ")").test(fmt)) {
|
if (new RegExp('(' + k + ')').test(fmt)) {
|
||||||
fmt = fmt.replace(
|
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
|
||||||
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} - 格式化后的字符串
|
* @returns {string} - 格式化后的字符串
|
||||||
*/
|
*/
|
||||||
tool.groupSeparator = function (num, decimals = 0) {
|
tool.groupSeparator = function (num, decimals = 0) {
|
||||||
if (num === null || num === undefined || num === "") return "";
|
if (num === null || num === undefined || num === '') return ''
|
||||||
const numStr = Number(num).toFixed(decimals);
|
const numStr = Number(num).toFixed(decimals)
|
||||||
const parts = numStr.split(".");
|
const parts = numStr.split('.')
|
||||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||||
return parts.join(".");
|
return parts.join('.')
|
||||||
};
|
}
|
||||||
|
|
||||||
/* 常用加解密 */
|
/* 常用加解密 */
|
||||||
tool.crypto = {
|
tool.crypto = {
|
||||||
//MD5加密
|
//MD5加密
|
||||||
MD5(data) {
|
MD5(data) {
|
||||||
return CryptoJS.MD5(data).toString();
|
return CryptoJS.MD5(data).toString()
|
||||||
},
|
},
|
||||||
//BASE64加解密
|
//BASE64加解密
|
||||||
BASE64: {
|
BASE64: {
|
||||||
encrypt(data) {
|
encrypt(data) {
|
||||||
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data));
|
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data))
|
||||||
},
|
},
|
||||||
decrypt(cipher) {
|
decrypt(cipher) {
|
||||||
return CryptoJS.enc.Base64.parse(cipher).toString(
|
return CryptoJS.enc.Base64.parse(cipher).toString(CryptoJS.enc.Utf8)
|
||||||
CryptoJS.enc.Utf8,
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
//AES加解密
|
//AES加解密
|
||||||
AES: {
|
AES: {
|
||||||
encrypt(data, secretKey, config = {}) {
|
encrypt(data, secretKey, config = {}) {
|
||||||
if (secretKey.length % 8 != 0) {
|
if (secretKey.length % 8 != 0) {
|
||||||
console.warn(
|
console.warn('[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。')
|
||||||
"[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const result = CryptoJS.AES.encrypt(
|
const result = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(secretKey), {
|
||||||
data,
|
iv: CryptoJS.enc.Utf8.parse(config.iv || ''),
|
||||||
CryptoJS.enc.Utf8.parse(secretKey),
|
mode: CryptoJS.mode[config.mode || 'ECB'],
|
||||||
{
|
padding: CryptoJS.pad[config.padding || 'Pkcs7'],
|
||||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
|
})
|
||||||
mode: CryptoJS.mode[config.mode || "ECB"],
|
return result.toString()
|
||||||
padding: CryptoJS.pad[config.padding || "Pkcs7"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return result.toString();
|
|
||||||
},
|
},
|
||||||
decrypt(cipher, secretKey, config = {}) {
|
decrypt(cipher, secretKey, config = {}) {
|
||||||
const result = CryptoJS.AES.decrypt(
|
const result = CryptoJS.AES.decrypt(cipher, CryptoJS.enc.Utf8.parse(secretKey), {
|
||||||
cipher,
|
iv: CryptoJS.enc.Utf8.parse(config.iv || ''),
|
||||||
CryptoJS.enc.Utf8.parse(secretKey),
|
mode: CryptoJS.mode[config.mode || 'ECB'],
|
||||||
{
|
padding: CryptoJS.pad[config.padding || 'Pkcs7'],
|
||||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
|
})
|
||||||
mode: CryptoJS.mode[config.mode || "ECB"],
|
return CryptoJS.enc.Utf8.stringify(result)
|
||||||
padding: CryptoJS.pad[config.padding || "Pkcs7"],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return CryptoJS.enc.Utf8.stringify(result);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
||||||
/* 树形数据转扁平数组 */
|
/* 树形数据转扁平数组 */
|
||||||
/**
|
/**
|
||||||
@@ -419,22 +381,22 @@ tool.crypto = {
|
|||||||
* @param {Object} config - 配置项 { children: "children" }
|
* @param {Object} config - 配置项 { children: "children" }
|
||||||
* @returns {Array} - 扁平化后的数组
|
* @returns {Array} - 扁平化后的数组
|
||||||
*/
|
*/
|
||||||
tool.treeToList = function (tree, config = { children: "children" }) {
|
tool.treeToList = function (tree, config = { children: 'children' }) {
|
||||||
const result = [];
|
const result = []
|
||||||
tree.forEach((item) => {
|
tree.forEach((item) => {
|
||||||
const tmp = { ...item };
|
const tmp = { ...item }
|
||||||
const childrenKey = config.children || "children";
|
const childrenKey = config.children || 'children'
|
||||||
|
|
||||||
if (tmp[childrenKey] && tmp[childrenKey].length > 0) {
|
if (tmp[childrenKey] && tmp[childrenKey].length > 0) {
|
||||||
result.push({ ...item });
|
result.push({ ...item })
|
||||||
const childrenRoutes = tool.treeToList(tmp[childrenKey], config);
|
const childrenRoutes = tool.treeToList(tmp[childrenKey], config)
|
||||||
result.push(...childrenRoutes);
|
result.push(...childrenRoutes)
|
||||||
} else {
|
} 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: [] }
|
* @param {Object} config - 配置项 { pid: "parent_id", idField: "id", field: [] }
|
||||||
* @returns {*} - 父节点数据或指定字段
|
* @returns {*} - 父节点数据或指定字段
|
||||||
*/
|
*/
|
||||||
tool.get_parents = function (
|
tool.get_parents = function (list, targetId = 0, config = { pid: 'parent_id', idField: 'id', field: [] }) {
|
||||||
list,
|
let res = null
|
||||||
targetId = 0,
|
|
||||||
config = { pid: "parent_id", idField: "id", field: [] },
|
|
||||||
) {
|
|
||||||
let res = null;
|
|
||||||
list.forEach((item) => {
|
list.forEach((item) => {
|
||||||
if (item[config.idField || "id"] === targetId) {
|
if (item[config.idField || 'id'] === targetId) {
|
||||||
if (config.field && config.field.length > 1) {
|
if (config.field && config.field.length > 1) {
|
||||||
res = {};
|
res = {}
|
||||||
config.field.forEach((field) => {
|
config.field.forEach((field) => {
|
||||||
res[field] = item[field];
|
res[field] = item[field]
|
||||||
});
|
})
|
||||||
} else if (config.field && config.field.length === 1) {
|
} else if (config.field && config.field.length === 1) {
|
||||||
res = item[config.field[0]];
|
res = item[config.field[0]]
|
||||||
} else {
|
} else {
|
||||||
res = item;
|
res = item
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
return res;
|
return res
|
||||||
};
|
}
|
||||||
|
|
||||||
/* 获取数据字段 */
|
/* 获取数据字段 */
|
||||||
/**
|
/**
|
||||||
@@ -475,25 +433,25 @@ tool.get_parents = function (
|
|||||||
* @returns {*} - 提取的字段数据
|
* @returns {*} - 提取的字段数据
|
||||||
*/
|
*/
|
||||||
tool.getDataField = function (data, fields = []) {
|
tool.getDataField = function (data, fields = []) {
|
||||||
if (!data || typeof data !== "object") {
|
if (!data || typeof data !== 'object') {
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
if (fields.length === 0) {
|
if (fields.length === 0) {
|
||||||
return data;
|
return data
|
||||||
}
|
}
|
||||||
if (fields.length === 1) {
|
if (fields.length === 1) {
|
||||||
return data[fields[0]];
|
return data[fields[0]]
|
||||||
} else {
|
} else {
|
||||||
const result = {};
|
const result = {}
|
||||||
fields.forEach((field) => {
|
fields.forEach((field) => {
|
||||||
result[field] = data[field];
|
result[field] = data[field]
|
||||||
});
|
})
|
||||||
return result;
|
return result
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// 兼容旧函数名
|
// 兼容旧函数名
|
||||||
tool.tree_to_list = tool.treeToList;
|
tool.tree_to_list = tool.treeToList
|
||||||
tool.get_data_field = tool.getDataField;
|
tool.get_data_field = tool.getDataField
|
||||||
|
|
||||||
export default tool;
|
export default tool
|
||||||
|
|||||||
Reference in New Issue
Block a user