Compare commits

...

3 Commits

Author SHA1 Message Date
molong 1d8144bf87 ts转js 2026-01-10 21:41:23 +08:00
molong 2f5ee49594 更新 2026-01-10 10:13:25 +08:00
molong a0afedf5f3 格式化文档 2026-01-10 10:10:57 +08:00
231 changed files with 27362 additions and 27177 deletions
+1 -2
View File
@@ -1,3 +1,2 @@
/node_modules/* /node_modules/*
/dist/* /dist/*
/src/main.ts
+18 -18
View File
@@ -1,20 +1,20 @@
{ {
"printWidth": 100, "printWidth": 100,
"tabWidth": 2, "tabWidth": 4,
"useTabs": false, "useTabs": true,
"semi": false, "semi": false,
"vueIndentScriptAndStyle": true, "vueIndentScriptAndStyle": true,
"singleQuote": true, "singleQuote": true,
"quoteProps": "as-needed", "quoteProps": "as-needed",
"bracketSpacing": true, "bracketSpacing": true,
"trailingComma": "none", "trailingComma": "none",
"bracketSameLine": false, "bracketSameLine": false,
"jsxSingleQuote": false, "jsxSingleQuote": false,
"arrowParens": "always", "arrowParens": "always",
"insertPragma": false, "insertPragma": false,
"requirePragma": false, "requirePragma": false,
"proseWrap": "never", "proseWrap": "never",
"htmlWhitespaceSensitivity": "strict", "htmlWhitespaceSensitivity": "strict",
"endOfLine": "auto", "endOfLine": "auto",
"rangeStart": 0 "rangeStart": 0
} }
+80 -80
View File
@@ -1,82 +1,82 @@
module.exports = { module.exports = {
// 继承推荐规范配置 // 继承推荐规范配置
extends: [ extends: [
'stylelint-config-standard', 'stylelint-config-standard',
'stylelint-config-recommended-scss', 'stylelint-config-recommended-scss',
'stylelint-config-recommended-vue/scss', 'stylelint-config-recommended-vue/scss',
'stylelint-config-html/vue', 'stylelint-config-html/vue',
'stylelint-config-recess-order' 'stylelint-config-recess-order'
], ],
// 指定不同文件对应的解析器 // 指定不同文件对应的解析器
overrides: [ overrides: [
{ {
files: ['**/*.{vue,html}'], files: ['**/*.{vue,html}'],
customSyntax: 'postcss-html' customSyntax: 'postcss-html'
}, },
{ {
files: ['**/*.{css,scss}'], files: ['**/*.{css,scss}'],
customSyntax: 'postcss-scss' customSyntax: 'postcss-scss'
} }
], ],
// 自定义规则 // 自定义规则
rules: { rules: {
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url") 'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
'selector-class-pattern': null, // 选择器类名命名规则 'selector-class-pattern': null, // 选择器类名命名规则
'custom-property-pattern': null, // 自定义属性命名规则 'custom-property-pattern': null, // 自定义属性命名规则
'keyframes-name-pattern': null, // 动画帧节点样式命名规则 'keyframes-name-pattern': null, // 动画帧节点样式命名规则
'no-descending-specificity': null, // 允许无降序特异性 'no-descending-specificity': null, // 允许无降序特异性
'no-empty-source': null, // 允许空样式 'no-empty-source': null, // 允许空样式
'property-no-vendor-prefix': null, // 允许属性前缀 'property-no-vendor-prefix': null, // 允许属性前缀
// 允许 global 、export 、deep伪类 // 允许 global 、export 、deep伪类
'selector-pseudo-class-no-unknown': [ 'selector-pseudo-class-no-unknown': [
true, true,
{ {
ignorePseudoClasses: ['global', 'export', 'deep'] ignorePseudoClasses: ['global', 'export', 'deep']
} }
], ],
// 允许未知属性 // 允许未知属性
'property-no-unknown': [ 'property-no-unknown': [
true, true,
{ {
ignoreProperties: [] ignoreProperties: []
} }
], ],
// 允许未知规则 // 允许未知规则
'at-rule-no-unknown': [ 'at-rule-no-unknown': [
true, true,
{ {
ignoreAtRules: [ ignoreAtRules: [
'apply', 'apply',
'use', 'use',
'mixin', 'mixin',
'include', 'include',
'extend', 'extend',
'each', 'each',
'if', 'if',
'else', 'else',
'for', 'for',
'while', 'while',
'reference' 'reference'
] ]
} }
], ],
'scss/at-rule-no-unknown': [ 'scss/at-rule-no-unknown': [
true, true,
{ {
ignoreAtRules: [ ignoreAtRules: [
'apply', 'apply',
'use', 'use',
'mixin', 'mixin',
'include', 'include',
'extend', 'extend',
'each', 'each',
'if', 'if',
'else', 'else',
'for', 'for',
'while', 'while',
'reference' 'reference'
] ]
} }
] ]
} }
} }
+39 -39
View File
@@ -1,47 +1,47 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<title>Art Design Pro</title> <title>Art Design Pro</title>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta <meta
name="description" name="description"
content="Art Design Pro - A modern admin dashboard template built with Vue 3, TypeScript, and Element Plus." content="Art Design Pro - A modern admin dashboard template built with Vue 3, TypeScript, and Element Plus."
/> />
<link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" /> <link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" />
<style> <style>
/* 防止页面刷新时白屏的初始样式 */ /* 防止页面刷新时白屏的初始样式 */
html { html {
background-color: #fafbfc; background-color: #fafbfc;
} }
html.dark { html.dark {
background-color: #070707; background-color: #070707;
} }
</style> </style>
<script> <script>
// 初始化 html class 主题属性 // 初始化 html class 主题属性
;(function () { ;(function () {
try { try {
if (typeof Storage === 'undefined' || !window.localStorage) { if (typeof Storage === 'undefined' || !window.localStorage) {
return return
} }
const themeType = localStorage.getItem('sys-theme') const themeType = localStorage.getItem('sys-theme')
if (themeType === 'dark') { if (themeType === 'dark') {
document.documentElement.classList.add('dark') document.documentElement.classList.add('dark')
} }
} catch (e) { } catch (e) {
console.warn('Failed to apply initial theme:', e) console.warn('Failed to apply initial theme:', e)
} }
})() })()
</script> </script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.js"></script>
</body> </body>
</html> </html>
+106 -116
View File
@@ -1,118 +1,108 @@
{ {
"name": "art-design-pro", "name": "art-design-pro",
"version": "0.0.0", "version": "0.1.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20.19.0", "node": ">=20.19.0"
"pnpm": ">=8.8.0" },
}, "scripts": {
"scripts": { "dev": "vite --open",
"dev": "vite --open", "build": "vue-tsc --noEmit && vite build",
"build": "vue-tsc --noEmit && vite build", "serve": "vite preview",
"serve": "vite preview", "lint": "eslint",
"lint": "eslint", "fix": "eslint --fix",
"fix": "eslint --fix", "lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"", "lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix", },
"lint:lint-staged": "lint-staged", "lint-staged": {
"prepare": "husky", "*.{js,ts,mjs,mts,tsx}": [
"commit": "git-cz", "eslint --fix",
"clean:dev": "tsx scripts/clean-dev.ts" "prettier --write"
}, ],
"config": { "*.{cjs,json,jsonc}": [
"commitizen": { "prettier --write"
"path": "node_modules/cz-git" ],
} "*.vue": [
}, "eslint --fix",
"lint-staged": { "stylelint --fix --allow-empty-input",
"*.{js,ts,mjs,mts,tsx}": [ "prettier --write"
"eslint --fix", ],
"prettier --write" "*.{html,htm}": [
], "prettier --write"
"*.{cjs,json,jsonc}": [ ],
"prettier --write" "*.{scss,css,less}": [
], "stylelint --fix --allow-empty-input",
"*.vue": [ "prettier --write"
"eslint --fix", ],
"stylelint --fix --allow-empty-input", "*.{md,mdx}": [
"prettier --write" "prettier --write"
], ],
"*.{html,htm}": [ "*.{yaml,yml}": [
"prettier --write" "prettier --write"
], ]
"*.{scss,css,less}": [ },
"stylelint --fix --allow-empty-input", "dependencies": {
"prettier --write" "@element-plus/icons-vue": "^2.3.2",
], "@iconify/vue": "^5.0.0",
"*.{md,mdx}": [ "@tailwindcss/vite": "^4.1.14",
"prettier --write" "@vue/reactivity": "^3.5.21",
], "@vueuse/core": "^13.9.0",
"*.{yaml,yml}": [ "@wangeditor/editor": "^5.1.23",
"prettier --write" "@wangeditor/editor-for-vue": "next",
] "axios": "^1.12.2",
}, "crypto-js": "^4.2.0",
"dependencies": { "echarts": "^6.0.0",
"@element-plus/icons-vue": "^2.3.2", "element-plus": "^2.11.2",
"@iconify/vue": "^5.0.0", "file-saver": "^2.0.5",
"@tailwindcss/vite": "^4.1.14", "highlight.js": "^11.10.0",
"@vue/reactivity": "^3.5.21", "mitt": "^3.0.1",
"@vueuse/core": "^13.9.0", "nprogress": "^0.2.0",
"@wangeditor/editor": "^5.1.23", "ohash": "^2.0.11",
"@wangeditor/editor-for-vue": "next", "pinia": "^3.0.3",
"axios": "^1.12.2", "pinia-plugin-persistedstate": "^4.3.0",
"crypto-js": "^4.2.0", "qrcode.vue": "^3.6.0",
"echarts": "^6.0.0", "tailwindcss": "^4.1.14",
"element-plus": "^2.11.2", "vue": "^3.5.21",
"file-saver": "^2.0.5", "vue-draggable-plus": "^0.6.0",
"highlight.js": "^11.10.0", "vue-i18n": "^9.14.0",
"mitt": "^3.0.1", "vue-router": "^4.5.1",
"nprogress": "^0.2.0", "xgplayer": "^3.0.20",
"ohash": "^2.0.11", "xlsx": "^0.18.5"
"pinia": "^3.0.3", },
"pinia-plugin-persistedstate": "^4.3.0", "devDependencies": {
"qrcode.vue": "^3.6.0", "@eslint/js": "^9.9.1",
"tailwindcss": "^4.1.14", "@types/node": "^24.0.5",
"vue": "^3.5.21", "@typescript-eslint/eslint-plugin": "^8.3.0",
"vue-draggable-plus": "^0.6.0", "@typescript-eslint/parser": "^8.3.0",
"vue-i18n": "^9.14.0", "@vitejs/plugin-vue": "^6.0.1",
"vue-router": "^4.5.1", "@vue/compiler-sfc": "^3.0.5",
"xgplayer": "^3.0.20", "eslint": "^9.9.1",
"xlsx": "^0.18.5" "eslint-config-prettier": "^9.1.0",
}, "eslint-plugin-prettier": "^5.2.1",
"devDependencies": { "eslint-plugin-vue": "^9.27.0",
"@eslint/js": "^9.9.1", "globals": "^15.9.0",
"@types/node": "^24.0.5", "lint-staged": "^15.5.2",
"@typescript-eslint/eslint-plugin": "^8.3.0", "prettier": "^3.5.3",
"@typescript-eslint/parser": "^8.3.0", "rollup-plugin-visualizer": "^5.12.0",
"@vitejs/plugin-vue": "^6.0.1", "sass": "^1.81.0",
"@vue/compiler-sfc": "^3.0.5", "stylelint": "^16.20.0",
"eslint": "^9.9.1", "stylelint-config-html": "^1.1.0",
"eslint-config-prettier": "^9.1.0", "stylelint-config-recess-order": "^4.6.0",
"eslint-plugin-prettier": "^5.2.1", "stylelint-config-recommended-scss": "^14.1.0",
"eslint-plugin-vue": "^9.27.0", "stylelint-config-recommended-vue": "^1.5.0",
"globals": "^15.9.0", "stylelint-config-standard": "^36.0.1",
"lint-staged": "^15.5.2", "terser": "^5.36.0",
"prettier": "^3.5.3", "tsx": "^4.20.3",
"rollup-plugin-visualizer": "^5.12.0", "typescript": "~5.6.3",
"sass": "^1.81.0", "typescript-eslint": "^8.9.0",
"stylelint": "^16.20.0", "unplugin-auto-import": "^20.2.0",
"stylelint-config-html": "^1.1.0", "unplugin-element-plus": "^0.10.0",
"stylelint-config-recess-order": "^4.6.0", "unplugin-vue-components": "^29.1.0",
"stylelint-config-recommended-scss": "^14.1.0", "vite": "^7.1.5",
"stylelint-config-recommended-vue": "^1.5.0", "vite-plugin-compression": "^0.5.1",
"stylelint-config-standard": "^36.0.1", "vite-plugin-vue-devtools": "^7.7.6",
"terser": "^5.36.0", "vue-demi": "^0.14.9",
"tsx": "^4.20.3", "vue-img-cutter": "^3.0.5",
"typescript": "~5.6.3", "vue-tsc": "~2.1.6"
"typescript-eslint": "^8.9.0", }
"unplugin-auto-import": "^20.2.0",
"unplugin-element-plus": "^0.10.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.6",
"vue-demi": "^0.14.9",
"vue-img-cutter": "^3.0.5",
"vue-tsc": "~2.1.6"
}
} }
+26 -26
View File
@@ -1,34 +1,34 @@
<template> <template>
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000"> <ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
<RouterView></RouterView> <RouterView></RouterView>
</ElConfigProvider> </ElConfigProvider>
</template> </template>
<script setup lang="ts"> <script setup>
import { useUserStore } from './store/modules/user' import { useUserStore } from './store/modules/user'
import zh from 'element-plus/es/locale/lang/zh-cn' import zh from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en' import en from 'element-plus/es/locale/lang/en'
import { systemUpgrade } from './utils/sys' import { systemUpgrade } from './utils/sys'
import { toggleTransition } from './utils/ui/animation' import { toggleTransition } from './utils/ui/animation'
import { checkStorageCompatibility } from './utils/storage' import { checkStorageCompatibility } from './utils/storage'
import { initializeTheme } from './hooks/core/useTheme' import { initializeTheme } from './hooks/core/useTheme'
const userStore = useUserStore() const userStore = useUserStore()
const { language } = storeToRefs(userStore) const { language } = storeToRefs(userStore)
const locales = { const locales = {
zh: zh, zh: zh,
en: en en: en
} }
onBeforeMount(() => { onBeforeMount(() => {
toggleTransition(true) toggleTransition(true)
initializeTheme() initializeTheme()
}) })
onMounted(() => { onMounted(() => {
checkStorageCompatibility() checkStorageCompatibility()
toggleTransition(false) toggleTransition(false)
systemUpgrade() systemUpgrade()
}) })
</script> </script>
+29
View File
@@ -0,0 +1,29 @@
import request from '@/utils/http'
/**
* 登录
* @param params 登录参数
* @returns 登录响应
*/
export function fetchLogin(params) {
return request.post({
url: '/api/auth/login',
params
// showSuccessMessage: true // 显示成功消息
// showErrorMessage: false // 不显示错误消息
})
}
/**
* 获取用户信息
* @returns 用户信息
*/
export function fetchGetUserInfo() {
return request.get({
url: '/api/user/info'
// 自定义请求头
// headers: {
// 'X-Custom-Header': 'your-custom-value'
// }
})
}
-29
View File
@@ -1,29 +0,0 @@
import request from '@/utils/http'
/**
* 登录
* @param params 登录参数
* @returns 登录响应
*/
export function fetchLogin(params: Api.Auth.LoginParams) {
return request.post<Api.Auth.LoginResponse>({
url: '/api/auth/login',
params
// showSuccessMessage: true // 显示成功消息
// showErrorMessage: false // 不显示错误消息
})
}
/**
* 获取用户信息
* @returns 用户信息
*/
export function fetchGetUserInfo() {
return request.get<Api.Auth.UserInfo>({
url: '/api/user/info'
// 自定义请求头
// headers: {
// 'X-Custom-Header': 'your-custom-value'
// }
})
}
+24
View File
@@ -0,0 +1,24 @@
import request from '@/utils/http'
// 获取用户列表
export function fetchGetUserList(params) {
return request.get({
url: '/api/user/list',
params
})
}
// 获取角色列表
export function fetchGetRoleList(params) {
return request.get({
url: '/api/role/list',
params
})
}
// 获取菜单列表
export function fetchGetMenuList() {
return request.get({
url: '/api/v3/system/menus/simple'
})
}
-25
View File
@@ -1,25 +0,0 @@
import request from '@/utils/http'
import { AppRouteRecord } from '@/types/router'
// 获取用户列表
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
return request.get<Api.SystemManage.UserList>({
url: '/api/user/list',
params
})
}
// 获取角色列表
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
return request.get<Api.SystemManage.RoleList>({
url: '/api/role/list',
params
})
}
// 获取菜单列表
export function fetchGetMenuList() {
return request.get<AppRouteRecord[]>({
url: '/api/v3/system/menus/simple'
})
}
+188 -188
View File
@@ -1,292 +1,292 @@
// 全局样式 // 全局样式
// 顶部进度条颜色 // 顶部进度条颜色
#nprogress .bar { #nprogress .bar {
z-index: 2400; z-index: 2400;
background-color: color-mix(in srgb, var(--theme-color) 70%, white); background-color: color-mix(in srgb, var(--theme-color) 70%, white);
} }
#nprogress .peg { #nprogress .peg {
box-shadow: box-shadow:
0 0 10px var(--theme-color), 0 0 10px var(--theme-color),
0 0 5px var(--theme-color) !important; 0 0 5px var(--theme-color) !important;
} }
#nprogress .spinner-icon { #nprogress .spinner-icon {
border-top-color: var(--theme-color) !important; border-top-color: var(--theme-color) !important;
border-left-color: var(--theme-color) !important; border-left-color: var(--theme-color) !important;
} }
// 处理移动端组件兼容性 // 处理移动端组件兼容性
@media screen and (max-width: 640px) { @media screen and (max-width: 640px) {
* { * {
cursor: default !important; cursor: default !important;
} }
} }
// 背景滤镜 // 背景滤镜
*, *,
::before, ::before,
::after { ::after {
--tw-backdrop-blur: ; --tw-backdrop-blur: ;
--tw-backdrop-brightness: ; --tw-backdrop-brightness: ;
--tw-backdrop-contrast: ; --tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ; --tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ; --tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ; --tw-backdrop-invert: ;
--tw-backdrop-opacity: ; --tw-backdrop-opacity: ;
--tw-backdrop-saturate: ; --tw-backdrop-saturate: ;
--tw-backdrop-sepia: ; --tw-backdrop-sepia: ;
} }
// 色弱模式 // 色弱模式
.color-weak { .color-weak {
filter: invert(80%); filter: invert(80%);
-webkit-filter: invert(80%); -webkit-filter: invert(80%);
} }
#noop { #noop {
display: none; display: none;
} }
// 语言切换选中样式 // 语言切换选中样式
.langDropDownStyle { .langDropDownStyle {
// 选中项背景颜色 // 选中项背景颜色
.is-selected { .is-selected {
background-color: var(--art-el-active-color) !important; background-color: var(--art-el-active-color) !important;
} }
// 语言切换按钮菜单样式优化 // 语言切换按钮菜单样式优化
.lang-btn-item { .lang-btn-item {
.el-dropdown-menu__item { .el-dropdown-menu__item {
padding-left: 13px !important; padding-left: 13px !important;
padding-right: 6px !important; padding-right: 6px !important;
margin-bottom: 3px !important; margin-bottom: 3px !important;
} }
&:last-child { &:last-child {
.el-dropdown-menu__item { .el-dropdown-menu__item {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
} }
.menu-txt { .menu-txt {
min-width: 60px; min-width: 60px;
display: block; display: block;
} }
i { i {
font-size: 10px; font-size: 10px;
margin-left: 10px; margin-left: 10px;
} }
} }
} }
// 盒子默认边框 // 盒子默认边框
.page-content { .page-content {
border: 1px solid var(--art-card-border) !important; border: 1px solid var(--art-card-border) !important;
} }
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) { @mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
background: var(--default-box-color); background: var(--default-box-color);
border: 1px solid #{$border-color} !important; border: 1px solid #{$border-color} !important;
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important; border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
box-shadow: #{$shadow} !important; box-shadow: #{$shadow} !important;
--el-card-border-color: var(--default-border) !important; --el-card-border-color: var(--default-border) !important;
} }
.art-card, .art-card,
.art-card-sm, .art-card-sm,
.art-card-xs { .art-card-xs {
border: 1px solid var(--art-card-border); border: 1px solid var(--art-card-border);
} }
// 盒子边框 // 盒子边框
[data-box-mode='border-mode'] { [data-box-mode='border-mode'] {
.page-content, .page-content,
.art-table-card { .art-table-card {
border: 1px solid var(--art-card-border) !important; border: 1px solid var(--art-card-border) !important;
} }
.art-card { .art-card {
@include art-card-base(var(--art-card-border), none, 4px); @include art-card-base(var(--art-card-border), none, 4px);
} }
.art-card-sm { .art-card-sm {
@include art-card-base(var(--art-card-border), none, 0px); @include art-card-base(var(--art-card-border), none, 0px);
} }
.art-card-xs { .art-card-xs {
@include art-card-base(var(--art-card-border), none, -4px); @include art-card-base(var(--art-card-border), none, -4px);
} }
} }
// 盒子阴影 // 盒子阴影
[data-box-mode='shadow-mode'] { [data-box-mode='shadow-mode'] {
.page-content, .page-content,
.art-table-card { .art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important; box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid var(--art-gray-200) !important; border: 1px solid var(--art-gray-200) !important;
} }
.layout-sidebar { .layout-sidebar {
border-right: 1px solid var(--art-card-border) !important; border-right: 1px solid var(--art-card-border) !important;
} }
.art-card { .art-card {
@include art-card-base( @include art-card-base(
var(--art-gray-200), var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
4px 4px
); );
} }
.art-card-sm { .art-card-sm {
@include art-card-base( @include art-card-base(
var(--art-gray-200), var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)), (0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
2px 2px
); );
} }
.art-card-xs { .art-card-xs {
@include art-card-base( @include art-card-base(
var(--art-gray-200), var(--art-gray-200),
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)), (0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
-4px -4px
); );
} }
} }
// 元素全屏 // 元素全屏
.el-full-screen { .el-full-screen {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
width: 100vw !important; width: 100vw !important;
height: 100% !important; height: 100% !important;
z-index: 2300; z-index: 2300;
margin-top: 0; margin-top: 0;
padding: 15px; padding: 15px;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--default-box-color); background-color: var(--default-box-color);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
// 表格卡片 // 表格卡片
.art-table-card { .art-table-card {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-top: 12px; margin-top: 12px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important; border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body { .el-card__body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
} }
} }
// 容器全高 // 容器全高
.art-full-height { .art-full-height {
height: var(--art-full-height); height: var(--art-full-height);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@media (max-width: 640px) { @media (max-width: 640px) {
height: auto; height: auto;
} }
} }
// 徽章样式 // 徽章样式
.art-badge { .art-badge {
position: absolute; position: absolute;
top: 0; top: 0;
right: 20px; right: 20px;
bottom: 0; bottom: 0;
width: 6px; width: 6px;
height: 6px; height: 6px;
margin: auto; margin: auto;
background: #ff3860; background: #ff3860;
border-radius: 50%; border-radius: 50%;
animation: breathe 1.5s ease-in-out infinite; animation: breathe 1.5s ease-in-out infinite;
&.art-badge-horizontal { &.art-badge-horizontal {
right: 0; right: 0;
} }
&.art-badge-mixed { &.art-badge-mixed {
right: 0; right: 0;
} }
&.art-badge-dual { &.art-badge-dual {
right: 5px; right: 5px;
top: 5px; top: 5px;
bottom: auto; bottom: auto;
} }
} }
// 文字徽章样式 // 文字徽章样式
.art-text-badge { .art-text-badge {
position: absolute; position: absolute;
top: 0; top: 0;
right: 12px; right: 12px;
bottom: 0; bottom: 0;
min-width: 20px; min-width: 20px;
height: 18px; height: 18px;
line-height: 17px; line-height: 17px;
padding: 0 5px; padding: 0 5px;
margin: auto; margin: auto;
font-size: 10px; font-size: 10px;
color: #fff; color: #fff;
text-align: center; text-align: center;
background: #fd4e4e; background: #fd4e4e;
border-radius: 4px; border-radius: 4px;
} }
@keyframes breathe { @keyframes breathe {
0% { 0% {
opacity: 0.7; opacity: 0.7;
transform: scale(1); transform: scale(1);
} }
50% { 50% {
opacity: 1; opacity: 1;
transform: scale(1.1); transform: scale(1.1);
} }
100% { 100% {
opacity: 0.7; opacity: 0.7;
transform: scale(1); transform: scale(1);
} }
} }
// 修复老机型 loading 定位问题 // 修复老机型 loading 定位问题
.art-loading-fix { .art-loading-fix {
position: fixed !important; position: fixed !important;
top: 0 !important; top: 0 !important;
left: 0 !important; left: 0 !important;
right: 0 !important; right: 0 !important;
bottom: 0 !important; bottom: 0 !important;
width: 100vw !important; width: 100vw !important;
height: 100vh !important; height: 100vh !important;
display: flex !important; display: flex !important;
align-items: center !important; align-items: center !important;
justify-content: center !important; justify-content: center !important;
} }
.art-loading-fix .el-loading-spinner { .art-loading-fix .el-loading-spinner {
position: static !important; position: static !important;
top: auto !important; top: auto !important;
left: auto !important; left: auto !important;
transform: none !important; transform: none !important;
} }
// 去除移动端点击背景色 // 去除移动端点击背景色
@media screen and (max-width: 1180px) { @media screen and (max-width: 1180px) {
* { * {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
} }
+69 -69
View File
@@ -7,87 +7,87 @@ $font-color: rgba(#ffffff, 0.85);
/* 覆盖element-plus默认深色背景色 */ /* 覆盖element-plus默认深色背景色 */
html.dark { html.dark {
// element-plus // element-plus
--el-bg-color: var(--default-box-color); --el-bg-color: var(--default-box-color);
--el-text-color-regular: #{$font-color}; --el-text-color-regular: #{$font-color};
// 富文本编辑器 // 富文本编辑器
// 工具栏背景颜色 // 工具栏背景颜色
--w-e-toolbar-bg-color: #18191c; --w-e-toolbar-bg-color: #18191c;
// 输入区域背景颜色 // 输入区域背景颜色
--w-e-textarea-bg-color: #090909; --w-e-textarea-bg-color: #090909;
// 工具栏文字颜色 // 工具栏文字颜色
--w-e-toolbar-color: var(--art-gray-600); --w-e-toolbar-color: var(--art-gray-600);
// 选中菜单颜色 // 选中菜单颜色
--w-e-toolbar-active-bg-color: #25262b; --w-e-toolbar-active-bg-color: #25262b;
// 弹窗边框颜色 // 弹窗边框颜色
--w-e-toolbar-border-color: var(--default-border-dashed); --w-e-toolbar-border-color: var(--default-border-dashed);
// 分割线颜色 // 分割线颜色
--w-e-textarea-border-color: var(--default-border-dashed); --w-e-textarea-border-color: var(--default-border-dashed);
// 链接输入框边框颜色 // 链接输入框边框颜色
--w-e-modal-button-border-color: var(--default-border-dashed); --w-e-modal-button-border-color: var(--default-border-dashed);
// 表格头颜色 // 表格头颜色
--w-e-textarea-slight-bg-color: #090909; --w-e-textarea-slight-bg-color: #090909;
// 按钮背景颜色 // 按钮背景颜色
--w-e-modal-button-bg-color: #090909; --w-e-modal-button-bg-color: #090909;
// hover toolbar 背景颜色 // hover toolbar 背景颜色
--w-e-toolbar-active-color: var(--art-gray-800); --w-e-toolbar-active-color: var(--art-gray-800);
} }
.dark { .dark {
.page-content .article-list .item .left .outer > div { .page-content .article-list .item .left .outer > div {
border-right-color: var(--dark-border-color) !important; border-right-color: var(--dark-border-color) !important;
} }
// 富文本编辑器 // 富文本编辑器
.editor-wrapper { .editor-wrapper {
*:not(pre code *) { *:not(pre code *) {
color: inherit !important; color: inherit !important;
} }
} }
// 分隔线 // 分隔线
.w-e-bar-divider { .w-e-bar-divider {
background-color: var(--art-gray-300) !important; background-color: var(--art-gray-300) !important;
} }
.w-e-select-list, .w-e-select-list,
.w-e-drop-panel, .w-e-drop-panel,
.w-e-bar-item-group .w-e-bar-item-menus-container, .w-e-bar-item-group .w-e-bar-item-menus-container,
.w-e-text-container [data-slate-editor] pre > code { .w-e-text-container [data-slate-editor] pre > code {
border: 1px solid var(--default-border) !important; border: 1px solid var(--default-border) !important;
} }
// 下拉选择框 // 下拉选择框
.w-e-select-list { .w-e-select-list {
background-color: var(--default-box-color) !important; background-color: var(--default-box-color) !important;
} }
/* 下拉选择框 hover 样式调整 */ /* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover, .w-e-select-list ul li:hover,
/* 工具栏 hover 按钮背景颜色 */ /* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover { .w-e-bar-item button:hover {
background-color: #090909 !important; background-color: #090909 !important;
} }
/* 代码块 */ /* 代码块 */
.w-e-text-container [data-slate-editor] pre > code { .w-e-text-container [data-slate-editor] pre > code {
background-color: #25262b !important; background-color: #25262b !important;
text-shadow: none !important; text-shadow: none !important;
} }
/* 引用 */ /* 引用 */
.w-e-text-container [data-slate-editor] blockquote { .w-e-text-container [data-slate-editor] blockquote {
border-left: 4px solid var(--default-border-dashed) !important; border-left: 4px solid var(--default-border-dashed) !important;
background-color: var(--art-color); background-color: var(--art-color);
} }
.editor-wrapper { .editor-wrapper {
.w-e-text-container [data-slate-editor] .table-container th:last-of-type { .w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--default-border-dashed) !important; border-right: 1px solid var(--default-border-dashed) !important;
} }
.w-e-modal { .w-e-modal {
background-color: var(--art-color); background-color: var(--art-color);
} }
} }
} }
+29 -29
View File
@@ -2,33 +2,33 @@
// 自定义Element 亮色主题 // 自定义Element 亮色主题
@forward 'element-plus/theme-chalk/src/common/var.scss' with ( @forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: ( $colors: (
'white': #ffffff, 'white': #ffffff,
'black': #000000, 'black': #000000,
'success': ( 'success': (
'base': #13deb9 'base': #13deb9
), ),
'warning': ( 'warning': (
'base': #ffae1f 'base': #ffae1f
), ),
'danger': ( 'danger': (
'base': #ff4d4f 'base': #ff4d4f
), ),
'error': ( 'error': (
'base': #fa896b 'base': #fa896b
) )
), ),
$button: ( $button: (
'hover-bg-color': var(--el-color-primary-light-9), 'hover-bg-color': var(--el-color-primary-light-9),
'hover-border-color': var(--el-color-primary), 'hover-border-color': var(--el-color-primary),
'border-color': var(--el-color-primary), 'border-color': var(--el-color-primary),
'text-color': var(--el-color-primary) 'text-color': var(--el-color-primary)
), ),
$messagebox: ( $messagebox: (
'border-radius': '12px' 'border-radius': '12px'
), ),
$popover: ( $popover: (
'padding': '14px', 'padding': '14px',
'border-radius': '10px' 'border-radius': '10px'
) )
); );
+286 -286
View File
@@ -1,519 +1,519 @@
// 优化 Element Plus 组件库默认样式 // 优化 Element Plus 组件库默认样式
:root { :root {
// 系统主色 // 系统主色
--main-color: var(--el-color-primary); --main-color: var(--el-color-primary);
--el-color-white: white !important; --el-color-white: white !important;
--el-color-black: white !important; --el-color-black: white !important;
// 输入框边框颜色 // 输入框边框颜色
// --el-border-color: #E4E4E7 !important; // DCDFE6 // --el-border-color: #E4E4E7 !important; // DCDFE6
// 按钮粗度 // 按钮粗度
--el-font-weight-primary: 400 !important; --el-font-weight-primary: 400 !important;
--el-component-custom-height: 36px !important; --el-component-custom-height: 36px !important;
--el-component-size: var(--el-component-custom-height) !important; --el-component-size: var(--el-component-custom-height) !important;
// 边框、按钮圆角... // 边框、按钮圆角...
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important; --el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important; --el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; --el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important; --el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
.region .el-radio-button__original-radio:checked + .el-radio-button__inner { .region .el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--theme-color); color: var(--theme-color);
} }
} }
// 优化 el-form-item 标签高度 // 优化 el-form-item 标签高度
.el-form-item__label { .el-form-item__label {
height: var(--el-component-custom-height) !important; height: var(--el-component-custom-height) !important;
line-height: var(--el-component-custom-height) !important; line-height: var(--el-component-custom-height) !important;
} }
// 日期选择器 // 日期选择器
.el-date-range-picker { .el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important; --el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
} }
// el-card 背景色跟系统背景色保持一致 // el-card 背景色跟系统背景色保持一致
html.dark .el-card { html.dark .el-card {
--el-card-bg-color: var(--default-box-color) !important; --el-card-bg-color: var(--default-box-color) !important;
} }
// 修改 el-pagination 大小 // 修改 el-pagination 大小
.el-pagination--default { .el-pagination--default {
& { & {
--el-pagination-button-width: 32px !important; --el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important; --el-pagination-button-height: var(--el-pagination-button-width) !important;
} }
@media (max-width: 1180px) { @media (max-width: 1180px) {
& { & {
--el-pagination-button-width: 28px !important; --el-pagination-button-width: 28px !important;
} }
} }
.el-select--default .el-select__wrapper { .el-select--default .el-select__wrapper {
min-height: var(--el-pagination-button-width) !important; min-height: var(--el-pagination-button-width) !important;
} }
.el-pagination__jump .el-input { .el-pagination__jump .el-input {
height: var(--el-pagination-button-width) !important; height: var(--el-pagination-button-width) !important;
} }
} }
.el-pager li { .el-pager li {
padding: 0 10px !important; padding: 0 10px !important;
// border: 1px solid red !important; // border: 1px solid red !important;
} }
// 优化菜单折叠展开动画(提升动画流畅度) // 优化菜单折叠展开动画(提升动画流畅度)
.el-menu.el-menu--inline { .el-menu.el-menu--inline {
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important; transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
} }
// 优化菜单 item hover 动画(提升鼠标跟手感) // 优化菜单 item hover 动画(提升鼠标跟手感)
.el-sub-menu__title, .el-sub-menu__title,
.el-menu-item { .el-menu-item {
transition: background-color 0s !important; transition: background-color 0s !important;
} }
// -------------------------------- 修改 el-size=default 组件默认高度 start -------------------------------- // -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
// 修改 el-button 高度 // 修改 el-button 高度
.el-button--default { .el-button--default {
height: var(--el-component-custom-height) !important; height: var(--el-component-custom-height) !important;
} }
// circle 按钮宽度优化 // circle 按钮宽度优化
.el-button--default.is-circle { .el-button--default.is-circle {
width: var(--el-component-custom-height) !important; width: var(--el-component-custom-height) !important;
} }
// 修改 el-select 高度 // 修改 el-select 高度
.el-select--default { .el-select--default {
.el-select__wrapper { .el-select__wrapper {
min-height: var(--el-component-custom-height) !important; min-height: var(--el-component-custom-height) !important;
} }
} }
// 修改 el-checkbox-button 高度 // 修改 el-checkbox-button 高度
.el-checkbox-button--default .el-checkbox-button__inner, .el-checkbox-button--default .el-checkbox-button__inner,
// 修改 el-radio-button 高度 // 修改 el-radio-button 高度
.el-radio-button--default .el-radio-button__inner { .el-radio-button--default .el-radio-button__inner {
padding: 10px 15px !important; padding: 10px 15px !important;
} }
// -------------------------------- 修改 el-size=default 组件默认高度 end -------------------------------- // -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
.el-pagination.is-background .btn-next, .el-pagination.is-background .btn-next,
.el-pagination.is-background .btn-prev, .el-pagination.is-background .btn-prev,
.el-pagination.is-background .el-pager li { .el-pagination.is-background .el-pager li {
border-radius: 6px; border-radius: 6px;
} }
.el-popover { .el-popover {
min-width: 80px; min-width: 80px;
border-radius: var(--el-border-radius-small) !important; border-radius: var(--el-border-radius-small) !important;
} }
.el-dialog { .el-dialog {
border-radius: 100px !important; border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important; border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
overflow: hidden; overflow: hidden;
} }
.el-dialog__header { .el-dialog__header {
.el-dialog__title { .el-dialog__title {
font-size: 16px; font-size: 16px;
} }
} }
.el-dialog__body { .el-dialog__body {
padding: 25px 0 !important; padding: 25px 0 !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275; position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
} }
.el-dialog.el-dialog-border { .el-dialog.el-dialog-border {
.el-dialog__body { .el-dialog__body {
// 上边框 // 上边框
&::before, &::before,
// 下边框 // 下边框
&::after { &::after {
content: ''; content: '';
position: absolute; position: absolute;
left: -16px; left: -16px;
width: calc(100% + 32px); width: calc(100% + 32px);
height: 1px; height: 1px;
background-color: var(--art-gray-300); background-color: var(--art-gray-300);
} }
&::before { &::before {
top: 0; top: 0;
} }
&::after { &::after {
bottom: 0; bottom: 0;
} }
} }
} }
// el-message 样式优化 // el-message 样式优化
.el-message { .el-message {
background-color: var(--default-box-color) !important; background-color: var(--default-box-color) !important;
border: 0 !important; border: 0 !important;
box-shadow: box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important; 0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
p { p {
font-size: 13px; font-size: 13px;
} }
} }
// 修改 el-dropdown 样式 // 修改 el-dropdown 样式
.el-dropdown-menu { .el-dropdown-menu {
padding: 6px !important; padding: 6px !important;
border-radius: 10px !important; border-radius: 10px !important;
border: none !important; border: none !important;
.el-dropdown-menu__item { .el-dropdown-menu__item {
padding: 6px 16px !important; padding: 6px 16px !important;
border-radius: 6px !important; border-radius: 6px !important;
&:hover:not(.is-disabled) { &:hover:not(.is-disabled) {
color: var(--art-gray-900) !important; color: var(--art-gray-900) !important;
background-color: var(--art-el-active-color) !important; background-color: var(--art-el-active-color) !important;
} }
&:focus:not(.is-disabled) { &:focus:not(.is-disabled) {
color: var(--art-gray-900) !important; color: var(--art-gray-900) !important;
background-color: var(--art-gray-200) !important; background-color: var(--art-gray-200) !important;
} }
} }
} }
// 隐藏 select、dropdown 的三角 // 隐藏 select、dropdown 的三角
.el-select__popper, .el-select__popper,
.el-dropdown__popper { .el-dropdown__popper {
margin-top: -6px !important; margin-top: -6px !important;
.el-popper__arrow { .el-popper__arrow {
display: none; display: none;
} }
} }
.el-dropdown-selfdefine:focus { .el-dropdown-selfdefine:focus {
outline: none !important; outline: none !important;
} }
// 处理移动端组件兼容性 // 处理移动端组件兼容性
@media screen and (max-width: 640px) { @media screen and (max-width: 640px) {
.el-message-box, .el-message-box,
.el-dialog { .el-dialog {
width: calc(100% - 24px) !important; width: calc(100% - 24px) !important;
} }
.el-date-picker.has-sidebar.has-time { .el-date-picker.has-sidebar.has-time {
width: calc(100% - 24px); width: calc(100% - 24px);
left: 12px !important; left: 12px !important;
} }
.el-picker-panel *[slot='sidebar'], .el-picker-panel *[slot='sidebar'],
.el-picker-panel__sidebar { .el-picker-panel__sidebar {
display: none; display: none;
} }
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body, .el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
.el-picker-panel__sidebar + .el-picker-panel__body { .el-picker-panel__sidebar + .el-picker-panel__body {
margin-left: 0; margin-left: 0;
} }
} }
// 修改el-button样式 // 修改el-button样式
.el-button { .el-button {
&.el-button--text { &.el-button--text {
background-color: transparent !important; background-color: transparent !important;
padding: 0 !important; padding: 0 !important;
span { span {
margin-left: 0 !important; margin-left: 0 !important;
} }
} }
} }
// 修改el-tag样式 // 修改el-tag样式
.el-tag { .el-tag {
font-weight: 500; font-weight: 500;
transition: all 0s !important; transition: all 0s !important;
&.el-tag--default { &.el-tag--default {
height: 26px !important; height: 26px !important;
} }
} }
.el-checkbox-group { .el-checkbox-group {
&.el-table-filter__checkbox-group label.el-checkbox { &.el-table-filter__checkbox-group label.el-checkbox {
height: 17px !important; height: 17px !important;
.el-checkbox__label { .el-checkbox__label {
font-weight: 400 !important; font-weight: 400 !important;
} }
} }
} }
.el-radio--default { .el-radio--default {
// 优化单选按钮大小 // 优化单选按钮大小
.el-radio__input { .el-radio__input {
.el-radio__inner { .el-radio__inner {
width: 16px; width: 16px;
height: 16px; height: 16px;
&::after { &::after {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
} }
} }
} }
.el-checkbox { .el-checkbox {
.el-checkbox__inner { .el-checkbox__inner {
border-radius: 2px !important; border-radius: 2px !important;
} }
} }
// 优化复选框样式 // 优化复选框样式
.el-checkbox--default { .el-checkbox--default {
.el-checkbox__inner { .el-checkbox__inner {
width: 16px !important; width: 16px !important;
height: 16px !important; height: 16px !important;
border-radius: 4px !important; border-radius: 4px !important;
&::before { &::before {
content: ''; content: '';
height: 4px !important; height: 4px !important;
top: 5px !important; top: 5px !important;
background-color: #fff !important; background-color: #fff !important;
transform: scale(0.6) !important; transform: scale(0.6) !important;
} }
} }
.is-checked { .is-checked {
.el-checkbox__inner { .el-checkbox__inner {
&::after { &::after {
width: 3px; width: 3px;
height: 8px; height: 8px;
margin: auto; margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color); border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0; border-left: 0;
border-top: 0; border-top: 0;
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important; transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
transform-origin: center; transform-origin: center;
} }
} }
} }
} }
.el-notification .el-notification__icon { .el-notification .el-notification__icon {
font-size: 22px !important; font-size: 22px !important;
} }
// 修改 el-message-box 样式 // 修改 el-message-box 样式
.el-message-box__headerbtn .el-message-box__close, .el-message-box__headerbtn .el-message-box__close,
.el-dialog__headerbtn .el-dialog__close { .el-dialog__headerbtn .el-dialog__close {
top: 7px; top: 7px;
right: 7px; right: 7px;
width: 30px; width: 30px;
height: 30px; height: 30px;
border-radius: 5px; border-radius: 5px;
transition: all 0.3s; transition: all 0.3s;
&:hover { &:hover {
background-color: var(--art-hover-color) !important; background-color: var(--art-hover-color) !important;
color: var(--art-gray-900) !important; color: var(--art-gray-900) !important;
} }
} }
.el-message-box { .el-message-box {
padding: 25px 20px !important; padding: 25px 20px !important;
} }
.el-message-box__title { .el-message-box__title {
font-weight: 500 !important; font-weight: 500 !important;
} }
.el-table__column-filter-trigger i { .el-table__column-filter-trigger i {
color: var(--theme-color) !important; color: var(--theme-color) !important;
margin: -3px 0 0 2px; margin: -3px 0 0 2px;
} }
// 去除 el-dropdown 鼠标放上去出现的边框 // 去除 el-dropdown 鼠标放上去出现的边框
.el-tooltip__trigger:focus-visible { .el-tooltip__trigger:focus-visible {
outline: unset; outline: unset;
} }
// ipad 表单右侧按钮优化 // ipad 表单右侧按钮优化
@media screen and (max-width: 1180px) { @media screen and (max-width: 1180px) {
.el-table-fixed-column--right { .el-table-fixed-column--right {
padding-right: 0 !important; padding-right: 0 !important;
} }
} }
.login-out-dialog { .login-out-dialog {
padding: 30px 20px !important; padding: 30px 20px !important;
border-radius: 10px !important; border-radius: 10px !important;
} }
// 修改 dialog 动画 // 修改 dialog 动画
.dialog-fade-enter-active { .dialog-fade-enter-active {
.el-dialog:not(.is-draggable) { .el-dialog:not(.is-draggable) {
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86); animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
// 修复 el-dialog 动画后宽度不自适应问题 // 修复 el-dialog 动画后宽度不自适应问题
.el-select__selected-item { .el-select__selected-item {
display: inline-block; display: inline-block;
} }
} }
} }
.dialog-fade-leave-active { .dialog-fade-leave-active {
animation: fade-out 0.2s linear; animation: fade-out 0.2s linear;
.el-dialog:not(.is-draggable) { .el-dialog:not(.is-draggable) {
animation: dialog-close 0.5s; animation: dialog-close 0.5s;
} }
} }
@keyframes dialog-open { @keyframes dialog-open {
0% { 0% {
opacity: 0; opacity: 0;
transform: scale(0.2); transform: scale(0.2);
} }
100% { 100% {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
} }
@keyframes dialog-close { @keyframes dialog-close {
0% { 0% {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
100% { 100% {
opacity: 0; opacity: 0;
transform: scale(0.2); transform: scale(0.2);
} }
} }
// 遮罩层动画 // 遮罩层动画
@keyframes fade-out { @keyframes fade-out {
0% { 0% {
opacity: 1; opacity: 1;
} }
100% { 100% {
opacity: 0; opacity: 0;
} }
} }
// 修改 el-select 样式 // 修改 el-select 样式
.el-select__popper:not(.el-tree-select__popper) { .el-select__popper:not(.el-tree-select__popper) {
.el-select-dropdown__list { .el-select-dropdown__list {
padding: 5px !important; padding: 5px !important;
.el-select-dropdown__item { .el-select-dropdown__item {
height: 34px !important; height: 34px !important;
line-height: 34px !important; line-height: 34px !important;
border-radius: 6px !important; border-radius: 6px !important;
&.is-selected { &.is-selected {
color: var(--art-gray-900) !important; color: var(--art-gray-900) !important;
font-weight: 400 !important; font-weight: 400 !important;
background-color: var(--art-el-active-color) !important; background-color: var(--art-el-active-color) !important;
margin-bottom: 4px !important; margin-bottom: 4px !important;
} }
&:hover { &:hover {
background-color: var(--art-hover-color) !important; background-color: var(--art-hover-color) !important;
} }
} }
.el-select-dropdown__item:hover ~ .is-selected, .el-select-dropdown__item:hover ~ .is-selected,
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) { .el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
background-color: transparent !important; background-color: transparent !important;
} }
} }
} }
// 修改 el-tree-select 样式 // 修改 el-tree-select 样式
.el-tree-select__popper { .el-tree-select__popper {
.el-select-dropdown__list { .el-select-dropdown__list {
padding: 5px !important; padding: 5px !important;
.el-tree-node { .el-tree-node {
.el-tree-node__content { .el-tree-node__content {
height: 36px !important; height: 36px !important;
border-radius: 6px !important; border-radius: 6px !important;
&:hover { &:hover {
background-color: var(--art-gray-200) !important; background-color: var(--art-gray-200) !important;
} }
} }
} }
} }
} }
// 实现水波纹在文字下面效果 // 实现水波纹在文字下面效果
.el-button > span { .el-button > span {
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
// 优化颜色选择器圆角 // 优化颜色选择器圆角
.el-color-picker__color { .el-color-picker__color {
border-radius: 2px !important; border-radius: 2px !important;
} }
// 优化日期时间选择器底部圆角 // 优化日期时间选择器底部圆角
.el-picker-panel { .el-picker-panel {
.el-picker-panel__footer { .el-picker-panel__footer {
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base); border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
} }
} }
// 优化树型菜单样式 // 优化树型菜单样式
.el-tree-node__content { .el-tree-node__content {
border-radius: 4px; border-radius: 4px;
margin-bottom: 4px; margin-bottom: 4px;
padding: 1px 0; padding: 1px 0;
&:hover { &:hover {
background-color: var(--art-hover-color) !important; background-color: var(--art-hover-color) !important;
} }
} }
.dark { .dark {
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content { .el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: var(--art-gray-300) !important; background-color: var(--art-gray-300) !important;
} }
} }
// 隐藏折叠菜单弹窗 hover 出现的边框 // 隐藏折叠菜单弹窗 hover 出现的边框
.menu-left-popper:focus-within, .menu-left-popper:focus-within,
.horizontal-menu-popper:focus-within { .horizontal-menu-popper:focus-within {
box-shadow: none !important; box-shadow: none !important;
outline: none !important; outline: none !important;
} }
// 数字输入组件右侧按钮高度跟随自定义组件高度 // 数字输入组件右侧按钮高度跟随自定义组件高度
.el-input-number--default.is-controls-right { .el-input-number--default.is-controls-right {
.el-input-number__decrease, .el-input-number__decrease,
.el-input-number__increase { .el-input-number__increase {
height: calc((var(--el-component-size) / 2)) !important; height: calc((var(--el-component-size) / 2)) !important;
} }
} }
File diff suppressed because it is too large Load Diff
+100 -99
View File
@@ -5,18 +5,18 @@
* @param {Number} 行数 * @param {Number} 行数
*/ */
@mixin ellipsis($rowCount: 1) { @mixin ellipsis($rowCount: 1) {
@if $rowCount <=1 { @if $rowCount <=1 {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} @else { } @else {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: $rowCount; -webkit-line-clamp: $rowCount;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
} }
/** /**
@@ -24,20 +24,20 @@
* @param {String} 类型 * @param {String} 类型
*/ */
@mixin userSelect($value: none) { @mixin userSelect($value: none) {
user-select: $value; user-select: $value;
-moz-user-select: $value; -moz-user-select: $value;
-ms-user-select: $value; -ms-user-select: $value;
-webkit-user-select: $value; -webkit-user-select: $value;
} }
// 绝对定位居中 // 绝对定位居中
@mixin absoluteCenter() { @mixin absoluteCenter() {
position: absolute; position: absolute;
left: 0; left: 0;
right: 0; right: 0;
top: 0; top: 0;
bottom: 0; bottom: 0;
margin: auto; margin: auto;
} }
/** /**
@@ -45,113 +45,114 @@
* *
*/ */
@mixin animation( @mixin animation(
$from: ( $from: (
width: 0px width: 0px
), ),
$to: ( $to: (
width: 100px width: 100px
), ),
$name: mymove, $name: mymove,
$animate: mymove 2s 1 linear infinite $animate: mymove 2s 1 linear infinite
) { ) {
-webkit-animation: $animate; -webkit-animation: $animate;
-o-animation: $animate; -o-animation: $animate;
animation: $animate; animation: $animate;
@keyframes #{$name} { @keyframes #{$name} {
from { from {
@each $key, $value in $from { @each $key, $value in $from {
#{$key}: #{$value}; #{$key}: #{$value};
} }
} }
to { to {
@each $key, $value in $to { @each $key, $value in $to {
#{$key}: #{$value}; #{$key}: #{$value};
} }
} }
} }
@-webkit-keyframes #{$name} { @-webkit-keyframes #{$name} {
from { from {
@each $key, $value in $from { @each $key, $value in $from {
$key: $value; $key: $value;
} }
} }
to { to {
@each $key, $value in $to { @each $key, $value in $to {
$key: $value; $key: $value;
} }
} }
} }
} }
// 圆形盒子 // 圆形盒子
@mixin circle($size: 11px, $bg: #fff) { @mixin circle($size: 11px, $bg: #fff) {
border-radius: 50%; border-radius: 50%;
width: $size; width: $size;
height: $size; height: $size;
line-height: $size; line-height: $size;
text-align: center; text-align: center;
background: $bg; background: $bg;
} }
// placeholder // placeholder
@mixin placeholder($color: #bbb) { @mixin placeholder($color: #bbb) {
// Firefox // Firefox
&::-moz-placeholder { &::-moz-placeholder {
color: $color; color: $color;
opacity: 1; opacity: 1;
} }
// Internet Explorer 10+ // Internet Explorer 10+
&:-ms-input-placeholder { &:-ms-input-placeholder {
color: $color; color: $color;
} }
// Safari and Chrome // Safari and Chrome
&::-webkit-input-placeholder { &::-webkit-input-placeholder {
color: $color; color: $color;
} }
&:placeholder-shown { &:placeholder-shown {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
} }
//背景透明,文字不透明。兼容IE8 //背景透明,文字不透明。兼容IE8
@mixin betterTransparentize($color, $alpha) { @mixin betterTransparentize($color, $alpha) {
$c: rgba($color, $alpha); $c: rgba($color, $alpha);
$ie_c: ie_hex_str($c); $ie_c: ie_hex_str($c);
background: rgba($color, 1); background: rgba($color, 1);
background: $c; background: $c;
background: transparent \9; background: transparent \9;
zoom: 1; zoom: 1;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c}); filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})'; -ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
} }
//添加浏览器前缀 //添加浏览器前缀
@mixin browserPrefix($propertyName, $value) { @mixin browserPrefix($propertyName, $value) {
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' { @each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
#{$prefix}#{$propertyName}: $value; #{$prefix}#{$propertyName}: $value;
} }
} }
// 边框 // 边框
@mixin border($color: red) { @mixin border($color: red) {
border: 1px solid $color; border: 1px solid $color;
} }
// 背景滤镜 // 背景滤镜
@mixin backdropBlur() { @mixin backdropBlur() {
--tw-backdrop-blur: blur(30px); --tw-backdrop-blur: blur(30px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) -webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
var(--tw-backdrop-sepia); var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia); var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
var(--tw-backdrop-sepia);
} }
+16 -16
View File
@@ -3,39 +3,39 @@
/*滚动条*/ /*滚动条*/
/*滚动条整体部分,必须要设置*/ /*滚动条整体部分,必须要设置*/
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 8px !important; width: 8px !important;
height: 0 !important; height: 0 !important;
} }
/*滚动条的轨道*/ /*滚动条的轨道*/
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
} }
/*滚动条的滑块按钮*/ /*滚动条的滑块按钮*/
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
border-radius: 5px; border-radius: 5px;
background-color: #cccccc !important; background-color: #cccccc !important;
transition: all 0.2s; transition: all 0.2s;
-webkit-transition: all 0.2s; -webkit-transition: all 0.2s;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background-color: #b0abab !important; background-color: #b0abab !important;
} }
/*滚动条的上下两端的按钮*/ /*滚动条的上下两端的按钮*/
::-webkit-scrollbar-button { ::-webkit-scrollbar-button {
height: 0px; height: 0px;
width: 0; width: 0;
} }
.dark { .dark {
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background-color: var(--default-bg-color); background-color: var(--default-bg-color);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: var(--art-gray-300) !important; background-color: var(--art-gray-300) !important;
} }
} }
+61 -61
View File
@@ -2,19 +2,19 @@
// === 变量区域 === // === 变量区域 ===
$transition: ( $transition: (
// 动画持续时间 // 动画持续时间
duration: 0.25s, duration: 0.25s,
// 滑动动画的移动距离 // 滑动动画的移动距离
distance: 15px, distance: 15px,
// 默认缓动函数 // 默认缓动函数
easing: cubic-bezier(0.25, 0.1, 0.25, 1), easing: cubic-bezier(0.25, 0.1, 0.25, 1),
// 淡入淡出专用的缓动函数 // 淡入淡出专用的缓动函数
fade-easing: cubic-bezier(0.4, 0, 0.6, 1) fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
); );
// 抽取配置值函数,提高可复用性 // 抽取配置值函数,提高可复用性
@function transition-config($key) { @function transition-config($key) {
@return map.get($transition, $key); @return map.get($transition, $key);
} }
// 变量简写 // 变量简写
@@ -27,78 +27,78 @@ $fade-easing: transition-config('fade-easing');
// 淡入淡出动画 // 淡入淡出动画
.fade { .fade {
&-enter-active, &-enter-active,
&-leave-active { &-leave-active {
transition: opacity $duration $fade-easing; transition: opacity $duration $fade-easing;
will-change: opacity; will-change: opacity;
} }
&-enter-from, &-enter-from,
&-leave-to { &-leave-to {
opacity: 0; opacity: 0;
} }
&-enter-to, &-enter-to,
&-leave-from { &-leave-from {
opacity: 1; opacity: 1;
} }
} }
// 滑动动画通用样式 // 滑动动画通用样式
@mixin slide-transition($direction) { @mixin slide-transition($direction) {
$distance-x: 0; $distance-x: 0;
$distance-y: 0; $distance-y: 0;
@if $direction == 'left' { @if $direction == 'left' {
$distance-x: -$distance; $distance-x: -$distance;
} @else if $direction == 'right' { } @else if $direction == 'right' {
$distance-x: $distance; $distance-x: $distance;
} @else if $direction == 'top' { } @else if $direction == 'top' {
$distance-y: -$distance; $distance-y: -$distance;
} @else if $direction == 'bottom' { } @else if $direction == 'bottom' {
$distance-y: $distance; $distance-y: $distance;
} }
&-enter-active { &-enter-active {
transition: transition:
opacity $duration $easing, opacity $duration $easing,
transform $duration $easing; transform $duration $easing;
will-change: opacity, transform; will-change: opacity, transform;
} }
&-leave-active { &-leave-active {
transition: transition:
opacity calc($duration * 0.7) $easing, opacity calc($duration * 0.7) $easing,
transform calc($duration * 0.7) $easing; transform calc($duration * 0.7) $easing;
will-change: opacity, transform; will-change: opacity, transform;
} }
&-enter-from { &-enter-from {
opacity: 0; opacity: 0;
transform: translate3d($distance-x, $distance-y, 0); transform: translate3d($distance-x, $distance-y, 0);
} }
&-enter-to { &-enter-to {
opacity: 1; opacity: 1;
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
} }
&-leave-to { &-leave-to {
opacity: 0; opacity: 0;
transform: translate3d(-$distance-x, -$distance-y, 0); transform: translate3d(-$distance-x, -$distance-y, 0);
} }
} }
// 滑动动画方向类 // 滑动动画方向类
.slide-left { .slide-left {
@include slide-transition('left'); @include slide-transition('left');
} }
.slide-right { .slide-right {
@include slide-transition('right'); @include slide-transition('right');
} }
.slide-top { .slide-top {
@include slide-transition('top'); @include slide-transition('top');
} }
.slide-bottom { .slide-bottom {
@include slide-transition('bottom'); @include slide-transition('bottom');
} }
+144 -144
View File
@@ -3,206 +3,206 @@
/* ==================== Light Mode Variables ==================== */ /* ==================== Light Mode Variables ==================== */
:root { :root {
/* Base Colors */ /* Base Colors */
--art-color: #ffffff; --art-color: #ffffff;
--theme-color: var(--main-color); --theme-color: var(--main-color);
/* Theme Colors - OKLCH Format */ /* Theme Colors - OKLCH Format */
--art-primary: oklch(0.7 0.23 260); --art-primary: oklch(0.7 0.23 260);
--art-secondary: oklch(0.72 0.19 231.6); --art-secondary: oklch(0.72 0.19 231.6);
--art-error: oklch(0.73 0.15 25.3); --art-error: oklch(0.73 0.15 25.3);
--art-info: oklch(0.58 0.03 254.1); --art-info: oklch(0.58 0.03 254.1);
--art-success: oklch(0.78 0.17 166.1); --art-success: oklch(0.78 0.17 166.1);
--art-warning: oklch(0.78 0.14 75.5); --art-warning: oklch(0.78 0.14 75.5);
--art-danger: oklch(0.68 0.22 25.3); --art-danger: oklch(0.68 0.22 25.3);
/* Gray Scale - Light Mode */ /* Gray Scale - Light Mode */
--art-gray-100: #f9fafb; --art-gray-100: #f9fafb;
--art-gray-200: #f2f4f5; --art-gray-200: #f2f4f5;
--art-gray-300: #e6eaeb; --art-gray-300: #e6eaeb;
--art-gray-400: #dbdfe1; --art-gray-400: #dbdfe1;
--art-gray-500: #949eb7; --art-gray-500: #949eb7;
--art-gray-600: #7987a1; --art-gray-600: #7987a1;
--art-gray-700: #4d5875; --art-gray-700: #4d5875;
--art-gray-800: #383853; --art-gray-800: #383853;
--art-gray-900: #323251; --art-gray-900: #323251;
/* Border Colors */ /* Border Colors */
--art-card-border: rgba(0, 0, 0, 0.08); --art-card-border: rgba(0, 0, 0, 0.08);
--default-border: #e2e8ee; --default-border: #e2e8ee;
--default-border-dashed: #dbdfe9; --default-border-dashed: #dbdfe9;
/* Background Colors */ /* Background Colors */
--default-bg-color: #fafbfc; --default-bg-color: #fafbfc;
--default-box-color: #ffffff; --default-box-color: #ffffff;
/* Hover Color */ /* Hover Color */
--art-hover-color: #edeff0; --art-hover-color: #edeff0;
/* Active Color */ /* Active Color */
--art-active-color: #f2f4f5; --art-active-color: #f2f4f5;
/* Element Component Active Color */ /* Element Component Active Color */
--art-el-active-color: #f2f4f5; --art-el-active-color: #f2f4f5;
} }
/* ==================== Dark Mode Variables ==================== */ /* ==================== Dark Mode Variables ==================== */
.dark { .dark {
/* Base Colors */ /* Base Colors */
--art-color: #000000; --art-color: #000000;
/* Gray Scale - Dark Mode */ /* Gray Scale - Dark Mode */
--art-gray-100: #110f0f; --art-gray-100: #110f0f;
--art-gray-200: #17171c; --art-gray-200: #17171c;
--art-gray-300: #393946; --art-gray-300: #393946;
--art-gray-400: #505062; --art-gray-400: #505062;
--art-gray-500: #73738c; --art-gray-500: #73738c;
--art-gray-600: #8f8fa3; --art-gray-600: #8f8fa3;
--art-gray-700: #ababba; --art-gray-700: #ababba;
--art-gray-800: #c7c7d1; --art-gray-800: #c7c7d1;
--art-gray-900: #e3e3e8; --art-gray-900: #e3e3e8;
/* Border Colors */ /* Border Colors */
--art-card-border: rgba(255, 255, 255, 0.08); --art-card-border: rgba(255, 255, 255, 0.08);
--default-border: rgba(255, 255, 255, 0.1); --default-border: rgba(255, 255, 255, 0.1);
--default-border-dashed: #363843; --default-border-dashed: #363843;
/* Background Colors */ /* Background Colors */
--default-bg-color: #070707; --default-bg-color: #070707;
--default-box-color: #161618; --default-box-color: #161618;
/* Hover Color */ /* Hover Color */
--art-hover-color: #252530; --art-hover-color: #252530;
/* Active Color */ /* Active Color */
--art-active-color: #202226; --art-active-color: #202226;
/* Element Component Active Color */ /* Element Component Active Color */
--art-el-active-color: #2e2e38; --art-el-active-color: #2e2e38;
} }
/* ==================== Tailwind Theme Configuration ==================== */ /* ==================== Tailwind Theme Configuration ==================== */
@theme { @theme {
/* Box Color (Light: white / Dark: black) */ /* Box Color (Light: white / Dark: black) */
--color-box: var(--default-box-color); --color-box: var(--default-box-color);
/* System Theme Color */ /* System Theme Color */
--color-theme: var(--theme-color); --color-theme: var(--theme-color);
/* Hover Color */ /* Hover Color */
--color-hover-color: var(--art-hover-color); --color-hover-color: var(--art-hover-color);
/* Active Color */ /* Active Color */
--color-active-color: var(--art-active-color); --color-active-color: var(--art-active-color);
/* Active Color */ /* Active Color */
--color-el-active-color: var(--art-active-color); --color-el-active-color: var(--art-active-color);
/* ElementPlus Theme Colors */ /* ElementPlus Theme Colors */
--color-primary: var(--art-primary); --color-primary: var(--art-primary);
--color-secondary: var(--art-secondary); --color-secondary: var(--art-secondary);
--color-error: var(--art-error); --color-error: var(--art-error);
--color-info: var(--art-info); --color-info: var(--art-info);
--color-success: var(--art-success); --color-success: var(--art-success);
--color-warning: var(--art-warning); --color-warning: var(--art-warning);
--color-danger: var(--art-danger); --color-danger: var(--art-danger);
/* Gray Scale Colors (Auto-adapts to dark mode) */ /* Gray Scale Colors (Auto-adapts to dark mode) */
--color-g-100: var(--art-gray-100); --color-g-100: var(--art-gray-100);
--color-g-200: var(--art-gray-200); --color-g-200: var(--art-gray-200);
--color-g-300: var(--art-gray-300); --color-g-300: var(--art-gray-300);
--color-g-400: var(--art-gray-400); --color-g-400: var(--art-gray-400);
--color-g-500: var(--art-gray-500); --color-g-500: var(--art-gray-500);
--color-g-600: var(--art-gray-600); --color-g-600: var(--art-gray-600);
--color-g-700: var(--art-gray-700); --color-g-700: var(--art-gray-700);
--color-g-800: var(--art-gray-800); --color-g-800: var(--art-gray-800);
--color-g-900: var(--art-gray-900); --color-g-900: var(--art-gray-900);
} }
/* ==================== Custom Border Radius Utilities ==================== */ /* ==================== Custom Border Radius Utilities ==================== */
@utility rounded-custom-xs { @utility rounded-custom-xs {
border-radius: calc(var(--custom-radius) / 2); border-radius: calc(var(--custom-radius) / 2);
} }
@utility rounded-custom-sm { @utility rounded-custom-sm {
border-radius: calc(var(--custom-radius) / 2 + 2px); border-radius: calc(var(--custom-radius) / 2 + 2px);
} }
/* ==================== Custom Utility Classes ==================== */ /* ==================== Custom Utility Classes ==================== */
@layer utilities { @layer utilities {
/* Flexbox Layout Utilities */ /* Flexbox Layout Utilities */
.flex-c { .flex-c {
@apply flex items-center; @apply flex items-center;
} }
.flex-b { .flex-b {
@apply flex justify-between; @apply flex justify-between;
} }
.flex-cc { .flex-cc {
@apply flex items-center justify-center; @apply flex items-center justify-center;
} }
.flex-cb { .flex-cb {
@apply flex items-center justify-between; @apply flex items-center justify-between;
} }
/* Transition Utilities */ /* Transition Utilities */
.tad-200 { .tad-200 {
@apply transition-all duration-200; @apply transition-all duration-200;
} }
.tad-300 { .tad-300 {
@apply transition-all duration-300; @apply transition-all duration-300;
} }
/* Border Utilities */ /* Border Utilities */
.border-full-d { .border-full-d {
@apply border border-[var(--default-border)]; @apply border border-[var(--default-border)];
} }
.border-b-d { .border-b-d {
@apply border-b border-[var(--default-border)]; @apply border-b border-[var(--default-border)];
} }
.border-t-d { .border-t-d {
@apply border-t border-[var(--default-border)]; @apply border-t border-[var(--default-border)];
} }
.border-l-d { .border-l-d {
@apply border-l border-[var(--default-border)]; @apply border-l border-[var(--default-border)];
} }
.border-r-d { .border-r-d {
@apply border-r border-[var(--default-border)]; @apply border-r border-[var(--default-border)];
} }
/* Cursor Utilities */ /* Cursor Utilities */
.c-p { .c-p {
@apply cursor-pointer; @apply cursor-pointer;
} }
} }
/* ==================== Custom Component Classes ==================== */ /* ==================== Custom Component Classes ==================== */
@layer components { @layer components {
/* Art Card Header Component */ /* Art Card Header Component */
.art-card-header { .art-card-header {
@apply flex justify-between pr-6 pb-1; @apply flex justify-between pr-6 pb-1;
.title { .title {
h4 { h4 {
@apply text-lg font-medium text-g-900; @apply text-lg font-medium text-g-900;
} }
p { p {
@apply mt-1 text-sm text-g-600; @apply mt-1 text-sm text-g-600;
span { span {
@apply ml-2 font-medium; @apply ml-2 font-medium;
} }
} }
} }
} }
} }
+38 -38
View File
@@ -4,60 +4,60 @@ $bg-animation-color-dark: #fff;
$bg-animation-duration: 0.5s; $bg-animation-duration: 0.5s;
html { html {
--bg-animation-color: $bg-animation-color-light; --bg-animation-color: $bg-animation-color-light;
&.dark { &.dark {
--bg-animation-color: $bg-animation-color-dark; --bg-animation-color: $bg-animation-color-dark;
} }
// View transition styles // View transition styles
&::view-transition-old(*) { &::view-transition-old(*) {
animation: none; animation: none;
} }
&::view-transition-new(*) { &::view-transition-new(*) {
animation: clip $bg-animation-duration ease-in both; animation: clip $bg-animation-duration ease-in both;
} }
&::view-transition-old(root) { &::view-transition-old(root) {
z-index: 1; z-index: 1;
} }
&::view-transition-new(root) { &::view-transition-new(root) {
z-index: 9999; z-index: 9999;
} }
&.dark { &.dark {
&::view-transition-old(*) { &::view-transition-old(*) {
animation: clip $bg-animation-duration ease-in reverse both; animation: clip $bg-animation-duration ease-in reverse both;
} }
&::view-transition-new(*) { &::view-transition-new(*) {
animation: none; animation: none;
} }
&::view-transition-old(root) { &::view-transition-old(root) {
z-index: 9999; z-index: 9999;
} }
&::view-transition-new(root) { &::view-transition-new(root) {
z-index: 1; z-index: 1;
} }
} }
} }
// 定义动画 // 定义动画
@keyframes clip { @keyframes clip {
from { from {
clip-path: circle(0% at var(--x) var(--y)); clip-path: circle(0% at var(--x) var(--y));
} }
to { to {
clip-path: circle(var(--r) at var(--x) var(--y)); clip-path: circle(var(--r) at var(--x) var(--y));
} }
} }
// body 相关样式 // body 相关样式
body { body {
background-color: var(--bg-animation-color); background-color: var(--bg-animation-color);
} }
+7 -7
View File
@@ -1,11 +1,11 @@
// 主题切换过渡优化,优化除视觉上的不适感 // 主题切换过渡优化,优化除视觉上的不适感
.theme-change { .theme-change {
* { * {
transition: 0s !important; transition: 0s !important;
} }
.el-switch__core, .el-switch__core,
.el-switch__action { .el-switch__action {
transition: all 0.3s !important; transition: all 0.3s !important;
} }
} }
+20 -20
View File
@@ -1,9 +1,9 @@
.hljs { .hljs {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
padding: 0.5em; padding: 0.5em;
color: #a6accd; color: #a6accd;
} }
.hljs-string, .hljs-string,
@@ -11,18 +11,18 @@
.hljs-selector-class, .hljs-selector-class,
.hljs-template-variable, .hljs-template-variable,
.hljs-deletion { .hljs-deletion {
color: #aed07e !important; color: #aed07e !important;
} }
.hljs-comment, .hljs-comment,
.hljs-quote { .hljs-quote {
color: #6f747d; color: #6f747d;
} }
.hljs-doctag, .hljs-doctag,
.hljs-keyword, .hljs-keyword,
.hljs-formula { .hljs-formula {
color: #c792ea; color: #c792ea;
} }
.hljs-section, .hljs-section,
@@ -30,11 +30,11 @@
.hljs-selector-tag, .hljs-selector-tag,
.hljs-deletion, .hljs-deletion,
.hljs-subst { .hljs-subst {
color: #c86068; color: #c86068;
} }
.hljs-literal { .hljs-literal {
color: #56b6c2; color: #56b6c2;
} }
.hljs-string, .hljs-string,
@@ -42,33 +42,33 @@
.hljs-addition, .hljs-addition,
.hljs-attribute, .hljs-attribute,
.hljs-meta-string { .hljs-meta-string {
color: #abb2bf; color: #abb2bf;
} }
.hljs-attribute { .hljs-attribute {
color: #c792ea; color: #c792ea;
} }
.hljs-function { .hljs-function {
color: #c792ea; color: #c792ea;
} }
.hljs-type { .hljs-type {
color: #f07178; color: #f07178;
} }
.hljs-title { .hljs-title {
color: #82aaff !important; color: #82aaff !important;
} }
.hljs-built_in, .hljs-built_in,
.hljs-class { .hljs-class {
color: #82aaff; color: #82aaff;
} }
// 括号 // 括号
.hljs-params { .hljs-params {
color: #a6accd; color: #a6accd;
} }
.hljs-attr, .hljs-attr,
@@ -78,7 +78,7 @@
.hljs-selector-attr, .hljs-selector-attr,
.hljs-selector-pseudo, .hljs-selector-pseudo,
.hljs-number { .hljs-number {
color: #de7e61; color: #de7e61;
} }
.hljs-symbol, .hljs-symbol,
@@ -86,13 +86,13 @@
.hljs-link, .hljs-link,
.hljs-meta, .hljs-meta,
.hljs-selector-id { .hljs-selector-id {
color: #61aeee; color: #61aeee;
} }
.hljs-strong { .hljs-strong {
font-weight: bold; font-weight: bold;
} }
.hljs-link { .hljs-link {
text-decoration: underline; text-decoration: underline;
} }
@@ -1,343 +1,352 @@
<!-- 基础横幅组件 --> <!-- 基础横幅组件 -->
<template> <template>
<div <div
class="art-card basic-banner" class="art-card basic-banner"
:class="[{ 'has-decoration': decoration }, boxStyle]" :class="[{ 'has-decoration': decoration }, boxStyle]"
:style="{ height }" :style="{ height }"
@click="emit('click')" @click="emit('click')"
> >
<!-- 流星效果 --> <!-- 流星效果 -->
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors"> <div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
<span <span
v-for="(meteor, index) in meteors" v-for="(meteor, index) in meteors"
:key="index" :key="index"
class="meteor" class="meteor"
:style="{ :style="{
top: '-60px', top: '-60px',
left: `${meteor.x}%`, left: `${meteor.x}%`,
animationDuration: `${meteor.speed}s`, animationDuration: `${meteor.speed}s`,
animationDelay: `${meteor.delay}s` animationDelay: `${meteor.delay}s`
}" }"
></span> ></span>
</div> </div>
<div class="basic-banner__content"> <div class="basic-banner__content">
<!-- title slot --> <!-- title slot -->
<slot name="title"> <slot name="title">
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p> <p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{
</slot> title
}}</p>
</slot>
<!-- subtitle slot --> <!-- subtitle slot -->
<slot name="subtitle"> <slot name="subtitle">
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{ <p
subtitle v-if="subtitle"
}}</p> class="basic-banner__subtitle"
</slot> :style="{ color: subtitleColor }"
>{{ subtitle }}</p
>
</slot>
<!-- button slot --> <!-- button slot -->
<slot name="button"> <slot name="button">
<div <div
v-if="buttonConfig?.show" v-if="buttonConfig?.show"
class="basic-banner__button" class="basic-banner__button"
:style="{ :style="{
backgroundColor: buttonColor, backgroundColor: buttonColor,
color: buttonTextColor, color: buttonTextColor,
borderRadius: buttonRadius borderRadius: buttonRadius
}" }"
@click.stop="emit('buttonClick')" @click.stop="emit('buttonClick')"
> >
{{ buttonConfig?.text }} {{ buttonConfig?.text }}
</div> </div>
</slot> </slot>
<!-- default slot --> <!-- default slot -->
<slot></slot> <slot></slot>
<!-- background image --> <!-- background image -->
<img <img
v-if="imageConfig.src" v-if="imageConfig.src"
class="basic-banner__background-image" class="basic-banner__background-image"
:src="imageConfig.src" :src="imageConfig.src"
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }" :style="{
loading="lazy" width: imageConfig.width,
alt="背景图片" bottom: imageConfig.bottom,
/> right: imageConfig.right
</div> }"
</div> loading="lazy"
alt="背景图片"
/>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore) const { isDark } = storeToRefs(settingStore)
defineOptions({ name: 'ArtBasicBanner' }) defineOptions({ name: 'ArtBasicBanner' })
// 流星对象接口定义 // 流星对象接口定义
interface Meteor { interface Meteor {
/** 流星的水平位置(百分比) */ /** 流星的水平位置(百分比) */
x: number x: number
/** 流星划过的速度 */ /** 流星划过的速度 */
speed: number speed: number
/** 流星出现的延迟时间 */ /** 流星出现的延迟时间 */
delay: number delay: number
} }
// 按钮配置接口定义 // 按钮配置接口定义
interface ButtonConfig { interface ButtonConfig {
/** 是否启用按钮 */ /** 是否启用按钮 */
show: boolean show: boolean
/** 按钮文本 */ /** 按钮文本 */
text: string text: string
/** 按钮背景色 */ /** 按钮背景色 */
color?: string color?: string
/** 按钮文字颜色 */ /** 按钮文字颜色 */
textColor?: string textColor?: string
/** 按钮圆角大小 */ /** 按钮圆角大小 */
radius?: string radius?: string
} }
// 流星效果配置接口定义 // 流星效果配置接口定义
interface MeteorConfig { interface MeteorConfig {
/** 是否启用流星效果 */ /** 是否启用流星效果 */
enabled: boolean enabled: boolean
/** 流星数量 */ /** 流星数量 */
count?: number count?: number
} }
// 背景图片配置接口定义 // 背景图片配置接口定义
interface ImageConfig { interface ImageConfig {
/** 图片源地址 */ /** 图片源地址 */
src: string src: string
/** 图片宽度 */ /** 图片宽度 */
width?: string width?: string
/** 距底部距离 */ /** 距底部距离 */
bottom?: string bottom?: string
/** 距右侧距离 */ /** 距右侧距离 */
right?: string // 距右侧距离 right?: string // 距右侧距离
} }
// 组件属性接口定义 // 组件属性接口定义
interface Props { interface Props {
/** 横幅高度 */ /** 横幅高度 */
height?: string height?: string
/** 标题文本 */ /** 标题文本 */
title?: string title?: string
/** 副标题文本 */ /** 副标题文本 */
subtitle?: string subtitle?: string
/** 盒子样式 */ /** 盒子样式 */
boxStyle?: string boxStyle?: string
/** 是否显示装饰效果 */ /** 是否显示装饰效果 */
decoration?: boolean decoration?: boolean
/** 按钮配置 */ /** 按钮配置 */
buttonConfig?: ButtonConfig buttonConfig?: ButtonConfig
/** 流星配置 */ /** 流星配置 */
meteorConfig?: MeteorConfig meteorConfig?: MeteorConfig
/** 图片配置 */ /** 图片配置 */
imageConfig?: ImageConfig imageConfig?: ImageConfig
/** 标题颜色 */ /** 标题颜色 */
titleColor?: string titleColor?: string
/** 副标题颜色 */ /** 副标题颜色 */
subtitleColor?: string subtitleColor?: string
} }
// 组件属性默认值设置 // 组件属性默认值设置
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: '11rem', height: '11rem',
titleColor: 'white', titleColor: 'white',
subtitleColor: 'white', subtitleColor: 'white',
boxStyle: '!bg-theme/60', boxStyle: '!bg-theme/60',
decoration: true, decoration: true,
buttonConfig: () => ({ buttonConfig: () => ({
show: true, show: true,
text: '查看', text: '查看',
color: '#fff', color: '#fff',
textColor: '#333', textColor: '#333',
radius: '6px' radius: '6px'
}), }),
meteorConfig: () => ({ enabled: false, count: 10 }), meteorConfig: () => ({ enabled: false, count: 10 }),
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' }) imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
}) })
// 定义组件事件 // 定义组件事件
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click'): void // 整体点击事件 (e: 'click'): void // 整体点击事件
(e: 'buttonClick'): void // 按钮点击事件 (e: 'buttonClick'): void // 按钮点击事件
}>() }>()
// 计算按钮样式属性 // 计算按钮样式属性
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff') const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333') const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px') const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
// 流星数据初始化 // 流星数据初始化
const meteors = ref<Meteor[]>([]) const meteors = ref<Meteor[]>([])
onMounted(() => { onMounted(() => {
if (props.meteorConfig?.enabled) { if (props.meteorConfig?.enabled) {
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10) meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
} }
}) })
/** /**
* 生成流星数据数组 * 生成流星数据数组
* @param count 流星数量 * @param count 流星数量
* @returns 流星数据数组 * @returns 流星数据数组
*/ */
function generateMeteors(count: number): Meteor[] { function generateMeteors(count: number): Meteor[] {
// 计算每个流星的区域宽度 // 计算每个流星的区域宽度
const segmentWidth = 100 / count const segmentWidth = 100 / count
return Array.from({ length: count }, (_, index) => { return Array.from({ length: count }, (_, index) => {
// 计算流星起始位置 // 计算流星起始位置
const segmentStart = index * segmentWidth const segmentStart = index * segmentWidth
// 在区域内随机生成x坐标 // 在区域内随机生成x坐标
const x = segmentStart + Math.random() * segmentWidth const x = segmentStart + Math.random() * segmentWidth
// 随机决定流星速度快慢 // 随机决定流星速度快慢
const isSlow = Math.random() > 0.5 const isSlow = Math.random() > 0.5
return { return {
x, x,
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2, speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
delay: Math.random() * 5 delay: Math.random() * 5
} }
}) })
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.basic-banner { .basic-banner {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
padding: 0 2rem; padding: 0 2rem;
overflow: hidden; overflow: hidden;
color: white; color: white;
border-radius: calc(var(--custom-radius) + 2px) !important; border-radius: calc(var(--custom-radius) + 2px) !important;
&__content { &__content {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
&__title { &__title {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
} }
&__subtitle { &__subtitle {
position: relative; position: relative;
z-index: 10; z-index: 10;
margin: 0 0 1.5rem; margin: 0 0 1.5rem;
font-size: 0.9rem; font-size: 0.9rem;
opacity: 0.9; opacity: 0.9;
} }
&__button { &__button {
box-sizing: border-box; box-sizing: border-box;
display: inline-block; display: inline-block;
min-width: 80px; min-width: 80px;
height: var(--el-component-custom-height); height: var(--el-component-custom-height);
padding: 0 12px; padding: 0 12px;
font-size: 14px; font-size: 14px;
line-height: var(--el-component-custom-height); line-height: var(--el-component-custom-height);
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
transition: all 0.3s; transition: all 0.3s;
&:hover { &:hover {
opacity: 0.8; opacity: 0.8;
} }
} }
&__background-image { &__background-image {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: -3rem; bottom: -3rem;
z-index: 0; z-index: 0;
width: 12rem; width: 12rem;
} }
&.has-decoration::after { &.has-decoration::after {
position: absolute; position: absolute;
right: -10%; right: -10%;
bottom: -20%; bottom: -20%;
width: 60%; width: 60%;
height: 140%; height: 140%;
content: ''; content: '';
background: rgb(255 255 255 / 10%); background: rgb(255 255 255 / 10%);
border-radius: 30%; border-radius: 30%;
transform: rotate(-20deg); transform: rotate(-20deg);
} }
&__meteors { &__meteors {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
z-index: 0; z-index: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
pointer-events: none; pointer-events: none;
.meteor { .meteor {
position: absolute; position: absolute;
width: 2px; width: 2px;
height: 60px; height: 60px;
background: linear-gradient( background: linear-gradient(
to top, to top,
rgb(255 255 255 / 40%), rgb(255 255 255 / 40%),
rgb(255 255 255 / 10%), rgb(255 255 255 / 10%),
transparent transparent
); );
opacity: 0; opacity: 0;
transform-origin: top left; transform-origin: top left;
animation-name: meteor-fall; animation-name: meteor-fall;
animation-timing-function: linear; animation-timing-function: linear;
animation-iteration-count: infinite; animation-iteration-count: infinite;
&::before { &::before {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 2px; width: 2px;
height: 2px; height: 2px;
content: ''; content: '';
background: rgb(255 255 255 / 50%); background: rgb(255 255 255 / 50%);
} }
} }
} }
} }
@keyframes meteor-fall { @keyframes meteor-fall {
0% { 0% {
opacity: 1; opacity: 1;
transform: translate(0, -60px) rotate(-45deg); transform: translate(0, -60px) rotate(-45deg);
} }
100% { 100% {
opacity: 0; opacity: 0;
transform: translate(400px, 340px) rotate(-45deg); transform: translate(400px, 340px) rotate(-45deg);
} }
} }
@media (width <= 640px) { @media (width <= 640px) {
.basic-banner { .basic-banner {
box-sizing: border-box; box-sizing: border-box;
justify-content: flex-start; justify-content: flex-start;
padding: 16px; padding: 16px;
&__title { &__title {
font-size: 1.4rem; font-size: 1.4rem;
} }
&__background-image { &__background-image {
display: none; display: none;
} }
&.has-decoration::after { &.has-decoration::after {
display: none; display: none;
} }
} }
} }
</style> </style>
@@ -1,114 +1,114 @@
<!-- 卡片横幅组件 --> <!-- 卡片横幅组件 -->
<template> <template>
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }"> <div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
<div class="flex-c flex-col gap-4 text-center"> <div class="flex-c flex-col gap-4 text-center">
<div class="w-45"> <div class="w-45">
<img :src="image" :alt="title" class="w-full h-full object-contain" /> <img :src="image" :alt="title" class="w-full h-full object-contain" />
</div> </div>
<div class="box-border px-4"> <div class="box-border px-4">
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p> <p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
<p class="m-0 text-sm text-g-600">{{ description }}</p> <p class="m-0 text-sm text-g-600">{{ description }}</p>
</div> </div>
<div class="flex-c gap-3"> <div class="flex-c gap-3">
<div <div
v-if="cancelButton?.show" v-if="cancelButton?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300" class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
:style="{ :style="{
backgroundColor: cancelButton?.color, backgroundColor: cancelButton?.color,
color: cancelButton?.textColor color: cancelButton?.textColor
}" }"
@click="handleCancel" @click="handleCancel"
> >
{{ cancelButton?.text }} {{ cancelButton?.text }}
</div> </div>
<div <div
v-if="button?.show" v-if="button?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md" class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
:style="{ backgroundColor: button?.color, color: button?.textColor }" :style="{ backgroundColor: button?.color, color: button?.textColor }"
@click="handleClick" @click="handleClick"
> >
{{ button?.text }} {{ button?.text }}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 导入默认图标 // 导入默认图标
import defaultIcon from '@imgs/3d/icon1.webp' import defaultIcon from '@imgs/3d/icon1.webp'
defineOptions({ name: 'ArtCardBanner' }) defineOptions({ name: 'ArtCardBanner' })
// 定义卡片横幅组件的属性接口 // 定义卡片横幅组件的属性接口
interface CardBannerProps { interface CardBannerProps {
/** 高度 */ /** 高度 */
height?: string height?: string
/** 图片路径 */ /** 图片路径 */
image?: string image?: string
/** 标题文本 */ /** 标题文本 */
title: string title: string
/** 描述文本 */ /** 描述文本 */
description: string description: string
/** 主按钮配置 */ /** 主按钮配置 */
button?: { button?: {
/** 是否显示 */ /** 是否显示 */
show?: boolean show?: boolean
/** 按钮文本 */ /** 按钮文本 */
text?: string text?: string
/** 背景颜色 */ /** 背景颜色 */
color?: string color?: string
/** 文字颜色 */ /** 文字颜色 */
textColor?: string textColor?: string
} }
/** 取消按钮配置 */ /** 取消按钮配置 */
cancelButton?: { cancelButton?: {
/** 是否显示 */ /** 是否显示 */
show?: boolean show?: boolean
/** 按钮文本 */ /** 按钮文本 */
text?: string text?: string
/** 背景颜色 */ /** 背景颜色 */
color?: string color?: string
/** 文字颜色 */ /** 文字颜色 */
textColor?: string textColor?: string
} }
} }
// 定义组件属性默认值 // 定义组件属性默认值
withDefaults(defineProps<CardBannerProps>(), { withDefaults(defineProps<CardBannerProps>(), {
height: '24rem', height: '24rem',
image: defaultIcon, image: defaultIcon,
title: '', title: '',
description: '', description: '',
// 主按钮默认配置 // 主按钮默认配置
button: () => ({ button: () => ({
show: true, show: true,
text: '查看详情', text: '查看详情',
color: 'var(--theme-color)', color: 'var(--theme-color)',
textColor: '#fff' textColor: '#fff'
}), }),
// 取消按钮默认配置 // 取消按钮默认配置
cancelButton: () => ({ cancelButton: () => ({
show: false, show: false,
text: '取消', text: '取消',
color: '#f5f5f5', color: '#f5f5f5',
textColor: '#666' textColor: '#666'
}) })
}) })
// 定义组件事件 // 定义组件事件
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click'): void // 主按钮点击事件 (e: 'click'): void // 主按钮点击事件
(e: 'cancel'): void // 取消按钮点击事件 (e: 'cancel'): void // 取消按钮点击事件
}>() }>()
// 主按钮点击处理函数 // 主按钮点击处理函数
const handleClick = () => { const handleClick = () => {
emit('click') emit('click')
} }
// 取消按钮点击处理函数 // 取消按钮点击处理函数
const handleCancel = () => { const handleCancel = () => {
emit('cancel') emit('cancel')
} }
</script> </script>
@@ -1,40 +1,40 @@
<!-- 返回顶部按钮 --> <!-- 返回顶部按钮 -->
<template> <template>
<Transition <Transition
enter-active-class="tad-300 ease-out" enter-active-class="tad-300 ease-out"
leave-active-class="tad-200 ease-in" leave-active-class="tad-200 ease-in"
enter-from-class="opacity-0 translate-y-2" enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0" enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0" leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2" leave-to-class="opacity-0 translate-y-2"
> >
<div <div
v-show="showButton" v-show="showButton"
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200" class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
@click="scrollToTop" @click="scrollToTop"
> >
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" /> <ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
</div> </div>
</Transition> </Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCommon } from '@/hooks/core/useCommon' import { useCommon } from '@/hooks/core/useCommon'
defineOptions({ name: 'ArtBackToTop' }) defineOptions({ name: 'ArtBackToTop' })
const { scrollToTop } = useCommon() const { scrollToTop } = useCommon()
const showButton = ref(false) const showButton = ref(false)
const scrollThreshold = 300 const scrollThreshold = 300
onMounted(() => { onMounted(() => {
const scrollContainer = document.getElementById('app-main') const scrollContainer = document.getElementById('app-main')
if (scrollContainer) { if (scrollContainer) {
const { y } = useScroll(scrollContainer) const { y } = useScroll(scrollContainer)
watch(y, (newY: number) => { watch(y, (newY: number) => {
showButton.value = newY > scrollThreshold showButton.value = newY > scrollThreshold
}) })
} }
}) })
</script> </script>
+12 -12
View File
@@ -1,21 +1,21 @@
<!-- 系统logo --> <!-- 系统logo -->
<template> <template>
<div class="flex-cc"> <div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" /> <img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtLogo' }) defineOptions({ name: 'ArtLogo' })
interface Props { interface Props {
/** logo 大小 */ /** logo 大小 */
size?: number | string size?: number | string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 36 size: 36
}) })
const logoStyle = computed(() => ({ width: `${props.size}px` })) const logoStyle = computed(() => ({ width: `${props.size}px` }))
</script> </script>
+13 -13
View File
@@ -1,24 +1,24 @@
<!-- 图标组件 --> <!-- 图标组件 -->
<template> <template>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" /> <Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue' import { Icon } from '@iconify/vue'
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false }) defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
interface Props { interface Props {
/** Iconify icon name */ /** Iconify icon name */
icon?: string icon?: string
} }
defineProps<Props>() defineProps<Props>()
const attrs = useAttrs() const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({ const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '', class: (attrs.class as string) || '',
style: (attrs.style as string) || '' style: (attrs.style as string) || ''
})) }))
</script> </script>
@@ -1,103 +1,108 @@
<!-- 柱状图卡片 --> <!-- 柱状图卡片 -->
<template> <template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }"> <div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-5 flex-b items-start px-5 pt-5"> <div class="mb-5 flex-b items-start px-5 pt-5">
<div> <div>
<p class="m-0 text-2xl font-medium leading-tight text-g-900"> <p class="m-0 text-2xl font-medium leading-tight text-g-900">
{{ value }} {{ value }}
</p> </p>
<p class="mt-1 text-sm text-g-600">{{ label }}</p> <p class="mt-1 text-sm text-g-600">{{ label }}</p>
</div> </div>
<div <div
class="text-sm font-medium text-danger" class="text-sm font-medium text-danger"
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']" :class="[
> percentage > 0 ? 'text-success' : '',
{{ percentage > 0 ? '+' : '' }}{{ percentage }}% isMiniChart ? 'absolute bottom-5' : ''
</div> ]"
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600"> >
{{ date }} {{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div> </div>
</div> <div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
<div {{ date }}
ref="chartRef" </div>
class="absolute bottom-0 left-0 right-0 mx-auto" </div>
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''" <div
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }" ref="chartRef"
></div> class="absolute bottom-0 left-0 right-0 mx-auto"
</div> :class="
isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''
"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { type EChartsOption } from '@/plugins/echarts' import { type EChartsOption } from '@/plugins/echarts'
defineOptions({ name: 'ArtBarChartCard' }) defineOptions({ name: 'ArtBarChartCard' })
interface Props { interface Props {
/** 数值 */ /** 数值 */
value: number value: number
/** 标签 */ /** 标签 */
label: string label: string
/** 百分比 +(绿色)-(红色) */ /** 百分比 +(绿色)-(红色) */
percentage: number percentage: number
/** 日期 */ /** 日期 */
date?: string date?: string
/** 高度 */ /** 高度 */
height?: number height?: number
/** 颜色 */ /** 颜色 */
color?: string color?: string
/** 图表数据 */ /** 图表数据 */
chartData: number[] chartData: number[]
/** 柱状图宽度 */ /** 柱状图宽度 */
barWidth?: string barWidth?: string
/** 是否为迷你图表 */ /** 是否为迷你图表 */
isMiniChart?: boolean isMiniChart?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: 11, height: 11,
barWidth: '26%' barWidth: '26%'
}) })
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { chartRef } = useChartComponent({ const { chartRef } = useChartComponent({
props: { props: {
height: `${props.height}rem`, height: `${props.height}rem`,
loading: false, loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0) isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
}, },
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0), checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.barWidth], watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor const computedColor = props.color || useChartOps().themeColor
return { return {
grid: { grid: {
top: 0, top: 0,
right: 0, right: 0,
bottom: 15, bottom: 15,
left: 0 left: 0
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
show: false show: false
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
show: false show: false
}, },
series: [ series: [
{ {
data: props.chartData, data: props.chartData,
type: 'bar', type: 'bar',
barWidth: props.barWidth, barWidth: props.barWidth,
itemStyle: { itemStyle: {
color: computedColor, color: computedColor,
borderRadius: 2 borderRadius: 2
} }
} }
] ]
} }
} }
}) })
</script> </script>
@@ -1,74 +1,74 @@
<!-- 数据列表卡片 --> <!-- 数据列表卡片 -->
<template> <template>
<div class="art-card p-5"> <div class="art-card p-5">
<div class="pb-3.5"> <div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p> <p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p> <p class="text-sm text-g-600">{{ subtitle }}</p>
</div> </div>
<ElScrollbar :style="{ height: maxHeight }"> <ElScrollbar :style="{ height: maxHeight }">
<div v-for="(item, index) in list" :key="index" class="flex-c py-3"> <div v-for="(item, index) in list" :key="index" class="flex-c py-3">
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class"> <div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
<ArtSvgIcon :icon="item.icon" class="text-xl" /> <ArtSvgIcon :icon="item.icon" class="text-xl" />
</div> </div>
<div class="flex-1"> <div class="flex-1">
<div class="mb-1 text-sm">{{ item.title }}</div> <div class="mb-1 text-sm">{{ item.title }}</div>
<div class="text-xs text-g-500">{{ item.status }}</div> <div class="text-xs text-g-500">{{ item.status }}</div>
</div> </div>
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div> <div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
</div> </div>
</ElScrollbar> </ElScrollbar>
<ElButton <ElButton
class="mt-[25px] w-full text-center" class="mt-[25px] w-full text-center"
v-if="showMoreButton" v-if="showMoreButton"
v-ripple v-ripple
@click="handleMore" @click="handleMore"
>查看更多</ElButton >查看更多</ElButton
> >
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtDataListCard' }) defineOptions({ name: 'ArtDataListCard' })
interface Props { interface Props {
/** 数据列表 */ /** 数据列表 */
list: Activity[] list: Activity[]
/** 标题 */ /** 标题 */
title: string title: string
/** 副标题 */ /** 副标题 */
subtitle?: string subtitle?: string
/** 最大显示数量 */ /** 最大显示数量 */
maxCount?: number maxCount?: number
/** 是否显示更多按钮 */ /** 是否显示更多按钮 */
showMoreButton?: boolean showMoreButton?: boolean
} }
interface Activity { interface Activity {
/** 标题 */ /** 标题 */
title: string title: string
/** 状态 */ /** 状态 */
status: string status: string
/** 时间 */ /** 时间 */
time: string time: string
/** 样式类名 */ /** 样式类名 */
class: string class: string
/** 图标 */ /** 图标 */
icon: string icon: string
} }
const ITEM_HEIGHT = 66 const ITEM_HEIGHT = 66
const DEFAULT_MAX_COUNT = 5 const DEFAULT_MAX_COUNT = 5
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
maxCount: DEFAULT_MAX_COUNT maxCount: DEFAULT_MAX_COUNT
}) })
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`) const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
const emit = defineEmits<{ const emit = defineEmits<{
/** 点击更多按钮事件 */ /** 点击更多按钮事件 */
(e: 'more'): void (e: 'more'): void
}>() }>()
const handleMore = () => emit('more') const handleMore = () => emit('more')
</script> </script>
@@ -1,124 +1,124 @@
<!-- 环型图卡片 --> <!-- 环型图卡片 -->
<template> <template>
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }"> <div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
<div class="flex box-border h-full p-5 pr-2"> <div class="flex box-border h-full p-5 pr-2">
<div class="flex w-full items-start gap-5"> <div class="flex w-full items-start gap-5">
<div class="flex-b h-full flex-1 flex-col"> <div class="flex-b h-full flex-1 flex-col">
<p class="m-0 text-xl font-medium leading-tight text-g-900"> <p class="m-0 text-xl font-medium leading-tight text-g-900">
{{ title }} {{ title }}
</p> </p>
<div> <div>
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900"> <p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
{{ formatNumber(value) }} {{ formatNumber(value) }}
</p> </p>
<div <div
class="mt-1.5 text-xs font-medium" class="mt-1.5 text-xs font-medium"
:class="percentage > 0 ? 'text-success' : 'text-danger'" :class="percentage > 0 ? 'text-success' : 'text-danger'"
> >
{{ percentage > 0 ? '+' : '' }}{{ percentage }}% {{ percentage > 0 ? '+' : '' }}{{ percentage }}%
<span v-if="percentageLabel">{{ percentageLabel }}</span> <span v-if="percentageLabel">{{ percentageLabel }}</span>
</div> </div>
</div> </div>
<div class="mt-2 flex gap-4 text-xs text-g-600"> <div class="mt-2 flex gap-4 text-xs text-g-600">
<div v-if="currentValue" class="flex-cc"> <div v-if="currentValue" class="flex-cc">
<div class="size-2 bg-theme/100 rounded mr-2"></div> <div class="size-2 bg-theme/100 rounded mr-2"></div>
{{ currentValue }} {{ currentValue }}
</div> </div>
<div v-if="previousValue" class="flex-cc"> <div v-if="previousValue" class="flex-cc">
<div class="size-2 bg-g-400 rounded mr-2"></div> <div class="size-2 bg-g-400 rounded mr-2"></div>
{{ previousValue }} {{ previousValue }}
</div> </div>
</div> </div>
</div> </div>
<div class="flex-c h-full max-w-40 flex-1"> <div class="flex-c h-full max-w-40 flex-1">
<div ref="chartRef" class="h-30 w-full"></div> <div ref="chartRef" class="h-30 w-full"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type EChartsOption } from '@/plugins/echarts' import { type EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
defineOptions({ name: 'ArtDonutChartCard' }) defineOptions({ name: 'ArtDonutChartCard' })
interface Props { interface Props {
/** 数值 */ /** 数值 */
value: number value: number
/** 标题 */ /** 标题 */
title: string title: string
/** 百分比 */ /** 百分比 */
percentage: number percentage: number
/** 百分比标签 */ /** 百分比标签 */
percentageLabel?: string percentageLabel?: string
/** 当前年份 */ /** 当前年份 */
currentValue?: string currentValue?: string
/** 去年年份 */ /** 去年年份 */
previousValue?: string previousValue?: string
/** 高度 */ /** 高度 */
height?: number height?: number
/** 颜色 */ /** 颜色 */
color?: string color?: string
/** 半径 */ /** 半径 */
radius?: [string, string] radius?: [string, string]
/** 数据 */ /** 数据 */
data: [number, number] data: [number, number]
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: 9, height: 9,
radius: () => ['70%', '90%'], radius: () => ['70%', '90%'],
data: () => [0, 0] data: () => [0, 0]
}) })
const formatNumber = (num: number) => { const formatNumber = (num: number) => {
return num.toLocaleString() return num.toLocaleString()
} }
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { chartRef } = useChartComponent({ const { chartRef } = useChartComponent({
props: { props: {
height: `${props.height}rem`, height: `${props.height}rem`,
loading: false, loading: false,
isEmpty: props.data.every((val) => val === 0) isEmpty: props.data.every((val) => val === 0)
}, },
checkEmpty: () => props.data.every((val) => val === 0), checkEmpty: () => props.data.every((val) => val === 0),
watchSources: [ watchSources: [
() => props.data, () => props.data,
() => props.color, () => props.color,
() => props.radius, () => props.radius,
() => props.currentValue, () => props.currentValue,
() => props.previousValue () => props.previousValue
], ],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor const computedColor = props.color || useChartOps().themeColor
return { return {
series: [ series: [
{ {
type: 'pie', type: 'pie',
radius: props.radius, radius: props.radius,
avoidLabelOverlap: false, avoidLabelOverlap: false,
label: { label: {
show: false show: false
}, },
data: [ data: [
{ {
value: props.data[0], value: props.data[0],
name: props.currentValue, name: props.currentValue,
itemStyle: { color: computedColor } itemStyle: { color: computedColor }
}, },
{ {
value: props.data[1], value: props.data[1],
name: props.previousValue, name: props.previousValue,
itemStyle: { color: '#e6e8f7' } itemStyle: { color: '#e6e8f7' }
} }
] ]
} }
] ]
} }
} }
}) })
</script> </script>
@@ -1,89 +1,89 @@
<!-- 图片卡片 --> <!-- 图片卡片 -->
<template> <template>
<div class="w-full c-p" @click="handleClick"> <div class="w-full c-p" @click="handleClick">
<div class="art-card overflow-hidden"> <div class="art-card overflow-hidden">
<div class="relative w-full aspect-[16/10] overflow-hidden"> <div class="relative w-full aspect-[16/10] overflow-hidden">
<ElImage <ElImage
:src="props.imageUrl" :src="props.imageUrl"
fit="cover" fit="cover"
loading="lazy" loading="lazy"
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105" class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
> >
<template #placeholder> <template #placeholder>
<div class="flex-cc w-full h-full bg-[#f5f7fa]"> <div class="flex-cc w-full h-full bg-[#f5f7fa]">
<ElIcon><Picture /></ElIcon> <ElIcon><Picture /></ElIcon>
</div> </div>
</template> </template>
</ElImage> </ElImage>
<div <div
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded" class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
v-if="props.readTime" v-if="props.readTime"
> >
{{ props.readTime }} 阅读 {{ props.readTime }} 阅读
</div> </div>
</div> </div>
<div class="p-4"> <div class="p-4">
<div <div
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded" class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
v-if="props.category" v-if="props.category"
> >
{{ props.category }} {{ props.category }}
</div> </div>
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p> <p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
<div class="flex-c gap-4 text-xs text-g-600"> <div class="flex-c gap-4 text-xs text-g-600">
<span class="flex-c gap-1" v-if="props.views"> <span class="flex-c gap-1" v-if="props.views">
<ElIcon class="text-base"><View /></ElIcon> <ElIcon class="text-base"><View /></ElIcon>
{{ props.views }} {{ props.views }}
</span> </span>
<span class="flex-c gap-1" v-if="props.comments"> <span class="flex-c gap-1" v-if="props.comments">
<ElIcon class="text-base"><ChatLineRound /></ElIcon> <ElIcon class="text-base"><ChatLineRound /></ElIcon>
{{ props.comments }} {{ props.comments }}
</span> </span>
<span>{{ props.date }}</span> <span>{{ props.date }}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue' import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
defineOptions({ name: 'ArtImageCard' }) defineOptions({ name: 'ArtImageCard' })
interface Props { interface Props {
/** 图片地址 */ /** 图片地址 */
imageUrl: string imageUrl: string
/** 标题 */ /** 标题 */
title: string title: string
/** 分类 */ /** 分类 */
category?: string category?: string
/** 阅读时间 */ /** 阅读时间 */
readTime?: string readTime?: string
/** 浏览量 */ /** 浏览量 */
views?: number views?: number
/** 评论数 */ /** 评论数 */
comments?: number comments?: number
/** 日期 */ /** 日期 */
date?: string date?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
imageUrl: '', imageUrl: '',
title: '', title: '',
category: '', category: '',
readTime: '', readTime: '',
views: 0, views: 0,
comments: 0, comments: 0,
date: '' date: ''
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click', card: Props): void (e: 'click', card: Props): void
}>() }>()
const handleClick = () => { const handleClick = () => {
emit('click', props) emit('click', props)
} }
</script> </script>
@@ -1,126 +1,130 @@
<!-- 折线图卡片 --> <!-- 折线图卡片 -->
<template> <template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }"> <div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-2.5 flex-b items-start p-5"> <div class="mb-2.5 flex-b items-start p-5">
<div> <div>
<p class="text-2xl font-medium leading-none"> <p class="text-2xl font-medium leading-none">
{{ value }} {{ value }}
</p> </p>
<p class="mt-1 text-sm text-g-500">{{ label }}</p> <p class="mt-1 text-sm text-g-500">{{ label }}</p>
</div> </div>
<div <div
class="text-sm font-medium" class="text-sm font-medium"
:class="[ :class="[
percentage > 0 ? 'text-success' : 'text-danger', percentage > 0 ? 'text-success' : 'text-danger',
isMiniChart ? 'absolute bottom-5' : '' isMiniChart ? 'absolute bottom-5' : ''
]" ]"
> >
{{ percentage > 0 ? '+' : '' }}{{ percentage }}% {{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div> </div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500"> <div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
{{ date }} {{ date }}
</div> </div>
</div> </div>
<div <div
ref="chartRef" ref="chartRef"
class="absolute bottom-0 left-0 right-0 box-border w-full" class="absolute bottom-0 left-0 right-0 box-border w-full"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''" :class="
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }" isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''
></div> "
</div> :style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { graphic, type EChartsOption } from '@/plugins/echarts' import { graphic, type EChartsOption } from '@/plugins/echarts'
import { getCssVar, hexToRgba } from '@/utils/ui' import { getCssVar, hexToRgba } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
defineOptions({ name: 'ArtLineChartCard' }) defineOptions({ name: 'ArtLineChartCard' })
interface Props { interface Props {
/** 数值 */ /** 数值 */
value: number value: number
/** 标签 */ /** 标签 */
label: string label: string
/** 百分比 */ /** 百分比 */
percentage: number percentage: number
/** 日期 */ /** 日期 */
date?: string date?: string
/** 高度 */ /** 高度 */
height?: number height?: number
/** 颜色 */ /** 颜色 */
color?: string color?: string
/** 是否显示区域颜色 */ /** 是否显示区域颜色 */
showAreaColor?: boolean showAreaColor?: boolean
/** 图表数据 */ /** 图表数据 */
chartData: number[] chartData: number[]
/** 是否为迷你图表 */ /** 是否为迷你图表 */
isMiniChart?: boolean isMiniChart?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: 11 height: 11
}) })
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { chartRef } = useChartComponent({ const { chartRef } = useChartComponent({
props: { props: {
height: `${props.height}rem`, height: `${props.height}rem`,
loading: false, loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0) isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
}, },
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0), checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor], watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor const computedColor = props.color || useChartOps().themeColor
return { return {
grid: { grid: {
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
left: 0 left: 0
}, },
xAxis: { xAxis: {
type: 'category', type: 'category',
show: false, show: false,
boundaryGap: false boundaryGap: false
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
show: false show: false
}, },
series: [ series: [
{ {
data: props.chartData, data: props.chartData,
type: 'line', type: 'line',
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
lineStyle: { lineStyle: {
width: 3, width: 3,
color: computedColor color: computedColor
}, },
areaStyle: props.showAreaColor areaStyle: props.showAreaColor
? { ? {
color: new graphic.LinearGradient(0, 0, 0, 1, [ color: new graphic.LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: props.color color: props.color
? hexToRgba(props.color, 0.2).rgba ? hexToRgba(props.color, 0.2).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba : hexToRgba(getCssVar('--el-color-primary'), 0.2)
}, .rgba
{ },
offset: 1, {
color: props.color offset: 1,
? hexToRgba(props.color, 0.01).rgba color: props.color
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba ? hexToRgba(props.color, 0.01).rgba
} : hexToRgba(getCssVar('--el-color-primary'), 0.01)
]) .rgba
} }
: undefined ])
} }
] : undefined
} }
} ]
}) }
}
})
</script> </script>
@@ -1,86 +1,89 @@
<!-- 进度条卡片 --> <!-- 进度条卡片 -->
<template> <template>
<div class="art-card h-32 flex flex-col justify-center px-5"> <div class="art-card h-32 flex flex-col justify-center px-5">
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }"> <div
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle"> class="mb-3.5 flex-c"
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon> :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }"
</div> >
<div> <div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
<ArtCountTo <ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
class="mb-1 block text-2xl font-semibold" </div>
:target="percentage" <div>
:duration="2000" <ArtCountTo
suffix="%" class="mb-1 block text-2xl font-semibold"
:style="{ textAlign: icon ? 'right' : 'left' }" :target="percentage"
/> :duration="2000"
<p class="text-sm text-g-500">{{ title }}</p> suffix="%"
</div> :style="{ textAlign: icon ? 'right' : 'left' }"
</div> />
<ElProgress <p class="text-sm text-g-500">{{ title }}</p>
:percentage="currentPercentage" </div>
:stroke-width="strokeWidth" </div>
:show-text="false" <ElProgress
:color="color" :percentage="currentPercentage"
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]" :stroke-width="strokeWidth"
/> :show-text="false"
</div> :color="color"
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
/>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtProgressCard' }) defineOptions({ name: 'ArtProgressCard' })
interface Props { interface Props {
/** 进度百分比 */ /** 进度百分比 */
percentage: number percentage: number
/** 标题 */ /** 标题 */
title: string title: string
/** 颜色 */ /** 颜色 */
color?: string color?: string
/** 图标 */ /** 图标 */
icon?: string icon?: string
/** 图标样式 */ /** 图标样式 */
iconStyle?: string iconStyle?: string
/** 进度条宽度 */ /** 进度条宽度 */
strokeWidth?: number strokeWidth?: number
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
strokeWidth: 5, strokeWidth: 5,
color: '#67C23A' color: '#67C23A'
}) })
const animationDuration = 500 const animationDuration = 500
const currentPercentage = ref(0) const currentPercentage = ref(0)
const animateProgress = () => { const animateProgress = () => {
const startTime = Date.now() const startTime = Date.now()
const startValue = currentPercentage.value const startValue = currentPercentage.value
const endValue = props.percentage const endValue = props.percentage
const animate = () => { const animate = () => {
const currentTime = Date.now() const currentTime = Date.now()
const elapsed = currentTime - startTime const elapsed = currentTime - startTime
const progress = Math.min(elapsed / animationDuration, 1) const progress = Math.min(elapsed / animationDuration, 1)
currentPercentage.value = startValue + (endValue - startValue) * progress currentPercentage.value = startValue + (endValue - startValue) * progress
if (progress < 1) { if (progress < 1) {
requestAnimationFrame(animate) requestAnimationFrame(animate)
} }
} }
requestAnimationFrame(animate) requestAnimationFrame(animate)
} }
onMounted(() => { onMounted(() => {
animateProgress() animateProgress()
}) })
// 当 percentage 属性变化时重新执行动画 // 当 percentage 属性变化时重新执行动画
watch( watch(
() => props.percentage, () => props.percentage,
() => { () => {
animateProgress() animateProgress()
} }
) )
</script> </script>
@@ -1,67 +1,71 @@
<!-- 统计卡片 --> <!-- 统计卡片 -->
<template> <template>
<div <div
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5" class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
:class="boxStyle" :class="boxStyle"
> >
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle"> <div
<ArtSvgIcon :icon="icon"></ArtSvgIcon> v-if="icon"
</div> class="mr-4 size-11 flex-cc rounded-lg text-xl text-white"
<div class="flex-1"> :class="iconStyle"
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title"> >
{{ title }} <ArtSvgIcon :icon="icon"></ArtSvgIcon>
</p> </div>
<ArtCountTo <div class="flex-1">
class="m-0 text-2xl font-medium" <p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
v-if="count !== undefined" {{ title }}
:target="count" </p>
:duration="2000" <ArtCountTo
:decimals="decimals" class="m-0 text-2xl font-medium"
:separator="separator" v-if="count !== undefined"
/> :target="count"
<p :duration="2000"
class="mt-1 text-sm text-g-500 opacity-90" :decimals="decimals"
:style="{ color: textColor }" :separator="separator"
v-if="description" />
>{{ description }}</p <p
> class="mt-1 text-sm text-g-500 opacity-90"
</div> :style="{ color: textColor }"
<div v-if="showArrow"> v-if="description"
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" /> >{{ description }}</p
</div> >
</div> </div>
<div v-if="showArrow">
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtStatsCard' }) defineOptions({ name: 'ArtStatsCard' })
interface StatsCardProps { interface StatsCardProps {
/** 盒子样式 */ /** 盒子样式 */
boxStyle?: string boxStyle?: string
/** 图标 */ /** 图标 */
icon?: string icon?: string
/** 图标样式 */ /** 图标样式 */
iconStyle?: string iconStyle?: string
/** 标题 */ /** 标题 */
title?: string title?: string
/** 数值 */ /** 数值 */
count?: number count?: number
/** 小数位 */ /** 小数位 */
decimals?: number decimals?: number
/** 分隔符 */ /** 分隔符 */
separator?: string separator?: string
/** 描述 */ /** 描述 */
description: string description: string
/** 文本颜色 */ /** 文本颜色 */
textColor?: string textColor?: string
/** 是否显示箭头 */ /** 是否显示箭头 */
showArrow?: boolean showArrow?: boolean
} }
withDefaults(defineProps<StatsCardProps>(), { withDefaults(defineProps<StatsCardProps>(), {
iconSize: 30, iconSize: 30,
iconBgRadius: 50, iconBgRadius: 50,
decimals: 0, decimals: 0,
separator: ',' separator: ','
}) })
</script> </script>
@@ -1,69 +1,71 @@
<!-- 时间轴列表卡片 --> <!-- 时间轴列表卡片 -->
<template> <template>
<div class="art-card p-5"> <div class="art-card p-5">
<div class="pb-3.5"> <div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p> <p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p> <p class="text-sm text-g-600">{{ subtitle }}</p>
</div> </div>
<ElScrollbar :style="{ height: maxHeight }"> <ElScrollbar :style="{ height: maxHeight }">
<ElTimeline class="!pl-0.5"> <ElTimeline class="!pl-0.5">
<ElTimelineItem <ElTimelineItem
v-for="item in list" v-for="item in list"
:key="item.time" :key="item.time"
:timestamp="item.time" :timestamp="item.time"
:placement="TIMELINE_PLACEMENT" :placement="TIMELINE_PLACEMENT"
:color="item.status" :color="item.status"
:center="true" :center="true"
> >
<div class="flex-c gap-3"> <div class="flex-c gap-3">
<div class="flex-c gap-2"> <div class="flex-c gap-2">
<span class="text-sm">{{ item.content }}</span> <span class="text-sm">{{ item.content }}</span>
<span v-if="item.code" class="text-sm text-theme"> #{{ item.code }} </span> <span v-if="item.code" class="text-sm text-theme">
</div> #{{ item.code }}
</div> </span>
</ElTimelineItem> </div>
</ElTimeline> </div>
</ElScrollbar> </ElTimelineItem>
</div> </ElTimeline>
</ElScrollbar>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtTimelineListCard' }) defineOptions({ name: 'ArtTimelineListCard' })
// 常量配置 // 常量配置
const ITEM_HEIGHT = 65 const ITEM_HEIGHT = 65
const TIMELINE_PLACEMENT = 'top' const TIMELINE_PLACEMENT = 'top'
const DEFAULT_MAX_COUNT = 5 const DEFAULT_MAX_COUNT = 5
interface TimelineItem { interface TimelineItem {
/** 时间 */ /** 时间 */
time: string time: string
/** 状态颜色 */ /** 状态颜色 */
status: string status: string
/** 内容 */ /** 内容 */
content: string content: string
/** 代码标识 */ /** 代码标识 */
code?: string code?: string
} }
interface Props { interface Props {
/** 时间轴列表数据 */ /** 时间轴列表数据 */
list: TimelineItem[] list: TimelineItem[]
/** 标题 */ /** 标题 */
title: string title: string
/** 副标题 */ /** 副标题 */
subtitle?: string subtitle?: string
/** 最大显示数量 */ /** 最大显示数量 */
maxCount?: number maxCount?: number
} }
// Props 定义和验证 // Props 定义和验证
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: '', title: '',
subtitle: '', subtitle: '',
maxCount: DEFAULT_MAX_COUNT maxCount: DEFAULT_MAX_COUNT
}) })
// 计算最大高度 // 计算最大高度
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`) const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
</script> </script>
+182 -176
View File
@@ -1,203 +1,209 @@
<!-- 柱状图 --> <!-- 柱状图 -->
<template> <template>
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div> <div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { getCssVar } from '@/utils/ui' import { getCssVar } from '@/utils/ui'
import { graphic, type EChartsOption } from '@/plugins/echarts' import { graphic, type EChartsOption } from '@/plugins/echarts'
import type { BarChartProps, BarDataItem } from '@/types/component/chart' import type { BarChartProps, BarDataItem } from '@/types/component/chart'
defineOptions({ name: 'ArtBarChart' }) defineOptions({ name: 'ArtBarChart' })
const props = withDefaults(defineProps<BarChartProps>(), { const props = withDefaults(defineProps<BarChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
borderRadius: 4, borderRadius: 4,
// 数据配置 // 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0], data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [], xAxisData: () => [],
barWidth: '40%', barWidth: '40%',
stack: false, stack: false,
// 轴线显示配置 // 轴线显示配置
showAxisLabel: true, showAxisLabel: true,
showAxisLine: true, showAxisLine: true,
showSplitLine: true, showSplitLine: true,
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'bottom' legendPosition: 'bottom'
}) })
// 判断是否为多数据 // 判断是否为多数据
const isMultipleData = computed(() => { const isMultipleData = computed(() => {
return ( return (
Array.isArray(props.data) && Array.isArray(props.data) &&
props.data.length > 0 && props.data.length > 0 &&
typeof props.data[0] === 'object' && typeof props.data[0] === 'object' &&
'name' in props.data[0] 'name' in props.data[0]
) )
}) })
// 获取颜色配置 // 获取颜色配置
const getColor = (customColor?: string, index?: number) => { const getColor = (customColor?: string, index?: number) => {
if (customColor) return customColor if (customColor) return customColor
if (index !== undefined) { if (index !== undefined) {
return props.colors![index % props.colors!.length] return props.colors![index % props.colors!.length]
} }
// 默认渐变色 // 默认渐变色
return new graphic.LinearGradient(0, 0, 0, 1, [ return new graphic.LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: getCssVar('--el-color-primary-light-4') color: getCssVar('--el-color-primary-light-4')
}, },
{ {
offset: 1, offset: 1,
color: getCssVar('--el-color-primary') color: getCssVar('--el-color-primary')
} }
]) ])
} }
// 创建渐变色 // 创建渐变色
const createGradientColor = (color: string) => { const createGradientColor = (color: string) => {
return new graphic.LinearGradient(0, 0, 0, 1, [ return new graphic.LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: color color: color
}, },
{ {
offset: 1, offset: 1,
color: color color: color
} }
]) ])
} }
// 获取基础样式配置 // 获取基础样式配置
const getBaseItemStyle = ( const getBaseItemStyle = (
color: string | InstanceType<typeof graphic.LinearGradient> | undefined color: string | InstanceType<typeof graphic.LinearGradient> | undefined
) => ({ ) => ({
borderRadius: props.borderRadius, borderRadius: props.borderRadius,
color: typeof color === 'string' ? createGradientColor(color) : color color: typeof color === 'string' ? createGradientColor(color) : color
}) })
// 创建系列配置 // 创建系列配置
const createSeriesItem = (config: { const createSeriesItem = (config: {
name?: string name?: string
data: number[] data: number[]
color?: string | InstanceType<typeof graphic.LinearGradient> color?: string | InstanceType<typeof graphic.LinearGradient>
barWidth?: string | number barWidth?: string | number
stack?: string stack?: string
}) => { }) => {
const animationConfig = getAnimationConfig() const animationConfig = getAnimationConfig()
return { return {
name: config.name, name: config.name,
data: config.data, data: config.data,
type: 'bar' as const, type: 'bar' as const,
stack: config.stack, stack: config.stack,
itemStyle: getBaseItemStyle(config.color), itemStyle: getBaseItemStyle(config.color),
barWidth: config.barWidth || props.barWidth, barWidth: config.barWidth || props.barWidth,
...animationConfig ...animationConfig
} }
} }
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { const {
chartRef, chartRef,
getAxisLineStyle, getAxisLineStyle,
getAxisLabelStyle, getAxisLabelStyle,
getAxisTickStyle, getAxisTickStyle,
getSplitLineStyle, getSplitLineStyle,
getAnimationConfig, getAnimationConfig,
getTooltipStyle, getTooltipStyle,
getLegendStyle, getLegendStyle,
getGridWithLegend getGridWithLegend
} = useChartComponent({ } = useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
// 检查单数据情况 // 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'number') { if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
const singleData = props.data as number[] const singleData = props.data as number[]
return !singleData.length || singleData.every((val) => val === 0) return !singleData.length || singleData.every((val) => val === 0)
} }
// 检查多数据情况 // 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'object') { if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
const multiData = props.data as BarDataItem[] const multiData = props.data as BarDataItem[]
return ( return (
!multiData.length || !multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0)) multiData.every(
) (item) => !item.data?.length || item.data.every((val) => val === 0)
} )
)
}
return true return true
}, },
watchSources: [() => props.data, () => props.xAxisData, () => props.colors], watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
const options: EChartsOption = { const options: EChartsOption = {
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, { grid: getGridWithLegend(
top: 15, props.showLegend && isMultipleData.value,
right: 0, props.legendPosition,
left: 0 {
}), top: 15,
tooltip: props.showTooltip ? getTooltipStyle() : undefined, right: 0,
xAxis: { left: 0
type: 'category', }
data: props.xAxisData, ),
axisTick: getAxisTickStyle(), tooltip: props.showTooltip ? getTooltipStyle() : undefined,
axisLine: getAxisLineStyle(props.showAxisLine), xAxis: {
axisLabel: getAxisLabelStyle(props.showAxisLabel) type: 'category',
}, data: props.xAxisData,
yAxis: { axisTick: getAxisTickStyle(),
type: 'value', axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel), axisLabel: getAxisLabelStyle(props.showAxisLabel)
axisLine: getAxisLineStyle(props.showAxisLine), },
splitLine: getSplitLineStyle(props.showSplitLine) yAxis: {
} type: 'value',
} axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine)
}
}
// 添加图例配置 // 添加图例配置
if (props.showLegend && isMultipleData.value) { if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition) options.legend = getLegendStyle(props.legendPosition)
} }
// 生成系列数据 // 生成系列数据
if (isMultipleData.value) { if (isMultipleData.value) {
const multiData = props.data as BarDataItem[] const multiData = props.data as BarDataItem[]
options.series = multiData.map((item, index) => { options.series = multiData.map((item, index) => {
const computedColor = getColor(props.colors[index], index) const computedColor = getColor(props.colors[index], index)
return createSeriesItem({ return createSeriesItem({
name: item.name, name: item.name,
data: item.data, data: item.data,
color: computedColor, color: computedColor,
barWidth: item.barWidth, barWidth: item.barWidth,
stack: props.stack ? item.stack || 'total' : undefined stack: props.stack ? item.stack || 'total' : undefined
}) })
}) })
} else { } else {
// 单数据情况 // 单数据情况
const singleData = props.data as number[] const singleData = props.data as number[]
const computedColor = getColor() const computedColor = getColor()
options.series = [ options.series = [
createSeriesItem({ createSeriesItem({
data: singleData, data: singleData,
color: computedColor color: computedColor
}) })
] ]
} }
return options return options
} }
}) })
</script> </script>
@@ -1,195 +1,195 @@
<!-- 双向堆叠柱状图 --> <!-- 双向堆叠柱状图 -->
<template> <template>
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div> <div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts' import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
import type { BidirectionalBarChartProps } from '@/types/component/chart' import type { BidirectionalBarChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtDualBarCompareChart' }) defineOptions({ name: 'ArtDualBarCompareChart' })
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), { const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
positiveData: () => [], positiveData: () => [],
negativeData: () => [], negativeData: () => [],
xAxisData: () => [], xAxisData: () => [],
positiveName: '正向数据', positiveName: '正向数据',
negativeName: '负向数据', negativeName: '负向数据',
barWidth: 16, barWidth: 16,
yAxisMin: -100, yAxisMin: -100,
yAxisMax: 100, yAxisMax: 100,
// 样式配置 // 样式配置
showDataLabel: false, showDataLabel: false,
positiveBorderRadius: () => [10, 10, 0, 0], positiveBorderRadius: () => [10, 10, 0, 0],
negativeBorderRadius: () => [0, 0, 10, 10], negativeBorderRadius: () => [0, 0, 10, 10],
// 轴线显示配置 // 轴线显示配置
showAxisLabel: true, showAxisLabel: true,
showAxisLine: false, showAxisLine: false,
showSplitLine: false, showSplitLine: false,
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'bottom' legendPosition: 'bottom'
}) })
// 创建系列配置的辅助函数 // 创建系列配置的辅助函数
const createSeriesConfig = (config: { const createSeriesConfig = (config: {
name: string name: string
data: number[] data: number[]
borderRadius: number | number[] borderRadius: number | number[]
labelPosition: 'top' | 'bottom' labelPosition: 'top' | 'bottom'
colorIndex: number colorIndex: number
formatter?: (params: unknown) => string formatter?: (params: unknown) => string
}): BarSeriesOption => { }): BarSeriesOption => {
const { fontColor } = useChartOps() const { fontColor } = useChartOps()
const animationConfig = getAnimationConfig() const animationConfig = getAnimationConfig()
return { return {
name: config.name, name: config.name,
type: 'bar', type: 'bar',
stack: 'total', stack: 'total',
barWidth: props.barWidth, barWidth: props.barWidth,
barGap: '-100%', barGap: '-100%',
data: config.data, data: config.data,
itemStyle: { itemStyle: {
borderRadius: config.borderRadius, borderRadius: config.borderRadius,
color: props.colors[config.colorIndex] color: props.colors[config.colorIndex]
}, },
label: { label: {
show: props.showDataLabel, show: props.showDataLabel,
position: config.labelPosition, position: config.labelPosition,
formatter: formatter:
config.formatter || config.formatter ||
((params: unknown) => String((params as Record<string, unknown>).value)), ((params: unknown) => String((params as Record<string, unknown>).value)),
color: fontColor, color: fontColor,
fontSize: 12 fontSize: 12
}, },
...animationConfig ...animationConfig
} }
} }
// 使用图表组件抽象 // 使用图表组件抽象
const { const {
chartRef, chartRef,
getAxisLineStyle, getAxisLineStyle,
getAxisLabelStyle, getAxisLabelStyle,
getAxisTickStyle, getAxisTickStyle,
getSplitLineStyle, getSplitLineStyle,
getAnimationConfig, getAnimationConfig,
getTooltipStyle, getTooltipStyle,
getLegendStyle, getLegendStyle,
getGridWithLegend getGridWithLegend
} = useChartComponent({ } = useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
return ( return (
props.isEmpty || props.isEmpty ||
!props.positiveData.length || !props.positiveData.length ||
!props.negativeData.length || !props.negativeData.length ||
(props.positiveData.every((val) => val === 0) && (props.positiveData.every((val) => val === 0) &&
props.negativeData.every((val) => val === 0)) props.negativeData.every((val) => val === 0))
) )
}, },
watchSources: [ watchSources: [
() => props.positiveData, () => props.positiveData,
() => props.negativeData, () => props.negativeData,
() => props.xAxisData, () => props.xAxisData,
() => props.colors () => props.colors
], ],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
// 处理负向数据,确保为负值 // 处理负向数据,确保为负值
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val)) const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
// 优化的Grid配置 // 优化的Grid配置
const gridConfig = { const gridConfig = {
top: props.showLegend ? 50 : 20, top: props.showLegend ? 50 : 20,
right: 0, right: 0,
left: 0, left: 0,
bottom: 0, // 增加底部间距 bottom: 0, // 增加底部间距
containLabel: true containLabel: true
} }
const options: EChartsOption = { const options: EChartsOption = {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animation: true, animation: true,
animationDuration: 1000, animationDuration: 1000,
animationEasing: 'cubicOut', animationEasing: 'cubicOut',
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig), grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
// 优化的提示框配置 // 优化的提示框配置
tooltip: props.showTooltip tooltip: props.showTooltip
? { ? {
...getTooltipStyle(), ...getTooltipStyle(),
trigger: 'axis', trigger: 'axis',
axisPointer: { axisPointer: {
type: 'none' // 去除指示线 type: 'none' // 去除指示线
} }
} }
: undefined, : undefined,
// 图例配置 // 图例配置
legend: props.showLegend legend: props.showLegend
? { ? {
...getLegendStyle(props.legendPosition), ...getLegendStyle(props.legendPosition),
data: [props.negativeName, props.positiveName] data: [props.negativeName, props.positiveName]
} }
: undefined, : undefined,
// X轴配置 // X轴配置
xAxis: { xAxis: {
type: 'category', type: 'category',
data: props.xAxisData, data: props.xAxisData,
axisTick: getAxisTickStyle(), axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine), axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel), axisLabel: getAxisLabelStyle(props.showAxisLabel),
boundaryGap: true boundaryGap: true
}, },
// Y轴配置 // Y轴配置
yAxis: { yAxis: {
type: 'value', type: 'value',
min: props.yAxisMin, min: props.yAxisMin,
max: props.yAxisMax, max: props.yAxisMax,
axisLabel: getAxisLabelStyle(props.showAxisLabel), axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine), axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine) splitLine: getSplitLineStyle(props.showSplitLine)
}, },
// 系列配置 // 系列配置
series: [ series: [
// 负向数据系列 // 负向数据系列
createSeriesConfig({ createSeriesConfig({
name: props.negativeName, name: props.negativeName,
data: processedNegativeData, data: processedNegativeData,
borderRadius: props.negativeBorderRadius, borderRadius: props.negativeBorderRadius,
labelPosition: 'bottom', labelPosition: 'bottom',
colorIndex: 1, colorIndex: 1,
formatter: (params: unknown) => formatter: (params: unknown) =>
String(Math.abs((params as Record<string, unknown>).value as number)) String(Math.abs((params as Record<string, unknown>).value as number))
}), }),
// 正向数据系列 // 正向数据系列
createSeriesConfig({ createSeriesConfig({
name: props.positiveName, name: props.positiveName,
data: props.positiveData, data: props.positiveData,
borderRadius: props.positiveBorderRadius, borderRadius: props.positiveBorderRadius,
labelPosition: 'top', labelPosition: 'top',
colorIndex: 0 colorIndex: 0
}) })
] ]
} }
return options return options
} }
}) })
</script> </script>
@@ -1,208 +1,214 @@
<!-- 水平柱状图 --> <!-- 水平柱状图 -->
<template> <template>
<div <div
ref="chartRef" ref="chartRef"
class="relative w-full" class="relative w-full"
:style="{ height: props.height }" :style="{ height: props.height }"
v-loading="props.loading" v-loading="props.loading"
></div> ></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { getCssVar } from '@/utils/ui' import { getCssVar } from '@/utils/ui'
import { graphic, type EChartsOption } from '@/plugins/echarts' import { graphic, type EChartsOption } from '@/plugins/echarts'
import type { BarChartProps, BarDataItem } from '@/types/component/chart' import type { BarChartProps, BarDataItem } from '@/types/component/chart'
defineOptions({ name: 'ArtHBarChart' }) defineOptions({ name: 'ArtHBarChart' })
const props = withDefaults(defineProps<BarChartProps>(), { const props = withDefaults(defineProps<BarChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0], data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [], xAxisData: () => [],
barWidth: '36%', barWidth: '36%',
stack: false, stack: false,
// 轴线显示配置 // 轴线显示配置
showAxisLabel: true, showAxisLabel: true,
showAxisLine: true, showAxisLine: true,
showSplitLine: true, showSplitLine: true,
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'bottom' legendPosition: 'bottom'
}) })
// 判断是否为多数据 // 判断是否为多数据
const isMultipleData = computed(() => { const isMultipleData = computed(() => {
return ( return (
Array.isArray(props.data) && Array.isArray(props.data) &&
props.data.length > 0 && props.data.length > 0 &&
typeof props.data[0] === 'object' && typeof props.data[0] === 'object' &&
'name' in props.data[0] 'name' in props.data[0]
) )
}) })
// 获取颜色配置 // 获取颜色配置
const getColor = (customColor?: string, index?: number) => { const getColor = (customColor?: string, index?: number) => {
if (customColor) return customColor if (customColor) return customColor
if (index !== undefined) { if (index !== undefined) {
return props.colors![index % props.colors!.length] return props.colors![index % props.colors!.length]
} }
// 默认渐变色 // 默认渐变色
return new graphic.LinearGradient(0, 0, 1, 0, [ return new graphic.LinearGradient(0, 0, 1, 0, [
{ {
offset: 0, offset: 0,
color: getCssVar('--el-color-primary') color: getCssVar('--el-color-primary')
}, },
{ {
offset: 1, offset: 1,
color: getCssVar('--el-color-primary-light-4') color: getCssVar('--el-color-primary-light-4')
} }
]) ])
} }
// 创建渐变色 // 创建渐变色
const createGradientColor = (color: string) => { const createGradientColor = (color: string) => {
return new graphic.LinearGradient(0, 0, 1, 0, [ return new graphic.LinearGradient(0, 0, 1, 0, [
{ {
offset: 0, offset: 0,
color: color color: color
}, },
{ {
offset: 1, offset: 1,
color: color color: color
} }
]) ])
} }
// 获取基础样式配置 // 获取基础样式配置
const getBaseItemStyle = ( const getBaseItemStyle = (
color: string | InstanceType<typeof graphic.LinearGradient> | undefined color: string | InstanceType<typeof graphic.LinearGradient> | undefined
) => ({ ) => ({
borderRadius: 4, borderRadius: 4,
color: typeof color === 'string' ? createGradientColor(color) : color color: typeof color === 'string' ? createGradientColor(color) : color
}) })
// 创建系列配置 // 创建系列配置
const createSeriesItem = (config: { const createSeriesItem = (config: {
name?: string name?: string
data: number[] data: number[]
color?: string | InstanceType<typeof graphic.LinearGradient> color?: string | InstanceType<typeof graphic.LinearGradient>
barWidth?: string | number barWidth?: string | number
stack?: string stack?: string
}) => { }) => {
const animationConfig = getAnimationConfig() const animationConfig = getAnimationConfig()
return { return {
name: config.name, name: config.name,
data: config.data, data: config.data,
type: 'bar' as const, type: 'bar' as const,
stack: config.stack, stack: config.stack,
itemStyle: getBaseItemStyle(config.color), itemStyle: getBaseItemStyle(config.color),
barWidth: config.barWidth || props.barWidth, barWidth: config.barWidth || props.barWidth,
...animationConfig ...animationConfig
} }
} }
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { const {
chartRef, chartRef,
getAxisLineStyle, getAxisLineStyle,
getAxisLabelStyle, getAxisLabelStyle,
getAxisTickStyle, getAxisTickStyle,
getSplitLineStyle, getSplitLineStyle,
getAnimationConfig, getAnimationConfig,
getTooltipStyle, getTooltipStyle,
getLegendStyle, getLegendStyle,
getGridWithLegend getGridWithLegend
} = useChartComponent({ } = useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
// 检查单数据情况 // 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'number') { if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
const singleData = props.data as number[] const singleData = props.data as number[]
return !singleData.length || singleData.every((val) => val === 0) return !singleData.length || singleData.every((val) => val === 0)
} }
// 检查多数据情况 // 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'object') { if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
const multiData = props.data as BarDataItem[] const multiData = props.data as BarDataItem[]
return ( return (
!multiData.length || !multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0)) multiData.every(
) (item) => !item.data?.length || item.data.every((val) => val === 0)
} )
)
}
return true return true
}, },
watchSources: [() => props.data, () => props.xAxisData, () => props.colors], watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
const options: EChartsOption = { const options: EChartsOption = {
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, { grid: getGridWithLegend(
top: 15, props.showLegend && isMultipleData.value,
right: 0, props.legendPosition,
left: 0 {
}), top: 15,
tooltip: props.showTooltip ? getTooltipStyle() : undefined, right: 0,
xAxis: { left: 0
type: 'value', }
axisTick: getAxisTickStyle(), ),
axisLine: getAxisLineStyle(props.showAxisLine), tooltip: props.showTooltip ? getTooltipStyle() : undefined,
axisLabel: getAxisLabelStyle(props.showAxisLabel), xAxis: {
splitLine: getSplitLineStyle(props.showSplitLine) type: 'value',
}, axisTick: getAxisTickStyle(),
yAxis: { axisLine: getAxisLineStyle(props.showAxisLine),
type: 'category', axisLabel: getAxisLabelStyle(props.showAxisLabel),
data: props.xAxisData, splitLine: getSplitLineStyle(props.showSplitLine)
axisTick: getAxisTickStyle(), },
axisLabel: getAxisLabelStyle(props.showAxisLabel), yAxis: {
axisLine: getAxisLineStyle(props.showAxisLine) type: 'category',
} data: props.xAxisData,
} axisTick: getAxisTickStyle(),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine)
}
}
// 添加图例配置 // 添加图例配置
if (props.showLegend && isMultipleData.value) { if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition) options.legend = getLegendStyle(props.legendPosition)
} }
// 生成系列数据 // 生成系列数据
if (isMultipleData.value) { if (isMultipleData.value) {
const multiData = props.data as BarDataItem[] const multiData = props.data as BarDataItem[]
options.series = multiData.map((item, index) => { options.series = multiData.map((item, index) => {
const computedColor = getColor(props.colors[index], index) const computedColor = getColor(props.colors[index], index)
return createSeriesItem({ return createSeriesItem({
name: item.name, name: item.name,
data: item.data, data: item.data,
color: computedColor, color: computedColor,
barWidth: item.barWidth, barWidth: item.barWidth,
stack: props.stack ? item.stack || 'total' : undefined stack: props.stack ? item.stack || 'total' : undefined
}) })
}) })
} else { } else {
// 单数据情况 // 单数据情况
const singleData = props.data as number[] const singleData = props.data as number[]
const computedColor = getColor() const computedColor = getColor()
options.series = [ options.series = [
createSeriesItem({ createSeriesItem({
data: singleData, data: singleData,
color: computedColor color: computedColor
}) })
] ]
} }
return options return options
} }
}) })
</script> </script>
@@ -1,90 +1,91 @@
<!-- k线图表 --> <!-- k线图表 -->
<template> <template>
<div <div
ref="chartRef" ref="chartRef"
class="relative w-full" class="relative w-full"
:style="{ height: props.height }" :style="{ height: props.height }"
v-loading="props.loading" v-loading="props.loading"
></div> ></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts' import type { EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { KLineChartProps } from '@/types/component/chart' import type { KLineChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtKLineChart' }) defineOptions({ name: 'ArtKLineChart' })
const props = withDefaults(defineProps<KLineChartProps>(), { const props = withDefaults(defineProps<KLineChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
data: () => [], data: () => [],
showDataZoom: false, showDataZoom: false,
dataZoomStart: 0, dataZoomStart: 0,
dataZoomEnd: 100 dataZoomEnd: 100
}) })
// 获取实际使用的颜色 // 获取实际使用的颜色
const getActualColors = () => { const getActualColors = () => {
const defaultUpColor = '#4C87F3' const defaultUpColor = '#4C87F3'
const defaultDownColor = '#8BD8FC' const defaultDownColor = '#8BD8FC'
return { return {
upColor: props.colors?.[0] || defaultUpColor, upColor: props.colors?.[0] || defaultUpColor,
downColor: props.colors?.[1] || defaultDownColor downColor: props.colors?.[1] || defaultDownColor
} }
} }
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { const {
chartRef, chartRef,
getAxisLineStyle, getAxisLineStyle,
getAxisLabelStyle, getAxisLabelStyle,
getAxisTickStyle, getAxisTickStyle,
getSplitLineStyle, getSplitLineStyle,
getAnimationConfig, getAnimationConfig,
getTooltipStyle getTooltipStyle
} = useChartComponent({ } = useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
return ( return (
!props.data?.length || !props.data?.length ||
props.data.every( props.data.every(
(item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0 (item) =>
) item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
) )
}, )
watchSources: [ },
() => props.data, watchSources: [
() => props.colors, () => props.data,
() => props.showDataZoom, () => props.colors,
() => props.dataZoomStart, () => props.showDataZoom,
() => props.dataZoomEnd () => props.dataZoomStart,
], () => props.dataZoomEnd
generateOptions: (): EChartsOption => { ],
const { upColor, downColor } = getActualColors() generateOptions: (): EChartsOption => {
const { upColor, downColor } = getActualColors()
return { return {
grid: { grid: {
top: 20, top: 20,
right: 20, right: 20,
bottom: props.showDataZoom ? 80 : 20, bottom: props.showDataZoom ? 80 : 20,
left: 20, left: 20,
containLabel: true containLabel: true
}, },
tooltip: getTooltipStyle('axis', { tooltip: getTooltipStyle('axis', {
axisPointer: { axisPointer: {
type: 'cross' type: 'cross'
}, },
formatter: (params: Array<{ name: string; data: number[] }>) => { formatter: (params: Array<{ name: string; data: number[] }>) => {
const param = params[0] const param = params[0]
const data = param.data const data = param.data
return ` return `
<div style="padding: 5px;"> <div style="padding: 5px;">
<div><strong>时间:</strong>${param.name}</div> <div><strong>时间:</strong>${param.name}</div>
<div><strong>开盘:</strong>${data[0]}</div> <div><strong>开盘:</strong>${data[0]}</div>
@@ -93,60 +94,65 @@
<div><strong>最高:</strong>${data[3]}</div> <div><strong>最高:</strong>${data[3]}</div>
</div> </div>
` `
} }
}), }),
xAxis: { xAxis: {
type: 'category', type: 'category',
data: props.data.map((item) => item.time), data: props.data.map((item) => item.time),
axisTick: getAxisTickStyle(), axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(true), axisLine: getAxisLineStyle(true),
axisLabel: getAxisLabelStyle(true) axisLabel: getAxisLabelStyle(true)
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
scale: true, scale: true,
axisLabel: getAxisLabelStyle(true), axisLabel: getAxisLabelStyle(true),
axisLine: getAxisLineStyle(true), axisLine: getAxisLineStyle(true),
splitLine: getSplitLineStyle(true) splitLine: getSplitLineStyle(true)
}, },
series: [ series: [
{ {
type: 'candlestick', type: 'candlestick',
data: props.data.map((item) => [item.open, item.close, item.low, item.high]), data: props.data.map((item) => [
itemStyle: { item.open,
color: upColor, item.close,
color0: downColor, item.low,
borderColor: upColor, item.high
borderColor0: downColor, ]),
borderWidth: 1 itemStyle: {
}, color: upColor,
emphasis: { color0: downColor,
itemStyle: { borderColor: upColor,
borderWidth: 2, borderColor0: downColor,
shadowBlur: 10, borderWidth: 1
shadowColor: 'rgba(0, 0, 0, 0.3)' },
} emphasis: {
}, itemStyle: {
...getAnimationConfig() borderWidth: 2,
} shadowBlur: 10,
], shadowColor: 'rgba(0, 0, 0, 0.3)'
dataZoom: props.showDataZoom }
? [ },
{ ...getAnimationConfig()
type: 'inside', }
start: props.dataZoomStart, ],
end: props.dataZoomEnd dataZoom: props.showDataZoom
}, ? [
{ {
show: true, type: 'inside',
type: 'slider', start: props.dataZoomStart,
top: '90%', end: props.dataZoomEnd
start: props.dataZoomStart, },
end: props.dataZoomEnd {
} show: true,
] type: 'slider',
: undefined top: '90%',
} start: props.dataZoomStart,
} end: props.dataZoomEnd
}) }
]
: undefined
}
}
})
</script> </script>
@@ -1,371 +1,377 @@
<!-- 折线图支持多组数据支持阶梯式动画效果 --> <!-- 折线图支持多组数据支持阶梯式动画效果 -->
<template> <template>
<div <div
ref="chartRef" ref="chartRef"
class="relative w-[calc(100%+10px)]" class="relative w-[calc(100%+10px)]"
:style="{ height: props.height }" :style="{ height: props.height }"
v-loading="props.loading" v-loading="props.loading"
> >
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { graphic, type EChartsOption } from '@/plugins/echarts' import { graphic, type EChartsOption } from '@/plugins/echarts'
import { getCssVar, hexToRgba } from '@/utils/ui' import { getCssVar, hexToRgba } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { LineChartProps, LineDataItem } from '@/types/component/chart' import type { LineChartProps, LineDataItem } from '@/types/component/chart'
defineOptions({ name: 'ArtLineChart' }) defineOptions({ name: 'ArtLineChart' })
const props = withDefaults(defineProps<LineChartProps>(), { const props = withDefaults(defineProps<LineChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0], data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [], xAxisData: () => [],
lineWidth: 2.5, lineWidth: 2.5,
showAreaColor: false, showAreaColor: false,
smooth: true, smooth: true,
symbol: 'none', symbol: 'none',
symbolSize: 6, symbolSize: 6,
animationDelay: 200, animationDelay: 200,
// 轴线显示配置 // 轴线显示配置
showAxisLabel: true, showAxisLabel: true,
showAxisLine: true, showAxisLine: true,
showSplitLine: true, showSplitLine: true,
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'bottom' legendPosition: 'bottom'
}) })
// 动画状态管理 // 动画状态管理
const isAnimating = ref(false) const isAnimating = ref(false)
const animationTimers = ref<number[]>([]) const animationTimers = ref<number[]>([])
const animatedData = ref<number[] | LineDataItem[]>([]) const animatedData = ref<number[] | LineDataItem[]>([])
// 清理所有定时器 // 清理所有定时器
const clearAnimationTimers = () => { const clearAnimationTimers = () => {
animationTimers.value.forEach((timer) => clearTimeout(timer)) animationTimers.value.forEach((timer) => clearTimeout(timer))
animationTimers.value = [] animationTimers.value = []
} }
// 判断是否为多数据(使用 VueUse 的 computedEager 优化) // 判断是否为多数据(使用 VueUse 的 computedEager 优化)
const isMultipleData = computed(() => { const isMultipleData = computed(() => {
return ( return (
Array.isArray(props.data) && Array.isArray(props.data) &&
props.data.length > 0 && props.data.length > 0 &&
typeof props.data[0] === 'object' && typeof props.data[0] === 'object' &&
'name' in props.data[0] 'name' in props.data[0]
) )
}) })
// 缓存计算的最大值,避免重复计算 // 缓存计算的最大值,避免重复计算
const maxValue = computed(() => { const maxValue = computed(() => {
if (isMultipleData.value) { if (isMultipleData.value) {
const multiData = props.data as LineDataItem[] const multiData = props.data as LineDataItem[]
return multiData.reduce((max, item) => { return multiData.reduce((max, item) => {
if (item.data?.length) { if (item.data?.length) {
const itemMax = Math.max(...item.data) const itemMax = Math.max(...item.data)
return Math.max(max, itemMax) return Math.max(max, itemMax)
} }
return max return max
}, 0) }, 0)
} else { } else {
const singleData = props.data as number[] const singleData = props.data as number[]
return singleData?.length ? Math.max(...singleData) : 0 return singleData?.length ? Math.max(...singleData) : 0
} }
}) })
// 初始化动画数据(优化:减少条件判断) // 初始化动画数据(优化:减少条件判断)
const initAnimationData = (): number[] | LineDataItem[] => { const initAnimationData = (): number[] | LineDataItem[] => {
if (isMultipleData.value) { if (isMultipleData.value) {
const multiData = props.data as LineDataItem[] const multiData = props.data as LineDataItem[]
return multiData.map((item) => ({ return multiData.map((item) => ({
...item, ...item,
data: Array(item.data.length).fill(0) data: Array(item.data.length).fill(0)
})) }))
} }
const singleData = props.data as number[] const singleData = props.data as number[]
return Array(singleData.length).fill(0) return Array(singleData.length).fill(0)
} }
// 复制真实数据(优化:使用结构化克隆) // 复制真实数据(优化:使用结构化克隆)
const copyRealData = (): number[] | LineDataItem[] => { const copyRealData = (): number[] | LineDataItem[] => {
if (isMultipleData.value) { if (isMultipleData.value) {
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] })) return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
} }
return [...(props.data as number[])] return [...(props.data as number[])]
} }
// 获取颜色配置(优化:缓存主题色) // 获取颜色配置(优化:缓存主题色)
const primaryColor = computed(() => getCssVar('--el-color-primary')) const primaryColor = computed(() => getCssVar('--el-color-primary'))
const getColor = (customColor?: string, index?: number): string => { const getColor = (customColor?: string, index?: number): string => {
if (customColor) return customColor if (customColor) return customColor
if (index !== undefined) return props.colors![index % props.colors!.length] if (index !== undefined) return props.colors![index % props.colors!.length]
return primaryColor.value return primaryColor.value
} }
// 生成区域样式 // 生成区域样式
const generateAreaStyle = (item: LineDataItem, color: string) => { const generateAreaStyle = (item: LineDataItem, color: string) => {
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式 // 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
const areaConfig = item.areaStyle || {} const areaConfig = item.areaStyle || {}
if (areaConfig.custom) return areaConfig.custom if (areaConfig.custom) return areaConfig.custom
return { return {
color: new graphic.LinearGradient(0, 0, 0, 1, [ color: new graphic.LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
}, },
{ {
offset: 1, offset: 1,
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
} }
]) ])
} }
} }
// 生成单数据区域样式 // 生成单数据区域样式
const generateSingleAreaStyle = () => { const generateSingleAreaStyle = () => {
if (!props.showAreaColor) return undefined if (!props.showAreaColor) return undefined
const color = getColor(props.colors[0]) const color = getColor(props.colors[0])
return { return {
color: new graphic.LinearGradient(0, 0, 0, 1, [ color: new graphic.LinearGradient(0, 0, 0, 1, [
{ {
offset: 0, offset: 0,
color: hexToRgba(color, 0.2).rgba color: hexToRgba(color, 0.2).rgba
}, },
{ {
offset: 1, offset: 1,
color: hexToRgba(color, 0.02).rgba color: hexToRgba(color, 0.02).rgba
} }
]) ])
} }
} }
// 创建系列配置 // 创建系列配置
const createSeriesItem = (config: { const createSeriesItem = (config: {
name?: string name?: string
data: number[] data: number[]
color?: string color?: string
smooth?: boolean smooth?: boolean
symbol?: string symbol?: string
symbolSize?: number symbolSize?: number
lineWidth?: number lineWidth?: number
areaStyle?: any areaStyle?: any
}) => { }) => {
return { return {
name: config.name, name: config.name,
data: config.data, data: config.data,
type: 'line' as const, type: 'line' as const,
color: config.color, color: config.color,
smooth: config.smooth ?? props.smooth, smooth: config.smooth ?? props.smooth,
symbol: config.symbol ?? props.symbol, symbol: config.symbol ?? props.symbol,
symbolSize: config.symbolSize ?? props.symbolSize, symbolSize: config.symbolSize ?? props.symbolSize,
lineStyle: { lineStyle: {
width: config.lineWidth ?? props.lineWidth, width: config.lineWidth ?? props.lineWidth,
color: config.color color: config.color
}, },
areaStyle: config.areaStyle, areaStyle: config.areaStyle,
emphasis: { emphasis: {
focus: 'series' as const, focus: 'series' as const,
lineStyle: { lineStyle: {
width: (config.lineWidth ?? props.lineWidth) + 1 width: (config.lineWidth ?? props.lineWidth) + 1
} }
} }
} }
} }
// 生成图表配置 // 生成图表配置
const generateChartOptions = (isInitial = false): EChartsOption => { const generateChartOptions = (isInitial = false): EChartsOption => {
const options: EChartsOption = { const options: EChartsOption = {
animation: true, animation: true,
animationDuration: isInitial ? 0 : 1300, animationDuration: isInitial ? 0 : 1300,
animationDurationUpdate: isInitial ? 0 : 1300, animationDurationUpdate: isInitial ? 0 : 1300,
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, { grid: getGridWithLegend(
top: 15, props.showLegend && isMultipleData.value,
right: 15, props.legendPosition,
left: 0 {
}), top: 15,
tooltip: props.showTooltip ? getTooltipStyle() : undefined, right: 15,
xAxis: { left: 0
type: 'category', }
boundaryGap: false, ),
data: props.xAxisData, tooltip: props.showTooltip ? getTooltipStyle() : undefined,
axisTick: getAxisTickStyle(), xAxis: {
axisLine: getAxisLineStyle(props.showAxisLine), type: 'category',
axisLabel: getAxisLabelStyle(props.showAxisLabel) boundaryGap: false,
}, data: props.xAxisData,
yAxis: { axisTick: getAxisTickStyle(),
type: 'value', axisLine: getAxisLineStyle(props.showAxisLine),
min: 0, axisLabel: getAxisLabelStyle(props.showAxisLabel)
max: maxValue.value, },
axisLabel: getAxisLabelStyle(props.showAxisLabel), yAxis: {
axisLine: getAxisLineStyle(props.showAxisLine), type: 'value',
splitLine: getSplitLineStyle(props.showSplitLine) min: 0,
} max: maxValue.value,
} axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine)
}
}
// 添加图例配置 // 添加图例配置
if (props.showLegend && isMultipleData.value) { if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition) options.legend = getLegendStyle(props.legendPosition)
} }
// 生成系列数据 // 生成系列数据
if (isMultipleData.value) { if (isMultipleData.value) {
const multiData = animatedData.value as LineDataItem[] const multiData = animatedData.value as LineDataItem[]
options.series = multiData.map((item, index) => { options.series = multiData.map((item, index) => {
const itemColor = getColor(props.colors[index], index) const itemColor = getColor(props.colors[index], index)
const areaStyle = generateAreaStyle(item, itemColor) const areaStyle = generateAreaStyle(item, itemColor)
return createSeriesItem({ return createSeriesItem({
name: item.name, name: item.name,
data: item.data, data: item.data,
color: itemColor, color: itemColor,
smooth: item.smooth, smooth: item.smooth,
symbol: item.symbol, symbol: item.symbol,
lineWidth: item.lineWidth, lineWidth: item.lineWidth,
areaStyle areaStyle
}) })
}) })
} else { } else {
// 单数据情况 // 单数据情况
const singleData = animatedData.value as number[] const singleData = animatedData.value as number[]
const computedColor = getColor(props.colors[0]) const computedColor = getColor(props.colors[0])
const areaStyle = generateSingleAreaStyle() const areaStyle = generateSingleAreaStyle()
options.series = [ options.series = [
createSeriesItem({ createSeriesItem({
data: singleData, data: singleData,
color: computedColor, color: computedColor,
areaStyle areaStyle
}) })
] ]
} }
return options return options
} }
// 更新图表 // 更新图表
const updateChartOptions = (options: EChartsOption) => { const updateChartOptions = (options: EChartsOption) => {
initChart(options) initChart(options)
} }
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险) // 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
const initChartWithAnimation = () => { const initChartWithAnimation = () => {
clearAnimationTimers() clearAnimationTimers()
isAnimating.value = true isAnimating.value = true
// 初始化为0值数据 // 初始化为0值数据
animatedData.value = initAnimationData() animatedData.value = initAnimationData()
updateChartOptions(generateChartOptions(true)) updateChartOptions(generateChartOptions(true))
if (isMultipleData.value) { if (isMultipleData.value) {
// 多数据阶梯式动画 // 多数据阶梯式动画
const multiData = props.data as LineDataItem[] const multiData = props.data as LineDataItem[]
const currentAnimatedData = animatedData.value as LineDataItem[] const currentAnimatedData = animatedData.value as LineDataItem[]
multiData.forEach((item, index) => { multiData.forEach((item, index) => {
const timer = window.setTimeout( const timer = window.setTimeout(
() => { () => {
currentAnimatedData[index] = { ...item, data: [...item.data] } currentAnimatedData[index] = { ...item, data: [...item.data] }
animatedData.value = [...currentAnimatedData] animatedData.value = [...currentAnimatedData]
updateChartOptions(generateChartOptions(false)) updateChartOptions(generateChartOptions(false))
}, },
index * props.animationDelay + 100 index * props.animationDelay + 100
) )
animationTimers.value.push(timer) animationTimers.value.push(timer)
}) })
// 标记动画完成 // 标记动画完成
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500 const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
const finishTimer = window.setTimeout(() => { const finishTimer = window.setTimeout(() => {
isAnimating.value = false isAnimating.value = false
}, totalDelay) }, totalDelay)
animationTimers.value.push(finishTimer) animationTimers.value.push(finishTimer)
} else { } else {
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染 // 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
nextTick(() => { nextTick(() => {
animatedData.value = copyRealData() animatedData.value = copyRealData()
updateChartOptions(generateChartOptions(false)) updateChartOptions(generateChartOptions(false))
isAnimating.value = false isAnimating.value = false
}) })
} }
} }
// 空数据检查函数 // 空数据检查函数
const checkIsEmpty = () => { const checkIsEmpty = () => {
// 检查单数据情况 // 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'number') { if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
const singleData = props.data as number[] const singleData = props.data as number[]
return !singleData.length || singleData.every((val) => val === 0) return !singleData.length || singleData.every((val) => val === 0)
} }
// 检查多数据情况 // 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'object') { if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
const multiData = props.data as LineDataItem[] const multiData = props.data as LineDataItem[]
return ( return (
!multiData.length || !multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0)) multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
) )
} }
return true return true
} }
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { const {
chartRef, chartRef,
initChart, initChart,
getAxisLineStyle, getAxisLineStyle,
getAxisLabelStyle, getAxisLabelStyle,
getAxisTickStyle, getAxisTickStyle,
getSplitLineStyle, getSplitLineStyle,
getTooltipStyle, getTooltipStyle,
getLegendStyle, getLegendStyle,
getGridWithLegend, getGridWithLegend,
isEmpty isEmpty
} = useChartComponent({ } = useChartComponent({
props, props,
checkEmpty: checkIsEmpty, checkEmpty: checkIsEmpty,
watchSources: [() => props.data, () => props.xAxisData, () => props.colors], watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
onVisible: () => { onVisible: () => {
// 当图表变为可见时,检查是否为空数据 // 当图表变为可见时,检查是否为空数据
if (!isEmpty.value) { if (!isEmpty.value) {
initChartWithAnimation() initChartWithAnimation()
} }
}, },
generateOptions: () => generateChartOptions(false) generateOptions: () => generateChartOptions(false)
}) })
// 图表渲染函数(优化:防止动画期间重复触发) // 图表渲染函数(优化:防止动画期间重复触发)
const renderChart = () => { const renderChart = () => {
if (!isAnimating.value && !isEmpty.value) { if (!isAnimating.value && !isEmpty.value) {
initChartWithAnimation() initChartWithAnimation()
} }
} }
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新) // 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true }) watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, {
deep: true
})
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
renderChart() renderChart()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearAnimationTimers() clearAnimationTimers()
}) })
</script> </script>
@@ -1,105 +1,108 @@
<!-- 雷达图 --> <!-- 雷达图 -->
<template> <template>
<div <div
ref="chartRef" ref="chartRef"
class="relative w-full" class="relative w-full"
:style="{ height: props.height }" :style="{ height: props.height }"
v-loading="props.loading" v-loading="props.loading"
></div> ></div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts' import type { EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { RadarChartProps } from '@/types/component/chart' import type { RadarChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtRadarChart' }) defineOptions({ name: 'ArtRadarChart' })
const props = withDefaults(defineProps<RadarChartProps>(), { const props = withDefaults(defineProps<RadarChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
indicator: () => [], indicator: () => [],
data: () => [], data: () => [],
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'bottom' legendPosition: 'bottom'
}) })
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({ const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0)) return (
}, !props.data?.length ||
watchSources: [() => props.data, () => props.indicator, () => props.colors], props.data.every((item) => item.value.every((val) => val === 0))
generateOptions: (): EChartsOption => { )
return { },
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined, watchSources: [() => props.data, () => props.indicator, () => props.colors],
radar: { generateOptions: (): EChartsOption => {
indicator: props.indicator, return {
center: ['50%', '50%'], tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
radius: '70%', radar: {
axisName: { indicator: props.indicator,
color: isDark.value ? '#ccc' : '#666', center: ['50%', '50%'],
fontSize: 12 radius: '70%',
}, axisName: {
splitLine: { color: isDark.value ? '#ccc' : '#666',
lineStyle: { fontSize: 12
color: isDark.value ? '#444' : '#e6e6e6' },
} splitLine: {
}, lineStyle: {
axisLine: { color: isDark.value ? '#444' : '#e6e6e6'
lineStyle: { }
color: isDark.value ? '#444' : '#e6e6e6' },
} axisLine: {
}, lineStyle: {
splitArea: { color: isDark.value ? '#444' : '#e6e6e6'
show: true, }
areaStyle: { },
color: isDark.value splitArea: {
? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)'] show: true,
: ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)'] areaStyle: {
} color: isDark.value
} ? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
}, : ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)']
series: [ }
{ }
type: 'radar', },
data: props.data.map((item, index) => ({ series: [
name: item.name, {
value: item.value, type: 'radar',
symbolSize: 4, data: props.data.map((item, index) => ({
lineStyle: { name: item.name,
width: 2, value: item.value,
color: props.colors[index % props.colors.length] symbolSize: 4,
}, lineStyle: {
itemStyle: { width: 2,
color: props.colors[index % props.colors.length] color: props.colors[index % props.colors.length]
}, },
areaStyle: { itemStyle: {
color: props.colors[index % props.colors.length], color: props.colors[index % props.colors.length]
opacity: 0.1 },
}, areaStyle: {
emphasis: { color: props.colors[index % props.colors.length],
areaStyle: { opacity: 0.1
opacity: 0.25 },
}, emphasis: {
lineStyle: { areaStyle: {
width: 3 opacity: 0.25
} },
} lineStyle: {
})), width: 3
...getAnimationConfig(200, 1800) }
} }
] })),
} ...getAnimationConfig(200, 1800)
} }
}) ]
}
}
})
</script> </script>
@@ -1,133 +1,133 @@
<!-- 环形图 --> <!-- 环形图 -->
<template> <template>
<div <div
ref="chartRef" ref="chartRef"
class="relative w-full" class="relative w-full"
:style="{ height: props.height }" :style="{ height: props.height }"
v-loading="props.loading" v-loading="props.loading"
> >
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts' import type { EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { RingChartProps } from '@/types/component/chart' import type { RingChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtRingChart' }) defineOptions({ name: 'ArtRingChart' })
const props = withDefaults(defineProps<RingChartProps>(), { const props = withDefaults(defineProps<RingChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
data: () => [], data: () => [],
radius: () => ['50%', '80%'], radius: () => ['50%', '80%'],
borderRadius: 10, borderRadius: 10,
centerText: '', centerText: '',
showLabel: false, showLabel: false,
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'right' legendPosition: 'right'
}) })
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } = const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
useChartComponent({ useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value === 0) return !props.data?.length || props.data.every((item) => item.value === 0)
}, },
watchSources: [() => props.data, () => props.centerText], watchSources: [() => props.data, () => props.centerText],
generateOptions: (): EChartsOption => { generateOptions: (): EChartsOption => {
// 根据图例位置计算环形图中心位置 // 根据图例位置计算环形图中心位置
const getCenterPosition = (): [string, string] => { const getCenterPosition = (): [string, string] => {
if (!props.showLegend) return ['50%', '50%'] if (!props.showLegend) return ['50%', '50%']
switch (props.legendPosition) { switch (props.legendPosition) {
case 'left': case 'left':
return ['60%', '50%'] return ['60%', '50%']
case 'right': case 'right':
return ['40%', '50%'] return ['40%', '50%']
case 'top': case 'top':
return ['50%', '60%'] return ['50%', '60%']
case 'bottom': case 'bottom':
return ['50%', '40%'] return ['50%', '40%']
default: default:
return ['50%', '50%'] return ['50%', '50%']
} }
} }
const option: EChartsOption = { const option: EChartsOption = {
tooltip: props.showTooltip tooltip: props.showTooltip
? getTooltipStyle('item', { ? getTooltipStyle('item', {
formatter: '{b}: {c} ({d}%)' formatter: '{b}: {c} ({d}%)'
}) })
: undefined, : undefined,
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined, legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
series: [ series: [
{ {
name: '数据占比', name: '数据占比',
type: 'pie', type: 'pie',
radius: props.radius, radius: props.radius,
center: getCenterPosition(), center: getCenterPosition(),
avoidLabelOverlap: false, avoidLabelOverlap: false,
itemStyle: { itemStyle: {
borderRadius: props.borderRadius, borderRadius: props.borderRadius,
borderColor: isDark.value ? '#2c2c2c' : '#fff', borderColor: isDark.value ? '#2c2c2c' : '#fff',
borderWidth: 0 borderWidth: 0
}, },
label: { label: {
show: props.showLabel, show: props.showLabel,
formatter: '{b}\n{d}%', formatter: '{b}\n{d}%',
position: 'outside', position: 'outside',
color: isDark.value ? '#ccc' : '#999', color: isDark.value ? '#ccc' : '#999',
fontSize: 12 fontSize: 12
}, },
emphasis: { emphasis: {
label: { label: {
show: false, show: false,
fontSize: 14, fontSize: 14,
fontWeight: 'bold' fontWeight: 'bold'
} }
}, },
labelLine: { labelLine: {
show: props.showLabel, show: props.showLabel,
length: 15, length: 15,
length2: 25, length2: 25,
smooth: true smooth: true
}, },
data: props.data, data: props.data,
color: props.colors, color: props.colors,
...getAnimationConfig(), ...getAnimationConfig(),
animationType: 'expansion' animationType: 'expansion'
} }
] ]
} }
// 添加中心文字 // 添加中心文字
if (props.centerText) { if (props.centerText) {
const centerPos = getCenterPosition() const centerPos = getCenterPosition()
option.title = { option.title = {
text: props.centerText, text: props.centerText,
left: centerPos[0], left: centerPos[0],
top: centerPos[1], top: centerPos[1],
textAlign: 'center', textAlign: 'center',
textVerticalAlign: 'middle', textVerticalAlign: 'middle',
textStyle: { textStyle: {
fontSize: 18, fontSize: 18,
fontWeight: 500, fontWeight: 500,
color: isDark.value ? '#999' : '#ADB0BC' color: isDark.value ? '#999' : '#ADB0BC'
} }
} }
} }
return option return option
} }
}) })
</script> </script>
@@ -1,115 +1,122 @@
<!-- 散点图 --> <!-- 散点图 -->
<template> <template>
<div <div
ref="chartRef" ref="chartRef"
class="relative w-full" class="relative w-full"
:style="{ height: props.height }" :style="{ height: props.height }"
v-loading="props.loading" v-loading="props.loading"
> >
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts' import type { EChartsOption } from '@/plugins/echarts'
import { getCssVar } from '@/utils/ui' import { getCssVar } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart' import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { ScatterChartProps } from '@/types/component/chart' import type { ScatterChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtScatterChart' }) defineOptions({ name: 'ArtScatterChart' })
const props = withDefaults(defineProps<ScatterChartProps>(), { const props = withDefaults(defineProps<ScatterChartProps>(), {
// 基础配置 // 基础配置
height: useChartOps().chartHeight, height: useChartOps().chartHeight,
loading: false, loading: false,
isEmpty: false, isEmpty: false,
colors: () => useChartOps().colors, colors: () => useChartOps().colors,
// 数据配置 // 数据配置
data: () => [{ value: [0, 0] }, { value: [0, 0] }], data: () => [{ value: [0, 0] }, { value: [0, 0] }],
symbolSize: 14, symbolSize: 14,
// 轴线显示配置 // 轴线显示配置
showAxisLabel: true, showAxisLabel: true,
showAxisLine: true, showAxisLine: true,
showSplitLine: true, showSplitLine: true,
// 交互配置 // 交互配置
showTooltip: true, showTooltip: true,
showLegend: false, showLegend: false,
legendPosition: 'bottom' legendPosition: 'bottom'
}) })
// 使用新的图表组件抽象 // 使用新的图表组件抽象
const { const {
chartRef, chartRef,
isDark, isDark,
getAxisLineStyle, getAxisLineStyle,
getAxisLabelStyle, getAxisLabelStyle,
getAxisTickStyle, getAxisTickStyle,
getSplitLineStyle, getSplitLineStyle,
getAnimationConfig, getAnimationConfig,
getTooltipStyle getTooltipStyle
} = useChartComponent({ } = useChartComponent({
props, props,
checkEmpty: () => { checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0)) return (
}, !props.data?.length ||
watchSources: [() => props.data, () => props.colors, () => props.symbolSize], props.data.every((item) => item.value.every((val) => val === 0))
generateOptions: (): EChartsOption => { )
const computedColor = props.colors[0] || getCssVar('--el-color-primary') },
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
generateOptions: (): EChartsOption => {
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
return { return {
grid: { grid: {
top: 20, top: 20,
right: 20, right: 20,
bottom: 20, bottom: 20,
left: 20, left: 20,
containLabel: true containLabel: true
}, },
tooltip: props.showTooltip tooltip: props.showTooltip
? getTooltipStyle('item', { ? getTooltipStyle('item', {
formatter: (params: { value: [number, number] }) => { formatter: (params: { value: [number, number] }) => {
const [x, y] = params.value const [x, y] = params.value
return `X: ${x}<br/>Y: ${y}` return `X: ${x}<br/>Y: ${y}`
} }
}) })
: undefined, : undefined,
xAxis: { xAxis: {
type: 'value', type: 'value',
axisLabel: getAxisLabelStyle(props.showAxisLabel), axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine), axisLine: getAxisLineStyle(props.showAxisLine),
axisTick: getAxisTickStyle(), axisTick: getAxisTickStyle(),
splitLine: getSplitLineStyle(props.showSplitLine) splitLine: getSplitLineStyle(props.showSplitLine)
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
axisLabel: getAxisLabelStyle(props.showAxisLabel), axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine), axisLine: getAxisLineStyle(props.showAxisLine),
axisTick: getAxisTickStyle(), axisTick: getAxisTickStyle(),
splitLine: getSplitLineStyle(props.showSplitLine) splitLine: getSplitLineStyle(props.showSplitLine)
}, },
series: [ series: [
{ {
type: 'scatter', type: 'scatter',
data: props.data, data: props.data,
symbolSize: props.symbolSize, symbolSize: props.symbolSize,
itemStyle: { itemStyle: {
color: computedColor, color: computedColor,
shadowBlur: 6, shadowBlur: 6,
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)', shadowColor: isDark.value
shadowOffsetY: 2 ? 'rgba(255, 255, 255, 0.1)'
}, : 'rgba(0, 0, 0, 0.1)',
emphasis: { shadowOffsetY: 2
itemStyle: { },
shadowBlur: 12, emphasis: {
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)' itemStyle: {
}, shadowBlur: 12,
scale: true shadowColor: isDark.value
}, ? 'rgba(255, 255, 255, 0.2)'
...getAnimationConfig() : 'rgba(0, 0, 0, 0.2)'
} },
] scale: true
} },
} ...getAnimationConfig()
}) }
]
}
}
})
</script> </script>
@@ -1,71 +1,74 @@
<!-- 更多按钮 --> <!-- 更多按钮 -->
<template> <template>
<div> <div>
<ElDropdown v-if="hasAnyAuthItem"> <ElDropdown v-if="hasAnyAuthItem">
<ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" /> <ArtIconButton
<template #dropdown> icon="ri:more-2-fill"
<ElDropdownMenu> class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm"
<template v-for="item in list" :key="item.key"> />
<ElDropdownItem <template #dropdown>
v-if="!item.auth || hasAuth(item.auth)" <ElDropdownMenu>
:disabled="item.disabled" <template v-for="item in list" :key="item.key">
@click="handleClick(item)" <ElDropdownItem
> v-if="!item.auth || hasAuth(item.auth)"
<div class="flex-c gap-2" :style="{ color: item.color }"> :disabled="item.disabled"
<ArtSvgIcon v-if="item.icon" :icon="item.icon" /> @click="handleClick(item)"
<span>{{ item.label }}</span> >
</div> <div class="flex-c gap-2" :style="{ color: item.color }">
</ElDropdownItem> <ArtSvgIcon v-if="item.icon" :icon="item.icon" />
</template> <span>{{ item.label }}</span>
</ElDropdownMenu> </div>
</template> </ElDropdownItem>
</ElDropdown> </template>
</div> </ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useAuth } from '@/hooks/core/useAuth' import { useAuth } from '@/hooks/core/useAuth'
defineOptions({ name: 'ArtButtonMore' }) defineOptions({ name: 'ArtButtonMore' })
const { hasAuth } = useAuth() const { hasAuth } = useAuth()
export interface ButtonMoreItem { export interface ButtonMoreItem {
/** 按钮标识,可用于点击事件 */ /** 按钮标识,可用于点击事件 */
key: string | number key: string | number
/** 按钮文本 */ /** 按钮文本 */
label: string label: string
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean disabled?: boolean
/** 权限标识 */ /** 权限标识 */
auth?: string auth?: string
/** 图标组件 */ /** 图标组件 */
icon?: string icon?: string
/** 文本颜色 */ /** 文本颜色 */
color?: string color?: string
/** 图标颜色(优先级高于 color) */ /** 图标颜色(优先级高于 color) */
iconColor?: string iconColor?: string
} }
interface Props { interface Props {
/** 下拉项列表 */ /** 下拉项列表 */
list: ButtonMoreItem[] list: ButtonMoreItem[]
/** 整体权限控制 */ /** 整体权限控制 */
auth?: string auth?: string
} }
const props = withDefaults(defineProps<Props>(), {}) const props = withDefaults(defineProps<Props>(), {})
// 检查是否有任何有权限的 item // 检查是否有任何有权限的 item
const hasAnyAuthItem = computed(() => { const hasAnyAuthItem = computed(() => {
return props.list.some((item) => !item.auth || hasAuth(item.auth)) return props.list.some((item) => !item.auth || hasAuth(item.auth))
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click', item: ButtonMoreItem): void (e: 'click', item: ButtonMoreItem): void
}>() }>()
const handleClick = (item: ButtonMoreItem) => { const handleClick = (item: ButtonMoreItem) => {
emit('click', item) emit('click', item)
} }
</script> </script>
@@ -1,59 +1,59 @@
<!-- 表格按钮 --> <!-- 表格按钮 -->
<template> <template>
<div <div
:class="[ :class="[
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle', 'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
buttonClass buttonClass
]" ]"
:style="{ backgroundColor: buttonBgColor, color: iconColor }" :style="{ backgroundColor: buttonBgColor, color: iconColor }"
@click="handleClick" @click="handleClick"
> >
<ArtSvgIcon :icon="iconContent" /> <ArtSvgIcon :icon="iconContent" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtButtonTable' }) defineOptions({ name: 'ArtButtonTable' })
interface Props { interface Props {
/** 按钮类型 */ /** 按钮类型 */
type?: 'add' | 'edit' | 'delete' | 'more' | 'view' type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
/** 按钮图标 */ /** 按钮图标 */
icon?: string icon?: string
/** 按钮样式类 */ /** 按钮样式类 */
iconClass?: string iconClass?: string
/** icon 颜色 */ /** icon 颜色 */
iconColor?: string iconColor?: string
/** 按钮背景色 */ /** 按钮背景色 */
buttonBgColor?: string buttonBgColor?: string
} }
const props = withDefaults(defineProps<Props>(), {}) const props = withDefaults(defineProps<Props>(), {})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'click'): void (e: 'click'): void
}>() }>()
// 默认按钮配置 // 默认按钮配置
const defaultButtons = { const defaultButtons = {
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' }, add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' }, edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' }, delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' }, view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
more: { icon: 'ri:more-2-fill', class: '' } more: { icon: 'ri:more-2-fill', class: '' }
} as const } as const
// 获取图标内容 // 获取图标内容
const iconContent = computed(() => { const iconContent = computed(() => {
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || '' return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
}) })
// 获取按钮样式类 // 获取按钮样式类
const buttonClass = computed(() => { const buttonClass = computed(() => {
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || '' return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
}) })
const handleClick = () => { const handleClick = () => {
emit('click') emit('click')
} }
</script> </script>
@@ -1,430 +1,431 @@
<!-- 拖拽验证组件 --> <!-- 拖拽验证组件 -->
<template> <template>
<div <div
ref="dragVerify" ref="dragVerify"
class="drag_verify" class="drag_verify"
:style="dragVerifyStyle" :style="dragVerifyStyle"
@mousemove="dragMoving" @mousemove="dragMoving"
@mouseup="dragFinish" @mouseup="dragFinish"
@mouseleave="dragFinish" @mouseleave="dragFinish"
@touchmove="dragMoving" @touchmove="dragMoving"
@touchend="dragFinish" @touchend="dragFinish"
> >
<!-- 进度条 --> <!-- 进度条 -->
<div <div
class="dv_progress_bar" class="dv_progress_bar"
:class="{ goFirst2: isOk }" :class="{ goFirst2: isOk }"
ref="progressBar" ref="progressBar"
:style="progressBarStyle" :style="progressBarStyle"
> >
</div> </div>
<!-- 提示文本 --> <!-- 提示文本 -->
<div class="dv_text" :style="textStyle" ref="messageRef"> <div class="dv_text" :style="textStyle" ref="messageRef">
<slot name="textBefore" v-if="$slots.textBefore"></slot> <slot name="textBefore" v-if="$slots.textBefore"></slot>
{{ message }} {{ message }}
<slot name="textAfter" v-if="$slots.textAfter"></slot> <slot name="textAfter" v-if="$slots.textAfter"></slot>
</div> </div>
<!-- 滑块处理器 --> <!-- 滑块处理器 -->
<div <div
class="dv_handler dv_handler_bg" class="dv_handler dv_handler_bg"
:class="{ goFirst: isOk }" :class="{ goFirst: isOk }"
@mousedown="dragStart" @mousedown="dragStart"
@touchstart="dragStart" @touchstart="dragStart"
ref="handler" ref="handler"
:style="handlerStyle" :style="handlerStyle"
> >
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon> <ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineOptions({ name: 'ArtDragVerify' }) defineOptions({ name: 'ArtDragVerify' })
// 事件定义 // 事件定义
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback']) const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
// 组件属性接口定义 // 组件属性接口定义
interface PropsType { interface PropsType {
/** 是否通过验证 */ /** 是否通过验证 */
value: boolean value: boolean
/** 组件宽度 */ /** 组件宽度 */
width?: number | string width?: number | string
/** 组件高度 */ /** 组件高度 */
height?: number height?: number
/** 默认提示文本 */ /** 默认提示文本 */
text?: string text?: string
/** 成功提示文本 */ /** 成功提示文本 */
successText?: string successText?: string
/** 背景色 */ /** 背景色 */
background?: string background?: string
/** 进度条背景色 */ /** 进度条背景色 */
progressBarBg?: string progressBarBg?: string
/** 完成状态背景色 */ /** 完成状态背景色 */
completedBg?: string completedBg?: string
/** 是否圆角 */ /** 是否圆角 */
circle?: boolean circle?: boolean
/** 圆角大小 */ /** 圆角大小 */
radius?: string radius?: string
/** 滑块图标 */ /** 滑块图标 */
handlerIcon?: string handlerIcon?: string
/** 成功图标 */ /** 成功图标 */
successIcon?: string successIcon?: string
/** 滑块背景色 */ /** 滑块背景色 */
handlerBg?: string handlerBg?: string
/** 文本大小 */ /** 文本大小 */
textSize?: string textSize?: string
/** 文本颜色 */ /** 文本颜色 */
textColor?: string textColor?: string
} }
// 属性默认值设置 // 属性默认值设置
const props = withDefaults(defineProps<PropsType>(), { const props = withDefaults(defineProps<PropsType>(), {
value: false, value: false,
width: '100%', width: '100%',
height: 40, height: 40,
text: '按住滑块拖动', text: '按住滑块拖动',
successText: 'success', successText: 'success',
background: '#eee', background: '#eee',
progressBarBg: '#1385FF', progressBarBg: '#1385FF',
completedBg: '#57D187', completedBg: '#57D187',
circle: false, circle: false,
radius: 'calc(var(--custom-radius) / 3 + 2px)', radius: 'calc(var(--custom-radius) / 3 + 2px)',
handlerIcon: 'solar:double-alt-arrow-right-linear', handlerIcon: 'solar:double-alt-arrow-right-linear',
successIcon: 'ri:check-fill', successIcon: 'ri:check-fill',
handlerBg: '#fff', handlerBg: '#fff',
textSize: '13px', textSize: '13px',
textColor: '#333' textColor: '#333'
}) })
// 组件状态接口定义 // 组件状态接口定义
interface StateType { interface StateType {
isMoving: boolean // 是否正在拖拽 isMoving: boolean // 是否正在拖拽
x: number // 拖拽起始位置 x: number // 拖拽起始位置
isOk: boolean // 是否验证成功 isOk: boolean // 是否验证成功
} }
// 响应式状态定义 // 响应式状态定义
const state = reactive(<StateType>{ const state = reactive(<StateType>{
isMoving: false, isMoving: false,
x: 0, x: 0,
isOk: false isOk: false
}) })
// 解构响应式状态 // 解构响应式状态
const { isOk } = toRefs(state) const { isOk } = toRefs(state)
// DOM 元素引用 // DOM 元素引用
const dragVerify = ref() const dragVerify = ref()
const messageRef = ref() const messageRef = ref()
const handler = ref() const handler = ref()
const progressBar = ref() const progressBar = ref()
// 触摸事件变量 - 用于禁止页面滑动 // 触摸事件变量 - 用于禁止页面滑动
let startX: number, startY: number, moveX: number, moveY: number let startX: number, startY: number, moveX: number, moveY: number
/** /**
* 触摸开始事件处理 * 触摸开始事件处理
* @param e 触摸事件对象 * @param e 触摸事件对象
*/ */
const onTouchStart = (e: any) => { const onTouchStart = (e: any) => {
startX = e.targetTouches[0].pageX startX = e.targetTouches[0].pageX
startY = e.targetTouches[0].pageY startY = e.targetTouches[0].pageY
} }
/** /**
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为 * 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
* @param e 触摸事件对象 * @param e 触摸事件对象
*/ */
const onTouchMove = (e: any) => { const onTouchMove = (e: any) => {
moveX = e.targetTouches[0].pageX moveX = e.targetTouches[0].pageX
moveY = e.targetTouches[0].pageY moveY = e.targetTouches[0].pageY
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动) // 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) { if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
e.preventDefault() e.preventDefault()
} }
} }
// 全局事件监听器添加 // 全局事件监听器添加
document.addEventListener('touchstart', onTouchStart) document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchmove', onTouchMove, { passive: false }) document.addEventListener('touchmove', onTouchMove, { passive: false })
// 获取数值形式的宽度 // 获取数值形式的宽度
const getNumericWidth = (): number => { const getNumericWidth = (): number => {
if (typeof props.width === 'string') { if (typeof props.width === 'string') {
// 如果是字符串,尝试从DOM元素获取实际宽度 // 如果是字符串,尝试从DOM元素获取实际宽度
return dragVerify.value?.offsetWidth || 260 return dragVerify.value?.offsetWidth || 260
} }
return props.width return props.width
} }
// 获取样式字符串形式的宽度 // 获取样式字符串形式的宽度
const getStyleWidth = (): string => { const getStyleWidth = (): string => {
if (typeof props.width === 'string') { if (typeof props.width === 'string') {
return props.width return props.width
} }
return props.width + 'px' return props.width + 'px'
} }
// 组件挂载后的初始化 // 组件挂载后的初始化
onMounted(() => { onMounted(() => {
// 设置 CSS 自定义属性 // 设置 CSS 自定义属性
dragVerify.value?.style.setProperty('--textColor', props.textColor) dragVerify.value?.style.setProperty('--textColor', props.textColor)
// 等待DOM更新后设置宽度相关属性 // 等待DOM更新后设置宽度相关属性
nextTick(() => { nextTick(() => {
const numericWidth = getNumericWidth() const numericWidth = getNumericWidth()
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px') dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px') dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
}) })
// 重复添加事件监听器(确保事件绑定) // 重复添加事件监听器(确保事件绑定)
document.addEventListener('touchstart', onTouchStart) document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchmove', onTouchMove, { passive: false }) document.addEventListener('touchmove', onTouchMove, { passive: false })
}) })
// 组件卸载前清理事件监听器 // 组件卸载前清理事件监听器
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.removeEventListener('touchstart', onTouchStart) document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove) document.removeEventListener('touchmove', onTouchMove)
}) })
// 滑块样式计算 // 滑块样式计算
const handlerStyle = { const handlerStyle = {
left: '0', left: '0',
width: props.height + 'px', width: props.height + 'px',
height: props.height + 'px', height: props.height + 'px',
background: props.handlerBg background: props.handlerBg
} }
// 主容器样式计算 // 主容器样式计算
const dragVerifyStyle = computed(() => ({ const dragVerifyStyle = computed(() => ({
width: getStyleWidth(), width: getStyleWidth(),
height: props.height + 'px', height: props.height + 'px',
lineHeight: props.height + 'px', lineHeight: props.height + 'px',
background: props.background, background: props.background,
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
})) }))
// 进度条样式计算 // 进度条样式计算
const progressBarStyle = { const progressBarStyle = {
background: props.progressBarBg, background: props.progressBarBg,
height: props.height + 'px', height: props.height + 'px',
borderRadius: props.circle borderRadius: props.circle
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px' ? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
: props.radius : props.radius
} }
// 文本样式计算 // 文本样式计算
const textStyle = computed(() => ({ const textStyle = computed(() => ({
fontSize: props.textSize fontSize: props.textSize
})) }))
// 显示消息计算属性 // 显示消息计算属性
const message = computed(() => { const message = computed(() => {
return props.value ? props.successText : props.text return props.value ? props.successText : props.text
}) })
/** /**
* 拖拽开始处理函数 * 拖拽开始处理函数
* @param e 鼠标或触摸事件对象 * @param e 鼠标或触摸事件对象
*/ */
const dragStart = (e: any) => { const dragStart = (e: any) => {
if (!props.value) { if (!props.value) {
state.isMoving = true state.isMoving = true
handler.value.style.transition = 'none' handler.value.style.transition = 'none'
// 计算拖拽起始位置 // 计算拖拽起始位置
state.x = state.x =
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10) (e.pageX || e.touches[0].pageX) -
} parseInt(handler.value.style.left.replace('px', ''), 10)
emit('handlerMove') }
} emit('handlerMove')
}
/** /**
* 拖拽移动处理函数 * 拖拽移动处理函数
* @param e 鼠标或触摸事件对象 * @param e 鼠标或触摸事件对象
*/ */
const dragMoving = (e: any) => { const dragMoving = (e: any) => {
if (state.isMoving && !props.value) { if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth() const numericWidth = getNumericWidth()
// 计算当前位置 // 计算当前位置
let _x = (e.pageX || e.touches[0].pageX) - state.x let _x = (e.pageX || e.touches[0].pageX) - state.x
// 在有效范围内移动 // 在有效范围内移动
if (_x > 0 && _x <= numericWidth - props.height) { if (_x > 0 && _x <= numericWidth - props.height) {
handler.value.style.left = _x + 'px' handler.value.style.left = _x + 'px'
progressBar.value.style.width = _x + props.height / 2 + 'px' progressBar.value.style.width = _x + props.height / 2 + 'px'
} else if (_x > numericWidth - props.height) { } else if (_x > numericWidth - props.height) {
// 拖拽到末端,触发验证成功 // 拖拽到末端,触发验证成功
handler.value.style.left = numericWidth - props.height + 'px' handler.value.style.left = numericWidth - props.height + 'px'
progressBar.value.style.width = numericWidth - props.height / 2 + 'px' progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
passVerify() passVerify()
} }
} }
} }
/** /**
* 拖拽结束处理函数 * 拖拽结束处理函数
* @param e 鼠标或触摸事件对象 * @param e 鼠标或触摸事件对象
*/ */
const dragFinish = (e: any) => { const dragFinish = (e: any) => {
if (state.isMoving && !props.value) { if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth() const numericWidth = getNumericWidth()
// 计算最终位置 // 计算最终位置
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
if (_x < numericWidth - props.height) { if (_x < numericWidth - props.height) {
// 未拖拽到末端,重置位置 // 未拖拽到末端,重置位置
state.isOk = true state.isOk = true
handler.value.style.left = '0' handler.value.style.left = '0'
handler.value.style.transition = 'all 0.2s' handler.value.style.transition = 'all 0.2s'
progressBar.value.style.width = '0' progressBar.value.style.width = '0'
state.isOk = false state.isOk = false
} else { } else {
// 拖拽到末端,保持验证成功状态 // 拖拽到末端,保持验证成功状态
handler.value.style.transition = 'none' handler.value.style.transition = 'none'
handler.value.style.left = numericWidth - props.height + 'px' handler.value.style.left = numericWidth - props.height + 'px'
progressBar.value.style.width = numericWidth - props.height / 2 + 'px' progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
passVerify() passVerify()
} }
state.isMoving = false state.isMoving = false
} }
} }
/** /**
* 验证通过处理函数 * 验证通过处理函数
*/ */
const passVerify = () => { const passVerify = () => {
emit('update:value', true) emit('update:value', true)
state.isMoving = false state.isMoving = false
// 更新样式为成功状态 // 更新样式为成功状态
progressBar.value.style.background = props.completedBg progressBar.value.style.background = props.completedBg
messageRef.value.style['-webkit-text-fill-color'] = 'unset' messageRef.value.style['-webkit-text-fill-color'] = 'unset'
messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite' messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite'
messageRef.value.style.color = '#fff' messageRef.value.style.color = '#fff'
emit('passCallback') emit('passCallback')
} }
/** /**
* 重置验证状态函数 * 重置验证状态函数
*/ */
const reset = () => { const reset = () => {
// 重置滑块位置 // 重置滑块位置
handler.value.style.left = '0' handler.value.style.left = '0'
progressBar.value.style.width = '0' progressBar.value.style.width = '0'
progressBar.value.style.background = props.progressBarBg progressBar.value.style.background = props.progressBarBg
// 重置文本样式 // 重置文本样式
messageRef.value.style['-webkit-text-fill-color'] = 'transparent' messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite' messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite'
messageRef.value.style.color = props.background messageRef.value.style.color = props.background
// 重置状态 // 重置状态
emit('update:value', false) emit('update:value', false)
state.isOk = false state.isOk = false
state.isMoving = false state.isMoving = false
state.x = 0 state.x = 0
} }
// 暴露重置方法给父组件 // 暴露重置方法给父组件
defineExpose({ defineExpose({
reset reset
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.drag_verify { .drag_verify {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
border: 1px solid var(--default-border-dashed); border: 1px solid var(--default-border-dashed);
.dv_handler { .dv_handler {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: move; cursor: move;
i { i {
padding-left: 0; padding-left: 0;
font-size: 14px; font-size: 14px;
color: #999; color: #999;
} }
.el-icon-circle-check { .el-icon-circle-check {
margin-top: 9px; margin-top: 9px;
color: #6c6; color: #6c6;
} }
} }
.dv_progress_bar { .dv_progress_bar {
position: absolute; position: absolute;
width: 0; width: 0;
height: 34px; height: 34px;
} }
.dv_text { .dv_text {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: transparent; color: transparent;
user-select: none; user-select: none;
background: linear-gradient( background: linear-gradient(
to right, to right,
var(--textColor) 0%, var(--textColor) 0%,
var(--textColor) 40%, var(--textColor) 40%,
#fff 50%, #fff 50%,
var(--textColor) 60%, var(--textColor) 60%,
var(--textColor) 100% var(--textColor) 100%
); );
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite; animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
text-size-adjust: none; text-size-adjust: none;
* { * {
-webkit-text-fill-color: var(--textColor); -webkit-text-fill-color: var(--textColor);
} }
} }
} }
.goFirst { .goFirst {
left: 0 !important; left: 0 !important;
transition: left 0.5s; transition: left 0.5s;
} }
.goFirst2 { .goFirst2 {
width: 0 !important; width: 0 !important;
transition: width 0.5s; transition: width 0.5s;
} }
</style> </style>
<style lang="scss"> <style lang="scss">
@keyframes slidetounlock { @keyframes slidetounlock {
0% { 0% {
background-position: var(--pwidth) 0; background-position: var(--pwidth) 0;
} }
100% { 100% {
background-position: var(--width) 0; background-position: var(--width) 0;
} }
} }
@keyframes slidetounlock2 { @keyframes slidetounlock2 {
0% { 0% {
background-position: var(--pwidth) 0; background-position: var(--pwidth) 0;
} }
100% { 100% {
background-position: var(--pwidth) 0; background-position: var(--pwidth) 0;
} }
} }
</style> </style>
@@ -1,389 +1,399 @@
<!-- 导出 Excel 文件 --> <!-- 导出 Excel 文件 -->
<template> <template>
<ElButton <ElButton
:type="type" :type="type"
:size="size" :size="size"
:loading="isExporting" :loading="isExporting"
:disabled="disabled || !hasData" :disabled="disabled || !hasData"
v-ripple v-ripple
@click="handleExport" @click="handleExport"
> >
<template #loading> <template #loading>
<ElIcon class="is-loading"> <ElIcon class="is-loading">
<Loading /> <Loading />
</ElIcon> </ElIcon>
{{ loadingText }} {{ loadingText }}
</template> </template>
<slot>{{ buttonText }}</slot> <slot>{{ buttonText }}</slot>
</ElButton> </ElButton>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import FileSaver from 'file-saver' import FileSaver from 'file-saver'
import { ref, computed, nextTick } from 'vue' import { ref, computed, nextTick } from 'vue'
import { Loading } from '@element-plus/icons-vue' import { Loading } from '@element-plus/icons-vue'
import type { ButtonType } from 'element-plus' import type { ButtonType } from 'element-plus'
import { useThrottleFn } from '@vueuse/core' import { useThrottleFn } from '@vueuse/core'
defineOptions({ name: 'ArtExcelExport' }) defineOptions({ name: 'ArtExcelExport' })
/** 导出数据类型 */ /** 导出数据类型 */
type ExportValue = string | number | boolean | null | undefined | Date type ExportValue = string | number | boolean | null | undefined | Date
interface ExportData { interface ExportData {
[key: string]: ExportValue [key: string]: ExportValue
} }
/** 列配置 */ /** 列配置 */
interface ColumnConfig { interface ColumnConfig {
/** 列标题 */ /** 列标题 */
title: string title: string
/** 列宽度 */ /** 列宽度 */
width?: number width?: number
/** 数据格式化函数 */ /** 数据格式化函数 */
formatter?: (value: ExportValue, row: ExportData, index: number) => string formatter?: (value: ExportValue, row: ExportData, index: number) => string
} }
/** 导出配置选项 */ /** 导出配置选项 */
interface ExportOptions { interface ExportOptions {
/** 数据源 */ /** 数据源 */
data: ExportData[] data: ExportData[]
/** 文件名(不含扩展名) */ /** 文件名(不含扩展名) */
filename?: string filename?: string
/** 工作表名称 */ /** 工作表名称 */
sheetName?: string sheetName?: string
/** 按钮类型 */ /** 按钮类型 */
type?: ButtonType type?: ButtonType
/** 按钮尺寸 */ /** 按钮尺寸 */
size?: 'large' | 'default' | 'small' size?: 'large' | 'default' | 'small'
/** 是否禁用 */ /** 是否禁用 */
disabled?: boolean disabled?: boolean
/** 按钮文本 */ /** 按钮文本 */
buttonText?: string buttonText?: string
/** 加载中文本 */ /** 加载中文本 */
loadingText?: string loadingText?: string
/** 是否自动添加序号列 */ /** 是否自动添加序号列 */
autoIndex?: boolean autoIndex?: boolean
/** 序号列标题 */ /** 序号列标题 */
indexColumnTitle?: string indexColumnTitle?: string
/** 列配置映射 */ /** 列配置映射 */
columns?: Record<string, ColumnConfig> columns?: Record<string, ColumnConfig>
/** 表头映射(简化版本,向后兼容) */ /** 表头映射(简化版本,向后兼容) */
headers?: Record<string, string> headers?: Record<string, string>
/** 最大导出行数 */ /** 最大导出行数 */
maxRows?: number maxRows?: number
/** 是否显示成功消息 */ /** 是否显示成功消息 */
showSuccessMessage?: boolean showSuccessMessage?: boolean
/** 是否显示错误消息 */ /** 是否显示错误消息 */
showErrorMessage?: boolean showErrorMessage?: boolean
/** 工作簿配置 */ /** 工作簿配置 */
workbookOptions?: { workbookOptions?: {
/** 创建者 */ /** 创建者 */
creator?: string creator?: string
/** 最后修改者 */ /** 最后修改者 */
lastModifiedBy?: string lastModifiedBy?: string
/** 创建时间 */ /** 创建时间 */
created?: Date created?: Date
/** 修改时间 */ /** 修改时间 */
modified?: Date modified?: Date
} }
} }
const props = withDefaults(defineProps<ExportOptions>(), { const props = withDefaults(defineProps<ExportOptions>(), {
filename: () => `export_${new Date().toISOString().slice(0, 10)}`, filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
sheetName: 'Sheet1', sheetName: 'Sheet1',
type: 'primary', type: 'primary',
size: 'default', size: 'default',
disabled: false, disabled: false,
buttonText: '导出 Excel', buttonText: '导出 Excel',
loadingText: '导出中...', loadingText: '导出中...',
autoIndex: false, autoIndex: false,
indexColumnTitle: '序号', indexColumnTitle: '序号',
columns: () => ({}), columns: () => ({}),
headers: () => ({}), headers: () => ({}),
maxRows: 100000, maxRows: 100000,
showSuccessMessage: true, showSuccessMessage: true,
showErrorMessage: true, showErrorMessage: true,
workbookOptions: () => ({}) workbookOptions: () => ({})
}) })
const emit = defineEmits<{ const emit = defineEmits<{
'before-export': [data: ExportData[]] 'before-export': [data: ExportData[]]
'export-success': [filename: string, rowCount: number] 'export-success': [filename: string, rowCount: number]
'export-error': [error: ExportError] 'export-error': [error: ExportError]
'export-progress': [progress: number] 'export-progress': [progress: number]
}>() }>()
/** 导出错误类型 */ /** 导出错误类型 */
class ExportError extends Error { class ExportError extends Error {
constructor( constructor(
message: string, message: string,
public code: string, public code: string,
public details?: any public details?: any
) { ) {
super(message) super(message)
this.name = 'ExportError' this.name = 'ExportError'
} }
} }
const isExporting = ref(false) const isExporting = ref(false)
/** 是否有数据可导出 */ /** 是否有数据可导出 */
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0) const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
/** 验证导出数据 */ /** 验证导出数据 */
const validateData = (data: ExportData[]): void => { const validateData = (data: ExportData[]): void => {
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE') throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
} }
if (data.length === 0) { if (data.length === 0) {
throw new ExportError('没有可导出的数据', 'NO_DATA') throw new ExportError('没有可导出的数据', 'NO_DATA')
} }
if (data.length > props.maxRows) { if (data.length > props.maxRows) {
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', { throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
currentRows: data.length, currentRows: data.length,
maxRows: props.maxRows maxRows: props.maxRows
}) })
} }
} }
/** 格式化单元格值 */ /** 格式化单元格值 */
const formatCellValue = ( const formatCellValue = (
value: ExportValue, value: ExportValue,
key: string, key: string,
row: ExportData, row: ExportData,
index: number index: number
): string => { ): string => {
// 使用列配置的格式化函数 // 使用列配置的格式化函数
const column = props.columns[key] const column = props.columns[key]
if (column?.formatter) { if (column?.formatter) {
return column.formatter(value, row, index) return column.formatter(value, row, index)
} }
// 处理特殊值 // 处理特殊值
if (value === null || value === undefined) { if (value === null || value === undefined) {
return '' return ''
} }
if (value instanceof Date) { if (value instanceof Date) {
return value.toLocaleDateString('zh-CN') return value.toLocaleDateString('zh-CN')
} }
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value ? '是' : '否' return value ? '是' : '否'
} }
return String(value) return String(value)
} }
/** 处理数据 */ /** 处理数据 */
const processData = (data: ExportData[]): Record<string, string>[] => { const processData = (data: ExportData[]): Record<string, string>[] => {
const processedData = data.map((item, index) => { const processedData = data.map((item, index) => {
const processedItem: Record<string, string> = {} const processedItem: Record<string, string> = {}
// 添加序号列 // 添加序号列
if (props.autoIndex) { if (props.autoIndex) {
processedItem[props.indexColumnTitle] = String(index + 1) processedItem[props.indexColumnTitle] = String(index + 1)
} }
// 处理数据列 // 处理数据列
Object.entries(item).forEach(([key, value]) => { Object.entries(item).forEach(([key, value]) => {
// 获取列标题 // 获取列标题
let columnTitle = key let columnTitle = key
if (props.columns[key]?.title) { if (props.columns[key]?.title) {
columnTitle = props.columns[key].title columnTitle = props.columns[key].title
} else if (props.headers[key]) { } else if (props.headers[key]) {
columnTitle = props.headers[key] columnTitle = props.headers[key]
} }
// 格式化值 // 格式化值
processedItem[columnTitle] = formatCellValue(value, key, item, index) processedItem[columnTitle] = formatCellValue(value, key, item, index)
}) })
return processedItem return processedItem
}) })
return processedData return processedData
} }
/** 计算列宽度 */ /** 计算列宽度 */
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => { const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
if (data.length === 0) return [] if (data.length === 0) return []
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽 const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
const columns = Object.keys(data[0]) const columns = Object.keys(data[0])
return columns.map((column) => { return columns.map((column) => {
// 使用配置的列宽度 // 使用配置的列宽度
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width const configWidth = Object.values(props.columns).find(
(col) => col.title === column
)?.width
if (configWidth) { if (configWidth) {
return { wch: configWidth } return { wch: configWidth }
} }
// 自动计算列宽度 // 自动计算列宽度
const maxLength = Math.max( const maxLength = Math.max(
column.length, // 标题长度 column.length, // 标题长度
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length) ...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
) )
// 限制最小和最大宽度 // 限制最小和最大宽度
const width = Math.min(Math.max(maxLength + 2, 8), 50) const width = Math.min(Math.max(maxLength + 2, 8), 50)
return { wch: width } return { wch: width }
}) })
} }
/** 导出到 Excel */ /** 导出到 Excel */
const exportToExcel = async ( const exportToExcel = async (
data: ExportData[], data: ExportData[],
filename: string, filename: string,
sheetName: string sheetName: string
): Promise<void> => { ): Promise<void> => {
try { try {
emit('export-progress', 10) emit('export-progress', 10)
// 处理数据 // 处理数据
const processedData = processData(data) const processedData = processData(data)
emit('export-progress', 30) emit('export-progress', 30)
// 创建工作簿 // 创建工作簿
const workbook = XLSX.utils.book_new() const workbook = XLSX.utils.book_new()
// 设置工作簿属性 // 设置工作簿属性
if (props.workbookOptions) { if (props.workbookOptions) {
workbook.Props = { workbook.Props = {
Title: filename, Title: filename,
Subject: '数据导出', Subject: '数据导出',
Author: props.workbookOptions.creator || 'Art Design Pro', Author: props.workbookOptions.creator || 'Art Design Pro',
Manager: props.workbookOptions.lastModifiedBy || '', Manager: props.workbookOptions.lastModifiedBy || '',
Company: '系统导出', Company: '系统导出',
Category: '数据', Category: '数据',
Keywords: 'excel,export,data', Keywords: 'excel,export,data',
Comments: '由系统自动生成', Comments: '由系统自动生成',
CreatedDate: props.workbookOptions.created || new Date(), CreatedDate: props.workbookOptions.created || new Date(),
ModifiedDate: props.workbookOptions.modified || new Date() ModifiedDate: props.workbookOptions.modified || new Date()
} }
} }
emit('export-progress', 50) emit('export-progress', 50)
// 创建工作表 // 创建工作表
const worksheet = XLSX.utils.json_to_sheet(processedData) const worksheet = XLSX.utils.json_to_sheet(processedData)
// 设置列宽度 // 设置列宽度
worksheet['!cols'] = calculateColumnWidths(processedData) worksheet['!cols'] = calculateColumnWidths(processedData)
emit('export-progress', 70) emit('export-progress', 70)
// 添加工作表到工作簿 // 添加工作表到工作簿
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName) XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
emit('export-progress', 85) emit('export-progress', 85)
// 生成 Excel 文件 // 生成 Excel 文件
const excelBuffer = XLSX.write(workbook, { const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx', bookType: 'xlsx',
type: 'array', type: 'array',
compression: true compression: true
}) })
// 创建 Blob 并下载 // 创建 Blob 并下载
const blob = new Blob([excelBuffer], { const blob = new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
}) })
emit('export-progress', 95) emit('export-progress', 95)
// 使用时间戳确保文件名唯一 // 使用时间戳确保文件名唯一
const timestamp = new Date().toISOString().replace(/[:.]/g, '-') const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const finalFilename = `${filename}_${timestamp}.xlsx` const finalFilename = `${filename}_${timestamp}.xlsx`
FileSaver.saveAs(blob, finalFilename) FileSaver.saveAs(blob, finalFilename)
emit('export-progress', 100) emit('export-progress', 100)
// 等待下载开始 // 等待下载开始
await nextTick() await nextTick()
return Promise.resolve() return Promise.resolve()
} catch (error) { } catch (error) {
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, 'EXPORT_FAILED', error) throw new ExportError(
} `Excel 导出失败: ${(error as Error).message}`,
} 'EXPORT_FAILED',
error
)
}
}
/** 处理导出 */ /** 处理导出 */
const handleExport = useThrottleFn(async () => { const handleExport = useThrottleFn(async () => {
if (isExporting.value) return if (isExporting.value) return
isExporting.value = true isExporting.value = true
try { try {
// 验证数据 // 验证数据
validateData(props.data) validateData(props.data)
// 触发导出前事件 // 触发导出前事件
emit('before-export', props.data) emit('before-export', props.data)
// 执行导出 // 执行导出
await exportToExcel(props.data, props.filename, props.sheetName) await exportToExcel(props.data, props.filename, props.sheetName)
// 触发成功事件 // 触发成功事件
emit('export-success', props.filename, props.data.length) emit('export-success', props.filename, props.data.length)
// 显示成功消息 // 显示成功消息
if (props.showSuccessMessage) { if (props.showSuccessMessage) {
ElMessage.success({ ElMessage.success({
message: `成功导出 ${props.data.length} 条数据`, message: `成功导出 ${props.data.length} 条数据`,
duration: 3000 duration: 3000
}) })
} }
} catch (error) { } catch (error) {
const exportError = const exportError =
error instanceof ExportError error instanceof ExportError
? error ? error
: new ExportError(`导出失败: ${(error as Error).message}`, 'UNKNOWN_ERROR', error) : new ExportError(
`导出失败: ${(error as Error).message}`,
'UNKNOWN_ERROR',
error
)
// 触发错误事件 // 触发错误事件
emit('export-error', exportError) emit('export-error', exportError)
// 显示错误消息 // 显示错误消息
if (props.showErrorMessage) { if (props.showErrorMessage) {
ElMessage.error({ ElMessage.error({
message: exportError.message, message: exportError.message,
duration: 5000 duration: 5000
}) })
} }
console.error('Excel 导出错误:', exportError) console.error('Excel 导出错误:', exportError)
} finally { } finally {
isExporting.value = false isExporting.value = false
emit('export-progress', 0) emit('export-progress', 0)
} }
}, 1000) }, 1000)
// 暴露方法供父组件调用 // 暴露方法供父组件调用
defineExpose({ defineExpose({
exportData: handleExport, exportData: handleExport,
isExporting: readonly(isExporting), isExporting: readonly(isExporting),
hasData hasData
}) })
</script> </script>
<style scoped> <style scoped>
.is-loading { .is-loading {
animation: rotating 2s linear infinite; animation: rotating 2s linear infinite;
} }
@keyframes rotating { @keyframes rotating {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);
} }
100% { 100% {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
</style> </style>
@@ -1,62 +1,62 @@
<!-- 导入 Excel 文件 --> <!-- 导入 Excel 文件 -->
<template> <template>
<div class="inline-block"> <div class="inline-block">
<ElUpload <ElUpload
:auto-upload="false" :auto-upload="false"
accept=".xlsx, .xls" accept=".xlsx, .xls"
:show-file-list="false" :show-file-list="false"
@change="handleFileChange" @change="handleFileChange"
> >
<ElButton type="primary" v-ripple> <ElButton type="primary" v-ripple>
<slot>导入 Excel</slot> <slot>导入 Excel</slot>
</ElButton> </ElButton>
</ElUpload> </ElUpload>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import * as XLSX from 'xlsx' import * as XLSX from 'xlsx'
import type { UploadFile } from 'element-plus' import type { UploadFile } from 'element-plus'
defineOptions({ name: 'ArtExcelImport' }) defineOptions({ name: 'ArtExcelImport' })
// Excel 导入工具函数 // Excel 导入工具函数
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> { async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader()
reader.onload = (e) => { reader.onload = (e) => {
try { try {
const data = e.target?.result const data = e.target?.result
const workbook = XLSX.read(data, { type: 'array' }) const workbook = XLSX.read(data, { type: 'array' })
const firstSheetName = workbook.SheetNames[0] const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName] const worksheet = workbook.Sheets[firstSheetName]
const results = XLSX.utils.sheet_to_json(worksheet) const results = XLSX.utils.sheet_to_json(worksheet)
resolve(results as Array<Record<string, unknown>>) resolve(results as Array<Record<string, unknown>>)
} catch (error) { } catch (error) {
reject(error) reject(error)
} }
} }
reader.onerror = (error) => reject(error) reader.onerror = (error) => reject(error)
reader.readAsArrayBuffer(file) reader.readAsArrayBuffer(file)
}) })
} }
// 定义 emits // 定义 emits
const emit = defineEmits<{ const emit = defineEmits<{
'import-success': [data: Array<Record<string, unknown>>] 'import-success': [data: Array<Record<string, unknown>>]
'import-error': [error: Error] 'import-error': [error: Error]
}>() }>()
// 处理文件导入 // 处理文件导入
const handleFileChange = async (uploadFile: UploadFile) => { const handleFileChange = async (uploadFile: UploadFile) => {
try { try {
if (!uploadFile.raw) return if (!uploadFile.raw) return
const results = await importExcel(uploadFile.raw) const results = await importExcel(uploadFile.raw)
emit('import-success', results) emit('import-success', results)
} catch (error) { } catch (error) {
emit('import-error', error as Error) emit('import-error', error as Error)
} }
} }
</script> </script>
+290 -277
View File
@@ -2,310 +2,323 @@
<!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 --> <!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 --> <!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 -->
<template> <template>
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4"> <section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
<ElForm <ElForm
ref="formRef" ref="formRef"
:model="modelValue" :model="modelValue"
:label-position="labelPosition" :label-position="labelPosition"
v-bind="{ ...$attrs }" v-bind="{ ...$attrs }"
> >
<ElRow class="flex flex-wrap" :gutter="gutter"> <ElRow class="flex flex-wrap" :gutter="gutter">
<ElCol <ElCol
v-for="item in visibleFormItems" v-for="item in visibleFormItems"
:key="item.key" :key="item.key"
:xs="getColSpan(item.span, 'xs')" :xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')" :sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')" :md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')" :lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')" :xl="getColSpan(item.span, 'xl')"
> >
<ElFormItem <ElFormItem
:prop="item.key" :prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined" :label-width="item.label ? item.labelWidth || labelWidth : undefined"
> >
<template #label v-if="item.label"> <template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" /> <component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span> <span v-else>{{ item.label }}</span>
</template> </template>
<slot :name="item.key" :item="item" :modelValue="modelValue"> <slot :name="item.key" :item="item" :modelValue="modelValue">
<component <component
:is="getComponent(item)" :is="getComponent(item)"
v-model="modelValue[item.key]" v-model="modelValue[item.key]"
v-bind="getProps(item)" v-bind="getProps(item)"
> >
<!-- 下拉选择 --> <!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options"> <template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption <ElOption
v-for="option in getProps(item).options" v-for="option in getProps(item).options"
v-bind="option" v-bind="option"
:key="option.value" :key="option.value"
/> />
</template> </template>
<!-- 复选框组 --> <!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options"> <template
<ElCheckbox v-if="item.type === 'checkboxgroup' && getProps(item)?.options"
v-for="option in getProps(item).options" >
v-bind="option" <ElCheckbox
:key="option.value" v-for="option in getProps(item).options"
/> v-bind="option"
</template> :key="option.value"
/>
</template>
<!-- 单选框组 --> <!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options"> <template
<ElRadio v-if="item.type === 'radiogroup' && getProps(item)?.options"
v-for="option in getProps(item).options" >
v-bind="option" <ElRadio
:key="option.value" v-for="option in getProps(item).options"
/> v-bind="option"
</template> :key="option.value"
/>
</template>
<!-- 动态插槽支持 --> <!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]> <template
<component :is="slotFn" /> v-for="(slotFn, slotName) in getSlots(item)"
</template> :key="slotName"
</component> #[slotName]
</slot> >
</ElFormItem> <component :is="slotFn" />
</ElCol> </template>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1"> </component>
<div </slot>
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2" </ElFormItem>
:style="actionButtonsStyle" </ElCol>
> <ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
<div class="flex gap-2 md:justify-center"> <div
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple> class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
{{ t('table.form.reset') }} :style="actionButtonsStyle"
</ElButton> >
<ElButton <div class="flex gap-2 md:justify-center">
v-if="showSubmit" <ElButton
type="primary" v-if="showReset"
class="submit-button" class="reset-button"
@click="handleSubmit" @click="handleReset"
v-ripple v-ripple
:disabled="disabledSubmit" >
> {{ t('table.form.reset') }}
{{ t('table.form.submit') }} </ElButton>
</ElButton> <ElButton
</div> v-if="showSubmit"
</div> type="primary"
</ElCol> class="submit-button"
</ElRow> @click="handleSubmit"
</ElForm> v-ripple
</section> :disabled="disabledSubmit"
>
{{ t('table.form.submit') }}
</ElButton>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Component } from 'vue' import type { Component } from 'vue'
import { import {
ElCascader, ElCascader,
ElCheckbox, ElCheckbox,
ElCheckboxGroup, ElCheckboxGroup,
ElDatePicker, ElDatePicker,
ElInput, ElInput,
ElInputTag, ElInputTag,
ElInputNumber, ElInputNumber,
ElRadioGroup, ElRadioGroup,
ElRate, ElRate,
ElSelect, ElSelect,
ElSlider, ElSlider,
ElSwitch, ElSwitch,
ElTimePicker, ElTimePicker,
ElTimeSelect, ElTimeSelect,
ElTreeSelect, ElTreeSelect,
type FormInstance type FormInstance
} from 'element-plus' } from 'element-plus'
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive' import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
defineOptions({ name: 'ArtForm' }) defineOptions({ name: 'ArtForm' })
const componentMap = { const componentMap = {
input: ElInput, // 输入框 input: ElInput, // 输入框
inputtag: ElInputTag, // 标签输入框 inputtag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框 number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器 select: ElSelect, // 选择器
switch: ElSwitch, // 开关 switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框 checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组 checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组 radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器 date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器 daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器 datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器 datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分 rate: ElRate, // 评分
slider: ElSlider, // 滑块 slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器 cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器 timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择 timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect // 树选择器 treeselect: ElTreeSelect // 树选择器
} }
const { width } = useWindowSize() const { width } = useWindowSize()
const { t } = useI18n() const { t } = useI18n()
const isMobile = computed(() => width.value < 500) const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef') const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单项配置 // 表单项配置
export interface FormItem { export interface FormItem {
/** 表单项的唯一标识 */ /** 表单项的唯一标识 */
key: string key: string
/** 表单项的标签文本或自定义渲染函数 */ /** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component label: string | (() => VNode) | Component
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */ /** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number labelWidth?: string | number
/** 表单项类型,支持预定义的组件类型 */ /** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string type?: keyof typeof componentMap | string
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */ /** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
render?: (() => VNode) | Component render?: (() => VNode) | Component
/** 是否隐藏该表单项 */ /** 是否隐藏该表单项 */
hidden?: boolean hidden?: boolean
/** 表单项占据的列宽,基于24格栅格系统 */ /** 表单项占据的列宽,基于24格栅格系统 */
span?: number span?: number
/** 选项数据,用于 select、checkbox-group、radio-group 等 */ /** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any> options?: Record<string, any>
/** 传递给表单项组件的属性 */ /** 传递给表单项组件的属性 */
props?: Record<string, any> props?: Record<string, any>
/** 表单项的插槽配置 */ /** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined> slots?: Record<string, (() => any) | undefined>
/** 表单项的占位符文本 */ /** 表单项的占位符文本 */
placeholder?: string placeholder?: string
/** 更多属性配置请参考 ElementPlus 官方文档 */ /** 更多属性配置请参考 ElementPlus 官方文档 */
} }
// 表单配置 // 表单配置
interface FormProps { interface FormProps {
/** 表单数据 */ /** 表单数据 */
items: FormItem[] items: FormItem[]
/** 每列的宽度(基于 24 格布局) */ /** 每列的宽度(基于 24 格布局) */
span?: number span?: number
/** 表单控件间隙 */ /** 表单控件间隙 */
gutter?: number gutter?: number
/** 表单域标签的位置 */ /** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top' labelPosition?: 'left' | 'right' | 'top'
/** 文字宽度 */ /** 文字宽度 */
labelWidth?: string | number labelWidth?: string | number
/** 按钮靠左对齐限制(表单项小于等于该值时) */ /** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number buttonLeftLimit?: number
/** 是否显示重置按钮 */ /** 是否显示重置按钮 */
showReset?: boolean showReset?: boolean
/** 是否显示提交按钮 */ /** 是否显示提交按钮 */
showSubmit?: boolean showSubmit?: boolean
/** 是否禁用提交按钮 */ /** 是否禁用提交按钮 */
disabledSubmit?: boolean disabledSubmit?: boolean
} }
const props = withDefaults(defineProps<FormProps>(), { const props = withDefaults(defineProps<FormProps>(), {
items: () => [], items: () => [],
span: 6, span: 6,
gutter: 12, gutter: 12,
labelPosition: 'right', labelPosition: 'right',
labelWidth: '70px', labelWidth: '70px',
buttonLeftLimit: 2, buttonLeftLimit: 2,
showReset: true, showReset: true,
showSubmit: true, showSubmit: true,
disabledSubmit: false disabledSubmit: false
}) })
interface FormEmits { interface FormEmits {
reset: [] reset: []
submit: [] submit: []
} }
const emit = defineEmits<FormEmits>() const emit = defineEmits<FormEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} }) const modelValue = defineModel<Record<string, any>>({ default: {} })
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots'] const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
const getProps = (item: FormItem) => { const getProps = (item: FormItem) => {
if (item.props) return item.props if (item.props) return item.props
const props = { ...item } const props = { ...item }
rootProps.forEach((key) => delete (props as Record<string, any>)[key]) rootProps.forEach((key) => delete (props as Record<string, any>)[key])
return props return props
} }
// 获取插槽 // 获取插槽
const getSlots = (item: FormItem) => { const getSlots = (item: FormItem) => {
if (!item.slots) return {} if (!item.slots) return {}
const validSlots: Record<string, () => any> = {} const validSlots: Record<string, () => any> = {}
Object.entries(item.slots).forEach(([key, slotFn]) => { Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) { if (slotFn) {
validSlots[key] = slotFn validSlots[key] = slotFn
} }
}) })
return validSlots return validSlots
} }
// 组件 // 组件
const getComponent = (item: FormItem) => { const getComponent = (item: FormItem) => {
// 优先使用 render 函数或组件渲染自定义组件 // 优先使用 render 函数或组件渲染自定义组件
if (item.render) { if (item.render) {
return item.render return item.render
} }
// 使用 type 获取预定义组件 // 使用 type 获取预定义组件
const { type } = item const { type } = item
return componentMap[type as keyof typeof componentMap] || componentMap['input'] return componentMap[type as keyof typeof componentMap] || componentMap['input']
} }
/** /**
* 获取列宽 span 值 * 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小 * 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/ */
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => { const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint) return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
} }
/** /**
* 可见的表单项 * 可见的表单项
*/ */
const visibleFormItems = computed(() => { const visibleFormItems = computed(() => {
return props.items.filter((item) => !item.hidden) return props.items.filter((item) => !item.hidden)
}) })
/** /**
* 操作按钮样式 * 操作按钮样式
*/ */
const actionButtonsStyle = computed(() => ({ const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value 'justify-content': isMobile.value
? 'flex-end' ? 'flex-end'
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit : props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? 'flex-start' ? 'flex-start'
: 'flex-end' : 'flex-end'
})) }))
/** /**
* 处理重置事件 * 处理重置事件
*/ */
const handleReset = () => { const handleReset = () => {
// 重置表单字段(UI 层) // 重置表单字段(UI 层)
formInstance.value?.resetFields() formInstance.value?.resetFields()
// 清空所有表单项值(包含隐藏项) // 清空所有表单项值(包含隐藏项)
Object.assign( Object.assign(
modelValue.value, modelValue.value,
Object.fromEntries(props.items.map(({ key }) => [key, undefined])) Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
) )
// 触发 reset 事件 // 触发 reset 事件
emit('reset') emit('reset')
} }
/** /**
* 处理提交事件 * 处理提交事件
*/ */
const handleSubmit = () => { const handleSubmit = () => {
emit('submit') emit('submit')
} }
defineExpose({ defineExpose({
ref: formInstance, ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args), validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset reset: handleReset
}) })
// 解构 props 以便在模板中直接使用 // 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props) const { span, gutter, labelPosition, labelWidth } = toRefs(props)
</script> </script>
+404 -385
View File
@@ -2,436 +2,455 @@
<!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 --> <!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 --> <!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 -->
<template> <template>
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }"> <section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
<ElForm <ElForm
ref="formRef" ref="formRef"
:model="modelValue" :model="modelValue"
:label-position="labelPosition" :label-position="labelPosition"
v-bind="{ ...$attrs }" v-bind="{ ...$attrs }"
> >
<ElRow :gutter="gutter"> <ElRow :gutter="gutter">
<ElCol <ElCol
v-for="item in visibleFormItems" v-for="item in visibleFormItems"
:key="item.key" :key="item.key"
:xs="getColSpan(item.span, 'xs')" :xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')" :sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')" :md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')" :lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')" :xl="getColSpan(item.span, 'xl')"
> >
<ElFormItem <ElFormItem
:prop="item.key" :prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined" :label-width="item.label ? item.labelWidth || labelWidth : undefined"
> >
<template #label v-if="item.label"> <template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" /> <component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span> <span v-else>{{ item.label }}</span>
</template> </template>
<slot :name="item.key" :item="item" :modelValue="modelValue"> <slot :name="item.key" :item="item" :modelValue="modelValue">
<component <component
:is="getComponent(item)" :is="getComponent(item)"
v-model="modelValue[item.key]" v-model="modelValue[item.key]"
v-bind="getProps(item)" v-bind="getProps(item)"
> >
<!-- 下拉选择 --> <!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options"> <template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption <ElOption
v-for="option in getProps(item).options" v-for="option in getProps(item).options"
v-bind="option" v-bind="option"
:key="option.value" :key="option.value"
/> />
</template> </template>
<!-- 复选框组 --> <!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options"> <template
<ElCheckbox v-if="item.type === 'checkboxgroup' && getProps(item)?.options"
v-for="option in getProps(item).options" >
v-bind="option" <ElCheckbox
:key="option.value" v-for="option in getProps(item).options"
/> v-bind="option"
</template> :key="option.value"
/>
</template>
<!-- 单选框组 --> <!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options"> <template
<ElRadio v-if="item.type === 'radiogroup' && getProps(item)?.options"
v-for="option in getProps(item).options" >
v-bind="option" <ElRadio
:key="option.value" v-for="option in getProps(item).options"
/> v-bind="option"
</template> :key="option.value"
/>
</template>
<!-- 动态插槽支持 --> <!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]> <template
<component :is="slotFn" /> v-for="(slotFn, slotName) in getSlots(item)"
</template> :key="slotName"
</component> #[slotName]
</slot> >
</ElFormItem> <component :is="slotFn" />
</ElCol> </template>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column"> </component>
<div class="action-buttons-wrapper" :style="actionButtonsStyle"> </slot>
<div class="form-buttons"> </ElFormItem>
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple> </ElCol>
{{ t('table.searchBar.reset') }} <ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
</ElButton> <div class="action-buttons-wrapper" :style="actionButtonsStyle">
<ElButton <div class="form-buttons">
v-if="showSearch" <ElButton
type="primary" v-if="showReset"
class="search-button" class="reset-button"
@click="handleSearch" @click="handleReset"
v-ripple v-ripple
:disabled="disabledSearch" >
> {{ t('table.searchBar.reset') }}
{{ t('table.searchBar.search') }} </ElButton>
</ElButton> <ElButton
</div> v-if="showSearch"
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand"> type="primary"
<span>{{ expandToggleText }}</span> class="search-button"
<div class="icon-wrapper"> @click="handleSearch"
<ElIcon> v-ripple
<ArrowUpBold v-if="isExpanded" /> :disabled="disabledSearch"
<ArrowDownBold v-else /> >
</ElIcon> {{ t('table.searchBar.search') }}
</div> </ElButton>
</div> </div>
</div> <div
</ElCol> v-if="shouldShowExpandToggle"
</ElRow> class="filter-toggle"
</ElForm> @click="toggleExpand"
</section> >
<span>{{ expandToggleText }}</span>
<div class="icon-wrapper">
<ElIcon>
<ArrowUpBold v-if="isExpanded" />
<ArrowDownBold v-else />
</ElIcon>
</div>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue' import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { useWindowSize } from '@vueuse/core' import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { Component } from 'vue' import type { Component } from 'vue'
import { import {
ElCascader, ElCascader,
ElCheckbox, ElCheckbox,
ElCheckboxGroup, ElCheckboxGroup,
ElDatePicker, ElDatePicker,
ElInput, ElInput,
ElInputTag, ElInputTag,
ElInputNumber, ElInputNumber,
ElRadioGroup, ElRadioGroup,
ElRate, ElRate,
ElSelect, ElSelect,
ElSlider, ElSlider,
ElSwitch, ElSwitch,
ElTimePicker, ElTimePicker,
ElTimeSelect, ElTimeSelect,
ElTreeSelect, ElTreeSelect,
type FormInstance type FormInstance
} from 'element-plus' } from 'element-plus'
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive' import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
defineOptions({ name: 'ArtSearchBar' }) defineOptions({ name: 'ArtSearchBar' })
const componentMap = { const componentMap = {
input: ElInput, // 输入框 input: ElInput, // 输入框
inputTag: ElInputTag, // 标签输入框 inputTag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框 number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器 select: ElSelect, // 选择器
switch: ElSwitch, // 开关 switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框 checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组 checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组 radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器 date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器 daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器 datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器 datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分 rate: ElRate, // 评分
slider: ElSlider, // 滑块 slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器 cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器 timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择 timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect // 树选择器 treeselect: ElTreeSelect // 树选择器
} }
const { width } = useWindowSize() const { width } = useWindowSize()
const { t } = useI18n() const { t } = useI18n()
const isMobile = computed(() => width.value < 500) const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef') const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单项配置 // 表单项配置
export interface SearchFormItem { export interface SearchFormItem {
/** 表单项的唯一标识 */ /** 表单项的唯一标识 */
key: string key: string
/** 表单项的标签文本或自定义渲染函数 */ /** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component label: string | (() => VNode) | Component
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */ /** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number labelWidth?: string | number
/** 表单项类型,支持预定义的组件类型 */ /** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string type?: keyof typeof componentMap | string
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */ /** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
render?: (() => VNode) | Component render?: (() => VNode) | Component
/** 是否隐藏该表单项 */ /** 是否隐藏该表单项 */
hidden?: boolean hidden?: boolean
/** 表单项占据的列宽,基于24格栅格系统 */ /** 表单项占据的列宽,基于24格栅格系统 */
span?: number span?: number
/** 选项数据,用于 select、checkbox-group、radio-group 等 */ /** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any> options?: Record<string, any>
/** 传递给表单项组件的属性 */ /** 传递给表单项组件的属性 */
props?: Record<string, any> props?: Record<string, any>
/** 表单项的插槽配置 */ /** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined> slots?: Record<string, (() => any) | undefined>
/** 表单项的占位符文本 */ /** 表单项的占位符文本 */
placeholder?: string placeholder?: string
/** 更多属性配置请参考 ElementPlus 官方文档 */ /** 更多属性配置请参考 ElementPlus 官方文档 */
} }
// 表单配置 // 表单配置
interface SearchBarProps { interface SearchBarProps {
/** 表单数据 */ /** 表单数据 */
items: SearchFormItem[] items: SearchFormItem[]
/** 每列的宽度(基于 24 格布局) */ /** 每列的宽度(基于 24 格布局) */
span?: number span?: number
/** 表单控件间隙 */ /** 表单控件间隙 */
gutter?: number gutter?: number
/** 展开/收起 */ /** 展开/收起 */
isExpand?: boolean isExpand?: boolean
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */ /** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
defaultExpanded?: boolean defaultExpanded?: boolean
/** 表单域标签的位置 */ /** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top' labelPosition?: 'left' | 'right' | 'top'
/** 文字宽度 */ /** 文字宽度 */
labelWidth?: string | number labelWidth?: string | number
/** 是否需要展示,收起 */ /** 是否需要展示,收起 */
showExpand?: boolean showExpand?: boolean
/** 按钮靠左对齐限制(表单项小于等于该值时) */ /** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number buttonLeftLimit?: number
/** 是否显示重置按钮 */ /** 是否显示重置按钮 */
showReset?: boolean showReset?: boolean
/** 是否显示搜索按钮 */ /** 是否显示搜索按钮 */
showSearch?: boolean showSearch?: boolean
/** 是否禁用搜索按钮 */ /** 是否禁用搜索按钮 */
disabledSearch?: boolean disabledSearch?: boolean
} }
const props = withDefaults(defineProps<SearchBarProps>(), { const props = withDefaults(defineProps<SearchBarProps>(), {
items: () => [], items: () => [],
span: 6, span: 6,
gutter: 12, gutter: 12,
isExpand: false, isExpand: false,
labelPosition: 'right', labelPosition: 'right',
labelWidth: '70px', labelWidth: '70px',
showExpand: true, showExpand: true,
defaultExpanded: false, defaultExpanded: false,
buttonLeftLimit: 2, buttonLeftLimit: 2,
showReset: true, showReset: true,
showSearch: true, showSearch: true,
disabledSearch: false disabledSearch: false
}) })
interface SearchBarEmits { interface SearchBarEmits {
reset: [] reset: []
search: [] search: []
} }
const emit = defineEmits<SearchBarEmits>() const emit = defineEmits<SearchBarEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} }) const modelValue = defineModel<Record<string, any>>({ default: {} })
/** /**
* 是否展开状态 * 是否展开状态
*/ */
const isExpanded = ref(props.defaultExpanded) const isExpanded = ref(props.defaultExpanded)
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots'] const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
const getProps = (item: SearchFormItem) => { const getProps = (item: SearchFormItem) => {
if (item.props) return item.props if (item.props) return item.props
const props = { ...item } const props = { ...item }
rootProps.forEach((key) => delete (props as Record<string, any>)[key]) rootProps.forEach((key) => delete (props as Record<string, any>)[key])
return props return props
} }
// 获取插槽 // 获取插槽
const getSlots = (item: SearchFormItem) => { const getSlots = (item: SearchFormItem) => {
if (!item.slots) return {} if (!item.slots) return {}
const validSlots: Record<string, () => any> = {} const validSlots: Record<string, () => any> = {}
Object.entries(item.slots).forEach(([key, slotFn]) => { Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) { if (slotFn) {
validSlots[key] = slotFn validSlots[key] = slotFn
} }
}) })
return validSlots return validSlots
} }
/** /**
* 获取列宽 span 值 * 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小 * 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/ */
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => { const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint) return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
} }
// 组件 // 组件
const getComponent = (item: SearchFormItem) => { const getComponent = (item: SearchFormItem) => {
// 优先使用 render 函数或组件渲染自定义组件 // 优先使用 render 函数或组件渲染自定义组件
if (item.render) { if (item.render) {
return item.render return item.render
} }
// 使用 type 获取预定义组件 // 使用 type 获取预定义组件
const { type } = item const { type } = item
return componentMap[type as keyof typeof componentMap] || componentMap['input'] return componentMap[type as keyof typeof componentMap] || componentMap['input']
} }
/** /**
* 可见的表单项 * 可见的表单项
*/ */
const visibleFormItems = computed(() => { const visibleFormItems = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden) const filteredItems = props.items.filter((item) => !item.hidden)
const shouldShowLess = !props.isExpand && !isExpanded.value const shouldShowLess = !props.isExpand && !isExpanded.value
if (shouldShowLess) { if (shouldShowLess) {
const maxItemsPerRow = Math.floor(24 / props.span) - 1 const maxItemsPerRow = Math.floor(24 / props.span) - 1
return filteredItems.slice(0, maxItemsPerRow) return filteredItems.slice(0, maxItemsPerRow)
} }
return filteredItems return filteredItems
}) })
/** /**
* 是否应该显示展开/收起按钮 * 是否应该显示展开/收起按钮
*/ */
const shouldShowExpandToggle = computed(() => { const shouldShowExpandToggle = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden) const filteredItems = props.items.filter((item) => !item.hidden)
return ( return (
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1 !props.isExpand &&
) props.showExpand &&
}) filteredItems.length > Math.floor(24 / props.span) - 1
)
})
/** /**
* 展开/收起按钮文本 * 展开/收起按钮文本
*/ */
const expandToggleText = computed(() => { const expandToggleText = computed(() => {
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand') return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
}) })
/** /**
* 操作按钮样式 * 操作按钮样式
*/ */
const actionButtonsStyle = computed(() => ({ const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value 'justify-content': isMobile.value
? 'flex-end' ? 'flex-end'
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit : props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? 'flex-start' ? 'flex-start'
: 'flex-end' : 'flex-end'
})) }))
/** /**
* 切换展开/收起状态 * 切换展开/收起状态
*/ */
const toggleExpand = () => { const toggleExpand = () => {
isExpanded.value = !isExpanded.value isExpanded.value = !isExpanded.value
} }
/** /**
* 处理重置事件 * 处理重置事件
*/ */
const handleReset = () => { const handleReset = () => {
// 重置表单字段(UI 层) // 重置表单字段(UI 层)
formInstance.value?.resetFields() formInstance.value?.resetFields()
// 清空所有表单项值(包含隐藏项) // 清空所有表单项值(包含隐藏项)
Object.assign( Object.assign(
modelValue.value, modelValue.value,
Object.fromEntries(props.items.map(({ key }) => [key, undefined])) Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
) )
// 触发 reset 事件 // 触发 reset 事件
emit('reset') emit('reset')
} }
/** /**
* 处理搜索事件 * 处理搜索事件
*/ */
const handleSearch = () => { const handleSearch = () => {
emit('search') emit('search')
} }
defineExpose({ defineExpose({
ref: formInstance, ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args), validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset reset: handleReset
}) })
// 解构 props 以便在模板中直接使用 // 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props) const { span, gutter, labelPosition, labelWidth } = toRefs(props)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.art-search-bar { .art-search-bar {
padding: 15px 20px 0; padding: 15px 20px 0;
.action-column { .action-column {
flex: 1; flex: 1;
max-width: 100%; max-width: 100%;
.action-buttons-wrapper { .action-buttons-wrapper {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
margin-bottom: 12px; margin-bottom: 12px;
} }
.form-buttons { .form-buttons {
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.filter-toggle { .filter-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: 10px; margin-left: 10px;
line-height: 32px; line-height: 32px;
color: var(--theme-color); color: var(--theme-color);
cursor: pointer; cursor: pointer;
transition: color 0.2s ease; transition: color 0.2s ease;
&:hover { &:hover {
color: var(--ElColor-primary); color: var(--ElColor-primary);
} }
span { span {
font-size: 14px; font-size: 14px;
user-select: none; user-select: none;
} }
.icon-wrapper { .icon-wrapper {
display: flex; display: flex;
align-items: center; align-items: center;
margin-left: 4px; margin-left: 4px;
font-size: 14px; font-size: 14px;
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
} }
} }
} }
// 响应式优化 // 响应式优化
@media (width <= 768px) { @media (width <= 768px) {
.art-search-bar { .art-search-bar {
padding: 16px 16px 0; padding: 16px 16px 0;
.action-column { .action-column {
.action-buttons-wrapper { .action-buttons-wrapper {
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
.form-buttons { .form-buttons {
justify-content: center; justify-content: center;
} }
.filter-toggle { .filter-toggle {
justify-content: center; justify-content: center;
margin-left: 0; margin-left: 0;
} }
} }
} }
} }
} }
</style> </style>
@@ -1,219 +1,222 @@
<!-- WangEditor 富文本编辑器 插件地址https://www.wangeditor.com/ --> <!-- WangEditor 富文本编辑器 插件地址https://www.wangeditor.com/ -->
<template> <template>
<div class="editor-wrapper"> <div class="editor-wrapper">
<Toolbar <Toolbar
class="editor-toolbar" class="editor-toolbar"
:editor="editorRef" :editor="editorRef"
:mode="mode" :mode="mode"
:defaultConfig="toolbarConfig" :defaultConfig="toolbarConfig"
/> />
<Editor <Editor
:style="{ height: height, overflowY: 'hidden' }" :style="{ height: height, overflowY: 'hidden' }"
v-model="modelValue" v-model="modelValue"
:mode="mode" :mode="mode"
:defaultConfig="editorConfig" :defaultConfig="editorConfig"
@onCreated="onCreateEditor" @onCreated="onCreateEditor"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css' import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue' import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue' import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import EmojiText from '@/utils/ui/emojo' import EmojiText from '@/utils/ui/emojo'
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor' import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
defineOptions({ name: 'ArtWangEditor' }) defineOptions({ name: 'ArtWangEditor' })
// Props 定义 // Props 定义
interface Props { interface Props {
/** 编辑器高度 */ /** 编辑器高度 */
height?: string height?: string
/** 自定义工具栏配置 */ /** 自定义工具栏配置 */
toolbarKeys?: string[] toolbarKeys?: string[]
/** 插入新工具到指定位置 */ /** 插入新工具到指定位置 */
insertKeys?: { index: number; keys: string[] } insertKeys?: { index: number; keys: string[] }
/** 排除的工具栏项 */ /** 排除的工具栏项 */
excludeKeys?: string[] excludeKeys?: string[]
/** 编辑器模式 */ /** 编辑器模式 */
mode?: 'default' | 'simple' mode?: 'default' | 'simple'
/** 占位符文本 */ /** 占位符文本 */
placeholder?: string placeholder?: string
/** 上传配置 */ /** 上传配置 */
uploadConfig?: { uploadConfig?: {
maxFileSize?: number maxFileSize?: number
maxNumberOfFiles?: number maxNumberOfFiles?: number
server?: string server?: string
} }
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
height: '500px', height: '500px',
mode: 'default', mode: 'default',
placeholder: '请输入内容...', placeholder: '请输入内容...',
excludeKeys: () => ['fontFamily'] excludeKeys: () => ['fontFamily']
}) })
const modelValue = defineModel<string>({ required: true }) const modelValue = defineModel<string>({ required: true })
// 编辑器实例 // 编辑器实例
const editorRef = shallowRef<IDomEditor>() const editorRef = shallowRef<IDomEditor>()
const userStore = useUserStore() const userStore = useUserStore()
// 常量配置 // 常量配置
const DEFAULT_UPLOAD_CONFIG = { const DEFAULT_UPLOAD_CONFIG = {
maxFileSize: 3 * 1024 * 1024, // 3MB maxFileSize: 3 * 1024 * 1024, // 3MB
maxNumberOfFiles: 10, maxNumberOfFiles: 10,
fieldName: 'file', fieldName: 'file',
allowedFileTypes: ['image/*'] allowedFileTypes: ['image/*']
} as const } as const
// 计算属性:上传服务器地址 // 计算属性:上传服务器地址
const uploadServer = computed( const uploadServer = computed(
() => () =>
props.uploadConfig?.server || `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor` props.uploadConfig?.server ||
) `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
)
// 合并上传配置 // 合并上传配置
const mergedUploadConfig = computed(() => ({ const mergedUploadConfig = computed(() => ({
...DEFAULT_UPLOAD_CONFIG, ...DEFAULT_UPLOAD_CONFIG,
...props.uploadConfig ...props.uploadConfig
})) }))
// 工具栏配置 // 工具栏配置
const toolbarConfig = computed((): Partial<IToolbarConfig> => { const toolbarConfig = computed((): Partial<IToolbarConfig> => {
const config: Partial<IToolbarConfig> = {} const config: Partial<IToolbarConfig> = {}
// 完全自定义工具栏 // 完全自定义工具栏
if (props.toolbarKeys && props.toolbarKeys.length > 0) { if (props.toolbarKeys && props.toolbarKeys.length > 0) {
config.toolbarKeys = props.toolbarKeys config.toolbarKeys = props.toolbarKeys
} }
// 插入新工具 // 插入新工具
if (props.insertKeys) { if (props.insertKeys) {
config.insertKeys = props.insertKeys config.insertKeys = props.insertKeys
} }
// 排除工具 // 排除工具
if (props.excludeKeys && props.excludeKeys.length > 0) { if (props.excludeKeys && props.excludeKeys.length > 0) {
config.excludeKeys = props.excludeKeys config.excludeKeys = props.excludeKeys
} }
return config return config
}) })
// 编辑器配置 // 编辑器配置
const editorConfig: Partial<IEditorConfig> = { const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder, placeholder: props.placeholder,
MENU_CONF: { MENU_CONF: {
uploadImage: { uploadImage: {
fieldName: mergedUploadConfig.value.fieldName, fieldName: mergedUploadConfig.value.fieldName,
maxFileSize: mergedUploadConfig.value.maxFileSize, maxFileSize: mergedUploadConfig.value.maxFileSize,
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles, maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes, allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
server: uploadServer.value, server: uploadServer.value,
headers: { headers: {
Authorization: userStore.accessToken Authorization: userStore.accessToken
}, },
onSuccess() { onSuccess() {
ElMessage.success(`图片上传成功 ${EmojiText[200]}`) ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
}, },
onError(file: File, err: any, res: any) { onError(file: File, err: any, res: any) {
console.error('图片上传失败:', err, res) console.error('图片上传失败:', err, res)
ElMessage.error(`图片上传失败 ${EmojiText[500]}`) ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
} }
} }
} }
} }
// 编辑器创建回调 // 编辑器创建回调
const onCreateEditor = (editor: IDomEditor) => { const onCreateEditor = (editor: IDomEditor) => {
editorRef.value = editor editorRef.value = editor
// 监听全屏事件 // 监听全屏事件
editor.on('fullScreen', () => { editor.on('fullScreen', () => {
console.log('编辑器进入全屏模式') console.log('编辑器进入全屏模式')
}) })
// 确保在编辑器创建后应用自定义图标 // 确保在编辑器创建后应用自定义图标
applyCustomIcons() applyCustomIcons()
} }
// 应用自定义图标(带重试机制) // 应用自定义图标(带重试机制)
const applyCustomIcons = () => { const applyCustomIcons = () => {
let retryCount = 0 let retryCount = 0
const maxRetries = 10 const maxRetries = 10
const retryDelay = 100 const retryDelay = 100
const tryApplyIcons = () => { const tryApplyIcons = () => {
const editor = editorRef.value const editor = editorRef.value
if (!editor) { if (!editor) {
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
retryCount++ retryCount++
setTimeout(tryApplyIcons, retryDelay) setTimeout(tryApplyIcons, retryDelay)
} }
return return
} }
// 获取当前编辑器的工具栏容器 // 获取当前编辑器的工具栏容器
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper') const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
if (!editorContainer) { if (!editorContainer) {
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
retryCount++ retryCount++
setTimeout(tryApplyIcons, retryDelay) setTimeout(tryApplyIcons, retryDelay)
} }
return return
} }
const toolbar = editorContainer.querySelector('.w-e-toolbar') const toolbar = editorContainer.querySelector('.w-e-toolbar')
const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]') const toolbarButtons = editorContainer.querySelectorAll(
'.w-e-bar-item button[data-menu-key]'
)
if (toolbar && toolbarButtons.length > 0) { if (toolbar && toolbarButtons.length > 0) {
return return
} }
// 如果工具栏还没渲染完成,继续重试 // 如果工具栏还没渲染完成,继续重试
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
retryCount++ retryCount++
setTimeout(tryApplyIcons, retryDelay) setTimeout(tryApplyIcons, retryDelay)
} else { } else {
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id) console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
} }
} }
// 使用 requestAnimationFrame 确保在下一帧执行 // 使用 requestAnimationFrame 确保在下一帧执行
requestAnimationFrame(tryApplyIcons) requestAnimationFrame(tryApplyIcons)
} }
// 暴露编辑器实例和方法 // 暴露编辑器实例和方法
defineExpose({ defineExpose({
/** 获取编辑器实例 */ /** 获取编辑器实例 */
getEditor: () => editorRef.value, getEditor: () => editorRef.value,
/** 设置编辑器内容 */ /** 设置编辑器内容 */
setHtml: (html: string) => editorRef.value?.setHtml(html), setHtml: (html: string) => editorRef.value?.setHtml(html),
/** 获取编辑器内容 */ /** 获取编辑器内容 */
getHtml: () => editorRef.value?.getHtml(), getHtml: () => editorRef.value?.getHtml(),
/** 清空编辑器 */ /** 清空编辑器 */
clear: () => editorRef.value?.clear(), clear: () => editorRef.value?.clear(),
/** 聚焦编辑器 */ /** 聚焦编辑器 */
focus: () => editorRef.value?.focus() focus: () => editorRef.value?.focus()
}) })
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
// 图标替换已在 onCreateEditor 中处理 // 图标替换已在 onCreateEditor 中处理
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
const editor = editorRef.value const editor = editorRef.value
if (editor) { if (editor) {
editor.destroy() editor.destroy()
} }
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
@use './style'; @use './style';
</style> </style>
@@ -2,209 +2,209 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
// 全屏容器 z-index 调整 // 全屏容器 z-index 调整
.w-e-full-screen-container { .w-e-full-screen-container {
z-index: 100 !important; z-index: 100 !important;
} }
/* 编辑器容器 */ /* 编辑器容器 */
.editor-wrapper { .editor-wrapper {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 1px solid var(--art-gray-300); border: 1px solid var(--art-gray-300);
border-radius: $box-radius !important; border-radius: $box-radius !important;
.w-e-bar { .w-e-bar {
border-radius: $box-radius $box-radius 0 0 !important; border-radius: $box-radius $box-radius 0 0 !important;
} }
.menu-item { .menu-item {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
i { i {
margin-right: 5px; margin-right: 5px;
} }
} }
/* 工具栏 */ /* 工具栏 */
.editor-toolbar { .editor-toolbar {
border-bottom: 1px solid var(--default-border); border-bottom: 1px solid var(--default-border);
} }
/* 下拉选择框配置 */ /* 下拉选择框配置 */
.w-e-select-list { .w-e-select-list {
min-width: 140px; min-width: 140px;
padding: 5px 10px 10px; padding: 5px 10px 10px;
border: none; border: none;
border-radius: $box-radius; border-radius: $box-radius;
} }
/* 下拉选择框元素配置 */ /* 下拉选择框元素配置 */
.w-e-select-list ul li { .w-e-select-list ul li {
margin-top: 5px; margin-top: 5px;
font-size: 15px !important; font-size: 15px !important;
border-radius: $box-radius; border-radius: $box-radius;
} }
/* 下拉选择框 正文文字大小调整 */ /* 下拉选择框 正文文字大小调整 */
.w-e-select-list ul li:last-of-type { .w-e-select-list ul li:last-of-type {
font-size: 16px !important; font-size: 16px !important;
} }
/* 下拉选择框 hover 样式调整 */ /* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover { .w-e-select-list ul li:hover {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
} }
:root { :root {
/* 激活颜色 */ /* 激活颜色 */
--w-e-toolbar-active-bg-color: var(--art-gray-200); --w-e-toolbar-active-bg-color: var(--art-gray-200);
/* toolbar 图标和文字颜色 */ /* toolbar 图标和文字颜色 */
--w-e-toolbar-color: #000; --w-e-toolbar-color: #000;
/* 表格选中时候的边框颜色 */ /* 表格选中时候的边框颜色 */
--w-e-textarea-selected-border-color: #ddd; --w-e-textarea-selected-border-color: #ddd;
/* 表格头背景颜色 */ /* 表格头背景颜色 */
--w-e-textarea-slight-bg-color: var(--art-gray-200); --w-e-textarea-slight-bg-color: var(--art-gray-200);
} }
/* 工具栏按钮样式 */ /* 工具栏按钮样式 */
.w-e-bar-item svg { .w-e-bar-item svg {
fill: var(--art-gray-800); fill: var(--art-gray-800);
} }
.w-e-bar-item button { .w-e-bar-item button {
color: var(--art-gray-800); color: var(--art-gray-800);
border-radius: $box-radius; border-radius: $box-radius;
} }
/* 工具栏 hover 按钮背景颜色 */ /* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover { .w-e-bar-item button:hover {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
} }
/* 工具栏分割线 */ /* 工具栏分割线 */
.w-e-bar-divider { .w-e-bar-divider {
height: 20px; height: 20px;
margin-top: 10px; margin-top: 10px;
background-color: #ccc; background-color: #ccc;
} }
/* 工具栏菜单 */ /* 工具栏菜单 */
.w-e-bar-item-group .w-e-bar-item-menus-container { .w-e-bar-item-group .w-e-bar-item-menus-container {
min-width: 120px; min-width: 120px;
padding: 10px 0; padding: 10px 0;
border: none; border: none;
border-radius: $box-radius; border-radius: $box-radius;
.w-e-bar-item { .w-e-bar-item {
button { button {
width: 100%; width: 100%;
margin: 0 5px; margin: 0 5px;
} }
} }
} }
/* 代码块 */ /* 代码块 */
.w-e-text-container [data-slate-editor] pre > code { .w-e-text-container [data-slate-editor] pre > code {
padding: 0.6rem 1rem; padding: 0.6rem 1rem;
background-color: var(--art-gray-50); background-color: var(--art-gray-50);
border-radius: $box-radius; border-radius: $box-radius;
} }
/* 弹出框 */ /* 弹出框 */
.w-e-drop-panel { .w-e-drop-panel {
border: 0; border: 0;
border-radius: $box-radius; border-radius: $box-radius;
} }
a { a {
color: #318ef4; color: #318ef4;
} }
.w-e-text-container { .w-e-text-container {
strong, strong,
b { b {
font-weight: 500; font-weight: 500;
} }
i, i,
em { em {
font-style: italic; font-style: italic;
} }
} }
/* 表格样式优化 */ /* 表格样式优化 */
.w-e-text-container [data-slate-editor] .table-container th { .w-e-text-container [data-slate-editor] .table-container th {
border-right: none; border-right: none;
} }
.w-e-text-container [data-slate-editor] .table-container th:last-of-type { .w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid #ccc !important; border-right: 1px solid #ccc !important;
} }
/* 引用 */ /* 引用 */
.w-e-text-container [data-slate-editor] blockquote { .w-e-text-container [data-slate-editor] blockquote {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
border-left: 4px solid var(--art-gray-300); border-left: 4px solid var(--art-gray-300);
} }
/* 输入区域弹出 bar */ /* 输入区域弹出 bar */
.w-e-hover-bar { .w-e-hover-bar {
border-radius: $box-radius; border-radius: $box-radius;
} }
/* 超链接弹窗 */ /* 超链接弹窗 */
.w-e-modal { .w-e-modal {
border: none; border: none;
border-radius: $box-radius; border-radius: $box-radius;
} }
/* 图片样式调整 */ /* 图片样式调整 */
.w-e-text-container [data-slate-editor] .w-e-selected-image-container { .w-e-text-container [data-slate-editor] .w-e-selected-image-container {
overflow: inherit; overflow: inherit;
&:hover { &:hover {
border: 0; border: 0;
} }
img { img {
border: 1px solid transparent; border: 1px solid transparent;
transition: border 0.3s; transition: border 0.3s;
&:hover { &:hover {
border: 1px solid #318ef4 !important; border: 1px solid #318ef4 !important;
} }
} }
.w-e-image-dragger { .w-e-image-dragger {
width: 12px; width: 12px;
height: 12px; height: 12px;
background-color: #318ef4; background-color: #318ef4;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: $box-radius; border-radius: $box-radius;
} }
.left-top { .left-top {
top: -6px; top: -6px;
left: -6px; left: -6px;
} }
.right-top { .right-top {
top: -6px; top: -6px;
right: -6px; right: -6px;
} }
.left-bottom { .left-bottom {
bottom: -6px; bottom: -6px;
left: -6px; left: -6px;
} }
.right-bottom { .right-bottom {
right: -6px; right: -6px;
bottom: -6px; bottom: -6px;
} }
} }
} }
@@ -1,142 +1,142 @@
<!-- 面包屑导航 --> <!-- 面包屑导航 -->
<template> <template>
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb"> <nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
<ul class="flex-c h-full"> <ul class="flex-c h-full">
<li <li
v-for="(item, index) in breadcrumbItems" v-for="(item, index) in breadcrumbItems"
:key="item.path" :key="item.path"
class="box-border flex-c h-7 text-sm leading-7" class="box-border flex-c h-7 text-sm leading-7"
> >
<div <div
:class=" :class="
isClickable(item, index) isClickable(item, index)
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600' ? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
: '' : ''
" "
@click="handleBreadcrumbClick(item, index)" @click="handleBreadcrumbClick(item, index)"
> >
<span <span
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800" class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
>{{ formatMenuTitle(item.meta?.title as string) }}</span >{{ formatMenuTitle(item.meta?.title as string) }}</span
> >
</div> </div>
<div <div
v-if="!isLastItem(index) && item.meta?.title" v-if="!isLastItem(index) && item.meta?.title"
class="mx-1 text-sm not-italic text-g-500" class="mx-1 text-sm not-italic text-g-500"
aria-hidden="true" aria-hidden="true"
> >
/ /
</div> </div>
</li> </li>
</ul> </ul>
</nav> </nav>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router' import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
import { formatMenuTitle } from '@/utils/router' import { formatMenuTitle } from '@/utils/router'
defineOptions({ name: 'ArtBreadcrumb' }) defineOptions({ name: 'ArtBreadcrumb' })
export interface BreadcrumbItem { export interface BreadcrumbItem {
path: string path: string
meta: RouteRecordRaw['meta'] meta: RouteRecordRaw['meta']
} }
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// 使用computed替代watch,提高性能 // 使用computed替代watch,提高性能
const breadcrumbItems = computed<BreadcrumbItem[]>(() => { const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const { matched } = route const { matched } = route
const matchedLength = matched.length const matchedLength = matched.length
// 处理首页情况 // 处理首页情况
if (!matchedLength || isHomeRoute(matched[0])) { if (!matchedLength || isHomeRoute(matched[0])) {
return [] return []
} }
// 处理一级菜单和普通路由 // 处理一级菜单和普通路由
const firstRoute = matched[0] const firstRoute = matched[0]
const isFirstLevel = firstRoute.meta?.isFirstLevel const isFirstLevel = firstRoute.meta?.isFirstLevel
const lastIndex = matchedLength - 1 const lastIndex = matchedLength - 1
const currentRoute = matched[lastIndex] const currentRoute = matched[lastIndex]
const currentRouteMeta = currentRoute.meta const currentRouteMeta = currentRoute.meta
let items = isFirstLevel let items = isFirstLevel
? [createBreadcrumbItem(currentRoute)] ? [createBreadcrumbItem(currentRoute)]
: matched.map(createBreadcrumbItem) : matched.map(createBreadcrumbItem)
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它 // 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
if (items.length > 1 && isWrapperContainer(items[0])) { if (items.length > 1 && isWrapperContainer(items[0])) {
items = items.slice(1) items = items.slice(1)
} }
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页 // IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) { if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
return [createBreadcrumbItem(currentRoute)] return [createBreadcrumbItem(currentRoute)]
} }
return items return items
}) })
// 辅助函数:判断是否为包裹容器路由 // 辅助函数:判断是否为包裹容器路由
const isWrapperContainer = (item: BreadcrumbItem): boolean => const isWrapperContainer = (item: BreadcrumbItem): boolean =>
item.path === '/outside' && !!item.meta?.isIframe item.path === '/outside' && !!item.meta?.isIframe
// 辅助函数:创建面包屑项目 // 辅助函数:创建面包屑项目
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({ const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
path: route.path, path: route.path,
meta: route.meta meta: route.meta
}) })
// 辅助函数:判断是否为首页 // 辅助函数:判断是否为首页
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/' const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
// 辅助函数:判断是否为最后一项 // 辅助函数:判断是否为最后一项
const isLastItem = (index: number): boolean => { const isLastItem = (index: number): boolean => {
const itemsLength = breadcrumbItems.value.length const itemsLength = breadcrumbItems.value.length
return index === itemsLength - 1 return index === itemsLength - 1
} }
// 辅助函数:判断是否可点击 // 辅助函数:判断是否可点击
const isClickable = (item: BreadcrumbItem, index: number): boolean => const isClickable = (item: BreadcrumbItem, index: number): boolean =>
item.path !== '/outside' && !isLastItem(index) item.path !== '/outside' && !isLastItem(index)
// 辅助函数:查找路由的第一个有效子路由 // 辅助函数:查找路由的第一个有效子路由
const findFirstValidChild = (route: RouteRecordRaw) => const findFirstValidChild = (route: RouteRecordRaw) =>
route.children?.find((child) => !child.redirect && !child.meta?.isHide) route.children?.find((child) => !child.redirect && !child.meta?.isHide)
// 辅助函数:构建完整路径 // 辅助函数:构建完整路径
const buildFullPath = (childPath: string): string => `/${childPath}`.replace('//', '/') const buildFullPath = (childPath: string): string => `/${childPath}`.replace('//', '/')
// 处理面包屑点击事件 // 处理面包屑点击事件
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> { async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
// 如果是最后一项或外部链接,不处理 // 如果是最后一项或外部链接,不处理
if (isLastItem(index) || item.path === '/outside') { if (isLastItem(index) || item.path === '/outside') {
return return
} }
try { try {
// 缓存路由表查找结果 // 缓存路由表查找结果
const routes = router.getRoutes() const routes = router.getRoutes()
const targetRoute = routes.find((route) => route.path === item.path) const targetRoute = routes.find((route) => route.path === item.path)
if (!targetRoute?.children?.length) { if (!targetRoute?.children?.length) {
await router.push(item.path) await router.push(item.path)
return return
} }
const firstValidChild = findFirstValidChild(targetRoute) const firstValidChild = findFirstValidChild(targetRoute)
if (firstValidChild) { if (firstValidChild) {
await router.push(buildFullPath(firstValidChild.path)) await router.push(buildFullPath(firstValidChild.path))
} else { } else {
await router.push(item.path) await router.push(item.path)
} }
} catch (error) { } catch (error) {
console.error('导航失败:', error) console.error('导航失败:', error)
} }
} }
</script> </script>
@@ -1,262 +1,279 @@
<!-- 系统聊天窗口 --> <!-- 系统聊天窗口 -->
<template> <template>
<div> <div>
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false"> <ElDrawer
<div class="mb-5 flex-cb"> v-model="isDrawerVisible"
<div> :size="isMobile ? '100%' : '480px'"
<span class="text-base font-medium">Art Bot</span> :with-header="false"
<div class="mt-1.5 flex-c gap-1"> >
<div <div class="mb-5 flex-cb">
class="h-2 w-2 rounded-full" <div>
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'" <span class="text-base font-medium">Art Bot</span>
></div> <div class="mt-1.5 flex-c gap-1">
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span> <div
</div> class="h-2 w-2 rounded-full"
</div> :class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
<div> ></div>
<ElIcon class="c-p" :size="20" @click="closeChat"> <span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
<Close /> </div>
</ElIcon> </div>
</div> <div>
</div> <ElIcon class="c-p" :size="20" @click="closeChat">
<div class="flex h-[calc(100%-70px)] flex-col"> <Close />
<!-- 聊天消息区域 --> </ElIcon>
<div </div>
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1" </div>
ref="messageContainer" <div class="flex h-[calc(100%-70px)] flex-col">
> <!-- 聊天消息区域 -->
<template v-for="(message, index) in messages" :key="index"> <div
<div class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
:class="[ ref="messageContainer"
'mb-7.5 flex w-full items-start gap-2', >
message.isMe ? 'flex-row-reverse' : 'flex-row' <template v-for="(message, index) in messages" :key="index">
]" <div
> :class="[
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" /> 'mb-7.5 flex w-full items-start gap-2',
<div message.isMe ? 'flex-row-reverse' : 'flex-row'
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']" ]"
> >
<div <ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
:class="[ <div
'mb-1 flex gap-2 text-xs', :class="[
message.isMe ? 'flex-row-reverse' : 'flex-row' 'flex max-w-[70%] flex-col',
]" message.isMe ? 'items-end' : 'items-start'
> ]"
<span class="font-medium">{{ message.sender }}</span> >
<span class="text-g-600">{{ message.time }}</span> <div
</div> :class="[
<div 'mb-1 flex gap-2 text-xs',
:class="[ message.isMe ? 'flex-row-reverse' : 'flex-row'
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900', ]"
message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50' >
]" <span class="font-medium">{{ message.sender }}</span>
>{{ message.content }}</div <span class="text-g-600">{{ message.time }}</span>
> </div>
</div> <div
</div> :class="[
</template> 'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
</div> message.isMe
? 'message-right bg-theme/15'
: 'message-left bg-g-300/50'
]"
>{{ message.content }}</div
>
</div>
</div>
</template>
</div>
<!-- 聊天输入区域 --> <!-- 聊天输入区域 -->
<div class="px-4 pt-4"> <div class="px-4 pt-4">
<ElInput <ElInput
v-model="messageText" v-model="messageText"
type="textarea" type="textarea"
:rows="3" :rows="3"
placeholder="输入消息" placeholder="输入消息"
resize="none" resize="none"
@keyup.enter.prevent="sendMessage" @keyup.enter.prevent="sendMessage"
> >
<template #append> <template #append>
<div class="flex gap-2 py-2"> <div class="flex gap-2 py-2">
<ElButton :icon="Paperclip" circle plain /> <ElButton :icon="Paperclip" circle plain />
<ElButton :icon="Picture" circle plain /> <ElButton :icon="Picture" circle plain />
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton> <ElButton type="primary" @click="sendMessage" v-ripple
</div> >发送</ElButton
</template> >
</ElInput> </div>
<div class="mt-3 flex-cb"> </template>
<div class="flex-c"> </ElInput>
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" /> <div class="mt-3 flex-cb">
<ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" /> <div class="flex-c">
</div> <ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton> <ArtSvgIcon
</div> icon="ri:emotion-happy-line"
</div> class="mr-5 c-p text-g-600 text-lg"
</div> />
</ElDrawer> </div>
</div> <ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20"
>发送</ElButton
>
</div>
</div>
</div>
</ElDrawer>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Picture, Paperclip, Close } from '@element-plus/icons-vue' import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
import meAvatar from '@/assets/images/avatar/avatar5.webp' import meAvatar from '@/assets/images/avatar/avatar5.webp'
import aiAvatar from '@/assets/images/avatar/avatar10.webp' import aiAvatar from '@/assets/images/avatar/avatar10.webp'
defineOptions({ name: 'ArtChatWindow' }) defineOptions({ name: 'ArtChatWindow' })
// 类型定义 // 类型定义
interface ChatMessage { interface ChatMessage {
id: number id: number
sender: string sender: string
content: string content: string
time: string time: string
isMe: boolean isMe: boolean
avatar: string avatar: string
} }
// 常量定义 // 常量定义
const MOBILE_BREAKPOINT = 640 const MOBILE_BREAKPOINT = 640
const SCROLL_DELAY = 100 const SCROLL_DELAY = 100
const BOT_NAME = 'Art Bot' const BOT_NAME = 'Art Bot'
const USER_NAME = 'Ricky' const USER_NAME = 'Ricky'
// 响应式布局 // 响应式布局
const { width } = useWindowSize() const { width } = useWindowSize()
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT) const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
// 组件状态 // 组件状态
const isDrawerVisible = ref(false) const isDrawerVisible = ref(false)
const isOnline = ref(true) const isOnline = ref(true)
// 消息相关状态 // 消息相关状态
const messageText = ref('') const messageText = ref('')
const messageId = ref(10) const messageId = ref(10)
const messageContainer = ref<HTMLElement | null>(null) const messageContainer = ref<HTMLElement | null>(null)
// 初始化聊天消息数据 // 初始化聊天消息数据
const initializeMessages = (): ChatMessage[] => [ const initializeMessages = (): ChatMessage[] => [
{ {
id: 1, id: 1,
sender: BOT_NAME, sender: BOT_NAME,
content: '你好!我是你的AI助手,有什么我可以帮你的吗?', content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
time: '10:00', time: '10:00',
isMe: false, isMe: false,
avatar: aiAvatar avatar: aiAvatar
}, },
{ {
id: 2, id: 2,
sender: USER_NAME, sender: USER_NAME,
content: '我想了解一下系统的使用方法。', content: '我想了解一下系统的使用方法。',
time: '10:01', time: '10:01',
isMe: true, isMe: true,
avatar: meAvatar avatar: meAvatar
}, },
{ {
id: 3, id: 3,
sender: BOT_NAME, sender: BOT_NAME,
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...', content:
time: '10:02', '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
isMe: false, time: '10:02',
avatar: aiAvatar isMe: false,
}, avatar: aiAvatar
{ },
id: 4, {
sender: USER_NAME, id: 4,
content: '听起来很不错,能具体讲讲数据分析部分吗?', sender: USER_NAME,
time: '10:05', content: '听起来很不错,能具体讲讲数据分析部分吗?',
isMe: true, time: '10:05',
avatar: meAvatar isMe: true,
}, avatar: meAvatar
{ },
id: 5, {
sender: BOT_NAME, id: 5,
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...', sender: BOT_NAME,
time: '10:06', content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
isMe: false, time: '10:06',
avatar: aiAvatar isMe: false,
}, avatar: aiAvatar
{ },
id: 6, {
sender: USER_NAME, id: 6,
content: '太好了,那我如何开始使用呢?', sender: USER_NAME,
time: '10:08', content: '太好了,那我如何开始使用呢?',
isMe: true, time: '10:08',
avatar: meAvatar isMe: true,
}, avatar: meAvatar
{ },
id: 7, {
sender: BOT_NAME, id: 7,
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。', sender: BOT_NAME,
time: '10:09', content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
isMe: false, time: '10:09',
avatar: aiAvatar isMe: false,
}, avatar: aiAvatar
{ },
id: 8, {
sender: USER_NAME, id: 8,
content: '明白了,谢谢你的帮助!', sender: USER_NAME,
time: '10:10', content: '明白了,谢谢你的帮助!',
isMe: true, time: '10:10',
avatar: meAvatar isMe: true,
}, avatar: meAvatar
{ },
id: 9, {
sender: BOT_NAME, id: 9,
content: '不客气,有任何问题随时联系我。', sender: BOT_NAME,
time: '10:11', content: '不客气,有任何问题随时联系我。',
isMe: false, time: '10:11',
avatar: aiAvatar isMe: false,
} avatar: aiAvatar
] }
]
const messages = ref<ChatMessage[]>(initializeMessages()) const messages = ref<ChatMessage[]>(initializeMessages())
// 工具函数 // 工具函数
const formatCurrentTime = (): string => { const formatCurrentTime = (): string => {
return new Date().toLocaleTimeString([], { return new Date().toLocaleTimeString([], {
hour: '2-digit', hour: '2-digit',
minute: '2-digit' minute: '2-digit'
}) })
} }
const scrollToBottom = (): void => { const scrollToBottom = (): void => {
nextTick(() => { nextTick(() => {
setTimeout(() => { setTimeout(() => {
if (messageContainer.value) { if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight messageContainer.value.scrollTop = messageContainer.value.scrollHeight
} }
}, SCROLL_DELAY) }, SCROLL_DELAY)
}) })
} }
// 消息处理方法 // 消息处理方法
const sendMessage = (): void => { const sendMessage = (): void => {
const text = messageText.value.trim() const text = messageText.value.trim()
if (!text) return if (!text) return
const newMessage: ChatMessage = { const newMessage: ChatMessage = {
id: messageId.value++, id: messageId.value++,
sender: USER_NAME, sender: USER_NAME,
content: text, content: text,
time: formatCurrentTime(), time: formatCurrentTime(),
isMe: true, isMe: true,
avatar: meAvatar avatar: meAvatar
} }
messages.value.push(newMessage) messages.value.push(newMessage)
messageText.value = '' messageText.value = ''
scrollToBottom() scrollToBottom()
} }
// 聊天窗口控制方法 // 聊天窗口控制方法
const openChat = (): void => { const openChat = (): void => {
isDrawerVisible.value = true isDrawerVisible.value = true
scrollToBottom() scrollToBottom()
} }
const closeChat = (): void => { const closeChat = (): void => {
isDrawerVisible.value = false isDrawerVisible.value = false
} }
// 生命周期 // 生命周期
onMounted(() => { onMounted(() => {
scrollToBottom() scrollToBottom()
mittBus.on('openChat', openChat) mittBus.on('openChat', openChat)
}) })
onUnmounted(() => { onUnmounted(() => {
mittBus.off('openChat', openChat) mittBus.off('openChat', openChat)
}) })
</script> </script>
@@ -1,113 +1,117 @@
<!-- 顶部快速入口面板 --> <!-- 顶部快速入口面板 -->
<template> <template>
<ElPopover <ElPopover
ref="popoverRef" ref="popoverRef"
:width="700" :width="700"
:offset="0" :offset="0"
:show-arrow="false" :show-arrow="false"
trigger="hover" trigger="hover"
placement="bottom-start" placement="bottom-start"
popper-class="fast-enter-popover" popper-class="fast-enter-popover"
:popper-style="{ :popper-style="{
border: '1px solid var(--default-border)', border: '1px solid var(--default-border)',
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)' borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
}" }"
> >
<template #reference> <template #reference>
<div class="flex-c gap-2"> <div class="flex-c gap-2">
<slot /> <slot />
</div> </div>
</template> </template>
<div class="grid grid-cols-[2fr_0.8fr]"> <div class="grid grid-cols-[2fr_0.8fr]">
<div> <div>
<div class="grid grid-cols-2 gap-1.5"> <div class="grid grid-cols-2 gap-1.5">
<!-- 应用列表 --> <!-- 应用列表 -->
<div <div
v-for="application in enabledApplications" v-for="application in enabledApplications"
:key="application.name" :key="application.name"
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent" class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent"
@click="handleApplicationClick(application)" @click="handleApplicationClick(application)"
> >
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30"> <div
<ArtSvgIcon class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30"
class="text-xl" >
:icon="application.icon" <ArtSvgIcon
:style="{ color: application.iconColor }" class="text-xl"
/> :icon="application.icon"
</div> :style="{ color: application.iconColor }"
<div> />
<h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3> </div>
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p> <div>
</div> <h3 class="m-0 text-sm font-medium text-g-800">{{
</div> application.name
</div> }}</h3>
</div> <p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
</div>
</div>
</div>
</div>
<div class="border-l-d pl-6 pt-2"> <div class="border-l-d pl-6 pt-2">
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3> <h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
<ul> <ul>
<li <li
v-for="quickLink in enabledQuickLinks" v-for="quickLink in enabledQuickLinks"
:key="quickLink.name" :key="quickLink.name"
class="c-p py-2 hover:[&_span]:text-theme" class="c-p py-2 hover:[&_span]:text-theme"
@click="handleQuickLinkClick(quickLink)" @click="handleQuickLinkClick(quickLink)"
> >
<span class="text-g-600 no-underline">{{ quickLink.name }}</span> <span class="text-g-600 no-underline">{{ quickLink.name }}</span>
</li> </li>
</ul> </ul>
</div> </div>
</div> </div>
</ElPopover> </ElPopover>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useFastEnter } from '@/hooks/core/useFastEnter' import { useFastEnter } from '@/hooks/core/useFastEnter'
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config' import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
defineOptions({ name: 'ArtFastEnter' }) defineOptions({ name: 'ArtFastEnter' })
const router = useRouter() const router = useRouter()
const popoverRef = ref() const popoverRef = ref()
// 使用快速入口配置 // 使用快速入口配置
const { enabledApplications, enabledQuickLinks } = useFastEnter() const { enabledApplications, enabledQuickLinks } = useFastEnter()
/** /**
* 处理导航跳转 * 处理导航跳转
* @param routeName 路由名称 * @param routeName 路由名称
* @param link 外部链接 * @param link 外部链接
*/ */
const handleNavigate = (routeName?: string, link?: string): void => { const handleNavigate = (routeName?: string, link?: string): void => {
const targetPath = routeName || link const targetPath = routeName || link
if (!targetPath) { if (!targetPath) {
console.warn('导航配置无效:缺少路由名称或链接') console.warn('导航配置无效:缺少路由名称或链接')
return return
} }
if (targetPath.startsWith('http')) { if (targetPath.startsWith('http')) {
window.open(targetPath, '_blank') window.open(targetPath, '_blank')
} else { } else {
router.push({ name: targetPath }) router.push({ name: targetPath })
} }
popoverRef.value?.hide() popoverRef.value?.hide()
} }
/** /**
* 处理应用项点击 * 处理应用项点击
* @param application 应用配置对象 * @param application 应用配置对象
*/ */
const handleApplicationClick = (application: FastEnterApplication): void => { const handleApplicationClick = (application: FastEnterApplication): void => {
handleNavigate(application.routeName, application.link) handleNavigate(application.routeName, application.link)
} }
/** /**
* 处理快速链接点击 * 处理快速链接点击
* @param quickLink 快速链接配置对象 * @param quickLink 快速链接配置对象
*/ */
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => { const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
handleNavigate(quickLink.routeName, quickLink.link) handleNavigate(quickLink.routeName, quickLink.link)
} }
</script> </script>
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,14 @@
<!-- 全局组件 --> <!-- 全局组件 -->
<template> <template>
<component <component
v-for="componentConfig in enabledComponents" v-for="componentConfig in enabledComponents"
:key="componentConfig.key" :key="componentConfig.key"
:is="componentConfig.component" :is="componentConfig.component"
/> />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { getEnabledGlobalComponents } from '@/config/modules/component' import { getEnabledGlobalComponents } from '@/config/modules/component'
defineOptions({ name: 'ArtGlobalComponent' }) defineOptions({ name: 'ArtGlobalComponent' })
const enabledComponents = computed(() => getEnabledGlobalComponents()) const enabledComponents = computed(() => getEnabledGlobalComponents())
</script> </script>
@@ -1,417 +1,430 @@
<!-- 全局搜索组件 --> <!-- 全局搜索组件 -->
<template> <template>
<div class="layout-search"> <div class="layout-search">
<ElDialog <ElDialog
v-model="showSearchDialog" v-model="showSearchDialog"
width="600" width="600"
:show-close="false" :show-close="false"
:lock-scroll="false" :lock-scroll="false"
modal-class="search-modal" modal-class="search-modal"
@close="closeSearchDialog" @close="closeSearchDialog"
> >
<ElInput <ElInput
v-model.trim="searchVal" v-model.trim="searchVal"
:placeholder="$t('search.placeholder')" :placeholder="$t('search.placeholder')"
@input="search" @input="search"
@blur="searchBlur" @blur="searchBlur"
ref="searchInput" ref="searchInput"
:prefix-icon="Search" :prefix-icon="Search"
class="h-12" class="h-12"
> >
<template #suffix> <template #suffix>
<div <div
class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500" class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500"
> >
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" /> <ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
</div> </div>
</template> </template>
</ElInput> </ElInput>
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always> <ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
<div class="result w-full" v-show="searchResult.length"> <div class="result w-full" v-show="searchResult.length">
<div <div
class="box !mt-0 c-p text-base leading-none" class="box !mt-0 c-p text-base leading-none"
v-for="(item, index) in searchResult" v-for="(item, index) in searchResult"
:key="index" :key="index"
> >
<div <div
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700" class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
:class="isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''" :class="
@click="searchGoPage(item)" isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''
@mouseenter="highlightOnHover(index)" "
> @click="searchGoPage(item)"
{{ formatMenuTitle(item.meta.title) }} @mouseenter="highlightOnHover(index)"
<ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" /> >
</div> {{ formatMenuTitle(item.meta.title) }}
</div> <ArtSvgIcon
</div> v-show="isHighlighted(index)"
icon="fluent:arrow-enter-left-20-filled"
/>
</div>
</div>
</div>
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0"> <div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p> <p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
<div class="mt-1.5 w-full"> <div class="mt-1.5 w-full">
<div <div
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800" class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
v-for="(item, index) in historyResult" v-for="(item, index) in historyResult"
:key="index" :key="index"
:class=" :class="
historyHIndex === index historyHIndex === index
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white' ? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
: '' : ''
" "
@click="searchGoPage(item)" @click="searchGoPage(item)"
@mouseenter="highlightOnHoverHistory(index)" @mouseenter="highlightOnHoverHistory(index)"
> >
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title) }}
<div <div
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p" class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
@click.stop="deleteHistory(index)" @click.stop="deleteHistory(index)"
> >
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" /> <ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</ElScrollbar> </ElScrollbar>
<template #footer> <template #footer>
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1"> <div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
<div class="flex-cc"> <div class="flex-cc">
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" /> <ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.selectKeydown') }}</span> <span class="mr-3.5 text-xs text-g-700">{{
</div> $t('search.selectKeydown')
<div class="flex-c"> }}</span>
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" /> </div>
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" /> <div class="flex-c">
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.switchKeydown') }}</span> <ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
</div> <ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
<div class="flex-c"> <span class="mr-3.5 text-xs text-g-700">{{
<i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i> $t('search.switchKeydown')
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.exitKeydown') }}</span> }}</span>
</div> </div>
</div> <div class="flex-c">
</template> <i class="keyboard !w-8 flex-cc"
</ElDialog> ><p class="text-[10px] font-medium">ESC</p></i
</div> >
<span class="mr-3.5 text-xs text-g-700">{{
$t('search.exitKeydown')
}}</span>
</div>
</div>
</template>
</ElDialog>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { Search } from '@element-plus/icons-vue' import { Search } from '@element-plus/icons-vue'
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu' import { useMenuStore } from '@/store/modules/menu'
import { formatMenuTitle } from '@/utils/router' import { formatMenuTitle } from '@/utils/router'
import { type ScrollbarInstance } from 'element-plus' import { type ScrollbarInstance } from 'element-plus'
defineOptions({ name: 'ArtGlobalSearch' }) defineOptions({ name: 'ArtGlobalSearch' })
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { menuList } = storeToRefs(useMenuStore()) const { menuList } = storeToRefs(useMenuStore())
const showSearchDialog = ref(false) const showSearchDialog = ref(false)
const searchVal = ref('') const searchVal = ref('')
const searchResult = ref<AppRouteRecord[]>([]) const searchResult = ref<AppRouteRecord[]>([])
const historyMaxLength = 10 const historyMaxLength = 10
const { searchHistory: historyResult } = storeToRefs(userStore) const { searchHistory: historyResult } = storeToRefs(userStore)
const searchInput = ref<HTMLInputElement | null>(null) const searchInput = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(0) const highlightedIndex = ref(0)
const historyHIndex = ref(0) const historyHIndex = ref(0)
const searchResultScrollbar = ref<ScrollbarInstance>() const searchResultScrollbar = ref<ScrollbarInstance>()
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航 const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
// 生命周期钩子 // 生命周期钩子
onMounted(() => { onMounted(() => {
mittBus.on('openSearchDialog', openSearchDialog) mittBus.on('openSearchDialog', openSearchDialog)
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
}) })
// 键盘快捷键处理 // 键盘快捷键处理
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0 const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
const isCommandKey = isMac ? event.metaKey : event.ctrlKey const isCommandKey = isMac ? event.metaKey : event.ctrlKey
if (isCommandKey && event.key.toLowerCase() === 'k') { if (isCommandKey && event.key.toLowerCase() === 'k') {
event.preventDefault() event.preventDefault()
showSearchDialog.value = true showSearchDialog.value = true
focusInput() focusInput()
} }
// 当搜索对话框打开时,处理方向键和回车键 // 当搜索对话框打开时,处理方向键和回车键
if (showSearchDialog.value) { if (showSearchDialog.value) {
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
event.preventDefault() event.preventDefault()
highlightPrevious() highlightPrevious()
} else if (event.key === 'ArrowDown') { } else if (event.key === 'ArrowDown') {
event.preventDefault() event.preventDefault()
highlightNext() highlightNext()
} else if (event.key === 'Enter') { } else if (event.key === 'Enter') {
event.preventDefault() event.preventDefault()
selectHighlighted() selectHighlighted()
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') {
event.preventDefault() event.preventDefault()
showSearchDialog.value = false showSearchDialog.value = false
} }
} }
} }
const focusInput = () => { const focusInput = () => {
setTimeout(() => { setTimeout(() => {
searchInput.value?.focus() searchInput.value?.focus()
}, 100) }, 100)
} }
// 搜索逻辑 // 搜索逻辑
const search = (val: string) => { const search = (val: string) => {
if (val) { if (val) {
searchResult.value = flattenAndFilterMenuItems(menuList.value, val) searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
} else { } else {
searchResult.value = [] searchResult.value = []
} }
} }
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => { const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
const lowerVal = val.toLowerCase() const lowerVal = val.toLowerCase()
const result: AppRouteRecord[] = [] const result: AppRouteRecord[] = []
const flattenAndMatch = (item: AppRouteRecord) => { const flattenAndMatch = (item: AppRouteRecord) => {
if (item.meta?.isHide) return if (item.meta?.isHide) return
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase() const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase()
if (item.children && item.children.length > 0) { if (item.children && item.children.length > 0) {
item.children.forEach(flattenAndMatch) item.children.forEach(flattenAndMatch)
return return
} }
if (lowerItemTitle.includes(lowerVal) && item.path) { if (lowerItemTitle.includes(lowerVal) && item.path) {
result.push({ ...item, children: undefined }) result.push({ ...item, children: undefined })
} }
} }
items.forEach(flattenAndMatch) items.forEach(flattenAndMatch)
return result return result
} }
// 高亮控制并实现滚动条跟随 // 高亮控制并实现滚动条跟随
const highlightPrevious = () => { const highlightPrevious = () => {
isKeyboardNavigating.value = true isKeyboardNavigating.value = true
if (searchVal.value) { if (searchVal.value) {
highlightedIndex.value = highlightedIndex.value =
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length (highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
scrollToHighlightedItem() scrollToHighlightedItem()
} else { } else {
historyHIndex.value = historyHIndex.value =
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length (historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
scrollToHighlightedHistoryItem() scrollToHighlightedHistoryItem()
} }
// 延迟重置键盘导航状态,防止立即被 hover 覆盖 // 延迟重置键盘导航状态,防止立即被 hover 覆盖
setTimeout(() => { setTimeout(() => {
isKeyboardNavigating.value = false isKeyboardNavigating.value = false
}, 100) }, 100)
} }
const highlightNext = () => { const highlightNext = () => {
isKeyboardNavigating.value = true isKeyboardNavigating.value = true
if (searchVal.value) { if (searchVal.value) {
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
scrollToHighlightedItem() scrollToHighlightedItem()
} else { } else {
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
scrollToHighlightedHistoryItem() scrollToHighlightedHistoryItem()
} }
setTimeout(() => { setTimeout(() => {
isKeyboardNavigating.value = false isKeyboardNavigating.value = false
}, 100) }, 100)
} }
const scrollToHighlightedItem = () => { const scrollToHighlightedItem = () => {
nextTick(() => { nextTick(() => {
if (!searchResultScrollbar.value || !searchResult.value.length) return if (!searchResultScrollbar.value || !searchResult.value.length) return
const scrollWrapper = searchResultScrollbar.value.wrapRef const scrollWrapper = searchResultScrollbar.value.wrapRef
if (!scrollWrapper) return if (!scrollWrapper) return
const highlightedElements = scrollWrapper.querySelectorAll('.result .box') const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
if (!highlightedElements[highlightedIndex.value]) return if (!highlightedElements[highlightedIndex.value]) return
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
const itemHeight = highlightedElement.offsetHeight const itemHeight = highlightedElement.offsetHeight
const scrollTop = scrollWrapper.scrollTop const scrollTop = scrollWrapper.scrollTop
const containerHeight = scrollWrapper.clientHeight const containerHeight = scrollWrapper.clientHeight
const itemTop = highlightedElement.offsetTop const itemTop = highlightedElement.offsetTop
const itemBottom = itemTop + itemHeight const itemBottom = itemTop + itemHeight
if (itemTop < scrollTop) { if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop) searchResultScrollbar.value.setScrollTop(itemTop)
} else if (itemBottom > scrollTop + containerHeight) { } else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight) searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
} }
}) })
} }
const scrollToHighlightedHistoryItem = () => { const scrollToHighlightedHistoryItem = () => {
nextTick(() => { nextTick(() => {
if (!searchResultScrollbar.value || !historyResult.value.length) return if (!searchResultScrollbar.value || !historyResult.value.length) return
const scrollWrapper = searchResultScrollbar.value.wrapRef const scrollWrapper = searchResultScrollbar.value.wrapRef
if (!scrollWrapper) return if (!scrollWrapper) return
const historyItems = scrollWrapper.querySelectorAll('.history-result .box') const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
if (!historyItems[historyHIndex.value]) return if (!historyItems[historyHIndex.value]) return
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
const itemHeight = highlightedElement.offsetHeight const itemHeight = highlightedElement.offsetHeight
const scrollTop = scrollWrapper.scrollTop const scrollTop = scrollWrapper.scrollTop
const containerHeight = scrollWrapper.clientHeight const containerHeight = scrollWrapper.clientHeight
const itemTop = highlightedElement.offsetTop const itemTop = highlightedElement.offsetTop
const itemBottom = itemTop + itemHeight const itemBottom = itemTop + itemHeight
if (itemTop < scrollTop) { if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop) searchResultScrollbar.value.setScrollTop(itemTop)
} else if (itemBottom > scrollTop + containerHeight) { } else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight) searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
} }
}) })
} }
const selectHighlighted = () => { const selectHighlighted = () => {
if (searchVal.value && searchResult.value.length) { if (searchVal.value && searchResult.value.length) {
searchGoPage(searchResult.value[highlightedIndex.value]) searchGoPage(searchResult.value[highlightedIndex.value])
} else if (!searchVal.value && historyResult.value.length) { } else if (!searchVal.value && historyResult.value.length) {
searchGoPage(historyResult.value[historyHIndex.value]) searchGoPage(historyResult.value[historyHIndex.value])
} }
} }
const isHighlighted = (index: number) => { const isHighlighted = (index: number) => {
return highlightedIndex.value === index return highlightedIndex.value === index
} }
const searchBlur = () => { const searchBlur = () => {
highlightedIndex.value = 0 highlightedIndex.value = 0
} }
const searchGoPage = (item: AppRouteRecord) => { const searchGoPage = (item: AppRouteRecord) => {
showSearchDialog.value = false showSearchDialog.value = false
addHistory(item) addHistory(item)
router.push(item.path) router.push(item.path)
searchVal.value = '' searchVal.value = ''
searchResult.value = [] searchResult.value = []
} }
// 历史记录管理 // 历史记录管理
const updateHistory = () => { const updateHistory = () => {
if (Array.isArray(historyResult.value)) { if (Array.isArray(historyResult.value)) {
userStore.setSearchHistory(historyResult.value) userStore.setSearchHistory(historyResult.value)
} }
} }
const addHistory = (item: AppRouteRecord) => { const addHistory = (item: AppRouteRecord) => {
const hasItemIndex = historyResult.value.findIndex( const hasItemIndex = historyResult.value.findIndex(
(historyItem: AppRouteRecord) => historyItem.path === item.path (historyItem: AppRouteRecord) => historyItem.path === item.path
) )
if (hasItemIndex !== -1) { if (hasItemIndex !== -1) {
historyResult.value.splice(hasItemIndex, 1) historyResult.value.splice(hasItemIndex, 1)
} else if (historyResult.value.length >= historyMaxLength) { } else if (historyResult.value.length >= historyMaxLength) {
historyResult.value.pop() historyResult.value.pop()
} }
const cleanedItem = { ...item } const cleanedItem = { ...item }
delete cleanedItem.children delete cleanedItem.children
delete cleanedItem.meta.authList delete cleanedItem.meta.authList
historyResult.value.unshift(cleanedItem) historyResult.value.unshift(cleanedItem)
updateHistory() updateHistory()
} }
const deleteHistory = (index: number) => { const deleteHistory = (index: number) => {
historyResult.value.splice(index, 1) historyResult.value.splice(index, 1)
updateHistory() updateHistory()
} }
// 对话框控制 // 对话框控制
const openSearchDialog = () => { const openSearchDialog = () => {
showSearchDialog.value = true showSearchDialog.value = true
focusInput() focusInput()
} }
const closeSearchDialog = () => { const closeSearchDialog = () => {
searchVal.value = '' searchVal.value = ''
searchResult.value = [] searchResult.value = []
highlightedIndex.value = 0 highlightedIndex.value = 0
historyHIndex.value = 0 historyHIndex.value = 0
} }
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效 // 修改 hover 高亮逻辑,只有在非键盘导航时才生效
const highlightOnHover = (index: number) => { const highlightOnHover = (index: number) => {
if (!isKeyboardNavigating.value && searchVal.value) { if (!isKeyboardNavigating.value && searchVal.value) {
highlightedIndex.value = index highlightedIndex.value = index
} }
} }
const highlightOnHoverHistory = (index: number) => { const highlightOnHoverHistory = (index: number) => {
if (!isKeyboardNavigating.value && !searchVal.value) { if (!isKeyboardNavigating.value && !searchVal.value) {
historyHIndex.value = index historyHIndex.value = index
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.layout-search { .layout-search {
:deep(.search-modal) { :deep(.search-modal) {
background-color: rgb(0 0 0 / 20%); background-color: rgb(0 0 0 / 20%);
} }
:deep(.el-dialog__body) { :deep(.el-dialog__body) {
padding: 5px 0 0 !important; padding: 5px 0 0 !important;
} }
:deep(.el-dialog__header) { :deep(.el-dialog__header) {
padding: 0; padding: 0;
} }
.el-input { .el-input {
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
border: 1px solid var(--default-border-dashed); border: 1px solid var(--default-border-dashed);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important; border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
box-shadow: none; box-shadow: none;
} }
:deep(.el-input__inner) { :deep(.el-input__inner) {
color: var(--art-gray-800) !important; color: var(--art-gray-800) !important;
} }
} }
} }
.dark .layout-search { .dark .layout-search {
.el-input { .el-input {
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
background-color: #333; background-color: #333;
border: 1px solid #4c4d50; border: 1px solid #4c4d50;
} }
} }
:deep(.search-modal) { :deep(.search-modal) {
background-color: rgb(23 23 26 / 60%); background-color: rgb(23 23 26 / 60%);
backdrop-filter: none; backdrop-filter: none;
} }
:deep(.el-dialog) { :deep(.el-dialog) {
background-color: #252526; background-color: #252526;
} }
} }
</style> </style>
<style scoped> <style scoped>
@reference '@styles/core/tailwind.css'; @reference '@styles/core/tailwind.css';
.keyboard { .keyboard {
@apply mr-2 @apply mr-2
box-border box-border
h-5 h-5
w-5.5 w-5.5
@@ -422,5 +435,5 @@
text-g-500 text-g-500
shadow-[0_2px_0_var(--default-border-dashed)] shadow-[0_2px_0_var(--default-border-dashed)]
last-of-type:mr-1.5; last-of-type:mr-1.5;
} }
</style> </style>
@@ -1,485 +1,509 @@
<!-- 顶部栏 --> <!-- 顶部栏 -->
<template> <template>
<div <div
class="w-full bg-[var(--default-bg-color)]" class="w-full bg-[var(--default-bg-color)]"
:class="[ :class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : '' tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
]" ]"
> >
<div <div
class="relative box-border flex-b h-15 leading-15 select-none" class="relative box-border flex-b h-15 leading-15 select-none"
:class="[ :class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' tabStyle === 'tab-card' || tabStyle === 'tab-google'
? 'border-b border-[var(--art-card-border)]' ? 'border-b border-[var(--art-card-border)]'
: '' : ''
]" ]"
> >
<div class="flex-c flex-1 min-w-0 leading-15" style="display: flex"> <div class="flex-c flex-1 min-w-0 leading-15" style="display: flex">
<!-- 系统信息 --> <!-- 系统信息 -->
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu"> <div class="flex-c c-p" @click="toHome" v-if="isTopMenu">
<ArtLogo class="pl-4.5" /> <ArtLogo class="pl-4.5" />
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ AppConfig.systemInfo.name }}</p> <p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{
</div> AppConfig.systemInfo.name
}}</p>
</div>
<ArtLogo <ArtLogo
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current" class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
@click="toHome" @click="toHome"
/> />
<!-- 菜单按钮 --> <!-- 菜单按钮 -->
<ArtIconButton <ArtIconButton
v-if="isLeftMenu && shouldShowMenuButton" v-if="isLeftMenu && shouldShowMenuButton"
icon="ri:menu-2-fill" icon="ri:menu-2-fill"
class="ml-3 max-sm:ml-[7px]" class="ml-3 max-sm:ml-[7px]"
@click="visibleMenu" @click="visibleMenu"
/> />
<!-- 刷新按钮 --> <!-- 刷新按钮 -->
<ArtIconButton <ArtIconButton
v-if="shouldShowRefreshButton" v-if="shouldShowRefreshButton"
icon="ri:refresh-line" icon="ri:refresh-line"
class="!ml-3 refresh-btn max-sm:!hidden" class="!ml-3 refresh-btn max-sm:!hidden"
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }" :style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
@click="reload" @click="reload"
/> />
<!-- 快速入口 --> <!-- 快速入口 -->
<ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth"> <ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
<ArtIconButton icon="ri:function-line" class="ml-3" /> <ArtIconButton icon="ri:function-line" class="ml-3" />
</ArtFastEnter> </ArtFastEnter>
<!-- 面包屑 --> <!-- 面包屑 -->
<ArtBreadcrumb <ArtBreadcrumb
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)" v-if="
/> (shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)
"
/>
<!-- 顶部菜单 --> <!-- 顶部菜单 -->
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" /> <ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
<!-- 混合菜单-顶部 --> <!-- 混合菜单-顶部 -->
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" /> <ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
</div> </div>
<div class="flex-c gap-2.5"> <div class="flex-c gap-2.5">
<!-- 搜索 --> <!-- 搜索 -->
<div <div
v-if="shouldShowGlobalSearch" v-if="shouldShowGlobalSearch"
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden" class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden"
@click="openSearchDialog" @click="openSearchDialog"
> >
<div class="flex-c"> <div class="flex-c">
<ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" /> <ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
<span class="ml-1 text-xs font-normal text-g-500">{{ $t('topBar.search.title') }}</span> <span class="ml-1 text-xs font-normal text-g-500">{{
</div> $t('topBar.search.title')
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded"> }}</span>
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" /> </div>
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" /> <div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
<span class="ml-0.5 text-xs">k</span> <ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
</div> <ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
</div> <span class="ml-0.5 text-xs">k</span>
</div>
</div>
<!-- 全屏按钮 --> <!-- 全屏按钮 -->
<ArtIconButton <ArtIconButton
v-if="shouldShowFullscreen" v-if="shouldShowFullscreen"
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'" :icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']" :class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
class="max-md:!hidden" class="max-md:!hidden"
@click="toggleFullScreen" @click="toggleFullScreen"
/> />
<!-- 国际化按钮 --> <!-- 国际化按钮 -->
<ElDropdown <ElDropdown
@command="changeLanguage" @command="changeLanguage"
popper-class="langDropDownStyle" popper-class="langDropDownStyle"
v-if="shouldShowLanguage" v-if="shouldShowLanguage"
> >
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" /> <ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
<template #dropdown> <template #dropdown>
<ElDropdownMenu> <ElDropdownMenu>
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item"> <div
<ElDropdownItem v-for="item in languageOptions"
:command="item.value" :key="item.value"
:class="{ 'is-selected': locale === item.value }" class="lang-btn-item"
> >
<span class="menu-txt">{{ item.label }}</span> <ElDropdownItem
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" /> :command="item.value"
</ElDropdownItem> :class="{ 'is-selected': locale === item.value }"
</div> >
</ElDropdownMenu> <span class="menu-txt">{{ item.label }}</span>
</template> <ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
</ElDropdown> </ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 通知按钮 --> <!-- 通知按钮 -->
<ArtIconButton <ArtIconButton
v-if="shouldShowNotification" v-if="shouldShowNotification"
icon="ri:notification-2-line" icon="ri:notification-2-line"
class="notice-button relative" class="notice-button relative"
@click="visibleNotice" @click="visibleNotice"
> >
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div> <div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
</ArtIconButton> </ArtIconButton>
<!-- 聊天按钮 --> <!-- 聊天按钮 -->
<ArtIconButton <ArtIconButton
v-if="shouldShowChat" v-if="shouldShowChat"
icon="ri:message-3-line" icon="ri:message-3-line"
class="chat-button relative" class="chat-button relative"
@click="openChat" @click="openChat"
> >
<div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div> <div
</ArtIconButton> class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"
></div>
</ArtIconButton>
<!-- 设置按钮 --> <!-- 设置按钮 -->
<div v-if="shouldShowSettings"> <div v-if="shouldShowSettings">
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0"> <ElPopover
<template #reference> :visible="showSettingGuide"
<div class="flex-cc"> placement="bottom-start"
<ArtIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" /> :width="190"
</div> :offset="0"
</template> >
<template #default> <template #reference>
<p <div class="flex-cc">
>{{ $t('topBar.guide.title') <ArtIconButton
}}<span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.theme') }} </span icon="ri:settings-line"
> <span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.menu') }} </span class="setting-btn"
>{{ $t('topBar.guide.description') }} @click="openSetting"
</p> />
</template> </div>
</ElPopover> </template>
</div> <template #default>
<p
>{{ $t('topBar.guide.title')
}}<span :style="{ color: systemThemeColor }">
{{ $t('topBar.guide.theme') }} </span
>、
<span :style="{ color: systemThemeColor }">
{{ $t('topBar.guide.menu') }} </span
>{{ $t('topBar.guide.description') }}
</p>
</template>
</ElPopover>
</div>
<!-- 主题切换按钮 --> <!-- 主题切换按钮 -->
<ArtIconButton <ArtIconButton
v-if="shouldShowThemeToggle" v-if="shouldShowThemeToggle"
@click="themeAnimation" @click="themeAnimation"
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'" :icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/> />
<!-- 用户头像菜单 --> <!-- 用户头像、菜单 -->
<ArtUserMenu /> <ArtUserMenu />
</div> </div>
</div> </div>
<!-- 标签页 --> <!-- 标签页 -->
<ArtWorkTab /> <ArtWorkTab />
<!-- 通知 --> <!-- 通知 -->
<ArtNotification v-model:value="showNotice" ref="notice" /> <ArtNotification v-model:value="showNotice" ref="notice" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useFullscreen, useWindowSize } from '@vueuse/core' import { useFullscreen, useWindowSize } from '@vueuse/core'
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum' import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu' import { useMenuStore } from '@/store/modules/menu'
import AppConfig from '@/config' import AppConfig from '@/config'
import { languageOptions } from '@/locales' import { languageOptions } from '@/locales'
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
import { themeAnimation } from '@/utils/ui/animation' import { themeAnimation } from '@/utils/ui/animation'
import { useCommon } from '@/hooks/core/useCommon' import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar' import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import ArtUserMenu from './widget/ArtUserMenu.vue' import ArtUserMenu from './widget/ArtUserMenu.vue'
defineOptions({ name: 'ArtHeaderBar' }) defineOptions({ name: 'ArtHeaderBar' })
// 检测操作系统类型 // 检测操作系统类型
const isWindows = navigator.userAgent.includes('Windows') const isWindows = navigator.userAgent.includes('Windows')
const router = useRouter() const router = useRouter()
const { locale } = useI18n() const { locale } = useI18n()
const { width } = useWindowSize() const { width } = useWindowSize()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const userStore = useUserStore() const userStore = useUserStore()
const menuStore = useMenuStore() const menuStore = useMenuStore()
// 顶部栏功能配置 // 顶部栏功能配置
const { const {
shouldShowMenuButton, shouldShowMenuButton,
shouldShowRefreshButton, shouldShowRefreshButton,
shouldShowFastEnter, shouldShowFastEnter,
shouldShowBreadcrumb, shouldShowBreadcrumb,
shouldShowGlobalSearch, shouldShowGlobalSearch,
shouldShowFullscreen, shouldShowFullscreen,
shouldShowNotification, shouldShowNotification,
shouldShowChat, shouldShowChat,
shouldShowLanguage, shouldShowLanguage,
shouldShowSettings, shouldShowSettings,
shouldShowThemeToggle, shouldShowThemeToggle,
fastEnterMinWidth: headerBarFastEnterMinWidth fastEnterMinWidth: headerBarFastEnterMinWidth
} = useHeaderBar() } = useHeaderBar()
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } = const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
storeToRefs(settingStore) storeToRefs(settingStore)
const { language } = storeToRefs(userStore) const { language } = storeToRefs(userStore)
const { menuList } = storeToRefs(menuStore) const { menuList } = storeToRefs(menuStore)
const showNotice = ref(false) const showNotice = ref(false)
const notice = ref(null) const notice = ref(null)
// 菜单类型判断 // 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT) const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU) const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP) const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT) const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen() const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
onMounted(() => { onMounted(() => {
initLanguage() initLanguage()
document.addEventListener('click', bodyCloseNotice) document.addEventListener('click', bodyCloseNotice)
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice) document.removeEventListener('click', bodyCloseNotice)
}) })
/** /**
* 切换全屏状态 * 切换全屏状态
*/ */
const toggleFullScreen = (): void => { const toggleFullScreen = (): void => {
toggleFullscreen() toggleFullscreen()
} }
/** /**
* 切换菜单显示/隐藏状态 * 切换菜单显示/隐藏状态
*/ */
const visibleMenu = (): void => { const visibleMenu = (): void => {
settingStore.setMenuOpen(!menuOpen.value) settingStore.setMenuOpen(!menuOpen.value)
} }
const { homePath } = useCommon() const { homePath } = useCommon()
const { refresh } = useCommon() const { refresh } = useCommon()
/** /**
* 跳转到首页 * 跳转到首页
*/ */
const toHome = (): void => { const toHome = (): void => {
router.push(homePath.value) router.push(homePath.value)
} }
/** /**
* 刷新页面 * 刷新页面
* @param {number} time - 延迟时间,默认为0毫秒 * @param {number} time - 延迟时间,默认为0毫秒
*/ */
const reload = (time: number = 0): void => { const reload = (time: number = 0): void => {
setTimeout(() => { setTimeout(() => {
refresh() refresh()
}, time) }, time)
} }
/** /**
* 初始化语言设置 * 初始化语言设置
*/ */
const initLanguage = (): void => { const initLanguage = (): void => {
locale.value = language.value locale.value = language.value
} }
/** /**
* 切换系统语言 * 切换系统语言
* @param {LanguageEnum} lang - 目标语言类型 * @param {LanguageEnum} lang - 目标语言类型
*/ */
const changeLanguage = (lang: LanguageEnum): void => { const changeLanguage = (lang: LanguageEnum): void => {
if (locale.value === lang) return if (locale.value === lang) return
locale.value = lang locale.value = lang
userStore.setLanguage(lang) userStore.setLanguage(lang)
reload(50) reload(50)
} }
/** /**
* 打开设置面板 * 打开设置面板
*/ */
const openSetting = (): void => { const openSetting = (): void => {
mittBus.emit('openSetting') mittBus.emit('openSetting')
// 隐藏设置引导提示 // 隐藏设置引导提示
if (showSettingGuide.value) { if (showSettingGuide.value) {
settingStore.hideSettingGuide() settingStore.hideSettingGuide()
} }
} }
/** /**
* 打开全局搜索对话框 * 打开全局搜索对话框
*/ */
const openSearchDialog = (): void => { const openSearchDialog = (): void => {
mittBus.emit('openSearchDialog') mittBus.emit('openSearchDialog')
} }
/** /**
* 点击页面其他区域关闭通知面板 * 点击页面其他区域关闭通知面板
* @param {Event} e - 点击事件对象 * @param {Event} e - 点击事件对象
*/ */
const bodyCloseNotice = (e: any): void => { const bodyCloseNotice = (e: any): void => {
if (!showNotice.value) return if (!showNotice.value) return
const target = e.target as HTMLElement const target = e.target as HTMLElement
// 检查是否点击了通知按钮或通知面板内部 // 检查是否点击了通知按钮或通知面板内部
const isNoticeButton = target.closest('.notice-button') const isNoticeButton = target.closest('.notice-button')
const isNoticePanel = target.closest('.art-notification-panel') const isNoticePanel = target.closest('.art-notification-panel')
if (!isNoticeButton && !isNoticePanel) { if (!isNoticeButton && !isNoticePanel) {
showNotice.value = false showNotice.value = false
} }
} }
/** /**
* 切换通知面板显示状态 * 切换通知面板显示状态
*/ */
const visibleNotice = (): void => { const visibleNotice = (): void => {
showNotice.value = !showNotice.value showNotice.value = !showNotice.value
} }
/** /**
* 打开聊天窗口 * 打开聊天窗口
*/ */
const openChat = (): void => { const openChat = (): void => {
mittBus.emit('openChat') mittBus.emit('openChat')
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
/* Custom animations */ /* Custom animations */
@keyframes rotate180 { @keyframes rotate180 {
0% { 0% {
transform: rotate(0); transform: rotate(0);
} }
100% { 100% {
transform: rotate(180deg); transform: rotate(180deg);
} }
} }
@keyframes shake { @keyframes shake {
0% { 0% {
transform: rotate(0); transform: rotate(0);
} }
25% { 25% {
transform: rotate(-5deg); transform: rotate(-5deg);
} }
50% { 50% {
transform: rotate(5deg); transform: rotate(5deg);
} }
75% { 75% {
transform: rotate(-5deg); transform: rotate(-5deg);
} }
100% { 100% {
transform: rotate(0); transform: rotate(0);
} }
} }
@keyframes expand { @keyframes expand {
0% { 0% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
transform: scale(1.1); transform: scale(1.1);
} }
100% { 100% {
transform: scale(1); transform: scale(1);
} }
} }
@keyframes shrink { @keyframes shrink {
0% { 0% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
transform: scale(0.9); transform: scale(0.9);
} }
100% { 100% {
transform: scale(1); transform: scale(1);
} }
} }
@keyframes moveUp { @keyframes moveUp {
0% { 0% {
transform: translateY(0); transform: translateY(0);
} }
50% { 50% {
transform: translateY(-3px); transform: translateY(-3px);
} }
100% { 100% {
transform: translateY(0); transform: translateY(0);
} }
} }
@keyframes breathing { @keyframes breathing {
0% { 0% {
opacity: 0.4; opacity: 0.4;
transform: scale(0.9); transform: scale(0.9);
} }
50% { 50% {
opacity: 1; opacity: 1;
transform: scale(1.1); transform: scale(1.1);
} }
100% { 100% {
opacity: 0.4; opacity: 0.4;
transform: scale(0.9); transform: scale(0.9);
} }
} }
/* Hover animation classes */ /* Hover animation classes */
.refresh-btn:hover :deep(.art-svg-icon) { .refresh-btn:hover :deep(.art-svg-icon) {
animation: rotate180 0.5s; animation: rotate180 0.5s;
} }
.language-btn:hover :deep(.art-svg-icon) { .language-btn:hover :deep(.art-svg-icon) {
animation: moveUp 0.4s; animation: moveUp 0.4s;
} }
.setting-btn:hover :deep(.art-svg-icon) { .setting-btn:hover :deep(.art-svg-icon) {
animation: rotate180 0.5s; animation: rotate180 0.5s;
} }
.full-screen-btn:hover :deep(.art-svg-icon) { .full-screen-btn:hover :deep(.art-svg-icon) {
animation: expand 0.6s forwards; animation: expand 0.6s forwards;
} }
.exit-full-screen-btn:hover :deep(.art-svg-icon) { .exit-full-screen-btn:hover :deep(.art-svg-icon) {
animation: shrink 0.6s forwards; animation: shrink 0.6s forwards;
} }
.notice-button:hover :deep(.art-svg-icon) { .notice-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out; animation: shake 0.5s ease-in-out;
} }
.chat-button:hover :deep(.art-svg-icon) { .chat-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out; animation: shake 0.5s ease-in-out;
} }
/* Breathing animation for chat dot */ /* Breathing animation for chat dot */
.breathing-dot { .breathing-dot {
animation: breathing 1.5s ease-in-out infinite; animation: breathing 1.5s ease-in-out infinite;
} }
/* iPad breakpoint adjustments */ /* iPad breakpoint adjustments */
@media screen and (width <= 768px) { @media screen and (width <= 768px) {
.logo2 { .logo2 {
display: block !important; display: block !important;
} }
} }
@media screen and (width <= 640px) { @media screen and (width <= 640px) {
.btn-box { .btn-box {
width: 40px; width: 40px;
} }
} }
</style> </style>
@@ -1,159 +1,161 @@
<!-- 用户菜单 --> <!-- 用户菜单 -->
<template> <template>
<ElPopover <ElPopover
ref="userMenuPopover" ref="userMenuPopover"
placement="bottom-end" placement="bottom-end"
:width="240" :width="240"
:hide-after="0" :hide-after="0"
:offset="10" :offset="10"
trigger="hover" trigger="hover"
:show-arrow="false" :show-arrow="false"
popper-class="user-menu-popover" popper-class="user-menu-popover"
popper-style="padding: 5px 16px;" popper-style="padding: 5px 16px;"
> >
<template #reference> <template #reference>
<img <img
class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]" class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]"
src="@imgs/user/avatar.webp" src="@imgs/user/avatar.webp"
alt="avatar" alt="avatar"
/> />
</template> </template>
<template #default> <template #default>
<div class="pt-3"> <div class="pt-3">
<div class="flex-c pb-1 px-0"> <div class="flex-c pb-1 px-0">
<img <img
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left" class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
src="@imgs/user/avatar.webp" src="@imgs/user/avatar.webp"
/> />
<div class="w-[calc(100%-60px)] h-full"> <div class="w-[calc(100%-60px)] h-full">
<span class="block text-sm font-medium text-g-800 truncate">{{ <span class="block text-sm font-medium text-g-800 truncate">{{
userInfo.userName userInfo.userName
}}</span> }}</span>
<span class="block mt-0.5 text-xs text-g-500 truncate">{{ userInfo.email }}</span> <span class="block mt-0.5 text-xs text-g-500 truncate">{{
</div> userInfo.email
</div> }}</span>
<ul class="py-4 mt-3 border-t border-g-300/80"> </div>
<li class="btn-item" @click="goPage('/system/user-center')"> </div>
<ArtSvgIcon icon="ri:user-3-line" /> <ul class="py-4 mt-3 border-t border-g-300/80">
<span>{{ $t('topBar.user.userCenter') }}</span> <li class="btn-item" @click="goPage('/system/user-center')">
</li> <ArtSvgIcon icon="ri:user-3-line" />
<li class="btn-item" @click="toDocs()"> <span>{{ $t('topBar.user.userCenter') }}</span>
<ArtSvgIcon icon="ri:book-2-line" /> </li>
<span>{{ $t('topBar.user.docs') }}</span> <li class="btn-item" @click="toDocs()">
</li> <ArtSvgIcon icon="ri:book-2-line" />
<li class="btn-item" @click="toGithub()"> <span>{{ $t('topBar.user.docs') }}</span>
<ArtSvgIcon icon="ri:github-line" /> </li>
<span>{{ $t('topBar.user.github') }}</span> <li class="btn-item" @click="toGithub()">
</li> <ArtSvgIcon icon="ri:github-line" />
<li class="btn-item" @click="lockScreen()"> <span>{{ $t('topBar.user.github') }}</span>
<ArtSvgIcon icon="ri:lock-line" /> </li>
<span>{{ $t('topBar.user.lockScreen') }}</span> <li class="btn-item" @click="lockScreen()">
</li> <ArtSvgIcon icon="ri:lock-line" />
<div class="w-full h-px my-2 bg-g-300/80"></div> <span>{{ $t('topBar.user.lockScreen') }}</span>
<div class="log-out c-p" @click="loginOut"> </li>
{{ $t('topBar.user.logout') }} <div class="w-full h-px my-2 bg-g-300/80"></div>
</div> <div class="log-out c-p" @click="loginOut">
</ul> {{ $t('topBar.user.logout') }}
</div> </div>
</template> </ul>
</ElPopover> </div>
</template>
</ElPopover>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus' import { ElMessageBox } from 'element-plus'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { WEB_LINKS } from '@/utils/constants' import { WEB_LINKS } from '@/utils/constants'
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
defineOptions({ name: 'ArtUserMenu' }) defineOptions({ name: 'ArtUserMenu' })
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const userStore = useUserStore() const userStore = useUserStore()
const { getUserInfo: userInfo } = storeToRefs(userStore) const { getUserInfo: userInfo } = storeToRefs(userStore)
const userMenuPopover = ref() const userMenuPopover = ref()
/** /**
* 页面跳转 * 页面跳转
* @param {string} path - 目标路径 * @param {string} path - 目标路径
*/ */
const goPage = (path: string): void => { const goPage = (path: string): void => {
router.push(path) router.push(path)
} }
/** /**
* 打开文档页面 * 打开文档页面
*/ */
const toDocs = (): void => { const toDocs = (): void => {
window.open(WEB_LINKS.DOCS) window.open(WEB_LINKS.DOCS)
} }
/** /**
* 打开 GitHub 页面 * 打开 GitHub 页面
*/ */
const toGithub = (): void => { const toGithub = (): void => {
window.open(WEB_LINKS.GITHUB) window.open(WEB_LINKS.GITHUB)
} }
/** /**
* 打开锁屏功能 * 打开锁屏功能
*/ */
const lockScreen = (): void => { const lockScreen = (): void => {
mittBus.emit('openLockScreen') mittBus.emit('openLockScreen')
} }
/** /**
* 用户登出确认 * 用户登出确认
*/ */
const loginOut = (): void => { const loginOut = (): void => {
closeUserMenu() closeUserMenu()
setTimeout(() => { setTimeout(() => {
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), { ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
confirmButtonText: t('common.confirm'), confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'), cancelButtonText: t('common.cancel'),
customClass: 'login-out-dialog' customClass: 'login-out-dialog'
}).then(() => { }).then(() => {
userStore.logOut() userStore.logOut()
}) })
}, 200) }, 200)
} }
/** /**
* 关闭用户菜单弹出层 * 关闭用户菜单弹出层
*/ */
const closeUserMenu = (): void => { const closeUserMenu = (): void => {
setTimeout(() => { setTimeout(() => {
userMenuPopover.value.hide() userMenuPopover.value.hide()
}, 100) }, 100)
} }
</script> </script>
<style scoped> <style scoped>
@reference '@styles/core/tailwind.css'; @reference '@styles/core/tailwind.css';
@layer components { @layer components {
.btn-item { .btn-item {
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0; @apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
span { span {
@apply text-sm; @apply text-sm;
} }
.art-svg-icon { .art-svg-icon {
@apply mr-2 text-base; @apply mr-2 text-base;
} }
&:hover { &:hover {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
} }
} }
} }
.log-out { .log-out {
@apply py-1.5 @apply py-1.5
mt-5 mt-5
text-xs text-xs
text-center text-center
@@ -163,5 +165,5 @@
transition-all transition-all
duration-200 duration-200
hover:shadow-xl; hover:shadow-xl;
} }
</style> </style>
@@ -1,110 +1,110 @@
<!-- 水平菜单 --> <!-- 水平菜单 -->
<template> <template>
<div class="flex-1 overflow-hidden"> <div class="flex-1 overflow-hidden">
<ElMenu <ElMenu
:ellipsis="true" :ellipsis="true"
mode="horizontal" mode="horizontal"
:default-active="routerPath" :default-active="routerPath"
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'" :text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
:popper-offset="-6" :popper-offset="-6"
background-color="transparent" background-color="transparent"
:show-timeout="50" :show-timeout="50"
:hide-timeout="50" :hide-timeout="50"
popper-class="horizontal-menu-popper" popper-class="horizontal-menu-popper"
class="w-full border-none" class="w-full border-none"
> >
<HorizontalSubmenu <HorizontalSubmenu
v-for="item in filteredMenuItems" v-for="item in filteredMenuItems"
:key="item.path" :key="item.path"
:item="item" :item="item"
:isMobile="false" :isMobile="false"
:level="0" :level="0"
/> />
</ElMenu> </ElMenu>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { AppRouteRecord } from '@/types/router' import type { AppRouteRecord } from '@/types/router'
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue' import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
defineOptions({ name: 'ArtHorizontalMenu' }) defineOptions({ name: 'ArtHorizontalMenu' })
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore) const { isDark } = storeToRefs(settingStore)
interface Props { interface Props {
/** 菜单列表数据 */ /** 菜单列表数据 */
list: AppRouteRecord[] list: AppRouteRecord[]
} }
const route = useRoute() const route = useRoute()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
list: () => [] list: () => []
}) })
/** /**
* 过滤后的菜单项列表 * 过滤后的菜单项列表
* 只显示未隐藏的菜单项 * 只显示未隐藏的菜单项
*/ */
const filteredMenuItems = computed(() => { const filteredMenuItems = computed(() => {
return filterMenuItems(props.list) return filterMenuItems(props.list)
}) })
/** /**
* 当前激活的路由路径 * 当前激活的路由路径
* 用于菜单高亮显示 * 用于菜单高亮显示
*/ */
const routerPath = computed(() => String(route.meta.activePath || route.path)) const routerPath = computed(() => String(route.meta.activePath || route.path))
/** /**
* 递归过滤菜单项,移除隐藏的菜单 * 递归过滤菜单项,移除隐藏的菜单
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏 * 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
* @param items 菜单项数组 * @param items 菜单项数组
* @returns 过滤后的菜单项数组 * @returns 过滤后的菜单项数组
*/ */
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => { const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items return items
.filter((item) => { .filter((item) => {
// 如果当前项被隐藏,直接过滤掉 // 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) { if (item.meta.isHide) {
return false return false
} }
// 如果有子菜单,递归过滤子菜单 // 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) { if (item.children && item.children.length > 0) {
const filteredChildren = filterMenuItems(item.children) const filteredChildren = filterMenuItems(item.children)
// 如果所有子菜单都被过滤掉了,则隐藏父菜单 // 如果所有子菜单都被过滤掉了,则隐藏父菜单
return filteredChildren.length > 0 return filteredChildren.length > 0
} }
// 叶子节点且未被隐藏,保留 // 叶子节点且未被隐藏,保留
return true return true
}) })
.map((item) => ({ .map((item) => ({
...item, ...item,
children: item.children ? filterMenuItems(item.children) : undefined children: item.children ? filterMenuItems(item.children) : undefined
})) }))
} }
</script> </script>
<style scoped> <style scoped>
/* Remove el-menu bottom border */ /* Remove el-menu bottom border */
:deep(.el-menu) { :deep(.el-menu) {
border-bottom: none !important; border-bottom: none !important;
} }
/* Remove default styles for first-level menu items */ /* Remove default styles for first-level menu items */
:deep(.el-menu-item[tabindex='0']) { :deep(.el-menu-item[tabindex='0']) {
background-color: transparent !important; background-color: transparent !important;
border: none !important; border: none !important;
} }
/* Remove bottom border from submenu titles */ /* Remove bottom border from submenu titles */
:deep(.el-menu--horizontal .el-sub-menu__title) { :deep(.el-menu--horizontal .el-sub-menu__title) {
padding: 0 30px 0 10px !important; padding: 0 30px 0 10px !important;
border: 0 !important; border: 0 !important;
} }
</style> </style>
@@ -1,95 +1,95 @@
<template> <template>
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0"> <ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
<template #title> <template #title>
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" /> <ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span> <span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" /> <div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" />
<div v-if="item.meta.showTextBadge" class="art-text-badge"> <div v-if="item.meta.showTextBadge" class="art-text-badge">
{{ item.meta.showTextBadge }} {{ item.meta.showTextBadge }}
</div> </div>
</template> </template>
<!-- 递归调用自身处理子菜单 --> <!-- 递归调用自身处理子菜单 -->
<HorizontalSubmenu <HorizontalSubmenu
v-for="child in filteredChildren" v-for="child in filteredChildren"
:key="child.path" :key="child.path"
:item="child" :item="child"
:theme="theme" :theme="theme"
:is-mobile="isMobile" :is-mobile="isMobile"
:level="level + 1" :level="level + 1"
@close="closeMenu" @close="closeMenu"
/> />
</ElSubMenu> </ElSubMenu>
<ElMenuItem <ElMenuItem
v-else-if="!item.meta.isHide" v-else-if="!item.meta.isHide"
:index="item.path || item.meta.title" :index="item.path || item.meta.title"
@click="goPage(item)" @click="goPage(item)"
> >
<ArtSvgIcon <ArtSvgIcon
:icon="item.meta.icon" :icon="item.meta.icon"
:color="theme?.iconColor" :color="theme?.iconColor"
class="mr-1 text-lg" class="mr-1 text-lg"
:style="{ color: theme.iconColor }" :style="{ color: theme.iconColor }"
/> />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span> <span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div <div
v-if="item.meta.showBadge" v-if="item.meta.showBadge"
class="art-badge" class="art-badge"
:style="{ right: level === 0 ? '10px' : '20px' }" :style="{ right: level === 0 ? '10px' : '20px' }"
/> />
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge"> <div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
{{ item.meta.showTextBadge }} {{ item.meta.showTextBadge }}
</div> </div>
</ElMenuItem> </ElMenuItem>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, type PropType } from 'vue' import { computed, type PropType } from 'vue'
import { AppRouteRecord } from '@/types/router' import { AppRouteRecord } from '@/types/router'
import { handleMenuJump } from '@/utils/navigation' import { handleMenuJump } from '@/utils/navigation'
import { formatMenuTitle } from '@/utils/router' import { formatMenuTitle } from '@/utils/router'
const props = defineProps({ const props = defineProps({
item: { item: {
type: Object as PropType<AppRouteRecord>, type: Object as PropType<AppRouteRecord>,
required: true required: true
}, },
theme: { theme: {
type: Object, type: Object,
default: () => ({}) default: () => ({})
}, },
isMobile: Boolean, isMobile: Boolean,
level: { level: {
type: Number, type: Number,
default: 0 default: 0
} }
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
// 过滤后的子菜单项(不包含隐藏的) // 过滤后的子菜单项(不包含隐藏的)
const filteredChildren = computed(() => { const filteredChildren = computed(() => {
return props.item.children?.filter((child) => !child.meta.isHide) || [] return props.item.children?.filter((child) => !child.meta.isHide) || []
}) })
// 计算当前项是否有可见的子菜单 // 计算当前项是否有可见的子菜单
const hasChildren = computed(() => { const hasChildren = computed(() => {
return filteredChildren.value.length > 0 return filteredChildren.value.length > 0
}) })
const goPage = (item: AppRouteRecord) => { const goPage = (item: AppRouteRecord) => {
closeMenu() closeMenu()
handleMenuJump(item) handleMenuJump(item)
} }
const closeMenu = () => { const closeMenu = () => {
emit('close') emit('close')
} }
</script> </script>
<style scoped> <style scoped>
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) { :deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
right: 10px !important; right: 10px !important;
} }
</style> </style>
@@ -1,234 +1,237 @@
<!-- 混合菜单 --> <!-- 混合菜单 -->
<template> <template>
<div class="relative box-border flex-c w-full overflow-hidden"> <div class="relative box-border flex-c w-full overflow-hidden">
<!-- 左侧滚动按钮 --> <!-- 左侧滚动按钮 -->
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')"> <div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
<ElIcon> <ElIcon>
<ArrowLeft /> <ArrowLeft />
</ElIcon> </ElIcon>
</div> </div>
<!-- 滚动容器 --> <!-- 滚动容器 -->
<ElScrollbar <ElScrollbar
ref="scrollbarRef" ref="scrollbarRef"
wrap-class="scrollbar-wrapper" wrap-class="scrollbar-wrapper"
:horizontal="true" :horizontal="true"
@scroll="handleScroll" @scroll="handleScroll"
@wheel="handleWheel" @wheel="handleWheel"
> >
<div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap"> <div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap">
<template v-for="item in processedMenuList" :key="item.meta.title"> <template v-for="item in processedMenuList" :key="item.meta.title">
<div <div
v-if="!item.meta.isHide" v-if="!item.meta.isHide"
class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme" class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
:class="{ :class="{
'menu-item-active text-theme': item.isActive 'menu-item-active text-theme': item.isActive
}" }"
@click="handleMenuJump(item, true)" @click="handleMenuJump(item, true)"
> >
<ArtSvgIcon <ArtSvgIcon
:icon="item.meta.icon" :icon="item.meta.icon"
class="text-lg text-g-700 dark:text-g-800 mr-1" class="text-lg text-g-700 dark:text-g-800 mr-1"
:class="item.isActive && '!text-theme'" :class="item.isActive && '!text-theme'"
/> />
<span <span
class="text-md text-g-700 dark:text-g-800" class="text-md text-g-700 dark:text-g-800"
:class="item.isActive && '!text-theme'" :class="item.isActive && '!text-theme'"
> >
{{ item.formattedTitle }} {{ item.formattedTitle }}
</span> </span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" /> <div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
</div> </div>
</template> </template>
</div> </div>
</ElScrollbar> </ElScrollbar>
<!-- 右侧滚动按钮 --> <!-- 右侧滚动按钮 -->
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')"> <div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
<ElIcon> <ElIcon>
<ArrowRight /> <ArrowRight />
</ElIcon> </ElIcon>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onMounted, nextTick } from 'vue'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue' import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { useThrottleFn } from '@vueuse/core' import { useThrottleFn } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router' import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation' import { handleMenuJump } from '@/utils/navigation'
import type { AppRouteRecord } from '@/types/router' import type { AppRouteRecord } from '@/types/router'
defineOptions({ name: 'ArtMixedMenu' }) defineOptions({ name: 'ArtMixedMenu' })
interface Props { interface Props {
/** 菜单列表数据 */ /** 菜单列表数据 */
list: AppRouteRecord[] list: AppRouteRecord[]
} }
interface ProcessedMenuItem extends AppRouteRecord { interface ProcessedMenuItem extends AppRouteRecord {
isActive: boolean isActive: boolean
formattedTitle: string formattedTitle: string
} }
type ScrollDirection = 'left' | 'right' type ScrollDirection = 'left' | 'right'
const route = useRoute() const route = useRoute()
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
list: () => [] list: () => []
}) })
const scrollbarRef = ref<any>() const scrollbarRef = ref<any>()
const showLeftArrow = ref(false) const showLeftArrow = ref(false)
const showRightArrow = ref(false) const showRightArrow = ref(false)
/** 滚动配置 */ /** 滚动配置 */
const SCROLL_CONFIG = { const SCROLL_CONFIG = {
/** 点击按钮时的滚动距离 */ /** 点击按钮时的滚动距离 */
BUTTON_SCROLL_DISTANCE: 200, BUTTON_SCROLL_DISTANCE: 200,
/** 鼠标滚轮快速滚动时的步长 */ /** 鼠标滚轮快速滚动时的步长 */
WHEEL_FAST_STEP: 35, WHEEL_FAST_STEP: 35,
/** 鼠标滚轮慢速滚动时的步长 */ /** 鼠标滚轮慢速滚动时的步长 */
WHEEL_SLOW_STEP: 30, WHEEL_SLOW_STEP: 30,
/** 区分快慢滚动的阈值 */ /** 区分快慢滚动的阈值 */
WHEEL_FAST_THRESHOLD: 100 WHEEL_FAST_THRESHOLD: 100
} }
/** /**
* 获取当前激活路径 * 获取当前激活路径
* 使用computed缓存,避免重复计算 * 使用computed缓存,避免重复计算
*/ */
const currentActivePath = computed(() => { const currentActivePath = computed(() => {
return String(route.meta.activePath || route.path) return String(route.meta.activePath || route.path)
}) })
/** /**
* 判断菜单项是否为激活状态 * 判断菜单项是否为激活状态
* 递归检查子菜单中是否包含当前路径 * 递归检查子菜单中是否包含当前路径
* @param item 菜单项数据 * @param item 菜单项数据
* @returns 是否为激活状态 * @returns 是否为激活状态
*/ */
const isMenuItemActive = (item: AppRouteRecord): boolean => { const isMenuItemActive = (item: AppRouteRecord): boolean => {
const activePath = currentActivePath.value const activePath = currentActivePath.value
// 如果有子菜单,递归检查子菜单 // 如果有子菜单,递归检查子菜单
if (item.children?.length) { if (item.children?.length) {
return item.children.some((child) => { return item.children.some((child) => {
if (child.children?.length) { if (child.children?.length) {
return isMenuItemActive(child) return isMenuItemActive(child)
} }
return child.path === activePath return child.path === activePath
}) })
} }
// 直接比较路径 // 直接比较路径
return item.path === activePath return item.path === activePath
} }
/** /**
* 预处理菜单列表 * 预处理菜单列表
* 缓存每个菜单项的激活状态和格式化标题 * 缓存每个菜单项的激活状态和格式化标题
*/ */
const processedMenuList = computed<ProcessedMenuItem[]>(() => { const processedMenuList = computed<ProcessedMenuItem[]>(() => {
return props.list.map((item) => ({ return props.list.map((item) => ({
...item, ...item,
isActive: isMenuItemActive(item), isActive: isMenuItemActive(item),
formattedTitle: formatMenuTitle(item.meta.title) formattedTitle: formatMenuTitle(item.meta.title)
})) }))
}) })
/** /**
* 处理滚动事件的核心逻辑 * 处理滚动事件的核心逻辑
* 根据滚动位置显示/隐藏滚动按钮 * 根据滚动位置显示/隐藏滚动按钮
*/ */
const handleScrollCore = (): void => { const handleScrollCore = (): void => {
if (!scrollbarRef.value?.wrapRef) return if (!scrollbarRef.value?.wrapRef) return
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef
// 判断是否显示左侧滚动按钮 // 判断是否显示左侧滚动按钮
showLeftArrow.value = scrollLeft > 0 showLeftArrow.value = scrollLeft > 0
// 判断是否显示右侧滚动按钮 // 判断是否显示右侧滚动按钮
showRightArrow.value = scrollLeft + clientWidth < scrollWidth showRightArrow.value = scrollLeft + clientWidth < scrollWidth
} }
/** /**
* 节流后的滚动事件处理函数 * 节流后的滚动事件处理函数
* 调整节流间隔为16ms,约等于60fps * 调整节流间隔为16ms,约等于60fps
*/ */
const handleScroll = useThrottleFn(handleScrollCore, 16) const handleScroll = useThrottleFn(handleScrollCore, 16)
/** /**
* 滚动菜单容器 * 滚动菜单容器
* @param direction 滚动方向,left 或 right * @param direction 滚动方向,left 或 right
*/ */
const scroll = (direction: ScrollDirection): void => { const scroll = (direction: ScrollDirection): void => {
if (!scrollbarRef.value?.wrapRef) return if (!scrollbarRef.value?.wrapRef) return
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
const targetScroll = const targetScroll =
direction === 'left' direction === 'left'
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE ? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE : currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
// 平滑滚动到目标位置 // 平滑滚动到目标位置
scrollbarRef.value.wrapRef.scrollTo({ scrollbarRef.value.wrapRef.scrollTo({
left: targetScroll, left: targetScroll,
behavior: 'smooth' behavior: 'smooth'
}) })
} }
/** /**
* 处理鼠标滚轮事件 * 处理鼠标滚轮事件
* 优化滚轮响应性能 * 优化滚轮响应性能
* @param event 滚轮事件 * @param event 滚轮事件
*/ */
const handleWheel = (event: WheelEvent): void => { const handleWheel = (event: WheelEvent): void => {
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动 // 立即阻止默认滚动行为和事件冒泡,避免页面滚动
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
// 直接处理滚动,提升响应性 // 直接处理滚动,提升响应性
if (!scrollbarRef.value?.wrapRef) return if (!scrollbarRef.value?.wrapRef) return
const { wrapRef } = scrollbarRef.value const { wrapRef } = scrollbarRef.value
const { scrollLeft, scrollWidth, clientWidth } = wrapRef const { scrollLeft, scrollWidth, clientWidth } = wrapRef
// 使用更小的滚动步长,让滚动更平滑 // 使用更小的滚动步长,让滚动更平滑
const scrollStep = const scrollStep =
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
? SCROLL_CONFIG.WHEEL_FAST_STEP ? SCROLL_CONFIG.WHEEL_FAST_STEP
: SCROLL_CONFIG.WHEEL_SLOW_STEP : SCROLL_CONFIG.WHEEL_SLOW_STEP
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth)) const targetScroll = Math.max(
0,
Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth)
)
// 立即滚动,无动画 // 立即滚动,无动画
wrapRef.scrollLeft = targetScroll wrapRef.scrollLeft = targetScroll
// 更新滚动按钮状态 // 更新滚动按钮状态
handleScrollCore() handleScrollCore()
} }
/** /**
* 初始化滚动状态 * 初始化滚动状态
*/ */
const initScrollState = (): void => { const initScrollState = (): void => {
nextTick(() => { nextTick(() => {
handleScrollCore() handleScrollCore()
}) })
} }
onMounted(initScrollState) onMounted(initScrollState)
</script> </script>
<style scoped> <style scoped>
@reference '@styles/core/tailwind.css'; @reference '@styles/core/tailwind.css';
.button-arrow { .button-arrow {
@apply absolute @apply absolute
top-1/2 top-1/2
z-2 z-2
flex flex
@@ -243,37 +246,37 @@
-translate-y-1/2 -translate-y-1/2
hover:text-g-900 hover:text-g-900
hover:bg-g-200; hover:bg-g-200;
} }
</style> </style>
<style scoped> <style scoped>
:deep(.el-scrollbar__bar.is-horizontal) { :deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px; bottom: 5px;
display: none; display: none;
height: 2px; height: 2px;
} }
:deep(.scrollbar-wrapper) { :deep(.scrollbar-wrapper) {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin: 0 50px 0 30px; margin: 0 50px 0 30px;
} }
.menu-item-active::after { .menu-item-active::after {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
width: 40px; width: 40px;
height: 2px; height: 2px;
margin: auto; margin: auto;
content: ''; content: '';
background-color: var(--theme-color); background-color: var(--theme-color);
} }
@media (width <= 1440px) { @media (width <= 1440px) {
:deep(.scrollbar-wrapper) { :deep(.scrollbar-wrapper) {
margin: 0 45px; margin: 0 45px;
} }
} }
</style> </style>
@@ -1,355 +1,362 @@
<!-- 左侧菜单 双列菜单 --> <!-- 左侧菜单 双列菜单 -->
<template> <template>
<div <div
class="layout-sidebar" class="layout-sidebar"
v-if="showLeftMenu || isDualMenu" v-if="showLeftMenu || isDualMenu"
:class="{ 'no-border': menuList.length === 0 }" :class="{ 'no-border': menuList.length === 0 }"
> >
<!-- 双列菜单左侧 --> <!-- 双列菜单左侧 -->
<div <div
v-if="isDualMenu" v-if="isDualMenu"
class="dual-menu-left" class="dual-menu-left"
:style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }" :style="{
> width: dualMenuShowText ? '80px' : '64px',
<ArtLogo class="logo" @click="navigateToHome" /> background: getMenuTheme.background
}"
>
<ArtLogo class="logo" @click="navigateToHome" />
<ElScrollbar style="height: calc(100% - 135px)"> <ElScrollbar style="height: calc(100% - 135px)">
<ul> <ul>
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)"> <li
<ElTooltip v-for="menu in firstLevelMenus"
class="box-item" :key="menu.path"
effect="dark" @click="handleMenuJump(menu, true)"
:content="$t(menu.meta.title)" >
placement="right" <ElTooltip
:offset="15" class="box-item"
:hide-after="0" effect="dark"
:disabled="dualMenuShowText" :content="$t(menu.meta.title)"
> placement="right"
<div :offset="15"
:class="{ :hide-after="0"
'is-active': menu.meta.isFirstLevel :disabled="dualMenuShowText"
? menu.path === route.path >
: menu.path === firstLevelMenuPath <div
}" :class="{
:style="{ 'is-active': menu.meta.isFirstLevel
height: dualMenuShowText ? '60px' : '46px' ? menu.path === route.path
}" : menu.path === firstLevelMenuPath
> }"
<ArtSvgIcon :style="{
class="menu-icon text-g-700 dark:text-g-800" height: dualMenuShowText ? '60px' : '46px'
:icon="menu.meta.icon" }"
:style="{ >
marginBottom: dualMenuShowText ? '5px' : '0' <ArtSvgIcon
}" class="menu-icon text-g-700 dark:text-g-800"
/> :icon="menu.meta.icon"
<span v-if="dualMenuShowText" class="text-md text-g-700"> :style="{
{{ $t(menu.meta.title) }} marginBottom: dualMenuShowText ? '5px' : '0'
</span> }"
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" /> />
</div> <span v-if="dualMenuShowText" class="text-md text-g-700">
</ElTooltip> {{ $t(menu.meta.title) }}
</li> </span>
</ul> <div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
</ElScrollbar> </div>
</ElTooltip>
</li>
</ul>
</ElScrollbar>
<ArtIconButton <ArtIconButton
class="switch-btn size-10" class="switch-btn size-10"
icon="ri:arrow-left-right-fill" icon="ri:arrow-left-right-fill"
@click="toggleDualMenuMode" @click="toggleDualMenuMode"
/> />
</div> </div>
<!-- 左侧菜单 || 双列菜单右侧 --> <!-- 左侧菜单 || 双列菜单右侧 -->
<div <div
v-show="menuList.length > 0" v-show="menuList.length > 0"
class="menu-left" class="menu-left"
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`" :class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
:style="{ background: getMenuTheme.background }" :style="{ background: getMenuTheme.background }"
> >
<ElScrollbar :style="scrollbarStyle"> <ElScrollbar :style="scrollbarStyle">
<!-- Logo系统名称 --> <!-- Logo系统名称 -->
<div <div
class="header" class="header"
@click="navigateToHome" @click="navigateToHome"
:style="{ :style="{
background: getMenuTheme.background background: getMenuTheme.background
}" }"
> >
<ArtLogo v-if="!isDualMenu" class="logo" /> <ArtLogo v-if="!isDualMenu" class="logo" />
<p <p
:class="{ 'is-dual-menu-name': isDualMenu }" :class="{ 'is-dual-menu-name': isDualMenu }"
:style="{ :style="{
color: getMenuTheme.systemNameColor, color: getMenuTheme.systemNameColor,
opacity: !menuOpen ? 0 : 1 opacity: !menuOpen ? 0 : 1
}" }"
> >
{{ AppConfig.systemInfo.name }} {{ AppConfig.systemInfo.name }}
</p> </p>
</div> </div>
<ElMenu <ElMenu
:class="'el-menu-' + getMenuTheme.theme" :class="'el-menu-' + getMenuTheme.theme"
:collapse="!menuOpen" :collapse="!menuOpen"
:default-active="routerPath" :default-active="routerPath"
:text-color="getMenuTheme.textColor" :text-color="getMenuTheme.textColor"
:unique-opened="uniqueOpened" :unique-opened="uniqueOpened"
:background-color="getMenuTheme.background" :background-color="getMenuTheme.background"
:default-openeds="defaultOpenedMenus" :default-openeds="defaultOpenedMenus"
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`" :popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
:show-timeout="50" :show-timeout="50"
:hide-timeout="50" :hide-timeout="50"
> >
<SidebarSubmenu <SidebarSubmenu
:list="menuList" :list="menuList"
:isMobile="isMobileMode" :isMobile="isMobileMode"
:theme="getMenuTheme" :theme="getMenuTheme"
@close="handleMenuClose" @close="handleMenuClose"
/> />
</ElMenu> </ElMenu>
</ElScrollbar> </ElScrollbar>
<!-- 双列菜单右侧折叠按钮 --> <!-- 双列菜单右侧折叠按钮 -->
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility"> <div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
<ArtSvgIcon <ArtSvgIcon
class="text-g-500/70" class="text-g-500/70"
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'" :icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
/> />
</div> </div>
<div <div
class="menu-model" class="menu-model"
@click="toggleMenuVisibility" @click="toggleMenuVisibility"
:style="{ :style="{
opacity: !menuOpen ? 0 : 1, opacity: !menuOpen ? 0 : 1,
transform: showMobileModal ? 'scale(1)' : 'scale(0)' transform: showMobileModal ? 'scale(1)' : 'scale(0)'
}" }"
/> />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppConfig from '@/config' import AppConfig from '@/config'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum' import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
import { useMenuStore } from '@/store/modules/menu' import { useMenuStore } from '@/store/modules/menu'
import { isIframe } from '@/utils/navigation' import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation' import { handleMenuJump } from '@/utils/navigation'
import SidebarSubmenu from './widget/SidebarSubmenu.vue' import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useCommon } from '@/hooks/core/useCommon' import { useCommon } from '@/hooks/core/useCommon'
import { useWindowSize, useTimeoutFn } from '@vueuse/core' import { useWindowSize, useTimeoutFn } from '@vueuse/core'
defineOptions({ name: 'ArtSidebarMenu' }) defineOptions({ name: 'ArtSidebarMenu' })
const MOBILE_BREAKPOINT = 800 const MOBILE_BREAKPOINT = 800
const ANIMATION_DELAY = 350 const ANIMATION_DELAY = 350
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } = const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
storeToRefs(settingStore) storeToRefs(settingStore)
// 组件内部状态 // 组件内部状态
const defaultOpenedMenus = ref<string[]>([]) const defaultOpenedMenus = ref<string[]>([])
const isMobileMode = ref(false) const isMobileMode = ref(false)
const showMobileModal = ref(false) const showMobileModal = ref(false)
// 使用 VueUse 的窗口尺寸监听 // 使用 VueUse 的窗口尺寸监听
const { width } = useWindowSize() const { width } = useWindowSize()
// 菜单宽度相关 // 菜单宽度相关
const menuopenwidth = computed(() => getMenuOpenWidth.value) const menuopenwidth = computed(() => getMenuOpenWidth.value)
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH) const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
// 菜单类型判断 // 菜单类型判断
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT) const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const showLeftMenu = computed( const showLeftMenu = computed(
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT () => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
) )
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU) const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
// 移动端屏幕判断(使用 computed 避免重复计算) // 移动端屏幕判断(使用 computed 避免重复计算)
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT) const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
// 路由相关 // 路由相关
const firstLevelMenuPath = computed(() => route.matched[0]?.path) const firstLevelMenuPath = computed(() => route.matched[0]?.path)
const routerPath = computed(() => String(route.meta.activePath || route.path)) const routerPath = computed(() => String(route.meta.activePath || route.path))
// 菜单数据 // 菜单数据
const firstLevelMenus = computed(() => { const firstLevelMenus = computed(() => {
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide) return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
}) })
const menuList = computed(() => { const menuList = computed(() => {
const menuStore = useMenuStore() const menuStore = useMenuStore()
const allMenus = menuStore.menuList const allMenus = menuStore.menuList
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表 // 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
if (!isTopLeftMenu.value && !isDualMenu.value) { if (!isTopLeftMenu.value && !isDualMenu.value) {
return allMenus return allMenus
} }
// 处理 iframe 路径 // 处理 iframe 路径
if (isIframe(route.path)) { if (isIframe(route.path)) {
return findIframeMenuList(route.path, allMenus) return findIframeMenuList(route.path, allMenus)
} }
// 处理一级菜单 // 处理一级菜单
if (route.meta.isFirstLevel) { if (route.meta.isFirstLevel) {
return [] return []
} }
// 返回当前顶级路径对应的子菜单 // 返回当前顶级路径对应的子菜单
const currentTopPath = `/${route.path.split('/')[1]}` const currentTopPath = `/${route.path.split('/')[1]}`
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath) const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
return currentMenu?.children ?? [] return currentMenu?.children ?? []
}) })
// 双列菜单收起时的滚动条样式 // 双列菜单收起时的滚动条样式
const scrollbarStyle = computed(() => { const scrollbarStyle = computed(() => {
const isCollapsed = isDualMenu.value && !menuOpen.value const isCollapsed = isDualMenu.value && !menuOpen.value
return { return {
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)', transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
height: isCollapsed ? 'calc(100% + 50px)' : '100%', height: isCollapsed ? 'calc(100% + 50px)' : '100%',
transition: 'transform 0.3s ease' transition: 'transform 0.3s ease'
} }
}) })
/** /**
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn * 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn
*/ */
const { start: delayHideMobileModal } = useTimeoutFn( const { start: delayHideMobileModal } = useTimeoutFn(
() => { () => {
showMobileModal.value = false showMobileModal.value = false
}, },
ANIMATION_DELAY, ANIMATION_DELAY,
{ immediate: false } { immediate: false }
) )
/** /**
* 查找 iframe 对应的二级菜单列表 * 查找 iframe 对应的二级菜单列表
*/ */
const findIframeMenuList = (currentPath: string, menuList: any[]) => { const findIframeMenuList = (currentPath: string, menuList: any[]) => {
// 递归查找包含当前路径的菜单项 // 递归查找包含当前路径的菜单项
const hasPath = (items: any[]): boolean => { const hasPath = (items: any[]): boolean => {
for (const item of items) { for (const item of items) {
if (item.path === currentPath) { if (item.path === currentPath) {
return true return true
} }
if (item.children && hasPath(item.children)) { if (item.children && hasPath(item.children)) {
return true return true
} }
} }
return false return false
} }
// 遍历一级菜单查找匹配的子菜单 // 遍历一级菜单查找匹配的子菜单
for (const menu of menuList) { for (const menu of menuList) {
if (menu.children && hasPath(menu.children)) { if (menu.children && hasPath(menu.children)) {
return menu.children return menu.children
} }
} }
return [] return []
} }
const { homePath } = useCommon() const { homePath } = useCommon()
/** /**
* 导航到首页 * 导航到首页
*/ */
const navigateToHome = (): void => { const navigateToHome = (): void => {
router.push(homePath.value) router.push(homePath.value)
} }
/** /**
* 切换菜单显示/隐藏 * 切换菜单显示/隐藏
*/ */
const toggleMenuVisibility = (): void => { const toggleMenuVisibility = (): void => {
settingStore.setMenuOpen(!menuOpen.value) settingStore.setMenuOpen(!menuOpen.value)
// 移动端模态框控制逻辑 // 移动端模态框控制逻辑
if (isMobileScreen.value) { if (isMobileScreen.value) {
if (!menuOpen.value) { if (!menuOpen.value) {
// 菜单即将打开,立即显示模态框 // 菜单即将打开,立即显示模态框
showMobileModal.value = true showMobileModal.value = true
} else { } else {
// 菜单即将关闭,延迟隐藏模态框确保动画完成 // 菜单即将关闭,延迟隐藏模态框确保动画完成
delayHideMobileModal() delayHideMobileModal()
} }
} }
} }
/** /**
* 处理菜单关闭(来自子组件) * 处理菜单关闭(来自子组件)
*/ */
const handleMenuClose = (): void => { const handleMenuClose = (): void => {
if (isMobileScreen.value) { if (isMobileScreen.value) {
settingStore.setMenuOpen(false) settingStore.setMenuOpen(false)
delayHideMobileModal() delayHideMobileModal()
} }
} }
/** /**
* 切换双列菜单模式 * 切换双列菜单模式
*/ */
const toggleDualMenuMode = (): void => { const toggleDualMenuMode = (): void => {
settingStore.setDualMenuShowText(!dualMenuShowText.value) settingStore.setDualMenuShowText(!dualMenuShowText.value)
} }
/** /**
* 监听窗口尺寸变化,自动处理移动端菜单 * 监听窗口尺寸变化,自动处理移动端菜单
*/ */
watch(width, (newWidth) => { watch(width, (newWidth) => {
if (newWidth < MOBILE_BREAKPOINT) { if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false) settingStore.setMenuOpen(false)
if (!menuOpen.value) { if (!menuOpen.value) {
showMobileModal.value = false showMobileModal.value = false
} }
} else { } else {
showMobileModal.value = false showMobileModal.value = false
} }
}) })
/** /**
* 监听菜单开关状态变化 * 监听菜单开关状态变化
*/ */
watch(menuOpen, (isMenuOpen: boolean) => { watch(menuOpen, (isMenuOpen: boolean) => {
if (!isMobileScreen.value) { if (!isMobileScreen.value) {
// 大屏幕设备上,模态框始终隐藏 // 大屏幕设备上,模态框始终隐藏
showMobileModal.value = false showMobileModal.value = false
} else { } else {
// 小屏幕设备上,根据菜单状态控制模态框 // 小屏幕设备上,根据菜单状态控制模态框
if (isMenuOpen) { if (isMenuOpen) {
// 菜单打开时立即显示模态框 // 菜单打开时立即显示模态框
showMobileModal.value = true showMobileModal.value = true
} else { } else {
// 菜单关闭时延迟隐藏模态框,确保动画完成 // 菜单关闭时延迟隐藏模态框,确保动画完成
delayHideMobileModal() delayHideMobileModal()
} }
} }
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use './style'; @use './style';
</style> </style>
<style lang="scss"> <style lang="scss">
@use './theme'; @use './theme';
.layout-sidebar { .layout-sidebar {
// 展开的宽度 // 展开的宽度
.el-menu:not(.el-menu--collapse) { .el-menu:not(.el-menu--collapse) {
width: v-bind(menuopenwidth); width: v-bind(menuopenwidth);
} }
// 折叠后宽度 // 折叠后宽度
.el-menu--collapse { .el-menu--collapse {
width: v-bind(menuclosewidth); width: v-bind(menuclosewidth);
} }
} }
</style> </style>
@@ -1,253 +1,253 @@
.layout-sidebar { .layout-sidebar {
display: flex; display: flex;
height: 100vh; height: 100vh;
user-select: none; user-select: none;
scrollbar-width: none; scrollbar-width: none;
border-right: 1px solid var(--art-card-border); border-right: 1px solid var(--art-card-border);
&.no-border { &.no-border {
border-right: none !important; border-right: none !important;
} }
// 自定义滚动条宽度 // 自定义滚动条宽度
:deep(.el-scrollbar__bar.is-vertical) { :deep(.el-scrollbar__bar.is-vertical) {
width: 4px; width: 4px;
} }
:deep(.el-scrollbar__thumb) { :deep(.el-scrollbar__thumb) {
right: -2px; right: -2px;
background-color: #ccc; background-color: #ccc;
border-radius: 2px; border-radius: 2px;
} }
.dual-menu-left { .dual-menu-left {
position: relative; position: relative;
width: 80px; width: 80px;
height: 100%; height: 100%;
border-right: 1px solid var(--art-card-border) !important; border-right: 1px solid var(--art-card-border) !important;
transition: width 0.25s; transition: width 0.25s;
.logo { .logo {
margin: auto; margin: auto;
margin-top: 12px; margin-top: 12px;
margin-bottom: 3px; margin-bottom: 3px;
cursor: pointer; cursor: pointer;
} }
ul { ul {
li { li {
> div { > div {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 8px; margin: 8px;
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
border-radius: 5px; border-radius: 5px;
.art-svg-icon { .art-svg-icon {
display: block; display: block;
margin: 0 auto; margin: 0 auto;
font-size: 20px; font-size: 20px;
} }
span { span {
display: -webkit-box; display: -webkit-box;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
font-size: 12px; font-size: 12px;
text-overflow: ellipsis; text-overflow: ellipsis;
-webkit-line-clamp: 1; -webkit-line-clamp: 1;
line-clamp: 1; line-clamp: 1;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
} }
&.is-active { &.is-active {
background: var(--el-color-primary-light-9); background: var(--el-color-primary-light-9);
.art-svg-icon, .art-svg-icon,
span { span {
color: var(--theme-color) !important; color: var(--theme-color) !important;
} }
} }
} }
} }
} }
.switch-btn { .switch-btn {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: 15px; bottom: 15px;
left: 0; left: 0;
margin: auto; margin: auto;
} }
} }
.menu-left { .menu-left {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
height: 100vh; height: 100vh;
@media only screen and (width <= 640px) { @media only screen and (width <= 640px) {
height: 100dvh; height: 100dvh;
} }
.el-menu { .el-menu {
height: 100%; height: 100%;
} }
&:hover { &:hover {
.dual-menu-collapse-btn { .dual-menu-collapse-btn {
opacity: 1 !important; opacity: 1 !important;
} }
} }
.dual-menu-collapse-btn { .dual-menu-collapse-btn {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: -11px; right: -11px;
z-index: 10; z-index: 10;
width: 11px; width: 11px;
height: 50px; height: 50px;
cursor: pointer; cursor: pointer;
background-color: var(--default-box-color); background-color: var(--default-box-color);
border: 1px solid var(--art-card-border); border: 1px solid var(--art-card-border);
border-radius: 0 15px 15px 0; border-radius: 0 15px 15px 0;
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
transform: translateY(-50%); transform: translateY(-50%);
&:hover { &:hover {
.art-svg-icon { .art-svg-icon {
color: var(--art-gray-800) !important; color: var(--art-gray-800) !important;
} }
} }
.art-svg-icon { .art-svg-icon {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: -4px; left: -4px;
margin: auto; margin: auto;
transition: all 0.3s; transition: all 0.3s;
} }
} }
} }
.header { .header {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
height: 60px; height: 60px;
overflow: hidden; overflow: hidden;
line-height: 60px; line-height: 60px;
cursor: pointer; cursor: pointer;
.logo { .logo {
margin-left: 22px; margin-left: 22px;
} }
p { p {
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 58px; left: 58px;
box-sizing: border-box; box-sizing: border-box;
margin-left: 10px; margin-left: 10px;
font-size: 18px; font-size: 18px;
&.is-dual-menu-name { &.is-dual-menu-name {
left: 25px; left: 25px;
margin: auto; margin: auto;
} }
} }
} }
.el-menu { .el-menu {
box-sizing: border-box; box-sizing: border-box;
height: calc(100vh - 60px); height: calc(100vh - 60px);
overflow-y: auto; overflow-y: auto;
// 防止菜单内的滚动影响整个页面滚动 // 防止菜单内的滚动影响整个页面滚动
overscroll-behavior: contain; overscroll-behavior: contain;
border-right: 0; border-right: 0;
scrollbar-width: none; scrollbar-width: none;
-ms-scroll-chaining: contain; -ms-scroll-chaining: contain;
&::-webkit-scrollbar { &::-webkit-scrollbar {
width: 0 !important; width: 0 !important;
} }
} }
.menu-model { .menu-model {
display: none; display: none;
} }
} }
@media only screen and (width <= 800px) { @media only screen and (width <= 800px) {
.layout-sidebar { .layout-sidebar {
width: 0; width: 0;
.header { .header {
height: 50px; height: 50px;
line-height: 50px; line-height: 50px;
} }
.el-menu { .el-menu {
height: calc(100vh - 60px); height: calc(100vh - 60px);
} }
.el-menu--collapse { .el-menu--collapse {
width: 0; width: 0;
} }
// 折叠状态下的header样式 // 折叠状态下的header样式
.menu-left-close .header { .menu-left-close .header {
.logo { .logo {
display: none; display: none;
} }
p { p {
left: 16px; left: 16px;
font-size: 0; font-size: 0;
opacity: 0 !important; opacity: 0 !important;
} }
} }
.menu-model { .menu-model {
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
z-index: -1; z-index: -1;
display: block; display: block;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
background: rgba($color: #000, $alpha: 50%); background: rgba($color: #000, $alpha: 50%);
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
} }
} }
} }
@media only screen and (width <= 640px) { @media only screen and (width <= 640px) {
.layout-sidebar { .layout-sidebar {
border-right: 0 !important; border-right: 0 !important;
} }
} }
.dark { .dark {
.layout-sidebar { .layout-sidebar {
border-right: 1px solid rgb(255 255 255 / 13%); border-right: 1px solid rgb(255 255 255 / 13%);
:deep(.el-scrollbar__thumb) { :deep(.el-scrollbar__thumb) {
background-color: #777; background-color: #777;
} }
.dual-menu-left { .dual-menu-left {
border-right: 1px solid rgb(255 255 255 / 9%) !important; border-right: 1px solid rgb(255 255 255 / 9%) !important;
} }
} }
} }
@@ -12,247 +12,247 @@ $popup-menu-radius: 6px;
// 通用菜单项样式 // 通用菜单项样式
@mixin menu-item-base { @mixin menu-item-base {
width: calc(100% - 16px); width: calc(100% - 16px);
margin-left: 8px; margin-left: 8px;
border-radius: 6px; border-radius: 6px;
.menu-icon { .menu-icon {
margin-left: -7px; margin-left: -7px;
} }
} }
// 通用 hover 样式 // 通用 hover 样式
@mixin menu-hover($bg-color) { @mixin menu-hover($bg-color) {
.el-sub-menu__title:hover, .el-sub-menu__title:hover,
.el-menu-item:not(.is-active):hover { .el-menu-item:not(.is-active):hover {
background: $bg-color !important; background: $bg-color !important;
} }
} }
// 通用选中样式 // 通用选中样式
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) { @mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
.el-menu-item.is-active { .el-menu-item.is-active {
color: $color !important; color: $color !important;
background-color: $bg-color; background-color: $bg-color;
.menu-icon { .menu-icon {
.art-svg-icon { .art-svg-icon {
color: $icon-color !important; color: $icon-color !important;
} }
} }
} }
} }
// 弹窗菜单项样式 // 弹窗菜单项样式
@mixin popup-menu-item { @mixin popup-menu-item {
height: $popup-menu-height; height: $popup-menu-height;
margin-bottom: $popup-menu-margin; margin-bottom: $popup-menu-margin;
border-radius: $popup-menu-radius; border-radius: $popup-menu-radius;
.menu-icon { .menu-icon {
margin-right: 5px; margin-right: 5px;
} }
&:last-of-type { &:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }
} }
// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑) // 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑)
@mixin theme-menu-base { @mixin theme-menu-base {
.el-sub-menu__title, .el-sub-menu__title,
.el-menu-item { .el-menu-item {
@include menu-item-base; @include menu-item-base;
} }
} }
// 弹窗菜单通用样式 // 弹窗菜单通用样式
@mixin popup-menu-base($hover-bg, $active-color, $active-bg) { @mixin popup-menu-base($hover-bg, $active-color, $active-bg) {
.el-menu--popup { .el-menu--popup {
padding: $popup-menu-padding; padding: $popup-menu-padding;
.el-sub-menu__title:hover, .el-sub-menu__title:hover,
.el-menu-item:hover { .el-menu-item:hover {
background-color: $hover-bg !important; background-color: $hover-bg !important;
border-radius: $popup-menu-radius; border-radius: $popup-menu-radius;
} }
.el-menu-item { .el-menu-item {
@include popup-menu-item; @include popup-menu-item;
&.is-active { &.is-active {
color: $active-color !important; color: $active-color !important;
background-color: $active-bg !important; background-color: $active-bg !important;
} }
} }
.el-sub-menu { .el-sub-menu {
@include popup-menu-item; @include popup-menu-item;
height: $popup-menu-height !important; height: $popup-menu-height !important;
.el-sub-menu__title { .el-sub-menu__title {
height: $popup-menu-height !important; height: $popup-menu-height !important;
border-radius: $popup-menu-radius; border-radius: $popup-menu-radius;
} }
} }
} }
} }
.layout-sidebar { .layout-sidebar {
// ---------------------- Modify default style ---------------------- // ---------------------- Modify default style ----------------------
// 菜单折叠样式 // 菜单折叠样式
.menu-left-close { .menu-left-close {
.header { .header {
.logo { .logo {
margin: 0 auto; margin: 0 auto;
} }
} }
} }
// 菜单图标 // 菜单图标
.menu-icon { .menu-icon {
margin-right: 8px; margin-right: 8px;
font-size: $menu-icon-size; font-size: $menu-icon-size;
} }
// 菜单高度 // 菜单高度
.el-sub-menu__title, .el-sub-menu__title,
.el-menu-item { .el-menu-item {
height: $menu-height !important; height: $menu-height !important;
margin-bottom: 4px; margin-bottom: 4px;
line-height: $menu-height !important; line-height: $menu-height !important;
span { span {
font-size: $menu-font-size !important; font-size: $menu-font-size !important;
@include ellipsis(); @include ellipsis();
} }
} }
// 右侧箭头 // 右侧箭头
.el-sub-menu__icon-arrow { .el-sub-menu__icon-arrow {
width: 13px !important; width: 13px !important;
font-size: 13px !important; font-size: 13px !important;
} }
// 菜单折叠 // 菜单折叠
.el-menu--collapse { .el-menu--collapse {
.el-sub-menu.is-active { .el-sub-menu.is-active {
.el-sub-menu__title { .el-sub-menu__title {
.menu-icon { .menu-icon {
.art-svg-icon { .art-svg-icon {
// 选中菜单图标颜色 // 选中菜单图标颜色
color: var(--theme-color) !important; color: var(--theme-color) !important;
} }
} }
} }
} }
} }
// ---------------------- Design theme menu ---------------------- // ---------------------- Design theme menu ----------------------
.el-menu-design { .el-menu-design {
@include theme-menu-base; @include theme-menu-base;
@include menu-active(var(--theme-color), var(--el-color-primary-light-9)); @include menu-active(var(--theme-color), var(--el-color-primary-light-9));
@include menu-hover($hover-bg-color); @include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow { .el-sub-menu__icon-arrow {
color: var(--art-gray-600); color: var(--art-gray-600);
} }
} }
// ---------------------- Dark theme menu ---------------------- // ---------------------- Dark theme menu ----------------------
.el-menu-dark { .el-menu-dark {
@include theme-menu-base; @include theme-menu-base;
@include menu-active(#fff, #27282d, #fff); @include menu-active(#fff, #27282d, #fff);
@include menu-hover(#0f1015); @include menu-hover(#0f1015);
.el-sub-menu__icon-arrow { .el-sub-menu__icon-arrow {
color: var(--art-gray-400); color: var(--art-gray-400);
} }
} }
// ---------------------- Light theme menu ---------------------- // ---------------------- Light theme menu ----------------------
.el-menu-light { .el-menu-light {
.el-sub-menu__title, .el-sub-menu__title,
.el-menu-item { .el-menu-item {
.menu-icon { .menu-icon {
margin-left: 1px; margin-left: 1px;
} }
} }
.el-menu-item.is-active { .el-menu-item.is-active {
background-color: var(--el-color-primary-light-9); background-color: var(--el-color-primary-light-9);
.art-svg-icon { .art-svg-icon {
color: var(--theme-color) !important; color: var(--theme-color) !important;
} }
&::before { &::before {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
width: 4px; width: 4px;
height: 100%; height: 100%;
content: ''; content: '';
background: var(--theme-color); background: var(--theme-color);
} }
} }
@include menu-hover($hover-bg-color); @include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow { .el-sub-menu__icon-arrow {
color: var(--art-gray-600); color: var(--art-gray-600);
} }
} }
} }
@media only screen and (width <= 640px) { @media only screen and (width <= 640px) {
.layout-sidebar { .layout-sidebar {
.el-menu-design { .el-menu-design {
> .el-sub-menu { > .el-sub-menu {
margin-left: 0; margin-left: 0;
} }
.el-sub-menu { .el-sub-menu {
width: 100% !important; width: 100% !important;
} }
} }
} }
} }
// 菜单折叠 hover 弹窗样式(浅色主题) // 菜单折叠 hover 弹窗样式(浅色主题)
.el-menu--vertical, .el-menu--vertical,
.el-menu--popup-container { .el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200)); @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
} }
// 暗黑模式菜单样式 // 暗黑模式菜单样式
.dark { .dark {
.el-menu--vertical, .el-menu--vertical,
.el-menu--popup-container { .el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e); @include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
} }
.layout-sidebar { .layout-sidebar {
// 图标颜色、文字颜色 // 图标颜色、文字颜色
.menu-icon .art-svg-icon, .menu-icon .art-svg-icon,
.menu-name { .menu-name {
color: var(--art-gray-800) !important; color: var(--art-gray-800) !important;
} }
// 选中的文字颜色跟图标颜色 // 选中的文字颜色跟图标颜色
.el-menu-item.is-active { .el-menu-item.is-active {
span, span,
.menu-icon .art-svg-icon { .menu-icon .art-svg-icon {
color: var(--theme-color) !important; color: var(--theme-color) !important;
} }
} }
// 右侧箭头颜色 // 右侧箭头颜色
.el-sub-menu__icon-arrow { .el-sub-menu__icon-arrow {
color: #fff; color: #fff;
} }
} }
} }
@@ -1,188 +1,191 @@
<template> <template>
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)"> <template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level"> <ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
<template #title> <template #title>
<div class="menu-icon flex-cc"> <div class="menu-icon flex-cc">
<ArtSvgIcon <ArtSvgIcon
:icon="item.meta.icon" :icon="item.meta.icon"
:color="theme?.iconColor" :color="theme?.iconColor"
:style="{ color: theme.iconColor }" :style="{ color: theme.iconColor }"
/> />
</div> </div>
<span class="menu-name"> <span class="menu-name">
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title) }}
</span> </span>
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" /> <div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
</template> </template>
<SidebarSubmenu <SidebarSubmenu
:list="item.children" :list="item.children"
:is-mobile="isMobile" :is-mobile="isMobile"
:level="level + 1" :level="level + 1"
:theme="theme" :theme="theme"
@close="closeMenu" @close="closeMenu"
/> />
</ElSubMenu> </ElSubMenu>
<ElMenuItem <ElMenuItem
v-else v-else
:index="isExternalLink(item) ? undefined : item.path || item.meta.title" :index="isExternalLink(item) ? undefined : item.path || item.meta.title"
:level-item="level + 1" :level-item="level + 1"
@click="goPage(item)" @click="goPage(item)"
> >
<div class="menu-icon flex-cc"> <div class="menu-icon flex-cc">
<ArtSvgIcon <ArtSvgIcon
:icon="item.meta.icon" :icon="item.meta.icon"
:color="theme?.iconColor" :color="theme?.iconColor"
:style="{ color: theme.iconColor }" :style="{ color: theme.iconColor }"
/> />
</div> </div>
<div <div
v-show="item.meta.showBadge && level === 0 && !menuOpen" v-show="item.meta.showBadge && level === 0 && !menuOpen"
class="art-badge" class="art-badge"
style="right: 5px" style="right: 5px"
/> />
<template #title> <template #title>
<span class="menu-name"> <span class="menu-name">
{{ formatMenuTitle(item.meta.title) }} {{ formatMenuTitle(item.meta.title) }}
</span> </span>
<div v-if="item.meta.showBadge" class="art-badge" /> <div v-if="item.meta.showBadge" class="art-badge" />
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge"> <div
{{ item.meta.showTextBadge }} v-if="item.meta.showTextBadge && (level > 0 || menuOpen)"
</div> class="art-text-badge"
</template> >
</ElMenuItem> {{ item.meta.showTextBadge }}
</template> </div>
</template>
</ElMenuItem>
</template>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import type { AppRouteRecord } from '@/types/router' import type { AppRouteRecord } from '@/types/router'
import { formatMenuTitle } from '@/utils/router' import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation' import { handleMenuJump } from '@/utils/navigation'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
interface MenuTheme { interface MenuTheme {
iconColor?: string iconColor?: string
} }
interface Props { interface Props {
/** 菜单标题 */ /** 菜单标题 */
title?: string title?: string
/** 菜单列表 */ /** 菜单列表 */
list?: AppRouteRecord[] list?: AppRouteRecord[]
/** 主题配置 */ /** 主题配置 */
theme?: MenuTheme theme?: MenuTheme
/** 是否为移动端模式 */ /** 是否为移动端模式 */
isMobile?: boolean isMobile?: boolean
/** 菜单层级 */ /** 菜单层级 */
level?: number level?: number
} }
interface Emits { interface Emits {
/** 关闭菜单事件 */ /** 关闭菜单事件 */
(e: 'close'): void (e: 'close'): void
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
title: '', title: '',
list: () => [], list: () => [],
theme: () => ({}), theme: () => ({}),
isMobile: false, isMobile: false,
level: 0 level: 0
}) })
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { menuOpen } = storeToRefs(settingStore) const { menuOpen } = storeToRefs(settingStore)
/** /**
* 过滤后的菜单项列表 * 过滤后的菜单项列表
* 只显示未隐藏的菜单项 * 只显示未隐藏的菜单项
*/ */
const filteredMenuItems = computed(() => filterRoutes(props.list)) const filteredMenuItems = computed(() => filterRoutes(props.list))
/** /**
* 跳转到指定页面 * 跳转到指定页面
* @param item 菜单项数据 * @param item 菜单项数据
*/ */
const goPage = (item: AppRouteRecord): void => { const goPage = (item: AppRouteRecord): void => {
closeMenu() closeMenu()
handleMenuJump(item) handleMenuJump(item)
} }
/** /**
* 关闭菜单 * 关闭菜单
* 触发父组件的关闭事件 * 触发父组件的关闭事件
*/ */
const closeMenu = (): void => { const closeMenu = (): void => {
emit('close') emit('close')
} }
/** /**
* 递归过滤菜单路由,移除隐藏的菜单项 * 递归过滤菜单路由,移除隐藏的菜单项
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏 * 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
* @param items 菜单项数组 * @param items 菜单项数组
* @returns 过滤后的菜单项数组 * @returns 过滤后的菜单项数组
*/ */
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => { const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items return items
.filter((item) => { .filter((item) => {
// 如果当前项被隐藏,直接过滤掉 // 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) { if (item.meta.isHide) {
return false return false
} }
// 如果有子菜单,递归过滤子菜单 // 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) { if (item.children && item.children.length > 0) {
const filteredChildren = filterRoutes(item.children) const filteredChildren = filterRoutes(item.children)
// 如果所有子菜单都被过滤掉了,则隐藏父菜单 // 如果所有子菜单都被过滤掉了,则隐藏父菜单
return filteredChildren.length > 0 return filteredChildren.length > 0
} }
// 叶子节点且未被隐藏,保留 // 叶子节点且未被隐藏,保留
return true return true
}) })
.map((item) => ({ .map((item) => ({
...item, ...item,
children: item.children ? filterRoutes(item.children) : undefined children: item.children ? filterRoutes(item.children) : undefined
})) }))
} }
/** /**
* 判断菜单项是否包含可见的子菜单 * 判断菜单项是否包含可见的子菜单
* @param item 菜单项数据 * @param item 菜单项数据
* @returns 是否包含可见的子菜单 * @returns 是否包含可见的子菜单
*/ */
const hasChildren = (item: AppRouteRecord): boolean => { const hasChildren = (item: AppRouteRecord): boolean => {
if (!item.children || item.children.length === 0) { if (!item.children || item.children.length === 0) {
return false return false
} }
// 递归检查是否有可见的子菜单 // 递归检查是否有可见的子菜单
const filteredChildren = filterRoutes(item.children) const filteredChildren = filterRoutes(item.children)
return filteredChildren.length > 0 return filteredChildren.length > 0
} }
/** /**
* 判断是否为外部链接 * 判断是否为外部链接
* @param item 菜单项数据 * @param item 菜单项数据
* @returns 是否为外部链接 * @returns 是否为外部链接
*/ */
const isExternalLink = (item: AppRouteRecord): boolean => { const isExternalLink = (item: AppRouteRecord): boolean => {
return !!(item.meta.link && !item.meta.isIframe) return !!(item.meta.link && !item.meta.isIframe)
} }
/** /**
* 生成唯一的 key * 生成唯一的 key
* 使用 path、title 和 index 组合确保唯一性 * 使用 path、title 和 index 组合确保唯一性
* @param item 菜单项数据 * @param item 菜单项数据
* @param index 索引 * @param index 索引
* @returns 唯一的 key * @returns 唯一的 key
*/ */
const getUniqueKey = (item: AppRouteRecord, index: number): string => { const getUniqueKey = (item: AppRouteRecord, index: number): string => {
return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}` return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}`
} }
</script> </script>
@@ -1,427 +1,432 @@
<!-- 通知组件 --> <!-- 通知组件 -->
<template> <template>
<div <div
class="art-notification-panel art-card-sm !shadow-xl" class="art-notification-panel art-card-sm !shadow-xl"
:style="{ :style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)', transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0 opacity: show ? 1 : 0
}" }"
v-show="visible" v-show="visible"
@click.stop @click.stop
> >
<div class="flex-cb px-3.5 mt-3.5"> <div class="flex-cb px-3.5 mt-3.5">
<span class="text-base font-medium text-g-800">{{ $t('notice.title') }}</span> <span class="text-base font-medium text-g-800">{{ $t('notice.title') }}</span>
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200"> <span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
{{ $t('notice.btnRead') }} {{ $t('notice.btnRead') }}
</span> </span>
</div> </div>
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d"> <ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
<li <li
v-for="(item, index) in barList" v-for="(item, index) in barList"
:key="index" :key="index"
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none" class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
:class="{ 'bar-active': barActiveIndex === index }" :class="{ 'bar-active': barActiveIndex === index }"
@click="changeBar(index)" @click="changeBar(index)"
> >
{{ item.name }} ({{ item.num }}) {{ item.name }} ({{ item.num }})
</li> </li>
</ul> </ul>
<div class="w-full h-[calc(100%-95px)]"> <div class="w-full h-[calc(100%-95px)]">
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin"> <div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
<!-- 通知 --> <!-- 通知 -->
<ul v-show="barActiveIndex === 0"> <ul v-show="barActiveIndex === 0">
<li <li
v-for="(item, index) in noticeList" v-for="(item, index) in noticeList"
:key="index" :key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60" class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
> >
<div <div
class="size-9 leading-9 text-center rounded-lg flex-cc" class="size-9 leading-9 text-center rounded-lg flex-cc"
:class="[getNoticeStyle(item.type).iconClass]" :class="[getNoticeStyle(item.type).iconClass]"
> >
<ArtSvgIcon class="text-lg !bg-transparent" :icon="getNoticeStyle(item.type).icon" /> <ArtSvgIcon
</div> class="text-lg !bg-transparent"
<div class="w-[calc(100%-45px)] ml-3.5"> :icon="getNoticeStyle(item.type).icon"
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4> />
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p> </div>
</div> <div class="w-[calc(100%-45px)] ml-3.5">
</li> <h4 class="text-sm font-normal leading-5.5 text-g-900">{{
</ul> item.title
}}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 消息 --> <!-- 消息 -->
<ul v-show="barActiveIndex === 1"> <ul v-show="barActiveIndex === 1">
<li <li
v-for="(item, index) in msgList" v-for="(item, index) in msgList"
:key="index" :key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60" class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
> >
<div class="w-9 h-9"> <div class="w-9 h-9">
<img :src="item.avatar" class="w-full h-full rounded-lg" /> <img :src="item.avatar" class="w-full h-full rounded-lg" />
</div> </div>
<div class="w-[calc(100%-45px)] ml-3.5"> <div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4> <h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p> <p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div> </div>
</li> </li>
</ul> </ul>
<!-- 待办 --> <!-- 待办 -->
<ul v-show="barActiveIndex === 2"> <ul v-show="barActiveIndex === 2">
<li <li
v-for="(item, index) in pendingList" v-for="(item, index) in pendingList"
:key="index" :key="index"
class="box-border px-5 py-3.5 last:border-b-0" class="box-border px-5 py-3.5 last:border-b-0"
> >
<h4>{{ item.title }}</h4> <h4>{{ item.title }}</h4>
<p class="text-xs text-g-500">{{ item.time }}</p> <p class="text-xs text-g-500">{{ item.time }}</p>
</li> </li>
</ul> </ul>
<!-- 空状态 --> <!-- 空状态 -->
<div <div
v-show="currentTabIsEmpty" v-show="currentTabIsEmpty"
class="relative top-25 h-full text-g-500 text-center !bg-transparent" class="relative top-25 h-full text-g-500 text-center !bg-transparent"
> >
<ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" /> <ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" />
<p class="mt-3.5 text-xs !bg-transparent" <p class="mt-3.5 text-xs !bg-transparent"
>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p >{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p
> >
</div> </div>
</div> </div>
<div class="relative box-border w-full px-3.5"> <div class="relative box-border w-full px-3.5">
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple> <ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
{{ $t('notice.viewAll') }} {{ $t('notice.viewAll') }}
</ElButton> </ElButton>
</div> </div>
</div> </div>
<div class="h-25"></div> <div class="h-25"></div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue' import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
// 导入头像图片 // 导入头像图片
import avatar1 from '@/assets/images/avatar/avatar1.webp' import avatar1 from '@/assets/images/avatar/avatar1.webp'
import avatar2 from '@/assets/images/avatar/avatar2.webp' import avatar2 from '@/assets/images/avatar/avatar2.webp'
import avatar3 from '@/assets/images/avatar/avatar3.webp' import avatar3 from '@/assets/images/avatar/avatar3.webp'
import avatar4 from '@/assets/images/avatar/avatar4.webp' import avatar4 from '@/assets/images/avatar/avatar4.webp'
import avatar5 from '@/assets/images/avatar/avatar5.webp' import avatar5 from '@/assets/images/avatar/avatar5.webp'
import avatar6 from '@/assets/images/avatar/avatar6.webp' import avatar6 from '@/assets/images/avatar/avatar6.webp'
defineOptions({ name: 'ArtNotification' }) defineOptions({ name: 'ArtNotification' })
interface NoticeItem { interface NoticeItem {
/** 标题 */ /** 标题 */
title: string title: string
/** 时间 */ /** 时间 */
time: string time: string
/** 类型 */ /** 类型 */
type: NoticeType type: NoticeType
} }
interface MessageItem { interface MessageItem {
/** 标题 */ /** 标题 */
title: string title: string
/** 时间 */ /** 时间 */
time: string time: string
/** 头像 */ /** 头像 */
avatar: string avatar: string
} }
interface PendingItem { interface PendingItem {
/** 标题 */ /** 标题 */
title: string title: string
/** 时间 */ /** 时间 */
time: string time: string
} }
interface BarItem { interface BarItem {
/** 名称 */ /** 名称 */
name: ComputedRef<string> name: ComputedRef<string>
/** 数量 */ /** 数量 */
num: number num: number
} }
interface NoticeStyle { interface NoticeStyle {
/** 图标 */ /** 图标 */
icon: string icon: string
/** icon 样式 */ /** icon 样式 */
iconClass: string iconClass: string
} }
type NoticeType = 'email' | 'message' | 'collection' | 'user' | 'notice' type NoticeType = 'email' | 'message' | 'collection' | 'user' | 'notice'
const { t } = useI18n() const { t } = useI18n()
const props = defineProps<{ const props = defineProps<{
value: boolean value: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
'update:value': [value: boolean] 'update:value': [value: boolean]
}>() }>()
const show = ref(false) const show = ref(false)
const visible = ref(false) const visible = ref(false)
const barActiveIndex = ref(0) const barActiveIndex = ref(0)
const useNotificationData = () => { const useNotificationData = () => {
// 通知数据 // 通知数据
const noticeList = ref<NoticeItem[]>([ const noticeList = ref<NoticeItem[]>([
{ {
title: '新增国际化', title: '新增国际化',
time: '2024-6-13 0:10', time: '2024-6-13 0:10',
type: 'notice' type: 'notice'
}, },
{ {
title: '冷月呆呆给你发了一条消息', title: '冷月呆呆给你发了一条消息',
time: '2024-4-21 8:05', time: '2024-4-21 8:05',
type: 'message' type: 'message'
}, },
{ {
title: '小肥猪关注了你', title: '小肥猪关注了你',
time: '2020-3-17 21:12', time: '2020-3-17 21:12',
type: 'collection' type: 'collection'
}, },
{ {
title: '新增使用文档', title: '新增使用文档',
time: '2024-02-14 0:20', time: '2024-02-14 0:20',
type: 'notice' type: 'notice'
}, },
{ {
title: '小肥猪给你发了一封邮件', title: '小肥猪给你发了一封邮件',
time: '2024-1-20 0:15', time: '2024-1-20 0:15',
type: 'email' type: 'email'
}, },
{ {
title: '菜单mock本地真实数据', title: '菜单mock本地真实数据',
time: '2024-1-17 22:06', time: '2024-1-17 22:06',
type: 'notice' type: 'notice'
} }
]) ])
// 消息数据 // 消息数据
const msgList = ref<MessageItem[]>([ const msgList = ref<MessageItem[]>([
{ {
title: '池不胖 关注了你', title: '池不胖 关注了你',
time: '2021-2-26 23:50', time: '2021-2-26 23:50',
avatar: avatar1 avatar: avatar1
}, },
{ {
title: '唐不苦 关注了你', title: '唐不苦 关注了你',
time: '2021-2-21 8:05', time: '2021-2-21 8:05',
avatar: avatar2 avatar: avatar2
}, },
{ {
title: '中小鱼 关注了你', title: '中小鱼 关注了你',
time: '2020-1-17 21:12', time: '2020-1-17 21:12',
avatar: avatar3 avatar: avatar3
}, },
{ {
title: '何小荷 关注了你', title: '何小荷 关注了你',
time: '2021-01-14 0:20', time: '2021-01-14 0:20',
avatar: avatar4 avatar: avatar4
}, },
{ {
title: '誶誶淰 关注了你', title: '誶誶淰 关注了你',
time: '2020-12-20 0:15', time: '2020-12-20 0:15',
avatar: avatar5 avatar: avatar5
}, },
{ {
title: '冷月呆呆 关注了你', title: '冷月呆呆 关注了你',
time: '2020-12-17 22:06', time: '2020-12-17 22:06',
avatar: avatar6 avatar: avatar6
} }
]) ])
// 待办数据 // 待办数据
const pendingList = ref<PendingItem[]>([]) const pendingList = ref<PendingItem[]>([])
// 标签栏数据 // 标签栏数据
const barList = computed<BarItem[]>(() => [ const barList = computed<BarItem[]>(() => [
{ {
name: computed(() => t('notice.bar[0]')), name: computed(() => t('notice.bar[0]')),
num: noticeList.value.length num: noticeList.value.length
}, },
{ {
name: computed(() => t('notice.bar[1]')), name: computed(() => t('notice.bar[1]')),
num: msgList.value.length num: msgList.value.length
}, },
{ {
name: computed(() => t('notice.bar[2]')), name: computed(() => t('notice.bar[2]')),
num: pendingList.value.length num: pendingList.value.length
} }
]) ])
return { return {
noticeList, noticeList,
msgList, msgList,
pendingList, pendingList,
barList barList
} }
} }
// 样式管理 // 样式管理
const useNotificationStyles = () => { const useNotificationStyles = () => {
const noticeStyleMap: Record<NoticeType, NoticeStyle> = { const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
email: { email: {
icon: 'ri:mail-line', icon: 'ri:mail-line',
iconClass: 'bg-warning/12 text-warning' iconClass: 'bg-warning/12 text-warning'
}, },
message: { message: {
icon: 'ri:volume-down-line', icon: 'ri:volume-down-line',
iconClass: 'bg-success/12 text-success' iconClass: 'bg-success/12 text-success'
}, },
collection: { collection: {
icon: 'ri:heart-3-line', icon: 'ri:heart-3-line',
iconClass: 'bg-danger/12 text-danger' iconClass: 'bg-danger/12 text-danger'
}, },
user: { user: {
icon: 'ri:volume-down-line', icon: 'ri:volume-down-line',
iconClass: 'bg-info/12 text-info' iconClass: 'bg-info/12 text-info'
}, },
notice: { notice: {
icon: 'ri:notification-3-line', icon: 'ri:notification-3-line',
iconClass: 'bg-theme/12 text-theme' iconClass: 'bg-theme/12 text-theme'
} }
} }
const getNoticeStyle = (type: NoticeType): NoticeStyle => { const getNoticeStyle = (type: NoticeType): NoticeStyle => {
const defaultStyle: NoticeStyle = { const defaultStyle: NoticeStyle = {
icon: 'ri:arrow-right-circle-line', icon: 'ri:arrow-right-circle-line',
iconClass: 'bg-theme/12 text-theme' iconClass: 'bg-theme/12 text-theme'
} }
return noticeStyleMap[type] || defaultStyle return noticeStyleMap[type] || defaultStyle
} }
return { return {
getNoticeStyle getNoticeStyle
} }
} }
// 动画管理 // 动画管理
const useNotificationAnimation = () => { const useNotificationAnimation = () => {
const showNotice = (open: boolean) => { const showNotice = (open: boolean) => {
if (open) { if (open) {
visible.value = true visible.value = true
setTimeout(() => { setTimeout(() => {
show.value = true show.value = true
}, 5) }, 5)
} else { } else {
show.value = false show.value = false
setTimeout(() => { setTimeout(() => {
visible.value = false visible.value = false
}, 350) }, 350)
} }
} }
return { return {
showNotice showNotice
} }
} }
// 标签页管理 // 标签页管理
const useTabManagement = ( const useTabManagement = (
noticeList: Ref<NoticeItem[]>, noticeList: Ref<NoticeItem[]>,
msgList: Ref<MessageItem[]>, msgList: Ref<MessageItem[]>,
pendingList: Ref<PendingItem[]>, pendingList: Ref<PendingItem[]>,
businessHandlers: { businessHandlers: {
handleNoticeAll: () => void handleNoticeAll: () => void
handleMsgAll: () => void handleMsgAll: () => void
handlePendingAll: () => void handlePendingAll: () => void
} }
) => { ) => {
const changeBar = (index: number) => { const changeBar = (index: number) => {
barActiveIndex.value = index barActiveIndex.value = index
} }
// 检查当前标签页是否为空 // 检查当前标签页是否为空
const currentTabIsEmpty = computed(() => { const currentTabIsEmpty = computed(() => {
const tabDataMap = [noticeList.value, msgList.value, pendingList.value] const tabDataMap = [noticeList.value, msgList.value, pendingList.value]
const currentData = tabDataMap[barActiveIndex.value] const currentData = tabDataMap[barActiveIndex.value]
return currentData && currentData.length === 0 return currentData && currentData.length === 0
}) })
const handleViewAll = () => { const handleViewAll = () => {
// 查看全部处理器映射 // 查看全部处理器映射
const viewAllHandlers: Record<number, () => void> = { const viewAllHandlers: Record<number, () => void> = {
0: businessHandlers.handleNoticeAll, 0: businessHandlers.handleNoticeAll,
1: businessHandlers.handleMsgAll, 1: businessHandlers.handleMsgAll,
2: businessHandlers.handlePendingAll 2: businessHandlers.handlePendingAll
} }
const handler = viewAllHandlers[barActiveIndex.value] const handler = viewAllHandlers[barActiveIndex.value]
handler?.() handler?.()
// 关闭通知面板 // 关闭通知面板
emit('update:value', false) emit('update:value', false)
} }
return { return {
changeBar, changeBar,
currentTabIsEmpty, currentTabIsEmpty,
handleViewAll handleViewAll
} }
} }
// 业务逻辑处理 // 业务逻辑处理
const useBusinessLogic = () => { const useBusinessLogic = () => {
const handleNoticeAll = () => { const handleNoticeAll = () => {
// 处理查看全部通知 // 处理查看全部通知
console.log('查看全部通知') console.log('查看全部通知')
} }
const handleMsgAll = () => { const handleMsgAll = () => {
// 处理查看全部消息 // 处理查看全部消息
console.log('查看全部消息') console.log('查看全部消息')
} }
const handlePendingAll = () => { const handlePendingAll = () => {
// 处理查看全部待办 // 处理查看全部待办
console.log('查看全部待办') console.log('查看全部待办')
} }
return { return {
handleNoticeAll, handleNoticeAll,
handleMsgAll, handleMsgAll,
handlePendingAll handlePendingAll
} }
} }
// 组合所有逻辑 // 组合所有逻辑
const { noticeList, msgList, pendingList, barList } = useNotificationData() const { noticeList, msgList, pendingList, barList } = useNotificationData()
const { getNoticeStyle } = useNotificationStyles() const { getNoticeStyle } = useNotificationStyles()
const { showNotice } = useNotificationAnimation() const { showNotice } = useNotificationAnimation()
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic() const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic()
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement( const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
noticeList, noticeList,
msgList, msgList,
pendingList, pendingList,
{ handleNoticeAll, handleMsgAll, handlePendingAll } { handleNoticeAll, handleMsgAll, handlePendingAll }
) )
// 监听属性变化 // 监听属性变化
watch( watch(
() => props.value, () => props.value,
(newValue) => { (newValue) => {
showNotice(newValue) showNotice(newValue)
} }
) )
</script> </script>
<style scoped> <style scoped>
@reference '@styles/core/tailwind.css'; @reference '@styles/core/tailwind.css';
.art-notification-panel { .art-notification-panel {
@apply absolute @apply absolute
top-14.5 top-14.5
right-5 right-5
w-90 w-90
@@ -435,22 +440,22 @@
max-[640px]:right-0 max-[640px]:right-0
max-[640px]:w-full max-[640px]:w-full
max-[640px]:h-[80vh]; max-[640px]:h-[80vh];
} }
.bar-active { .bar-active {
color: var(--theme-color) !important; color: var(--theme-color) !important;
border-bottom: 2px solid var(--theme-color); border-bottom: 2px solid var(--theme-color);
} }
.scrollbar-thin::-webkit-scrollbar { .scrollbar-thin::-webkit-scrollbar {
width: 5px !important; width: 5px !important;
} }
.dark .scrollbar-thin::-webkit-scrollbar-track { .dark .scrollbar-thin::-webkit-scrollbar-track {
background-color: var(--default-box-color); background-color: var(--default-box-color);
} }
.dark .scrollbar-thin::-webkit-scrollbar-thumb { .dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #222 !important; background-color: #222 !important;
} }
</style> </style>
@@ -1,136 +1,136 @@
<!-- 布局内容 --> <!-- 布局内容 -->
<template> <template>
<div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle"> <div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle">
<div id="app-content-header"> <div id="app-content-header">
<!-- 节日滚动 --> <!-- 节日滚动 -->
<ArtFestivalTextScroll v-if="!isFullPage" /> <ArtFestivalTextScroll v-if="!isFullPage" />
<!-- 路由信息调试 --> <!-- 路由信息调试 -->
<div <div
v-if="isOpenRouteInfo === 'true'" v-if="isOpenRouteInfo === 'true'"
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md" class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md"
> >
router meta{{ route.meta }} router meta{{ route.meta }}
</div> </div>
</div> </div>
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle"> <RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
<!-- 缓存路由动画 --> <!-- 缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear> <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<KeepAlive :max="10" :exclude="keepAliveExclude"> <KeepAlive :max="10" :exclude="keepAliveExclude">
<component <component
class="art-page-view" class="art-page-view"
:is="Component" :is="Component"
:key="route.path" :key="route.path"
v-if="route.meta.keepAlive" v-if="route.meta.keepAlive"
/> />
</KeepAlive> </KeepAlive>
</Transition> </Transition>
<!-- 非缓存路由动画 --> <!-- 非缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear> <Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<component <component
class="art-page-view" class="art-page-view"
:is="Component" :is="Component"
:key="route.path" :key="route.path"
v-if="!route.meta.keepAlive" v-if="!route.meta.keepAlive"
/> />
</Transition> </Transition>
</RouterView> </RouterView>
<!-- 全屏页面切换过渡遮罩用于提升页面切换视觉体验 --> <!-- 全屏页面切换过渡遮罩用于提升页面切换视觉体验 -->
<Teleport to="body"> <Teleport to="body">
<div <div
v-show="showTransitionMask" v-show="showTransitionMask"
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box" class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
/> />
</Teleport> </Teleport>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue' import type { CSSProperties } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight' import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useWorktabStore } from '@/store/modules/worktab' import { useWorktabStore } from '@/store/modules/worktab'
defineOptions({ name: 'ArtPageContent' }) defineOptions({ name: 'ArtPageContent' })
const route = useRoute() const route = useRoute()
const { containerMinHeight } = useAutoLayoutHeight() const { containerMinHeight } = useAutoLayoutHeight()
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore()) const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
const { keepAliveExclude } = storeToRefs(useWorktabStore()) const { keepAliveExclude } = storeToRefs(useWorktabStore())
const isRefresh = shallowRef(true) const isRefresh = shallowRef(true)
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
const showTransitionMask = ref(false) const showTransitionMask = ref(false)
// 标记是否是首次加载(浏览器刷新) // 标记是否是首次加载(浏览器刷新)
const isFirstLoad = ref(true) const isFirstLoad = ref(true)
// 检查当前路由是否需要使用无基础布局模式 // 检查当前路由是否需要使用无基础布局模式
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage)) const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage))
const prevIsFullPage = ref(isFullPage.value) const prevIsFullPage = ref(isFullPage.value)
// 切换动画名称:首次加载、从全屏返回时不使用动画 // 切换动画名称:首次加载、从全屏返回时不使用动画
const actualTransition = computed(() => { const actualTransition = computed(() => {
if (isFirstLoad.value) return '' if (isFirstLoad.value) return ''
if (prevIsFullPage.value && !isFullPage.value) return '' if (prevIsFullPage.value && !isFullPage.value) return ''
return pageTransition.value return pageTransition.value
}) })
// 监听全屏状态变化,显示过渡遮罩 // 监听全屏状态变化,显示过渡遮罩
watch(isFullPage, (val, oldVal) => { watch(isFullPage, (val, oldVal) => {
if (val !== oldVal) { if (val !== oldVal) {
showTransitionMask.value = true showTransitionMask.value = true
// 延迟隐藏遮罩,给足时间让页面完成切换 // 延迟隐藏遮罩,给足时间让页面完成切换
setTimeout(() => { setTimeout(() => {
showTransitionMask.value = false showTransitionMask.value = false
}, 50) }, 50)
} }
nextTick(() => { nextTick(() => {
prevIsFullPage.value = val prevIsFullPage.value = val
}) })
}) })
const containerStyle = computed( const containerStyle = computed(
(): CSSProperties => (): CSSProperties =>
isFullPage.value isFullPage.value
? { ? {
position: 'fixed', position: 'fixed',
top: 0, top: 0,
left: 0, left: 0,
width: '100%', width: '100%',
height: '100vh', height: '100vh',
zIndex: 2500, zIndex: 2500,
background: 'var(--default-bg-color)' background: 'var(--default-bg-color)'
} }
: { : {
maxWidth: containerWidth.value maxWidth: containerWidth.value
} }
) )
const contentStyle = computed( const contentStyle = computed(
(): CSSProperties => ({ (): CSSProperties => ({
minHeight: containerMinHeight.value minHeight: containerMinHeight.value
}) })
) )
const reload = () => { const reload = () => {
isRefresh.value = false isRefresh.value = false
nextTick(() => { nextTick(() => {
isRefresh.value = true isRefresh.value = true
}) })
} }
watch(refresh, reload, { flush: 'post' }) watch(refresh, reload, { flush: 'post' })
// 组件挂载后标记首次加载完成 // 组件挂载后标记首次加载完成
onMounted(() => { onMounted(() => {
// 延迟一帧,确保首次渲染完成 // 延迟一帧,确保首次渲染完成
nextTick(() => { nextTick(() => {
isFirstLoad.value = false isFirstLoad.value = false
}) })
}) })
</script> </script>
@@ -1,519 +1,530 @@
<!-- 锁屏 --> <!-- 锁屏 -->
<template> <template>
<div class="layout-lock-screen"> <div class="layout-lock-screen">
<!-- 开发者工具警告覆盖层 --> <!-- 开发者工具警告覆盖层 -->
<div <div
v-if="showDevToolsWarning" v-if="showDevToolsWarning"
class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in" class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in"
> >
<div class="p-5 text-center select-none"> <div class="p-5 text-center select-none">
<div class="mb-7.5 text-5xl">🔒</div> <div class="mb-7.5 text-5xl">🔒</div>
<h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1> <h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1>
<p class="max-w-125 m-0 text-lg leading-relaxed text-white"> <p class="max-w-125 m-0 text-lg leading-relaxed text-white">
检测到开发者工具已打开<br /> 检测到开发者工具已打开<br />
为了系统安全请关闭开发者工具后继续使用 为了系统安全请关闭开发者工具后继续使用
</p> </p>
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div> <div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
</div> </div>
</div> </div>
<!-- 锁屏弹窗 --> <!-- 锁屏弹窗 -->
<div v-if="!isLock"> <div v-if="!isLock">
<ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen"> <ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
<div class="flex-c flex-col"> <div class="flex-c flex-col">
<img class="w-16 h-16 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" /> <img
<div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div> class="w-16 h-16 rounded-full"
<ElForm src="@imgs/user/avatar.webp"
ref="formRef" alt="用户头像"
:model="formData" />
:rules="rules" <div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div>
class="w-[90%]" <ElForm
@submit.prevent="handleLock" ref="formRef"
> :model="formData"
<ElFormItem prop="password"> :rules="rules"
<ElInput class="w-[90%]"
v-model="formData.password" @submit.prevent="handleLock"
type="password" >
:placeholder="$t('lockScreen.lock.inputPlaceholder')" <ElFormItem prop="password">
:show-password="true" <ElInput
autocomplete="new-password" v-model="formData.password"
ref="lockInputRef" type="password"
class="w-full mt-9" :placeholder="$t('lockScreen.lock.inputPlaceholder')"
@keyup.enter="handleLock" :show-password="true"
> autocomplete="new-password"
<template #suffix> ref="lockInputRef"
<ElIcon class="c-p" @click="handleLock"> class="w-full mt-9"
<Lock /> @keyup.enter="handleLock"
</ElIcon> >
</template> <template #suffix>
</ElInput> <ElIcon class="c-p" @click="handleLock">
</ElFormItem> <Lock />
<ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple> </ElIcon>
{{ $t('lockScreen.lock.btnText') }} </template>
</ElButton> </ElInput>
</ElForm> </ElFormItem>
</div> <ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple>
</ElDialog> {{ $t('lockScreen.lock.btnText') }}
</div> </ElButton>
</ElForm>
</div>
</ElDialog>
</div>
<!-- 解锁界面 --> <!-- 解锁界面 -->
<div v-else class="unlock-content"> <div v-else class="unlock-content">
<div class="flex-c flex-col w-80"> <div class="flex-c flex-col w-80">
<img class="w-16 h-16 mt-5 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" /> <img
<div class="mt-3 mb-3.5 text-base font-medium"> class="w-16 h-16 mt-5 rounded-full"
{{ userInfo.userName }} src="@imgs/user/avatar.webp"
</div> alt="用户头像"
<ElForm />
ref="unlockFormRef" <div class="mt-3 mb-3.5 text-base font-medium">
:model="unlockForm" {{ userInfo.userName }}
:rules="rules" </div>
class="w-full !px-2.5" <ElForm
@submit.prevent="handleUnlock" ref="unlockFormRef"
> :model="unlockForm"
<ElFormItem prop="password"> :rules="rules"
<ElInput class="w-full !px-2.5"
v-model="unlockForm.password" @submit.prevent="handleUnlock"
type="password" >
:placeholder="$t('lockScreen.unlock.inputPlaceholder')" <ElFormItem prop="password">
:show-password="true" <ElInput
autocomplete="new-password" v-model="unlockForm.password"
ref="unlockInputRef" type="password"
class="mt-5" :placeholder="$t('lockScreen.unlock.inputPlaceholder')"
> :show-password="true"
<template #suffix> autocomplete="new-password"
<ElIcon class="c-p" @click="handleUnlock"> ref="unlockInputRef"
<Unlock /> class="mt-5"
</ElIcon> >
</template> <template #suffix>
</ElInput> <ElIcon class="c-p" @click="handleUnlock">
</ElFormItem> <Unlock />
</ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple> <ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple>
{{ $t('lockScreen.unlock.btnText') }} {{ $t('lockScreen.unlock.btnText') }}
</ElButton> </ElButton>
<div class="w-full text-center"> <div class="w-full text-center">
<ElButton <ElButton
text text
class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent" class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent"
@click="toLogin" @click="toLogin"
> >
{{ $t('lockScreen.unlock.backBtnText') }} {{ $t('lockScreen.unlock.backBtnText') }}
</ElButton> </ElButton>
</div> </div>
</ElForm> </ElForm>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Lock, Unlock } from '@element-plus/icons-vue' import { Lock, Unlock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import CryptoJS from 'crypto-js' import CryptoJS from 'crypto-js'
import { useUserStore } from '@/store/modules/user' import { useUserStore } from '@/store/modules/user'
import { mittBus } from '@/utils/sys' import { mittBus } from '@/utils/sys'
// 国际化 // 国际化
const { t } = useI18n() const { t } = useI18n()
// 环境变量 // 环境变量
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
// Store // Store
const userStore = useUserStore() const userStore = useUserStore()
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore) const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
// 响应式数据 // 响应式数据
const visible = ref<boolean>(false) const visible = ref<boolean>(false)
const lockInputRef = ref<any>(null) const lockInputRef = ref<any>(null)
const unlockInputRef = ref<any>(null) const unlockInputRef = ref<any>(null)
const showDevToolsWarning = ref<boolean>(false) const showDevToolsWarning = ref<boolean>(false)
// 表单相关 // 表单相关
const formRef = ref<FormInstance>() const formRef = ref<FormInstance>()
const unlockFormRef = ref<FormInstance>() const unlockFormRef = ref<FormInstance>()
const formData = reactive({ const formData = reactive({
password: '' password: ''
}) })
const unlockForm = reactive({ const unlockForm = reactive({
password: '' password: ''
}) })
// 表单验证规则 // 表单验证规则
const rules = computed<FormRules>(() => ({ const rules = computed<FormRules>(() => ({
password: [ password: [
{ {
required: true, required: true,
message: t('lockScreen.lock.inputPlaceholder'), message: t('lockScreen.lock.inputPlaceholder'),
trigger: 'blur' trigger: 'blur'
} }
] ]
})) }))
// 检测是否为移动设备 // 检测是否为移动设备
const isMobile = () => { const isMobile = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test( return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent navigator.userAgent
) )
} }
// 添加禁用控制台的函数 // 添加禁用控制台的函数
const disableDevTools = () => { const disableDevTools = () => {
// 禁用右键菜单 // 禁用右键菜单
const handleContextMenu = (e: Event) => { const handleContextMenu = (e: Event) => {
if (isLock.value) { if (isLock.value) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
} }
document.addEventListener('contextmenu', handleContextMenu, true) document.addEventListener('contextmenu', handleContextMenu, true)
// 禁用开发者工具相关快捷键 // 禁用开发者工具相关快捷键
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (!isLock.value) return if (!isLock.value) return
// 禁用 F12 // 禁用 F12
if (e.key === 'F12') { if (e.key === 'F12') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具) // 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
if (e.ctrlKey && e.shiftKey) { if (e.ctrlKey && e.shiftKey) {
const key = e.key.toLowerCase() const key = e.key.toLowerCase()
if (['i', 'j', 'c', 'k'].includes(key)) { if (['i', 'j', 'c', 'k'].includes(key)) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
} }
// 禁用 Ctrl+U (查看源代码) // 禁用 Ctrl+U (查看源代码)
if (e.ctrlKey && e.key.toLowerCase() === 'u') { if (e.ctrlKey && e.key.toLowerCase() === 'u') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+S (保存页面) // 禁用 Ctrl+S (保存页面)
if (e.ctrlKey && e.key.toLowerCase() === 's') { if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+A (全选) // 禁用 Ctrl+A (全选)
if (e.ctrlKey && e.key.toLowerCase() === 'a') { if (e.ctrlKey && e.key.toLowerCase() === 'a') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+P (打印) // 禁用 Ctrl+P (打印)
if (e.ctrlKey && e.key.toLowerCase() === 'p') { if (e.ctrlKey && e.key.toLowerCase() === 'p') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+F (查找) // 禁用 Ctrl+F (查找)
if (e.ctrlKey && e.key.toLowerCase() === 'f') { if (e.ctrlKey && e.key.toLowerCase() === 'f') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Alt+Tab (切换窗口) // 禁用 Alt+Tab (切换窗口)
if (e.altKey && e.key === 'Tab') { if (e.altKey && e.key === 'Tab') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+Tab (切换标签页) // 禁用 Ctrl+Tab (切换标签页)
if (e.ctrlKey && e.key === 'Tab') { if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+W (关闭标签页) // 禁用 Ctrl+W (关闭标签页)
if (e.ctrlKey && e.key.toLowerCase() === 'w') { if (e.ctrlKey && e.key.toLowerCase() === 'w') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+R 和 F5 (刷新页面) // 禁用 Ctrl+R 和 F5 (刷新页面)
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') { if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
// 禁用 Ctrl+Shift+R (强制刷新) // 禁用 Ctrl+Shift+R (强制刷新)
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') { if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
return false return false
} }
} }
document.addEventListener('keydown', handleKeyDown, true) document.addEventListener('keydown', handleKeyDown, true)
// 禁用选择文本 // 禁用选择文本
const handleSelectStart = (e: Event) => { const handleSelectStart = (e: Event) => {
if (isLock.value) { if (isLock.value) {
e.preventDefault() e.preventDefault()
return false return false
} }
} }
document.addEventListener('selectstart', handleSelectStart, true) document.addEventListener('selectstart', handleSelectStart, true)
// 禁用拖拽 // 禁用拖拽
const handleDragStart = (e: Event) => { const handleDragStart = (e: Event) => {
if (isLock.value) { if (isLock.value) {
e.preventDefault() e.preventDefault()
return false return false
} }
} }
document.addEventListener('dragstart', handleDragStart, true) document.addEventListener('dragstart', handleDragStart, true)
// 监听开发者工具打开状态(仅在桌面端启用) // 监听开发者工具打开状态(仅在桌面端启用)
let devtools = { open: false } let devtools = { open: false }
const threshold = 160 const threshold = 160
let devToolsInterval: ReturnType<typeof setInterval> | null = null let devToolsInterval: ReturnType<typeof setInterval> | null = null
const checkDevTools = () => { const checkDevTools = () => {
if (!isLock.value || isMobile()) return if (!isLock.value || isMobile()) return
const isDevToolsOpen = const isDevToolsOpen =
window.outerHeight - window.innerHeight > threshold || window.outerHeight - window.innerHeight > threshold ||
window.outerWidth - window.innerWidth > threshold window.outerWidth - window.innerWidth > threshold
if (isDevToolsOpen && !devtools.open) { if (isDevToolsOpen && !devtools.open) {
devtools.open = true devtools.open = true
showDevToolsWarning.value = true showDevToolsWarning.value = true
} else if (!isDevToolsOpen && devtools.open) { } else if (!isDevToolsOpen && devtools.open) {
devtools.open = false devtools.open = false
showDevToolsWarning.value = false showDevToolsWarning.value = false
} }
} }
// 仅在桌面端启用开发者工具检测 // 仅在桌面端启用开发者工具检测
if (!isMobile()) { if (!isMobile()) {
devToolsInterval = setInterval(checkDevTools, 500) devToolsInterval = setInterval(checkDevTools, 500)
} }
// 返回清理函数 // 返回清理函数
return () => { return () => {
document.removeEventListener('contextmenu', handleContextMenu, true) document.removeEventListener('contextmenu', handleContextMenu, true)
document.removeEventListener('keydown', handleKeyDown, true) document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('selectstart', handleSelectStart, true) document.removeEventListener('selectstart', handleSelectStart, true)
document.removeEventListener('dragstart', handleDragStart, true) document.removeEventListener('dragstart', handleDragStart, true)
if (devToolsInterval) { if (devToolsInterval) {
clearInterval(devToolsInterval) clearInterval(devToolsInterval)
} }
} }
} }
// 工具函数 // 工具函数
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => { const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
try { try {
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString( const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
CryptoJS.enc.Utf8 CryptoJS.enc.Utf8
) )
return inputPassword === decryptedPassword return inputPassword === decryptedPassword
} catch (error) { } catch (error) {
console.error('密码解密失败:', error) console.error('密码解密失败:', error)
return false return false
} }
} }
// 事件处理函数 // 事件处理函数
const handleKeydown = (event: KeyboardEvent) => { const handleKeydown = (event: KeyboardEvent) => {
if (event.altKey && event.key.toLowerCase() === '¬') { if (event.altKey && event.key.toLowerCase() === '¬') {
event.preventDefault() event.preventDefault()
visible.value = true visible.value = true
} }
} }
const handleDialogOpen = () => { const handleDialogOpen = () => {
setTimeout(() => { setTimeout(() => {
lockInputRef.value?.input?.focus() lockInputRef.value?.input?.focus()
}, 100) }, 100)
} }
const handleLock = async () => { const handleLock = async () => {
if (!formRef.value) return if (!formRef.value) return
await formRef.value.validate((valid, fields) => { await formRef.value.validate((valid, fields) => {
if (valid) { if (valid) {
const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString() const encryptedPassword = CryptoJS.AES.encrypt(
userStore.setLockStatus(true) formData.password,
userStore.setLockPassword(encryptedPassword) ENCRYPT_KEY
visible.value = false ).toString()
formData.password = '' userStore.setLockStatus(true)
} else { userStore.setLockPassword(encryptedPassword)
console.error('表单验证失败:', fields) visible.value = false
} formData.password = ''
}) } else {
} console.error('表单验证失败:', fields)
}
})
}
const handleUnlock = async () => { const handleUnlock = async () => {
if (!unlockFormRef.value) return if (!unlockFormRef.value) return
await unlockFormRef.value.validate((valid, fields) => { await unlockFormRef.value.validate((valid, fields) => {
if (valid) { if (valid) {
const isValid = verifyPassword(unlockForm.password, lockPassword.value) const isValid = verifyPassword(unlockForm.password, lockPassword.value)
if (isValid) { if (isValid) {
try { try {
userStore.setLockStatus(false) userStore.setLockStatus(false)
userStore.setLockPassword('') userStore.setLockPassword('')
unlockForm.password = '' unlockForm.password = ''
visible.value = false visible.value = false
showDevToolsWarning.value = false showDevToolsWarning.value = false
} catch (error) { } catch (error) {
console.error('更新store失败:', error) console.error('更新store失败:', error)
} }
} else { } else {
// 触发抖动动画 // 触发抖动动画
const inputElement = unlockInputRef.value?.$el const inputElement = unlockInputRef.value?.$el
if (inputElement) { if (inputElement) {
inputElement.classList.add('shake-animation') inputElement.classList.add('shake-animation')
setTimeout(() => { setTimeout(() => {
inputElement.classList.remove('shake-animation') inputElement.classList.remove('shake-animation')
}, 300) }, 300)
} }
ElMessage.error(t('lockScreen.pwdError')) ElMessage.error(t('lockScreen.pwdError'))
unlockForm.password = '' unlockForm.password = ''
} }
} else { } else {
console.error('表单验证失败:', fields) console.error('表单验证失败:', fields)
} }
}) })
} }
const toLogin = () => { const toLogin = () => {
userStore.logOut() userStore.logOut()
} }
const openLockScreen = () => { const openLockScreen = () => {
visible.value = true visible.value = true
} }
// 监听锁屏状态变化 // 监听锁屏状态变化
watch(isLock, (newValue) => { watch(isLock, (newValue) => {
if (newValue) { if (newValue) {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
setTimeout(() => { setTimeout(() => {
unlockInputRef.value?.input?.focus() unlockInputRef.value?.input?.focus()
}, 100) }, 100)
} else { } else {
document.body.style.overflow = 'auto' document.body.style.overflow = 'auto'
showDevToolsWarning.value = false showDevToolsWarning.value = false
} }
}) })
// 存储清理函数 // 存储清理函数
let cleanupDevTools: (() => void) | null = null let cleanupDevTools: (() => void) | null = null
// 生命周期钩子 // 生命周期钩子
onMounted(() => { onMounted(() => {
mittBus.on('openLockScreen', openLockScreen) mittBus.on('openLockScreen', openLockScreen)
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
if (isLock.value) { if (isLock.value) {
visible.value = true visible.value = true
setTimeout(() => { setTimeout(() => {
unlockInputRef.value?.input?.focus() unlockInputRef.value?.input?.focus()
}, 100) }, 100)
} }
// 初始化禁用开发者工具功能 // 初始化禁用开发者工具功能
cleanupDevTools = disableDevTools() cleanupDevTools = disableDevTools()
}) })
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = 'auto' document.body.style.overflow = 'auto'
// 清理禁用开发者工具的事件监听器 // 清理禁用开发者工具的事件监听器
if (cleanupDevTools) { if (cleanupDevTools) {
cleanupDevTools() cleanupDevTools()
cleanupDevTools = null cleanupDevTools = null
} }
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.layout-lock-screen :deep(.el-dialog) { .layout-lock-screen :deep(.el-dialog) {
border-radius: 10px; border-radius: 10px;
} }
.unlock-content { .unlock-content {
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 2500; z-index: 2500;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
background-color: #fff; background-color: #fff;
background-image: url('@imgs/lock/bg_light.webp'); background-image: url('@imgs/lock/bg_light.webp');
background-size: cover; background-size: cover;
transition: transform 0.3s ease-in-out; transition: transform 0.3s ease-in-out;
} }
.dark { .dark {
.unlock-content { .unlock-content {
background-image: url('@imgs/lock/bg_dark.webp'); background-image: url('@imgs/lock/bg_dark.webp');
} }
} }
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
} }
to { to {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
} }
.animate-fade-in { .animate-fade-in {
animation: fade-in 0.3s ease-in-out; animation: fade-in 0.3s ease-in-out;
} }
@keyframes shake { @keyframes shake {
0%, 0%,
100% { 100% {
transform: translateX(0); transform: translateX(0);
} }
10%, 10%,
30%, 30%,
50%, 50%,
70%, 70%,
90% { 90% {
transform: translateX(-10px); transform: translateX(-10px);
} }
20%, 20%,
40%, 40%,
60%, 60%,
80% { 80% {
transform: translateX(10px); transform: translateX(10px);
} }
} }
.shake-animation { .shake-animation {
animation: shake 0.5s ease-in-out; animation: shake 0.5s ease-in-out;
} }
</style> </style>
@@ -8,241 +8,241 @@ import { headerBarConfig } from '@/config/modules/headerBar'
* 设置项配置选项管理 * 设置项配置选项管理
*/ */
export function useSettingsConfig() { export function useSettingsConfig() {
const { t } = useI18n() const { t } = useI18n()
// 标签页风格选项 // 标签页风格选项
const tabStyleOptions = computed(() => [ const tabStyleOptions = computed(() => [
{ {
value: 'tab-default', value: 'tab-default',
label: t('setting.tabStyle.default') label: t('setting.tabStyle.default')
}, },
{ {
value: 'tab-card', value: 'tab-card',
label: t('setting.tabStyle.card') label: t('setting.tabStyle.card')
}, },
{ {
value: 'tab-google', value: 'tab-google',
label: t('setting.tabStyle.google') label: t('setting.tabStyle.google')
} }
]) ])
// 页面切换动画选项 // 页面切换动画选项
const pageTransitionOptions = computed(() => [ const pageTransitionOptions = computed(() => [
{ {
value: '', value: '',
label: t('setting.transition.list.none') label: t('setting.transition.list.none')
}, },
{ {
value: 'fade', value: 'fade',
label: t('setting.transition.list.fade') label: t('setting.transition.list.fade')
}, },
{ {
value: 'slide-left', value: 'slide-left',
label: t('setting.transition.list.slideLeft') label: t('setting.transition.list.slideLeft')
}, },
{ {
value: 'slide-bottom', value: 'slide-bottom',
label: t('setting.transition.list.slideBottom') label: t('setting.transition.list.slideBottom')
}, },
{ {
value: 'slide-top', value: 'slide-top',
label: t('setting.transition.list.slideTop') label: t('setting.transition.list.slideTop')
} }
]) ])
// 圆角大小选项 // 圆角大小选项
const customRadiusOptions = [ const customRadiusOptions = [
{ value: '0', label: '0' }, { value: '0', label: '0' },
{ value: '0.25', label: '0.25' }, { value: '0.25', label: '0.25' },
{ value: '0.5', label: '0.5' }, { value: '0.5', label: '0.5' },
{ value: '0.75', label: '0.75' }, { value: '0.75', label: '0.75' },
{ value: '1', label: '1' } { value: '1', label: '1' }
] ]
// 容器宽度选项 // 容器宽度选项
const containerWidthOptions = computed(() => [ const containerWidthOptions = computed(() => [
{ {
value: ContainerWidthEnum.FULL, value: ContainerWidthEnum.FULL,
label: t('setting.container.list[0]'), label: t('setting.container.list[0]'),
icon: 'icon-park-outline:auto-width' icon: 'icon-park-outline:auto-width'
}, },
{ {
value: ContainerWidthEnum.BOXED, value: ContainerWidthEnum.BOXED,
label: t('setting.container.list[1]'), label: t('setting.container.list[1]'),
icon: 'ix:width' icon: 'ix:width'
} }
]) ])
// 盒子样式选项 // 盒子样式选项
const boxStyleOptions = computed(() => [ const boxStyleOptions = computed(() => [
{ {
value: 'border-mode', value: 'border-mode',
label: t('setting.box.list[0]'), label: t('setting.box.list[0]'),
type: 'border-mode' as const type: 'border-mode' as const
}, },
{ {
value: 'shadow-mode', value: 'shadow-mode',
label: t('setting.box.list[1]'), label: t('setting.box.list[1]'),
type: 'shadow-mode' as const type: 'shadow-mode' as const
} }
]) ])
// 从配置文件获取的选项 // 从配置文件获取的选项
const configOptions = { const configOptions = {
// 主题色彩选项 // 主题色彩选项
mainColors: AppConfig.systemMainColor, mainColors: AppConfig.systemMainColor,
// 主题风格选项 // 主题风格选项
themeList: AppConfig.settingThemeList, themeList: AppConfig.settingThemeList,
// 菜单布局选项 // 菜单布局选项
menuLayoutList: AppConfig.menuLayoutList menuLayoutList: AppConfig.menuLayoutList
} }
// 基础设置项配置 // 基础设置项配置
const basicSettingsConfig = computed(() => { const basicSettingsConfig = computed(() => {
// 定义所有基础设置项 // 定义所有基础设置项
const allSettings = [ const allSettings = [
{ {
key: 'showWorkTab', key: 'showWorkTab',
label: t('setting.basics.list.multiTab'), label: t('setting.basics.list.multiTab'),
type: 'switch' as const, type: 'switch' as const,
handler: 'workTab', handler: 'workTab',
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'uniqueOpened', key: 'uniqueOpened',
label: t('setting.basics.list.accordion'), label: t('setting.basics.list.accordion'),
type: 'switch' as const, type: 'switch' as const,
handler: 'uniqueOpened', handler: 'uniqueOpened',
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'showMenuButton', key: 'showMenuButton',
label: t('setting.basics.list.collapseSidebar'), label: t('setting.basics.list.collapseSidebar'),
type: 'switch' as const, type: 'switch' as const,
handler: 'menuButton', handler: 'menuButton',
headerBarKey: 'menuButton' as const headerBarKey: 'menuButton' as const
}, },
{ {
key: 'showFastEnter', key: 'showFastEnter',
label: t('setting.basics.list.fastEnter'), label: t('setting.basics.list.fastEnter'),
type: 'switch' as const, type: 'switch' as const,
handler: 'fastEnter', handler: 'fastEnter',
headerBarKey: 'fastEnter' as const headerBarKey: 'fastEnter' as const
}, },
{ {
key: 'showRefreshButton', key: 'showRefreshButton',
label: t('setting.basics.list.reloadPage'), label: t('setting.basics.list.reloadPage'),
type: 'switch' as const, type: 'switch' as const,
handler: 'refreshButton', handler: 'refreshButton',
headerBarKey: 'refreshButton' as const headerBarKey: 'refreshButton' as const
}, },
{ {
key: 'showCrumbs', key: 'showCrumbs',
label: t('setting.basics.list.breadcrumb'), label: t('setting.basics.list.breadcrumb'),
type: 'switch' as const, type: 'switch' as const,
handler: 'crumbs', handler: 'crumbs',
mobileHide: true, mobileHide: true,
headerBarKey: 'breadcrumb' as const headerBarKey: 'breadcrumb' as const
}, },
{ {
key: 'showLanguage', key: 'showLanguage',
label: t('setting.basics.list.language'), label: t('setting.basics.list.language'),
type: 'switch' as const, type: 'switch' as const,
handler: 'language', handler: 'language',
headerBarKey: 'language' as const headerBarKey: 'language' as const
}, },
{ {
key: 'showNprogress', key: 'showNprogress',
label: t('setting.basics.list.progressBar'), label: t('setting.basics.list.progressBar'),
type: 'switch' as const, type: 'switch' as const,
handler: 'nprogress', handler: 'nprogress',
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'colorWeak', key: 'colorWeak',
label: t('setting.basics.list.weakMode'), label: t('setting.basics.list.weakMode'),
type: 'switch' as const, type: 'switch' as const,
handler: 'colorWeak', handler: 'colorWeak',
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'watermarkVisible', key: 'watermarkVisible',
label: t('setting.basics.list.watermark'), label: t('setting.basics.list.watermark'),
type: 'switch' as const, type: 'switch' as const,
handler: 'watermark', handler: 'watermark',
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'menuOpenWidth', key: 'menuOpenWidth',
label: t('setting.basics.list.menuWidth'), label: t('setting.basics.list.menuWidth'),
type: 'input-number' as const, type: 'input-number' as const,
handler: 'menuOpenWidth', handler: 'menuOpenWidth',
min: 180, min: 180,
max: 320, max: 320,
step: 10, step: 10,
style: { width: '120px' }, style: { width: '120px' },
controlsPosition: 'right' as const, controlsPosition: 'right' as const,
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'tabStyle', key: 'tabStyle',
label: t('setting.basics.list.tabStyle'), label: t('setting.basics.list.tabStyle'),
type: 'select' as const, type: 'select' as const,
handler: 'tabStyle', handler: 'tabStyle',
options: tabStyleOptions.value, options: tabStyleOptions.value,
style: { width: '120px' }, style: { width: '120px' },
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'pageTransition', key: 'pageTransition',
label: t('setting.basics.list.pageTransition'), label: t('setting.basics.list.pageTransition'),
type: 'select' as const, type: 'select' as const,
handler: 'pageTransition', handler: 'pageTransition',
options: pageTransitionOptions.value, options: pageTransitionOptions.value,
style: { width: '120px' }, style: { width: '120px' },
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
}, },
{ {
key: 'customRadius', key: 'customRadius',
label: t('setting.basics.list.borderRadius'), label: t('setting.basics.list.borderRadius'),
type: 'select' as const, type: 'select' as const,
handler: 'customRadius', handler: 'customRadius',
options: customRadiusOptions, options: customRadiusOptions,
style: { width: '120px' }, style: { width: '120px' },
headerBarKey: null // 不依赖headerBar配置 headerBarKey: null // 不依赖headerBar配置
} }
] ]
// 根据 headerBarConfig 过滤设置项 // 根据 headerBarConfig 过滤设置项
return ( return (
allSettings allSettings
.filter((setting) => { .filter((setting) => {
// 如果设置项不依赖headerBar配置,则始终显示 // 如果设置项不依赖headerBar配置,则始终显示
if (setting.headerBarKey === null) { if (setting.headerBarKey === null) {
return true return true
} }
// 如果依赖headerBar配置,检查对应的功能是否启用 // 如果依赖headerBar配置,检查对应的功能是否启用
const headerBarFeature = headerBarConfig[setting.headerBarKey] const headerBarFeature = headerBarConfig[setting.headerBarKey]
return headerBarFeature?.enabled !== false return headerBarFeature?.enabled !== false
}) })
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting) .map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
) )
}) })
return { return {
// 选项配置 // 选项配置
tabStyleOptions, tabStyleOptions,
pageTransitionOptions, pageTransitionOptions,
customRadiusOptions, customRadiusOptions,
containerWidthOptions, containerWidthOptions,
boxStyleOptions, boxStyleOptions,
configOptions, configOptions,
// 设置项配置 // 设置项配置
basicSettingsConfig basicSettingsConfig
} }
} }
@@ -6,162 +6,162 @@ import type { ContainerWidthEnum } from '@/enums/appEnum'
* 设置项通用处理逻辑 * 设置项通用处理逻辑
*/ */
export function useSettingsHandlers() { export function useSettingsHandlers() {
const settingStore = useSettingStore() const settingStore = useSettingStore()
// DOM 操作相关 // DOM 操作相关
const domOperations = { const domOperations = {
// 设置HTML类名 // 设置HTML类名
setHtmlClass: (className: string, add: boolean) => { setHtmlClass: (className: string, add: boolean) => {
const el = document.getElementsByTagName('html')[0] const el = document.getElementsByTagName('html')[0]
if (add) { if (add) {
el.classList.add(className) el.classList.add(className)
} else { } else {
el.classList.remove(className) el.classList.remove(className)
} }
}, },
// 设置根元素属性 // 设置根元素属性
setRootAttribute: (attribute: string, value: string) => { setRootAttribute: (attribute: string, value: string) => {
const el = document.documentElement const el = document.documentElement
el.setAttribute(attribute, value) el.setAttribute(attribute, value)
}, },
// 设置body类名 // 设置body类名
setBodyClass: (className: string, add: boolean) => { setBodyClass: (className: string, add: boolean) => {
const el = document.getElementsByTagName('body')[0] const el = document.getElementsByTagName('body')[0]
if (add) { if (add) {
el.classList.add(className) el.classList.add(className)
} else { } else {
el.classList.remove(className) el.classList.remove(className)
} }
} }
} }
// 通用切换处理器 // 通用切换处理器
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => { const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
return () => { return () => {
storeMethod() storeMethod()
callback?.() callback?.()
} }
} }
// 通用值变更处理器 // 通用值变更处理器
const createValueHandler = <T>( const createValueHandler = <T>(
storeMethod: (value: T) => void, storeMethod: (value: T) => void,
callback?: (value: T) => void callback?: (value: T) => void
) => { ) => {
return (value: T) => { return (value: T) => {
if (value !== undefined && value !== null) { if (value !== undefined && value !== null) {
storeMethod(value) storeMethod(value)
callback?.(value) callback?.(value)
} }
} }
} }
// 基础设置处理器 // 基础设置处理器
const basicHandlers = { const basicHandlers = {
// 工作台标签页 // 工作台标签页
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)), workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
// 菜单手风琴 // 菜单手风琴
uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()), uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()),
// 显示菜单按钮 // 显示菜单按钮
menuButton: createToggleHandler(() => settingStore.setButton()), menuButton: createToggleHandler(() => settingStore.setButton()),
// 显示快速入口 // 显示快速入口
fastEnter: createToggleHandler(() => settingStore.setFastEnter()), fastEnter: createToggleHandler(() => settingStore.setFastEnter()),
// 显示刷新按钮 // 显示刷新按钮
refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()), refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()),
// 显示面包屑 // 显示面包屑
crumbs: createToggleHandler(() => settingStore.setCrumbs()), crumbs: createToggleHandler(() => settingStore.setCrumbs()),
// 显示语言切换 // 显示语言切换
language: createToggleHandler(() => settingStore.setLanguage()), language: createToggleHandler(() => settingStore.setLanguage()),
// 显示进度条 // 显示进度条
nprogress: createToggleHandler(() => settingStore.setNprogress()), nprogress: createToggleHandler(() => settingStore.setNprogress()),
// 色弱模式 // 色弱模式
colorWeak: createToggleHandler( colorWeak: createToggleHandler(
() => settingStore.setColorWeak(), () => settingStore.setColorWeak(),
() => { () => {
domOperations.setHtmlClass('color-weak', settingStore.colorWeak) domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
} }
), ),
// 水印显示 // 水印显示
watermark: createToggleHandler(() => watermark: createToggleHandler(() =>
settingStore.setWatermarkVisible(!settingStore.watermarkVisible) settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
), ),
// 菜单展开宽度 // 菜单展开宽度
menuOpenWidth: createValueHandler<number>((width: number) => menuOpenWidth: createValueHandler<number>((width: number) =>
settingStore.setMenuOpenWidth(width) settingStore.setMenuOpenWidth(width)
), ),
// 标签页风格 // 标签页风格
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)), tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
// 页面切换动画 // 页面切换动画
pageTransition: createValueHandler<string>((transition: string) => pageTransition: createValueHandler<string>((transition: string) =>
settingStore.setPageTransition(transition) settingStore.setPageTransition(transition)
), ),
// 圆角大小 // 圆角大小
customRadius: createValueHandler<string>((radius: string) => customRadius: createValueHandler<string>((radius: string) =>
settingStore.setCustomRadius(radius) settingStore.setCustomRadius(radius)
) )
} }
// 盒子样式处理器 // 盒子样式处理器
const boxStyleHandlers = { const boxStyleHandlers = {
// 设置盒子模式 // 设置盒子模式
setBoxMode: (type: 'border-mode' | 'shadow-mode') => { setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
const { boxBorderMode } = storeToRefs(settingStore) const { boxBorderMode } = storeToRefs(settingStore)
// 防止重复设置 // 防止重复设置
if ( if (
(type === 'shadow-mode' && boxBorderMode.value === false) || (type === 'shadow-mode' && boxBorderMode.value === false) ||
(type === 'border-mode' && boxBorderMode.value === true) (type === 'border-mode' && boxBorderMode.value === true)
) { ) {
return return
} }
setTimeout(() => { setTimeout(() => {
domOperations.setRootAttribute('data-box-mode', type) domOperations.setRootAttribute('data-box-mode', type)
settingStore.setBorderMode() settingStore.setBorderMode()
}, 50) }, 50)
} }
} }
// 颜色设置处理器 // 颜色设置处理器
const colorHandlers = { const colorHandlers = {
// 选择主题色 // 选择主题色
selectColor: (theme: string) => { selectColor: (theme: string) => {
settingStore.setElementTheme(theme) settingStore.setElementTheme(theme)
settingStore.reload() settingStore.reload()
} }
} }
// 容器设置处理器 // 容器设置处理器
const containerHandlers = { const containerHandlers = {
// 设置容器宽度 // 设置容器宽度
setWidth: (type: ContainerWidthEnum) => { setWidth: (type: ContainerWidthEnum) => {
settingStore.setContainerWidth(type) settingStore.setContainerWidth(type)
settingStore.reload() settingStore.reload()
} }
} }
return { return {
domOperations, domOperations,
basicHandlers, basicHandlers,
boxStyleHandlers, boxStyleHandlers,
colorHandlers, colorHandlers,
containerHandlers, containerHandlers,
createToggleHandler, createToggleHandler,
createValueHandler createValueHandler
} }
} }
@@ -14,194 +14,194 @@ import { useSettingsHandlers } from './useSettingsHandlers'
* 设置面板核心逻辑管理 * 设置面板核心逻辑管理
*/ */
export function useSettingsPanel() { export function useSettingsPanel() {
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore) const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
// Composables // Composables
const { openFestival, cleanup } = useCeremony() const { openFestival, cleanup } = useCeremony()
const { setSystemTheme, setSystemAutoTheme } = useTheme() const { setSystemTheme, setSystemAutoTheme } = useTheme()
const { initColorWeak } = useSettingsState() const { initColorWeak } = useSettingsState()
const { domOperations } = useSettingsHandlers() const { domOperations } = useSettingsHandlers()
// 响应式状态 // 响应式状态
const showDrawer = ref(false) const showDrawer = ref(false)
// 使用 VueUse breakpoints 优化性能 // 使用 VueUse breakpoints 优化性能
const breakpoints = useBreakpoints({ tablet: 1000 }) const breakpoints = useBreakpoints({ tablet: 1000 })
const isMobile = breakpoints.smaller('tablet') const isMobile = breakpoints.smaller('tablet')
// 记录窗口宽度变化前的菜单类型 // 记录窗口宽度变化前的菜单类型
const beforeMenuType = ref<MenuTypeEnum>() const beforeMenuType = ref<MenuTypeEnum>()
const hasChangedMenu = ref(false) const hasChangedMenu = ref(false)
// 计算属性 // 计算属性
const systemThemeColor = computed(() => settingStore.systemThemeColor as string) const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
// 主题相关处理 // 主题相关处理
const useThemeHandlers = () => { const useThemeHandlers = () => {
// 初始化系统颜色 // 初始化系统颜色
const initSystemColor = () => { const initSystemColor = () => {
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) { if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
settingStore.setElementTheme(AppConfig.systemMainColor[0]) settingStore.setElementTheme(AppConfig.systemMainColor[0])
settingStore.reload() settingStore.reload()
} }
} }
// 初始化系统主题 // 初始化系统主题
const initSystemTheme = () => { const initSystemTheme = () => {
if (systemThemeMode.value === SystemThemeEnum.AUTO) { if (systemThemeMode.value === SystemThemeEnum.AUTO) {
setSystemAutoTheme() setSystemAutoTheme()
} else { } else {
setSystemTheme(systemThemeType.value) setSystemTheme(systemThemeType.value)
} }
} }
// 监听系统主题变化 // 监听系统主题变化
const listenerSystemTheme = () => { const listenerSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', initSystemTheme) mediaQuery.addEventListener('change', initSystemTheme)
return () => { return () => {
mediaQuery.removeEventListener('change', initSystemTheme) mediaQuery.removeEventListener('change', initSystemTheme)
} }
} }
return { return {
initSystemColor, initSystemColor,
initSystemTheme, initSystemTheme,
listenerSystemTheme listenerSystemTheme
} }
} }
// 响应式布局处理 // 响应式布局处理
const useResponsiveLayout = () => { const useResponsiveLayout = () => {
// 使用 watch 监听断点变化,性能更优 // 使用 watch 监听断点变化,性能更优
const stopWatch = watch( const stopWatch = watch(
isMobile, isMobile,
(mobile: boolean) => { (mobile: boolean) => {
if (mobile) { if (mobile) {
// 切换到移动端布局 // 切换到移动端布局
if (!hasChangedMenu.value) { if (!hasChangedMenu.value) {
beforeMenuType.value = menuType.value beforeMenuType.value = menuType.value
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT) useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
settingStore.setMenuOpen(false) settingStore.setMenuOpen(false)
hasChangedMenu.value = true hasChangedMenu.value = true
} }
} else { } else {
// 恢复桌面端布局 // 恢复桌面端布局
if (hasChangedMenu.value && beforeMenuType.value) { if (hasChangedMenu.value && beforeMenuType.value) {
useSettingsState().switchMenuLayouts(beforeMenuType.value) useSettingsState().switchMenuLayouts(beforeMenuType.value)
settingStore.setMenuOpen(true) settingStore.setMenuOpen(true)
hasChangedMenu.value = false hasChangedMenu.value = false
} }
} }
}, },
{ immediate: true } { immediate: true }
) )
return { stopWatch } return { stopWatch }
} }
// 抽屉控制 // 抽屉控制
const useDrawerControl = () => { const useDrawerControl = () => {
// 用于存储 setTimeout 的 ID,以便在需要时清除 // 用于存储 setTimeout 的 ID,以便在需要时清除
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null let themeChangeTimer: ReturnType<typeof setTimeout> | null = null
// 打开抽屉 // 打开抽屉
const handleOpen = () => { const handleOpen = () => {
// 清除可能存在的旧定时器 // 清除可能存在的旧定时器
if (themeChangeTimer) { if (themeChangeTimer) {
clearTimeout(themeChangeTimer) clearTimeout(themeChangeTimer)
} }
// 延迟添加 theme-change class,避免抽屉打开动画受影响 // 延迟添加 theme-change class,避免抽屉打开动画受影响
themeChangeTimer = setTimeout(() => { themeChangeTimer = setTimeout(() => {
domOperations.setBodyClass('theme-change', true) domOperations.setBodyClass('theme-change', true)
themeChangeTimer = null themeChangeTimer = null
}, 500) }, 500)
} }
// 关闭抽屉 // 关闭抽屉
const handleClose = () => { const handleClose = () => {
// 清除未执行的定时器,防止关闭后才添加 class // 清除未执行的定时器,防止关闭后才添加 class
if (themeChangeTimer) { if (themeChangeTimer) {
clearTimeout(themeChangeTimer) clearTimeout(themeChangeTimer)
themeChangeTimer = null themeChangeTimer = null
} }
// 立即移除 theme-change class // 立即移除 theme-change class
domOperations.setBodyClass('theme-change', false) domOperations.setBodyClass('theme-change', false)
} }
// 打开设置 // 打开设置
const openSetting = () => { const openSetting = () => {
showDrawer.value = true showDrawer.value = true
} }
// 关闭设置 // 关闭设置
const closeDrawer = () => { const closeDrawer = () => {
showDrawer.value = false showDrawer.value = false
} }
return { return {
handleOpen, handleOpen,
handleClose, handleClose,
openSetting, openSetting,
closeDrawer closeDrawer
} }
} }
// Props 变化监听 // Props 变化监听
const usePropsWatcher = (props: { open?: boolean }) => { const usePropsWatcher = (props: { open?: boolean }) => {
watch( watch(
() => props.open, () => props.open,
(val: boolean | undefined) => { (val: boolean | undefined) => {
if (val !== undefined) { if (val !== undefined) {
showDrawer.value = val showDrawer.value = val
} }
} }
) )
} }
// 初始化设置 // 初始化设置
const useSettingsInitializer = () => { const useSettingsInitializer = () => {
const themeHandlers = useThemeHandlers() const themeHandlers = useThemeHandlers()
const { openSetting } = useDrawerControl() const { openSetting } = useDrawerControl()
const { stopWatch } = useResponsiveLayout() const { stopWatch } = useResponsiveLayout()
let themeCleanup: (() => void) | null = null let themeCleanup: (() => void) | null = null
const initializeSettings = () => { const initializeSettings = () => {
mittBus.on('openSetting', openSetting) mittBus.on('openSetting', openSetting)
themeHandlers.initSystemColor() themeHandlers.initSystemColor()
themeCleanup = themeHandlers.listenerSystemTheme() themeCleanup = themeHandlers.listenerSystemTheme()
initColorWeak() initColorWeak()
// 设置盒子模式 // 设置盒子模式
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode' const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
domOperations.setRootAttribute('data-box-mode', boxMode) domOperations.setRootAttribute('data-box-mode', boxMode)
themeHandlers.initSystemTheme() themeHandlers.initSystemTheme()
openFestival() openFestival()
} }
const cleanupSettings = () => { const cleanupSettings = () => {
stopWatch() stopWatch()
themeCleanup?.() themeCleanup?.()
cleanup() cleanup()
} }
return { return {
initializeSettings, initializeSettings,
cleanupSettings cleanupSettings
} }
} }
return { return {
// 状态 // 状态
showDrawer, showDrawer,
// 方法组合 // 方法组合
useThemeHandlers, useThemeHandlers,
useResponsiveLayout, useResponsiveLayout,
useDrawerControl, useDrawerControl,
usePropsWatcher, usePropsWatcher,
useSettingsInitializer useSettingsInitializer
} }
} }
@@ -5,33 +5,33 @@ import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
* 设置状态管理 * 设置状态管理
*/ */
export function useSettingsState() { export function useSettingsState() {
const settingStore = useSettingStore() const settingStore = useSettingStore()
// 色弱模式初始化 // 色弱模式初始化
const initColorWeak = () => { const initColorWeak = () => {
if (settingStore.colorWeak) { if (settingStore.colorWeak) {
const el = document.getElementsByTagName('html')[0] const el = document.getElementsByTagName('html')[0]
setTimeout(() => { setTimeout(() => {
el.classList.add('color-weak') el.classList.add('color-weak')
}, 100) }, 100)
} }
} }
// 菜单布局切换 // 菜单布局切换
const switchMenuLayouts = (type: MenuTypeEnum) => { const switchMenuLayouts = (type: MenuTypeEnum) => {
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) { if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
settingStore.setMenuOpen(true) settingStore.setMenuOpen(true)
} }
settingStore.switchMenuLayouts(type) settingStore.switchMenuLayouts(type)
if (type === MenuTypeEnum.DUAL_MENU) { if (type === MenuTypeEnum.DUAL_MENU) {
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN) settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
settingStore.setMenuOpen(true) settingStore.setMenuOpen(true)
} }
} }
return { return {
// 方法 // 方法
initColorWeak, initColorWeak,
switchMenuLayouts switchMenuLayouts
} }
} }
@@ -1,72 +1,72 @@
<!-- 设置面板 --> <!-- 设置面板 -->
<template> <template>
<div class="layout-settings"> <div class="layout-settings">
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose"> <SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
<!-- 头部关闭按钮 --> <!-- 头部关闭按钮 -->
<SettingHeader @close="closeDrawer" /> <SettingHeader @close="closeDrawer" />
<!-- 主题风格 --> <!-- 主题风格 -->
<ThemeSettings /> <ThemeSettings />
<!-- 菜单布局 --> <!-- 菜单布局 -->
<MenuLayoutSettings /> <MenuLayoutSettings />
<!-- 菜单风格 --> <!-- 菜单风格 -->
<MenuStyleSettings /> <MenuStyleSettings />
<!-- 系统主题色 --> <!-- 系统主题色 -->
<ColorSettings /> <ColorSettings />
<!-- 盒子样式 --> <!-- 盒子样式 -->
<BoxStyleSettings /> <BoxStyleSettings />
<!-- 容器宽度 --> <!-- 容器宽度 -->
<ContainerSettings /> <ContainerSettings />
<!-- 基础配置 --> <!-- 基础配置 -->
<BasicSettings /> <BasicSettings />
<!-- 操作按钮 --> <!-- 操作按钮 -->
<SettingActions /> <SettingActions />
</SettingDrawer> </SettingDrawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSettingsPanel } from './composables/useSettingsPanel' import { useSettingsPanel } from './composables/useSettingsPanel'
import SettingDrawer from './widget/SettingDrawer.vue' import SettingDrawer from './widget/SettingDrawer.vue'
import SettingHeader from './widget/SettingHeader.vue' import SettingHeader from './widget/SettingHeader.vue'
import ThemeSettings from './widget/ThemeSettings.vue' import ThemeSettings from './widget/ThemeSettings.vue'
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue' import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
import MenuStyleSettings from './widget/MenuStyleSettings.vue' import MenuStyleSettings from './widget/MenuStyleSettings.vue'
import ColorSettings from './widget/ColorSettings.vue' import ColorSettings from './widget/ColorSettings.vue'
import BoxStyleSettings from './widget/BoxStyleSettings.vue' import BoxStyleSettings from './widget/BoxStyleSettings.vue'
import ContainerSettings from './widget/ContainerSettings.vue' import ContainerSettings from './widget/ContainerSettings.vue'
import BasicSettings from './widget/BasicSettings.vue' import BasicSettings from './widget/BasicSettings.vue'
import SettingActions from './widget/SettingActions.vue' import SettingActions from './widget/SettingActions.vue'
defineOptions({ name: 'ArtSettingsPanel' }) defineOptions({ name: 'ArtSettingsPanel' })
interface Props { interface Props {
/** 是否打开 */ /** 是否打开 */
open?: boolean open?: boolean
} }
const props = defineProps<Props>() const props = defineProps<Props>()
// 使用设置面板逻辑 // 使用设置面板逻辑
const settingsPanel = useSettingsPanel() const settingsPanel = useSettingsPanel()
const { showDrawer } = settingsPanel const { showDrawer } = settingsPanel
// 获取各种处理器 // 获取各种处理器
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl() const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer() const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
// 监听 props 变化 // 监听 props 变化
settingsPanel.usePropsWatcher(props) settingsPanel.usePropsWatcher(props)
onMounted(() => { onMounted(() => {
initializeSettings() initializeSettings()
}) })
onUnmounted(() => { onUnmounted(() => {
cleanupSettings() cleanupSettings()
}) })
</script> </script>
<style lang="scss"> <style lang="scss">
@use './style'; @use './style';
</style> </style>
@@ -2,91 +2,91 @@
// 设置抽屉模态框样式 // 设置抽屉模态框样式
.setting-modal { .setting-modal {
background: transparent !important; background: transparent !important;
.el-drawer { .el-drawer {
// 背景滤镜效果 // 背景滤镜效果
background: rgba($color: #fff, $alpha: 50%) !important; background: rgba($color: #fff, $alpha: 50%) !important;
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important; box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
@include backdropBlur(); @include backdropBlur();
.setting-box-wrap { .setting-box-wrap {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
width: calc(100% + 15px); width: calc(100% + 15px);
margin-bottom: 10px; margin-bottom: 10px;
.setting-item { .setting-item {
box-sizing: border-box; box-sizing: border-box;
width: calc(33.333% - 15px); width: calc(33.333% - 15px);
margin-right: 15px; margin-right: 15px;
text-align: center; text-align: center;
.box { .box {
position: relative; position: relative;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
height: 52px; height: 52px;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
border: 2px solid var(--default-border); border: 2px solid var(--default-border);
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%); box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
transition: box-shadow 0.1s; transition: box-shadow 0.1s;
&.mt-16 { &.mt-16 {
margin-top: 16px; margin-top: 16px;
} }
&.is-active { &.is-active {
border: 2px solid var(--theme-color); border: 2px solid var(--theme-color);
} }
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
.name { .name {
margin-top: 6px; margin-top: 6px;
font-size: 14px; font-size: 14px;
text-align: center; text-align: center;
} }
} }
} }
} }
// 去除滚动条 // 去除滚动条
.el-drawer__body::-webkit-scrollbar { .el-drawer__body::-webkit-scrollbar {
width: 0 !important; width: 0 !important;
} }
} }
.dark { .dark {
.setting-modal { .setting-modal {
.el-drawer { .el-drawer {
background: rgba($color: #000, $alpha: 50%) !important; background: rgba($color: #000, $alpha: 50%) !important;
.setting-item { .setting-item {
.box { .box {
border: 2px solid transparent; border: 2px solid transparent;
} }
} }
} }
} }
} }
// 去除火狐浏览器滚动条 // 去除火狐浏览器滚动条
:deep(.el-drawer__body) { :deep(.el-drawer__body) {
scrollbar-width: none; scrollbar-width: none;
} }
// 移动端隐藏 // 移动端隐藏
@media screen and (width <= 800px) { @media screen and (width <= 800px) {
.mobile-hide { .mobile-hide {
display: none !important; display: none !important;
} }
} }
@@ -1,77 +1,77 @@
<template> <template>
<div> <div>
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" /> <SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
<SettingItem <SettingItem
v-for="config in basicSettingsConfig" v-for="config in basicSettingsConfig"
:key="config.key" :key="config.key"
:config="config" :config="config"
:model-value="getSettingValue(config.key)" :model-value="getSettingValue(config.key)"
@change="handleSettingChange(config.handler, $event)" @change="handleSettingChange(config.handler, $event)"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import SettingItem from './SettingItem.vue' import SettingItem from './SettingItem.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig' import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers' import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { basicSettingsConfig } = useSettingsConfig() const { basicSettingsConfig } = useSettingsConfig()
const { basicHandlers } = useSettingsHandlers() const { basicHandlers } = useSettingsHandlers()
// 获取store的响应式状态 // 获取store的响应式状态
const { const {
uniqueOpened, uniqueOpened,
showMenuButton, showMenuButton,
showFastEnter, showFastEnter,
showRefreshButton, showRefreshButton,
showCrumbs, showCrumbs,
showWorkTab, showWorkTab,
showLanguage, showLanguage,
showNprogress, showNprogress,
colorWeak, colorWeak,
watermarkVisible, watermarkVisible,
menuOpenWidth, menuOpenWidth,
tabStyle, tabStyle,
pageTransition, pageTransition,
customRadius customRadius
} = storeToRefs(settingStore) } = storeToRefs(settingStore)
// 创建设置值映射 // 创建设置值映射
const settingValueMap = { const settingValueMap = {
uniqueOpened, uniqueOpened,
showMenuButton, showMenuButton,
showFastEnter, showFastEnter,
showRefreshButton, showRefreshButton,
showCrumbs, showCrumbs,
showWorkTab, showWorkTab,
showLanguage, showLanguage,
showNprogress, showNprogress,
colorWeak, colorWeak,
watermarkVisible, watermarkVisible,
menuOpenWidth, menuOpenWidth,
tabStyle, tabStyle,
pageTransition, pageTransition,
customRadius customRadius
} }
// 获取设置值的方法 // 获取设置值的方法
const getSettingValue = (key: string) => { const getSettingValue = (key: string) => {
const settingRef = settingValueMap[key as keyof typeof settingValueMap] const settingRef = settingValueMap[key as keyof typeof settingValueMap]
return settingRef?.value ?? null return settingRef?.value ?? null
} }
// 统一的设置变更处理 // 统一的设置变更处理
const handleSettingChange = (handlerName: string, value: any) => { const handleSettingChange = (handlerName: string, value: any) => {
const handler = (basicHandlers as any)[handlerName] const handler = (basicHandlers as any)[handlerName]
if (typeof handler === 'function') { if (typeof handler === 'function') {
handler(value) handler(value)
} else { } else {
console.warn(`Handler "${handlerName}" not found in basicHandlers`) console.warn(`Handler "${handlerName}" not found in basicHandlers`)
} }
} }
</script> </script>
@@ -1,38 +1,38 @@
<template> <template>
<div> <div>
<SectionTitle :title="$t('setting.box.title')" class="mt-10" /> <SectionTitle :title="$t('setting.box.title')" class="mt-10" />
<div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200"> <div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200">
<div <div
v-for="option in boxStyleOptions" v-for="option in boxStyleOptions"
:key="option.value" :key="option.value"
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200" class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200"
:class=" :class="
isActive(option.type) isActive(option.type)
? 'text-g-800 bg-[var(--default-box-color)] dark:!text-white dark:bg-g-300' ? 'text-g-800 bg-[var(--default-box-color)] dark:!text-white dark:bg-g-300'
: 'hover:text-g-800 hover:bg-black/[0.04] dark:hover:bg-black/20' : 'hover:text-g-800 hover:bg-black/[0.04] dark:hover:bg-black/20'
" "
@click="boxStyleHandlers.setBoxMode(option.type)" @click="boxStyleHandlers.setBoxMode(option.type)"
> >
{{ option.label }} {{ option.label }}
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig' import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers' import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { boxBorderMode } = storeToRefs(settingStore) const { boxBorderMode } = storeToRefs(settingStore)
const { boxStyleOptions } = useSettingsConfig() const { boxStyleOptions } = useSettingsConfig()
const { boxStyleHandlers } = useSettingsHandlers() const { boxStyleHandlers } = useSettingsHandlers()
// 判断当前选项是否激活 // 判断当前选项是否激活
const isActive = (type: 'border-mode' | 'shadow-mode') => { const isActive = (type: 'border-mode' | 'shadow-mode') => {
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
} }
</script> </script>
@@ -1,35 +1,35 @@
<template> <template>
<div> <div>
<SectionTitle :title="$t('setting.color.title')" class="mt-10" /> <SectionTitle :title="$t('setting.color.title')" class="mt-10" />
<div class="-mr-4"> <div class="-mr-4">
<div class="flex flex-wrap"> <div class="flex flex-wrap">
<div <div
v-for="color in configOptions.mainColors" v-for="color in configOptions.mainColors"
:key="color" :key="color"
class="flex items-center justify-center size-[23px] mr-4 mb-2.5 cursor-pointer rounded-full transition-all duration-200 hover:opacity-85" class="flex items-center justify-center size-[23px] mr-4 mb-2.5 cursor-pointer rounded-full transition-all duration-200 hover:opacity-85"
:style="{ background: `${color} !important` }" :style="{ background: `${color} !important` }"
@click="colorHandlers.selectColor(color)" @click="colorHandlers.selectColor(color)"
> >
<ArtSvgIcon <ArtSvgIcon
icon="ri:check-fill" icon="ri:check-fill"
class="text-base !text-white" class="text-base !text-white"
v-show="color === systemThemeColor" v-show="color === systemThemeColor"
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig' import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers' import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { systemThemeColor } = storeToRefs(settingStore) const { systemThemeColor } = storeToRefs(settingStore)
const { configOptions } = useSettingsConfig() const { configOptions } = useSettingsConfig()
const { colorHandlers } = useSettingsHandlers() const { colorHandlers } = useSettingsHandlers()
</script> </script>
@@ -1,33 +1,33 @@
<template> <template>
<div> <div>
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" /> <SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
<div class="flex"> <div class="flex">
<div <div
v-for="option in containerWidthOptions" v-for="option in containerWidthOptions"
:key="option.value" :key="option.value"
class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer !border-2 rounded-lg !text-g-800 last:mr-0" class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer !border-2 rounded-lg !text-g-800 last:mr-0"
:class="{ :class="{
'border-theme [&_i]:!text-theme': containerWidth === option.value, 'border-theme [&_i]:!text-theme': containerWidth === option.value,
'border-full-d': containerWidth !== option.value 'border-full-d': containerWidth !== option.value
}" }"
@click="containerHandlers.setWidth(option.value)" @click="containerHandlers.setWidth(option.value)"
> >
<ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" /> <ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" />
<span class="text-sm">{{ option.label }}</span> <span class="text-sm">{{ option.label }}</span>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig' import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers' import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { containerWidth } = storeToRefs(settingStore) const { containerWidth } = storeToRefs(settingStore)
const { containerWidthOptions } = useSettingsConfig() const { containerWidthOptions } = useSettingsConfig()
const { containerHandlers } = useSettingsHandlers() const { containerHandlers } = useSettingsHandlers()
</script> </script>
@@ -1,31 +1,34 @@
<template> <template>
<div v-if="width > 1000"> <div v-if="width > 1000">
<SectionTitle :title="$t('setting.menuType.title')" /> <SectionTitle :title="$t('setting.menuType.title')" />
<div class="setting-box-wrap"> <div class="setting-box-wrap">
<div <div
class="setting-item" class="setting-item"
v-for="(item, index) in configOptions.menuLayoutList" v-for="(item, index) in configOptions.menuLayoutList"
:key="item.value" :key="item.value"
@click="switchMenuLayouts(item.value)" @click="switchMenuLayouts(item.value)"
> >
<div class="box" :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }"> <div
<img :src="item.img" /> class="box"
</div> :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }"
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p> >
</div> <img :src="item.img" />
</div> </div>
</div> <p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig' import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsState } from '../composables/useSettingsState' import { useSettingsState } from '../composables/useSettingsState'
const { width } = useWindowSize() const { width } = useWindowSize()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { menuType } = storeToRefs(settingStore) const { menuType } = storeToRefs(settingStore)
const { configOptions } = useSettingsConfig() const { configOptions } = useSettingsConfig()
const { switchMenuLayouts } = useSettingsState() const { switchMenuLayouts } = useSettingsState()
</script> </script>
@@ -1,44 +1,44 @@
<template> <template>
<SectionTitle :title="$t('setting.menu.title')" /> <SectionTitle :title="$t('setting.menu.title')" />
<div class="setting-box-wrap"> <div class="setting-box-wrap">
<div <div
class="setting-item" class="setting-item"
v-for="item in menuThemeList" v-for="item in menuThemeList"
:key="item.theme" :key="item.theme"
@click="switchMenuStyles(item.theme)" @click="switchMenuStyles(item.theme)"
> >
<div <div
class="box" class="box"
:class="{ 'is-active': item.theme === menuThemeType }" :class="{ 'is-active': item.theme === menuThemeType }"
:style="{ :style="{
cursor: disabled ? 'no-drop' : 'pointer' cursor: disabled ? 'no-drop' : 'pointer'
}" }"
> >
<img :src="item.img" /> <img :src="item.img" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppConfig from '@/config' import AppConfig from '@/config'
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum' import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
const menuThemeList = AppConfig.themeList const menuThemeList = AppConfig.themeList
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore) const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP) const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU) const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value) const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value)
// 菜单样式切换 // 菜单样式切换
const switchMenuStyles = (theme: MenuThemeEnum) => { const switchMenuStyles = (theme: MenuThemeEnum) => {
if (isDualMenu.value || isTopMenu.value || isDark.value) { if (isDualMenu.value || isTopMenu.value || isDark.value) {
return return
} }
settingStore.switchMenuStyles(theme) settingStore.switchMenuStyles(theme)
} }
</script> </script>
@@ -1,17 +1,17 @@
<template> <template>
<p <p
class="relative mt-7.5 mb-5.5 text-sm text-center text-g-800 before:absolute before:top-[10px] before:left-0 before:w-[50px] before:m-auto before:content-[''] before:border-b before:border-[var(--art-gray-300)] after:absolute after:top-[10px] after:right-0 after:w-[50px] after:m-auto after:content-[''] after:border-b after:border-g-300" class="relative mt-7.5 mb-5.5 text-sm text-center text-g-800 before:absolute before:top-[10px] before:left-0 before:w-[50px] before:m-auto before:content-[''] before:border-b before:border-[var(--art-gray-300)] after:absolute after:top-[10px] after:right-0 after:w-[50px] after:m-auto after:content-[''] after:border-b after:border-g-300"
:style="style" :style="style"
> >
{{ title }} {{ title }}
</p> </p>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
title: string title: string
style?: Record<string, any> style?: Record<string, any>
} }
defineProps<Props>() defineProps<Props>()
</script> </script>
@@ -1,235 +1,241 @@
<!-- 设置操作按钮 --> <!-- 设置操作按钮 -->
<template> <template>
<div <div
class="mt-10 flex gap-8 border-t border-[var(--default-border)] bg-[var(--art-bg-color)] pt-5" class="mt-10 flex gap-8 border-t border-[var(--default-border)] bg-[var(--art-bg-color)] pt-5"
> >
<ElButton type="primary" class="flex-1 !h-8" @click="handleCopyConfig"> <ElButton type="primary" class="flex-1 !h-8" @click="handleCopyConfig">
{{ $t('setting.actions.copyConfig') }} {{ $t('setting.actions.copyConfig') }}
</ElButton> </ElButton>
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig"> <ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
{{ $t('setting.actions.resetConfig') }} {{ $t('setting.actions.resetConfig') }}
</ElButton> </ElButton>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { SETTING_DEFAULT_CONFIG } from '@/config/setting' import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
import { useClipboard } from '@vueuse/core' import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { MenuThemeEnum } from '@/enums/appEnum' import { MenuThemeEnum } from '@/enums/appEnum'
import { useTheme } from '@/hooks/core/useTheme' import { useTheme } from '@/hooks/core/useTheme'
defineOptions({ name: 'SettingActions' }) defineOptions({ name: 'SettingActions' })
const { t } = useI18n() const { t } = useI18n()
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { copy, copied } = useClipboard() const { copy, copied } = useClipboard()
const { switchThemeStyles } = useTheme() const { switchThemeStyles } = useTheme()
/** 枚举映射表 */ /** 枚举映射表 */
const ENUM_MAPS = { const ENUM_MAPS = {
menuType: { menuType: {
left: 'MenuTypeEnum.LEFT', left: 'MenuTypeEnum.LEFT',
top: 'MenuTypeEnum.TOP', top: 'MenuTypeEnum.TOP',
'top-left': 'MenuTypeEnum.TOP_LEFT', 'top-left': 'MenuTypeEnum.TOP_LEFT',
'dual-menu': 'MenuTypeEnum.DUAL_MENU' 'dual-menu': 'MenuTypeEnum.DUAL_MENU'
}, },
systemTheme: { systemTheme: {
auto: 'SystemThemeEnum.AUTO', auto: 'SystemThemeEnum.AUTO',
light: 'SystemThemeEnum.LIGHT', light: 'SystemThemeEnum.LIGHT',
dark: 'SystemThemeEnum.DARK' dark: 'SystemThemeEnum.DARK'
}, },
menuTheme: { menuTheme: {
design: 'MenuThemeEnum.DESIGN', design: 'MenuThemeEnum.DESIGN',
light: 'MenuThemeEnum.LIGHT', light: 'MenuThemeEnum.LIGHT',
dark: 'MenuThemeEnum.DARK' dark: 'MenuThemeEnum.DARK'
}, },
containerWidth: { containerWidth: {
'100%': 'ContainerWidthEnum.FULL', '100%': 'ContainerWidthEnum.FULL',
'1200px': 'ContainerWidthEnum.BOXED' '1200px': 'ContainerWidthEnum.BOXED'
} }
} as const } as const
/** 配置项定义 */ /** 配置项定义 */
interface ConfigItem { interface ConfigItem {
comment: string comment: string
key: keyof typeof settingStore key: keyof typeof settingStore
enumMap?: Record<string, string> enumMap?: Record<string, string>
forceValue?: any forceValue?: any
} }
const CONFIG_ITEMS: ConfigItem[] = [ const CONFIG_ITEMS: ConfigItem[] = [
{ comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType }, { comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType },
{ comment: '菜单展开宽度', key: 'menuOpenWidth' }, { comment: '菜单展开宽度', key: 'menuOpenWidth' },
{ comment: '菜单是否展开', key: 'menuOpen' }, { comment: '菜单是否展开', key: 'menuOpen' },
{ comment: '双菜单是否显示文本', key: 'dualMenuShowText' }, { comment: '双菜单是否显示文本', key: 'dualMenuShowText' },
{ comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme }, { comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme },
{ comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme }, { comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme },
{ comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme }, { comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme },
{ comment: '系统主题颜色', key: 'systemThemeColor' }, { comment: '系统主题颜色', key: 'systemThemeColor' },
{ comment: '是否显示菜单按钮', key: 'showMenuButton' }, { comment: '是否显示菜单按钮', key: 'showMenuButton' },
{ comment: '是否显示快速入口', key: 'showFastEnter' }, { comment: '是否显示快速入口', key: 'showFastEnter' },
{ comment: '是否显示刷新按钮', key: 'showRefreshButton' }, { comment: '是否显示刷新按钮', key: 'showRefreshButton' },
{ comment: '是否显示面包屑', key: 'showCrumbs' }, { comment: '是否显示面包屑', key: 'showCrumbs' },
{ comment: '是否显示工作台标签', key: 'showWorkTab' }, { comment: '是否显示工作台标签', key: 'showWorkTab' },
{ comment: '是否显示语言切换', key: 'showLanguage' }, { comment: '是否显示语言切换', key: 'showLanguage' },
{ comment: '是否显示进度条', key: 'showNprogress' }, { comment: '是否显示进度条', key: 'showNprogress' },
{ comment: '是否显示设置引导', key: 'showSettingGuide' }, { comment: '是否显示设置引导', key: 'showSettingGuide' },
{ comment: '是否显示节日文本', key: 'showFestivalText' }, { comment: '是否显示节日文本', key: 'showFestivalText' },
{ comment: '是否显示水印', key: 'watermarkVisible' }, { comment: '是否显示水印', key: 'watermarkVisible' },
{ comment: '是否自动关闭', key: 'autoClose' }, { comment: '是否自动关闭', key: 'autoClose' },
{ comment: '是否唯一展开', key: 'uniqueOpened' }, { comment: '是否唯一展开', key: 'uniqueOpened' },
{ comment: '是否色弱模式', key: 'colorWeak' }, { comment: '是否色弱模式', key: 'colorWeak' },
{ comment: '是否刷新', key: 'refresh' }, { comment: '是否刷新', key: 'refresh' },
{ comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' }, { comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' },
{ comment: '边框模式', key: 'boxBorderMode' }, { comment: '边框模式', key: 'boxBorderMode' },
{ comment: '页面过渡效果', key: 'pageTransition' }, { comment: '页面过渡效果', key: 'pageTransition' },
{ comment: '标签页样式', key: 'tabStyle' }, { comment: '标签页样式', key: 'tabStyle' },
{ comment: '自定义圆角', key: 'customRadius' }, { comment: '自定义圆角', key: 'customRadius' },
{ comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth }, { comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth },
{ comment: '节日日期', key: 'festivalDate', forceValue: '' } { comment: '节日日期', key: 'festivalDate', forceValue: '' }
] ]
/** /**
* 将值转换为代码字符串 * 将值转换为代码字符串
*/ */
const valueToCode = (value: any, enumMap?: Record<string, string>): string => { const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
if (value === null) return 'null' if (value === null) return 'null'
if (value === undefined) return 'undefined' if (value === undefined) return 'undefined'
// 优先查找枚举映射 // 优先查找枚举映射
if (enumMap && typeof value === 'string' && enumMap[value]) { if (enumMap && typeof value === 'string' && enumMap[value]) {
return enumMap[value] return enumMap[value]
} }
// 其他类型处理 // 其他类型处理
if (typeof value === 'string') return `'${value}'` if (typeof value === 'string') return `'${value}'`
if (typeof value === 'boolean' || typeof value === 'number') return String(value) if (typeof value === 'boolean' || typeof value === 'number') return String(value)
return JSON.stringify(value) return JSON.stringify(value)
} }
/** /**
* 生成配置代码 * 生成配置代码
*/ */
const generateConfigCode = (): string => { const generateConfigCode = (): string => {
const lines = ['export const SETTING_DEFAULT_CONFIG = {'] const lines = ['export const SETTING_DEFAULT_CONFIG = {']
CONFIG_ITEMS.forEach((item) => { CONFIG_ITEMS.forEach((item) => {
lines.push(` /** ${item.comment} */`) lines.push(` /** ${item.comment} */`)
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key] const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key]
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`) lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`)
}) })
lines.push('}') lines.push('}')
return lines.join('\n') return lines.join('\n')
} }
/** /**
* 复制配置到剪贴板 * 复制配置到剪贴板
*/ */
const handleCopyConfig = async () => { const handleCopyConfig = async () => {
try { try {
const configText = generateConfigCode() const configText = generateConfigCode()
await copy(configText) await copy(configText)
if (copied.value) { if (copied.value) {
ElMessage.success({ ElMessage.success({
message: t('setting.actions.copySuccess'), message: t('setting.actions.copySuccess'),
duration: 3000 duration: 3000
}) })
} }
} catch (error) { } catch (error) {
console.error('复制配置失败:', error) console.error('复制配置失败:', error)
ElMessage.error(t('setting.actions.copyFailed')) ElMessage.error(t('setting.actions.copyFailed'))
} }
} }
/** /**
* 切换布尔值配置(如果当前值与默认值不同) * 切换布尔值配置(如果当前值与默认值不同)
*/ */
const toggleIfDifferent = ( const toggleIfDifferent = (
currentValue: boolean, currentValue: boolean,
defaultValue: boolean, defaultValue: boolean,
toggleFn: () => void toggleFn: () => void
) => { ) => {
if (currentValue !== defaultValue) { if (currentValue !== defaultValue) {
toggleFn() toggleFn()
} }
} }
/** /**
* 重置配置为默认值 * 重置配置为默认值
*/ */
const handleResetConfig = async () => { const handleResetConfig = async () => {
try { try {
const config = SETTING_DEFAULT_CONFIG const config = SETTING_DEFAULT_CONFIG
// 菜单相关 // 菜单相关
settingStore.switchMenuLayouts(config.menuType) settingStore.switchMenuLayouts(config.menuType)
settingStore.setMenuOpenWidth(config.menuOpenWidth) settingStore.setMenuOpenWidth(config.menuOpenWidth)
settingStore.setMenuOpen(config.menuOpen) settingStore.setMenuOpen(config.menuOpen)
settingStore.setDualMenuShowText(config.dualMenuShowText) settingStore.setDualMenuShowText(config.dualMenuShowText)
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式 // 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
switchThemeStyles(config.systemThemeMode) switchThemeStyles(config.systemThemeMode)
// 等待主题切换完成后,根据实际应用的主题设置菜单主题 // 等待主题切换完成后,根据实际应用的主题设置菜单主题
await nextTick() await nextTick()
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType
settingStore.switchMenuStyles(menuTheme) settingStore.switchMenuStyles(menuTheme)
settingStore.setElementTheme(config.systemThemeColor) settingStore.setElementTheme(config.systemThemeColor)
// 界面显示(切换类方法) // 界面显示(切换类方法)
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () => toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
settingStore.setButton() settingStore.setButton()
) )
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () => toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
settingStore.setFastEnter() settingStore.setFastEnter()
) )
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () => toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
settingStore.setShowRefreshButton() settingStore.setShowRefreshButton()
) )
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () => settingStore.setCrumbs()) toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () =>
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () => settingStore.setCrumbs()
settingStore.setLanguage() )
) toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () => settingStore.setLanguage()
settingStore.setNprogress() )
) toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
settingStore.setNprogress()
)
// 界面显示(直接设置类方法) // 界面显示(直接设置类方法)
settingStore.setWorkTab(config.showWorkTab) settingStore.setWorkTab(config.showWorkTab)
settingStore.setShowFestivalText(config.showFestivalText) settingStore.setShowFestivalText(config.showFestivalText)
settingStore.setWatermarkVisible(config.watermarkVisible) settingStore.setWatermarkVisible(config.watermarkVisible)
// 功能设置 // 功能设置
toggleIfDifferent(settingStore.autoClose, config.autoClose, () => settingStore.setAutoClose()) toggleIfDifferent(settingStore.autoClose, config.autoClose, () =>
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () => settingStore.setAutoClose()
settingStore.setUniqueOpened() )
) toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () => settingStore.setColorWeak()) settingStore.setUniqueOpened()
)
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () =>
settingStore.setColorWeak()
)
// 样式设置 // 样式设置
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () => toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
settingStore.setBorderMode() settingStore.setBorderMode()
) )
settingStore.setPageTransition(config.pageTransition) settingStore.setPageTransition(config.pageTransition)
settingStore.setTabStyle(config.tabStyle) settingStore.setTabStyle(config.tabStyle)
settingStore.setCustomRadius(config.customRadius) settingStore.setCustomRadius(config.customRadius)
settingStore.setContainerWidth(config.containerWidth) settingStore.setContainerWidth(config.containerWidth)
// 节日相关 // 节日相关
settingStore.setFestivalDate(config.festivalDate) settingStore.setFestivalDate(config.festivalDate)
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded) settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded)
location.reload() location.reload()
} catch (error) { } catch (error) {
console.error('重置配置失败:', error) console.error('重置配置失败:', error)
ElMessage.error(t('setting.actions.resetFailed')) ElMessage.error(t('setting.actions.resetFailed'))
} }
} }
</script> </script>
@@ -1,51 +1,51 @@
<template> <template>
<div class="setting-drawer"> <div class="setting-drawer">
<ElDrawer <ElDrawer
size="300px" size="300px"
v-model="visible" v-model="visible"
:lock-scroll="true" :lock-scroll="true"
:with-header="false" :with-header="false"
:before-close="handleClose" :before-close="handleClose"
:destroy-on-close="false" :destroy-on-close="false"
modal-class="setting-modal" modal-class="setting-modal"
@open="handleOpen" @open="handleOpen"
@close="handleDrawerClose" @close="handleDrawerClose"
> >
<div class="drawer-con"> <div class="drawer-con">
<slot /> <slot />
</div> </div>
</ElDrawer> </ElDrawer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface Props { interface Props {
modelValue: boolean modelValue: boolean
} }
interface Emits { interface Emits {
(e: 'update:modelValue', value: boolean): void (e: 'update:modelValue', value: boolean): void
(e: 'open'): void (e: 'open'): void
(e: 'close'): void (e: 'close'): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
const visible = computed({ const visible = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value) set: (value: boolean) => emit('update:modelValue', value)
}) })
const handleOpen = () => { const handleOpen = () => {
emit('open') emit('open')
} }
const handleDrawerClose = () => { const handleDrawerClose = () => {
emit('close') emit('close')
} }
const handleClose = () => { const handleClose = () => {
visible.value = false visible.value = false
} }
</script> </script>
@@ -1,18 +1,18 @@
<template> <template>
<div> <div>
<div class="flex justify-end"> <div class="flex justify-end">
<div <div
@click="$emit('close')" @click="$emit('close')"
class="flex-cc c-p size-7.5 !transition-all duration-200 rounded hover:bg-g-300/80" class="flex-cc c-p size-7.5 !transition-all duration-200 rounded hover:bg-g-300/80"
> >
<ArtSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" /> <ArtSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" />
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineEmits<{ defineEmits<{
close: [] close: []
}>() }>()
</script> </script>
@@ -1,101 +1,105 @@
<template> <template>
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }"> <div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
<span class="text-sm">{{ config.label }}</span> <span class="text-sm">{{ config.label }}</span>
<!-- 开关类型 --> <!-- 开关类型 -->
<ElSwitch v-if="config.type === 'switch'" :model-value="modelValue" @change="handleChange" /> <ElSwitch
v-if="config.type === 'switch'"
:model-value="modelValue"
@change="handleChange"
/>
<!-- 数字输入类型 --> <!-- 数字输入类型 -->
<ElInputNumber <ElInputNumber
v-else-if="config.type === 'input-number'" v-else-if="config.type === 'input-number'"
:model-value="modelValue" :model-value="modelValue"
:min="config.min" :min="config.min"
:max="config.max" :max="config.max"
:step="config.step" :step="config.step"
:style="config.style" :style="config.style"
:controls-position="config.controlsPosition" :controls-position="config.controlsPosition"
@change="handleChange" @change="handleChange"
/> />
<!-- 选择器类型 --> <!-- 选择器类型 -->
<ElSelect <ElSelect
v-else-if="config.type === 'select'" v-else-if="config.type === 'select'"
:model-value="modelValue" :model-value="modelValue"
:style="config.style" :style="config.style"
@change="handleChange" @change="handleChange"
> >
<ElOption <ElOption
v-for="option in normalizedOptions" v-for="option in normalizedOptions"
:key="option.value" :key="option.value"
:label="option.label" :label="option.label"
:value="option.value" :value="option.value"
/> />
</ElSelect> </ElSelect>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { ComputedRef } from 'vue' import type { ComputedRef } from 'vue'
interface SettingItemConfig { interface SettingItemConfig {
key: string key: string
label: string label: string
type: 'switch' | 'input-number' | 'select' type: 'switch' | 'input-number' | 'select'
handler: string handler: string
mobileHide?: boolean mobileHide?: boolean
min?: number min?: number
max?: number max?: number
step?: number step?: number
style?: Record<string, string> style?: Record<string, string>
controlsPosition?: '' | 'right' controlsPosition?: '' | 'right'
options?: options?:
| Array<{ value: any; label: string }> | Array<{ value: any; label: string }>
| ComputedRef<Array<{ value: any; label: string }>> | ComputedRef<Array<{ value: any; label: string }>>
} }
interface Props { interface Props {
config: SettingItemConfig config: SettingItemConfig
modelValue: any modelValue: any
} }
interface Emits { interface Emits {
(e: 'change', value: any): void (e: 'change', value: any): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
const emit = defineEmits<Emits>() const emit = defineEmits<Emits>()
// 标准化选项,处理computed和普通数组 // 标准化选项,处理computed和普通数组
const normalizedOptions = computed(() => { const normalizedOptions = computed(() => {
if (!props.config.options) return [] if (!props.config.options) return []
try { try {
// 如果是 ComputedRef,则返回其值 // 如果是 ComputedRef,则返回其值
if (typeof props.config.options === 'object' && 'value' in props.config.options) { if (typeof props.config.options === 'object' && 'value' in props.config.options) {
return props.config.options.value || [] return props.config.options.value || []
} }
// 如果是普通数组,直接返回 // 如果是普通数组,直接返回
return Array.isArray(props.config.options) ? props.config.options : [] return Array.isArray(props.config.options) ? props.config.options : []
} catch (error) { } catch (error) {
console.warn('Error processing options for config:', props.config.key, error) console.warn('Error processing options for config:', props.config.key, error)
return [] return []
} }
}) })
const handleChange = (value: any) => { const handleChange = (value: any) => {
try { try {
emit('change', value) emit('change', value)
} catch (error) { } catch (error) {
console.error('Error handling change for config:', props.config.key, error) console.error('Error handling change for config:', props.config.key, error)
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@media screen and (width <= 768px) { @media screen and (width <= 768px) {
.mobile-hide { .mobile-hide {
display: none !important; display: none !important;
} }
} }
</style> </style>
@@ -1,28 +1,28 @@
<template> <template>
<SectionTitle :title="$t('setting.theme.title')" /> <SectionTitle :title="$t('setting.theme.title')" />
<div class="setting-box-wrap"> <div class="setting-box-wrap">
<div <div
class="setting-item" class="setting-item"
v-for="(item, index) in configOptions.themeList" v-for="(item, index) in configOptions.themeList"
:key="item.theme" :key="item.theme"
@click="switchThemeStyles(item.theme)" @click="switchThemeStyles(item.theme)"
> >
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }"> <div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
<img :src="item.img" /> <img :src="item.img" />
</div> </div>
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p> <p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import SectionTitle from './SectionTitle.vue' import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig' import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useTheme } from '@/hooks/core/useTheme' import { useTheme } from '@/hooks/core/useTheme'
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { systemThemeMode } = storeToRefs(settingStore) const { systemThemeMode } = storeToRefs(settingStore)
const { configOptions } = useSettingsConfig() const { configOptions } = useSettingsConfig()
const { switchThemeStyles } = useTheme() const { switchThemeStyles } = useTheme()
</script> </script>
File diff suppressed because it is too large Load Diff
+296 -296
View File
@@ -1,350 +1,350 @@
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master --> <!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
<template> <template>
<div class="cutter-container"> <div class="cutter-container">
<div class="cutter-component"> <div class="cutter-component">
<div class="title">{{ title }}</div> <div class="title">{{ title }}</div>
<ImgCutter <ImgCutter
ref="imgCutterModal" ref="imgCutterModal"
@cutDown="cutDownImg" @cutDown="cutDownImg"
@onPrintImg="cutterPrintImg" @onPrintImg="cutterPrintImg"
@onImageLoadComplete="handleImageLoadComplete" @onImageLoadComplete="handleImageLoadComplete"
@onImageLoadError="handleImageLoadError" @onImageLoadError="handleImageLoadError"
@onClearAll="handleClearAll" @onClearAll="handleClearAll"
v-bind="cutterProps" v-bind="cutterProps"
class="img-cutter" class="img-cutter"
> >
<template #choose> <template #choose>
<ElButton type="primary" plain v-ripple>选择图片</ElButton> <ElButton type="primary" plain v-ripple>选择图片</ElButton>
</template> </template>
<template #cancel> <template #cancel>
<ElButton type="danger" plain v-ripple>清除</ElButton> <ElButton type="danger" plain v-ripple>清除</ElButton>
</template> </template>
<template #confirm> <template #confirm>
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> --> <!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
<div></div> <div></div>
</template> </template>
</ImgCutter> </ImgCutter>
</div> </div>
<div v-if="showPreview" class="preview-container"> <div v-if="showPreview" class="preview-container">
<div class="title">{{ previewTitle }}</div> <div class="title">{{ previewTitle }}</div>
<div <div
class="preview-box" class="preview-box"
:style="{ :style="{
width: `${cutterProps.cutWidth}px`, width: `${cutterProps.cutWidth}px`,
height: `${cutterProps.cutHeight}px` height: `${cutterProps.cutHeight}px`
}" }"
> >
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" /> <img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
</div> </div>
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple <ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
>下载图片</ElButton >下载图片</ElButton
> >
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ImgCutter from 'vue-img-cutter' import ImgCutter from 'vue-img-cutter'
defineOptions({ name: 'ArtCutterImg' }) defineOptions({ name: 'ArtCutterImg' })
interface CutterProps { interface CutterProps {
// 基础配置 // 基础配置
/** 是否模态框 */ /** 是否模态框 */
isModal?: boolean isModal?: boolean
/** 是否显示工具栏 */ /** 是否显示工具栏 */
tool?: boolean tool?: boolean
/** 工具栏背景色 */ /** 工具栏背景色 */
toolBgc?: string toolBgc?: string
/** 标题 */ /** 标题 */
title?: string title?: string
/** 预览标题 */ /** 预览标题 */
previewTitle?: string previewTitle?: string
/** 是否显示预览 */ /** 是否显示预览 */
showPreview?: boolean showPreview?: boolean
// 尺寸相关 // 尺寸相关
/** 容器宽度 */ /** 容器宽度 */
boxWidth?: number boxWidth?: number
/** 容器高度 */ /** 容器高度 */
boxHeight?: number boxHeight?: number
/** 裁剪宽度 */ /** 裁剪宽度 */
cutWidth?: number cutWidth?: number
/** 裁剪高度 */ /** 裁剪高度 */
cutHeight?: number cutHeight?: number
/** 是否允许大小调整 */ /** 是否允许大小调整 */
sizeChange?: boolean sizeChange?: boolean
// 移动和缩放 // 移动和缩放
/** 是否允许移动 */ /** 是否允许移动 */
moveAble?: boolean moveAble?: boolean
/** 是否允许图片移动 */ /** 是否允许图片移动 */
imgMove?: boolean imgMove?: boolean
/** 是否允许缩放 */ /** 是否允许缩放 */
scaleAble?: boolean scaleAble?: boolean
// 图片相关 // 图片相关
/** 是否显示原始图片 */ /** 是否显示原始图片 */
originalGraph?: boolean originalGraph?: boolean
/** 是否允许跨域 */ /** 是否允许跨域 */
crossOrigin?: boolean crossOrigin?: boolean
/** 文件类型 */ /** 文件类型 */
fileType?: 'png' | 'jpeg' | 'webp' fileType?: 'png' | 'jpeg' | 'webp'
/** 质量 */ /** 质量 */
quality?: number quality?: number
// 水印 // 水印
/** 水印文本 */ /** 水印文本 */
watermarkText?: string watermarkText?: string
/** 水印字体大小 */ /** 水印字体大小 */
watermarkFontSize?: number watermarkFontSize?: number
/** 水印颜色 */ /** 水印颜色 */
watermarkColor?: string watermarkColor?: string
// 其他功能 // 其他功能
/** 是否保存裁剪位置 */ /** 是否保存裁剪位置 */
saveCutPosition?: boolean saveCutPosition?: boolean
/** 是否预览模式 */ /** 是否预览模式 */
previewMode?: boolean previewMode?: boolean
// 输入图片 // 输入图片
imgUrl?: string imgUrl?: string
} }
interface CutterResult { interface CutterResult {
fileName: string fileName: string
file: File file: File
blob: Blob blob: Blob
dataURL: string dataURL: string
} }
const props = withDefaults(defineProps<CutterProps>(), { const props = withDefaults(defineProps<CutterProps>(), {
// 基础配置默认值 // 基础配置默认值
isModal: false, isModal: false,
tool: true, tool: true,
toolBgc: '#fff', toolBgc: '#fff',
title: '', title: '',
previewTitle: '', previewTitle: '',
showPreview: true, showPreview: true,
// 尺寸相关默认值 // 尺寸相关默认值
boxWidth: 700, boxWidth: 700,
boxHeight: 458, boxHeight: 458,
cutWidth: 470, cutWidth: 470,
cutHeight: 270, cutHeight: 270,
sizeChange: true, sizeChange: true,
// 移动和缩放默认值 // 移动和缩放默认值
moveAble: true, moveAble: true,
imgMove: true, imgMove: true,
scaleAble: true, scaleAble: true,
// 图片相关默认值 // 图片相关默认值
originalGraph: true, originalGraph: true,
crossOrigin: true, crossOrigin: true,
fileType: 'png', fileType: 'png',
quality: 0.9, quality: 0.9,
// 水印默认值 // 水印默认值
watermarkText: '', watermarkText: '',
watermarkFontSize: 20, watermarkFontSize: 20,
watermarkColor: '#ffffff', watermarkColor: '#ffffff',
// 其他功能默认值 // 其他功能默认值
saveCutPosition: true, saveCutPosition: true,
previewMode: true previewMode: true
}) })
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError']) const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
const temImgPath = ref('') const temImgPath = ref('')
const imgCutterModal = ref() const imgCutterModal = ref()
// 计算属性:整合所有ImgCutter的props // 计算属性:整合所有ImgCutter的props
const cutterProps = computed(() => ({ const cutterProps = computed(() => ({
...props, ...props,
WatermarkText: props.watermarkText, WatermarkText: props.watermarkText,
WatermarkFontSize: props.watermarkFontSize, WatermarkFontSize: props.watermarkFontSize,
WatermarkColor: props.watermarkColor WatermarkColor: props.watermarkColor
})) }))
// 图片预加载 // 图片预加载
function preloadImage(url: string): Promise<void> { function preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image()
img.crossOrigin = 'anonymous' img.crossOrigin = 'anonymous'
img.onload = () => resolve() img.onload = () => resolve()
img.onerror = reject img.onerror = reject
img.src = url img.src = url
}) })
} }
// 初始化裁剪器 // 初始化裁剪器
async function initImgCutter() { async function initImgCutter() {
if (props.imgUrl) { if (props.imgUrl) {
try { try {
await preloadImage(props.imgUrl) await preloadImage(props.imgUrl)
imgCutterModal.value?.handleOpen({ imgCutterModal.value?.handleOpen({
name: '封面图片', name: '封面图片',
src: props.imgUrl src: props.imgUrl
}) })
} catch (error) { } catch (error) {
emit('error', error) emit('error', error)
console.error('图片加载失败:', error) console.error('图片加载失败:', error)
} }
} }
} }
// 生命周期钩子 // 生命周期钩子
onMounted(() => { onMounted(() => {
if (props.imgUrl) { if (props.imgUrl) {
temImgPath.value = props.imgUrl temImgPath.value = props.imgUrl
initImgCutter() initImgCutter()
} }
}) })
// 监听图片URL变化 // 监听图片URL变化
watch( watch(
() => props.imgUrl, () => props.imgUrl,
(newVal) => { (newVal) => {
if (newVal) { if (newVal) {
temImgPath.value = newVal temImgPath.value = newVal
initImgCutter() initImgCutter()
} }
} }
) )
// 实时预览 // 实时预览
function cutterPrintImg(result: { dataURL: string }) { function cutterPrintImg(result: { dataURL: string }) {
temImgPath.value = result.dataURL temImgPath.value = result.dataURL
} }
// 裁剪完成 // 裁剪完成
function cutDownImg(result: CutterResult) { function cutDownImg(result: CutterResult) {
emit('update:imgUrl', result.dataURL) emit('update:imgUrl', result.dataURL)
} }
// 图片加载完成 // 图片加载完成
function handleImageLoadComplete(result: any) { function handleImageLoadComplete(result: any) {
emit('imageLoadComplete', result) emit('imageLoadComplete', result)
} }
// 图片加载失败 // 图片加载失败
function handleImageLoadError(error: any) { function handleImageLoadError(error: any) {
emit('error', error) emit('error', error)
emit('imageLoadError', error) emit('imageLoadError', error)
} }
// 清除所有 // 清除所有
function handleClearAll() { function handleClearAll() {
temImgPath.value = '' temImgPath.value = ''
} }
// 下载图片 // 下载图片
function downloadImg() { function downloadImg() {
console.log('下载图片') console.log('下载图片')
const a = document.createElement('a') const a = document.createElement('a')
a.href = temImgPath.value a.href = temImgPath.value
a.download = 'image.png' a.download = 'image.png'
a.click() a.click()
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.cutter-container { .cutter-container {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
.title { .title {
padding-bottom: 10px; padding-bottom: 10px;
font-size: 18px; font-size: 18px;
font-weight: 500; font-weight: 500;
} }
.cutter-component { .cutter-component {
margin-right: 30px; margin-right: 30px;
} }
.preview-container { .preview-container {
.preview-box { .preview-box {
background-color: var(--art-active-color) !important; background-color: var(--art-active-color) !important;
.preview-img { .preview-img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
} }
} }
.download-btn { .download-btn {
display: block; display: block;
margin: 20px auto; margin: 20px auto;
} }
} }
:deep(.toolBoxControl) { :deep(.toolBoxControl) {
z-index: 100; z-index: 100;
} }
:deep(.dockMain) { :deep(.dockMain) {
right: 0; right: 0;
bottom: -40px; bottom: -40px;
left: 0; left: 0;
z-index: 10; z-index: 10;
padding: 0; padding: 0;
background-color: transparent !important; background-color: transparent !important;
opacity: 1; opacity: 1;
} }
:deep(.copyright) { :deep(.copyright) {
display: none !important; display: none !important;
} }
:deep(.i-dialog-footer) { :deep(.i-dialog-footer) {
margin-top: 60px !important; margin-top: 60px !important;
} }
:deep(.dockBtn) { :deep(.dockBtn) {
height: 26px; height: 26px;
padding: 0 10px; padding: 0 10px;
font-size: 12px; font-size: 12px;
line-height: 26px; line-height: 26px;
color: var(--el-color-primary) !important; color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important; background-color: var(--el-color-primary-light-9) !important;
border: 1px solid var(--el-color-primary-light-4) !important; border: 1px solid var(--el-color-primary-light-4) !important;
} }
:deep(.dockBtnScrollBar) { :deep(.dockBtnScrollBar) {
margin: 0 10px 0 6px; margin: 0 10px 0 6px;
background-color: var(--el-color-primary-light-1); background-color: var(--el-color-primary-light-1);
} }
:deep(.scrollBarControl) { :deep(.scrollBarControl) {
border-color: var(--el-color-primary); border-color: var(--el-color-primary);
} }
:deep(.closeIcon) { :deep(.closeIcon) {
line-height: 15px !important; line-height: 15px !important;
} }
} }
.dark { .dark {
.cutter-container { .cutter-container {
:deep(.toolBox) { :deep(.toolBox) {
border: transparent; border: transparent;
} }
:deep(.dialogMain) { :deep(.dialogMain) {
background-color: transparent !important; background-color: transparent !important;
} }
:deep(.i-dialog-footer) { :deep(.i-dialog-footer) {
.btn { .btn {
background-color: var(--el-color-primary) !important; background-color: var(--el-color-primary) !important;
border: transparent; border: transparent;
} }
} }
} }
} }
</style> </style>
@@ -1,111 +1,111 @@
<!-- 视频播放器组件https://h5player.bytedance.com/--> <!-- 视频播放器组件https://h5player.bytedance.com/-->
<template> <template>
<div :id="playerId" /> <div :id="playerId" />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Player from 'xgplayer' import Player from 'xgplayer'
import 'xgplayer/dist/index.min.css' import 'xgplayer/dist/index.min.css'
defineOptions({ name: 'ArtVideoPlayer' }) defineOptions({ name: 'ArtVideoPlayer' })
interface Props { interface Props {
/** 播放器容器 ID */ /** 播放器容器 ID */
playerId: string playerId: string
/** 视频源URL */ /** 视频源URL */
videoUrl: string videoUrl: string
/** 视频封面图URL */ /** 视频封面图URL */
posterUrl: string posterUrl: string
/** 是否自动播放 */ /** 是否自动播放 */
autoplay?: boolean autoplay?: boolean
/** 音量大小(0-1) */ /** 音量大小(0-1) */
volume?: number volume?: number
/** 可选的播放速率 */ /** 可选的播放速率 */
playbackRates?: number[] playbackRates?: number[]
/** 是否循环播放 */ /** 是否循环播放 */
loop?: boolean loop?: boolean
/** 是否静音 */ /** 是否静音 */
muted?: boolean muted?: boolean
commonStyle?: VideoPlayerStyle commonStyle?: VideoPlayerStyle
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
playerId: '', playerId: '',
videoUrl: '', videoUrl: '',
posterUrl: '', posterUrl: '',
autoplay: false, autoplay: false,
volume: 1, volume: 1,
loop: false, loop: false,
muted: false muted: false
}) })
// 设置属性默认值 // 设置属性默认值
// 播放器实例引用 // 播放器实例引用
const playerInstance = ref<Player | null>(null) const playerInstance = ref<Player | null>(null)
// 播放器样式接口定义 // 播放器样式接口定义
interface VideoPlayerStyle { interface VideoPlayerStyle {
progressColor?: string // 进度条背景色 progressColor?: string // 进度条背景色
playedColor?: string // 已播放部分颜色 playedColor?: string // 已播放部分颜色
cachedColor?: string // 缓存部分颜色 cachedColor?: string // 缓存部分颜色
sliderBtnStyle?: Record<string, string> // 滑块按钮样式 sliderBtnStyle?: Record<string, string> // 滑块按钮样式
volumeColor?: string // 音量控制器颜色 volumeColor?: string // 音量控制器颜色
} }
// 默认样式配置 // 默认样式配置
const defaultStyle: VideoPlayerStyle = { const defaultStyle: VideoPlayerStyle = {
progressColor: 'rgba(255, 255, 255, 0.3)', progressColor: 'rgba(255, 255, 255, 0.3)',
playedColor: '#00AEED', playedColor: '#00AEED',
cachedColor: 'rgba(255, 255, 255, 0.6)', cachedColor: 'rgba(255, 255, 255, 0.6)',
sliderBtnStyle: { sliderBtnStyle: {
width: '10px', width: '10px',
height: '10px', height: '10px',
backgroundColor: '#00AEED' backgroundColor: '#00AEED'
}, },
volumeColor: '#00AEED' volumeColor: '#00AEED'
} }
// 组件挂载时初始化播放器 // 组件挂载时初始化播放器
onMounted(() => { onMounted(() => {
playerInstance.value = new Player({ playerInstance.value = new Player({
id: props.playerId, id: props.playerId,
lang: 'zh', // 设置界面语言为中文 lang: 'zh', // 设置界面语言为中文
volume: props.volume, volume: props.volume,
autoplay: props.autoplay, autoplay: props.autoplay,
screenShot: true, // 启用截图功能 screenShot: true, // 启用截图功能
url: props.videoUrl, url: props.videoUrl,
poster: props.posterUrl, poster: props.posterUrl,
fluid: true, // 启用流式布局,自适应容器大小 fluid: true, // 启用流式布局,自适应容器大小
playbackRate: props.playbackRates, playbackRate: props.playbackRates,
loop: props.loop, loop: props.loop,
muted: props.muted, muted: props.muted,
commonStyle: { commonStyle: {
...defaultStyle, ...defaultStyle,
...props.commonStyle ...props.commonStyle
} }
}) })
// 播放事件监听器 // 播放事件监听器
playerInstance.value.on('play', () => { playerInstance.value.on('play', () => {
console.log('Video is playing') console.log('Video is playing')
}) })
// 暂停事件监听器 // 暂停事件监听器
playerInstance.value.on('pause', () => { playerInstance.value.on('pause', () => {
console.log('Video is paused') console.log('Video is paused')
}) })
// 错误事件监听器 // 错误事件监听器
playerInstance.value.on('error', (error) => { playerInstance.value.on('error', (error) => {
console.error('Error occurred:', error) console.error('Error occurred:', error)
}) })
}) })
// 组件卸载前清理播放器实例 // 组件卸载前清理播放器实例
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (playerInstance.value) { if (playerInstance.value) {
playerInstance.value.destroy() playerInstance.value.destroy()
} }
}) })
</script> </script>
@@ -1,415 +1,418 @@
<!-- 右键菜单 --> <!-- 右键菜单 -->
<template> <template>
<div class="menu-right"> <div class="menu-right">
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave"> <Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
<div <div
v-show="visible" v-show="visible"
:style="menuStyle" :style="menuStyle"
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]" class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
> >
<ul class="menu-list m-0 list-none" :style="menuListStyle"> <ul class="menu-list m-0 list-none" :style="menuListStyle">
<template v-for="item in menuItems" :key="item.key"> <template v-for="item in menuItems" :key="item.key">
<!-- 普通菜单项 --> <!-- 普通菜单项 -->
<li <li
v-if="!item.children" v-if="!item.children"
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200" class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }" :class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
:style="menuItemStyle" :style="menuItemStyle"
@click="handleMenuClick(item)" @click="handleMenuClick(item)"
> >
<ArtSvgIcon <ArtSvgIcon
v-if="item.icon" v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800" class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon" :icon="item.icon"
/> />
<span <span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800" class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ item.label }}</span >{{ item.label }}</span
> >
</li> </li>
<!-- 子菜单 --> <!-- 子菜单 -->
<li <li
v-else v-else
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200" class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:style="menuItemStyle" :style="menuItemStyle"
> >
<div class="submenu-title flex-c w-full"> <div class="submenu-title flex-c w-full">
<ArtSvgIcon <ArtSvgIcon
v-if="item.icon" v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800" class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon" :icon="item.icon"
/> />
<span <span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800" class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ item.label }}</span >{{ item.label }}</span
> >
<ArtSvgIcon <ArtSvgIcon
icon="ri:arrow-right-s-line" icon="ri:arrow-right-s-line"
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150" class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
/> />
</div> </div>
<ul <ul
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl" class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
:style="submenuListStyle" :style="submenuListStyle"
> >
<li <li
v-for="child in item.children" v-for="child in item.children"
:key="child.key" :key="child.key"
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200" class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': child.disabled, 'has-line': child.showLine }" :class="{
:style="menuItemStyle" 'is-disabled': child.disabled,
@click="handleMenuClick(child)" 'has-line': child.showLine
> }"
<ArtSvgIcon :style="menuItemStyle"
v-if="child.icon" @click="handleMenuClick(child)"
class="r-2 shrink-0 text-base text-g-800 mr-1" >
:icon="child.icon" <ArtSvgIcon
/> v-if="child.icon"
<span class="r-2 shrink-0 text-base text-g-800 mr-1"
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800" :icon="child.icon"
>{{ child.label }}</span />
> <span
</li> class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
</ul> >{{ child.label }}</span
</li> >
</template> </li>
</ul> </ul>
</div> </li>
</Transition> </template>
</div> </ul>
</div>
</Transition>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { CSSProperties } from 'vue' import type { CSSProperties } from 'vue'
defineOptions({ name: 'ArtMenuRight' }) defineOptions({ name: 'ArtMenuRight' })
export interface MenuItemType { export interface MenuItemType {
/** 菜单项唯一标识 */ /** 菜单项唯一标识 */
key: string key: string
/** 菜单项标签 */ /** 菜单项标签 */
label: string label: string
/** 菜单项图标 */ /** 菜单项图标 */
icon?: string icon?: string
/** 菜单项是否禁用 */ /** 菜单项是否禁用 */
disabled?: boolean disabled?: boolean
/** 菜单项是否显示分割线 */ /** 菜单项是否显示分割线 */
showLine?: boolean showLine?: boolean
/** 子菜单 */ /** 子菜单 */
children?: MenuItemType[] children?: MenuItemType[]
[key: string]: any [key: string]: any
} }
interface Props { interface Props {
menuItems: MenuItemType[] menuItems: MenuItemType[]
/** 菜单宽度 */ /** 菜单宽度 */
menuWidth?: number menuWidth?: number
/** 子菜单宽度 */ /** 子菜单宽度 */
submenuWidth?: number submenuWidth?: number
/** 菜单项高度 */ /** 菜单项高度 */
itemHeight?: number itemHeight?: number
/** 边界距离 */ /** 边界距离 */
boundaryDistance?: number boundaryDistance?: number
/** 菜单内边距 */ /** 菜单内边距 */
menuPadding?: number menuPadding?: number
/** 菜单项水平内边距 */ /** 菜单项水平内边距 */
itemPaddingX?: number itemPaddingX?: number
/** 菜单圆角 */ /** 菜单圆角 */
borderRadius?: number borderRadius?: number
/** 动画持续时间 */ /** 动画持续时间 */
animationDuration?: number animationDuration?: number
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
menuWidth: 120, menuWidth: 120,
submenuWidth: 150, submenuWidth: 150,
itemHeight: 32, itemHeight: 32,
boundaryDistance: 10, boundaryDistance: 10,
menuPadding: 5, menuPadding: 5,
itemPaddingX: 6, itemPaddingX: 6,
borderRadius: 6, borderRadius: 6,
animationDuration: 100 animationDuration: 100
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'select', item: MenuItemType): void (e: 'select', item: MenuItemType): void
(e: 'show'): void (e: 'show'): void
(e: 'hide'): void (e: 'hide'): void
}>() }>()
const visible = ref(false) const visible = ref(false)
const position = ref({ x: 0, y: 0 }) const position = ref({ x: 0, y: 0 })
// 用于清理定时器和事件监听器 // 用于清理定时器和事件监听器
let showTimer: number | null = null let showTimer: number | null = null
let eventListenersAdded = false let eventListenersAdded = false
// 计算菜单样式 // 计算菜单样式
const menuStyle = computed( const menuStyle = computed(
(): CSSProperties => ({ (): CSSProperties => ({
position: 'fixed' as const, position: 'fixed' as const,
left: `${position.value.x}px`, left: `${position.value.x}px`,
top: `${position.value.y}px`, top: `${position.value.y}px`,
zIndex: 2000, zIndex: 2000,
width: `${props.menuWidth}px` width: `${props.menuWidth}px`
}) })
) )
// 计算菜单列表样式 // 计算菜单列表样式
const menuListStyle = computed( const menuListStyle = computed(
(): CSSProperties => ({ (): CSSProperties => ({
padding: `${props.menuPadding}px` padding: `${props.menuPadding}px`
}) })
) )
// 计算菜单项样式 // 计算菜单项样式
const menuItemStyle = computed( const menuItemStyle = computed(
(): CSSProperties => ({ (): CSSProperties => ({
height: `${props.itemHeight}px`, height: `${props.itemHeight}px`,
padding: `0 ${props.itemPaddingX}px`, padding: `0 ${props.itemPaddingX}px`,
borderRadius: '4px' borderRadius: '4px'
}) })
) )
// 计算子菜单列表样式 // 计算子菜单列表样式
const submenuListStyle = computed( const submenuListStyle = computed(
(): CSSProperties => ({ (): CSSProperties => ({
minWidth: `${props.submenuWidth}px`, minWidth: `${props.submenuWidth}px`,
padding: `${props.menuPadding}px 0`, padding: `${props.menuPadding}px 0`,
borderRadius: `${props.borderRadius}px` borderRadius: `${props.borderRadius}px`
}) })
) )
// 计算菜单高度(用于边界检测) // 计算菜单高度(用于边界检测)
const calculateMenuHeight = (): number => { const calculateMenuHeight = (): number => {
let totalHeight = props.menuPadding * 2 // 上下内边距 let totalHeight = props.menuPadding * 2 // 上下内边距
props.menuItems.forEach((item) => { props.menuItems.forEach((item) => {
totalHeight += props.itemHeight totalHeight += props.itemHeight
if (item.showLine) { if (item.showLine) {
totalHeight += 10 // 分割线额外高度 totalHeight += 10 // 分割线额外高度
} }
}) })
return totalHeight return totalHeight
} }
// 优化的位置计算函数 // 优化的位置计算函数
const calculatePosition = (e: MouseEvent) => { const calculatePosition = (e: MouseEvent) => {
const screenWidth = window.innerWidth const screenWidth = window.innerWidth
const screenHeight = window.innerHeight const screenHeight = window.innerHeight
const menuHeight = calculateMenuHeight() const menuHeight = calculateMenuHeight()
let x = e.clientX let x = e.clientX
let y = e.clientY let y = e.clientY
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧 // 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
if (x + props.menuWidth > screenWidth - props.boundaryDistance) { if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
x = Math.max(props.boundaryDistance, x - props.menuWidth) x = Math.max(props.boundaryDistance, x - props.menuWidth)
} }
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整 // 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
if (y + menuHeight > screenHeight - props.boundaryDistance) { if (y + menuHeight > screenHeight - props.boundaryDistance) {
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance) y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
} }
// 确保不会超出边界 // 确保不会超出边界
x = Math.max( x = Math.max(
props.boundaryDistance, props.boundaryDistance,
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance) Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
) )
y = Math.max( y = Math.max(
props.boundaryDistance, props.boundaryDistance,
Math.min(y, screenHeight - menuHeight - props.boundaryDistance) Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
) )
return { x, y } return { x, y }
} }
// 添加事件监听器 // 添加事件监听器
const addEventListeners = () => { const addEventListeners = () => {
if (eventListenersAdded) return if (eventListenersAdded) return
document.addEventListener('click', handleDocumentClick) document.addEventListener('click', handleDocumentClick)
document.addEventListener('contextmenu', handleDocumentContextmenu) document.addEventListener('contextmenu', handleDocumentContextmenu)
document.addEventListener('keydown', handleKeydown) document.addEventListener('keydown', handleKeydown)
eventListenersAdded = true eventListenersAdded = true
} }
// 移除事件监听器 // 移除事件监听器
const removeEventListeners = () => { const removeEventListeners = () => {
if (!eventListenersAdded) return if (!eventListenersAdded) return
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('contextmenu', handleDocumentContextmenu) document.removeEventListener('contextmenu', handleDocumentContextmenu)
document.removeEventListener('keydown', handleKeydown) document.removeEventListener('keydown', handleKeydown)
eventListenersAdded = false eventListenersAdded = false
} }
// 处理文档点击事件 // 处理文档点击事件
const handleDocumentClick = (e: Event) => { const handleDocumentClick = (e: Event) => {
// 检查点击是否在菜单内部 // 检查点击是否在菜单内部
const target = e.target as Element const target = e.target as Element
const menuElement = document.querySelector('.context-menu') const menuElement = document.querySelector('.context-menu')
if (menuElement && menuElement.contains(target)) { if (menuElement && menuElement.contains(target)) {
return return
} }
hide() hide()
} }
// 处理文档右键事件 // 处理文档右键事件
const handleDocumentContextmenu = () => { const handleDocumentContextmenu = () => {
hide() hide()
} }
// 处理键盘事件 // 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => { const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
hide() hide()
} }
} }
const show = (e: MouseEvent) => { const show = (e: MouseEvent) => {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
// 清理之前的定时器 // 清理之前的定时器
if (showTimer) { if (showTimer) {
window.clearTimeout(showTimer) window.clearTimeout(showTimer)
showTimer = null showTimer = null
} }
// 计算位置 // 计算位置
position.value = calculatePosition(e) position.value = calculatePosition(e)
visible.value = true visible.value = true
emit('show') emit('show')
// 延迟添加事件监听器,避免立即触发关闭 // 延迟添加事件监听器,避免立即触发关闭
showTimer = window.setTimeout(() => { showTimer = window.setTimeout(() => {
if (visible.value) { if (visible.value) {
addEventListeners() addEventListeners()
} }
showTimer = null showTimer = null
}, 50) // 减少延迟时间,提升响应性 }, 50) // 减少延迟时间,提升响应性
} }
const hide = () => { const hide = () => {
if (!visible.value) return if (!visible.value) return
visible.value = false visible.value = false
emit('hide') emit('hide')
// 清理定时器 // 清理定时器
if (showTimer) { if (showTimer) {
window.clearTimeout(showTimer) window.clearTimeout(showTimer)
showTimer = null showTimer = null
} }
// 移除事件监听器 // 移除事件监听器
removeEventListeners() removeEventListeners()
} }
const handleMenuClick = (item: MenuItemType) => { const handleMenuClick = (item: MenuItemType) => {
if (item.disabled) return if (item.disabled) return
emit('select', item) emit('select', item)
hide() hide()
} }
// 动画钩子函数 // 动画钩子函数
const onBeforeEnter = (el: Element) => { const onBeforeEnter = (el: Element) => {
const element = el as HTMLElement const element = el as HTMLElement
element.style.transformOrigin = 'top left' element.style.transformOrigin = 'top left'
} }
const onAfterLeave = () => { const onAfterLeave = () => {
// 确保清理所有资源 // 确保清理所有资源
removeEventListeners() removeEventListeners()
if (showTimer) { if (showTimer) {
window.clearTimeout(showTimer) window.clearTimeout(showTimer)
showTimer = null showTimer = null
} }
} }
// 组件卸载时清理资源 // 组件卸载时清理资源
onUnmounted(() => { onUnmounted(() => {
removeEventListeners() removeEventListeners()
if (showTimer) { if (showTimer) {
window.clearTimeout(showTimer) window.clearTimeout(showTimer)
showTimer = null showTimer = null
} }
}) })
// 导出方法供父组件调用 // 导出方法供父组件调用
defineExpose({ defineExpose({
show, show,
hide, hide,
visible: computed(() => visible.value) visible: computed(() => visible.value)
}) })
</script> </script>
<style scoped> <style scoped>
.menu-right { .menu-right {
--menu-width: v-bind('props.menuWidth + "px"'); --menu-width: v-bind('props.menuWidth + "px"');
--border-radius: v-bind('props.borderRadius + "px"'); --border-radius: v-bind('props.borderRadius + "px"');
} }
.menu-item.has-line { .menu-item.has-line {
margin-bottom: 10px; margin-bottom: 10px;
} }
.menu-item.has-line::after { .menu-item.has-line::after {
position: absolute; position: absolute;
right: 0; right: 0;
bottom: -5px; bottom: -5px;
left: 0; left: 0;
height: 1px; height: 1px;
content: ''; content: '';
background-color: var(--art-gray-300); background-color: var(--art-gray-300);
} }
.menu-item.is-disabled { .menu-item.is-disabled {
color: var(--el-text-color-disabled); color: var(--el-text-color-disabled);
cursor: not-allowed; cursor: not-allowed;
} }
.menu-item.is-disabled:hover { .menu-item.is-disabled:hover {
background-color: transparent !important; background-color: transparent !important;
} }
.menu-item.is-disabled i:not(.submenu-arrow), .menu-item.is-disabled i:not(.submenu-arrow),
.menu-item.is-disabled :deep(.art-svg-icon) { .menu-item.is-disabled :deep(.art-svg-icon) {
color: var(--el-text-color-disabled) !important; color: var(--el-text-color-disabled) !important;
} }
.menu-item.is-disabled .menu-label { .menu-item.is-disabled .menu-label {
color: var(--el-text-color-disabled) !important; color: var(--el-text-color-disabled) !important;
} }
.menu-item.submenu:hover .submenu-list { .menu-item.submenu:hover .submenu-list {
display: block; display: block;
} }
.menu-item.submenu:hover .submenu-title .submenu-arrow { .menu-item.submenu:hover .submenu-title .submenu-arrow {
transform: rotate(90deg); transform: rotate(90deg);
} }
/* 动画样式 */ /* 动画样式 */
.context-menu-enter-active, .context-menu-enter-active,
.context-menu-leave-active { .context-menu-leave-active {
transition: all v-bind('props.animationDuration + "ms"') ease-out; transition: all v-bind('props.animationDuration + "ms"') ease-out;
} }
.context-menu-enter-from, .context-menu-enter-from,
.context-menu-leave-to { .context-menu-leave-to {
opacity: 0; opacity: 0;
transform: scale(0.9); transform: scale(0.9);
} }
.context-menu-enter-to, .context-menu-enter-to,
.context-menu-leave-from { .context-menu-leave-from {
opacity: 1; opacity: 1;
transform: scale(1); transform: scale(1);
} }
</style> </style>
@@ -1,64 +1,64 @@
<!-- 水印组件 --> <!-- 水印组件 -->
<template> <template>
<div <div
v-if="watermarkVisible" v-if="watermarkVisible"
class="fixed left-0 top-0 h-screen w-screen pointer-events-none" class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
:style="{ zIndex: zIndex }" :style="{ zIndex: zIndex }"
> >
<ElWatermark <ElWatermark
:content="content" :content="content"
:font="{ fontSize: fontSize, color: fontColor }" :font="{ fontSize: fontSize, color: fontColor }"
:rotate="rotate" :rotate="rotate"
:gap="[gapX, gapY]" :gap="[gapX, gapY]"
:offset="[offsetX, offsetY]" :offset="[offsetX, offsetY]"
> >
<div style="height: 100vh"></div> <div style="height: 100vh"></div>
</ElWatermark> </ElWatermark>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import AppConfig from '@/config' import AppConfig from '@/config'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
defineOptions({ name: 'ArtWatermark' }) defineOptions({ name: 'ArtWatermark' })
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { watermarkVisible } = storeToRefs(settingStore) const { watermarkVisible } = storeToRefs(settingStore)
interface WatermarkProps { interface WatermarkProps {
/** 水印内容 */ /** 水印内容 */
content?: string content?: string
/** 水印是否可见 */ /** 水印是否可见 */
visible?: boolean visible?: boolean
/** 水印字体大小 */ /** 水印字体大小 */
fontSize?: number fontSize?: number
/** 水印字体颜色 */ /** 水印字体颜色 */
fontColor?: string fontColor?: string
/** 水印旋转角度 */ /** 水印旋转角度 */
rotate?: number rotate?: number
/** 水印间距X */ /** 水印间距X */
gapX?: number gapX?: number
/** 水印间距Y */ /** 水印间距Y */
gapY?: number gapY?: number
/** 水印偏移X */ /** 水印偏移X */
offsetX?: number offsetX?: number
/** 水印偏移Y */ /** 水印偏移Y */
offsetY?: number offsetY?: number
/** 水印层级 */ /** 水印层级 */
zIndex?: number zIndex?: number
} }
withDefaults(defineProps<WatermarkProps>(), { withDefaults(defineProps<WatermarkProps>(), {
content: AppConfig.systemInfo.name, content: AppConfig.systemInfo.name,
visible: false, visible: false,
fontSize: 16, fontSize: 16,
fontColor: 'rgba(128, 128, 128, 0.2)', fontColor: 'rgba(128, 128, 128, 0.2)',
rotate: -22, rotate: -22,
gapX: 100, gapX: 100,
gapY: 100, gapY: 100,
offsetX: 50, offsetX: 50,
offsetY: 50, offsetY: 50,
zIndex: 3100 zIndex: 3100
}) })
</script> </script>
@@ -1,328 +1,339 @@
<!-- 表格头部包含表格大小刷新全屏列设置其他设置 --> <!-- 表格头部包含表格大小刷新全屏列设置其他设置 -->
<template> <template>
<div class="flex-cb max-md:!block" id="art-table-header"> <div class="flex-cb max-md:!block" id="art-table-header">
<div class="flex-wrap"> <div class="flex-wrap">
<slot name="left"></slot> <slot name="left"></slot>
</div> </div>
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden"> <div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
<div <div
v-if="showSearchBar != null" v-if="showSearchBar != null"
class="button" class="button"
@click="search" @click="search"
:class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''" :class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
> >
<ArtSvgIcon icon="ri:search-line" :class="showSearchBar ? 'text-white' : 'text-g-700'" /> <ArtSvgIcon
</div> icon="ri:search-line"
<div :class="showSearchBar ? 'text-white' : 'text-g-700'"
v-if="shouldShow('refresh')" />
class="button" </div>
@click="refresh" <div
:class="{ loading: loading && isManualRefresh }" v-if="shouldShow('refresh')"
> class="button"
<ArtSvgIcon @click="refresh"
icon="ri:refresh-line" :class="{ loading: loading && isManualRefresh }"
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''" >
/> <ArtSvgIcon
</div> icon="ri:refresh-line"
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
/>
</div>
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange"> <ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
<div class="button"> <div class="button">
<ArtSvgIcon icon="ri:arrow-up-down-fill" /> <ArtSvgIcon icon="ri:arrow-up-down-fill" />
</div> </div>
<template #dropdown> <template #dropdown>
<ElDropdownMenu> <ElDropdownMenu>
<div <div
v-for="item in tableSizeOptions" v-for="item in tableSizeOptions"
:key="item.value" :key="item.value"
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0" class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
> >
<ElDropdownItem <ElDropdownItem
:key="item.value" :key="item.value"
:command="item.value" :command="item.value"
:class="tableSize === item.value ? '!bg-g-300/55' : ''" :class="tableSize === item.value ? '!bg-g-300/55' : ''"
> >
{{ item.label }} {{ item.label }}
</ElDropdownItem> </ElDropdownItem>
</div> </div>
</ElDropdownMenu> </ElDropdownMenu>
</template> </template>
</ElDropdown> </ElDropdown>
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen"> <div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
<ArtSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" /> <ArtSvgIcon
</div> :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'"
/>
</div>
<!-- 列设置 --> <!-- 列设置 -->
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click"> <ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
<template #reference> <template #reference>
<div class="button"> <div class="button">
<ArtSvgIcon icon="ri:align-right" /> <ArtSvgIcon icon="ri:align-right" />
</div> </div>
</template> </template>
<div> <div>
<ElScrollbar max-height="380px"> <ElScrollbar max-height="380px">
<VueDraggable <VueDraggable
v-model="columns" v-model="columns"
:disabled="false" :disabled="false"
filter=".fixed-column" filter=".fixed-column"
:prevent-on-filter="false" :prevent-on-filter="false"
@move="checkColumnMove" @move="checkColumnMove"
> >
<div <div
v-for="item in columns" v-for="item in columns"
:key="item.prop || item.type" :key="item.prop || item.type"
class="column-option flex-c" class="column-option flex-c"
:class="{ 'fixed-column': item.fixed }" :class="{ 'fixed-column': item.fixed }"
> >
<div <div
class="drag-icon mr-2 h-4.5 flex-cc text-g-500" class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
:class="item.fixed ? 'cursor-default text-g-300' : 'cursor-move'" :class="
> item.fixed ? 'cursor-default text-g-300' : 'cursor-move'
<ArtSvgIcon "
:icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'" >
class="text-base" <ArtSvgIcon
/> :icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'"
</div> class="text-base"
<ElCheckbox />
:model-value="getColumnVisibility(item)" </div>
@update:model-value="(val) => updateColumnVisibility(item, val)" <ElCheckbox
:disabled="item.disabled" :model-value="getColumnVisibility(item)"
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap" @update:model-value="(val) => updateColumnVisibility(item, val)"
>{{ :disabled="item.disabled"
item.label || (item.type === 'selection' ? t('table.selection') : '') class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
}}</ElCheckbox >{{
> item.label ||
</div> (item.type === 'selection' ? t('table.selection') : '')
</VueDraggable> }}</ElCheckbox
</ElScrollbar> >
</div> </div>
</ElPopover> </VueDraggable>
<!-- 其他设置 --> </ElScrollbar>
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click"> </div>
<template #reference> </ElPopover>
<div class="button"> <!-- 其他设置 -->
<ArtSvgIcon icon="ri:settings-line" /> <ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
</div> <template #reference>
</template> <div class="button">
<div> <ArtSvgIcon icon="ri:settings-line" />
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{ </div>
t('table.zebra') </template>
}}</ElCheckbox> <div>
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{ <ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
t('table.border') t('table.zebra')
}}</ElCheckbox> }}</ElCheckbox>
<ElCheckbox v-if="showHeaderBackground" v-model="isHeaderBackground" :value="true">{{ <ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
t('table.headerBackground') t('table.border')
}}</ElCheckbox> }}</ElCheckbox>
</div> <ElCheckbox
</ElPopover> v-if="showHeaderBackground"
<slot name="right"></slot> v-model="isHeaderBackground"
</div> :value="true"
</div> >{{ t('table.headerBackground') }}</ElCheckbox
>
</div>
</ElPopover>
<slot name="right"></slot>
</div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted } from 'vue' import { computed, ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { TableSizeEnum } from '@/enums/formEnum' import { TableSizeEnum } from '@/enums/formEnum'
import { useTableStore } from '@/store/modules/table' import { useTableStore } from '@/store/modules/table'
import { VueDraggable } from 'vue-draggable-plus' import { VueDraggable } from 'vue-draggable-plus'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { ColumnOption } from '@/types/component' import type { ColumnOption } from '@/types/component'
import { ElScrollbar } from 'element-plus' import { ElScrollbar } from 'element-plus'
defineOptions({ name: 'ArtTableHeader' }) defineOptions({ name: 'ArtTableHeader' })
const { t } = useI18n() const { t } = useI18n()
interface Props { interface Props {
/** 斑马纹 */ /** 斑马纹 */
showZebra?: boolean showZebra?: boolean
/** 边框 */ /** 边框 */
showBorder?: boolean showBorder?: boolean
/** 表头背景 */ /** 表头背景 */
showHeaderBackground?: boolean showHeaderBackground?: boolean
/** 全屏 class */ /** 全屏 class */
fullClass?: string fullClass?: string
/** 组件布局,子组件名用逗号分隔 */ /** 组件布局,子组件名用逗号分隔 */
layout?: string layout?: string
/** 加载中 */ /** 加载中 */
loading?: boolean loading?: boolean
/** 搜索栏显示状态 */ /** 搜索栏显示状态 */
showSearchBar?: boolean showSearchBar?: boolean
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
showZebra: true, showZebra: true,
showBorder: true, showBorder: true,
showHeaderBackground: true, showHeaderBackground: true,
fullClass: 'art-page-view', fullClass: 'art-page-view',
layout: 'search,refresh,size,fullscreen,columns,settings', layout: 'search,refresh,size,fullscreen,columns,settings',
showSearchBar: undefined showSearchBar: undefined
}) })
const columns = defineModel<ColumnOption[]>('columns', { const columns = defineModel<ColumnOption[]>('columns', {
required: false, required: false,
default: () => [] default: () => []
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'refresh'): void (e: 'refresh'): void
(e: 'search'): void (e: 'search'): void
(e: 'update:showSearchBar', value: boolean): void (e: 'update:showSearchBar', value: boolean): void
}>() }>()
/** /**
* 获取列的显示状态 * 获取列的显示状态
* 优先使用 visible 字段,如果不存在则使用 checked 字段 * 优先使用 visible 字段,如果不存在则使用 checked 字段
*/ */
const getColumnVisibility = (col: ColumnOption): boolean => { const getColumnVisibility = (col: ColumnOption): boolean => {
if (col.visible !== undefined) { if (col.visible !== undefined) {
return col.visible return col.visible
} }
return col.checked ?? true return col.checked ?? true
} }
/** /**
* 更新列的显示状态 * 更新列的显示状态
* 同时更新 checked 和 visible 字段以保持兼容性 * 同时更新 checked 和 visible 字段以保持兼容性
*/ */
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => { const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
const boolValue = !!value const boolValue = !!value
col.checked = boolValue col.checked = boolValue
col.visible = boolValue col.visible = boolValue
} }
/** 表格大小选项配置 */ /** 表格大小选项配置 */
const tableSizeOptions = [ const tableSizeOptions = [
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') }, { value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') }, { value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') } { value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
] ]
const tableStore = useTableStore() const tableStore = useTableStore()
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore) const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
/** 解析 layout 属性,转换为数组 */ /** 解析 layout 属性,转换为数组 */
const layoutItems = computed(() => { const layoutItems = computed(() => {
return props.layout.split(',').map((item) => item.trim()) return props.layout.split(',').map((item) => item.trim())
}) })
/** /**
* 检查组件是否应该显示 * 检查组件是否应该显示
* @param componentName 组件名称 * @param componentName 组件名称
* @returns 是否显示 * @returns 是否显示
*/ */
const shouldShow = (componentName: string) => { const shouldShow = (componentName: string) => {
return layoutItems.value.includes(componentName) return layoutItems.value.includes(componentName)
} }
/** /**
* 拖拽移动事件处理 - 防止固定列位置改变 * 拖拽移动事件处理 - 防止固定列位置改变
* @param evt move事件对象 * @param evt move事件对象
* @returns 是否允许移动 * @returns 是否允许移动
*/ */
const checkColumnMove = (event: any) => { const checkColumnMove = (event: any) => {
// 拖拽进入的目标 DOM 元素 // 拖拽进入的目标 DOM 元素
const toElement = event.related as HTMLElement const toElement = event.related as HTMLElement
// 如果目标位置是 fixed 列,则不允许移动 // 如果目标位置是 fixed 列,则不允许移动
if (toElement && toElement.classList.contains('fixed-column')) { if (toElement && toElement.classList.contains('fixed-column')) {
return false return false
} }
return true return true
} }
/** 搜索事件处理 */ /** 搜索事件处理 */
const search = () => { const search = () => {
// 切换搜索栏显示状态 // 切换搜索栏显示状态
emit('update:showSearchBar', !props.showSearchBar) emit('update:showSearchBar', !props.showSearchBar)
emit('search') emit('search')
} }
/** 刷新事件处理 */ /** 刷新事件处理 */
const refresh = () => { const refresh = () => {
isManualRefresh.value = true isManualRefresh.value = true
emit('refresh') emit('refresh')
} }
/** /**
* 表格大小变化处理 * 表格大小变化处理
* @param command 表格大小枚举值 * @param command 表格大小枚举值
*/ */
const handleTableSizeChange = (command: TableSizeEnum) => { const handleTableSizeChange = (command: TableSizeEnum) => {
useTableStore().setTableSize(command) useTableStore().setTableSize(command)
} }
/** 是否手动点击刷新 */ /** 是否手动点击刷新 */
const isManualRefresh = ref(false) const isManualRefresh = ref(false)
/** 加载中 */ /** 加载中 */
const isFullScreen = ref(false) const isFullScreen = ref(false)
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */ /** 保存原始的 overflow 样式,用于退出全屏时恢复 */
const originalOverflow = ref('') const originalOverflow = ref('')
/** /**
* 切换全屏状态 * 切换全屏状态
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态 * 进入全屏时会隐藏页面滚动条,退出时恢复原状态
*/ */
const toggleFullScreen = () => { const toggleFullScreen = () => {
const el = document.querySelector(`.${props.fullClass}`) const el = document.querySelector(`.${props.fullClass}`)
if (!el) return if (!el) return
isFullScreen.value = !isFullScreen.value isFullScreen.value = !isFullScreen.value
if (isFullScreen.value) { if (isFullScreen.value) {
// 进入全屏:保存原始样式并隐藏滚动条 // 进入全屏:保存原始样式并隐藏滚动条
originalOverflow.value = document.body.style.overflow originalOverflow.value = document.body.style.overflow
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden'
el.classList.add('el-full-screen') el.classList.add('el-full-screen')
tableStore.setIsFullScreen(true) tableStore.setIsFullScreen(true)
} else { } else {
// 退出全屏:恢复原始样式 // 退出全屏:恢复原始样式
document.body.style.overflow = originalOverflow.value document.body.style.overflow = originalOverflow.value
el.classList.remove('el-full-screen') el.classList.remove('el-full-screen')
tableStore.setIsFullScreen(false) tableStore.setIsFullScreen(false)
} }
} }
/** /**
* ESC键退出全屏的事件处理器 * ESC键退出全屏的事件处理器
* 需要保存引用以便在组件卸载时正确移除监听器 * 需要保存引用以便在组件卸载时正确移除监听器
*/ */
const handleEscapeKey = (e: KeyboardEvent) => { const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullScreen.value) { if (e.key === 'Escape' && isFullScreen.value) {
toggleFullScreen() toggleFullScreen()
} }
} }
/** 组件挂载时注册全局事件监听器 */ /** 组件挂载时注册全局事件监听器 */
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', handleEscapeKey) document.addEventListener('keydown', handleEscapeKey)
}) })
/** 组件卸载时清理资源 */ /** 组件卸载时清理资源 */
onUnmounted(() => { onUnmounted(() => {
// 移除事件监听器 // 移除事件监听器
document.removeEventListener('keydown', handleEscapeKey) document.removeEventListener('keydown', handleEscapeKey)
// 如果组件在全屏状态下被卸载,恢复页面滚动状态 // 如果组件在全屏状态下被卸载,恢复页面滚动状态
if (isFullScreen.value) { if (isFullScreen.value) {
document.body.style.overflow = originalOverflow.value document.body.style.overflow = originalOverflow.value
const el = document.querySelector(`.${props.fullClass}`) const el = document.querySelector(`.${props.fullClass}`)
if (el) { if (el) {
el.classList.remove('el-full-screen') el.classList.remove('el-full-screen')
} }
} }
}) })
</script> </script>
<style scoped> <style scoped>
@reference '@styles/core/tailwind.css'; @reference '@styles/core/tailwind.css';
.button { .button {
@apply ml-2 @apply ml-2
size-8 size-8
flex flex
items-center items-center
@@ -335,5 +346,5 @@
hover:bg-g-300 hover:bg-g-300
md:ml-0 md:ml-0
md:mr-2.5; md:mr-2.5;
} }
</style> </style>
+295 -294
View File
@@ -3,340 +3,341 @@
<!-- 扩展功能分页组件渲染自定义列loading表格全局边框斑马纹表格尺寸表头背景配置 --> <!-- 扩展功能分页组件渲染自定义列loading表格全局边框斑马纹表格尺寸表头背景配置 -->
<!-- 获取 ref默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 --> <!-- 获取 ref默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
<template> <template>
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight"> <div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
<ElTable <ElTable
ref="elTableRef" ref="elTableRef"
v-loading="!!loading" v-loading="!!loading"
v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }" v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }"
> >
<template v-for="col in columns" :key="col.prop || col.type"> <template v-for="col in columns" :key="col.prop || col.type">
<!-- 渲染全局序号列 --> <!-- 渲染全局序号列 -->
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }"> <ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
<template #default="{ $index }"> <template #default="{ $index }">
<span>{{ getGlobalIndex($index) }}</span> <span>{{ getGlobalIndex($index) }}</span>
</template> </template>
</ElTableColumn> </ElTableColumn>
<!-- 渲染展开行 --> <!-- 渲染展开行 -->
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)"> <ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row }"> <template #default="{ row }">
<component :is="col.formatter ? col.formatter(row) : null" /> <component :is="col.formatter ? col.formatter(row) : null" />
</template> </template>
</ElTableColumn> </ElTableColumn>
<!-- 渲染普通列 --> <!-- 渲染普通列 -->
<ElTableColumn v-else v-bind="cleanColumnProps(col)"> <ElTableColumn v-else v-bind="cleanColumnProps(col)">
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope"> <template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot <slot
:name="col.headerSlotName || `${col.prop}-header`" :name="col.headerSlotName || `${col.prop}-header`"
v-bind="{ ...headerScope, prop: col.prop, label: col.label }" v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
> >
{{ col.label }} {{ col.label }}
</slot> </slot>
</template> </template>
<template v-if="col.useSlot && col.prop" #default="slotScope"> <template v-if="col.useSlot && col.prop" #default="slotScope">
<slot <slot
:name="col.slotName || col.prop" :name="col.slotName || col.prop"
v-bind="{ v-bind="{
...slotScope, ...slotScope,
prop: col.prop, prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined value: col.prop ? slotScope.row[col.prop] : undefined
}" }"
/> />
</template> </template>
</ElTableColumn> </ElTableColumn>
</template> </template>
<template v-if="$slots.default" #default><slot /></template> <template v-if="$slots.default" #default><slot /></template>
<template #empty> <template #empty>
<div v-if="loading"></div> <div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="120" /> <ElEmpty v-else :description="emptyText" :image-size="120" />
</template> </template>
</ElTable> </ElTable>
<div <div
class="pagination custom-pagination" class="pagination custom-pagination"
v-if="showPagination" v-if="showPagination"
:class="mergedPaginationOptions?.align" :class="mergedPaginationOptions?.align"
ref="paginationRef" ref="paginationRef"
> >
<ElPagination <ElPagination
v-bind="mergedPaginationOptions" v-bind="mergedPaginationOptions"
:total="pagination?.total" :total="pagination?.total"
:disabled="loading" :disabled="loading"
:page-size="pagination?.size" :page-size="pagination?.size"
:current-page="pagination?.current" :current-page="pagination?.current"
@size-change="handleSizeChange" @size-change="handleSizeChange"
@current-change="handleCurrentChange" @current-change="handleCurrentChange"
/> />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, nextTick, watchEffect } from 'vue' import { ref, computed, nextTick, watchEffect } from 'vue'
import type { ElTable, TableProps } from 'element-plus' import type { ElTable, TableProps } from 'element-plus'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { ColumnOption } from '@/types' import { ColumnOption } from '@/types'
import { useTableStore } from '@/store/modules/table' import { useTableStore } from '@/store/modules/table'
import { useCommon } from '@/hooks/core/useCommon' import { useCommon } from '@/hooks/core/useCommon'
import { useTableHeight } from '@/hooks/core/useTableHeight' import { useTableHeight } from '@/hooks/core/useTableHeight'
import { useResizeObserver, useWindowSize } from '@vueuse/core' import { useResizeObserver, useWindowSize } from '@vueuse/core'
defineOptions({ name: 'ArtTable' }) defineOptions({ name: 'ArtTable' })
const { width } = useWindowSize() const { width } = useWindowSize()
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null) const elTableRef = ref<InstanceType<typeof ElTable> | null>(null)
const paginationRef = ref<HTMLElement>() const paginationRef = ref<HTMLElement>()
const tableHeaderRef = ref<HTMLElement>() const tableHeaderRef = ref<HTMLElement>()
const tableStore = useTableStore() const tableStore = useTableStore()
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } = storeToRefs(tableStore) const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } =
storeToRefs(tableStore)
/** 分页配置接口 */ /** 分页配置接口 */
interface PaginationConfig { interface PaginationConfig {
/** 当前页码 */ /** 当前页码 */
current: number current: number
/** 每页显示条目个数 */ /** 每页显示条目个数 */
size: number size: number
/** 总条目数 */ /** 总条目数 */
total: number total: number
} }
/** 分页器配置选项接口 */ /** 分页器配置选项接口 */
interface PaginationOptions { interface PaginationOptions {
/** 每页显示个数选择器的选项列表 */ /** 每页显示个数选择器的选项列表 */
pageSizes?: number[] pageSizes?: number[]
/** 分页器的对齐方式 */ /** 分页器的对齐方式 */
align?: 'left' | 'center' | 'right' align?: 'left' | 'center' | 'right'
/** 分页器的布局 */ /** 分页器的布局 */
layout?: string layout?: string
/** 是否显示分页器背景 */ /** 是否显示分页器背景 */
background?: boolean background?: boolean
/** 只有一页时是否隐藏分页器 */ /** 只有一页时是否隐藏分页器 */
hideOnSinglePage?: boolean hideOnSinglePage?: boolean
/** 分页器的大小 */ /** 分页器的大小 */
size?: 'small' | 'default' | 'large' size?: 'small' | 'default' | 'large'
/** 分页器的页码数量 */ /** 分页器的页码数量 */
pagerCount?: number pagerCount?: number
} }
/** ArtTable 组件的 Props 接口 */ /** ArtTable 组件的 Props 接口 */
interface ArtTableProps extends TableProps<Record<string, any>> { interface ArtTableProps extends TableProps<Record<string, any>> {
/** 加载状态 */ /** 加载状态 */
loading?: boolean loading?: boolean
/** 列渲染配置 */ /** 列渲染配置 */
columns?: ColumnOption[] columns?: ColumnOption[]
/** 分页状态 */ /** 分页状态 */
pagination?: PaginationConfig pagination?: PaginationConfig
/** 分页配置 */ /** 分页配置 */
paginationOptions?: PaginationOptions paginationOptions?: PaginationOptions
/** 空数据表格高度 */ /** 空数据表格高度 */
emptyHeight?: string emptyHeight?: string
/** 空数据时显示的文本 */ /** 空数据时显示的文本 */
emptyText?: string emptyText?: string
/** 是否开启 ArtTableHeader,解决表格高度自适应问题 */ /** 是否开启 ArtTableHeader,解决表格高度自适应问题 */
showTableHeader?: boolean showTableHeader?: boolean
} }
const props = withDefaults(defineProps<ArtTableProps>(), { const props = withDefaults(defineProps<ArtTableProps>(), {
columns: () => [], columns: () => [],
fit: true, fit: true,
showHeader: true, showHeader: true,
stripe: undefined, stripe: undefined,
border: undefined, border: undefined,
size: undefined, size: undefined,
emptyHeight: '100%', emptyHeight: '100%',
emptyText: '暂无数据', emptyText: '暂无数据',
showTableHeader: true showTableHeader: true
}) })
const LAYOUT = { const LAYOUT = {
MOBILE: 'prev, pager, next, sizes, jumper, total', MOBILE: 'prev, pager, next, sizes, jumper, total',
IPAD: 'prev, pager, next, jumper, total', IPAD: 'prev, pager, next, jumper, total',
DESKTOP: 'total, prev, pager, next, sizes, jumper' DESKTOP: 'total, prev, pager, next, sizes, jumper'
} }
const layout = computed(() => { const layout = computed(() => {
if (width.value < 768) { if (width.value < 768) {
return LAYOUT.MOBILE return LAYOUT.MOBILE
} else if (width.value < 1024) { } else if (width.value < 1024) {
return LAYOUT.IPAD return LAYOUT.IPAD
} else { } else {
return LAYOUT.DESKTOP return LAYOUT.DESKTOP
} }
}) })
// 默认分页常量 // 默认分页常量
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = { const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
pageSizes: [10, 20, 30, 50, 100], pageSizes: [10, 20, 30, 50, 100],
align: 'center', align: 'center',
background: true, background: true,
layout: layout.value, layout: layout.value,
hideOnSinglePage: false, hideOnSinglePage: false,
size: 'default', size: 'default',
pagerCount: width.value > 1200 ? 7 : 5 pagerCount: width.value > 1200 ? 7 : 5
} }
// 合并分页配置 // 合并分页配置
const mergedPaginationOptions = computed(() => ({ const mergedPaginationOptions = computed(() => ({
...DEFAULT_PAGINATION_OPTIONS, ...DEFAULT_PAGINATION_OPTIONS,
...props.paginationOptions ...props.paginationOptions
})) }))
// 边框 (优先级:props > store) // 边框 (优先级:props > store)
const border = computed(() => props.border ?? isBorder.value) const border = computed(() => props.border ?? isBorder.value)
// 斑马纹 // 斑马纹
const stripe = computed(() => props.stripe ?? isZebra.value) const stripe = computed(() => props.stripe ?? isZebra.value)
// 表格尺寸 // 表格尺寸
const size = computed(() => props.size ?? tableSize.value) const size = computed(() => props.size ?? tableSize.value)
// 数据是否为空 // 数据是否为空
const isEmpty = computed(() => props.data?.length === 0) const isEmpty = computed(() => props.data?.length === 0)
const paginationHeight = ref(0) const paginationHeight = ref(0)
const tableHeaderHeight = ref(0) const tableHeaderHeight = ref(0)
// 使用 useResizeObserver 监听分页器高度变化 // 使用 useResizeObserver 监听分页器高度变化
useResizeObserver(paginationRef, (entries) => { useResizeObserver(paginationRef, (entries) => {
const entry = entries[0] const entry = entries[0]
if (entry) { if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告 // 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => { requestAnimationFrame(() => {
paginationHeight.value = entry.contentRect.height paginationHeight.value = entry.contentRect.height
}) })
} }
}) })
// 使用 useResizeObserver 监听表格头部高度变化 // 使用 useResizeObserver 监听表格头部高度变化
useResizeObserver(tableHeaderRef, (entries) => { useResizeObserver(tableHeaderRef, (entries) => {
const entry = entries[0] const entry = entries[0]
if (entry) { if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告 // 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => { requestAnimationFrame(() => {
tableHeaderHeight.value = entry.contentRect.height tableHeaderHeight.value = entry.contentRect.height
}) })
} }
}) })
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化) // 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15)) const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
// 使用表格高度计算 Hook // 使用表格高度计算 Hook
const { containerHeight } = useTableHeight({ const { containerHeight } = useTableHeight({
showTableHeader: computed(() => props.showTableHeader), showTableHeader: computed(() => props.showTableHeader),
paginationHeight, paginationHeight,
tableHeaderHeight, tableHeaderHeight,
paginationSpacing: PAGINATION_SPACING paginationSpacing: PAGINATION_SPACING
}) })
// 表格高度逻辑 // 表格高度逻辑
const height = computed(() => { const height = computed(() => {
// 全屏模式下占满全屏 // 全屏模式下占满全屏
if (isFullScreen.value) return '100%' if (isFullScreen.value) return '100%'
// 空数据且非加载状态时固定高度 // 空数据且非加载状态时固定高度
if (isEmpty.value && !props.loading) return props.emptyHeight if (isEmpty.value && !props.loading) return props.emptyHeight
// 使用传入的高度 // 使用传入的高度
if (props.height) return props.height if (props.height) return props.height
// 默认占满容器高度 // 默认占满容器高度
return '100%' return '100%'
}) })
// 表头背景颜色样式 // 表头背景颜色样式
const headerCellStyle = computed(() => ({ const headerCellStyle = computed(() => ({
background: isHeaderBackground.value background: isHeaderBackground.value
? 'var(--el-fill-color-lighter)' ? 'var(--el-fill-color-lighter)'
: 'var(--default-box-color)', : 'var(--default-box-color)',
...(props.headerCellStyle || {}) // 合并用户传入的样式 ...(props.headerCellStyle || {}) // 合并用户传入的样式
})) }))
// 是否显示分页器 // 是否显示分页器
const showPagination = computed(() => props.pagination && !isEmpty.value) const showPagination = computed(() => props.pagination && !isEmpty.value)
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释 // 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
const cleanColumnProps = (col: ColumnOption) => { const cleanColumnProps = (col: ColumnOption) => {
const columnProps = { ...col } const columnProps = { ...col }
// 删除自定义的插槽控制属性 // 删除自定义的插槽控制属性
delete columnProps.useHeaderSlot delete columnProps.useHeaderSlot
delete columnProps.headerSlotName delete columnProps.headerSlotName
delete columnProps.useSlot delete columnProps.useSlot
delete columnProps.slotName delete columnProps.slotName
return columnProps return columnProps
} }
// 分页大小变化 // 分页大小变化
const handleSizeChange = (val: number) => { const handleSizeChange = (val: number) => {
emit('pagination:size-change', val) emit('pagination:size-change', val)
} }
// 分页当前页变化 // 分页当前页变化
const handleCurrentChange = (val: number) => { const handleCurrentChange = (val: number) => {
emit('pagination:current-change', val) emit('pagination:current-change', val)
scrollToTop() // 页码改变后滚动到表格顶部 scrollToTop() // 页码改变后滚动到表格顶部
} }
const { scrollToTop: scrollPageToTop } = useCommon() const { scrollToTop: scrollPageToTop } = useCommon()
// 滚动表格内容到顶部,并可以联动页面滚动到顶部 // 滚动表格内容到顶部,并可以联动页面滚动到顶部
const scrollToTop = () => { const scrollToTop = () => {
nextTick(() => { nextTick(() => {
elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部 elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部
scrollPageToTop() // 调用公共 composable 滚动页面到顶部 scrollPageToTop() // 调用公共 composable 滚动页面到顶部
}) })
} }
// 全局序号 // 全局序号
const getGlobalIndex = (index: number) => { const getGlobalIndex = (index: number) => {
if (!props.pagination) return index + 1 if (!props.pagination) return index + 1
const { current, size } = props.pagination const { current, size } = props.pagination
return (current - 1) * size + index + 1 return (current - 1) * size + index + 1
} }
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'pagination:size-change', val: number): void (e: 'pagination:size-change', val: number): void
(e: 'pagination:current-change', val: number): void (e: 'pagination:current-change', val: number): void
}>() }>()
// 查找并绑定表格头部元素 - 使用 VueUse 优化 // 查找并绑定表格头部元素 - 使用 VueUse 优化
const findTableHeader = () => { const findTableHeader = () => {
if (!props.showTableHeader) { if (!props.showTableHeader) {
tableHeaderRef.value = undefined tableHeaderRef.value = undefined
return return
} }
const tableHeader = document.getElementById('art-table-header') const tableHeader = document.getElementById('art-table-header')
if (tableHeader) { if (tableHeader) {
tableHeaderRef.value = tableHeader tableHeaderRef.value = tableHeader
} else { } else {
// 如果找不到表格头部,设置为 undefineduseElementSize 会返回 0 // 如果找不到表格头部,设置为 undefineduseElementSize 会返回 0
tableHeaderRef.value = undefined tableHeaderRef.value = undefined
} }
} }
watchEffect( watchEffect(
() => { () => {
// 访问响应式数据以建立依赖追踪 // 访问响应式数据以建立依赖追踪
void props.data?.length // 追踪数据变化 void props.data?.length // 追踪数据变化
const shouldShow = props.showTableHeader const shouldShow = props.showTableHeader
// 只有在需要显示表格头部时才查找 // 只有在需要显示表格头部时才查找
if (shouldShow) { if (shouldShow) {
nextTick(() => { nextTick(() => {
findTableHeader() findTableHeader()
}) })
} else { } else {
// 不显示时清空引用 // 不显示时清空引用
tableHeaderRef.value = undefined tableHeaderRef.value = undefined
} }
}, },
{ flush: 'post' } { flush: 'post' }
) )
defineExpose({ defineExpose({
scrollToTop, scrollToTop,
elTableRef elTableRef
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@use './style'; @use './style';
</style> </style>
+78 -78
View File
@@ -1,99 +1,99 @@
.art-table { .art-table {
position: relative; position: relative;
height: 100%; height: 100%;
.el-table { .el-table {
height: 100%; height: 100%;
margin-top: 10px; margin-top: 10px;
} }
:deep(.el-loading-mask) { :deep(.el-loading-mask) {
z-index: 100; z-index: 100;
background-color: var(--default-box-color) !important; background-color: var(--default-box-color) !important;
} }
// Loading 过渡动画 - 消失时淡出 // Loading 过渡动画 - 消失时淡出
.loading-fade-leave-active { .loading-fade-leave-active {
transition: opacity 0.3s ease-out; transition: opacity 0.3s ease-out;
} }
.loading-fade-leave-to { .loading-fade-leave-to {
opacity: 0; opacity: 0;
} }
// 空状态垂直居中 // 空状态垂直居中
&.is-empty { &.is-empty {
:deep(.el-scrollbar__wrap) { :deep(.el-scrollbar__wrap) {
display: flex; display: flex;
} }
} }
.pagination { .pagination {
display: flex; display: flex;
margin-top: 13px; margin-top: 13px;
:deep(.el-select) { :deep(.el-select) {
width: 102px !important; width: 102px !important;
} }
// 分页对齐方式 // 分页对齐方式
&.left { &.left {
justify-content: flex-start; justify-content: flex-start;
} }
&.center { &.center {
justify-content: center; justify-content: center;
} }
&.right { &.right {
justify-content: flex-end; justify-content: flex-end;
} }
// 自定义分页组件样式 // 自定义分页组件样式
&.custom-pagination { &.custom-pagination {
:deep(.el-pagination) { :deep(.el-pagination) {
.btn-prev, .btn-prev,
.btn-next { .btn-next {
background-color: transparent; background-color: transparent;
border: 1px solid var(--art-gray-300); border: 1px solid var(--art-gray-300);
transition: border-color 0.15s; transition: border-color 0.15s;
&:hover:not(.is-disabled) { &:hover:not(.is-disabled) {
color: var(--theme-color); color: var(--theme-color);
border-color: var(--theme-color); border-color: var(--theme-color);
} }
} }
li { li {
box-sizing: border-box; box-sizing: border-box;
font-weight: 400 !important; font-weight: 400 !important;
background-color: transparent; background-color: transparent;
border: 1px solid var(--art-gray-300); border: 1px solid var(--art-gray-300);
transition: border-color 0.15s; transition: border-color 0.15s;
&.is-active { &.is-active {
font-weight: 400; font-weight: 400;
color: #fff; color: #fff;
background-color: var(--theme-color); background-color: var(--theme-color);
border: 1px solid var(--theme-color); border: 1px solid var(--theme-color);
} }
&:hover:not(.is-disabled) { &:hover:not(.is-disabled) {
border-color: var(--theme-color); border-color: var(--theme-color);
} }
} }
} }
} }
} }
} }
// 移动端分页 // 移动端分页
@media (width <= 640px) { @media (width <= 640px) {
:deep(.el-pagination) { :deep(.el-pagination) {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 15px 0; gap: 15px 0;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
} }
@@ -1,310 +1,319 @@
<!-- 数字滚动 --> <!-- 数字滚动 -->
<template> <template>
<span <span
class="text-g-900 tabular-nums" class="text-g-900 tabular-nums"
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''" :class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
> >
{{ formattedValue }} {{ formattedValue }}
</span> </span>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue' import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue'
import { useTransition, TransitionPresets } from '@vueuse/core' import { useTransition, TransitionPresets } from '@vueuse/core'
// 类型定义 // 类型定义
interface CountToProps { interface CountToProps {
/** 目标值 */ /** 目标值 */
target: number target: number
/** 动画持续时间(毫秒) */ /** 动画持续时间(毫秒) */
duration?: number duration?: number
/** 是否自动开始 */ /** 是否自动开始 */
autoStart?: boolean autoStart?: boolean
/** 小数位数 */ /** 小数位数 */
decimals?: number decimals?: number
/** 小数点符号 */ /** 小数点符号 */
decimal?: string decimal?: string
/** 千分位分隔符 */ /** 千分位分隔符 */
separator?: string separator?: string
/** 前缀 */ /** 前缀 */
prefix?: string prefix?: string
/** 后缀 */ /** 后缀 */
suffix?: string suffix?: string
/** 缓动函数 */ /** 缓动函数 */
easing?: keyof typeof TransitionPresets easing?: keyof typeof TransitionPresets
/** 是否禁用动画 */ /** 是否禁用动画 */
disabled?: boolean disabled?: boolean
} }
interface CountToEmits { interface CountToEmits {
started: [value: number] started: [value: number]
finished: [value: number] finished: [value: number]
paused: [value: number] paused: [value: number]
reset: [] reset: []
} }
interface CountToExpose { interface CountToExpose {
start: (target?: number) => void start: (target?: number) => void
pause: () => void pause: () => void
reset: (newTarget?: number) => void reset: (newTarget?: number) => void
stop: () => void stop: () => void
setTarget: (target: number) => void setTarget: (target: number) => void
readonly isRunning: boolean readonly isRunning: boolean
readonly isPaused: boolean readonly isPaused: boolean
readonly currentValue: number readonly currentValue: number
readonly targetValue: number readonly targetValue: number
readonly progress: number readonly progress: number
} }
// 常量定义 // 常量定义
const EPSILON = Number.EPSILON const EPSILON = Number.EPSILON
const MIN_DURATION = 100 const MIN_DURATION = 100
const MAX_DURATION = 60000 const MAX_DURATION = 60000
const MAX_DECIMALS = 10 const MAX_DECIMALS = 10
const DEFAULT_EASING = 'easeOutExpo' const DEFAULT_EASING = 'easeOutExpo'
const DEFAULT_DURATION = 2000 const DEFAULT_DURATION = 2000
const props = withDefaults(defineProps<CountToProps>(), { const props = withDefaults(defineProps<CountToProps>(), {
target: 0, target: 0,
duration: DEFAULT_DURATION, duration: DEFAULT_DURATION,
autoStart: true, autoStart: true,
decimals: 0, decimals: 0,
decimal: '.', decimal: '.',
separator: '', separator: '',
prefix: '', prefix: '',
suffix: '', suffix: '',
easing: DEFAULT_EASING, easing: DEFAULT_EASING,
disabled: false disabled: false
}) })
const emit = defineEmits<CountToEmits>() const emit = defineEmits<CountToEmits>()
// 工具函数 // 工具函数
const validateNumber = (value: number, name: string, defaultValue: number): number => { const validateNumber = (value: number, name: string, defaultValue: number): number => {
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
console.warn(`[CountTo] Invalid ${name} value:`, value) console.warn(`[CountTo] Invalid ${name} value:`, value)
return defaultValue return defaultValue
} }
return value return value
} }
const clamp = (value: number, min: number, max: number): number => { const clamp = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(value, max)) return Math.max(min, Math.min(value, max))
} }
const formatNumber = ( const formatNumber = (
value: number, value: number,
decimals: number, decimals: number,
decimal: string, decimal: string,
separator: string separator: string
): string => { ): string => {
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString() let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString()
// 处理小数点符号 // 处理小数点符号
if (decimal !== '.' && result.includes('.')) { if (decimal !== '.' && result.includes('.')) {
result = result.replace('.', decimal) result = result.replace('.', decimal)
} }
// 处理千分位分隔符 // 处理千分位分隔符
if (separator) { if (separator) {
const parts = result.split(decimal) const parts = result.split(decimal)
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator) parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
result = parts.join(decimal) result = parts.join(decimal)
} }
return result return result
} }
// 安全计算值 // 安全计算值
const safeTarget = computed(() => validateNumber(props.target, 'target', 0)) const safeTarget = computed(() => validateNumber(props.target, 'target', 0))
const safeDuration = computed(() => const safeDuration = computed(() =>
clamp(validateNumber(props.duration, 'duration', DEFAULT_DURATION), MIN_DURATION, MAX_DURATION) clamp(
) validateNumber(props.duration, 'duration', DEFAULT_DURATION),
const safeDecimals = computed(() => MIN_DURATION,
clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS) MAX_DURATION
) )
const safeEasing = computed(() => { )
const easing = props.easing const safeDecimals = computed(() =>
if (!(easing in TransitionPresets)) { clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS)
console.warn('[CountTo] Invalid easing value:', easing) )
return DEFAULT_EASING const safeEasing = computed(() => {
} const easing = props.easing
return easing if (!(easing in TransitionPresets)) {
}) console.warn('[CountTo] Invalid easing value:', easing)
return DEFAULT_EASING
}
return easing
})
// 状态管理 // 状态管理
const currentValue = shallowRef(0) const currentValue = shallowRef(0)
const targetValue = shallowRef(safeTarget.value) const targetValue = shallowRef(safeTarget.value)
const isRunning = shallowRef(false) const isRunning = shallowRef(false)
const isPaused = shallowRef(false) const isPaused = shallowRef(false)
const pausedValue = shallowRef(0) const pausedValue = shallowRef(0)
// 动画控制 // 动画控制
const transitionValue = useTransition(currentValue, { const transitionValue = useTransition(currentValue, {
duration: safeDuration, duration: safeDuration,
transition: computed(() => TransitionPresets[safeEasing.value]), transition: computed(() => TransitionPresets[safeEasing.value]),
onStarted: () => { onStarted: () => {
isRunning.value = true isRunning.value = true
isPaused.value = false isPaused.value = false
emit('started', targetValue.value) emit('started', targetValue.value)
}, },
onFinished: () => { onFinished: () => {
isRunning.value = false isRunning.value = false
isPaused.value = false isPaused.value = false
emit('finished', targetValue.value) emit('finished', targetValue.value)
} }
}) })
// 格式化显示值 // 格式化显示值
const formattedValue = computed(() => { const formattedValue = computed(() => {
const value = isPaused.value ? pausedValue.value : transitionValue.value const value = isPaused.value ? pausedValue.value : transitionValue.value
if (!Number.isFinite(value)) { if (!Number.isFinite(value)) {
return `${props.prefix}0${props.suffix}` return `${props.prefix}0${props.suffix}`
} }
const formattedNumber = formatNumber(value, safeDecimals.value, props.decimal, props.separator) const formattedNumber = formatNumber(
return `${props.prefix}${formattedNumber}${props.suffix}` value,
}) safeDecimals.value,
props.decimal,
props.separator
)
return `${props.prefix}${formattedNumber}${props.suffix}`
})
// 私有方法 // 私有方法
const shouldSkipAnimation = (target: number): boolean => { const shouldSkipAnimation = (target: number): boolean => {
const current = isPaused.value ? pausedValue.value : transitionValue.value const current = isPaused.value ? pausedValue.value : transitionValue.value
return Math.abs(current - target) < EPSILON return Math.abs(current - target) < EPSILON
} }
const resetPauseState = (): void => { const resetPauseState = (): void => {
isPaused.value = false isPaused.value = false
pausedValue.value = 0 pausedValue.value = 0
} }
// 公共方法 // 公共方法
const start = (target?: number): void => { const start = (target?: number): void => {
if (props.disabled) { if (props.disabled) {
console.warn('[CountTo] Animation is disabled') console.warn('[CountTo] Animation is disabled')
return return
} }
const finalTarget = target !== undefined ? target : targetValue.value const finalTarget = target !== undefined ? target : targetValue.value
if (!Number.isFinite(finalTarget)) { if (!Number.isFinite(finalTarget)) {
console.warn('[CountTo] Invalid target value for start:', finalTarget) console.warn('[CountTo] Invalid target value for start:', finalTarget)
return return
} }
targetValue.value = finalTarget targetValue.value = finalTarget
if (shouldSkipAnimation(finalTarget)) { if (shouldSkipAnimation(finalTarget)) {
return return
} }
// 从暂停值开始(如果存在) // 从暂停值开始(如果存在)
if (isPaused.value) { if (isPaused.value) {
currentValue.value = pausedValue.value currentValue.value = pausedValue.value
resetPauseState() resetPauseState()
} }
nextTick(() => { nextTick(() => {
currentValue.value = finalTarget currentValue.value = finalTarget
}) })
} }
const pause = (): void => { const pause = (): void => {
if (!isRunning.value || isPaused.value) { if (!isRunning.value || isPaused.value) {
return return
} }
isPaused.value = true isPaused.value = true
pausedValue.value = transitionValue.value pausedValue.value = transitionValue.value
currentValue.value = pausedValue.value currentValue.value = pausedValue.value
emit('paused', pausedValue.value) emit('paused', pausedValue.value)
} }
const reset = (newTarget = 0): void => { const reset = (newTarget = 0): void => {
const target = validateNumber(newTarget, 'reset target', 0) const target = validateNumber(newTarget, 'reset target', 0)
currentValue.value = target currentValue.value = target
targetValue.value = target targetValue.value = target
resetPauseState() resetPauseState()
emit('reset') emit('reset')
} }
const setTarget = (target: number): void => { const setTarget = (target: number): void => {
if (!Number.isFinite(target)) { if (!Number.isFinite(target)) {
console.warn('[CountTo] Invalid target value for setTarget:', target) console.warn('[CountTo] Invalid target value for setTarget:', target)
return return
} }
targetValue.value = target targetValue.value = target
if ((isRunning.value || props.autoStart) && !props.disabled) { if ((isRunning.value || props.autoStart) && !props.disabled) {
start(target) start(target)
} }
} }
const stop = (): void => { const stop = (): void => {
if (isRunning.value || isPaused.value) { if (isRunning.value || isPaused.value) {
currentValue.value = 0 currentValue.value = 0
resetPauseState() resetPauseState()
emit('paused', 0) emit('paused', 0)
} }
} }
// 监听器 // 监听器
watch( watch(
safeTarget, safeTarget,
(newTarget) => { (newTarget) => {
if (props.autoStart && !props.disabled) { if (props.autoStart && !props.disabled) {
start(newTarget) start(newTarget)
} else { } else {
targetValue.value = newTarget targetValue.value = newTarget
} }
}, },
{ immediate: props.autoStart && !props.disabled } { immediate: props.autoStart && !props.disabled }
) )
watch( watch(
() => props.disabled, () => props.disabled,
(disabled) => { (disabled) => {
if (disabled && isRunning.value) { if (disabled && isRunning.value) {
stop() stop()
} }
} }
) )
// 清理 // 清理
onUnmounted(() => { onUnmounted(() => {
if (isRunning.value) { if (isRunning.value) {
stop() stop()
} }
}) })
// 暴露 API // 暴露 API
defineExpose<CountToExpose>({ defineExpose<CountToExpose>({
start, start,
pause, pause,
reset, reset,
stop, stop,
setTarget, setTarget,
get isRunning() { get isRunning() {
return isRunning.value return isRunning.value
}, },
get isPaused() { get isPaused() {
return isPaused.value return isPaused.value
}, },
get currentValue() { get currentValue() {
return isPaused.value ? pausedValue.value : transitionValue.value return isPaused.value ? pausedValue.value : transitionValue.value
}, },
get targetValue() { get targetValue() {
return targetValue.value return targetValue.value
}, },
get progress() { get progress() {
const current = isPaused.value ? pausedValue.value : transitionValue.value const current = isPaused.value ? pausedValue.value : transitionValue.value
const target = targetValue.value const target = targetValue.value
if (target === 0) return current === 0 ? 1 : 0 if (target === 0) return current === 0 ? 1 : 0
return Math.abs(current / target) return Math.abs(current / target)
} }
}) })
</script> </script>
@@ -1,32 +1,32 @@
<!-- 节日文本滚动 --> <!-- 节日文本滚动 -->
<template> <template>
<div <div
class="overflow-hidden transition-[height] duration-600 ease-in-out" class="overflow-hidden transition-[height] duration-600 ease-in-out"
:style="{ :style="{
height: showFestivalText ? '48px' : '0' height: showFestivalText ? '48px' : '0'
}" }"
> >
<ArtTextScroll <ArtTextScroll
v-if="showFestivalText && currentFestivalData?.scrollText !== ''" v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
:text="currentFestivalData?.scrollText || ''" :text="currentFestivalData?.scrollText || ''"
style="margin-bottom: 12px" style="margin-bottom: 12px"
showClose showClose
@close="handleClose" @close="handleClose"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
import { useCeremony } from '@/hooks/core/useCeremony' import { useCeremony } from '@/hooks/core/useCeremony'
defineOptions({ name: 'ArtFestivalTextScroll' }) defineOptions({ name: 'ArtFestivalTextScroll' })
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { showFestivalText } = storeToRefs(settingStore) const { showFestivalText } = storeToRefs(settingStore)
const { currentFestivalData } = useCeremony() const { currentFestivalData } = useCeremony()
const handleClose = () => { const handleClose = () => {
settingStore.setShowFestivalText(false) settingStore.setShowFestivalText(false)
} }
</script> </script>
@@ -1,285 +1,285 @@
<!-- 文字滚动 --> <!-- 文字滚动 -->
<template> <template>
<div <div
ref="containerRef" ref="containerRef"
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm" class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
:class="themeClasses" :class="themeClasses"
:style="containerStyle" :style="containerStyle"
> >
<div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }"> <div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }">
<ArtSvgIcon icon="ri:volume-down-line" class="text-lg" /> <ArtSvgIcon icon="ri:volume-down-line" class="text-lg" />
</div> </div>
<div <div
ref="contentRef" ref="contentRef"
class="whitespace-nowrap inline-block transition-opacity duration-600 [&_a]:text-danger [&_a:hover]:underline [&_a:hover]:text-danger/80 px-9" class="whitespace-nowrap inline-block transition-opacity duration-600 [&_a]:text-danger [&_a:hover]:underline [&_a:hover]:text-danger/80 px-9"
:class="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]" :class="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
:style="contentStyle" :style="contentStyle"
@click="handleContentClick" @click="handleContentClick"
> >
<!-- 原始内容 --> <!-- 原始内容 -->
<span ref="textRef" class="inline-block"> <span ref="textRef" class="inline-block">
<slot> <slot>
<span v-html="text"></span> <span v-html="text"></span>
</slot> </slot>
</span> </span>
<!-- 克隆内容用于无缝循环 --> <!-- 克隆内容用于无缝循环 -->
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing"> <span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
<slot> <slot>
<span v-html="text"></span> <span v-html="text"></span>
</slot> </slot>
</span> </span>
</div> </div>
<div <div
v-if="showClose" v-if="showClose"
class="flex-cc absolute right-0 h-full w-9 c-p" class="flex-cc absolute right-0 h-full w-9 c-p"
:style="{ backgroundColor: bgColor }" :style="{ backgroundColor: bgColor }"
@click="handleClose" @click="handleClose"
> >
<ArtSvgIcon icon="ri:close-fill" class="text-lg" /> <ArtSvgIcon icon="ri:close-fill" class="text-lg" />
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
useElementSize, useElementSize,
useRafFn, useRafFn,
useElementHover, useElementHover,
useDebounceFn, useDebounceFn,
useTimeoutFn useTimeoutFn
} from '@vueuse/core' } from '@vueuse/core'
import { useSettingStore } from '@/store/modules/setting' import { useSettingStore } from '@/store/modules/setting'
type ThemeType = type ThemeType =
| 'theme' | 'theme'
| 'primary' | 'primary'
| 'secondary' | 'secondary'
| 'error' | 'error'
| 'info' | 'info'
| 'success' | 'success'
| 'warning' | 'warning'
| 'danger' | 'danger'
/** /**
* 文本滚动组件属性接口 * 文本滚动组件属性接口
*/ */
export interface TextScrollProps { export interface TextScrollProps {
/** 滚动文本内容 */ /** 滚动文本内容 */
text?: string text?: string
/** 主题类型 */ /** 主题类型 */
type?: ThemeType type?: ThemeType
/** 滚动方向 */ /** 滚动方向 */
direction?: 'left' | 'right' | 'up' | 'down' direction?: 'left' | 'right' | 'up' | 'down'
/** 滚动速度,单位:像素/秒 */ /** 滚动速度,单位:像素/秒 */
speed?: number speed?: number
/** 容器宽度 */ /** 容器宽度 */
width?: string width?: string
/** 容器高度 */ /** 容器高度 */
height?: string height?: string
/** 鼠标悬停时是否暂停滚动 */ /** 鼠标悬停时是否暂停滚动 */
pauseOnHover?: boolean pauseOnHover?: boolean
/** 是否显示关闭按钮 */ /** 是否显示关闭按钮 */
showClose?: boolean showClose?: boolean
/** 始终滚动(即使文字未溢出) */ /** 始终滚动(即使文字未溢出) */
alwaysScroll?: boolean alwaysScroll?: boolean
} }
const props = withDefaults(defineProps<TextScrollProps>(), { const props = withDefaults(defineProps<TextScrollProps>(), {
text: '', text: '',
direction: 'left', direction: 'left',
speed: 80, speed: 80,
width: '100%', width: '100%',
height: '36px', height: '36px',
pauseOnHover: true, pauseOnHover: true,
type: 'theme', type: 'theme',
showClose: false, showClose: false,
alwaysScroll: true alwaysScroll: true
}) })
const emit = defineEmits<{ const emit = defineEmits<{
close: [] close: []
}>() }>()
const handleClose = () => { const handleClose = () => {
emit('close') emit('close')
} }
const settingStore = useSettingStore() const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore) const { isDark } = storeToRefs(settingStore)
const containerRef = ref<HTMLElement>() const containerRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>() const contentRef = ref<HTMLElement>()
const textRef = ref<HTMLElement>() const textRef = ref<HTMLElement>()
const isReady = ref(false) const isReady = ref(false)
const currentPosition = ref(0) const currentPosition = ref(0)
const textSize = ref(0) const textSize = ref(0)
const containerSize = ref(0) const containerSize = ref(0)
const shouldClone = ref(false) const shouldClone = ref(false)
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right') const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
const isReverse = computed(() => props.direction === 'right' || props.direction === 'down') const isReverse = computed(() => props.direction === 'right' || props.direction === 'down')
// 使用 VueUse 的 useElementSize 监听容器尺寸变化 // 使用 VueUse 的 useElementSize 监听容器尺寸变化
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef) const { width: containerWidth, height: containerHeight } = useElementSize(containerRef)
// 使用 VueUse 的 useElementHover 检测鼠标悬停 // 使用 VueUse 的 useElementHover 检测鼠标悬停
const isHovered = useElementHover(containerRef) const isHovered = useElementHover(containerRef)
// 计算是否应该暂停动画 // 计算是否应该暂停动画
const isPaused = computed(() => { const isPaused = computed(() => {
// 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动 // 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动
if (!props.alwaysScroll && textSize.value <= containerSize.value) { if (!props.alwaysScroll && textSize.value <= containerSize.value) {
return true return true
} }
return props.pauseOnHover && isHovered.value return props.pauseOnHover && isHovered.value
}) })
// 主题样式映射 // 主题样式映射
const themeClasses = computed(() => { const themeClasses = computed(() => {
const themeMap: Record<ThemeType, string> = { const themeMap: Record<ThemeType, string> = {
theme: 'text-theme/90 !border-theme/50', theme: 'text-theme/90 !border-theme/50',
primary: 'text-primary/90 !border-primary/50', primary: 'text-primary/90 !border-primary/50',
secondary: 'text-secondary/90 !border-secondary/50', secondary: 'text-secondary/90 !border-secondary/50',
error: 'text-error/90 !border-error/50', error: 'text-error/90 !border-error/50',
info: 'text-info/90 !border-info/50', info: 'text-info/90 !border-info/50',
success: 'text-success/90 !border-success/50', success: 'text-success/90 !border-success/50',
warning: 'text-warning/90 !border-warning/50', warning: 'text-warning/90 !border-warning/50',
danger: 'text-danger/90 !border-danger/50' danger: 'text-danger/90 !border-danger/50'
} }
return themeMap[props.type] || themeMap.theme return themeMap[props.type] || themeMap.theme
}) })
// 背景色 // 背景色
const bgColor = computed( const bgColor = computed(
() => () =>
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))` `color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))`
) )
const containerStyle = computed(() => ({ const containerStyle = computed(() => ({
width: props.width, width: props.width,
height: props.height, height: props.height,
backgroundColor: bgColor.value backgroundColor: bgColor.value
})) }))
const contentClass = computed(() => { const contentClass = computed(() => {
if (!isHorizontal.value) { if (!isHorizontal.value) {
return 'flex flex-col' return 'flex flex-col'
} }
return '' return ''
}) })
const contentStyle = computed(() => { const contentStyle = computed(() => {
const transform = isHorizontal.value const transform = isHorizontal.value
? `translateX(${currentPosition.value}px)` ? `translateX(${currentPosition.value}px)`
: `translateY(${currentPosition.value}px)` : `translateY(${currentPosition.value}px)`
return { return {
transform, transform,
willChange: 'transform' willChange: 'transform'
} }
}) })
// 克隆元素的间距 // 克隆元素的间距
const cloneSpacing = computed(() => { const cloneSpacing = computed(() => {
const spacing = '2em' const spacing = '2em'
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing } return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing }
}) })
const measureSizes = () => { const measureSizes = () => {
if (!containerRef.value || !textRef.value) return if (!containerRef.value || !textRef.value) return
const text = textRef.value const text = textRef.value
if (isHorizontal.value) { if (isHorizontal.value) {
containerSize.value = containerWidth.value containerSize.value = containerWidth.value
textSize.value = text.offsetWidth textSize.value = text.offsetWidth
} else { } else {
containerSize.value = containerHeight.value containerSize.value = containerHeight.value
textSize.value = text.offsetHeight textSize.value = text.offsetHeight
} }
const isOverflow = textSize.value > containerSize.value const isOverflow = textSize.value > containerSize.value
shouldClone.value = isOverflow shouldClone.value = isOverflow
// 居中显示 // 居中显示
currentPosition.value = (containerSize.value - textSize.value) / 2 currentPosition.value = (containerSize.value - textSize.value) / 2
// 测量完成后才显示内容 // 测量完成后才显示内容
if (!isReady.value) { if (!isReady.value) {
isReady.value = true isReady.value = true
} }
} }
// 使用 VueUse 的 useDebounceFn 防抖测量 // 使用 VueUse 的 useDebounceFn 防抖测量
const debouncedMeasure = useDebounceFn(measureSizes, 150) const debouncedMeasure = useDebounceFn(measureSizes, 150)
let lastTimestamp = 0 let lastTimestamp = 0
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame // 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
const { pause, resume } = useRafFn( const { pause, resume } = useRafFn(
({ timestamp }) => { ({ timestamp }) => {
if (!lastTimestamp) lastTimestamp = timestamp if (!lastTimestamp) lastTimestamp = timestamp
if (!isPaused.value) { if (!isPaused.value) {
const delta = (timestamp - lastTimestamp) / 1000 const delta = (timestamp - lastTimestamp) / 1000
const distance = props.speed * delta const distance = props.speed * delta
const spacing = textSize.value * 0.1 const spacing = textSize.value * 0.1
currentPosition.value += isReverse.value ? distance : -distance currentPosition.value += isReverse.value ? distance : -distance
// 循环边界检测 // 循环边界检测
if (isReverse.value) { if (isReverse.value) {
if (currentPosition.value > containerSize.value) { if (currentPosition.value > containerSize.value) {
currentPosition.value = -(textSize.value + spacing) currentPosition.value = -(textSize.value + spacing)
} }
} else { } else {
if (currentPosition.value < -(textSize.value + spacing)) { if (currentPosition.value < -(textSize.value + spacing)) {
currentPosition.value = containerSize.value currentPosition.value = containerSize.value
} }
} }
} }
lastTimestamp = timestamp lastTimestamp = timestamp
}, },
{ immediate: false } { immediate: false }
) )
const handleContentClick = (e: MouseEvent) => { const handleContentClick = (e: MouseEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement
if (target.tagName === 'A') { if (target.tagName === 'A') {
e.stopPropagation() e.stopPropagation()
} }
} }
// 监听容器尺寸变化 // 监听容器尺寸变化
watch([containerWidth, containerHeight], () => { watch([containerWidth, containerHeight], () => {
debouncedMeasure() debouncedMeasure()
}) })
// 监听属性变化 // 监听属性变化
watch( watch(
() => [props.direction, props.speed, props.text], () => [props.direction, props.speed, props.text],
() => { () => {
measureSizes() measureSizes()
lastTimestamp = 0 lastTimestamp = 0
} }
) )
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout // 使用 VueUse 的 useTimeoutFn 替代 setTimeout
const { start: startMeasure } = useTimeoutFn(() => { const { start: startMeasure } = useTimeoutFn(() => {
measureSizes() measureSizes()
// 测量完成后立即开始动画 // 测量完成后立即开始动画
resume() resume()
}, 100) }, 100)
onMounted(() => { onMounted(() => {
startMeasure() startMeasure()
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
pause() pause()
}) })
</script> </script>
+77 -77
View File
@@ -1,100 +1,100 @@
<!-- 一个让 SVG 图片跟随主题的组件只对特定 svg 图片生效不建议开发者使用 --> <!-- 一个让 SVG 图片跟随主题的组件只对特定 svg 图片生效不建议开发者使用 -->
<!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 --> <!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 -->
<template> <template>
<div class="theme-svg" :style="sizeStyle"> <div class="theme-svg" :style="sizeStyle">
<div v-if="src" class="svg-container" v-html="svgContent"></div> <div v-if="src" class="svg-container" v-html="svgContent"></div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watchEffect } from 'vue' import { ref, computed, watchEffect } from 'vue'
interface Props { interface Props {
size?: string | number size?: string | number
themeColor?: string themeColor?: string
src?: string src?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: 500, size: 500,
themeColor: 'var(--el-color-primary)' themeColor: 'var(--el-color-primary)'
}) })
const svgContent = ref('') const svgContent = ref('')
// 计算样式 // 计算样式
const sizeStyle = computed(() => { const sizeStyle = computed(() => {
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
return { return {
width: sizeValue, width: sizeValue,
height: sizeValue height: sizeValue
} }
}) })
// 颜色映射配置 // 颜色映射配置
const COLOR_MAPPINGS = { const COLOR_MAPPINGS = {
'#C7DEFF': 'var(--el-color-primary-light-6)', '#C7DEFF': 'var(--el-color-primary-light-6)',
'#071F4D': 'var(--el-color-primary-dark-2)', '#071F4D': 'var(--el-color-primary-dark-2)',
'#00E4E5': 'var(--el-color-primary-light-1)', '#00E4E5': 'var(--el-color-primary-light-1)',
'#006EFF': 'var(--el-color-primary)', '#006EFF': 'var(--el-color-primary)',
'#fff': 'var(--default-box-color)', '#fff': 'var(--default-box-color)',
'#ffffff': 'var(--default-box-color)', '#ffffff': 'var(--default-box-color)',
'#DEEBFC': 'var(--el-color-primary-light-7)' '#DEEBFC': 'var(--el-color-primary-light-7)'
} as const } as const
// 将主题色应用到 SVG 内容 // 将主题色应用到 SVG 内容
const applyThemeToSvg = (content: string): string => { const applyThemeToSvg = (content: string): string => {
return Object.entries(COLOR_MAPPINGS).reduce( return Object.entries(COLOR_MAPPINGS).reduce(
(processedContent, [originalColor, themeColor]) => { (processedContent, [originalColor, themeColor]) => {
const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi') const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi')
const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi') const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi')
return processedContent return processedContent
.replace(fillRegex, `fill="${themeColor}"`) .replace(fillRegex, `fill="${themeColor}"`)
.replace(strokeRegex, `stroke="${themeColor}"`) .replace(strokeRegex, `stroke="${themeColor}"`)
}, },
content content
) )
} }
// 加载 SVG 文件内容 // 加载 SVG 文件内容
const loadSvgContent = async () => { const loadSvgContent = async () => {
if (!props.src) { if (!props.src) {
svgContent.value = '' svgContent.value = ''
return return
} }
try { try {
const response = await fetch(props.src) const response = await fetch(props.src)
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`) throw new Error(`HTTP error! status: ${response.status}`)
} }
const content = await response.text() const content = await response.text()
svgContent.value = applyThemeToSvg(content) svgContent.value = applyThemeToSvg(content)
} catch (error) { } catch (error) {
console.error('Failed to load SVG:', error) console.error('Failed to load SVG:', error)
svgContent.value = '' svgContent.value = ''
} }
} }
watchEffect(() => { watchEffect(() => {
loadSvgContent() loadSvgContent()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.theme-svg { .theme-svg {
display: inline-block; display: inline-block;
.svg-container { .svg-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
:deep(svg) { :deep(svg) {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
} }
} }
</style> </style>

Some files were not shown because too many files have changed in this diff Show More