Compare commits
27 Commits
8c2d943dba
...
element
| Author | SHA1 | Date | |
|---|---|---|---|
| 24f7a1469b | |||
| bd07886ffa | |||
| 8b0e5a5642 | |||
| 2a9cb82fef | |||
| 3f58d013ca | |||
| 01e87acfd1 | |||
| 42be40ee9f | |||
| 1134ecb732 | |||
| 5569e73ef1 | |||
| 177c35cc15 | |||
| c0f27fd0ef | |||
| 8327f6c3e6 | |||
| 0608f0febb | |||
| 8283555457 | |||
| 1963ea7244 | |||
| 0c2ebc8501 | |||
| 72a6a6a709 | |||
| 1a3f3ecd82 | |||
| 155ec5c986 | |||
| 638c846aed | |||
| 431d2c7071 | |||
| 8d4290e131 | |||
| 656c328aef | |||
| 8e55f7de9d | |||
| 0f2a9a4cc3 | |||
| ee2047ba04 | |||
| f877097398 |
@@ -37,3 +37,4 @@ __screenshots__/
|
||||
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
.clinerules
|
||||
@@ -1,44 +1,158 @@
|
||||
# vueadmin
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
## 功能特性
|
||||
|
||||
## Recommended IDE Setup
|
||||
### 1. 权限管理
|
||||
- 基于角色的权限控制 (RBAC)
|
||||
- 动态路由生成
|
||||
- 菜单权限过滤
|
||||
- 按钮级权限控制
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
### 2. 布局系统
|
||||
- 响应式布局设计
|
||||
- 顶部导航栏
|
||||
- 侧边菜单栏
|
||||
- 面包屑导航
|
||||
- 标签页导航
|
||||
- 设置面板
|
||||
|
||||
## Recommended Browser Setup
|
||||
### 3. 国际化支持
|
||||
- 多语言切换 (中文/英文)
|
||||
- 动态加载语言包
|
||||
- 支持自定义语言扩展
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
### 4. 组件库
|
||||
- 自定义表格组件 (scTable)
|
||||
- 自定义表单组件 (scForm)
|
||||
- 自定义上传组件 (scUpload)
|
||||
- 其他常用业务组件
|
||||
|
||||
## Customize configuration
|
||||
### 5. 系统功能
|
||||
- 用户登录/注册/重置密码
|
||||
- 仪表盘
|
||||
- 系统设置
|
||||
- 用户管理
|
||||
- 角色管理
|
||||
- 菜单管理
|
||||
- 区域管理
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
## 安装和运行
|
||||
|
||||
## Project Setup
|
||||
### 环境要求
|
||||
- Node.js >= 20.19.0 || >= 22.12.0
|
||||
|
||||
```sh
|
||||
yarn
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
### 开发模式
|
||||
|
||||
```sh
|
||||
yarn dev
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
项目将在 `http://localhost:5173` 启动。
|
||||
|
||||
```sh
|
||||
yarn build
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
构建后的文件将输出到 `dist` 目录。
|
||||
|
||||
```sh
|
||||
yarn lint
|
||||
### 预览生产版本
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
### 代码检查和格式化
|
||||
|
||||
```bash
|
||||
# ESLint 检查并修复
|
||||
npm run lint
|
||||
|
||||
# Prettier 格式化代码
|
||||
npm run format
|
||||
```
|
||||
|
||||
## 配置说明
|
||||
|
||||
项目的主要配置文件位于 `src/config/index.js`,包含以下配置项:
|
||||
|
||||
```javascript
|
||||
{
|
||||
APP_NAME: 'vueadmin', // 应用名称
|
||||
DASHBOARD_URL: '/dashboard', // 仪表盘URL
|
||||
API_URL: 'https://www.tensent.cn/admin/', // API接口地址
|
||||
TIMEOUT: 50000, // 请求超时时间
|
||||
TOKEN_NAME: 'authorization', // Token名称
|
||||
TOKEN_PREFIX: 'Bearer ', // Token前缀
|
||||
LANG: 'zh-cn', // 默认语言
|
||||
// 更多配置...
|
||||
}
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 路由配置
|
||||
|
||||
路由配置分为两部分:
|
||||
1. 系统基础路由 (`src/router/systemRoutes.js`)
|
||||
2. 动态生成的业务路由 (由后端菜单数据转换)
|
||||
|
||||
### 组件开发
|
||||
|
||||
自定义组件位于 `src/components/` 目录,每个组件有独立的文件夹,包含组件文件和相关资源。
|
||||
|
||||
### API 调用
|
||||
|
||||
API 接口定义位于 `src/api/` 目录,使用 Axios 封装,支持拦截器、请求缓存等功能。
|
||||
|
||||
### 状态管理
|
||||
|
||||
使用 Pinia 进行状态管理,store 定义位于 `src/stores/` 目录,支持模块化管理。
|
||||
|
||||
## 国际化
|
||||
|
||||
国际化配置位于 `src/i18n/` 目录,支持中英文切换,可通过以下方式扩展其他语言:
|
||||
|
||||
1. 在 `src/i18n/locales/` 目录下添加新的语言文件
|
||||
2. 在 `src/i18n/index.js` 中注册新语言
|
||||
|
||||
## 权限管理
|
||||
|
||||
系统采用基于角色的权限控制 (RBAC),权限信息由后端提供,前端根据权限信息动态生成路由和菜单。
|
||||
|
||||
## 浏览器支持
|
||||
|
||||
- Chrome (最新版本)
|
||||
- Firefox (最新版本)
|
||||
- Safari (最新版本)
|
||||
- Edge (最新版本)
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保 Node.js 版本符合要求
|
||||
2. 开发环境下 API 请求可能需要配置代理
|
||||
3. 生产环境需要配置正确的 API 地址
|
||||
4. 首次运行需要先安装依赖
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.6.6
|
||||
- 升级 Vue 3 到 3.5.26
|
||||
- 升级 Vite 到 7.3.0
|
||||
- 升级 Ant Design Vue 到 4.2.6
|
||||
- 优化路由管理和权限控制
|
||||
- 修复已知 bug
|
||||
|
||||
### 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request 来帮助改进这个项目!
|
||||
+10
-4
@@ -15,27 +15,33 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"@ckeditor/ckeditor5-vue": "^7.3.0",
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.2",
|
||||
"ckeditor5": "^47.4.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.13.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pinia": "^3.0.4",
|
||||
"pinia-plugin-persistedstate": "^4.7.1",
|
||||
"vue": "^3.5.26",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^4.6.4"
|
||||
"vue-router": "^4.6.4",
|
||||
"vuedraggable": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/eslint-config-prettier": "^10.2.0",
|
||||
"sass-embedded": "^1.97.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "~10.6.2",
|
||||
"globals": "^17.0.0",
|
||||
"prettier": "3.7.4",
|
||||
"sass-embedded": "^1.97.2",
|
||||
"vite": "^7.3.0",
|
||||
"vite-plugin-vue-devtools": "^8.0.5"
|
||||
"vite-plugin-vue-devtools": "^8.0.5",
|
||||
"vue-eslint-parser": "^10.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-81
@@ -1,86 +1,12 @@
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useI18nStore } from './stores/modules/i18n'
|
||||
import { useLayoutStore } from './stores/modules/layout'
|
||||
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'
|
||||
|
||||
// i18n store
|
||||
const i18nStore = useI18nStore()
|
||||
|
||||
// layout store
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
// 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 {
|
||||
token: {
|
||||
colorPrimary: layoutStore.themeColor,
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
headerBg: '#fff',
|
||||
siderBg: '#001529',
|
||||
},
|
||||
Menu: {
|
||||
darkItemBg: '#001529',
|
||||
darkItemSelectedBg: '#1890ff',
|
||||
darkItemHoverBg: '#002140',
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
// 从持久化的 store 中读取语言设置并同步到 i18n
|
||||
i18n.global.locale.value = i18nStore.currentLocale
|
||||
|
||||
// 同步 dayjs 语言
|
||||
dayjs.locale(i18nStore.currentLocale === 'zh-CN' ? 'zh-cn' : 'en')
|
||||
|
||||
// 初始化主题颜色
|
||||
if (layoutStore.themeColor) {
|
||||
document.documentElement.style.setProperty('--primary-color', layoutStore.themeColor)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-config-provider :locale="antLocale" :theme="antdTheme" :getPopupContainer="getPopupContainer">
|
||||
<el-config-provider :size="size" :z-index="zIndex">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
const size = ref('default')
|
||||
const zIndex = ref(3000)
|
||||
</script>
|
||||
|
||||
+148
-32
@@ -1,34 +1,150 @@
|
||||
import request from '../utils/request'
|
||||
import request from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @returns {Promise} 菜单数据
|
||||
*/
|
||||
export function userLogin(params) {
|
||||
return request({
|
||||
url: '/auth/login',
|
||||
method: 'post',
|
||||
data: params
|
||||
})
|
||||
}
|
||||
|
||||
export function userLogout() {
|
||||
return request({
|
||||
url: '/auth/logout',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getUserInfo() {
|
||||
return request({
|
||||
url: '/auth/user',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMyMenu(){
|
||||
return request({
|
||||
url: `auth/menu/my`,
|
||||
method: 'get'
|
||||
})
|
||||
export default {
|
||||
login: {
|
||||
name: '用户登录',
|
||||
post: async function (params) {
|
||||
return await request.post('auth/login', params)
|
||||
},
|
||||
},
|
||||
logout: {
|
||||
name: '用户登出',
|
||||
get: async function () {
|
||||
return await request.get('auth/logout')
|
||||
},
|
||||
},
|
||||
user: {
|
||||
name: '获取用户信息',
|
||||
get: async function () {
|
||||
return await request.get('auth/user')
|
||||
},
|
||||
},
|
||||
users: {
|
||||
list: {
|
||||
name: '获得用户列表',
|
||||
get: async function (params) {
|
||||
return await request.get('auth/users/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '添加用户',
|
||||
post: async function (params) {
|
||||
return await request.post('auth/users/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '编辑用户',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/users/edit', params)
|
||||
},
|
||||
},
|
||||
uppasswd: {
|
||||
name: '修改密码',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/users/passwd', params)
|
||||
},
|
||||
},
|
||||
uprole: {
|
||||
name: '设置角色',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/users/uprole', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '删除用户',
|
||||
post: async function (params) {
|
||||
return await request.delete('auth/users/delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
role: {
|
||||
list: {
|
||||
name: '获得角色列表',
|
||||
get: async function (params) {
|
||||
return await request.get('auth/role/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '添加角色',
|
||||
post: async function (params) {
|
||||
return await request.post('auth/role/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '编辑角色',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/role/edit', params)
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
name: '角色授权',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/role/auth', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '删除角色',
|
||||
post: async function (params) {
|
||||
return await request.delete('auth/role/delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
department: {
|
||||
list: {
|
||||
name: '获得部门列表',
|
||||
get: async function (params) {
|
||||
return await request.get('auth/department/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '添加部门',
|
||||
post: async function (params) {
|
||||
return await request.post('auth/department/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '编辑部门',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/department/edit', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '删除部门',
|
||||
post: async function (params) {
|
||||
return await request.delete('auth/department/delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
my: {
|
||||
name: '获取我的菜单',
|
||||
get: async function () {
|
||||
return await request.get('auth/menu/my')
|
||||
},
|
||||
},
|
||||
list: {
|
||||
name: '获取菜单',
|
||||
get: async function (params) {
|
||||
return await request.get('auth/menu/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '添加菜单',
|
||||
post: async function (params) {
|
||||
return await request.post('auth/menu/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '编辑菜单',
|
||||
post: async function (params) {
|
||||
return await request.put('auth/menu/edit', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '删除菜单',
|
||||
post: async function (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'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export default {
|
||||
version: {
|
||||
name: '获取最新版本号',
|
||||
get: async function () {
|
||||
return await request.get('system/index/version')
|
||||
},
|
||||
},
|
||||
clearcache: {
|
||||
name: '清除缓存',
|
||||
post: async function () {
|
||||
return await request.post('system/index/clearcache')
|
||||
},
|
||||
},
|
||||
info: {
|
||||
name: '系统信息',
|
||||
get: function (params) {
|
||||
return request.get('system/index/info', { params })
|
||||
},
|
||||
},
|
||||
setting: {
|
||||
list: {
|
||||
name: '获取配置信息',
|
||||
get: function (params) {
|
||||
return request.get('system/setting/index', { params })
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
name: '获取配置字段',
|
||||
get: async function (params) {
|
||||
return await request.get('system/setting/fields', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '保存配置信息',
|
||||
post: function (data) {
|
||||
return request.post('system/setting/add', data)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '编辑配置信息',
|
||||
post: function (data) {
|
||||
return request.put('system/setting/edit', data)
|
||||
},
|
||||
},
|
||||
save: {
|
||||
name: '保存配置信息',
|
||||
post: function (data) {
|
||||
return request.put('system/setting/save', data)
|
||||
},
|
||||
},
|
||||
},
|
||||
dictionary: {
|
||||
category: {
|
||||
name: '获取字典树',
|
||||
get: async function (params) {
|
||||
return await request.get('system/dict/category', { params })
|
||||
},
|
||||
},
|
||||
editcate: {
|
||||
name: '编辑字典树',
|
||||
post: async function (data = {}) {
|
||||
return await request.put('system/dict/editcate', data)
|
||||
},
|
||||
},
|
||||
addcate: {
|
||||
name: '添加字典树',
|
||||
post: async function (data = {}) {
|
||||
return await request.post('system/dict/addcate', data)
|
||||
},
|
||||
},
|
||||
delCate: {
|
||||
name: '删除字典树',
|
||||
post: async function (data = {}) {
|
||||
return await request.delete('system/dict/deletecate', data)
|
||||
},
|
||||
},
|
||||
list: {
|
||||
name: '字典明细',
|
||||
get: async function (params) {
|
||||
return await request.get('system/dict/lists', { params })
|
||||
},
|
||||
},
|
||||
get: {
|
||||
name: '获取字典数据',
|
||||
get: async function (params) {
|
||||
return await request.get('system/dict/detail', { params })
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '编辑字典明细',
|
||||
post: async function (data = {}) {
|
||||
return await request.put('system/dict/edit', data)
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '添加字典明细',
|
||||
post: async function (data = {}) {
|
||||
return await request.post('system/dict/add', data)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '删除字典明细',
|
||||
post: async function (data = {}) {
|
||||
return await request.delete('system/dict/delete', data)
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
name: '字典明细',
|
||||
get: async function (params) {
|
||||
return await request.get('system/dict/detail', { params })
|
||||
},
|
||||
},
|
||||
alldic: {
|
||||
name: '全部字典',
|
||||
get: async function (params) {
|
||||
return await request.get('system/dict/all', { params })
|
||||
},
|
||||
},
|
||||
},
|
||||
area: {
|
||||
list: {
|
||||
name: '地区列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/area/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '地区添加',
|
||||
post: async function (params) {
|
||||
return await request.post('system/area/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '地区编辑',
|
||||
post: async function (params) {
|
||||
return await request.put('system/area/edit', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
app: {
|
||||
list: {
|
||||
name: '应用列表',
|
||||
get: async function () {
|
||||
return await request.get('system/app/list')
|
||||
},
|
||||
},
|
||||
},
|
||||
client: {
|
||||
list: {
|
||||
name: '客户端列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/client/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '客户端添加',
|
||||
post: async function (params) {
|
||||
return await request.post('system/client/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '客户端编辑',
|
||||
post: async function (params) {
|
||||
return await request.put('system/client/edit', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '客户端删除',
|
||||
post: async function (params) {
|
||||
return await request.delete('system/client/delete', params)
|
||||
},
|
||||
},
|
||||
menu: {
|
||||
list: {
|
||||
name: '客户端菜单列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/menu/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '客户端菜单添加',
|
||||
post: async function (params) {
|
||||
return await request.post('system/menu/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '客户端菜单编辑',
|
||||
post: async function (params) {
|
||||
return await request.put('system/menu/edit', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '客户端菜单删除',
|
||||
post: async function (params) {
|
||||
return await request.delete('system/menu/delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
log: {
|
||||
list: {
|
||||
name: '日志列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/log/index', { params })
|
||||
},
|
||||
},
|
||||
my: {
|
||||
name: '我的日志',
|
||||
get: async function (params) {
|
||||
return await request.get('system/log/my', { params })
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '日志删除',
|
||||
post: async function (params) {
|
||||
return await request.delete('system/log/delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
list: {
|
||||
name: '任务列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/tasks/index', { params })
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '任务删除',
|
||||
post: async function (params) {
|
||||
return await request.delete('system/tasks/delete', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
crontab: {
|
||||
list: {
|
||||
name: '定时任务列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/crontab/index', { params })
|
||||
},
|
||||
},
|
||||
add: {
|
||||
name: '定时任务添加',
|
||||
post: async function (params) {
|
||||
return await request.post('system/crontab/add', params)
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
name: '定时任务编辑',
|
||||
post: async function (params) {
|
||||
return await request.put('system/crontab/edit', params)
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
name: '定时任务删除',
|
||||
post: async function (params) {
|
||||
return await request.delete('system/crontab/delete', params)
|
||||
},
|
||||
},
|
||||
log: {
|
||||
name: '定时任务日志',
|
||||
get: async function (params) {
|
||||
return await request.get('system/crontab/log', { params })
|
||||
},
|
||||
},
|
||||
reload: {
|
||||
name: '定时任务重载',
|
||||
post: async function (params) {
|
||||
return await request.put('system/crontab/reload', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: {
|
||||
list: {
|
||||
name: '模块列表',
|
||||
get: async function (params) {
|
||||
return await request.get('system/modules/index', { params })
|
||||
},
|
||||
},
|
||||
update: {
|
||||
name: '更新模块',
|
||||
post: async function (params) {
|
||||
return await request.post('system/modules/update', params)
|
||||
},
|
||||
},
|
||||
},
|
||||
sms: {
|
||||
count: {
|
||||
name: '短信发送统计',
|
||||
get: async function (params) {
|
||||
return await request.get('system/sms/count', { params })
|
||||
},
|
||||
},
|
||||
},
|
||||
upload: {
|
||||
name: '文件上传',
|
||||
post: async function (params = {}) {
|
||||
return await request.post('system/file/upload', params)
|
||||
},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -0,0 +1,198 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
// ==================== 全局滚动条样式优化 ====================
|
||||
// Webkit 滚动条基础样式
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
// 滚动条轨道
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 8px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
// 滚动条滑块 - 渐变色设计
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #d9d9d9 0%, #bfbfbf 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(180deg, #c0c0c0 0%, #a6a6a6 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: linear-gradient(180deg, #a6a6a6 0%, #8c8c8c 100%);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
|
||||
// 滚动条两端按钮
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 滚动条角落
|
||||
::-webkit-scrollbar-corner {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
// Firefox 滚动条样式
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #d4d4d4 rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
#app {
|
||||
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 {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #ffffff;
|
||||
|
||||
.tool-bar {
|
||||
padding: 12px 16px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.left-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
|
||||
:deep(.ant-form) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-form-item-label) {
|
||||
min-width: 70px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
// 按钮组样式
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// 搜索输入框样式
|
||||
:deep(.ant-input),
|
||||
:deep(.ant-select-selector) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// 按钮样式优化
|
||||
:deep(.ant-btn) {
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 主按钮特殊样式
|
||||
:deep(.ant-btn-primary) {
|
||||
background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%);
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, #40a9ff 0%, #1890ff 100%);
|
||||
}
|
||||
}
|
||||
|
||||
// 危险按钮样式
|
||||
:deep(.ant-btn-dangerous) {
|
||||
&:hover {
|
||||
background: #ff4d4f;
|
||||
border-color: #ff4d4f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+7
-3
@@ -1,10 +1,14 @@
|
||||
import * as AIcons from '@ant-design/icons-vue'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
|
||||
for (let icon in AIcons) {
|
||||
app.component(`${icon}`, AIcons[icon])
|
||||
app.component(`A${icon}`, AIcons[icon])
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(`ElIcon${key}`, component)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,144 @@
|
||||
export default class UploadAdapter {
|
||||
constructor(loader, options) {
|
||||
this.loader = loader
|
||||
this.options = options
|
||||
this.timeout = 60000 // 60秒超时
|
||||
}
|
||||
|
||||
upload() {
|
||||
return this.loader.file.then(
|
||||
(file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
this._initRequest()
|
||||
this._initListeners(resolve, reject, file)
|
||||
this._sendRequest(file)
|
||||
this._initTimeout(reject)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
abort() {
|
||||
if (this.xhr) {
|
||||
this.xhr.abort()
|
||||
}
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
}
|
||||
}
|
||||
|
||||
_initRequest() {
|
||||
const xhr = (this.xhr = new XMLHttpRequest())
|
||||
|
||||
xhr.open('POST', this.options.upload.uploadUrl, true)
|
||||
xhr.responseType = 'json'
|
||||
}
|
||||
|
||||
_initListeners(resolve, reject, file) {
|
||||
const xhr = this.xhr
|
||||
const loader = this.loader
|
||||
const genericErrorText = `Couldn't upload file: ${file.name}.`
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
console.error('[UploadAdapter] Upload error for file:', file.name)
|
||||
reject(genericErrorText)
|
||||
})
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
console.warn('[UploadAdapter] Upload aborted for file:', file.name)
|
||||
reject()
|
||||
})
|
||||
|
||||
xhr.addEventListener('timeout', () => {
|
||||
console.error('[UploadAdapter] Upload timeout for file:', file.name)
|
||||
reject(`Upload timeout: ${file.name}. Please try again.`)
|
||||
})
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
const response = xhr.response
|
||||
|
||||
// 检查响应状态码
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
if (!response) {
|
||||
console.error('[UploadAdapter] Empty response for file:', file.name)
|
||||
reject(genericErrorText)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查业务状态码(假设 code=1 表示成功)
|
||||
if (response.code == 1 || response.code == undefined) {
|
||||
const url = response.data?.url || response.data?.src
|
||||
if (!url) {
|
||||
console.error('[UploadAdapter] No URL in response for file:', file.name, response)
|
||||
reject('Upload succeeded but no URL returned')
|
||||
return
|
||||
}
|
||||
resolve({ default: url })
|
||||
} else {
|
||||
const errorMessage = response.message || genericErrorText
|
||||
console.error('[UploadAdapter] Upload failed for file:', file.name, 'Error:', errorMessage)
|
||||
reject(errorMessage)
|
||||
}
|
||||
} else {
|
||||
console.error('[UploadAdapter] HTTP error for file:', file.name, 'Status:', xhr.status)
|
||||
reject(`Server error (${xhr.status}): ${file.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
// 上传进度监听
|
||||
if (xhr.upload) {
|
||||
xhr.upload.addEventListener('progress', (evt) => {
|
||||
if (evt.lengthComputable) {
|
||||
loader.uploadTotal = evt.total
|
||||
loader.uploaded = evt.loaded
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
_initTimeout(reject) {
|
||||
// 清除之前的超时定时器(如果有)
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId)
|
||||
}
|
||||
|
||||
// 设置新的超时定时器
|
||||
this.timeoutId = setTimeout(() => {
|
||||
if (this.xhr) {
|
||||
this.xhr.abort()
|
||||
reject(new Error('Upload timeout'))
|
||||
}
|
||||
}, this.timeout)
|
||||
}
|
||||
|
||||
_sendRequest(file) {
|
||||
// 设置请求超时
|
||||
this.xhr.timeout = this.timeout
|
||||
|
||||
// Set headers if specified.
|
||||
const headers = this.options.upload.headers || {}
|
||||
const extendData = this.options.upload.extendData || {}
|
||||
// Use the withCredentials flag if specified.
|
||||
const withCredentials = this.options.upload.withCredentials || false
|
||||
const uploadName = this.options.upload.uploadName || 'file'
|
||||
|
||||
for (const headerName of Object.keys(headers)) {
|
||||
this.xhr.setRequestHeader(headerName, headers[headerName])
|
||||
}
|
||||
|
||||
this.xhr.withCredentials = withCredentials
|
||||
|
||||
const data = new FormData()
|
||||
for (const key of Object.keys(extendData)) {
|
||||
data.append(key, extendData[key])
|
||||
}
|
||||
data.append(uploadName, file)
|
||||
|
||||
this.xhr.send(data)
|
||||
}
|
||||
}
|
||||
|
||||
export function UploadAdapterPlugin(editor) {
|
||||
editor.plugins.get('FileRepository').createUploadAdapter = (loader) => {
|
||||
return new UploadAdapter(loader, editor.config._config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<template>
|
||||
<div :style="{ '--editor-height': editorHeight }">
|
||||
<ckeditor :editor="editor" v-model="editorData" :config="editorConfig" :disabled="disabled" @blur="onBlur" @focus="onFocus"></ckeditor>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ClassicEditor,
|
||||
Alignment,
|
||||
AutoImage,
|
||||
Autoformat,
|
||||
BlockQuote,
|
||||
Bold,
|
||||
CodeBlock,
|
||||
DataFilter,
|
||||
DataSchema,
|
||||
Essentials,
|
||||
FindAndReplace,
|
||||
FontBackgroundColor,
|
||||
FontColor,
|
||||
FontFamily,
|
||||
FontSize,
|
||||
GeneralHtmlSupport,
|
||||
Heading,
|
||||
Highlight,
|
||||
HorizontalLine,
|
||||
Image,
|
||||
ImageCaption,
|
||||
ImageInsert,
|
||||
ImageResize,
|
||||
ImageStyle,
|
||||
ImageToolbar,
|
||||
ImageUpload,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
Link,
|
||||
LinkImage,
|
||||
List,
|
||||
MediaEmbed,
|
||||
MediaEmbedToolbar,
|
||||
Mention,
|
||||
Paragraph,
|
||||
PasteFromOffice,
|
||||
RemoveFormat,
|
||||
SelectAll,
|
||||
ShowBlocks,
|
||||
SourceEditing,
|
||||
SpecialCharacters,
|
||||
SpecialCharactersArrows,
|
||||
SpecialCharactersCurrency,
|
||||
SpecialCharactersEssentials,
|
||||
SpecialCharactersLatin,
|
||||
SpecialCharactersMathematical,
|
||||
SpecialCharactersText,
|
||||
Style,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Table,
|
||||
TableCaption,
|
||||
TableCellProperties,
|
||||
TableColumnResize,
|
||||
TableProperties,
|
||||
TableToolbar,
|
||||
TextTransformation,
|
||||
TodoList,
|
||||
Underline,
|
||||
Undo,
|
||||
WordCount,
|
||||
} from 'ckeditor5'
|
||||
import { Ckeditor } from '@ckeditor/ckeditor5-vue'
|
||||
import { UploadAdapterPlugin } from './UploadAdapter.js'
|
||||
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useCurrentInstance } from '@/utils/tool'
|
||||
|
||||
import coreTranslations from 'ckeditor5/translations/zh-cn.js'
|
||||
import 'ckeditor5/ckeditor5.css'
|
||||
|
||||
const { proxy } = useCurrentInstance()
|
||||
|
||||
// 组件名称
|
||||
defineOptions({
|
||||
name: 'scCkeditor',
|
||||
})
|
||||
|
||||
// Props 定义
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容……',
|
||||
},
|
||||
toolbar: {
|
||||
type: String,
|
||||
default: 'basic',
|
||||
},
|
||||
height: {
|
||||
type: String,
|
||||
default: '400px',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
// Emits 定义
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 工具栏配置常量
|
||||
const TOOLBARS = {
|
||||
full: [
|
||||
'sourceEditing',
|
||||
'undo',
|
||||
'redo',
|
||||
'heading',
|
||||
'style',
|
||||
'|',
|
||||
'superscript',
|
||||
'subscript',
|
||||
'removeFormat',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'link',
|
||||
'fontBackgroundColor',
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'fontColor',
|
||||
'|',
|
||||
'outdent',
|
||||
'indent',
|
||||
'alignment',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'todoList',
|
||||
'|',
|
||||
'blockQuote',
|
||||
'insertTable',
|
||||
'imageInsert',
|
||||
'mediaEmbed',
|
||||
'highlight',
|
||||
'horizontalLine',
|
||||
'selectAll',
|
||||
'showBlocks',
|
||||
'specialCharacters',
|
||||
'codeBlock',
|
||||
'findAndReplace',
|
||||
],
|
||||
basic: [
|
||||
'sourceEditing',
|
||||
'undo',
|
||||
'redo',
|
||||
'heading',
|
||||
'|',
|
||||
'removeFormat',
|
||||
'bold',
|
||||
'italic',
|
||||
'underline',
|
||||
'link',
|
||||
'fontBackgroundColor',
|
||||
'fontFamily',
|
||||
'fontSize',
|
||||
'fontColor',
|
||||
'|',
|
||||
'outdent',
|
||||
'indent',
|
||||
'alignment',
|
||||
'bulletedList',
|
||||
'numberedList',
|
||||
'todoList',
|
||||
'|',
|
||||
'insertTable',
|
||||
'imageInsert',
|
||||
'mediaEmbed',
|
||||
],
|
||||
simple: ['undo', 'redo', 'heading', '|', 'removeFormat', 'bold', 'italic', 'underline', 'link', 'fontBackgroundColor', 'fontFamily', 'fontSize', 'fontColor', '|', 'insertTable', 'imageInsert', 'mediaEmbed'],
|
||||
}
|
||||
|
||||
// 插件配置常量
|
||||
const PLUGINS = [
|
||||
Alignment,
|
||||
AutoImage,
|
||||
Autoformat,
|
||||
BlockQuote,
|
||||
Bold,
|
||||
CodeBlock,
|
||||
DataFilter,
|
||||
DataSchema,
|
||||
Essentials,
|
||||
FindAndReplace,
|
||||
FontBackgroundColor,
|
||||
FontColor,
|
||||
FontFamily,
|
||||
FontSize,
|
||||
GeneralHtmlSupport,
|
||||
Heading,
|
||||
Highlight,
|
||||
HorizontalLine,
|
||||
Image,
|
||||
ImageCaption,
|
||||
ImageInsert,
|
||||
ImageResize,
|
||||
ImageStyle,
|
||||
ImageToolbar,
|
||||
ImageUpload,
|
||||
Indent,
|
||||
IndentBlock,
|
||||
Italic,
|
||||
Link,
|
||||
LinkImage,
|
||||
List,
|
||||
MediaEmbed,
|
||||
MediaEmbedToolbar,
|
||||
Mention,
|
||||
Paragraph,
|
||||
PasteFromOffice,
|
||||
RemoveFormat,
|
||||
SelectAll,
|
||||
ShowBlocks,
|
||||
SourceEditing,
|
||||
SpecialCharacters,
|
||||
SpecialCharactersArrows,
|
||||
SpecialCharactersCurrency,
|
||||
SpecialCharactersEssentials,
|
||||
SpecialCharactersLatin,
|
||||
SpecialCharactersMathematical,
|
||||
SpecialCharactersText,
|
||||
Style,
|
||||
Subscript,
|
||||
Superscript,
|
||||
Table,
|
||||
TableCaption,
|
||||
TableCellProperties,
|
||||
TableColumnResize,
|
||||
TableProperties,
|
||||
TableToolbar,
|
||||
TextTransformation,
|
||||
TodoList,
|
||||
Underline,
|
||||
Undo,
|
||||
WordCount,
|
||||
UploadAdapterPlugin,
|
||||
]
|
||||
|
||||
// 响应式数据
|
||||
const editorData = ref('')
|
||||
const editorHeight = ref(props.height)
|
||||
const editor = ClassicEditor
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig = computed(() => ({
|
||||
language: { ui: 'zh-cn', content: 'zh-cn' },
|
||||
translations: [coreTranslations],
|
||||
plugins: PLUGINS,
|
||||
toolbar: {
|
||||
shouldNotGroupWhenFull: true,
|
||||
items: TOOLBARS[props.toolbar] || TOOLBARS.basic,
|
||||
},
|
||||
placeholder: props.placeholder,
|
||||
image: {
|
||||
styles: ['alignLeft', 'alignCenter', 'alignRight'],
|
||||
toolbar: ['imageTextAlternative', 'toggleImageCaption', '|', 'imageStyle:alignLeft', 'imageStyle:alignCenter', 'imageStyle:alignRight', '|', 'linkImage'],
|
||||
},
|
||||
mediaEmbed: {
|
||||
previewsInData: true,
|
||||
providers: [
|
||||
{
|
||||
name: 'mp4',
|
||||
url: /\.(mp4|avi|mov|flv|wmv|mkv)$/i,
|
||||
html: (match) => {
|
||||
const url = match['input']
|
||||
return '<video controls width="100%" height="100%" src="' + url + '"></video>'
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
fontSize: {
|
||||
options: [10, 12, 14, 16, 18, 20, 22, 24, 26, 30, 32, 36],
|
||||
},
|
||||
style: {
|
||||
definitions: [
|
||||
{
|
||||
name: 'Article category',
|
||||
element: 'h3',
|
||||
classes: ['category'],
|
||||
},
|
||||
{
|
||||
name: 'Info box',
|
||||
element: 'p',
|
||||
classes: ['info-box'],
|
||||
},
|
||||
],
|
||||
},
|
||||
upload: {
|
||||
uploadUrl: proxy?.$API?.common?.upload?.url || '',
|
||||
withCredentials: false,
|
||||
extendData: { type: 'images' },
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + proxy?.$TOOL?.data?.get('TOKEN'),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
// 监听 modelValue 变化
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newVal) => {
|
||||
editorData.value = newVal ?? ''
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听 height 变化
|
||||
watch(
|
||||
() => props.height,
|
||||
(newVal) => {
|
||||
editorHeight.value = newVal
|
||||
},
|
||||
)
|
||||
|
||||
// 移除图片宽高的正则替换函数
|
||||
const stripImageDimensions = (html) => {
|
||||
return html.replace(/<img[^>]*>/gi, (match) => {
|
||||
return match.replace(/width="[^"]*"/gi, '').replace(/height="[^"]*"/gi, '')
|
||||
})
|
||||
}
|
||||
|
||||
// 失去焦点事件 - 移除图片的固定宽高,避免响应式布局问题
|
||||
const onBlur = () => {
|
||||
const cleanedData = stripImageDimensions(editorData.value)
|
||||
editorData.value = cleanedData
|
||||
emit('update:modelValue', cleanedData)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--ck-z-panel: 9999;
|
||||
}
|
||||
|
||||
.ck-content {
|
||||
height: var(--editor-height);
|
||||
}
|
||||
|
||||
.ck-source-editing-area,
|
||||
.ck-source-editing-area textarea {
|
||||
height: var(--editor-height);
|
||||
}
|
||||
|
||||
.ck-source-editing-area textarea {
|
||||
overflow-y: scroll !important;
|
||||
}
|
||||
</style>
|
||||
+10
-1
@@ -1,9 +1,10 @@
|
||||
export default {
|
||||
APP_NAME: 'vueadmin',
|
||||
LOGO: '',
|
||||
DASHBOARD_URL: '/dashboard',
|
||||
|
||||
// 白名单路由(不需要登录即可访问)
|
||||
whiteList: ['/login', '/register', '/reset-password'],
|
||||
whiteList: ['/login', '/register', '/forgot-password'],
|
||||
//版本号
|
||||
APP_VER: '1.6.6',
|
||||
|
||||
@@ -30,6 +31,14 @@ export default {
|
||||
//语言
|
||||
LANG: 'zh-cn',
|
||||
|
||||
DASHBOARD_LAYOUT: 'widgets', //控制台首页默认布局
|
||||
DEFAULT_GRID: {
|
||||
//默认分栏数量和宽度 例如 [24] [18,6] [8,8,8] [6,12,6]
|
||||
layout: [24, 12, 12],
|
||||
//小组件分布,com取值:pages/home/components 文件名
|
||||
compsList: [['welcome'], ['info'], ['ver']],
|
||||
},
|
||||
|
||||
//是否加密localStorage, 为空不加密
|
||||
//支持多种加密方式: 'AES', 'BASE64', 'DES'
|
||||
LS_ENCRYPTION: '',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import systemApi from '@/api/system'
|
||||
|
||||
//上传配置
|
||||
|
||||
export default {
|
||||
apiObj: systemApi.upload.post, //上传请求API对象
|
||||
filename: 'file', //form请求时文件的key
|
||||
successCode: 1, //请求完成代码
|
||||
maxSize: 10, //最大文件大小 默认10MB
|
||||
parseData: function (res) {
|
||||
return {
|
||||
code: res.code, //分析状态字段结构
|
||||
fileName: res.data.name, //分析文件名称
|
||||
src: res.data.url, //分析图片远程地址结构
|
||||
msg: res.message, //分析描述字段结构
|
||||
}
|
||||
},
|
||||
apiObjFile: systemApi.upload.post, //附件上传请求API对象
|
||||
maxSizeFile: 10, //最大文件大小 默认10MB
|
||||
}
|
||||
@@ -11,6 +11,6 @@ export function useI18n() {
|
||||
availableLocales,
|
||||
setLocale: i18nStore.setLocale,
|
||||
currentLocale: i18nStore.currentLocale,
|
||||
localeLabel: i18nStore.localeLabel
|
||||
localeLabel: i18nStore.localeLabel,
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -8,8 +8,8 @@ const i18n = createI18n({
|
||||
fallbackLocale: 'en-US',
|
||||
messages: {
|
||||
'zh-CN': zh,
|
||||
'en-US': en
|
||||
}
|
||||
'en-US': en,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
|
||||
+98
-10
@@ -5,6 +5,37 @@ export default {
|
||||
logout: 'Logout',
|
||||
register: 'Register',
|
||||
searchMenu: 'Search Menu',
|
||||
searchPlaceholder: 'Please enter menu name to search',
|
||||
noResults: 'No matching menus found',
|
||||
searchTips: 'Keyboard Shortcuts Tips',
|
||||
navigateResults: 'Use up/down arrows to navigate',
|
||||
selectResult: 'Press Enter to select',
|
||||
closeSearch: 'Press ESC to close',
|
||||
taskCenter: 'Task Center',
|
||||
totalTasks: 'Total Tasks',
|
||||
pendingTasks: 'Pending',
|
||||
completedTasks: 'Completed',
|
||||
searchTasks: 'Search tasks...',
|
||||
all: 'All',
|
||||
pending: 'Pending',
|
||||
completed: 'Completed',
|
||||
taskTitle: 'Task Title',
|
||||
enterTaskTitle: 'Please enter task title',
|
||||
taskPriority: 'Task Priority',
|
||||
priorityHigh: 'High',
|
||||
priorityMedium: 'Medium',
|
||||
priorityLow: 'Low',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
addTask: 'Add Task',
|
||||
pleaseEnterTaskTitle: 'Please enter task title',
|
||||
added: 'Added',
|
||||
deleted: 'Deleted',
|
||||
justNow: 'Just now',
|
||||
clearCache: 'Clear Cache',
|
||||
confirmClearCache: 'Confirm Clear Cache',
|
||||
clearCacheConfirm: 'Are you sure you want to clear all cache? This will clear local storage, session storage and cached data.',
|
||||
cacheCleared: 'Cache cleared',
|
||||
clearCacheFailed: 'Failed to clear cache',
|
||||
messages: 'Messages',
|
||||
tasks: 'Tasks',
|
||||
clearAll: 'Clear All',
|
||||
@@ -62,6 +93,33 @@ export default {
|
||||
info: 'Info',
|
||||
confirmDelete: 'Are you sure you want to delete?',
|
||||
confirmLogout: 'Are you sure you want to logout?',
|
||||
addConfig: 'Add Config',
|
||||
editConfig: 'Edit Config',
|
||||
configCategory: 'Config Category',
|
||||
configName: 'Config Name',
|
||||
configTitle: 'Config Title',
|
||||
configType: 'Config Type',
|
||||
configValue: 'Config Value',
|
||||
configTip: 'Config Tip',
|
||||
typeText: 'Text',
|
||||
typeTextarea: 'Textarea',
|
||||
typeNumber: 'Number',
|
||||
typeSwitch: 'Switch',
|
||||
typeSelect: 'Select',
|
||||
typeMultiselect: 'Multiselect',
|
||||
typeDatetime: 'Datetime',
|
||||
typeColor: 'Color',
|
||||
pleaseSelect: 'Please Select',
|
||||
pleaseEnter: 'Please Enter',
|
||||
noConfig: 'No Config',
|
||||
fetchConfigFailed: 'Failed to fetch config',
|
||||
addSuccess: 'Added Successfully',
|
||||
addFailed: 'Failed to Add',
|
||||
editSuccess: 'Edited Successfully',
|
||||
editFailed: 'Failed to Edit',
|
||||
saveSuccess: 'Saved Successfully',
|
||||
saveFailed: 'Failed to Save',
|
||||
resetSuccess: 'Reset Successfully',
|
||||
required: 'This field is required',
|
||||
operation: 'Operation',
|
||||
time: 'Time',
|
||||
@@ -69,7 +127,37 @@ export default {
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
yes: 'Yes',
|
||||
no: 'No'
|
||||
no: 'No',
|
||||
areaManage: 'Area Management',
|
||||
areaName: 'Area Name',
|
||||
areaCode: 'Area Code',
|
||||
areaLevel: 'Area Level',
|
||||
parentArea: 'Parent Area',
|
||||
province: 'Province',
|
||||
city: 'City',
|
||||
district: 'District',
|
||||
street: 'Street',
|
||||
unknown: 'Unknown',
|
||||
addArea: 'Add Area',
|
||||
editArea: 'Edit Area',
|
||||
remark: 'Remark',
|
||||
sort: 'Sort',
|
||||
createTime: 'Create Time',
|
||||
action: 'Action',
|
||||
batchDelete: 'Batch Delete',
|
||||
confirmBatchDelete: 'Confirm Batch Delete',
|
||||
batchDeleteConfirm: 'Are you sure you want to delete the selected',
|
||||
items: 'items?',
|
||||
deleteConfirm: 'Are you sure you want to delete',
|
||||
selectDataFirst: 'Please select data to operate first',
|
||||
pleaseEnterNumber: 'Please enter a valid number',
|
||||
exitFullScreen: 'Exit Fullscreen',
|
||||
columns: 'Columns',
|
||||
columnSettings: 'Column Settings',
|
||||
selectAll: 'Select All',
|
||||
unselectAll: 'Unselect All',
|
||||
retry: 'Retry',
|
||||
fetchDataFailed: 'Failed to fetch data',
|
||||
},
|
||||
menu: {
|
||||
dashboard: 'Dashboard',
|
||||
@@ -77,7 +165,7 @@ export default {
|
||||
roleManagement: 'Role Management',
|
||||
permissionManagement: 'Permission Management',
|
||||
systemSettings: 'System Settings',
|
||||
logManagement: 'Log Management'
|
||||
logManagement: 'Log Management',
|
||||
},
|
||||
login: {
|
||||
title: 'User Login',
|
||||
@@ -90,7 +178,7 @@ export default {
|
||||
noAccount: "Don't have an account?",
|
||||
registerNow: 'Register Now',
|
||||
forgotPassword: 'Forgot Password?',
|
||||
rememberMe: 'Remember Me'
|
||||
rememberMe: 'Remember Me',
|
||||
},
|
||||
register: {
|
||||
title: 'User Registration',
|
||||
@@ -109,7 +197,7 @@ export default {
|
||||
agreeTerms: 'I have read and agree to the',
|
||||
terms: 'User Agreement',
|
||||
hasAccount: 'Already have an account?',
|
||||
loginNow: 'Login Now'
|
||||
loginNow: 'Login Now',
|
||||
},
|
||||
resetPassword: {
|
||||
title: 'Reset Password',
|
||||
@@ -128,13 +216,13 @@ export default {
|
||||
codeSent: 'Verification code has been sent to your email',
|
||||
resendCode: 'Resend in {seconds} seconds',
|
||||
sendCodeFirst: 'Please enter email address first',
|
||||
backToLogin: 'Back to Login'
|
||||
backToLogin: 'Back to Login',
|
||||
},
|
||||
layout: {
|
||||
toggleSidebar: 'Toggle Sidebar',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
logout: 'Logout'
|
||||
logout: 'Logout',
|
||||
},
|
||||
table: {
|
||||
total: 'Total {total} items',
|
||||
@@ -142,13 +230,13 @@ export default {
|
||||
actions: 'Actions',
|
||||
noData: 'No Data',
|
||||
sort: 'Sort',
|
||||
filter: 'Filter'
|
||||
filter: 'Filter',
|
||||
},
|
||||
pagination: {
|
||||
goTo: 'Go to',
|
||||
page: 'Page',
|
||||
total: 'Total {total} items',
|
||||
itemsPerPage: '{size} items per page'
|
||||
itemsPerPage: '{size} items per page',
|
||||
},
|
||||
form: {
|
||||
required: 'This field is required',
|
||||
@@ -156,6 +244,6 @@ export default {
|
||||
invalidPhone: 'Please enter a valid phone number',
|
||||
passwordMismatch: 'Passwords do not match',
|
||||
minLength: 'Minimum {min} characters required',
|
||||
maxLength: 'Maximum {max} characters allowed'
|
||||
}
|
||||
maxLength: 'Maximum {max} characters allowed',
|
||||
},
|
||||
}
|
||||
|
||||
+97
-10
@@ -5,6 +5,37 @@ export default {
|
||||
logout: '退出登录',
|
||||
register: '注册',
|
||||
searchMenu: '搜索菜单',
|
||||
searchPlaceholder: '请输入菜单名称进行搜索',
|
||||
noResults: '未找到匹配的菜单',
|
||||
searchTips: '快捷键操作提示',
|
||||
navigateResults: '使用上下键导航',
|
||||
selectResult: '按回车键选择',
|
||||
closeSearch: '按 ESC 关闭',
|
||||
taskCenter: '任务中心',
|
||||
totalTasks: '总任务',
|
||||
pendingTasks: '待完成',
|
||||
completedTasks: '已完成',
|
||||
searchTasks: '搜索任务...',
|
||||
all: '全部',
|
||||
pending: '待完成',
|
||||
completed: '已完成',
|
||||
taskTitle: '任务标题',
|
||||
enterTaskTitle: '请输入任务标题',
|
||||
taskPriority: '任务优先级',
|
||||
priorityHigh: '高',
|
||||
priorityMedium: '中',
|
||||
priorityLow: '低',
|
||||
confirmDelete: '确认删除',
|
||||
addTask: '添加任务',
|
||||
pleaseEnterTaskTitle: '请输入任务标题',
|
||||
added: '已添加',
|
||||
deleted: '已删除',
|
||||
justNow: '刚刚',
|
||||
clearCache: '清除缓存',
|
||||
confirmClearCache: '确认清除缓存',
|
||||
clearCacheConfirm: '确定要清除所有缓存吗?这将清除本地存储、会话存储和缓存数据。',
|
||||
cacheCleared: '缓存已清除',
|
||||
clearCacheFailed: '清除缓存失败',
|
||||
messages: '消息',
|
||||
tasks: '任务',
|
||||
clearAll: '清空全部',
|
||||
@@ -62,6 +93,33 @@ export default {
|
||||
info: '提示',
|
||||
confirmDelete: '确定要删除吗?',
|
||||
confirmLogout: '确定要退出登录吗?',
|
||||
addConfig: '添加配置',
|
||||
editConfig: '编辑配置',
|
||||
configCategory: '配置分类',
|
||||
configName: '配置名称',
|
||||
configTitle: '配置标题',
|
||||
configType: '配置类型',
|
||||
configValue: '配置值',
|
||||
configTip: '配置提示',
|
||||
typeText: '文本',
|
||||
typeTextarea: '文本域',
|
||||
typeNumber: '数字',
|
||||
typeSwitch: '开关',
|
||||
typeSelect: '下拉选择',
|
||||
typeMultiselect: '多选',
|
||||
typeDatetime: '日期时间',
|
||||
typeColor: '颜色',
|
||||
pleaseSelect: '请选择',
|
||||
pleaseEnter: '请输入',
|
||||
noConfig: '暂无配置',
|
||||
fetchConfigFailed: '获取配置失败',
|
||||
addSuccess: '添加成功',
|
||||
addFailed: '添加失败',
|
||||
editSuccess: '编辑成功',
|
||||
editFailed: '编辑失败',
|
||||
saveSuccess: '保存成功',
|
||||
saveFailed: '保存失败',
|
||||
resetSuccess: '重置成功',
|
||||
required: '此项为必填项',
|
||||
operation: '操作',
|
||||
time: '时间',
|
||||
@@ -69,7 +127,36 @@ export default {
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
yes: '是',
|
||||
no: '否'
|
||||
no: '否',
|
||||
areaManage: '地区管理',
|
||||
areaName: '地区名称',
|
||||
areaCode: '地区编码',
|
||||
areaLevel: '地区级别',
|
||||
parentArea: '上级地区',
|
||||
province: '省份',
|
||||
city: '城市',
|
||||
district: '区县',
|
||||
street: '街道',
|
||||
unknown: '未知',
|
||||
addArea: '添加地区',
|
||||
editArea: '编辑地区',
|
||||
remark: '备注',
|
||||
sort: '排序',
|
||||
createTime: '创建时间',
|
||||
action: '操作',
|
||||
batchDelete: '批量删除',
|
||||
confirmBatchDelete: '确认批量删除',
|
||||
batchDeleteConfirm: '确定要删除选中的',
|
||||
items: '条数据吗?',
|
||||
deleteConfirm: '确定要删除',
|
||||
selectDataFirst: '请先选择要操作的数据',
|
||||
pleaseEnterNumber: '请输入有效的数字',
|
||||
exitFullScreen: '退出全屏',
|
||||
columns: '列设置',
|
||||
columnSettings: '列显示设置',
|
||||
selectAll: '全选',
|
||||
unselectAll: '取消全选',
|
||||
retry: '重试',
|
||||
},
|
||||
menu: {
|
||||
dashboard: '仪表板',
|
||||
@@ -77,7 +164,7 @@ export default {
|
||||
roleManagement: '角色管理',
|
||||
permissionManagement: '权限管理',
|
||||
systemSettings: '系统设置',
|
||||
logManagement: '日志管理'
|
||||
logManagement: '日志管理',
|
||||
},
|
||||
login: {
|
||||
title: '用户登录',
|
||||
@@ -90,7 +177,7 @@ export default {
|
||||
noAccount: '还没有账户?',
|
||||
registerNow: '立即注册',
|
||||
forgotPassword: '忘记密码?',
|
||||
rememberMe: '记住我'
|
||||
rememberMe: '记住我',
|
||||
},
|
||||
register: {
|
||||
title: '用户注册',
|
||||
@@ -109,7 +196,7 @@ export default {
|
||||
agreeTerms: '我已阅读并同意',
|
||||
terms: '用户协议',
|
||||
hasAccount: '已有账户?',
|
||||
loginNow: '立即登录'
|
||||
loginNow: '立即登录',
|
||||
},
|
||||
resetPassword: {
|
||||
title: '重置密码',
|
||||
@@ -128,13 +215,13 @@ export default {
|
||||
codeSent: '验证码已发送到您的邮箱',
|
||||
resendCode: '{seconds}秒后重新发送',
|
||||
sendCodeFirst: '请先输入邮箱地址',
|
||||
backToLogin: '返回登录'
|
||||
backToLogin: '返回登录',
|
||||
},
|
||||
layout: {
|
||||
toggleSidebar: '切换侧边栏',
|
||||
collapse: '折叠',
|
||||
expand: '展开',
|
||||
logout: '退出登录'
|
||||
logout: '退出登录',
|
||||
},
|
||||
table: {
|
||||
total: '共 {total} 条',
|
||||
@@ -142,13 +229,13 @@ export default {
|
||||
actions: '操作',
|
||||
noData: '暂无数据',
|
||||
sort: '排序',
|
||||
filter: '筛选'
|
||||
filter: '筛选',
|
||||
},
|
||||
pagination: {
|
||||
goTo: '前往',
|
||||
page: '页',
|
||||
total: '共 {total} 条',
|
||||
itemsPerPage: '每页 {size} 条'
|
||||
itemsPerPage: '每页 {size} 条',
|
||||
},
|
||||
form: {
|
||||
required: '此项为必填项',
|
||||
@@ -156,6 +243,6 @@ export default {
|
||||
invalidPhone: '请输入有效的手机号',
|
||||
passwordMismatch: '两次输入的密码不一致',
|
||||
minLength: '最少需要 {min} 个字符',
|
||||
maxLength: '最多允许 {max} 个字符'
|
||||
}
|
||||
maxLength: '最多允许 {max} 个字符',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,50 +1,67 @@
|
||||
<template>
|
||||
<a-breadcrumb class="breadcrumb">
|
||||
<a-breadcrumb-item v-for="(item, index) in breadcrumbList" :key="item.path">
|
||||
<span v-if="index === breadcrumbList.length - 1" class="no-redirect">
|
||||
<component :is="item.meta?.icon || 'FileTextOutlined'" />
|
||||
{{ item.meta.title }}
|
||||
</span>
|
||||
<a v-else @click.prevent="handleLink(item)">
|
||||
<component :is="item.meta?.icon || 'HomeOutlined'" />
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<el-breadcrumb separator="/">
|
||||
<transition-group name="breadcrumb">
|
||||
<el-breadcrumb-item v-for="(item, index) in levelList" :key="item.path">
|
||||
<span v-if="item.redirect === 'noRedirect' || index === levelList.length - 1" class="no-redirect">
|
||||
{{ item.meta.title }}
|
||||
</span>
|
||||
<a v-else class="redirect" @click.prevent="handleLink(item)">
|
||||
{{ item.meta.title }}
|
||||
</a>
|
||||
</el-breadcrumb-item>
|
||||
</transition-group>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'LayoutBreadcrumb'
|
||||
name: 'LayoutBreadcrumb',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const breadcrumbList = ref([])
|
||||
|
||||
// 获取面包屑列表
|
||||
const levelList = ref([])
|
||||
|
||||
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]
|
||||
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) => {
|
||||
router.push(item.path)
|
||||
const { redirect, path } = item
|
||||
if (redirect) {
|
||||
router.push(redirect)
|
||||
return
|
||||
}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
// 监听路由变化
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
@@ -54,22 +71,41 @@ watch(
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.breadcrumb {
|
||||
<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;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1890ff;
|
||||
cursor: pointer;
|
||||
.redirect {
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
color: #40a9ff;
|
||||
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="`submenu-${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>
|
||||
+110
-313
@@ -1,349 +1,146 @@
|
||||
<template>
|
||||
<a-drawer v-model:open="open" title="布局配置" placement="right" :width="420">
|
||||
<div class="setting-content">
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">布局模式</div>
|
||||
<div class="layout-mode-list">
|
||||
<div v-for="mode in layoutModes" :key="mode.value" class="layout-mode-item"
|
||||
:class="{ active: layoutStore.layoutMode === mode.value }"
|
||||
@click="handleLayoutChange(mode.value)">
|
||||
<div class="layout-preview" :class="`preview-${mode.value}`">
|
||||
<div class="preview-sidebar"></div>
|
||||
<div v-if="mode.value === 'default'" class="preview-sidebar-2"></div>
|
||||
<div 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="drawer-container">
|
||||
<el-drawer v-model="drawerVisible" title="布局设置" size="280px">
|
||||
<el-scrollbar>
|
||||
<el-form label-width="100px" label-position="left">
|
||||
<el-form-item label="布局模式">
|
||||
<el-select v-model="layoutMode" placeholder="请选择" @change="handleLayoutModeChange">
|
||||
<el-option label="默认布局" value="default" />
|
||||
<el-option label="菜单布局" value="menu" />
|
||||
<el-option label="顶部布局" value="top" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">主题颜色</div>
|
||||
<div class="color-list">
|
||||
<div v-for="color in themeColors" :key="color" class="color-item"
|
||||
:class="{ active: themeColor === color }" :style="{ backgroundColor: color }"
|
||||
@click="changeThemeColor(color)">
|
||||
<CheckOutlined v-if="themeColor === color" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="主题颜色">
|
||||
<el-color-picker v-model="themeColor" @change="handleThemeChange" />
|
||||
</el-form-item>
|
||||
|
||||
<div class="setting-item">
|
||||
<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>
|
||||
<el-divider>显示设置</el-divider>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="setting-title">其他设置</div>
|
||||
<div class="action-buttons">
|
||||
<a-button type="primary" block @click="handleResetSettings">
|
||||
<ReloadOutlined />
|
||||
重置设置
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-form-item label="显示标签栏">
|
||||
<el-switch v-model="showTags" @change="handleShowTagsChange" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="显示面包屑">
|
||||
<el-switch v-model="showBreadcrumb" @change="handleShowBreadcrumbChange" />
|
||||
</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>
|
||||
</a-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { useLayoutStore } from '@/stores/modules/layout'
|
||||
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useLayoutStore } from '../../stores/modules/layout'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'LayoutSetting',
|
||||
})
|
||||
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const open = ref(false)
|
||||
const themeColor = ref('#1890ff')
|
||||
const showTags = ref(true)
|
||||
const showBreadcrumb = ref(true)
|
||||
const drawerVisible = ref(false)
|
||||
|
||||
const layoutModes = [
|
||||
{ value: 'default', label: '默认布局' },
|
||||
{ value: 'menu', label: '菜单布局' },
|
||||
{ value: 'top', label: '顶部布局' },
|
||||
]
|
||||
|
||||
const themeColors = ['#1890ff', '#f5222d', '#fa541c', '#faad14', '#13c2c2', '#52c41a', '#2f54eb', '#722ed1']
|
||||
|
||||
const openDrawer = () => {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
// 布局模式
|
||||
const layoutMode = computed({
|
||||
get: () => layoutStore.layoutMode,
|
||||
set: (value) => {
|
||||
layoutStore.setLayoutMode(value)
|
||||
},
|
||||
})
|
||||
|
||||
// 切换布局
|
||||
const handleLayoutChange = (mode) => {
|
||||
layoutStore.setLayoutMode(mode)
|
||||
const modeLabel = layoutModes.find((m) => m.value === mode)?.label || mode
|
||||
message.success(`已切换到${modeLabel}`)
|
||||
// 主题颜色
|
||||
const themeColor = computed({
|
||||
get: () => layoutStore.themeColor,
|
||||
set: (value) => {
|
||||
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) => {
|
||||
themeColor.value = color
|
||||
// 更新 CSS 变量
|
||||
document.documentElement.style.setProperty('--primary-color', color)
|
||||
message.success('主题颜色已更新')
|
||||
// 主题颜色变化
|
||||
const handleThemeChange = (value) => {
|
||||
layoutStore.setThemeColor(value)
|
||||
}
|
||||
|
||||
// 切换标签栏显示
|
||||
const handleShowTagsChange = (checked) => {
|
||||
showTags.value = checked
|
||||
// 触发自定义事件或更新状态
|
||||
document.documentElement.style.setProperty('--show-tags', checked ? 'block' : 'none')
|
||||
message.success(checked ? '标签栏已显示' : '标签栏已隐藏')
|
||||
// 显示标签栏变化
|
||||
const handleShowTagsChange = (value) => {
|
||||
layoutStore.setShowTags(value)
|
||||
}
|
||||
|
||||
// 切换面包屑显示
|
||||
const handleShowBreadcrumbChange = (checked) => {
|
||||
showBreadcrumb.value = checked
|
||||
message.success(checked ? '面包屑已显示' : '面包屑已隐藏')
|
||||
// 显示面包屑变化
|
||||
const handleShowBreadcrumbChange = (value) => {
|
||||
layoutStore.setShowBreadcrumb(value)
|
||||
}
|
||||
|
||||
// 重置设置
|
||||
const handleResetSettings = () => {
|
||||
themeColor.value = '#1890ff'
|
||||
showTags.value = true
|
||||
showBreadcrumb.value = true
|
||||
layoutStore.setLayoutMode('default')
|
||||
document.documentElement.style.setProperty('--primary-color', '#1890ff')
|
||||
document.documentElement.style.setProperty('--show-tags', 'block')
|
||||
message.success('设置已重置')
|
||||
const handleReset = () => {
|
||||
layoutStore.resetTheme()
|
||||
ElMessage.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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.setting-content {
|
||||
.setting-item {
|
||||
margin-bottom: 32px;
|
||||
<style lang="scss" scoped>
|
||||
.drawer-container {
|
||||
.setting-btn {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary-color);
|
||||
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;
|
||||
|
||||
.setting-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 16px;
|
||||
color: #333;
|
||||
&:hover {
|
||||
background: var(--primary-color);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #fff;
|
||||
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;
|
||||
}
|
||||
.el-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+341
-214
@@ -1,65 +1,54 @@
|
||||
<template>
|
||||
<div v-show="showTags" class="tags-view">
|
||||
<a-dropdown :trigger="['contextmenu']">
|
||||
<div class="tags-wrapper">
|
||||
<a-space :size="4">
|
||||
<a-tag v-for="tag in visitedViews" :key="tag.fullPath" :closable="!tag.meta?.affix" class="tag-item"
|
||||
:class="{ active: isActive(tag) }" @click="clickTag(tag)" @close="closeSelectedTag(tag)"
|
||||
@contextmenu.prevent="handleContextMenu($event, tag)">
|
||||
{{ tag.meta?.title || tag.name }}
|
||||
</a-tag>
|
||||
</a-space>
|
||||
<div class="tags-view-container">
|
||||
<div class="tags-view-wrapper" @wheel="handleWheel">
|
||||
<div ref="scrollRef" class="scroll-content"
|
||||
:style="{ transform: `translateX(${scrollState.translateX}px)`, transition: scrollState.transition }">
|
||||
<router-link v-for="(tag, index) in visitedViews" :id="`scroll-li-${index}`" :key="tag.path"
|
||||
:class="isActive(tag) ? 'active' : ''"
|
||||
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }" class="tags-view-item"
|
||||
@click.middle="!isAffix(tag) ? closeSelectedTag(tag) : ''"
|
||||
@contextmenu.prevent="openMenu(tag, $event)">
|
||||
{{ tag.title }}
|
||||
<el-icon v-if="!isAffix(tag)" class="close-icon" @click.prevent.stop="closeSelectedTag(tag)">
|
||||
<ElIconClose />
|
||||
</el-icon>
|
||||
</router-link>
|
||||
</div>
|
||||
<template #overlay>
|
||||
<a-menu @click="handleMenuClick">
|
||||
<a-menu-item key="refresh">
|
||||
<ReloadOutlined />
|
||||
<span>刷新</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item v-if="!selectedTag.meta?.affix" key="close">
|
||||
<CloseOutlined />
|
||||
<span>关闭</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeOthers">
|
||||
<ColumnWidthOutlined />
|
||||
<span>关闭其他</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="closeAll">
|
||||
<CloseCircleOutlined />
|
||||
<span>关闭所有</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<div class="tags-actions">
|
||||
<a-tooltip title="刷新当前页">
|
||||
<a-button size="small" type="text" @click="refreshSelectedTag">
|
||||
<ReloadOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="关闭其他">
|
||||
<a-button size="small" type="text" @click="closeOthersTags">
|
||||
<ColumnWidthOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip title="关闭所有">
|
||||
<a-button size="small" type="text" @click="closeAllTags">
|
||||
<CloseCircleOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
<ul v-show="visible" :style="{ left: left + 'px', top: top + 'px' }" class="contextmenu">
|
||||
<li @click="refreshSelectedTag(selectedTag)">
|
||||
<el-icon>
|
||||
<ElIconRefresh />
|
||||
</el-icon>
|
||||
刷新
|
||||
</li>
|
||||
<li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">
|
||||
<el-icon>
|
||||
<ElIconClose />
|
||||
</el-icon>
|
||||
关闭
|
||||
</li>
|
||||
<li @click="closeOthersTags">
|
||||
<el-icon>
|
||||
<ElIconCircleClose />
|
||||
</el-icon>
|
||||
关闭其他
|
||||
</li>
|
||||
<li @click="closeAllTags(selectedTag)">
|
||||
<el-icon>
|
||||
<ElIconFolderDelete />
|
||||
</el-icon>
|
||||
关闭所有
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { ref, computed, watch, onMounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayoutStore } from '@/stores/modules/layout'
|
||||
import { ReloadOutlined, CloseOutlined, ColumnWidthOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
|
||||
import config from '@/config'
|
||||
import { useLayoutStore } from '../../stores/modules/layout'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'TagsView',
|
||||
})
|
||||
@@ -68,223 +57,361 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const showTags = ref(true)
|
||||
const visible = ref(false)
|
||||
const top = ref(0)
|
||||
const left = ref(0)
|
||||
const selectedTag = ref({})
|
||||
const visitedViews = computed(() => layoutStore.viewTags)
|
||||
const affixTags = ref([])
|
||||
const scrollRef = ref(null)
|
||||
const scrollState = ref({
|
||||
translateX: 0,
|
||||
transition: ''
|
||||
})
|
||||
|
||||
const visitedViews = computed(() => layoutStore.viewTags)
|
||||
const activeTabIndex = computed(() => {
|
||||
const index = visitedViews.value.findIndex(view => view.path === route.path)
|
||||
return index
|
||||
})
|
||||
|
||||
// 判断是否是当前激活的标签
|
||||
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 { name } = route
|
||||
if (name && !route.meta?.noCache) {
|
||||
if (name) {
|
||||
layoutStore.updateViewTags({
|
||||
fullPath: route.fullPath,
|
||||
path: route.path,
|
||||
fullPath: route.fullPath,
|
||||
name: name,
|
||||
query: route.query,
|
||||
params: route.params,
|
||||
title: route.meta.title,
|
||||
meta: route.meta,
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 移除标签
|
||||
const closeSelectedTag = (view) => {
|
||||
// 如果是固定标签,不允许关闭
|
||||
if (view.meta?.affix) {
|
||||
return
|
||||
}
|
||||
|
||||
layoutStore.removeViewTags(view.fullPath)
|
||||
|
||||
// 如果关闭的是当前激活的标签,需要跳转
|
||||
setTransition()
|
||||
nextTick(() => {
|
||||
adjustPositionAfterClose()
|
||||
})
|
||||
if (isActive(view)) {
|
||||
const nextTag = visitedViews.value.find((tag) => tag.fullPath !== view.fullPath)
|
||||
if (nextTag) {
|
||||
router.push(nextTag.fullPath)
|
||||
toLastView(visitedViews.value, view)
|
||||
}
|
||||
}
|
||||
|
||||
const closeOthersTags = () => {
|
||||
router.push(selectedTag.value)
|
||||
layoutStore.viewTags = visitedViews.value.filter((tag) => {
|
||||
return tag.meta && tag.meta.affix || tag.fullPath === selectedTag.value.fullPath
|
||||
})
|
||||
setTransition()
|
||||
}
|
||||
|
||||
const closeAllTags = (view) => {
|
||||
layoutStore.viewTags = visitedViews.value.filter((tag) => tag.meta && tag.meta.affix)
|
||||
setTransition()
|
||||
if (view && view.fullPath) {
|
||||
toLastView(visitedViews.value, view)
|
||||
} else {
|
||||
toLastView(layoutStore.viewTags)
|
||||
}
|
||||
}
|
||||
|
||||
const toLastView = (visitedViews, view) => {
|
||||
const latestView = visitedViews.slice(-1)[0]
|
||||
if (latestView) {
|
||||
router.push(latestView.fullPath)
|
||||
} else {
|
||||
if (view.name === 'Dashboard') {
|
||||
router.replace({ path: '/redirect' + view.fullPath })
|
||||
} else {
|
||||
// 如果没有其他标签,跳转到首页
|
||||
router.push(config.DASHBOARD_URL)
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭其他标签
|
||||
const closeOthersTags = () => {
|
||||
if (!selectedTag.value || !selectedTag.value.fullPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// 保留固定标签和当前选中的标签
|
||||
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 refreshSelectedTag = (view) => {
|
||||
layoutStore.removeViewTags(view.fullPath)
|
||||
const { fullPath } = view
|
||||
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 closeAllTags = () => {
|
||||
// 只保留固定标签
|
||||
const affixTags = visitedViews.value.filter((tag) => tag.meta?.affix)
|
||||
layoutStore.viewTags = affixTags
|
||||
const openMenu = (tag, e) => {
|
||||
const scrollWrapper = scrollRef.value?.parentElement
|
||||
if (!scrollWrapper) return
|
||||
|
||||
// 如果还有固定标签,跳转到第一个固定标签
|
||||
if (affixTags.length > 0) {
|
||||
router.push(affixTags[0].fullPath)
|
||||
} else {
|
||||
// 如果没有固定标签,跳转到首页
|
||||
router.push('/home')
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 点击标签
|
||||
const clickTag = (tag) => {
|
||||
if (!isActive(tag)) {
|
||||
router.push(tag.fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新当前标签
|
||||
const refreshSelectedTag = () => {
|
||||
// 使用 router.go(0) 刷新页面
|
||||
router.go(0)
|
||||
}
|
||||
|
||||
// 右键菜单处理
|
||||
const handleContextMenu = (event, tag) => {
|
||||
event.preventDefault()
|
||||
top.value = e.clientY
|
||||
visible.value = true
|
||||
selectedTag.value = tag
|
||||
}
|
||||
|
||||
// 菜单点击处理
|
||||
const handleMenuClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'refresh':
|
||||
refreshSelectedTag()
|
||||
break
|
||||
case 'close':
|
||||
if (selectedTag.value && !selectedTag.value.meta?.affix) {
|
||||
closeSelectedTag(selectedTag.value)
|
||||
}
|
||||
break
|
||||
case 'closeOthers':
|
||||
closeOthersTags()
|
||||
break
|
||||
case 'closeAll':
|
||||
closeAllTags()
|
||||
break
|
||||
}
|
||||
const closeMenu = () => {
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
// 监听路由变化,自动添加标签
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
addTags()
|
||||
// 更新当前选中的标签
|
||||
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || {}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
const handleWheel = (e) => {
|
||||
const scrollWrapper = scrollRef.value?.parentElement
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const scrollWidth = scrollWrapper.offsetWidth
|
||||
const tagElements = scrollRef.value?.querySelectorAll('.tags-view-item')
|
||||
if (!tagElements || tagElements.length === 0) return
|
||||
|
||||
const ulWidth = scrollRef.value.offsetWidth
|
||||
const eventDelta = e.wheelDelta || -e.deltaY * 40
|
||||
const currentScroll = -scrollState.value.translateX
|
||||
const newScroll = Math.max(0, Math.min(currentScroll + eventDelta, scrollWidth - ulWidth))
|
||||
scrollState.value.translateX = -newScroll
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
watch(route, () => {
|
||||
addTags()
|
||||
nextTick(() => {
|
||||
setTransition()
|
||||
autoPositionTab()
|
||||
})
|
||||
})
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (value) {
|
||||
document.body.addEventListener('click', closeMenu)
|
||||
} else {
|
||||
document.body.removeEventListener('click', closeMenu)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
addTags()
|
||||
// 初始化选中的标签
|
||||
selectedTag.value = visitedViews.value.find((tag) => isActive(tag)) || {}
|
||||
nextTick(() => {
|
||||
setTransition()
|
||||
autoPositionTab()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tags-view {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 40px;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
<style lang="scss" scoped>
|
||||
.tags-view-container {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #d8dce5;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
|
||||
position: relative;
|
||||
|
||||
.tags-wrapper {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
.tags-view-wrapper {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: inline-flex;
|
||||
.scroll-content {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
will-change: transform;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
.tags-view-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
border: 1px solid #d8dce5;
|
||||
color: #495060;
|
||||
background: #fff;
|
||||
padding: 0 8px;
|
||||
font-size: 12px;
|
||||
margin-left: 5px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s;
|
||||
text-decoration: none;
|
||||
user-select: none;
|
||||
|
||||
&.active {
|
||||
background-color: #1890ff;
|
||||
border-color: #1890ff;
|
||||
color: #ffffff;
|
||||
|
||||
&:hover {
|
||||
background-color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
&:first-of-type {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-tag-close-icon) {
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tags-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 12px;
|
||||
flex-shrink: 0;
|
||||
.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;
|
||||
|
||||
:deep(.ant-btn) {
|
||||
font-size: 14px;
|
||||
padding: 2px 6px;
|
||||
li {
|
||||
margin: 0;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background: #ecf5ff;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.el-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+127
-291
@@ -1,163 +1,125 @@
|
||||
<template>
|
||||
<div class="userbar">
|
||||
<!-- 菜单搜索 -->
|
||||
<a-tooltip :title="$t('common.search')">
|
||||
<a-button type="text" @click="showSearch" class="action-btn">
|
||||
<SearchOutlined />
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<div class="navbar-right">
|
||||
<div class="right-item">
|
||||
<el-tooltip content="全屏" placement="bottom">
|
||||
<el-icon class="icon-btn" @click="toggleFullScreen">
|
||||
<component :is="isFullscreen ? 'ElIconFullScreen' : 'ElIconAim'" />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 消息通知 -->
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<a-badge :count="messageCount" :offset="[-5, 5]">
|
||||
<a-button type="text" class="action-btn">
|
||||
<BellOutlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<template #overlay>
|
||||
<a-card class="dropdown-card" :title="$t('common.messages')" :bordered="false">
|
||||
<template #extra>
|
||||
<a @click="clearMessages">{{ $t('common.clearAll') }}</a>
|
||||
</template>
|
||||
<div class="message-list">
|
||||
<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>
|
||||
<a-badge v-if="!msg.read" dot />
|
||||
</div>
|
||||
<a-empty v-if="messages.length === 0" :description="$t('common.noMessages')" />
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<div class="right-item">
|
||||
<el-tooltip content="刷新" placement="bottom">
|
||||
<el-icon class="icon-btn" @click="refreshPage">
|
||||
<component :is="'ElIconRefresh'" />
|
||||
</el-icon>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 任务列表 -->
|
||||
<a-dropdown :trigger="['click']" placement="bottomRight">
|
||||
<a-badge :count="taskCount" :offset="[-5, 5]">
|
||||
<a-button type="text" class="action-btn">
|
||||
<CheckSquareOutlined />
|
||||
</a-button>
|
||||
</a-badge>
|
||||
<template #overlay>
|
||||
<a-card class="dropdown-card" :title="$t('common.tasks')" :bordered="false">
|
||||
<template #extra>
|
||||
<a @click="clearTasks">{{ $t('common.clearAll') }}</a>
|
||||
</template>
|
||||
<div class="task-list">
|
||||
<div v-for="task in tasks" :key="task.id" class="task-item">
|
||||
<a-checkbox :checked="task.completed" @change="toggleTask(task)">
|
||||
<span :class="{ completed: task.completed }">{{ task.title }}</span>
|
||||
</a-checkbox>
|
||||
</div>
|
||||
<a-empty v-if="tasks.length === 0" :description="$t('common.noTasks')" />
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
|
||||
<!-- 语言切换 -->
|
||||
<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-divider />
|
||||
<a-menu-item key="logout">
|
||||
<LogoutOutlined />
|
||||
<span>{{ $t('common.logout') }}</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<div class="right-item">
|
||||
<el-dropdown trigger="click" @command="handleCommand">
|
||||
<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>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { useI18nStore } from '@/stores/modules/i18n'
|
||||
import { DownOutlined, UserOutlined, LogoutOutlined, FullscreenOutlined, FullscreenExitOutlined, BellOutlined, CheckSquareOutlined, GlobalOutlined, SearchOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '../../stores/modules/user'
|
||||
import { useLayoutStore } from '../../stores/modules/layout'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'UserBar',
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const i18nStore = useI18nStore()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const isFullscreen = ref(false)
|
||||
|
||||
// 消息数据
|
||||
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: '完成用户审核', completed: false },
|
||||
{ id: 2, title: '更新系统文档', completed: false },
|
||||
{ id: 3, title: '优化数据库查询', completed: true },
|
||||
])
|
||||
|
||||
const taskCount = computed(() => tasks.value.filter((t) => !t.completed).length)
|
||||
const userInfo = ref({
|
||||
username: '',
|
||||
avatar: '',
|
||||
})
|
||||
|
||||
// 切换全屏
|
||||
const toggleFullscreen = () => {
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen()
|
||||
isFullscreen.value = true
|
||||
} else {
|
||||
document.exitFullscreen()
|
||||
isFullscreen.value = false
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen()
|
||||
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 || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,183 +129,57 @@ const handleFullscreenChange = () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getUserInfo()
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
})
|
||||
|
||||
// 显示搜索功能
|
||||
const showSearch = () => {
|
||||
message.info('搜索功能开发中')
|
||||
}
|
||||
|
||||
// 清除消息
|
||||
const clearMessages = () => {
|
||||
messages.value = []
|
||||
message.success(t('common.cleared'))
|
||||
}
|
||||
|
||||
// 清除任务
|
||||
const clearTasks = () => {
|
||||
tasks.value = []
|
||||
message.success(t('common.cleared'))
|
||||
}
|
||||
|
||||
// 切换任务状态
|
||||
const toggleTask = (task) => {
|
||||
task.completed = !task.completed
|
||||
}
|
||||
|
||||
// 切换语言
|
||||
const handleLanguageChange = ({ key }) => {
|
||||
i18nStore.setLocale(key)
|
||||
message.success(t('common.languageChanged'))
|
||||
}
|
||||
|
||||
// 处理菜单点击
|
||||
const handleMenuClick = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'profile':
|
||||
router.push('/profile')
|
||||
break
|
||||
case 'settings':
|
||||
// 系统设置功能暂未实现
|
||||
message.info(t('common.settingsDeveloping'))
|
||||
break
|
||||
case 'logout':
|
||||
handleLogout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.userbar {
|
||||
<style lang="scss" scoped>
|
||||
.navbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
|
||||
.search-input {
|
||||
width: 240px;
|
||||
margin-right: 8px;
|
||||
|
||||
:deep(.ant-input) {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.user-info {
|
||||
.right-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.icon-btn {
|
||||
font-size: 20px;
|
||||
color: #5a5e66;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s;
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
font-size: 16px;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.lang-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
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;
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
|
||||
.message-content {
|
||||
.message-title {
|
||||
.el-dropdown-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: #5a5e66;
|
||||
transition: color 0.3s;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.username {
|
||||
margin: 0 8px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
.el-icon--right {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.task-item {
|
||||
.completed {
|
||||
text-decoration: line-through;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+343
-521
@@ -1,643 +1,465 @@
|
||||
<template>
|
||||
<a-layout class="app-wrapper" :class="layoutClass">
|
||||
<el-container class="app-wrapper" :class="layoutClass">
|
||||
<!-- 默认布局:左侧双栏布局 -->
|
||||
<template v-if="layoutMode === 'default'">
|
||||
<!-- 第一个侧边栏:显示一级菜单 -->
|
||||
<a-layout-sider theme="dark" width="70" class="left-sidebar">
|
||||
<div class="logo-box">
|
||||
<span class="logo-text">VUE</span>
|
||||
<!-- 一级菜单栏 (窄侧边栏) -->
|
||||
<el-aside width="60px" class="first-sidebar">
|
||||
<div class="logo-mini">
|
||||
<img :src="logo" alt="logo" />
|
||||
</div>
|
||||
<ul class="left-nav">
|
||||
<li v-for="(item, index) in menuList" :key="index"
|
||||
:class="{ active: selectedParentMenu?.path === item.path }"
|
||||
@click="handleParentMenuClick(item)">
|
||||
<component :is="getIconComponent(item.meta?.icon)" />
|
||||
<span>{{ item.meta?.title }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</a-layout-sider>
|
||||
|
||||
<!-- 第二个侧边栏:显示选中的父菜单的子菜单 -->
|
||||
<a-layout-sider
|
||||
v-if="selectedParentMenu && selectedParentMenu.children && selectedParentMenu.children.length > 0"
|
||||
theme="light" :collapsed="sidebarCollapsed" :collapsible="true" @collapse="handleCollapse" width="200"
|
||||
:collapsed-width="64" class="right-sidebar">
|
||||
<div class="parent-title">
|
||||
<component :is="getIconComponent(selectedParentMenu.meta?.icon)" />
|
||||
<span v-if="!sidebarCollapsed">{{ selectedParentMenu.meta?.title }}</span>
|
||||
</div>
|
||||
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
|
||||
:selected-keys="[route.path]">
|
||||
<navMenu :menu-items="selectedParentMenu.children" :active-path="route.path" />
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-header class="app-header">
|
||||
<div class="header-left">
|
||||
<breadcrumb />
|
||||
<div class="menu-list">
|
||||
<div
|
||||
v-for="menu in parentMenus"
|
||||
:key="menu.path"
|
||||
class="menu-item"
|
||||
:class="{ active: selectedParentMenu?.path === menu.path }"
|
||||
@click="handleParentMenuClick(menu)"
|
||||
>
|
||||
<el-icon v-if="menu.meta?.icon">
|
||||
<component :is="menu.meta.icon" />
|
||||
</el-icon>
|
||||
<span class="menu-text">{{ menu.meta?.title }}</span>
|
||||
</div>
|
||||
<userbar />
|
||||
</a-layout-header>
|
||||
<tags />
|
||||
<a-layout-content class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<!-- 二级菜单栏 -->
|
||||
<el-aside :width="sidebarCollapsed ? '60px' : '220px'" class="second-sidebar">
|
||||
<div v-if="!sidebarCollapsed && selectedParentMenu" class="second-sidebar-header">
|
||||
<el-icon v-if="selectedParentMenu.meta?.icon" class="menu-icon">
|
||||
<component :is="selectedParentMenu.meta.icon" />
|
||||
</el-icon>
|
||||
<span class="menu-title">{{ selectedParentMenu.meta?.title }}</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" :collapse="sidebarCollapsed" :collapse-transition="false">
|
||||
<layout-menu-item :menu-list="childMenus" />
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-container class="main-container">
|
||||
<el-header class="app-header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-icon" @click="toggleSidebar">
|
||||
<component :is="sidebarCollapsed ? 'ElIconExpand' : 'ElIconFold'" />
|
||||
</el-icon>
|
||||
<layout-breadcrumb v-if="showBreadcrumb" />
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<userbar />
|
||||
</div>
|
||||
</el-header>
|
||||
<tags v-if="showTags" />
|
||||
<el-main class="app-main">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
<component v-if="!route.meta.link" :is="Component" :key="refreshKey" />
|
||||
<iframe v-else :src="route.meta.link" class="iframe-container" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<!-- Menu布局:左侧菜单栏布局 -->
|
||||
<template v-else-if="layoutMode === 'menu'">
|
||||
<a-layout-sider theme="light" style="border-right: 1px solid #f0f0f0" :collapsed="sidebarCollapsed"
|
||||
:collapsible="true" @collapse="handleCollapse" class="full-menu-sidebar" width="200"
|
||||
:collapsed-width="64">
|
||||
<div class="logo-box-full">
|
||||
<span v-if="!sidebarCollapsed" class="logo-text">VUE ADMIN</span>
|
||||
<span v-else class="logo-text-mini">V</span>
|
||||
<el-aside :width="sidebarCollapsed ? '60px' : '220px'" class="menu-sidebar">
|
||||
<div v-if="!sidebarCollapsed" class="logo">
|
||||
<img v-if="logo" :src="logo" alt="logo" />
|
||||
<span class="logo-text">{{ logoText }}</span>
|
||||
</div>
|
||||
<a-menu v-model:openKeys="openKeys" v-model:selectedKeys="selectedKeys" mode="inline"
|
||||
:selected-keys="[route.path]">
|
||||
<navMenu :menu-items="menuList" :active-path="route.path" />
|
||||
</a-menu>
|
||||
</a-layout-sider>
|
||||
<a-layout class="main-layout">
|
||||
<a-layout-header class="app-header">
|
||||
<el-menu :default-active="activeMenu" :collapse="sidebarCollapsed" :collapse-transition="false">
|
||||
<layout-menu-item :menu-list="allMenus" />
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container class="main-container">
|
||||
<el-header class="app-header">
|
||||
<div class="header-left">
|
||||
<breadcrumb />
|
||||
<el-icon class="collapse-icon" @click="toggleSidebar">
|
||||
<component :is="sidebarCollapsed ? 'ElIconExpand' : 'ElIconFold'" />
|
||||
</el-icon>
|
||||
<layout-breadcrumb v-if="showBreadcrumb" />
|
||||
</div>
|
||||
<userbar />
|
||||
</a-layout-header>
|
||||
<tags />
|
||||
<a-layout-content class="app-main">
|
||||
<router-view v-slot="{ Component }">
|
||||
<div class="header-right">
|
||||
<userbar />
|
||||
</div>
|
||||
</el-header>
|
||||
<tags v-if="showTags" />
|
||||
<el-main class="app-main">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
<component v-if="!route.meta.link" :is="Component" :key="refreshKey" />
|
||||
<iframe v-else :src="route.meta.link" class="iframe-container" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<!-- Top布局:顶部菜单栏布局 -->
|
||||
<template v-else-if="layoutMode === 'top'">
|
||||
<a-layout-header class="app-header top-header">
|
||||
<div class="top-header-left">
|
||||
<div class="logo-box-top">
|
||||
<span class="logo-text">VUE ADMIN</span>
|
||||
<el-container class="main-container">
|
||||
<el-header class="top-header">
|
||||
<div class="header-left">
|
||||
<div class="logo">
|
||||
<img v-if="logo" :src="logo" alt="logo" />
|
||||
<span class="logo-text">{{ logoText }}</span>
|
||||
</div>
|
||||
<el-menu :default-active="activeMenu" mode="horizontal" :ellipsis="false">
|
||||
<layout-menu-item :menu-list="allMenus" />
|
||||
</el-menu>
|
||||
</div>
|
||||
<a-menu v-model:selectedKeys="selectedKeys" mode="horizontal" :selected-keys="[route.path]"
|
||||
style="line-height: 60px">
|
||||
<navMenu :menu-items="menuList" :active-path="route.path" />
|
||||
</a-menu>
|
||||
</div>
|
||||
<userbar />
|
||||
</a-layout-header>
|
||||
<tags />
|
||||
<a-layout-content class="app-main top-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component :is="Component" :key="$route.fullPath" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
<div class="header-right">
|
||||
<userbar />
|
||||
</div>
|
||||
</el-header>
|
||||
<tags v-if="showTags" />
|
||||
<el-main class="app-main">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<keep-alive :include="cachedViews">
|
||||
<component v-if="!route.meta.link" :is="Component" :key="refreshKey" />
|
||||
<iframe v-else :src="route.meta.link" class="iframe-container" />
|
||||
</keep-alive>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
</el-container>
|
||||
|
||||
<!-- 漂浮的设置按钮 -->
|
||||
<a-float-button type="primary" @click="openSetting">
|
||||
<template #icon>
|
||||
<SettingOutlined />
|
||||
</template>
|
||||
</a-float-button>
|
||||
|
||||
<!-- 布局设置组件 -->
|
||||
<setting ref="settingRef" />
|
||||
</a-layout>
|
||||
<!-- 设置组件 -->
|
||||
<layout-setting />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useLayoutStore } from '@/stores/modules/layout'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { SettingOutlined } from '@ant-design/icons-vue'
|
||||
import * as icons from '@ant-design/icons-vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '../stores/modules/user'
|
||||
import { useLayoutStore } from '../stores/modules/layout'
|
||||
import logo from '@/assets/imagse/logo.png'
|
||||
import LayoutMenuItem from './components/menu-item.vue'
|
||||
import LayoutBreadcrumb from './components/breadcrumb.vue'
|
||||
import Tags from './components/tags.vue'
|
||||
import Userbar from './components/userbar.vue'
|
||||
import LayoutSetting from './components/setting.vue'
|
||||
|
||||
import userbar from './components/userbar.vue'
|
||||
import navMenu from './components/navMenu.vue'
|
||||
import breadcrumb from './components/breadcrumb.vue'
|
||||
import tags from './components/tags.vue'
|
||||
import setting from './components/setting.vue'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
defineOptions({
|
||||
name: 'AppLayouts',
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const layoutStore = useLayoutStore()
|
||||
const userStore = useUserStore()
|
||||
const layoutStore = useLayoutStore()
|
||||
|
||||
const settingRef = ref(null)
|
||||
|
||||
const layoutMode = computed(() => layoutStore.layoutMode)
|
||||
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
|
||||
const selectedParentMenu = computed(() => layoutStore.selectedParentMenu)
|
||||
|
||||
// 缓存的视图列表
|
||||
const cachedViews = computed(() => {
|
||||
return layoutStore.viewTags.filter((tag) => !tag.meta?.noCache).map((tag) => tag.name)
|
||||
const logoText = computed(() => {
|
||||
return import.meta.env.VITE_APP_TITLE || 'Admin'
|
||||
})
|
||||
|
||||
// 布局类名
|
||||
// 布局相关
|
||||
const layoutMode = computed(() => layoutStore.layoutMode)
|
||||
const sidebarCollapsed = computed(() => layoutStore.sidebarCollapsed)
|
||||
const showTags = computed(() => layoutStore.showTags)
|
||||
const showBreadcrumb = computed(() => layoutStore.showBreadcrumb)
|
||||
const refreshKey = computed(() => layoutStore.refreshKey)
|
||||
|
||||
// 菜单数据
|
||||
const allMenus = computed(() => {
|
||||
return userStore.getMenu() || []
|
||||
})
|
||||
|
||||
const parentMenus = computed(() => {
|
||||
return allMenus.value.filter((menu) => !menu.meta?.hidden)
|
||||
})
|
||||
|
||||
const childMenus = computed(() => {
|
||||
if (!selectedParentMenu.value) {
|
||||
return []
|
||||
}
|
||||
return selectedParentMenu.value.children || []
|
||||
})
|
||||
|
||||
const selectedParentMenu = computed({
|
||||
get: () => layoutStore.selectedParentMenu,
|
||||
set: (value) => layoutStore.setSelectedParentMenu(value),
|
||||
})
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { meta, path } = route
|
||||
if (meta.activeMenu) {
|
||||
return meta.activeMenu
|
||||
}
|
||||
return path
|
||||
})
|
||||
|
||||
// 缓存的视图
|
||||
const cachedViews = computed(() => {
|
||||
return layoutStore.viewTags.map((tag) => tag.name).filter((name) => name)
|
||||
})
|
||||
|
||||
// 布局样式类
|
||||
const layoutClass = computed(() => {
|
||||
return {
|
||||
'layout-default': layoutMode.value === 'default',
|
||||
'layout-menu': layoutMode.value === 'menu',
|
||||
'layout-top': layoutMode.value === 'top',
|
||||
'is-collapse': sidebarCollapsed.value,
|
||||
'sidebar-collapsed': sidebarCollapsed.value,
|
||||
}
|
||||
})
|
||||
|
||||
const openKeys = ref([])
|
||||
const selectedKeys = ref([])
|
||||
const menuList = computed(() => {
|
||||
return userStore.menu
|
||||
})
|
||||
|
||||
// 获取图标组件
|
||||
const getIconComponent = (iconName) => {
|
||||
return icons[iconName] || icons.FileTextOutlined
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
layoutStore.toggleSidebar()
|
||||
}
|
||||
|
||||
// 处理父菜单点击(默认布局的第一级菜单)
|
||||
const handleParentMenuClick = (item) => {
|
||||
// 设置选中的父菜单
|
||||
layoutStore.setSelectedParentMenu(item)
|
||||
// 如果没有子菜单,直接跳转
|
||||
if (!item.children || item.children.length === 0) {
|
||||
if (item.path) {
|
||||
router.push(item.path)
|
||||
}
|
||||
} else {
|
||||
// 默认展开第一个子菜单
|
||||
if (item.children.length > 0 && item.children[0].path) {
|
||||
router.push(item.children[0].path)
|
||||
}
|
||||
}
|
||||
// 处理一级菜单点击
|
||||
const handleParentMenuClick = (menu) => {
|
||||
selectedParentMenu.value = menu
|
||||
}
|
||||
|
||||
// 处理折叠
|
||||
const handleCollapse = (collapsed) => {
|
||||
layoutStore.sidebarCollapsed = collapsed
|
||||
}
|
||||
|
||||
// 打开设置抽屉
|
||||
const openSetting = () => {
|
||||
settingRef.value?.openDrawer()
|
||||
}
|
||||
|
||||
// 更新选中的菜单和展开的菜单
|
||||
const updateMenuState = () => {
|
||||
selectedKeys.value = [route.path]
|
||||
|
||||
// 获取所有父级路径
|
||||
const matched = route.matched.filter((item) => item.path !== '/' && item.path !== route.path)
|
||||
const parentPaths = matched.map((item) => item.path)
|
||||
|
||||
// 对于不同的布局模式,处理方式不同
|
||||
if (layoutMode.value === 'default') {
|
||||
// 默认布局:找到当前路由对应的父菜单
|
||||
const currentMenu = findMenuByPath(menuList.value, route.path)
|
||||
if (currentMenu) {
|
||||
// 如果当前菜单有子菜单,设置为选中的父菜单
|
||||
if (currentMenu.children && currentMenu.children.length > 0) {
|
||||
layoutStore.setSelectedParentMenu(currentMenu)
|
||||
} else {
|
||||
// 如果当前菜单是子菜单,找到它的父菜单
|
||||
const parentMenu = findParentMenu(menuList.value, route.path)
|
||||
if (parentMenu) {
|
||||
layoutStore.setSelectedParentMenu(parentMenu)
|
||||
} else {
|
||||
layoutStore.setSelectedParentMenu(currentMenu)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!sidebarCollapsed.value) {
|
||||
// 其他布局模式:展开所有父级菜单
|
||||
openKeys.value = parentPaths
|
||||
}
|
||||
}
|
||||
|
||||
// 根据路径查找菜单
|
||||
const findMenuByPath = (menus, path) => {
|
||||
for (const menu of menus) {
|
||||
if (menu.path === path) {
|
||||
return menu
|
||||
}
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
const found = findMenuByPath(menu.children, path)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 查找父菜单
|
||||
const findParentMenu = (menus, path) => {
|
||||
for (const menu of menus) {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
for (const child of menu.children) {
|
||||
if (child.path === path) {
|
||||
return menu
|
||||
}
|
||||
if (child.children && child.children.length > 0) {
|
||||
const found = findParentMenu([child], path)
|
||||
if (found) {
|
||||
return menu
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 监听路由变化,更新菜单状态
|
||||
// 监听路由变化,自动选择父菜单
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
console.log('路由变化:', newPath)
|
||||
updateMenuState()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 监听布局模式变化,确保菜单状态正确
|
||||
watch(
|
||||
() => layoutMode.value,
|
||||
() => {
|
||||
updateMenuState()
|
||||
},
|
||||
)
|
||||
if (layoutMode.value === 'default') {
|
||||
const path = route.path
|
||||
const parent = parentMenus.value.find((menu) => {
|
||||
if (menu.path === path || path.startsWith(menu.path + '/')) {
|
||||
return true
|
||||
}
|
||||
if (menu.children) {
|
||||
return menu.children.some((child) => child.path === path || path.startsWith(child.path + '/'))
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// 监听折叠状态
|
||||
watch(
|
||||
() => sidebarCollapsed.value,
|
||||
(val) => {
|
||||
if (val) {
|
||||
openKeys.value = []
|
||||
} else {
|
||||
updateMenuState()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 如果还没有选中的父菜单,默认选中第一个
|
||||
if (layoutMode.value === 'default' && !selectedParentMenu.value && menuList.value.length > 0) {
|
||||
layoutStore.setSelectedParentMenu(menuList.value[0])
|
||||
}
|
||||
updateMenuState()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.app-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
height: 60px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 16px;
|
||||
padding: 0 8px;
|
||||
if (parent && (!selectedParentMenu.value || !path.startsWith(selectedParentMenu.value.path + '/'))) {
|
||||
selectedParentMenu.value = parent
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
.app-main {
|
||||
padding: 16px;
|
||||
background-color: #f0f2f5;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
height: calc(100vh - 106px);
|
||||
}
|
||||
<style lang="scss" scoped>
|
||||
.app-wrapper {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
/* 默认布局 - 双栏菜单 */
|
||||
&.layout-default {
|
||||
.left-sidebar {
|
||||
background-color: #001529;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
|
||||
.logo-box {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
.first-sidebar {
|
||||
width: 60px;
|
||||
height: 100%;
|
||||
background: #001529;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
.logo-mini {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: #002140;
|
||||
|
||||
.logo-text {
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 2px;
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
.left-nav {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
li {
|
||||
height: 70px;
|
||||
.menu-list {
|
||||
.menu-item {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
cursor: pointer;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
transition: all 0.3s;
|
||||
border-left: 3px solid transparent;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
color: #ffffff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
color: #fff;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ffffff;
|
||||
background-color: #1890ff;
|
||||
border-left-color: #ffffff;
|
||||
color: #fff;
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
:deep(.anticon) {
|
||||
font-size: 20px;
|
||||
.el-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
span {
|
||||
.menu-text {
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
padding: 0 4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.right-sidebar {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 1px 0 6px rgba(0, 0, 0, 0.1);
|
||||
z-index: 9;
|
||||
|
||||
.parent-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
gap: 5px;
|
||||
height: 60px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #262626;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
|
||||
.ant-menu-item {
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: #e6f7ff;
|
||||
|
||||
&::after {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu {
|
||||
>.ant-menu-submenu-title {
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open {
|
||||
>.ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
.second-sidebar {
|
||||
background: #fff;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
transition: width 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 0 20px;
|
||||
}
|
||||
.second-sidebar-header {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
background: #fafafa;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
|
||||
.app-main {
|
||||
height: calc(100vh - 106px);
|
||||
.menu-icon {
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Menu布局 */
|
||||
&.layout-menu {
|
||||
.full-menu-sidebar {
|
||||
background-color: #ffffff;
|
||||
z-index: 10;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
|
||||
.logo-box-full {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
.menu-sidebar {
|
||||
background: #fff;
|
||||
border-right: 1px solid #e6e6e6;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
transition: width 0.3s;
|
||||
|
||||
.logo {
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
transition: all 0.2s;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #1890ff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.logo-text-mini {
|
||||
color: #1890ff;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-right: none;
|
||||
height: calc(100% - 60px);
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.ant-menu-item {
|
||||
margin: 0;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
padding-left: 20px !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
background-color: #e6f7ff;
|
||||
}
|
||||
|
||||
&.ant-menu-item-selected {
|
||||
background-color: #e6f7ff;
|
||||
|
||||
&::after {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-submenu {
|
||||
>.ant-menu-submenu-title {
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
margin: 0;
|
||||
padding-left: 20px !important;
|
||||
|
||||
&:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
&.ant-menu-submenu-open {
|
||||
>.ant-menu-submenu-title {
|
||||
color: #1890ff;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-menu-sub {
|
||||
background-color: #fafafa;
|
||||
|
||||
.ant-menu-item {
|
||||
padding-left: 40px !important;
|
||||
}
|
||||
}
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-layout {
|
||||
flex: 1;
|
||||
&.layout-top {
|
||||
.top-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 50px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
padding: 0 20px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 40px;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.app-header {
|
||||
height: 50px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.collapse-icon {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
margin-right: 16px;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.app-main {
|
||||
height: calc(100vh - 106px);
|
||||
}
|
||||
}
|
||||
flex: 1;
|
||||
background: #f0f2f5;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
|
||||
/* Top布局 */
|
||||
&.layout-top {
|
||||
flex-direction: column;
|
||||
|
||||
.top-header {
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
height: 60px;
|
||||
border-bottom: none;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.top-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
|
||||
.logo-box-top {
|
||||
.logo-text {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
}
|
||||
.iframe-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
.top-content {
|
||||
height: calc(100vh - 116px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用菜单样式优化 */
|
||||
:deep(.ant-menu-item-icon) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+181
-354
@@ -1,401 +1,228 @@
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.back()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="not-found">
|
||||
<div class="content-wrapper">
|
||||
<div class="not-found-container">
|
||||
<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-text">
|
||||
<h1>页面未找到</h1>
|
||||
<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="error-title">页面未找到</div>
|
||||
<div class="error-description">抱歉,您访问的页面不存在或已被移除</div>
|
||||
|
||||
<div class="action-buttons">
|
||||
<button class="btn btn-primary" @click="goHome">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"></path>
|
||||
<polyline points="9 22 9 12 15 12 15 22"></polyline>
|
||||
</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>
|
||||
<el-button type="primary" size="large" @click="goBack">
|
||||
<el-icon>
|
||||
<ArrowLeft />
|
||||
</el-icon>
|
||||
返回上一页
|
||||
</button>
|
||||
</el-button>
|
||||
<el-button size="large" @click="goHome">
|
||||
<el-icon>
|
||||
<HomeFilled />
|
||||
</el-icon>
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.not-found {
|
||||
<script setup>
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #0c1929 0%, #1a237e 50%, #0d47a1 100%);
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 星空背景 */
|
||||
.not-found::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
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;
|
||||
}
|
||||
.tech-decoration {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
|
||||
@keyframes stars {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
.tech-circle {
|
||||
position: absolute;
|
||||
border: 2px solid rgba(255, 107, 53, 0.1);
|
||||
border-radius: 50%;
|
||||
animation: pulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 550px 250px;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@keyframes pulse {
|
||||
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;
|
||||
height: 200px;
|
||||
margin: 0 auto 40px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
opacity: 0.6;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.rocket {
|
||||
position: absolute;
|
||||
top: 30%;
|
||||
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%;
|
||||
.not-found-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
}
|
||||
z-index: 1;
|
||||
|
||||
.rocket-window {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
box-shadow: inset -2px -2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.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% {
|
||||
height: 30px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes flyRocket {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0) rotate(-15deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-15px) rotate(-10deg);
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 28px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #00d4ff 0%, #7c4dff 100%);
|
||||
color: white;
|
||||
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 25px rgba(0, 212, 255, 0.6);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
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);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--auth-primary-dark), var(--auth-primary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.error-text h1 {
|
||||
font-size: 28px;
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.error-text p {
|
||||
font-size: 14px;
|
||||
.error-title {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.error-illustration {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
.error-description {
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.el-button {
|
||||
--el-button-border-radius: 12px;
|
||||
height: 48px;
|
||||
padding: 0 32px;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.el-button--primary) {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border: 2px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--auth-primary);
|
||||
color: var(--auth-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(255, 107, 53, 0.15);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.not-found-content {
|
||||
padding: 20px;
|
||||
|
||||
.error-code {
|
||||
font-size: 80px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
.el-button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+160
-241
@@ -1,288 +1,207 @@
|
||||
<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>
|
||||
<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-illustration">
|
||||
<div class="illustration-wrapper">
|
||||
<svg class="empty-icon" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<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 class="empty-icon">
|
||||
<el-icon :size="120" color="#ff6b35">
|
||||
<Box />
|
||||
</el-icon>
|
||||
</div>
|
||||
|
||||
<div class="empty-text">
|
||||
<h3 class="empty-title">{{ title }}</h3>
|
||||
<p class="empty-description">{{ description }}</p>
|
||||
<div class="empty-title">暂无数据</div>
|
||||
<div class="empty-description">
|
||||
{{ description || '当前页面暂无数据,请稍后再试' }}
|
||||
</div>
|
||||
|
||||
<div v-if="showAction" class="empty-action">
|
||||
<button class="action-btn" @click="handleAction">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M23 4v6h-6"></path>
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path>
|
||||
</svg>
|
||||
{{ actionText }}
|
||||
</button>
|
||||
</div>
|
||||
<el-button v-if="showButton" type="primary" size="large" @click="handleAction">
|
||||
<el-icon v-if="buttonIcon">
|
||||
<component :is="buttonIcon" />
|
||||
</el-icon>
|
||||
{{ buttonText || '刷新页面' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
min-height: 400px;
|
||||
<script setup>
|
||||
import { Box } from '@element-plus/icons-vue'
|
||||
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 60px 20px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.03) 0%, rgba(118, 75, 162, 0.03) 100%);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 装饰性背景 */
|
||||
.empty-state::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
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 {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200px 150px;
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 480px;
|
||||
animation: fadeIn 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
.empty-icon {
|
||||
margin-bottom: 32px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
margin-bottom: 32px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.illustration-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
animation: floatIcon 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes floatIcon {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 15px;
|
||||
color: #666;
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.empty-action {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.action-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 暗色主题适配 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.empty-title {
|
||||
color: #fff;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 40px;
|
||||
line-height: 1.6;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.el-button {
|
||||
--el-button-border-radius: 12px;
|
||||
height: 48px;
|
||||
padding: 0 40px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式适配 */
|
||||
// Responsive design
|
||||
@media (max-width: 768px) {
|
||||
.empty-state {
|
||||
min-height: 300px;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.empty-content {
|
||||
padding: 20px;
|
||||
|
||||
.empty-icon {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
.empty-icon {
|
||||
:deep(.el-icon) {
|
||||
font-size: 80px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 20px;
|
||||
}
|
||||
.empty-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
.empty-description {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 10px 24px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
.el-button {
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+4
-3
@@ -1,7 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import Antd from 'ant-design-vue'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '@/assets/style/app.scss'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './stores'
|
||||
@@ -10,7 +11,7 @@ import boot from './boot'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(Antd)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
app.use(i18n)
|
||||
|
||||
+6
-322
@@ -1,324 +1,8 @@
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h1 class="page-title">欢迎回来,管理员!</h1>
|
||||
<div class="dashboard-cards">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">👥</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">用户总数</div>
|
||||
<div class="stat-value">1,234</div>
|
||||
<div class="stat-change positive">+12.5%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">数据统计</div>
|
||||
<div class="stat-value">5,678</div>
|
||||
<div class="stat-change positive">+8.3%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">📁</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">文件数量</div>
|
||||
<div class="stat-value">892</div>
|
||||
<div class="stat-change negative">-2.1%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon">⚙️</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-label">系统运行</div>
|
||||
<div class="stat-value">99.9%</div>
|
||||
<div class="stat-change positive">+0.5%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-content">
|
||||
<div class="content-card">
|
||||
<h2>最近活动</h2>
|
||||
<div class="activity-list">
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">✓</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-title">新用户注册</div>
|
||||
<div class="activity-desc">用户 John Doe 完成了注册</div>
|
||||
<div class="activity-time">5分钟前</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">📊</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-title">数据更新</div>
|
||||
<div class="activity-desc">月度报告已生成</div>
|
||||
<div class="activity-time">30分钟前</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">⚠️</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-title">系统警告</div>
|
||||
<div class="activity-desc">服务器负载较高</div>
|
||||
<div class="activity-time">1小时前</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="activity-item">
|
||||
<div class="activity-icon">📁</div>
|
||||
<div class="activity-info">
|
||||
<div class="activity-title">文件上传</div>
|
||||
<div class="activity-desc">项目文档已上传</div>
|
||||
<div class="activity-time">2小时前</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-card">
|
||||
<h2>快速操作</h2>
|
||||
<div class="quick-actions">
|
||||
<button class="action-btn">
|
||||
<span class="action-icon">➕</span>
|
||||
<span>添加用户</span>
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<span class="action-icon">📤</span>
|
||||
<span>上传文件</span>
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<span class="action-icon">📝</span>
|
||||
<span>新建报告</span>
|
||||
</button>
|
||||
<button class="action-btn">
|
||||
<span class="action-icon">⚙️</span>
|
||||
<span>系统设置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.dashboard-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-change {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-change.positive {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.stat-change.negative {
|
||||
color: #f5222d;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.content-card h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 16px;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.05) 0%, rgba(118, 75, 162, 0.05) 100%);
|
||||
border: 2px solid transparent;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: #f8f9ff;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #667eea;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-title {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.dashboard-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script setup>
|
||||
defineOptions({
|
||||
name: 'HomeIndex',
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<h1>Vue Admin</h1>
|
||||
</div>
|
||||
<p class="login-subtitle">欢迎回来</p>
|
||||
</div>
|
||||
|
||||
<a-form :model="formState" @finish="handleLogin" layout="vertical" class="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="register-link">
|
||||
立即注册
|
||||
</router-link>
|
||||
</div>
|
||||
</a-form>
|
||||
</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 { userLogin, getUserInfo, getMyMenu } from '@/api/auth'
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
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 userLogin({ username: formState.username, password: formState.password })
|
||||
if (res.code == 1) {
|
||||
userStore.setToken(res.data.access_token)
|
||||
let userInfo = await getUserInfo()
|
||||
if (userInfo.code == 1) {
|
||||
userStore.setUserInfo(userInfo.data)
|
||||
}
|
||||
let authData = await getMyMenu()
|
||||
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>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-input-affix-wrapper),
|
||||
.login-form :deep(.ant-input) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.forgot-password {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.forgot-password:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.register-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,210 +0,0 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<h1>Vue Admin</h1>
|
||||
</div>
|
||||
<p class="login-subtitle">重置密码</p>
|
||||
</div>
|
||||
|
||||
<a-form :model="formState" @finish="handleReset" layout="vertical" class="login-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: '请输入验证码' }]">
|
||||
<a-input v-model:value="formState.verificationCode" placeholder="验证码" size="large"
|
||||
:prefix="h(SafetyOutlined)">
|
||||
<template #suffix>
|
||||
<a-button type="link" :disabled="countdown > 0" @click="sendCode" class="code-btn">
|
||||
{{ countdown > 0 ? `${countdown}秒后重试` : '发送验证码' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-input>
|
||||
</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="register-link">
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
</a-form>
|
||||
</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';
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
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>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-input-affix-wrapper),
|
||||
.login-form :deep(.ant-input) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.code-btn {
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.register-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,203 +0,0 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-wrapper">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="login-logo">
|
||||
<h1>Vue Admin</h1>
|
||||
</div>
|
||||
<p class="login-subtitle">创建新账号</p>
|
||||
</div>
|
||||
|
||||
<a-form :model="formState" @finish="handleRegister" layout="vertical" class="login-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">
|
||||
我已阅读并同意
|
||||
<a href="#" class="link">用户协议</a>
|
||||
和
|
||||
<a href="#" class="link">隐私政策</a>
|
||||
</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="register-link">
|
||||
立即登录
|
||||
</router-link>
|
||||
</div>
|
||||
</a-form>
|
||||
</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';
|
||||
|
||||
// 定义组件名称(多词命名)
|
||||
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>
|
||||
.login-container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.login-logo h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-form-item) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.login-form :deep(.ant-input-affix-wrapper),
|
||||
.login-form :deep(.ant-input) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
text-align: center;
|
||||
margin-top: 16px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.register-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -1,193 +0,0 @@
|
||||
<template>
|
||||
<div class="system-menu">
|
||||
<a-card title="菜单管理">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined />
|
||||
新增菜单
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="false" row-key="id">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'icon'">
|
||||
<component :is="record.icon || 'MenuOutlined'" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'type'">
|
||||
<a-tag :color="getTypeColor(record.type)">
|
||||
{{ getTypeText(record.type) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handleAddChild(record)">新增子菜单</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, MenuOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '菜单名称',
|
||||
dataIndex: 'title',
|
||||
key: 'title'
|
||||
},
|
||||
{
|
||||
title: '图标',
|
||||
dataIndex: 'icon',
|
||||
key: 'icon',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '路径',
|
||||
dataIndex: 'path',
|
||||
key: 'path'
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '排序',
|
||||
dataIndex: 'sort',
|
||||
key: 'sort',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 220
|
||||
}
|
||||
]
|
||||
|
||||
const dataSource = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 模拟数据
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
title: '首页',
|
||||
path: '/home',
|
||||
icon: 'HomeOutlined',
|
||||
type: 1,
|
||||
sort: 1,
|
||||
status: 1,
|
||||
children: []
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '系统管理',
|
||||
path: '/system',
|
||||
icon: 'SettingOutlined',
|
||||
type: 1,
|
||||
sort: 2,
|
||||
status: 1,
|
||||
children: [
|
||||
{
|
||||
id: 21,
|
||||
title: '用户管理',
|
||||
path: '/system/user',
|
||||
icon: 'UserOutlined',
|
||||
type: 2,
|
||||
sort: 1,
|
||||
status: 1
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
title: '角色管理',
|
||||
path: '/system/role',
|
||||
icon: 'TeamOutlined',
|
||||
type: 2,
|
||||
sort: 2,
|
||||
status: 1
|
||||
},
|
||||
{
|
||||
id: 23,
|
||||
title: '菜单管理',
|
||||
path: '/system/menu',
|
||||
icon: 'MenuOutlined',
|
||||
type: 2,
|
||||
sort: 3,
|
||||
status: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
dataSource.value = mockData
|
||||
loading.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const colorMap = {
|
||||
1: 'blue',
|
||||
2: 'green',
|
||||
3: 'orange'
|
||||
}
|
||||
return colorMap[type] || 'default'
|
||||
}
|
||||
|
||||
const getTypeText = (type) => {
|
||||
const textMap = {
|
||||
1: '目录',
|
||||
2: '菜单',
|
||||
3: '按钮'
|
||||
}
|
||||
return textMap[type] || '未知'
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
message.info('新增菜单功能开发中')
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
message.info('编辑菜单功能开发中')
|
||||
}
|
||||
|
||||
const handleAddChild = (record) => {
|
||||
message.info(`新增 ${record.title} 的子菜单功能开发中`)
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
message.info('删除菜单功能开发中')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-menu {
|
||||
// 样式
|
||||
}
|
||||
</style>
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<div class="system-role">
|
||||
<a-card title="角色管理">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined />
|
||||
新增角色
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" @click="handlePermission(record)">权限</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '角色名称',
|
||||
dataIndex: 'roleName',
|
||||
key: 'roleName'
|
||||
},
|
||||
{
|
||||
title: '角色标识',
|
||||
dataIndex: 'roleCode',
|
||||
key: 'roleCode'
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 180
|
||||
}
|
||||
]
|
||||
|
||||
const dataSource = ref([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 模拟数据
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
roleName: '管理员',
|
||||
roleCode: 'admin',
|
||||
description: '系统管理员',
|
||||
status: 1,
|
||||
createTime: '2024-01-01 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
roleName: '普通用户',
|
||||
roleCode: 'user',
|
||||
description: '普通用户角色',
|
||||
status: 1,
|
||||
createTime: '2024-01-02 10:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
dataSource.value = mockData
|
||||
pagination.value.total = mockData.length
|
||||
loading.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
message.info('新增角色功能开发中')
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
message.info('编辑角色功能开发中')
|
||||
}
|
||||
|
||||
const handlePermission = (record) => {
|
||||
message.info('权限配置功能开发中')
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
message.info('删除角色功能开发中')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-role {
|
||||
// 样式
|
||||
}
|
||||
</style>
|
||||
@@ -1,172 +0,0 @@
|
||||
<template>
|
||||
<div class="system-user">
|
||||
<a-card title="用户管理">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="handleAdd">
|
||||
<PlusOutlined />
|
||||
新增用户
|
||||
</a-button>
|
||||
</template>
|
||||
|
||||
<div class="search-form">
|
||||
<a-form :model="searchForm" layout="inline">
|
||||
<a-form-item label="用户名">
|
||||
<a-input v-model:value="searchForm.username" placeholder="请输入用户名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="searchForm.status" placeholder="请选择状态" style="width: 120px">
|
||||
<a-select-option value="">全部</a-select-option>
|
||||
<a-select-option :value="1">正常</a-select-option>
|
||||
<a-select-option :value="0">禁用</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSearch">查询</a-button>
|
||||
<a-button @click="handleReset">重置</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination">
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'green' : 'red'">
|
||||
{{ record.status === 1 ? '正常' : '禁用' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-space>
|
||||
<a-button type="link" size="small" @click="handleEdit(record)">编辑</a-button>
|
||||
<a-button type="link" size="small" danger @click="handleDelete(record)">删除</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80
|
||||
},
|
||||
{
|
||||
title: '用户名',
|
||||
dataIndex: 'username',
|
||||
key: 'username'
|
||||
},
|
||||
{
|
||||
title: '昵称',
|
||||
dataIndex: 'nickname',
|
||||
key: 'nickname'
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createTime',
|
||||
key: 'createTime'
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 150
|
||||
}
|
||||
]
|
||||
|
||||
const searchForm = ref({
|
||||
username: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const dataSource = ref([])
|
||||
const loading = ref(false)
|
||||
const pagination = ref({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => `共 ${total} 条`
|
||||
})
|
||||
|
||||
// 模拟数据
|
||||
const mockData = [
|
||||
{
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
status: 1,
|
||||
createTime: '2024-01-01 10:00:00'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
username: 'user',
|
||||
nickname: '普通用户',
|
||||
status: 1,
|
||||
createTime: '2024-01-02 10:00:00'
|
||||
}
|
||||
]
|
||||
|
||||
const loadData = () => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
dataSource.value = mockData
|
||||
pagination.value.total = mockData.length
|
||||
loading.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
searchForm.value = {
|
||||
username: '',
|
||||
status: ''
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
message.info('新增用户功能开发中')
|
||||
}
|
||||
|
||||
const handleEdit = (record) => {
|
||||
message.info('编辑用户功能开发中')
|
||||
}
|
||||
|
||||
const handleDelete = (record) => {
|
||||
message.info('删除用户功能开发中')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.system-user {
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
background-color: #fafafa;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<DynamicForm :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 DynamicForm from '@/components/DynamicForm.vue'
|
||||
|
||||
const props = defineProps({
|
||||
userInfo: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update'])
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单初始值
|
||||
const initialValues = computed(() => ({
|
||||
username: props.userInfo.username || '',
|
||||
nickname: props.userInfo.nickname || '',
|
||||
phone: props.userInfo.phone || '',
|
||||
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',
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
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: 'birthday',
|
||||
label: '生日',
|
||||
type: 'date',
|
||||
},
|
||||
{
|
||||
field: 'bio',
|
||||
label: '个人简介',
|
||||
type: 'textarea',
|
||||
rows: 4,
|
||||
maxLength: 200,
|
||||
showCount: true,
|
||||
},
|
||||
]
|
||||
|
||||
// 表单提交
|
||||
const handleFinish = (values) => {
|
||||
loading.value = true
|
||||
// 模拟接口请求
|
||||
setTimeout(() => {
|
||||
emit('update', values)
|
||||
message.success('保存成功')
|
||||
loading.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const handleReset = () => {
|
||||
message.info('已重置')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<template>
|
||||
<DynamicForm :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 DynamicForm from '@/components/DynamicForm.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,202 +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'
|
||||
|
||||
// 用户信息
|
||||
const userInfo = ref({
|
||||
username: '',
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
gender: 0,
|
||||
birthday: null,
|
||||
bio: '',
|
||||
})
|
||||
|
||||
// 选中的菜单
|
||||
const selectedKeys = ref(['basic'])
|
||||
|
||||
// 头像上传
|
||||
const showAvatarModal = ref(false)
|
||||
const avatarFileList = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
// 初始化用户信息
|
||||
const initUserInfo = () => {
|
||||
// 模拟用户数据
|
||||
const mockUserInfo = {
|
||||
username: 'admin',
|
||||
nickname: '管理员',
|
||||
phone: '13800138000',
|
||||
email: 'admin@example.com',
|
||||
avatar: '',
|
||||
status: 1,
|
||||
gender: 1,
|
||||
birthday: dayjs('1990-01-01'),
|
||||
bio: '热爱编程,专注于前端开发技术。',
|
||||
}
|
||||
userInfo.value = { ...mockUserInfo }
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
const handleUpdateUserInfo = (data) => {
|
||||
Object.assign(userInfo.value, data)
|
||||
}
|
||||
|
||||
// 密码修改成功
|
||||
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>
|
||||
+14
-15
@@ -9,7 +9,7 @@ import systemRoutes from './systemRoutes'
|
||||
NProgress.configure({
|
||||
showSpinner: false,
|
||||
trickleSpeed: 200,
|
||||
minimum: 0.3
|
||||
minimum: 0.3,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -21,14 +21,14 @@ const notFoundRoute = {
|
||||
component: () => import('../layouts/other/404.vue'),
|
||||
meta: {
|
||||
title: '404',
|
||||
hidden: true
|
||||
}
|
||||
hidden: true,
|
||||
},
|
||||
}
|
||||
|
||||
// 创建路由实例
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: systemRoutes
|
||||
routes: systemRoutes,
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -63,8 +63,8 @@ function transformMenusToRoutes(menus) {
|
||||
}
|
||||
|
||||
return menus
|
||||
.filter(menu => menu && menu.path)
|
||||
.map(menu => {
|
||||
.filter((menu) => menu && menu.path)
|
||||
.map((menu) => {
|
||||
const route = {
|
||||
path: menu.path,
|
||||
name: menu.name || menu.path.replace(/\//g, '-'),
|
||||
@@ -73,8 +73,9 @@ function transformMenusToRoutes(menus) {
|
||||
icon: menu.meta?.icon || menu.icon,
|
||||
hidden: menu.hidden || menu.meta?.hidden,
|
||||
keepAlive: menu.meta?.keepAlive || false,
|
||||
role: menu.meta?.role || []
|
||||
}
|
||||
affix: menu.meta?.affix || 0,
|
||||
role: menu.meta?.role || [],
|
||||
},
|
||||
}
|
||||
|
||||
// 处理组件
|
||||
@@ -106,9 +107,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
NProgress.start()
|
||||
|
||||
// 设置页面标题
|
||||
document.title = to.meta.title
|
||||
? `${to.meta.title} - ${config.APP_NAME}`
|
||||
: config.APP_NAME
|
||||
document.title = to.meta.title ? `${to.meta.title} - ${config.APP_NAME}` : config.APP_NAME
|
||||
|
||||
const userStore = useUserStore()
|
||||
const isLoggedIn = userStore.isLoggedIn()
|
||||
@@ -125,7 +124,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
// 保存目标路由,登录后跳转
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
query: { redirect: to.fullPath },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -148,7 +147,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
const dynamicRoutes = transformMenusToRoutes(mergedMenus)
|
||||
|
||||
// 添加动态路由到 Layout 的子路由
|
||||
dynamicRoutes.forEach(route => {
|
||||
dynamicRoutes.forEach((route) => {
|
||||
router.addRoute('Layout', route)
|
||||
})
|
||||
|
||||
@@ -171,7 +170,7 @@ router.beforeEach(async (to, from, next) => {
|
||||
userStore.logout()
|
||||
next({
|
||||
path: '/login',
|
||||
query: { redirect: to.fullPath }
|
||||
query: { redirect: to.fullPath },
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -195,7 +194,7 @@ export function resetRouter() {
|
||||
// 重置为初始路由
|
||||
const newRouter = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: systemRoutes
|
||||
routes: systemRoutes,
|
||||
})
|
||||
|
||||
router.matcher = newRouter.matcher
|
||||
|
||||
@@ -7,27 +7,27 @@ const systemRoutes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('../pages/login/index.vue'),
|
||||
component: () => import('../pages/ucenter/login/index.vue'),
|
||||
meta: {
|
||||
title: 'login',
|
||||
title: '登录',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'Register',
|
||||
component: () => import('../pages/login/userRegister.vue'),
|
||||
component: () => import('../pages/ucenter/register/index.vue'),
|
||||
meta: {
|
||||
title: 'register',
|
||||
title: '注册',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reset-password',
|
||||
name: 'ResetPassword',
|
||||
component: () => import('../pages/login/resetPassword.vue'),
|
||||
path: '/forgot-password',
|
||||
name: 'ForgotPassword',
|
||||
component: () => import('../pages/ucenter/forgot-password/index.vue'),
|
||||
meta: {
|
||||
title: 'resetPassword',
|
||||
title: '找回密码',
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
|
||||
+23
-28
@@ -1,36 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import i18n from '@/i18n'
|
||||
import { customStorage } from '../persist'
|
||||
|
||||
export const useI18nStore = defineStore(
|
||||
'i18n',
|
||||
{
|
||||
state: () => ({
|
||||
currentLocale: 'zh-CN',
|
||||
availableLocales: [
|
||||
{ label: '简体中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' }
|
||||
]
|
||||
}),
|
||||
export const useI18nStore = defineStore('i18n', {
|
||||
state: () => ({
|
||||
currentLocale: 'zh-CN',
|
||||
availableLocales: [
|
||||
{ label: '简体中文', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en-US' },
|
||||
],
|
||||
}),
|
||||
|
||||
getters: {
|
||||
localeLabel: (state) => {
|
||||
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
|
||||
return locale ? locale.label : ''
|
||||
}
|
||||
getters: {
|
||||
localeLabel: (state) => {
|
||||
const locale = state.availableLocales.find((item) => item.value === state.currentLocale)
|
||||
return locale ? locale.label : ''
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
setLocale(locale) {
|
||||
this.currentLocale = locale
|
||||
i18n.global.locale.value = locale
|
||||
}
|
||||
actions: {
|
||||
setLocale(locale) {
|
||||
this.currentLocale = locale
|
||||
i18n.global.locale.value = locale
|
||||
},
|
||||
},
|
||||
|
||||
persist: {
|
||||
key: 'i18n-store',
|
||||
storage: customStorage,
|
||||
pick: ['currentLocale']
|
||||
}
|
||||
}
|
||||
)
|
||||
persist: {
|
||||
key: 'i18n-store',
|
||||
pick: ['currentLocale'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { customStorage } from '../persist'
|
||||
|
||||
export const useLayoutStore = defineStore(
|
||||
'layout',
|
||||
@@ -26,6 +25,9 @@ export const useLayoutStore = defineStore(
|
||||
// 视图标签页(用于记录页面滚动位置)
|
||||
const viewTags = ref([])
|
||||
|
||||
// 刷新标签的 key,用于触发组件刷新
|
||||
const refreshKey = ref(0)
|
||||
|
||||
// 切换侧边栏折叠
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
@@ -81,6 +83,11 @@ export const useLayoutStore = defineStore(
|
||||
showBreadcrumb.value = show
|
||||
}
|
||||
|
||||
// 刷新标签
|
||||
const refreshTag = () => {
|
||||
refreshKey.value++
|
||||
}
|
||||
|
||||
// 重置主题设置
|
||||
const resetTheme = () => {
|
||||
themeColor.value = '#1890ff'
|
||||
@@ -98,6 +105,7 @@ export const useLayoutStore = defineStore(
|
||||
themeColor,
|
||||
showTags,
|
||||
showBreadcrumb,
|
||||
refreshKey,
|
||||
toggleSidebar,
|
||||
setLayoutMode,
|
||||
setSelectedParentMenu,
|
||||
@@ -108,13 +116,13 @@ export const useLayoutStore = defineStore(
|
||||
setShowTags,
|
||||
setShowBreadcrumb,
|
||||
resetTheme,
|
||||
refreshTag,
|
||||
}
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'layout-store',
|
||||
storage: customStorage,
|
||||
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb'],
|
||||
pick: ['layoutMode', 'sidebarCollapsed', 'themeColor', 'showTags', 'showBreadcrumb', 'viewTags'],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { resetRouter } from '../../router'
|
||||
import { customStorage } from '../persist'
|
||||
import userRoutes from '@/config/routes'
|
||||
|
||||
export const useUserStore = defineStore(
|
||||
@@ -42,14 +41,14 @@ export const useUserStore = defineStore(
|
||||
const menuMap = new Map()
|
||||
|
||||
// 先添加静态菜单
|
||||
staticMenus.forEach(menu => {
|
||||
staticMenus.forEach((menu) => {
|
||||
if (menu.path) {
|
||||
menuMap.set(menu.path, menu)
|
||||
}
|
||||
})
|
||||
|
||||
// 添加后端菜单,如果路径重复则覆盖
|
||||
newMenu.forEach(menu => {
|
||||
newMenu.forEach((menu) => {
|
||||
if (menu.path) {
|
||||
menuMap.set(menu.path, menu)
|
||||
}
|
||||
@@ -72,7 +71,7 @@ export const useUserStore = defineStore(
|
||||
}
|
||||
|
||||
// 设置权限
|
||||
function setPermissions(data){
|
||||
function setPermissions(data) {
|
||||
permissions.value = data
|
||||
}
|
||||
|
||||
@@ -111,8 +110,7 @@ export const useUserStore = defineStore(
|
||||
{
|
||||
persist: {
|
||||
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 config from '@/config'
|
||||
import { useUserStore } from '@/stores/modules/user'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
|
||||
const http = axios.create({
|
||||
timeout: 30000,
|
||||
baseURL: config.API_URL
|
||||
baseURL: config.API_URL,
|
||||
})
|
||||
|
||||
// 是否正在刷新 token
|
||||
@@ -29,7 +29,7 @@ http.interceptors.request.use(
|
||||
},
|
||||
(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 || '请求失败'))
|
||||
},
|
||||
async (error) => {
|
||||
@@ -54,7 +54,7 @@ http.interceptors.response.use(
|
||||
|
||||
// 无响应(网络错误、超时等)
|
||||
if (!response) {
|
||||
message.error('网络错误,请检查网络连接')
|
||||
ElMessage.error('网络错误,请检查网络连接')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ http.interceptors.response.use(
|
||||
requests = []
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
message.error('登录已过期,请重新登录')
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
@@ -104,27 +104,27 @@ http.interceptors.response.use(
|
||||
|
||||
// 403 禁止访问
|
||||
if (status === 403) {
|
||||
message.error('没有权限访问该资源')
|
||||
ElMessage.error('没有权限访问该资源')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 404 资源不存在
|
||||
if (status === 404) {
|
||||
message.error('请求的资源不存在')
|
||||
ElMessage.error('请求的资源不存在')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 500 服务器错误
|
||||
if (status >= 500) {
|
||||
message.error('服务器错误,请稍后重试')
|
||||
ElMessage.error('服务器错误,请稍后重试')
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// 其他错误
|
||||
const errorMessage = data?.message || error.message || '请求失败'
|
||||
message.error(errorMessage)
|
||||
ElMessage.error(errorMessage)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 刷新 token 的方法
|
||||
@@ -136,7 +136,7 @@ async function refreshToken() {
|
||||
const refreshTokenValue = userStore.refreshToken
|
||||
|
||||
const response = await axios.post(refreshUrl, {
|
||||
refreshToken: refreshTokenValue
|
||||
refreshToken: refreshTokenValue,
|
||||
})
|
||||
|
||||
// 假设返回格式为 { code, data: { token, refreshToken } }
|
||||
|
||||
+175
-217
@@ -5,10 +5,10 @@
|
||||
* @LastEditTime: 2026年1月15日
|
||||
*/
|
||||
|
||||
import CryptoJS from "crypto-js";
|
||||
import sysConfig from "@/config";
|
||||
import CryptoJS from 'crypto-js'
|
||||
import sysConfig from '@/config'
|
||||
|
||||
const tool = {};
|
||||
const tool = {}
|
||||
|
||||
/**
|
||||
* 检查是否为有效的值(非null、非undefined、非空字符串、非空数组、非空对象)
|
||||
@@ -17,19 +17,19 @@ const tool = {};
|
||||
*/
|
||||
tool.isValid = function (value) {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
if (typeof value === "string" && value.trim() === "") {
|
||||
return false;
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return false
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
if (typeof value === "object" && Object.keys(value).length === 0) {
|
||||
return false;
|
||||
if (typeof value === 'object' && Object.keys(value).length === 0) {
|
||||
return false
|
||||
}
|
||||
return true;
|
||||
};
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
@@ -39,21 +39,21 @@ tool.isValid = function (value) {
|
||||
* @returns {Function}
|
||||
*/
|
||||
tool.debounce = function (func, wait = 300, immediate = false) {
|
||||
let timeout;
|
||||
let timeout
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
const context = this
|
||||
clearTimeout(timeout)
|
||||
if (immediate && !timeout) {
|
||||
func.apply(context, args);
|
||||
func.apply(context, args)
|
||||
}
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null;
|
||||
timeout = null
|
||||
if (!immediate) {
|
||||
func.apply(context, args);
|
||||
func.apply(context, args)
|
||||
}
|
||||
}, wait);
|
||||
};
|
||||
};
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
@@ -63,36 +63,36 @@ tool.debounce = function (func, wait = 300, immediate = false) {
|
||||
* @returns {Function}
|
||||
*/
|
||||
tool.throttle = function (func, wait = 300, options = {}) {
|
||||
let timeout;
|
||||
let previous = 0;
|
||||
const { leading = true, trailing = true } = options;
|
||||
let timeout
|
||||
let previous = 0
|
||||
const { leading = true, trailing = true } = options
|
||||
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
const now = Date.now();
|
||||
const context = this
|
||||
const now = Date.now()
|
||||
|
||||
if (!previous && !leading) {
|
||||
previous = now;
|
||||
previous = now
|
||||
}
|
||||
|
||||
const remaining = wait - (now - previous);
|
||||
const remaining = wait - (now - previous)
|
||||
|
||||
if (remaining <= 0 || remaining > wait) {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
clearTimeout(timeout)
|
||||
timeout = null
|
||||
}
|
||||
previous = now;
|
||||
func.apply(context, args);
|
||||
previous = now
|
||||
func.apply(context, args)
|
||||
} else if (!timeout && trailing) {
|
||||
timeout = setTimeout(() => {
|
||||
previous = leading ? Date.now() : 0;
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
}, remaining);
|
||||
previous = leading ? Date.now() : 0
|
||||
timeout = null
|
||||
func.apply(context, args)
|
||||
}, remaining)
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝对象(支持循环引用)
|
||||
@@ -101,99 +101,88 @@ tool.throttle = function (func, wait = 300, options = {}) {
|
||||
* @returns {*}
|
||||
*/
|
||||
tool.deepClone = function (obj, hash = new WeakMap()) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
|
||||
if (hash.has(obj)) {
|
||||
return hash.get(obj);
|
||||
return hash.get(obj)
|
||||
}
|
||||
|
||||
const clone = Array.isArray(obj) ? [] : {};
|
||||
hash.set(obj, clone);
|
||||
const clone = Array.isArray(obj) ? [] : {}
|
||||
hash.set(obj, clone)
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
clone[key] = tool.deepClone(obj[key], hash);
|
||||
clone[key] = tool.deepClone(obj[key], hash)
|
||||
}
|
||||
}
|
||||
|
||||
return clone;
|
||||
};
|
||||
return clone
|
||||
}
|
||||
|
||||
/* localStorage */
|
||||
tool.data = {
|
||||
set(key, data, datetime = 0) {
|
||||
//加密
|
||||
if (sysConfig.LS_ENCRYPTION == "AES") {
|
||||
data = tool.crypto.AES.encrypt(
|
||||
JSON.stringify(data),
|
||||
sysConfig.LS_ENCRYPTION_key,
|
||||
);
|
||||
if (sysConfig.LS_ENCRYPTION == 'AES') {
|
||||
data = tool.crypto.AES.encrypt(JSON.stringify(data), sysConfig.LS_ENCRYPTION_key)
|
||||
}
|
||||
let cacheValue = {
|
||||
content: data,
|
||||
datetime:
|
||||
parseInt(datetime) === 0
|
||||
? 0
|
||||
: new Date().getTime() + parseInt(datetime) * 1000,
|
||||
};
|
||||
return localStorage.setItem(key, JSON.stringify(cacheValue));
|
||||
datetime: parseInt(datetime) === 0 ? 0 : new Date().getTime() + parseInt(datetime) * 1000,
|
||||
}
|
||||
return localStorage.setItem(key, JSON.stringify(cacheValue))
|
||||
},
|
||||
get(key) {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key));
|
||||
const value = JSON.parse(localStorage.getItem(key))
|
||||
if (value) {
|
||||
let nowTime = new Date().getTime();
|
||||
let nowTime = new Date().getTime()
|
||||
if (nowTime > value.datetime && value.datetime != 0) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
localStorage.removeItem(key)
|
||||
return null
|
||||
}
|
||||
//解密
|
||||
if (sysConfig.LS_ENCRYPTION == "AES") {
|
||||
value.content = JSON.parse(
|
||||
tool.crypto.AES.decrypt(
|
||||
value.content,
|
||||
sysConfig.LS_ENCRYPTION_key,
|
||||
),
|
||||
);
|
||||
if (sysConfig.LS_ENCRYPTION == 'AES') {
|
||||
value.content = JSON.parse(tool.crypto.AES.decrypt(value.content, sysConfig.LS_ENCRYPTION_key))
|
||||
}
|
||||
return value.content;
|
||||
return value.content
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
} catch {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
},
|
||||
remove(key) {
|
||||
return localStorage.removeItem(key);
|
||||
return localStorage.removeItem(key)
|
||||
},
|
||||
clear() {
|
||||
return localStorage.clear();
|
||||
return localStorage.clear()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*sessionStorage*/
|
||||
tool.session = {
|
||||
set(table, settings) {
|
||||
const _set = JSON.stringify(settings);
|
||||
return sessionStorage.setItem(table, _set);
|
||||
const _set = JSON.stringify(settings)
|
||||
return sessionStorage.setItem(table, _set)
|
||||
},
|
||||
get(table) {
|
||||
const data = sessionStorage.getItem(table);
|
||||
const data = sessionStorage.getItem(table)
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
},
|
||||
remove(table) {
|
||||
return sessionStorage.removeItem(table);
|
||||
return sessionStorage.removeItem(table)
|
||||
},
|
||||
clear() {
|
||||
return sessionStorage.clear();
|
||||
return sessionStorage.clear()
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/*cookie*/
|
||||
tool.cookie = {
|
||||
@@ -210,28 +199,28 @@ tool.cookie = {
|
||||
domain: null,
|
||||
secure: false,
|
||||
httpOnly: false,
|
||||
sameSite: "Lax",
|
||||
sameSite: 'Lax',
|
||||
...config,
|
||||
};
|
||||
let cookieStr = `${name}=${encodeURIComponent(value)}`;
|
||||
}
|
||||
let cookieStr = `${name}=${encodeURIComponent(value)}`
|
||||
if (cfg.expires) {
|
||||
const exp = new Date();
|
||||
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000);
|
||||
cookieStr += `;expires=${exp.toUTCString()}`;
|
||||
const exp = new Date()
|
||||
exp.setTime(exp.getTime() + parseInt(cfg.expires) * 1000)
|
||||
cookieStr += `;expires=${exp.toUTCString()}`
|
||||
}
|
||||
if (cfg.path) {
|
||||
cookieStr += `;path=${cfg.path}`;
|
||||
cookieStr += `;path=${cfg.path}`
|
||||
}
|
||||
if (cfg.domain) {
|
||||
cookieStr += `;domain=${cfg.domain}`;
|
||||
cookieStr += `;domain=${cfg.domain}`
|
||||
}
|
||||
if (cfg.secure) {
|
||||
cookieStr += `;secure`;
|
||||
cookieStr += `;secure`
|
||||
}
|
||||
if (cfg.sameSite) {
|
||||
cookieStr += `;SameSite=${cfg.sameSite}`;
|
||||
cookieStr += `;SameSite=${cfg.sameSite}`
|
||||
}
|
||||
document.cookie = cookieStr;
|
||||
document.cookie = cookieStr
|
||||
},
|
||||
/**
|
||||
* 获取cookie
|
||||
@@ -239,24 +228,22 @@ tool.cookie = {
|
||||
* @returns {string|null}
|
||||
*/
|
||||
get(name) {
|
||||
const arr = document.cookie.match(
|
||||
new RegExp("(^| )" + name + "=([^;]*)(;|$)"),
|
||||
);
|
||||
const arr = document.cookie.match(new RegExp('(^| )' + name + '=([^;]*)(;|$)'))
|
||||
if (arr != null) {
|
||||
return decodeURIComponent(arr[2]);
|
||||
return decodeURIComponent(arr[2])
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
},
|
||||
/**
|
||||
* 删除cookie
|
||||
* @param {string} name - cookie名称
|
||||
*/
|
||||
remove(name) {
|
||||
const exp = new Date();
|
||||
exp.setTime(exp.getTime() - 1);
|
||||
document.cookie = `${name}=;expires=${exp.toUTCString()}`;
|
||||
const exp = new Date()
|
||||
exp.setTime(exp.getTime() - 1)
|
||||
document.cookie = `${name}=;expires=${exp.toUTCString()}`
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* Fullscreen */
|
||||
/**
|
||||
@@ -264,34 +251,29 @@ tool.cookie = {
|
||||
* @param {HTMLElement} element - 要全屏的元素
|
||||
*/
|
||||
tool.screen = function (element) {
|
||||
const isFull = !!(
|
||||
document.webkitIsFullScreen ||
|
||||
document.mozFullScreen ||
|
||||
document.msFullscreenElement ||
|
||||
document.fullscreenElement
|
||||
);
|
||||
const isFull = !!(document.webkitIsFullScreen || document.mozFullScreen || document.msFullscreenElement || document.fullscreenElement)
|
||||
if (isFull) {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
document.exitFullscreen()
|
||||
} else if (document.msExitFullscreen) {
|
||||
document.msExitFullscreen();
|
||||
document.msExitFullscreen()
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
document.mozCancelFullScreen()
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
document.webkitExitFullscreen()
|
||||
}
|
||||
} else {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
element.requestFullscreen()
|
||||
} else if (element.msRequestFullscreen) {
|
||||
element.msRequestFullscreen();
|
||||
element.msRequestFullscreen()
|
||||
} else if (element.mozRequestFullScreen) {
|
||||
element.mozRequestFullScreen();
|
||||
element.mozRequestFullScreen()
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
element.webkitRequestFullscreen();
|
||||
element.webkitRequestFullscreen()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* 复制对象(浅拷贝) */
|
||||
/**
|
||||
@@ -300,11 +282,11 @@ tool.screen = function (element) {
|
||||
* @returns {*} - 拷贝后的对象
|
||||
*/
|
||||
tool.objCopy = function (obj) {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj;
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
return obj
|
||||
}
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
};
|
||||
return JSON.parse(JSON.stringify(obj))
|
||||
}
|
||||
|
||||
/* 日期格式化 */
|
||||
/**
|
||||
@@ -313,38 +295,30 @@ tool.objCopy = function (obj) {
|
||||
* @param {string} fmt - 格式化字符串,默认 "yyyy-MM-dd hh:mm:ss"
|
||||
* @returns {string} - 格式化后的日期字符串
|
||||
*/
|
||||
tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") {
|
||||
if (!date) return "";
|
||||
const dateObj = new Date(date);
|
||||
if (isNaN(dateObj.getTime())) return "";
|
||||
tool.dateFormat = function (date, fmt = 'yyyy-MM-dd hh:mm:ss') {
|
||||
if (!date) return ''
|
||||
const dateObj = new Date(date)
|
||||
if (isNaN(dateObj.getTime())) return ''
|
||||
|
||||
const o = {
|
||||
"M+": dateObj.getMonth() + 1, // 月份
|
||||
"d+": dateObj.getDate(), // 日
|
||||
"h+": dateObj.getHours(), // 小时
|
||||
"m+": dateObj.getMinutes(), // 分
|
||||
"s+": dateObj.getSeconds(), // 秒
|
||||
"q+": Math.floor((dateObj.getMonth() + 3) / 3), // 季度
|
||||
'M+': dateObj.getMonth() + 1, // 月份
|
||||
'd+': dateObj.getDate(), // 日
|
||||
'h+': dateObj.getHours(), // 小时
|
||||
'm+': dateObj.getMinutes(), // 分
|
||||
's+': dateObj.getSeconds(), // 秒
|
||||
'q+': Math.floor((dateObj.getMonth() + 3) / 3), // 季度
|
||||
S: dateObj.getMilliseconds(), // 毫秒
|
||||
};
|
||||
}
|
||||
if (/(y+)/.test(fmt)) {
|
||||
fmt = fmt.replace(
|
||||
RegExp.$1,
|
||||
(dateObj.getFullYear() + "").substr(4 - RegExp.$1.length),
|
||||
);
|
||||
fmt = fmt.replace(RegExp.$1, (dateObj.getFullYear() + '').substr(4 - RegExp.$1.length))
|
||||
}
|
||||
for (const k in o) {
|
||||
if (new RegExp("(" + k + ")").test(fmt)) {
|
||||
fmt = fmt.replace(
|
||||
RegExp.$1,
|
||||
RegExp.$1.length == 1
|
||||
? o[k]
|
||||
: ("00" + o[k]).substr(("" + o[k]).length),
|
||||
);
|
||||
if (new RegExp('(' + k + ')').test(fmt)) {
|
||||
fmt = fmt.replace(RegExp.$1, RegExp.$1.length == 1 ? o[k] : ('00' + o[k]).substr(('' + o[k]).length))
|
||||
}
|
||||
}
|
||||
return fmt;
|
||||
};
|
||||
return fmt
|
||||
}
|
||||
|
||||
/* 千分符 */
|
||||
/**
|
||||
@@ -354,63 +328,51 @@ tool.dateFormat = function (date, fmt = "yyyy-MM-dd hh:mm:ss") {
|
||||
* @returns {string} - 格式化后的字符串
|
||||
*/
|
||||
tool.groupSeparator = function (num, decimals = 0) {
|
||||
if (num === null || num === undefined || num === "") return "";
|
||||
const numStr = Number(num).toFixed(decimals);
|
||||
const parts = numStr.split(".");
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
return parts.join(".");
|
||||
};
|
||||
if (num === null || num === undefined || num === '') return ''
|
||||
const numStr = Number(num).toFixed(decimals)
|
||||
const parts = numStr.split('.')
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return parts.join('.')
|
||||
}
|
||||
|
||||
/* 常用加解密 */
|
||||
tool.crypto = {
|
||||
//MD5加密
|
||||
MD5(data) {
|
||||
return CryptoJS.MD5(data).toString();
|
||||
return CryptoJS.MD5(data).toString()
|
||||
},
|
||||
//BASE64加解密
|
||||
BASE64: {
|
||||
encrypt(data) {
|
||||
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data));
|
||||
return CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(data))
|
||||
},
|
||||
decrypt(cipher) {
|
||||
return CryptoJS.enc.Base64.parse(cipher).toString(
|
||||
CryptoJS.enc.Utf8,
|
||||
);
|
||||
return CryptoJS.enc.Base64.parse(cipher).toString(CryptoJS.enc.Utf8)
|
||||
},
|
||||
},
|
||||
//AES加解密
|
||||
AES: {
|
||||
encrypt(data, secretKey, config = {}) {
|
||||
if (secretKey.length % 8 != 0) {
|
||||
console.warn(
|
||||
"[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。",
|
||||
);
|
||||
console.warn('[SCUI error]: 秘钥长度需为8的倍数,否则解密将会失败。')
|
||||
}
|
||||
const result = CryptoJS.AES.encrypt(
|
||||
data,
|
||||
CryptoJS.enc.Utf8.parse(secretKey),
|
||||
{
|
||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
|
||||
mode: CryptoJS.mode[config.mode || "ECB"],
|
||||
padding: CryptoJS.pad[config.padding || "Pkcs7"],
|
||||
},
|
||||
);
|
||||
return result.toString();
|
||||
const result = CryptoJS.AES.encrypt(data, CryptoJS.enc.Utf8.parse(secretKey), {
|
||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ''),
|
||||
mode: CryptoJS.mode[config.mode || 'ECB'],
|
||||
padding: CryptoJS.pad[config.padding || 'Pkcs7'],
|
||||
})
|
||||
return result.toString()
|
||||
},
|
||||
decrypt(cipher, secretKey, config = {}) {
|
||||
const result = CryptoJS.AES.decrypt(
|
||||
cipher,
|
||||
CryptoJS.enc.Utf8.parse(secretKey),
|
||||
{
|
||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ""),
|
||||
mode: CryptoJS.mode[config.mode || "ECB"],
|
||||
padding: CryptoJS.pad[config.padding || "Pkcs7"],
|
||||
},
|
||||
);
|
||||
return CryptoJS.enc.Utf8.stringify(result);
|
||||
const result = CryptoJS.AES.decrypt(cipher, CryptoJS.enc.Utf8.parse(secretKey), {
|
||||
iv: CryptoJS.enc.Utf8.parse(config.iv || ''),
|
||||
mode: CryptoJS.mode[config.mode || 'ECB'],
|
||||
padding: CryptoJS.pad[config.padding || 'Pkcs7'],
|
||||
})
|
||||
return CryptoJS.enc.Utf8.stringify(result)
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* 树形数据转扁平数组 */
|
||||
/**
|
||||
@@ -419,22 +381,22 @@ tool.crypto = {
|
||||
* @param {Object} config - 配置项 { children: "children" }
|
||||
* @returns {Array} - 扁平化后的数组
|
||||
*/
|
||||
tool.treeToList = function (tree, config = { children: "children" }) {
|
||||
const result = [];
|
||||
tool.treeToList = function (tree, config = { children: 'children' }) {
|
||||
const result = []
|
||||
tree.forEach((item) => {
|
||||
const tmp = { ...item };
|
||||
const childrenKey = config.children || "children";
|
||||
const tmp = { ...item }
|
||||
const childrenKey = config.children || 'children'
|
||||
|
||||
if (tmp[childrenKey] && tmp[childrenKey].length > 0) {
|
||||
result.push({ ...item });
|
||||
const childrenRoutes = tool.treeToList(tmp[childrenKey], config);
|
||||
result.push(...childrenRoutes);
|
||||
result.push({ ...item })
|
||||
const childrenRoutes = tool.treeToList(tmp[childrenKey], config)
|
||||
result.push(...childrenRoutes)
|
||||
} else {
|
||||
result.push(tmp);
|
||||
result.push(tmp)
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/* 获取父节点数据(保留原有函数名) */
|
||||
/**
|
||||
@@ -444,28 +406,24 @@ tool.treeToList = function (tree, config = { children: "children" }) {
|
||||
* @param {Object} config - 配置项 { pid: "parent_id", idField: "id", field: [] }
|
||||
* @returns {*} - 父节点数据或指定字段
|
||||
*/
|
||||
tool.get_parents = function (
|
||||
list,
|
||||
targetId = 0,
|
||||
config = { pid: "parent_id", idField: "id", field: [] },
|
||||
) {
|
||||
let res = null;
|
||||
tool.get_parents = function (list, targetId = 0, config = { pid: 'parent_id', idField: 'id', field: [] }) {
|
||||
let res = null
|
||||
list.forEach((item) => {
|
||||
if (item[config.idField || "id"] === targetId) {
|
||||
if (item[config.idField || 'id'] === targetId) {
|
||||
if (config.field && config.field.length > 1) {
|
||||
res = {};
|
||||
res = {}
|
||||
config.field.forEach((field) => {
|
||||
res[field] = item[field];
|
||||
});
|
||||
res[field] = item[field]
|
||||
})
|
||||
} else if (config.field && config.field.length === 1) {
|
||||
res = item[config.field[0]];
|
||||
res = item[config.field[0]]
|
||||
} else {
|
||||
res = item;
|
||||
res = item
|
||||
}
|
||||
}
|
||||
});
|
||||
return res;
|
||||
};
|
||||
})
|
||||
return res
|
||||
}
|
||||
|
||||
/* 获取数据字段 */
|
||||
/**
|
||||
@@ -475,25 +433,25 @@ tool.get_parents = function (
|
||||
* @returns {*} - 提取的字段数据
|
||||
*/
|
||||
tool.getDataField = function (data, fields = []) {
|
||||
if (!data || typeof data !== "object") {
|
||||
return data;
|
||||
if (!data || typeof data !== 'object') {
|
||||
return data
|
||||
}
|
||||
if (fields.length === 0) {
|
||||
return data;
|
||||
return data
|
||||
}
|
||||
if (fields.length === 1) {
|
||||
return data[fields[0]];
|
||||
return data[fields[0]]
|
||||
} else {
|
||||
const result = {};
|
||||
const result = {}
|
||||
fields.forEach((field) => {
|
||||
result[field] = data[field];
|
||||
});
|
||||
return result;
|
||||
result[field] = data[field]
|
||||
})
|
||||
return result
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 兼容旧函数名
|
||||
tool.tree_to_list = tool.treeToList;
|
||||
tool.get_data_field = tool.getDataField;
|
||||
tool.tree_to_list = tool.treeToList
|
||||
tool.get_data_field = tool.getDataField
|
||||
|
||||
export default tool;
|
||||
export default tool
|
||||
|
||||
Reference in New Issue
Block a user