Compare commits

..
2 Commits
Author SHA1 Message Date
molong 2f5ee49594 更新 2026-01-10 10:13:25 +08:00
molong a0afedf5f3 格式化文档 2026-01-10 10:10:57 +08:00
224 changed files with 27336 additions and 26996 deletions

No files matched your search

+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.ts"></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"
}
} }
+25 -25
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 lang="ts">
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>
+13 -13
View File
@@ -6,12 +6,12 @@ import request from '@/utils/http'
* @returns 登录响应 * @returns 登录响应
*/ */
export function fetchLogin(params: Api.Auth.LoginParams) { export function fetchLogin(params: Api.Auth.LoginParams) {
return request.post<Api.Auth.LoginResponse>({ return request.post<Api.Auth.LoginResponse>({
url: '/api/auth/login', url: '/api/auth/login',
params params
// showSuccessMessage: true // 显示成功消息 // showSuccessMessage: true // 显示成功消息
// showErrorMessage: false // 不显示错误消息 // showErrorMessage: false // 不显示错误消息
}) })
} }
/** /**
@@ -19,11 +19,11 @@ export function fetchLogin(params: Api.Auth.LoginParams) {
* @returns 用户信息 * @returns 用户信息
*/ */
export function fetchGetUserInfo() { export function fetchGetUserInfo() {
return request.get<Api.Auth.UserInfo>({ return request.get<Api.Auth.UserInfo>({
url: '/api/user/info' url: '/api/user/info'
// 自定义请求头 // 自定义请求头
// headers: { // headers: {
// 'X-Custom-Header': 'your-custom-value' // 'X-Custom-Header': 'your-custom-value'
// } // }
}) })
} }
+11 -11
View File
@@ -3,23 +3,23 @@ import { AppRouteRecord } from '@/types/router'
// 获取用户列表 // 获取用户列表
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) { export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
return request.get<Api.SystemManage.UserList>({ return request.get<Api.SystemManage.UserList>({
url: '/api/user/list', url: '/api/user/list',
params params
}) })
} }
// 获取角色列表 // 获取角色列表
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) { export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
return request.get<Api.SystemManage.RoleList>({ return request.get<Api.SystemManage.RoleList>({
url: '/api/role/list', url: '/api/role/list',
params params
}) })
} }
// 获取菜单列表 // 获取菜单列表
export function fetchGetMenuList() { export function fetchGetMenuList() {
return request.get<AppRouteRecord[]>({ return request.get<AppRouteRecord[]>({
url: '/api/v3/system/menus/simple' 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 {
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0 // 如果找不到表格头部,设置为 undefined,useElementSize 会返回 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>
@@ -1,43 +1,43 @@
<template> <template>
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc"> <div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
<div class="flex-cc max-md:!block max-md:text-center"> <div class="flex-cc max-md:!block max-md:text-center">
<ThemeSvg :src="data.imgUrl" size="100%" class="!w-100" /> <ThemeSvg :src="data.imgUrl" size="100%" class="!w-100" />
<div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center"> <div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center">
<p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p> <p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p>
<ElButton type="primary" size="large" @click="backHome" v-ripple class="mt-5">{{ <ElButton type="primary" size="large" @click="backHome" v-ripple class="mt-5">{{
data.btnText data.btnText
}}</ElButton> }}</ElButton>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCommon } from '@/hooks/core/useCommon' import { useCommon } from '@/hooks/core/useCommon'
const router = useRouter() const router = useRouter()
interface ExceptionData { interface ExceptionData {
/** 标题 */ /** 标题 */
title: string title: string
/** 描述 */ /** 描述 */
desc: string desc: string
/** 按钮文本 */ /** 按钮文本 */
btnText: string btnText: string
/** 图片地址 */ /** 图片地址 */
imgUrl: string imgUrl: string
} }
withDefaults( withDefaults(
defineProps<{ defineProps<{
data: ExceptionData data: ExceptionData
}>(), }>(),
{} {}
) )
const { homePath } = useCommon() const { homePath } = useCommon()
const backHome = () => { const backHome = () => {
router.push(homePath.value) router.push(homePath.value)
} }
</script> </script>
+140 -128
View File
@@ -1,149 +1,161 @@
<!-- 授权页右上角组件 --> <!-- 授权页右上角组件 -->
<template> <template>
<div <div
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between" class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
> >
<div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6"> <div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
<ArtLogo class="icon" size="46" /> <ArtLogo class="icon" size="46" />
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1> <h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
</div> </div>
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5"> <div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
<div class="color-picker-expandable relative flex-c max-sm:!hidden"> <div class="color-picker-expandable relative flex-c max-sm:!hidden">
<div <div
class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0" class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
> >
<div <div
v-for="(color, index) in mainColors" v-for="(color, index) in mainColors"
:key="color" :key="color"
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0" class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
:class="{ active: color === systemThemeColor }" :class="{ active: color === systemThemeColor }"
:style="{ background: color, '--index': index }" :style="{ background: color, '--index': index }"
@click="changeThemeColor(color)" @click="changeThemeColor(color)"
> >
<ArtSvgIcon v-if="color === systemThemeColor" icon="ri:check-fill" class="text-white" /> <ArtSvgIcon
</div> v-if="color === systemThemeColor"
</div> icon="ri:check-fill"
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300"> class="text-white"
<ArtSvgIcon />
icon="ri:palette-line" </div>
class="text-xl text-g-800 transition-colors duration-300" </div>
/> <div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
</div> <ArtSvgIcon
</div> icon="ri:palette-line"
<ElDropdown class="text-xl text-g-800 transition-colors duration-300"
v-if="shouldShowLanguage" />
@command="changeLanguage" </div>
popper-class="langDropDownStyle" </div>
> <ElDropdown
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300"> v-if="shouldShowLanguage"
<ArtSvgIcon @command="changeLanguage"
icon="ri:translate-2" popper-class="langDropDownStyle"
class="text-[19px] text-g-800 transition-colors duration-300" >
/> <div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
</div> <ArtSvgIcon
<template #dropdown> icon="ri:translate-2"
<ElDropdownMenu> class="text-[19px] text-g-800 transition-colors duration-300"
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item"> />
<ElDropdownItem </div>
:command="lang.value" <template #dropdown>
:class="{ 'is-selected': locale === lang.value }" <ElDropdownMenu>
> <div
<span class="menu-txt">{{ lang.label }}</span> v-for="lang in languageOptions"
<ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" /> :key="lang.value"
</ElDropdownItem> class="lang-btn-item"
</div> >
</ElDropdownMenu> <ElDropdownItem
</template> :command="lang.value"
</ElDropdown> :class="{ 'is-selected': locale === lang.value }"
<div >
v-if="shouldShowThemeToggle" <span class="menu-txt">{{ lang.label }}</span>
class="btn theme-btn h-8 w-8 c-p flex-cc tad-300" <ArtSvgIcon
@click="themeAnimation" icon="ri:check-fill"
> class="text-base"
<ArtSvgIcon v-if="locale === lang.value"
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'" />
class="text-xl text-g-800 transition-colors duration-300" </ElDropdownItem>
/> </div>
</div> </ElDropdownMenu>
</div> </template>
</div> </ElDropdown>
<div
v-if="shouldShowThemeToggle"
class="btn theme-btn h-8 w-8 c-p flex-cc tad-300"
@click="themeAnimation"
>
<ArtSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
</div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
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 { useHeaderBar } from '@/hooks/core/useHeaderBar' import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import { themeAnimation } from '@/utils/ui/animation' import { themeAnimation } from '@/utils/ui/animation'
import { languageOptions } from '@/locales' import { languageOptions } from '@/locales'
import { LanguageEnum } from '@/enums/appEnum' import { LanguageEnum } from '@/enums/appEnum'
import AppConfig from '@/config' import AppConfig from '@/config'
defineOptions({ name: 'AuthTopBar' }) defineOptions({ name: 'AuthTopBar' })
const settingStore = useSettingStore() const settingStore = useSettingStore()
const userStore = useUserStore() const userStore = useUserStore()
const { isDark, systemThemeColor } = storeToRefs(settingStore) const { isDark, systemThemeColor } = storeToRefs(settingStore)
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar() const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
const { locale } = useI18n() const { locale } = useI18n()
const mainColors = AppConfig.systemMainColor const mainColors = AppConfig.systemMainColor
const color = systemThemeColor // css v-bind 使用 const color = systemThemeColor // css v-bind 使用
const changeLanguage = (lang: LanguageEnum) => { const changeLanguage = (lang: LanguageEnum) => {
if (locale.value === lang) return if (locale.value === lang) return
locale.value = lang locale.value = lang
userStore.setLanguage(lang) userStore.setLanguage(lang)
} }
const changeThemeColor = (color: string) => { const changeThemeColor = (color: string) => {
if (systemThemeColor.value === color) return if (systemThemeColor.value === color) return
settingStore.setElementTheme(color) settingStore.setElementTheme(color)
settingStore.reload() settingStore.reload()
} }
</script> </script>
<style scoped> <style scoped>
.color-dots { .color-dots {
pointer-events: none; pointer-events: none;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
box-shadow: 0 2px 12px var(--art-gray-300); box-shadow: 0 2px 12px var(--art-gray-300);
transition: transition:
opacity 0.3s ease, opacity 0.3s ease,
transform 0.3s ease; transform 0.3s ease;
transform: translateX(10px); transform: translateX(10px);
} }
.color-dot { .color-dot {
box-shadow: 0 2px 4px rgb(0 0 0 / 15%); box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: calc(var(--index) * 0.05s); transition-delay: calc(var(--index) * 0.05s);
transform: translateX(20px) scale(0.8); transform: translateX(20px) scale(0.8);
} }
.color-dot:hover { .color-dot:hover {
box-shadow: 0 4px 8px rgb(0 0 0 / 20%); box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
transform: translateX(0) scale(1.1); transform: translateX(0) scale(1.1);
} }
.color-picker-expandable:hover .color-dots { .color-picker-expandable:hover .color-dots {
pointer-events: auto; pointer-events: auto;
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
} }
.color-picker-expandable:hover .color-dot { .color-picker-expandable:hover .color-dot {
opacity: 1; opacity: 1;
transform: translateX(0) scale(1); transform: translateX(0) scale(1);
} }
.dark .color-dots { .dark .color-dots {
background-color: var(--art-gray-200); background-color: var(--art-gray-200);
box-shadow: none; box-shadow: none;
} }
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) { .color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
color: v-bind(color); color: v-bind(color);
} }
</style> </style>
File diff suppressed because it is too large. Load diff
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff. Show more