Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f5ee49594 | |||
| a0afedf5f3 |
+18
-18
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSpacing": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"insertPragma": false,
|
||||
"requirePragma": false,
|
||||
"proseWrap": "never",
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"endOfLine": "auto",
|
||||
"rangeStart": 0
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"useTabs": true,
|
||||
"semi": false,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSpacing": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"insertPragma": false,
|
||||
"requirePragma": false,
|
||||
"proseWrap": "never",
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"endOfLine": "auto",
|
||||
"rangeStart": 0
|
||||
}
|
||||
|
||||
+80
-80
@@ -1,82 +1,82 @@
|
||||
module.exports = {
|
||||
// 继承推荐规范配置
|
||||
extends: [
|
||||
'stylelint-config-standard',
|
||||
'stylelint-config-recommended-scss',
|
||||
'stylelint-config-recommended-vue/scss',
|
||||
'stylelint-config-html/vue',
|
||||
'stylelint-config-recess-order'
|
||||
],
|
||||
// 指定不同文件对应的解析器
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.{vue,html}'],
|
||||
customSyntax: 'postcss-html'
|
||||
},
|
||||
{
|
||||
files: ['**/*.{css,scss}'],
|
||||
customSyntax: 'postcss-scss'
|
||||
}
|
||||
],
|
||||
// 自定义规则
|
||||
rules: {
|
||||
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
|
||||
'selector-class-pattern': null, // 选择器类名命名规则
|
||||
'custom-property-pattern': null, // 自定义属性命名规则
|
||||
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
|
||||
'no-descending-specificity': null, // 允许无降序特异性
|
||||
'no-empty-source': null, // 允许空样式
|
||||
'property-no-vendor-prefix': null, // 允许属性前缀
|
||||
// 允许 global 、export 、deep伪类
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['global', 'export', 'deep']
|
||||
}
|
||||
],
|
||||
// 允许未知属性
|
||||
'property-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: []
|
||||
}
|
||||
],
|
||||
// 允许未知规则
|
||||
'at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: [
|
||||
'apply',
|
||||
'use',
|
||||
'mixin',
|
||||
'include',
|
||||
'extend',
|
||||
'each',
|
||||
'if',
|
||||
'else',
|
||||
'for',
|
||||
'while',
|
||||
'reference'
|
||||
]
|
||||
}
|
||||
],
|
||||
'scss/at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: [
|
||||
'apply',
|
||||
'use',
|
||||
'mixin',
|
||||
'include',
|
||||
'extend',
|
||||
'each',
|
||||
'if',
|
||||
'else',
|
||||
'for',
|
||||
'while',
|
||||
'reference'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
// 继承推荐规范配置
|
||||
extends: [
|
||||
'stylelint-config-standard',
|
||||
'stylelint-config-recommended-scss',
|
||||
'stylelint-config-recommended-vue/scss',
|
||||
'stylelint-config-html/vue',
|
||||
'stylelint-config-recess-order'
|
||||
],
|
||||
// 指定不同文件对应的解析器
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.{vue,html}'],
|
||||
customSyntax: 'postcss-html'
|
||||
},
|
||||
{
|
||||
files: ['**/*.{css,scss}'],
|
||||
customSyntax: 'postcss-scss'
|
||||
}
|
||||
],
|
||||
// 自定义规则
|
||||
rules: {
|
||||
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
|
||||
'selector-class-pattern': null, // 选择器类名命名规则
|
||||
'custom-property-pattern': null, // 自定义属性命名规则
|
||||
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
|
||||
'no-descending-specificity': null, // 允许无降序特异性
|
||||
'no-empty-source': null, // 允许空样式
|
||||
'property-no-vendor-prefix': null, // 允许属性前缀
|
||||
// 允许 global 、export 、deep伪类
|
||||
'selector-pseudo-class-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignorePseudoClasses: ['global', 'export', 'deep']
|
||||
}
|
||||
],
|
||||
// 允许未知属性
|
||||
'property-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreProperties: []
|
||||
}
|
||||
],
|
||||
// 允许未知规则
|
||||
'at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: [
|
||||
'apply',
|
||||
'use',
|
||||
'mixin',
|
||||
'include',
|
||||
'extend',
|
||||
'each',
|
||||
'if',
|
||||
'else',
|
||||
'for',
|
||||
'while',
|
||||
'reference'
|
||||
]
|
||||
}
|
||||
],
|
||||
'scss/at-rule-no-unknown': [
|
||||
true,
|
||||
{
|
||||
ignoreAtRules: [
|
||||
'apply',
|
||||
'use',
|
||||
'mixin',
|
||||
'include',
|
||||
'extend',
|
||||
'each',
|
||||
'if',
|
||||
'else',
|
||||
'for',
|
||||
'while',
|
||||
'reference'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+39
-39
@@ -1,47 +1,47 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Art Design Pro</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
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" />
|
||||
<head>
|
||||
<title>Art Design Pro</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
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" />
|
||||
|
||||
<style>
|
||||
/* 防止页面刷新时白屏的初始样式 */
|
||||
html {
|
||||
background-color: #fafbfc;
|
||||
}
|
||||
<style>
|
||||
/* 防止页面刷新时白屏的初始样式 */
|
||||
html {
|
||||
background-color: #fafbfc;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
background-color: #070707;
|
||||
}
|
||||
</style>
|
||||
html.dark {
|
||||
background-color: #070707;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 初始化 html class 主题属性
|
||||
;(function () {
|
||||
try {
|
||||
if (typeof Storage === 'undefined' || !window.localStorage) {
|
||||
return
|
||||
}
|
||||
<script>
|
||||
// 初始化 html class 主题属性
|
||||
;(function () {
|
||||
try {
|
||||
if (typeof Storage === 'undefined' || !window.localStorage) {
|
||||
return
|
||||
}
|
||||
|
||||
const themeType = localStorage.getItem('sys-theme')
|
||||
if (themeType === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to apply initial theme:', e)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
const themeType = localStorage.getItem('sys-theme')
|
||||
if (themeType === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to apply initial theme:', e)
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+106
-116
@@ -1,118 +1,108 @@
|
||||
{
|
||||
"name": "art-design-pro",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.19.0",
|
||||
"pnpm": ">=8.8.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --open",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint",
|
||||
"fix": "eslint --fix",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
||||
"lint:lint-staged": "lint-staged",
|
||||
"prepare": "husky",
|
||||
"commit": "git-cz",
|
||||
"clean:dev": "tsx scripts/clean-dev.ts"
|
||||
},
|
||||
"config": {
|
||||
"commitizen": {
|
||||
"path": "node_modules/cz-git"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,mjs,mts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{cjs,json,jsonc}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.vue": [
|
||||
"eslint --fix",
|
||||
"stylelint --fix --allow-empty-input",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{html,htm}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{scss,css,less}": [
|
||||
"stylelint --fix --allow-empty-input",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{md,mdx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{yaml,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vue/reactivity": "^3.5.21",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "next",
|
||||
"axios": "^1.12.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.11.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"ohash": "^2.0.11",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.3.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"vue": "^3.5.21",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"vue-router": "^4.5.1",
|
||||
"xgplayer": "^3.0.20",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/node": "^24.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/compiler-sfc": "^3.0.5",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"globals": "^15.9.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.81.0",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recess-order": "^4.6.0",
|
||||
"stylelint-config-recommended-scss": "^14.1.0",
|
||||
"stylelint-config-recommended-vue": "^1.5.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"terser": "^5.36.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.6.3",
|
||||
"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"
|
||||
}
|
||||
"name": "art-design-pro",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.19.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite --open",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"serve": "vite preview",
|
||||
"lint": "eslint",
|
||||
"fix": "eslint --fix",
|
||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,mjs,mts,tsx}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{cjs,json,jsonc}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.vue": [
|
||||
"eslint --fix",
|
||||
"stylelint --fix --allow-empty-input",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{html,htm}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{scss,css,less}": [
|
||||
"stylelint --fix --allow-empty-input",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{md,mdx}": [
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{yaml,yml}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"@iconify/vue": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vue/reactivity": "^3.5.21",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "next",
|
||||
"axios": "^1.12.2",
|
||||
"crypto-js": "^4.2.0",
|
||||
"echarts": "^6.0.0",
|
||||
"element-plus": "^2.11.2",
|
||||
"file-saver": "^2.0.5",
|
||||
"highlight.js": "^11.10.0",
|
||||
"mitt": "^3.0.1",
|
||||
"nprogress": "^0.2.0",
|
||||
"ohash": "^2.0.11",
|
||||
"pinia": "^3.0.3",
|
||||
"pinia-plugin-persistedstate": "^4.3.0",
|
||||
"qrcode.vue": "^3.6.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"vue": "^3.5.21",
|
||||
"vue-draggable-plus": "^0.6.0",
|
||||
"vue-i18n": "^9.14.0",
|
||||
"vue-router": "^4.5.1",
|
||||
"xgplayer": "^3.0.20",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.1",
|
||||
"@types/node": "^24.0.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||
"@typescript-eslint/parser": "^8.3.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/compiler-sfc": "^3.0.5",
|
||||
"eslint": "^9.9.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-vue": "^9.27.0",
|
||||
"globals": "^15.9.0",
|
||||
"lint-staged": "^15.5.2",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup-plugin-visualizer": "^5.12.0",
|
||||
"sass": "^1.81.0",
|
||||
"stylelint": "^16.20.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-recess-order": "^4.6.0",
|
||||
"stylelint-config-recommended-scss": "^14.1.0",
|
||||
"stylelint-config-recommended-vue": "^1.5.0",
|
||||
"stylelint-config-standard": "^36.0.1",
|
||||
"terser": "^5.36.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "~5.6.3",
|
||||
"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
@@ -1,34 +1,34 @@
|
||||
<template>
|
||||
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
||||
<RouterView></RouterView>
|
||||
</ElConfigProvider>
|
||||
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
||||
<RouterView></RouterView>
|
||||
</ElConfigProvider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useUserStore } from './store/modules/user'
|
||||
import zh from 'element-plus/es/locale/lang/zh-cn'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
import { systemUpgrade } from './utils/sys'
|
||||
import { toggleTransition } from './utils/ui/animation'
|
||||
import { checkStorageCompatibility } from './utils/storage'
|
||||
import { initializeTheme } from './hooks/core/useTheme'
|
||||
import { useUserStore } from './store/modules/user'
|
||||
import zh from 'element-plus/es/locale/lang/zh-cn'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
import { systemUpgrade } from './utils/sys'
|
||||
import { toggleTransition } from './utils/ui/animation'
|
||||
import { checkStorageCompatibility } from './utils/storage'
|
||||
import { initializeTheme } from './hooks/core/useTheme'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { language } = storeToRefs(userStore)
|
||||
const userStore = useUserStore()
|
||||
const { language } = storeToRefs(userStore)
|
||||
|
||||
const locales = {
|
||||
zh: zh,
|
||||
en: en
|
||||
}
|
||||
const locales = {
|
||||
zh: zh,
|
||||
en: en
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
toggleTransition(true)
|
||||
initializeTheme()
|
||||
})
|
||||
onBeforeMount(() => {
|
||||
toggleTransition(true)
|
||||
initializeTheme()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
checkStorageCompatibility()
|
||||
toggleTransition(false)
|
||||
systemUpgrade()
|
||||
})
|
||||
onMounted(() => {
|
||||
checkStorageCompatibility()
|
||||
toggleTransition(false)
|
||||
systemUpgrade()
|
||||
})
|
||||
</script>
|
||||
|
||||
+13
-13
@@ -6,12 +6,12 @@ import request from '@/utils/http'
|
||||
* @returns 登录响应
|
||||
*/
|
||||
export function fetchLogin(params: Api.Auth.LoginParams) {
|
||||
return request.post<Api.Auth.LoginResponse>({
|
||||
url: '/api/auth/login',
|
||||
params
|
||||
// showSuccessMessage: true // 显示成功消息
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
return request.post<Api.Auth.LoginResponse>({
|
||||
url: '/api/auth/login',
|
||||
params
|
||||
// showSuccessMessage: true // 显示成功消息
|
||||
// showErrorMessage: false // 不显示错误消息
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,11 +19,11 @@ export function fetchLogin(params: Api.Auth.LoginParams) {
|
||||
* @returns 用户信息
|
||||
*/
|
||||
export function fetchGetUserInfo() {
|
||||
return request.get<Api.Auth.UserInfo>({
|
||||
url: '/api/user/info'
|
||||
// 自定义请求头
|
||||
// headers: {
|
||||
// 'X-Custom-Header': 'your-custom-value'
|
||||
// }
|
||||
})
|
||||
return request.get<Api.Auth.UserInfo>({
|
||||
url: '/api/user/info'
|
||||
// 自定义请求头
|
||||
// headers: {
|
||||
// 'X-Custom-Header': 'your-custom-value'
|
||||
// }
|
||||
})
|
||||
}
|
||||
|
||||
+11
-11
@@ -3,23 +3,23 @@ import { AppRouteRecord } from '@/types/router'
|
||||
|
||||
// 获取用户列表
|
||||
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
|
||||
return request.get<Api.SystemManage.UserList>({
|
||||
url: '/api/user/list',
|
||||
params
|
||||
})
|
||||
return request.get<Api.SystemManage.UserList>({
|
||||
url: '/api/user/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取角色列表
|
||||
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
|
||||
return request.get<Api.SystemManage.RoleList>({
|
||||
url: '/api/role/list',
|
||||
params
|
||||
})
|
||||
return request.get<Api.SystemManage.RoleList>({
|
||||
url: '/api/role/list',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// 获取菜单列表
|
||||
export function fetchGetMenuList() {
|
||||
return request.get<AppRouteRecord[]>({
|
||||
url: '/api/v3/system/menus/simple'
|
||||
})
|
||||
return request.get<AppRouteRecord[]>({
|
||||
url: '/api/v3/system/menus/simple'
|
||||
})
|
||||
}
|
||||
|
||||
+188
-188
@@ -1,292 +1,292 @@
|
||||
// 全局样式
|
||||
// 顶部进度条颜色
|
||||
#nprogress .bar {
|
||||
z-index: 2400;
|
||||
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
|
||||
z-index: 2400;
|
||||
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
|
||||
}
|
||||
|
||||
#nprogress .peg {
|
||||
box-shadow:
|
||||
0 0 10px var(--theme-color),
|
||||
0 0 5px var(--theme-color) !important;
|
||||
box-shadow:
|
||||
0 0 10px var(--theme-color),
|
||||
0 0 5px var(--theme-color) !important;
|
||||
}
|
||||
|
||||
#nprogress .spinner-icon {
|
||||
border-top-color: var(--theme-color) !important;
|
||||
border-left-color: var(--theme-color) !important;
|
||||
border-top-color: var(--theme-color) !important;
|
||||
border-left-color: var(--theme-color) !important;
|
||||
}
|
||||
|
||||
// 处理移动端组件兼容性
|
||||
@media screen and (max-width: 640px) {
|
||||
* {
|
||||
cursor: default !important;
|
||||
}
|
||||
* {
|
||||
cursor: default !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 背景滤镜
|
||||
*,
|
||||
::before,
|
||||
::after {
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
--tw-backdrop-blur: ;
|
||||
--tw-backdrop-brightness: ;
|
||||
--tw-backdrop-contrast: ;
|
||||
--tw-backdrop-grayscale: ;
|
||||
--tw-backdrop-hue-rotate: ;
|
||||
--tw-backdrop-invert: ;
|
||||
--tw-backdrop-opacity: ;
|
||||
--tw-backdrop-saturate: ;
|
||||
--tw-backdrop-sepia: ;
|
||||
}
|
||||
|
||||
// 色弱模式
|
||||
.color-weak {
|
||||
filter: invert(80%);
|
||||
-webkit-filter: invert(80%);
|
||||
filter: invert(80%);
|
||||
-webkit-filter: invert(80%);
|
||||
}
|
||||
|
||||
#noop {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
// 语言切换选中样式
|
||||
.langDropDownStyle {
|
||||
// 选中项背景颜色
|
||||
.is-selected {
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
}
|
||||
// 选中项背景颜色
|
||||
.is-selected {
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
}
|
||||
|
||||
// 语言切换按钮菜单样式优化
|
||||
.lang-btn-item {
|
||||
.el-dropdown-menu__item {
|
||||
padding-left: 13px !important;
|
||||
padding-right: 6px !important;
|
||||
margin-bottom: 3px !important;
|
||||
}
|
||||
// 语言切换按钮菜单样式优化
|
||||
.lang-btn-item {
|
||||
.el-dropdown-menu__item {
|
||||
padding-left: 13px !important;
|
||||
padding-right: 6px !important;
|
||||
margin-bottom: 3px !important;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
.el-dropdown-menu__item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
&:last-child {
|
||||
.el-dropdown-menu__item {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-txt {
|
||||
min-width: 60px;
|
||||
display: block;
|
||||
}
|
||||
.menu-txt {
|
||||
min-width: 60px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
i {
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 盒子默认边框
|
||||
.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) {
|
||||
background: var(--default-box-color);
|
||||
border: 1px solid #{$border-color} !important;
|
||||
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
|
||||
box-shadow: #{$shadow} !important;
|
||||
background: var(--default-box-color);
|
||||
border: 1px solid #{$border-color} !important;
|
||||
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !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-sm,
|
||||
.art-card-xs {
|
||||
border: 1px solid var(--art-card-border);
|
||||
border: 1px solid var(--art-card-border);
|
||||
}
|
||||
|
||||
// 盒子边框
|
||||
[data-box-mode='border-mode'] {
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
border: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
border: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@include art-card-base(var(--art-card-border), none, 4px);
|
||||
}
|
||||
.art-card {
|
||||
@include art-card-base(var(--art-card-border), none, 4px);
|
||||
}
|
||||
|
||||
.art-card-sm {
|
||||
@include art-card-base(var(--art-card-border), none, 0px);
|
||||
}
|
||||
.art-card-sm {
|
||||
@include art-card-base(var(--art-card-border), none, 0px);
|
||||
}
|
||||
|
||||
.art-card-xs {
|
||||
@include art-card-base(var(--art-card-border), none, -4px);
|
||||
}
|
||||
.art-card-xs {
|
||||
@include art-card-base(var(--art-card-border), none, -4px);
|
||||
}
|
||||
}
|
||||
|
||||
// 盒子阴影
|
||||
[data-box-mode='shadow-mode'] {
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid var(--art-gray-200) !important;
|
||||
}
|
||||
.page-content,
|
||||
.art-table-card {
|
||||
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
|
||||
border: 1px solid var(--art-gray-200) !important;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
}
|
||||
|
||||
.art-card {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
4px
|
||||
);
|
||||
}
|
||||
.art-card {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
4px
|
||||
);
|
||||
}
|
||||
|
||||
.art-card-sm {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
2px
|
||||
);
|
||||
}
|
||||
.art-card-sm {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||
2px
|
||||
);
|
||||
}
|
||||
|
||||
.art-card-xs {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
|
||||
-4px
|
||||
);
|
||||
}
|
||||
.art-card-xs {
|
||||
@include art-card-base(
|
||||
var(--art-gray-200),
|
||||
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
|
||||
-4px
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 元素全屏
|
||||
.el-full-screen {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100vw !important;
|
||||
height: 100% !important;
|
||||
z-index: 2300;
|
||||
margin-top: 0;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--default-box-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100vw !important;
|
||||
height: 100% !important;
|
||||
z-index: 2300;
|
||||
margin-top: 0;
|
||||
padding: 15px;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--default-box-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
// 表格卡片
|
||||
.art-table-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 12px;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 12px;
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
|
||||
.el-card__body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-card__body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// 容器全高
|
||||
.art-full-height {
|
||||
height: var(--art-full-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--art-full-height);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 640px) {
|
||||
height: auto;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 徽章样式
|
||||
.art-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto;
|
||||
background: #ff3860;
|
||||
border-radius: 50%;
|
||||
animation: breathe 1.5s ease-in-out infinite;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 20px;
|
||||
bottom: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin: auto;
|
||||
background: #ff3860;
|
||||
border-radius: 50%;
|
||||
animation: breathe 1.5s ease-in-out infinite;
|
||||
|
||||
&.art-badge-horizontal {
|
||||
right: 0;
|
||||
}
|
||||
&.art-badge-horizontal {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.art-badge-mixed {
|
||||
right: 0;
|
||||
}
|
||||
&.art-badge-mixed {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&.art-badge-dual {
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
bottom: auto;
|
||||
}
|
||||
&.art-badge-dual {
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// 文字徽章样式
|
||||
.art-text-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
line-height: 17px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #fd4e4e;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 12px;
|
||||
bottom: 0;
|
||||
min-width: 20px;
|
||||
height: 18px;
|
||||
line-height: 17px;
|
||||
padding: 0 5px;
|
||||
margin: auto;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
background: #fd4e4e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 修复老机型 loading 定位问题
|
||||
.art-loading-fix {
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
position: fixed !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.art-loading-fix .el-loading-spinner {
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
position: static !important;
|
||||
top: auto !important;
|
||||
left: auto !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
// 去除移动端点击背景色
|
||||
@media screen and (max-width: 1180px) {
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,87 +7,87 @@ $font-color: rgba(#ffffff, 0.85);
|
||||
|
||||
/* 覆盖element-plus默认深色背景色 */
|
||||
html.dark {
|
||||
// element-plus
|
||||
--el-bg-color: var(--default-box-color);
|
||||
--el-text-color-regular: #{$font-color};
|
||||
// element-plus
|
||||
--el-bg-color: var(--default-box-color);
|
||||
--el-text-color-regular: #{$font-color};
|
||||
|
||||
// 富文本编辑器
|
||||
// 工具栏背景颜色
|
||||
--w-e-toolbar-bg-color: #18191c;
|
||||
// 输入区域背景颜色
|
||||
--w-e-textarea-bg-color: #090909;
|
||||
// 工具栏文字颜色
|
||||
--w-e-toolbar-color: var(--art-gray-600);
|
||||
// 选中菜单颜色
|
||||
--w-e-toolbar-active-bg-color: #25262b;
|
||||
// 弹窗边框颜色
|
||||
--w-e-toolbar-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-textarea-slight-bg-color: #090909;
|
||||
// 按钮背景颜色
|
||||
--w-e-modal-button-bg-color: #090909;
|
||||
// hover toolbar 背景颜色
|
||||
--w-e-toolbar-active-color: var(--art-gray-800);
|
||||
// 富文本编辑器
|
||||
// 工具栏背景颜色
|
||||
--w-e-toolbar-bg-color: #18191c;
|
||||
// 输入区域背景颜色
|
||||
--w-e-textarea-bg-color: #090909;
|
||||
// 工具栏文字颜色
|
||||
--w-e-toolbar-color: var(--art-gray-600);
|
||||
// 选中菜单颜色
|
||||
--w-e-toolbar-active-bg-color: #25262b;
|
||||
// 弹窗边框颜色
|
||||
--w-e-toolbar-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-textarea-slight-bg-color: #090909;
|
||||
// 按钮背景颜色
|
||||
--w-e-modal-button-bg-color: #090909;
|
||||
// hover toolbar 背景颜色
|
||||
--w-e-toolbar-active-color: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.dark {
|
||||
.page-content .article-list .item .left .outer > div {
|
||||
border-right-color: var(--dark-border-color) !important;
|
||||
}
|
||||
.page-content .article-list .item .left .outer > div {
|
||||
border-right-color: var(--dark-border-color) !important;
|
||||
}
|
||||
|
||||
// 富文本编辑器
|
||||
.editor-wrapper {
|
||||
*:not(pre code *) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
// 分隔线
|
||||
.w-e-bar-divider {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
// 富文本编辑器
|
||||
.editor-wrapper {
|
||||
*:not(pre code *) {
|
||||
color: inherit !important;
|
||||
}
|
||||
}
|
||||
// 分隔线
|
||||
.w-e-bar-divider {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
|
||||
.w-e-select-list,
|
||||
.w-e-drop-panel,
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container,
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
border: 1px solid var(--default-border) !important;
|
||||
}
|
||||
.w-e-select-list,
|
||||
.w-e-drop-panel,
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container,
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
border: 1px solid var(--default-border) !important;
|
||||
}
|
||||
|
||||
// 下拉选择框
|
||||
.w-e-select-list {
|
||||
background-color: var(--default-box-color) !important;
|
||||
}
|
||||
// 下拉选择框
|
||||
.w-e-select-list {
|
||||
background-color: var(--default-box-color) !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover,
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover,
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: #090909 !important;
|
||||
}
|
||||
background-color: #090909 !important;
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
background-color: #25262b !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
background-color: #25262b !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
border-left: 4px solid var(--default-border-dashed) !important;
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
border-left: 4px solid var(--default-border-dashed) !important;
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
|
||||
.editor-wrapper {
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid var(--default-border-dashed) !important;
|
||||
}
|
||||
.editor-wrapper {
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid var(--default-border-dashed) !important;
|
||||
}
|
||||
|
||||
.w-e-modal {
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
}
|
||||
.w-e-modal {
|
||||
background-color: var(--art-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,33 @@
|
||||
// 自定义Element 亮色主题
|
||||
|
||||
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||
$colors: (
|
||||
'white': #ffffff,
|
||||
'black': #000000,
|
||||
'success': (
|
||||
'base': #13deb9
|
||||
),
|
||||
'warning': (
|
||||
'base': #ffae1f
|
||||
),
|
||||
'danger': (
|
||||
'base': #ff4d4f
|
||||
),
|
||||
'error': (
|
||||
'base': #fa896b
|
||||
)
|
||||
),
|
||||
$button: (
|
||||
'hover-bg-color': var(--el-color-primary-light-9),
|
||||
'hover-border-color': var(--el-color-primary),
|
||||
'border-color': var(--el-color-primary),
|
||||
'text-color': var(--el-color-primary)
|
||||
),
|
||||
$messagebox: (
|
||||
'border-radius': '12px'
|
||||
),
|
||||
$popover: (
|
||||
'padding': '14px',
|
||||
'border-radius': '10px'
|
||||
)
|
||||
$colors: (
|
||||
'white': #ffffff,
|
||||
'black': #000000,
|
||||
'success': (
|
||||
'base': #13deb9
|
||||
),
|
||||
'warning': (
|
||||
'base': #ffae1f
|
||||
),
|
||||
'danger': (
|
||||
'base': #ff4d4f
|
||||
),
|
||||
'error': (
|
||||
'base': #fa896b
|
||||
)
|
||||
),
|
||||
$button: (
|
||||
'hover-bg-color': var(--el-color-primary-light-9),
|
||||
'hover-border-color': var(--el-color-primary),
|
||||
'border-color': var(--el-color-primary),
|
||||
'text-color': var(--el-color-primary)
|
||||
),
|
||||
$messagebox: (
|
||||
'border-radius': '12px'
|
||||
),
|
||||
$popover: (
|
||||
'padding': '14px',
|
||||
'border-radius': '10px'
|
||||
)
|
||||
);
|
||||
|
||||
+286
-286
@@ -1,519 +1,519 @@
|
||||
// 优化 Element Plus 组件库默认样式
|
||||
|
||||
:root {
|
||||
// 系统主色
|
||||
--main-color: var(--el-color-primary);
|
||||
--el-color-white: white !important;
|
||||
--el-color-black: white !important;
|
||||
// 输入框边框颜色
|
||||
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
||||
// 按钮粗度
|
||||
--el-font-weight-primary: 400 !important;
|
||||
// 系统主色
|
||||
--main-color: var(--el-color-primary);
|
||||
--el-color-white: white !important;
|
||||
--el-color-black: white !important;
|
||||
// 输入框边框颜色
|
||||
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
||||
// 按钮粗度
|
||||
--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-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
--el-popover-border-radius: 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-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||
|
||||
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||
color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
// 优化 el-form-item 标签高度
|
||||
.el-form-item__label {
|
||||
height: var(--el-component-custom-height) !important;
|
||||
line-height: var(--el-component-custom-height) !important;
|
||||
height: var(--el-component-custom-height) !important;
|
||||
line-height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
|
||||
// 日期选择器
|
||||
.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 背景色跟系统背景色保持一致
|
||||
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--default {
|
||||
& {
|
||||
--el-pagination-button-width: 32px !important;
|
||||
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
& {
|
||||
--el-pagination-button-width: 32px !important;
|
||||
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
& {
|
||||
--el-pagination-button-width: 28px !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
& {
|
||||
--el-pagination-button-width: 28px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select--default .el-select__wrapper {
|
||||
min-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
.el-select--default .el-select__wrapper {
|
||||
min-height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
|
||||
.el-pagination__jump .el-input {
|
||||
height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
.el-pagination__jump .el-input {
|
||||
height: var(--el-pagination-button-width) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-pager li {
|
||||
padding: 0 10px !important;
|
||||
// border: 1px solid red !important;
|
||||
padding: 0 10px !important;
|
||||
// border: 1px solid red !important;
|
||||
}
|
||||
|
||||
// 优化菜单折叠展开动画(提升动画流畅度)
|
||||
.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 动画(提升鼠标跟手感)
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
transition: background-color 0s !important;
|
||||
transition: background-color 0s !important;
|
||||
}
|
||||
|
||||
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
|
||||
// 修改 el-button 高度
|
||||
.el-button--default {
|
||||
height: var(--el-component-custom-height) !important;
|
||||
height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
|
||||
// circle 按钮宽度优化
|
||||
.el-button--default.is-circle {
|
||||
width: var(--el-component-custom-height) !important;
|
||||
width: var(--el-component-custom-height) !important;
|
||||
}
|
||||
|
||||
// 修改 el-select 高度
|
||||
.el-select--default {
|
||||
.el-select__wrapper {
|
||||
min-height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
.el-select__wrapper {
|
||||
min-height: var(--el-component-custom-height) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-checkbox-button 高度
|
||||
.el-checkbox-button--default .el-checkbox-button__inner,
|
||||
// 修改 el-radio-button 高度
|
||||
.el-radio-button--default .el-radio-button__inner {
|
||||
padding: 10px 15px !important;
|
||||
padding: 10px 15px !important;
|
||||
}
|
||||
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
|
||||
|
||||
.el-pagination.is-background .btn-next,
|
||||
.el-pagination.is-background .btn-prev,
|
||||
.el-pagination.is-background .el-pager li {
|
||||
border-radius: 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.el-popover {
|
||||
min-width: 80px;
|
||||
border-radius: var(--el-border-radius-small) !important;
|
||||
min-width: 80px;
|
||||
border-radius: var(--el-border-radius-small) !important;
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
border-radius: 100px !important;
|
||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||
overflow: hidden;
|
||||
border-radius: 100px !important;
|
||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-dialog__header {
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
.el-dialog__title {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dialog__body {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.el-dialog.el-dialog-border {
|
||||
.el-dialog__body {
|
||||
// 上边框
|
||||
&::before,
|
||||
.el-dialog__body {
|
||||
// 上边框
|
||||
&::before,
|
||||
// 下边框
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
width: calc(100% + 32px);
|
||||
height: 1px;
|
||||
background-color: var(--art-gray-300);
|
||||
}
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -16px;
|
||||
width: calc(100% + 32px);
|
||||
height: 1px;
|
||||
background-color: var(--art-gray-300);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
}
|
||||
&::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// el-message 样式优化
|
||||
.el-message {
|
||||
background-color: var(--default-box-color) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
background-color: var(--default-box-color) !important;
|
||||
border: 0 !important;
|
||||
box-shadow:
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
|
||||
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
p {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-dropdown 样式
|
||||
.el-dropdown-menu {
|
||||
padding: 6px !important;
|
||||
border-radius: 10px !important;
|
||||
border: none !important;
|
||||
padding: 6px !important;
|
||||
border-radius: 10px !important;
|
||||
border: none !important;
|
||||
|
||||
.el-dropdown-menu__item {
|
||||
padding: 6px 16px !important;
|
||||
border-radius: 6px !important;
|
||||
.el-dropdown-menu__item {
|
||||
padding: 6px 16px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
}
|
||||
&:hover:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
}
|
||||
|
||||
&:focus:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
&:focus:not(.is-disabled) {
|
||||
color: var(--art-gray-900) !important;
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏 select、dropdown 的三角
|
||||
.el-select__popper,
|
||||
.el-dropdown__popper {
|
||||
margin-top: -6px !important;
|
||||
margin-top: -6px !important;
|
||||
|
||||
.el-popper__arrow {
|
||||
display: none;
|
||||
}
|
||||
.el-popper__arrow {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.el-dropdown-selfdefine:focus {
|
||||
outline: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// 处理移动端组件兼容性
|
||||
@media screen and (max-width: 640px) {
|
||||
.el-message-box,
|
||||
.el-dialog {
|
||||
width: calc(100% - 24px) !important;
|
||||
}
|
||||
.el-message-box,
|
||||
.el-dialog {
|
||||
width: calc(100% - 24px) !important;
|
||||
}
|
||||
|
||||
.el-date-picker.has-sidebar.has-time {
|
||||
width: calc(100% - 24px);
|
||||
left: 12px !important;
|
||||
}
|
||||
.el-date-picker.has-sidebar.has-time {
|
||||
width: calc(100% - 24px);
|
||||
left: 12px !important;
|
||||
}
|
||||
|
||||
.el-picker-panel *[slot='sidebar'],
|
||||
.el-picker-panel__sidebar {
|
||||
display: none;
|
||||
}
|
||||
.el-picker-panel *[slot='sidebar'],
|
||||
.el-picker-panel__sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
|
||||
.el-picker-panel__sidebar + .el-picker-panel__body {
|
||||
margin-left: 0;
|
||||
}
|
||||
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
|
||||
.el-picker-panel__sidebar + .el-picker-panel__body {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改el-button样式
|
||||
.el-button {
|
||||
&.el-button--text {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
&.el-button--text {
|
||||
background-color: transparent !important;
|
||||
padding: 0 !important;
|
||||
|
||||
span {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
span {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修改el-tag样式
|
||||
.el-tag {
|
||||
font-weight: 500;
|
||||
transition: all 0s !important;
|
||||
font-weight: 500;
|
||||
transition: all 0s !important;
|
||||
|
||||
&.el-tag--default {
|
||||
height: 26px !important;
|
||||
}
|
||||
&.el-tag--default {
|
||||
height: 26px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-checkbox-group {
|
||||
&.el-table-filter__checkbox-group label.el-checkbox {
|
||||
height: 17px !important;
|
||||
&.el-table-filter__checkbox-group label.el-checkbox {
|
||||
height: 17px !important;
|
||||
|
||||
.el-checkbox__label {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
.el-checkbox__label {
|
||||
font-weight: 400 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-radio--default {
|
||||
// 优化单选按钮大小
|
||||
.el-radio__input {
|
||||
.el-radio__inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
// 优化单选按钮大小
|
||||
.el-radio__input {
|
||||
.el-radio__inner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::after {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-checkbox {
|
||||
.el-checkbox__inner {
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
.el-checkbox__inner {
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 优化复选框样式
|
||||
.el-checkbox--default {
|
||||
.el-checkbox__inner {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
border-radius: 4px !important;
|
||||
.el-checkbox__inner {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
border-radius: 4px !important;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
height: 4px !important;
|
||||
top: 5px !important;
|
||||
background-color: #fff !important;
|
||||
transform: scale(0.6) !important;
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
height: 4px !important;
|
||||
top: 5px !important;
|
||||
background-color: #fff !important;
|
||||
transform: scale(0.6) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.is-checked {
|
||||
.el-checkbox__inner {
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
margin: auto;
|
||||
border: 2px solid var(--el-checkbox-checked-icon-color);
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.is-checked {
|
||||
.el-checkbox__inner {
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 8px;
|
||||
margin: auto;
|
||||
border: 2px solid var(--el-checkbox-checked-icon-color);
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-notification .el-notification__icon {
|
||||
font-size: 22px !important;
|
||||
font-size: 22px !important;
|
||||
}
|
||||
|
||||
// 修改 el-message-box 样式
|
||||
.el-message-box__headerbtn .el-message-box__close,
|
||||
.el-dialog__headerbtn .el-dialog__close {
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
color: var(--art-gray-900) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
color: var(--art-gray-900) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-message-box {
|
||||
padding: 25px 20px !important;
|
||||
padding: 25px 20px !important;
|
||||
}
|
||||
|
||||
.el-message-box__title {
|
||||
font-weight: 500 !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.el-table__column-filter-trigger i {
|
||||
color: var(--theme-color) !important;
|
||||
margin: -3px 0 0 2px;
|
||||
color: var(--theme-color) !important;
|
||||
margin: -3px 0 0 2px;
|
||||
}
|
||||
|
||||
// 去除 el-dropdown 鼠标放上去出现的边框
|
||||
.el-tooltip__trigger:focus-visible {
|
||||
outline: unset;
|
||||
outline: unset;
|
||||
}
|
||||
|
||||
// ipad 表单右侧按钮优化
|
||||
@media screen and (max-width: 1180px) {
|
||||
.el-table-fixed-column--right {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
.el-table-fixed-column--right {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.login-out-dialog {
|
||||
padding: 30px 20px !important;
|
||||
border-radius: 10px !important;
|
||||
padding: 30px 20px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
// 修改 dialog 动画
|
||||
.dialog-fade-enter-active {
|
||||
.el-dialog:not(.is-draggable) {
|
||||
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
|
||||
.el-dialog:not(.is-draggable) {
|
||||
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
|
||||
|
||||
// 修复 el-dialog 动画后宽度不自适应问题
|
||||
.el-select__selected-item {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
// 修复 el-dialog 动画后宽度不自适应问题
|
||||
.el-select__selected-item {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dialog-fade-leave-active {
|
||||
animation: fade-out 0.2s linear;
|
||||
animation: fade-out 0.2s linear;
|
||||
|
||||
.el-dialog:not(.is-draggable) {
|
||||
animation: dialog-close 0.5s;
|
||||
}
|
||||
.el-dialog:not(.is-draggable) {
|
||||
animation: dialog-close 0.5s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-open {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dialog-close {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 遮罩层动画
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-select 样式
|
||||
.el-select__popper:not(.el-tree-select__popper) {
|
||||
.el-select-dropdown__list {
|
||||
padding: 5px !important;
|
||||
.el-select-dropdown__list {
|
||||
padding: 5px !important;
|
||||
|
||||
.el-select-dropdown__item {
|
||||
height: 34px !important;
|
||||
line-height: 34px !important;
|
||||
border-radius: 6px !important;
|
||||
.el-select-dropdown__item {
|
||||
height: 34px !important;
|
||||
line-height: 34px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&.is-selected {
|
||||
color: var(--art-gray-900) !important;
|
||||
font-weight: 400 !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
&.is-selected {
|
||||
color: var(--art-gray-900) !important;
|
||||
font-weight: 400 !important;
|
||||
background-color: var(--art-el-active-color) !important;
|
||||
margin-bottom: 4px !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-select-dropdown__item:hover ~ .is-selected,
|
||||
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
.el-select-dropdown__item:hover ~ .is-selected,
|
||||
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 修改 el-tree-select 样式
|
||||
.el-tree-select__popper {
|
||||
.el-select-dropdown__list {
|
||||
padding: 5px !important;
|
||||
.el-select-dropdown__list {
|
||||
padding: 5px !important;
|
||||
|
||||
.el-tree-node {
|
||||
.el-tree-node__content {
|
||||
height: 36px !important;
|
||||
border-radius: 6px !important;
|
||||
.el-tree-node {
|
||||
.el-tree-node__content {
|
||||
height: 36px !important;
|
||||
border-radius: 6px !important;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 实现水波纹在文字下面效果
|
||||
.el-button > span {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// 优化颜色选择器圆角
|
||||
.el-color-picker__color {
|
||||
border-radius: 2px !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
// 优化日期时间选择器底部圆角
|
||||
.el-picker-panel {
|
||||
.el-picker-panel__footer {
|
||||
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
|
||||
}
|
||||
.el-picker-panel__footer {
|
||||
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
|
||||
}
|
||||
}
|
||||
|
||||
// 优化树型菜单样式
|
||||
.el-tree-node__content {
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 0;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
padding: 1px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--art-hover-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏折叠菜单弹窗 hover 出现的边框
|
||||
.menu-left-popper:focus-within,
|
||||
.horizontal-menu-popper:focus-within {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
// 数字输入组件右侧按钮高度跟随自定义组件高度
|
||||
.el-input-number--default.is-controls-right {
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
height: calc((var(--el-component-size) / 2)) !important;
|
||||
}
|
||||
.el-input-number__decrease,
|
||||
.el-input-number__increase {
|
||||
height: calc((var(--el-component-size) / 2)) !important;
|
||||
}
|
||||
}
|
||||
|
||||
+417
-417
File diff suppressed because it is too large
Load Diff
@@ -5,18 +5,18 @@
|
||||
* @param {Number} 行数
|
||||
*/
|
||||
@mixin ellipsis($rowCount: 1) {
|
||||
@if $rowCount <=1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
} @else {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: $rowCount;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
@if $rowCount <=1 {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
} @else {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: $rowCount;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,20 +24,20 @@
|
||||
* @param {String} 类型
|
||||
*/
|
||||
@mixin userSelect($value: none) {
|
||||
user-select: $value;
|
||||
-moz-user-select: $value;
|
||||
-ms-user-select: $value;
|
||||
-webkit-user-select: $value;
|
||||
user-select: $value;
|
||||
-moz-user-select: $value;
|
||||
-ms-user-select: $value;
|
||||
-webkit-user-select: $value;
|
||||
}
|
||||
|
||||
// 绝对定位居中
|
||||
@mixin absoluteCenter() {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,113 +45,114 @@
|
||||
*
|
||||
*/
|
||||
@mixin animation(
|
||||
$from: (
|
||||
width: 0px
|
||||
),
|
||||
$to: (
|
||||
width: 100px
|
||||
),
|
||||
$name: mymove,
|
||||
$animate: mymove 2s 1 linear infinite
|
||||
$from: (
|
||||
width: 0px
|
||||
),
|
||||
$to: (
|
||||
width: 100px
|
||||
),
|
||||
$name: mymove,
|
||||
$animate: mymove 2s 1 linear infinite
|
||||
) {
|
||||
-webkit-animation: $animate;
|
||||
-o-animation: $animate;
|
||||
animation: $animate;
|
||||
-webkit-animation: $animate;
|
||||
-o-animation: $animate;
|
||||
animation: $animate;
|
||||
|
||||
@keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
#{$key}: #{$value};
|
||||
}
|
||||
}
|
||||
@keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
#{$key}: #{$value};
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
#{$key}: #{$value};
|
||||
}
|
||||
}
|
||||
}
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
#{$key}: #{$value};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
@-webkit-keyframes #{$name} {
|
||||
from {
|
||||
@each $key, $value in $from {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
to {
|
||||
@each $key, $value in $to {
|
||||
$key: $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 圆形盒子
|
||||
@mixin circle($size: 11px, $bg: #fff) {
|
||||
border-radius: 50%;
|
||||
width: $size;
|
||||
height: $size;
|
||||
line-height: $size;
|
||||
text-align: center;
|
||||
background: $bg;
|
||||
border-radius: 50%;
|
||||
width: $size;
|
||||
height: $size;
|
||||
line-height: $size;
|
||||
text-align: center;
|
||||
background: $bg;
|
||||
}
|
||||
|
||||
// placeholder
|
||||
@mixin placeholder($color: #bbb) {
|
||||
// Firefox
|
||||
&::-moz-placeholder {
|
||||
color: $color;
|
||||
opacity: 1;
|
||||
}
|
||||
// Firefox
|
||||
&::-moz-placeholder {
|
||||
color: $color;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
// Internet Explorer 10+
|
||||
&:-ms-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
// Internet Explorer 10+
|
||||
&:-ms-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
|
||||
// Safari and Chrome
|
||||
&::-webkit-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
// Safari and Chrome
|
||||
&::-webkit-input-placeholder {
|
||||
color: $color;
|
||||
}
|
||||
|
||||
&:placeholder-shown {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
&:placeholder-shown {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
//背景透明,文字不透明。兼容IE8
|
||||
@mixin betterTransparentize($color, $alpha) {
|
||||
$c: rgba($color, $alpha);
|
||||
$ie_c: ie_hex_str($c);
|
||||
background: rgba($color, 1);
|
||||
background: $c;
|
||||
background: transparent \9;
|
||||
zoom: 1;
|
||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
|
||||
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
|
||||
$c: rgba($color, $alpha);
|
||||
$ie_c: ie_hex_str($c);
|
||||
background: rgba($color, 1);
|
||||
background: $c;
|
||||
background: transparent \9;
|
||||
zoom: 1;
|
||||
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) {
|
||||
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
|
||||
#{$prefix}#{$propertyName}: $value;
|
||||
}
|
||||
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
|
||||
#{$prefix}#{$propertyName}: $value;
|
||||
}
|
||||
}
|
||||
|
||||
// 边框
|
||||
@mixin border($color: red) {
|
||||
border: 1px solid $color;
|
||||
border: 1px solid $color;
|
||||
}
|
||||
|
||||
// 背景滤镜
|
||||
@mixin backdropBlur() {
|
||||
--tw-backdrop-blur: blur(30px);
|
||||
-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-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||
var(--tw-backdrop-sepia);
|
||||
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-invert)
|
||||
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
||||
--tw-backdrop-blur: blur(30px);
|
||||
-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-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||
var(--tw-backdrop-sepia);
|
||||
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-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||
var(--tw-backdrop-sepia);
|
||||
}
|
||||
|
||||
@@ -3,39 +3,39 @@
|
||||
/*滚动条*/
|
||||
/*滚动条整体部分,必须要设置*/
|
||||
::-webkit-scrollbar {
|
||||
width: 8px !important;
|
||||
height: 0 !important;
|
||||
width: 8px !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
/*滚动条的轨道*/
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--art-gray-200);
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/*滚动条的滑块按钮*/
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 5px;
|
||||
background-color: #cccccc !important;
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
border-radius: 5px;
|
||||
background-color: #cccccc !important;
|
||||
transition: all 0.2s;
|
||||
-webkit-transition: all 0.2s;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #b0abab !important;
|
||||
background-color: #b0abab !important;
|
||||
}
|
||||
|
||||
/*滚动条的上下两端的按钮*/
|
||||
::-webkit-scrollbar-button {
|
||||
height: 0px;
|
||||
width: 0;
|
||||
height: 0px;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.dark {
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--default-bg-color);
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: var(--default-bg-color);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--art-gray-300) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,19 +2,19 @@
|
||||
|
||||
// === 变量区域 ===
|
||||
$transition: (
|
||||
// 动画持续时间
|
||||
duration: 0.25s,
|
||||
// 滑动动画的移动距离
|
||||
distance: 15px,
|
||||
// 默认缓动函数
|
||||
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||
// 淡入淡出专用的缓动函数
|
||||
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
|
||||
// 动画持续时间
|
||||
duration: 0.25s,
|
||||
// 滑动动画的移动距离
|
||||
distance: 15px,
|
||||
// 默认缓动函数
|
||||
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||
// 淡入淡出专用的缓动函数
|
||||
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
|
||||
);
|
||||
|
||||
// 抽取配置值函数,提高可复用性
|
||||
@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 {
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity $duration $fade-easing;
|
||||
will-change: opacity;
|
||||
}
|
||||
&-enter-active,
|
||||
&-leave-active {
|
||||
transition: opacity $duration $fade-easing;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
&-enter-from,
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&-enter-to,
|
||||
&-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
&-enter-to,
|
||||
&-leave-from {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动动画通用样式
|
||||
@mixin slide-transition($direction) {
|
||||
$distance-x: 0;
|
||||
$distance-y: 0;
|
||||
$distance-x: 0;
|
||||
$distance-y: 0;
|
||||
|
||||
@if $direction == 'left' {
|
||||
$distance-x: -$distance;
|
||||
} @else if $direction == 'right' {
|
||||
$distance-x: $distance;
|
||||
} @else if $direction == 'top' {
|
||||
$distance-y: -$distance;
|
||||
} @else if $direction == 'bottom' {
|
||||
$distance-y: $distance;
|
||||
}
|
||||
@if $direction == 'left' {
|
||||
$distance-x: -$distance;
|
||||
} @else if $direction == 'right' {
|
||||
$distance-x: $distance;
|
||||
} @else if $direction == 'top' {
|
||||
$distance-y: -$distance;
|
||||
} @else if $direction == 'bottom' {
|
||||
$distance-y: $distance;
|
||||
}
|
||||
|
||||
&-enter-active {
|
||||
transition:
|
||||
opacity $duration $easing,
|
||||
transform $duration $easing;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
&-enter-active {
|
||||
transition:
|
||||
opacity $duration $easing,
|
||||
transform $duration $easing;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
&-leave-active {
|
||||
transition:
|
||||
opacity calc($duration * 0.7) $easing,
|
||||
transform calc($duration * 0.7) $easing;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
&-leave-active {
|
||||
transition:
|
||||
opacity calc($duration * 0.7) $easing,
|
||||
transform calc($duration * 0.7) $easing;
|
||||
will-change: opacity, transform;
|
||||
}
|
||||
|
||||
&-enter-from {
|
||||
opacity: 0;
|
||||
transform: translate3d($distance-x, $distance-y, 0);
|
||||
}
|
||||
&-enter-from {
|
||||
opacity: 0;
|
||||
transform: translate3d($distance-x, $distance-y, 0);
|
||||
}
|
||||
|
||||
&-enter-to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
&-enter-to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(-$distance-x, -$distance-y, 0);
|
||||
}
|
||||
&-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate3d(-$distance-x, -$distance-y, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动动画方向类
|
||||
.slide-left {
|
||||
@include slide-transition('left');
|
||||
@include slide-transition('left');
|
||||
}
|
||||
.slide-right {
|
||||
@include slide-transition('right');
|
||||
@include slide-transition('right');
|
||||
}
|
||||
.slide-top {
|
||||
@include slide-transition('top');
|
||||
@include slide-transition('top');
|
||||
}
|
||||
.slide-bottom {
|
||||
@include slide-transition('bottom');
|
||||
@include slide-transition('bottom');
|
||||
}
|
||||
|
||||
+144
-144
@@ -3,206 +3,206 @@
|
||||
|
||||
/* ==================== Light Mode Variables ==================== */
|
||||
:root {
|
||||
/* Base Colors */
|
||||
--art-color: #ffffff;
|
||||
--theme-color: var(--main-color);
|
||||
/* Base Colors */
|
||||
--art-color: #ffffff;
|
||||
--theme-color: var(--main-color);
|
||||
|
||||
/* Theme Colors - OKLCH Format */
|
||||
--art-primary: oklch(0.7 0.23 260);
|
||||
--art-secondary: oklch(0.72 0.19 231.6);
|
||||
--art-error: oklch(0.73 0.15 25.3);
|
||||
--art-info: oklch(0.58 0.03 254.1);
|
||||
--art-success: oklch(0.78 0.17 166.1);
|
||||
--art-warning: oklch(0.78 0.14 75.5);
|
||||
--art-danger: oklch(0.68 0.22 25.3);
|
||||
/* Theme Colors - OKLCH Format */
|
||||
--art-primary: oklch(0.7 0.23 260);
|
||||
--art-secondary: oklch(0.72 0.19 231.6);
|
||||
--art-error: oklch(0.73 0.15 25.3);
|
||||
--art-info: oklch(0.58 0.03 254.1);
|
||||
--art-success: oklch(0.78 0.17 166.1);
|
||||
--art-warning: oklch(0.78 0.14 75.5);
|
||||
--art-danger: oklch(0.68 0.22 25.3);
|
||||
|
||||
/* Gray Scale - Light Mode */
|
||||
--art-gray-100: #f9fafb;
|
||||
--art-gray-200: #f2f4f5;
|
||||
--art-gray-300: #e6eaeb;
|
||||
--art-gray-400: #dbdfe1;
|
||||
--art-gray-500: #949eb7;
|
||||
--art-gray-600: #7987a1;
|
||||
--art-gray-700: #4d5875;
|
||||
--art-gray-800: #383853;
|
||||
--art-gray-900: #323251;
|
||||
/* Gray Scale - Light Mode */
|
||||
--art-gray-100: #f9fafb;
|
||||
--art-gray-200: #f2f4f5;
|
||||
--art-gray-300: #e6eaeb;
|
||||
--art-gray-400: #dbdfe1;
|
||||
--art-gray-500: #949eb7;
|
||||
--art-gray-600: #7987a1;
|
||||
--art-gray-700: #4d5875;
|
||||
--art-gray-800: #383853;
|
||||
--art-gray-900: #323251;
|
||||
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(0, 0, 0, 0.08);
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(0, 0, 0, 0.08);
|
||||
|
||||
--default-border: #e2e8ee;
|
||||
--default-border-dashed: #dbdfe9;
|
||||
--default-border: #e2e8ee;
|
||||
--default-border-dashed: #dbdfe9;
|
||||
|
||||
/* Background Colors */
|
||||
--default-bg-color: #fafbfc;
|
||||
--default-box-color: #ffffff;
|
||||
/* Background Colors */
|
||||
--default-bg-color: #fafbfc;
|
||||
--default-box-color: #ffffff;
|
||||
|
||||
/* Hover Color */
|
||||
--art-hover-color: #edeff0;
|
||||
/* Hover Color */
|
||||
--art-hover-color: #edeff0;
|
||||
|
||||
/* Active Color */
|
||||
--art-active-color: #f2f4f5;
|
||||
/* Active Color */
|
||||
--art-active-color: #f2f4f5;
|
||||
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #f2f4f5;
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #f2f4f5;
|
||||
}
|
||||
|
||||
/* ==================== Dark Mode Variables ==================== */
|
||||
.dark {
|
||||
/* Base Colors */
|
||||
--art-color: #000000;
|
||||
/* Base Colors */
|
||||
--art-color: #000000;
|
||||
|
||||
/* Gray Scale - Dark Mode */
|
||||
--art-gray-100: #110f0f;
|
||||
--art-gray-200: #17171c;
|
||||
--art-gray-300: #393946;
|
||||
--art-gray-400: #505062;
|
||||
--art-gray-500: #73738c;
|
||||
--art-gray-600: #8f8fa3;
|
||||
--art-gray-700: #ababba;
|
||||
--art-gray-800: #c7c7d1;
|
||||
--art-gray-900: #e3e3e8;
|
||||
/* Gray Scale - Dark Mode */
|
||||
--art-gray-100: #110f0f;
|
||||
--art-gray-200: #17171c;
|
||||
--art-gray-300: #393946;
|
||||
--art-gray-400: #505062;
|
||||
--art-gray-500: #73738c;
|
||||
--art-gray-600: #8f8fa3;
|
||||
--art-gray-700: #ababba;
|
||||
--art-gray-800: #c7c7d1;
|
||||
--art-gray-900: #e3e3e8;
|
||||
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(255, 255, 255, 0.08);
|
||||
/* Border Colors */
|
||||
--art-card-border: rgba(255, 255, 255, 0.08);
|
||||
|
||||
--default-border: rgba(255, 255, 255, 0.1);
|
||||
--default-border-dashed: #363843;
|
||||
--default-border: rgba(255, 255, 255, 0.1);
|
||||
--default-border-dashed: #363843;
|
||||
|
||||
/* Background Colors */
|
||||
--default-bg-color: #070707;
|
||||
--default-box-color: #161618;
|
||||
/* Background Colors */
|
||||
--default-bg-color: #070707;
|
||||
--default-box-color: #161618;
|
||||
|
||||
/* Hover Color */
|
||||
--art-hover-color: #252530;
|
||||
/* Hover Color */
|
||||
--art-hover-color: #252530;
|
||||
|
||||
/* Active Color */
|
||||
--art-active-color: #202226;
|
||||
/* Active Color */
|
||||
--art-active-color: #202226;
|
||||
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #2e2e38;
|
||||
/* Element Component Active Color */
|
||||
--art-el-active-color: #2e2e38;
|
||||
}
|
||||
|
||||
/* ==================== Tailwind Theme Configuration ==================== */
|
||||
@theme {
|
||||
/* Box Color (Light: white / Dark: black) */
|
||||
--color-box: var(--default-box-color);
|
||||
/* Box Color (Light: white / Dark: black) */
|
||||
--color-box: var(--default-box-color);
|
||||
|
||||
/* System Theme Color */
|
||||
--color-theme: var(--theme-color);
|
||||
/* System Theme Color */
|
||||
--color-theme: var(--theme-color);
|
||||
|
||||
/* Hover Color */
|
||||
--color-hover-color: var(--art-hover-color);
|
||||
/* Hover Color */
|
||||
--color-hover-color: var(--art-hover-color);
|
||||
|
||||
/* Active Color */
|
||||
--color-active-color: var(--art-active-color);
|
||||
/* Active Color */
|
||||
--color-active-color: var(--art-active-color);
|
||||
|
||||
/* Active Color */
|
||||
--color-el-active-color: var(--art-active-color);
|
||||
/* Active Color */
|
||||
--color-el-active-color: var(--art-active-color);
|
||||
|
||||
/* ElementPlus Theme Colors */
|
||||
--color-primary: var(--art-primary);
|
||||
--color-secondary: var(--art-secondary);
|
||||
--color-error: var(--art-error);
|
||||
--color-info: var(--art-info);
|
||||
--color-success: var(--art-success);
|
||||
--color-warning: var(--art-warning);
|
||||
--color-danger: var(--art-danger);
|
||||
/* ElementPlus Theme Colors */
|
||||
--color-primary: var(--art-primary);
|
||||
--color-secondary: var(--art-secondary);
|
||||
--color-error: var(--art-error);
|
||||
--color-info: var(--art-info);
|
||||
--color-success: var(--art-success);
|
||||
--color-warning: var(--art-warning);
|
||||
--color-danger: var(--art-danger);
|
||||
|
||||
/* Gray Scale Colors (Auto-adapts to dark mode) */
|
||||
--color-g-100: var(--art-gray-100);
|
||||
--color-g-200: var(--art-gray-200);
|
||||
--color-g-300: var(--art-gray-300);
|
||||
--color-g-400: var(--art-gray-400);
|
||||
--color-g-500: var(--art-gray-500);
|
||||
--color-g-600: var(--art-gray-600);
|
||||
--color-g-700: var(--art-gray-700);
|
||||
--color-g-800: var(--art-gray-800);
|
||||
--color-g-900: var(--art-gray-900);
|
||||
/* Gray Scale Colors (Auto-adapts to dark mode) */
|
||||
--color-g-100: var(--art-gray-100);
|
||||
--color-g-200: var(--art-gray-200);
|
||||
--color-g-300: var(--art-gray-300);
|
||||
--color-g-400: var(--art-gray-400);
|
||||
--color-g-500: var(--art-gray-500);
|
||||
--color-g-600: var(--art-gray-600);
|
||||
--color-g-700: var(--art-gray-700);
|
||||
--color-g-800: var(--art-gray-800);
|
||||
--color-g-900: var(--art-gray-900);
|
||||
}
|
||||
|
||||
/* ==================== Custom Border Radius Utilities ==================== */
|
||||
@utility rounded-custom-xs {
|
||||
border-radius: calc(var(--custom-radius) / 2);
|
||||
border-radius: calc(var(--custom-radius) / 2);
|
||||
}
|
||||
|
||||
@utility rounded-custom-sm {
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||
}
|
||||
|
||||
/* ==================== Custom Utility Classes ==================== */
|
||||
@layer utilities {
|
||||
/* Flexbox Layout Utilities */
|
||||
.flex-c {
|
||||
@apply flex items-center;
|
||||
}
|
||||
/* Flexbox Layout Utilities */
|
||||
.flex-c {
|
||||
@apply flex items-center;
|
||||
}
|
||||
|
||||
.flex-b {
|
||||
@apply flex justify-between;
|
||||
}
|
||||
.flex-b {
|
||||
@apply flex justify-between;
|
||||
}
|
||||
|
||||
.flex-cc {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
.flex-cc {
|
||||
@apply flex items-center justify-center;
|
||||
}
|
||||
|
||||
.flex-cb {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
.flex-cb {
|
||||
@apply flex items-center justify-between;
|
||||
}
|
||||
|
||||
/* Transition Utilities */
|
||||
.tad-200 {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
/* Transition Utilities */
|
||||
.tad-200 {
|
||||
@apply transition-all duration-200;
|
||||
}
|
||||
|
||||
.tad-300 {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
.tad-300 {
|
||||
@apply transition-all duration-300;
|
||||
}
|
||||
|
||||
/* Border Utilities */
|
||||
.border-full-d {
|
||||
@apply border border-[var(--default-border)];
|
||||
}
|
||||
/* Border Utilities */
|
||||
.border-full-d {
|
||||
@apply border border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-b-d {
|
||||
@apply border-b border-[var(--default-border)];
|
||||
}
|
||||
.border-b-d {
|
||||
@apply border-b border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-t-d {
|
||||
@apply border-t border-[var(--default-border)];
|
||||
}
|
||||
.border-t-d {
|
||||
@apply border-t border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-l-d {
|
||||
@apply border-l border-[var(--default-border)];
|
||||
}
|
||||
.border-l-d {
|
||||
@apply border-l border-[var(--default-border)];
|
||||
}
|
||||
|
||||
.border-r-d {
|
||||
@apply border-r border-[var(--default-border)];
|
||||
}
|
||||
.border-r-d {
|
||||
@apply border-r border-[var(--default-border)];
|
||||
}
|
||||
|
||||
/* Cursor Utilities */
|
||||
.c-p {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
/* Cursor Utilities */
|
||||
.c-p {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==================== Custom Component Classes ==================== */
|
||||
@layer components {
|
||||
/* Art Card Header Component */
|
||||
.art-card-header {
|
||||
@apply flex justify-between pr-6 pb-1;
|
||||
/* Art Card Header Component */
|
||||
.art-card-header {
|
||||
@apply flex justify-between pr-6 pb-1;
|
||||
|
||||
.title {
|
||||
h4 {
|
||||
@apply text-lg font-medium text-g-900;
|
||||
}
|
||||
.title {
|
||||
h4 {
|
||||
@apply text-lg font-medium text-g-900;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mt-1 text-sm text-g-600;
|
||||
p {
|
||||
@apply mt-1 text-sm text-g-600;
|
||||
|
||||
span {
|
||||
@apply ml-2 font-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
span {
|
||||
@apply ml-2 font-medium;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,60 +4,60 @@ $bg-animation-color-dark: #fff;
|
||||
$bg-animation-duration: 0.5s;
|
||||
|
||||
html {
|
||||
--bg-animation-color: $bg-animation-color-light;
|
||||
--bg-animation-color: $bg-animation-color-light;
|
||||
|
||||
&.dark {
|
||||
--bg-animation-color: $bg-animation-color-dark;
|
||||
}
|
||||
&.dark {
|
||||
--bg-animation-color: $bg-animation-color-dark;
|
||||
}
|
||||
|
||||
// View transition styles
|
||||
&::view-transition-old(*) {
|
||||
animation: none;
|
||||
}
|
||||
// View transition styles
|
||||
&::view-transition-old(*) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-new(*) {
|
||||
animation: clip $bg-animation-duration ease-in both;
|
||||
}
|
||||
&::view-transition-new(*) {
|
||||
animation: clip $bg-animation-duration ease-in both;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
&::view-transition-old(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
&.dark {
|
||||
&::view-transition-old(*) {
|
||||
animation: clip $bg-animation-duration ease-in reverse both;
|
||||
}
|
||||
&.dark {
|
||||
&::view-transition-old(*) {
|
||||
animation: clip $bg-animation-duration ease-in reverse both;
|
||||
}
|
||||
|
||||
&::view-transition-new(*) {
|
||||
animation: none;
|
||||
}
|
||||
&::view-transition-new(*) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
&::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
&::view-transition-old(root) {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
&::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
&::view-transition-new(root) {
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 定义动画
|
||||
@keyframes clip {
|
||||
from {
|
||||
clip-path: circle(0% at var(--x) var(--y));
|
||||
}
|
||||
from {
|
||||
clip-path: circle(0% at var(--x) var(--y));
|
||||
}
|
||||
|
||||
to {
|
||||
clip-path: circle(var(--r) at var(--x) var(--y));
|
||||
}
|
||||
to {
|
||||
clip-path: circle(var(--r) at var(--x) var(--y));
|
||||
}
|
||||
}
|
||||
|
||||
// body 相关样式
|
||||
body {
|
||||
background-color: var(--bg-animation-color);
|
||||
background-color: var(--bg-animation-color);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 主题切换过渡优化,优化除视觉上的不适感
|
||||
.theme-change {
|
||||
* {
|
||||
transition: 0s !important;
|
||||
}
|
||||
* {
|
||||
transition: 0s !important;
|
||||
}
|
||||
|
||||
.el-switch__core,
|
||||
.el-switch__action {
|
||||
transition: all 0.3s !important;
|
||||
}
|
||||
.el-switch__core,
|
||||
.el-switch__action {
|
||||
transition: all 0.3s !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 0.5em;
|
||||
|
||||
color: #a6accd;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
@@ -11,18 +11,18 @@
|
||||
.hljs-selector-class,
|
||||
.hljs-template-variable,
|
||||
.hljs-deletion {
|
||||
color: #aed07e !important;
|
||||
color: #aed07e !important;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #6f747d;
|
||||
color: #6f747d;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-formula {
|
||||
color: #c792ea;
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-section,
|
||||
@@ -30,11 +30,11 @@
|
||||
.hljs-selector-tag,
|
||||
.hljs-deletion,
|
||||
.hljs-subst {
|
||||
color: #c86068;
|
||||
color: #c86068;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #56b6c2;
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
@@ -42,33 +42,33 @@
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta-string {
|
||||
color: #abb2bf;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
.hljs-attribute {
|
||||
color: #c792ea;
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-function {
|
||||
color: #c792ea;
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-type {
|
||||
color: #f07178;
|
||||
color: #f07178;
|
||||
}
|
||||
|
||||
.hljs-title {
|
||||
color: #82aaff !important;
|
||||
color: #82aaff !important;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class {
|
||||
color: #82aaff;
|
||||
color: #82aaff;
|
||||
}
|
||||
|
||||
// 括号
|
||||
.hljs-params {
|
||||
color: #a6accd;
|
||||
color: #a6accd;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
@@ -78,7 +78,7 @@
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-number {
|
||||
color: #de7e61;
|
||||
color: #de7e61;
|
||||
}
|
||||
|
||||
.hljs-symbol,
|
||||
@@ -86,13 +86,13 @@
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id {
|
||||
color: #61aeee;
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: bold;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,343 +1,352 @@
|
||||
<!-- 基础横幅组件 -->
|
||||
<template>
|
||||
<div
|
||||
class="art-card basic-banner"
|
||||
:class="[{ 'has-decoration': decoration }, boxStyle]"
|
||||
:style="{ height }"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- 流星效果 -->
|
||||
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
|
||||
<span
|
||||
v-for="(meteor, index) in meteors"
|
||||
:key="index"
|
||||
class="meteor"
|
||||
:style="{
|
||||
top: '-60px',
|
||||
left: `${meteor.x}%`,
|
||||
animationDuration: `${meteor.speed}s`,
|
||||
animationDelay: `${meteor.delay}s`
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
<div
|
||||
class="art-card basic-banner"
|
||||
:class="[{ 'has-decoration': decoration }, boxStyle]"
|
||||
:style="{ height }"
|
||||
@click="emit('click')"
|
||||
>
|
||||
<!-- 流星效果 -->
|
||||
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
|
||||
<span
|
||||
v-for="(meteor, index) in meteors"
|
||||
:key="index"
|
||||
class="meteor"
|
||||
:style="{
|
||||
top: '-60px',
|
||||
left: `${meteor.x}%`,
|
||||
animationDuration: `${meteor.speed}s`,
|
||||
animationDelay: `${meteor.delay}s`
|
||||
}"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div class="basic-banner__content">
|
||||
<!-- title slot -->
|
||||
<slot name="title">
|
||||
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
|
||||
</slot>
|
||||
<div class="basic-banner__content">
|
||||
<!-- title slot -->
|
||||
<slot name="title">
|
||||
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{
|
||||
title
|
||||
}}</p>
|
||||
</slot>
|
||||
|
||||
<!-- subtitle slot -->
|
||||
<slot name="subtitle">
|
||||
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{
|
||||
subtitle
|
||||
}}</p>
|
||||
</slot>
|
||||
<!-- subtitle slot -->
|
||||
<slot name="subtitle">
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="basic-banner__subtitle"
|
||||
:style="{ color: subtitleColor }"
|
||||
>{{ subtitle }}</p
|
||||
>
|
||||
</slot>
|
||||
|
||||
<!-- button slot -->
|
||||
<slot name="button">
|
||||
<div
|
||||
v-if="buttonConfig?.show"
|
||||
class="basic-banner__button"
|
||||
:style="{
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
borderRadius: buttonRadius
|
||||
}"
|
||||
@click.stop="emit('buttonClick')"
|
||||
>
|
||||
{{ buttonConfig?.text }}
|
||||
</div>
|
||||
</slot>
|
||||
<!-- button slot -->
|
||||
<slot name="button">
|
||||
<div
|
||||
v-if="buttonConfig?.show"
|
||||
class="basic-banner__button"
|
||||
:style="{
|
||||
backgroundColor: buttonColor,
|
||||
color: buttonTextColor,
|
||||
borderRadius: buttonRadius
|
||||
}"
|
||||
@click.stop="emit('buttonClick')"
|
||||
>
|
||||
{{ buttonConfig?.text }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<!-- default slot -->
|
||||
<slot></slot>
|
||||
<!-- default slot -->
|
||||
<slot></slot>
|
||||
|
||||
<!-- background image -->
|
||||
<img
|
||||
v-if="imageConfig.src"
|
||||
class="basic-banner__background-image"
|
||||
:src="imageConfig.src"
|
||||
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
|
||||
loading="lazy"
|
||||
alt="背景图片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- background image -->
|
||||
<img
|
||||
v-if="imageConfig.src"
|
||||
class="basic-banner__background-image"
|
||||
:src="imageConfig.src"
|
||||
:style="{
|
||||
width: imageConfig.width,
|
||||
bottom: imageConfig.bottom,
|
||||
right: imageConfig.right
|
||||
}"
|
||||
loading="lazy"
|
||||
alt="背景图片"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
defineOptions({ name: 'ArtBasicBanner' })
|
||||
defineOptions({ name: 'ArtBasicBanner' })
|
||||
|
||||
// 流星对象接口定义
|
||||
interface Meteor {
|
||||
/** 流星的水平位置(百分比) */
|
||||
x: number
|
||||
/** 流星划过的速度 */
|
||||
speed: number
|
||||
/** 流星出现的延迟时间 */
|
||||
delay: number
|
||||
}
|
||||
// 流星对象接口定义
|
||||
interface Meteor {
|
||||
/** 流星的水平位置(百分比) */
|
||||
x: number
|
||||
/** 流星划过的速度 */
|
||||
speed: number
|
||||
/** 流星出现的延迟时间 */
|
||||
delay: number
|
||||
}
|
||||
|
||||
// 按钮配置接口定义
|
||||
interface ButtonConfig {
|
||||
/** 是否启用按钮 */
|
||||
show: boolean
|
||||
/** 按钮文本 */
|
||||
text: string
|
||||
/** 按钮背景色 */
|
||||
color?: string
|
||||
/** 按钮文字颜色 */
|
||||
textColor?: string
|
||||
/** 按钮圆角大小 */
|
||||
radius?: string
|
||||
}
|
||||
// 按钮配置接口定义
|
||||
interface ButtonConfig {
|
||||
/** 是否启用按钮 */
|
||||
show: boolean
|
||||
/** 按钮文本 */
|
||||
text: string
|
||||
/** 按钮背景色 */
|
||||
color?: string
|
||||
/** 按钮文字颜色 */
|
||||
textColor?: string
|
||||
/** 按钮圆角大小 */
|
||||
radius?: string
|
||||
}
|
||||
|
||||
// 流星效果配置接口定义
|
||||
interface MeteorConfig {
|
||||
/** 是否启用流星效果 */
|
||||
enabled: boolean
|
||||
/** 流星数量 */
|
||||
count?: number
|
||||
}
|
||||
// 流星效果配置接口定义
|
||||
interface MeteorConfig {
|
||||
/** 是否启用流星效果 */
|
||||
enabled: boolean
|
||||
/** 流星数量 */
|
||||
count?: number
|
||||
}
|
||||
|
||||
// 背景图片配置接口定义
|
||||
interface ImageConfig {
|
||||
/** 图片源地址 */
|
||||
src: string
|
||||
/** 图片宽度 */
|
||||
width?: string
|
||||
/** 距底部距离 */
|
||||
bottom?: string
|
||||
/** 距右侧距离 */
|
||||
right?: string // 距右侧距离
|
||||
}
|
||||
// 背景图片配置接口定义
|
||||
interface ImageConfig {
|
||||
/** 图片源地址 */
|
||||
src: string
|
||||
/** 图片宽度 */
|
||||
width?: string
|
||||
/** 距底部距离 */
|
||||
bottom?: string
|
||||
/** 距右侧距离 */
|
||||
right?: string // 距右侧距离
|
||||
}
|
||||
|
||||
// 组件属性接口定义
|
||||
interface Props {
|
||||
/** 横幅高度 */
|
||||
height?: string
|
||||
/** 标题文本 */
|
||||
title?: string
|
||||
/** 副标题文本 */
|
||||
subtitle?: string
|
||||
/** 盒子样式 */
|
||||
boxStyle?: string
|
||||
/** 是否显示装饰效果 */
|
||||
decoration?: boolean
|
||||
/** 按钮配置 */
|
||||
buttonConfig?: ButtonConfig
|
||||
/** 流星配置 */
|
||||
meteorConfig?: MeteorConfig
|
||||
/** 图片配置 */
|
||||
imageConfig?: ImageConfig
|
||||
/** 标题颜色 */
|
||||
titleColor?: string
|
||||
/** 副标题颜色 */
|
||||
subtitleColor?: string
|
||||
}
|
||||
// 组件属性接口定义
|
||||
interface Props {
|
||||
/** 横幅高度 */
|
||||
height?: string
|
||||
/** 标题文本 */
|
||||
title?: string
|
||||
/** 副标题文本 */
|
||||
subtitle?: string
|
||||
/** 盒子样式 */
|
||||
boxStyle?: string
|
||||
/** 是否显示装饰效果 */
|
||||
decoration?: boolean
|
||||
/** 按钮配置 */
|
||||
buttonConfig?: ButtonConfig
|
||||
/** 流星配置 */
|
||||
meteorConfig?: MeteorConfig
|
||||
/** 图片配置 */
|
||||
imageConfig?: ImageConfig
|
||||
/** 标题颜色 */
|
||||
titleColor?: string
|
||||
/** 副标题颜色 */
|
||||
subtitleColor?: string
|
||||
}
|
||||
|
||||
// 组件属性默认值设置
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '11rem',
|
||||
titleColor: 'white',
|
||||
subtitleColor: 'white',
|
||||
boxStyle: '!bg-theme/60',
|
||||
decoration: true,
|
||||
buttonConfig: () => ({
|
||||
show: true,
|
||||
text: '查看',
|
||||
color: '#fff',
|
||||
textColor: '#333',
|
||||
radius: '6px'
|
||||
}),
|
||||
meteorConfig: () => ({ enabled: false, count: 10 }),
|
||||
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
|
||||
})
|
||||
// 组件属性默认值设置
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '11rem',
|
||||
titleColor: 'white',
|
||||
subtitleColor: 'white',
|
||||
boxStyle: '!bg-theme/60',
|
||||
decoration: true,
|
||||
buttonConfig: () => ({
|
||||
show: true,
|
||||
text: '查看',
|
||||
color: '#fff',
|
||||
textColor: '#333',
|
||||
radius: '6px'
|
||||
}),
|
||||
meteorConfig: () => ({ enabled: false, count: 10 }),
|
||||
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
|
||||
})
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void // 整体点击事件
|
||||
(e: 'buttonClick'): void // 按钮点击事件
|
||||
}>()
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void // 整体点击事件
|
||||
(e: 'buttonClick'): void // 按钮点击事件
|
||||
}>()
|
||||
|
||||
// 计算按钮样式属性
|
||||
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
|
||||
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
|
||||
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
|
||||
// 计算按钮样式属性
|
||||
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
|
||||
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
|
||||
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
|
||||
|
||||
// 流星数据初始化
|
||||
const meteors = ref<Meteor[]>([])
|
||||
onMounted(() => {
|
||||
if (props.meteorConfig?.enabled) {
|
||||
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
|
||||
}
|
||||
})
|
||||
// 流星数据初始化
|
||||
const meteors = ref<Meteor[]>([])
|
||||
onMounted(() => {
|
||||
if (props.meteorConfig?.enabled) {
|
||||
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 生成流星数据数组
|
||||
* @param count 流星数量
|
||||
* @returns 流星数据数组
|
||||
*/
|
||||
function generateMeteors(count: number): Meteor[] {
|
||||
// 计算每个流星的区域宽度
|
||||
const segmentWidth = 100 / count
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
// 计算流星起始位置
|
||||
const segmentStart = index * segmentWidth
|
||||
// 在区域内随机生成x坐标
|
||||
const x = segmentStart + Math.random() * segmentWidth
|
||||
// 随机决定流星速度快慢
|
||||
const isSlow = Math.random() > 0.5
|
||||
return {
|
||||
x,
|
||||
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
|
||||
delay: Math.random() * 5
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 生成流星数据数组
|
||||
* @param count 流星数量
|
||||
* @returns 流星数据数组
|
||||
*/
|
||||
function generateMeteors(count: number): Meteor[] {
|
||||
// 计算每个流星的区域宽度
|
||||
const segmentWidth = 100 / count
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
// 计算流星起始位置
|
||||
const segmentStart = index * segmentWidth
|
||||
// 在区域内随机生成x坐标
|
||||
const x = segmentStart + Math.random() * segmentWidth
|
||||
// 随机决定流星速度快慢
|
||||
const isSlow = Math.random() > 0.5
|
||||
return {
|
||||
x,
|
||||
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
|
||||
delay: Math.random() * 5
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.basic-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 2rem;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
.basic-banner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 2rem;
|
||||
overflow: hidden;
|
||||
color: white;
|
||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
&__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
&__title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__subtitle {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
&__subtitle {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
margin: 0 0 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&__button {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
height: var(--el-component-custom-height);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: var(--el-component-custom-height);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
&__button {
|
||||
box-sizing: border-box;
|
||||
display: inline-block;
|
||||
min-width: 80px;
|
||||
height: var(--el-component-custom-height);
|
||||
padding: 0 12px;
|
||||
font-size: 14px;
|
||||
line-height: var(--el-component-custom-height);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
&__background-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3rem;
|
||||
z-index: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
&__background-image {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -3rem;
|
||||
z-index: 0;
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
&.has-decoration::after {
|
||||
position: absolute;
|
||||
right: -10%;
|
||||
bottom: -20%;
|
||||
width: 60%;
|
||||
height: 140%;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 30%;
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
&.has-decoration::after {
|
||||
position: absolute;
|
||||
right: -10%;
|
||||
bottom: -20%;
|
||||
width: 60%;
|
||||
height: 140%;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 10%);
|
||||
border-radius: 30%;
|
||||
transform: rotate(-20deg);
|
||||
}
|
||||
|
||||
&__meteors {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
&__meteors {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
|
||||
.meteor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 60px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(255 255 255 / 40%),
|
||||
rgb(255 255 255 / 10%),
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transform-origin: top left;
|
||||
animation-name: meteor-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
.meteor {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
height: 60px;
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
rgb(255 255 255 / 40%),
|
||||
rgb(255 255 255 / 10%),
|
||||
transparent
|
||||
);
|
||||
opacity: 0;
|
||||
transform-origin: top left;
|
||||
animation-name: meteor-fall;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
height: 2px;
|
||||
content: '';
|
||||
background: rgb(255 255 255 / 50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes meteor-fall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -60px) rotate(-45deg);
|
||||
}
|
||||
@keyframes meteor-fall {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate(0, -60px) rotate(-45deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(400px, 340px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(400px, 340px) rotate(-45deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 640px) {
|
||||
.basic-banner {
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
@media (width <= 640px) {
|
||||
.basic-banner {
|
||||
box-sizing: border-box;
|
||||
justify-content: flex-start;
|
||||
padding: 16px;
|
||||
|
||||
&__title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
&__title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
&__background-image {
|
||||
display: none;
|
||||
}
|
||||
&__background-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.has-decoration::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.has-decoration::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,114 +1,114 @@
|
||||
<!-- 卡片横幅组件 -->
|
||||
<template>
|
||||
<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="w-45">
|
||||
<img :src="image" :alt="title" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div class="box-border px-4">
|
||||
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
||||
<p class="m-0 text-sm text-g-600">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex-c gap-3">
|
||||
<div
|
||||
v-if="cancelButton?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
|
||||
:style="{
|
||||
backgroundColor: cancelButton?.color,
|
||||
color: cancelButton?.textColor
|
||||
}"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelButton?.text }}
|
||||
</div>
|
||||
<div
|
||||
v-if="button?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
|
||||
:style="{ backgroundColor: button?.color, color: button?.textColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ button?.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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="w-45">
|
||||
<img :src="image" :alt="title" class="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div class="box-border px-4">
|
||||
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
||||
<p class="m-0 text-sm text-g-600">{{ description }}</p>
|
||||
</div>
|
||||
<div class="flex-c gap-3">
|
||||
<div
|
||||
v-if="cancelButton?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
|
||||
:style="{
|
||||
backgroundColor: cancelButton?.color,
|
||||
color: cancelButton?.textColor
|
||||
}"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelButton?.text }}
|
||||
</div>
|
||||
<div
|
||||
v-if="button?.show"
|
||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
|
||||
:style="{ backgroundColor: button?.color, color: button?.textColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ button?.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
/** 高度 */
|
||||
height?: string
|
||||
/** 图片路径 */
|
||||
image?: string
|
||||
/** 标题文本 */
|
||||
title: string
|
||||
/** 描述文本 */
|
||||
description: string
|
||||
/** 主按钮配置 */
|
||||
button?: {
|
||||
/** 是否显示 */
|
||||
show?: boolean
|
||||
/** 按钮文本 */
|
||||
text?: string
|
||||
/** 背景颜色 */
|
||||
color?: string
|
||||
/** 文字颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
/** 取消按钮配置 */
|
||||
cancelButton?: {
|
||||
/** 是否显示 */
|
||||
show?: boolean
|
||||
/** 按钮文本 */
|
||||
text?: string
|
||||
/** 背景颜色 */
|
||||
color?: string
|
||||
/** 文字颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
}
|
||||
// 定义卡片横幅组件的属性接口
|
||||
interface CardBannerProps {
|
||||
/** 高度 */
|
||||
height?: string
|
||||
/** 图片路径 */
|
||||
image?: string
|
||||
/** 标题文本 */
|
||||
title: string
|
||||
/** 描述文本 */
|
||||
description: string
|
||||
/** 主按钮配置 */
|
||||
button?: {
|
||||
/** 是否显示 */
|
||||
show?: boolean
|
||||
/** 按钮文本 */
|
||||
text?: string
|
||||
/** 背景颜色 */
|
||||
color?: string
|
||||
/** 文字颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
/** 取消按钮配置 */
|
||||
cancelButton?: {
|
||||
/** 是否显示 */
|
||||
show?: boolean
|
||||
/** 按钮文本 */
|
||||
text?: string
|
||||
/** 背景颜色 */
|
||||
color?: string
|
||||
/** 文字颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 定义组件属性默认值
|
||||
withDefaults(defineProps<CardBannerProps>(), {
|
||||
height: '24rem',
|
||||
image: defaultIcon,
|
||||
title: '',
|
||||
description: '',
|
||||
// 主按钮默认配置
|
||||
button: () => ({
|
||||
show: true,
|
||||
text: '查看详情',
|
||||
color: 'var(--theme-color)',
|
||||
textColor: '#fff'
|
||||
}),
|
||||
// 取消按钮默认配置
|
||||
cancelButton: () => ({
|
||||
show: false,
|
||||
text: '取消',
|
||||
color: '#f5f5f5',
|
||||
textColor: '#666'
|
||||
})
|
||||
})
|
||||
// 定义组件属性默认值
|
||||
withDefaults(defineProps<CardBannerProps>(), {
|
||||
height: '24rem',
|
||||
image: defaultIcon,
|
||||
title: '',
|
||||
description: '',
|
||||
// 主按钮默认配置
|
||||
button: () => ({
|
||||
show: true,
|
||||
text: '查看详情',
|
||||
color: 'var(--theme-color)',
|
||||
textColor: '#fff'
|
||||
}),
|
||||
// 取消按钮默认配置
|
||||
cancelButton: () => ({
|
||||
show: false,
|
||||
text: '取消',
|
||||
color: '#f5f5f5',
|
||||
textColor: '#666'
|
||||
})
|
||||
})
|
||||
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void // 主按钮点击事件
|
||||
(e: 'cancel'): void // 取消按钮点击事件
|
||||
}>()
|
||||
// 定义组件事件
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void // 主按钮点击事件
|
||||
(e: 'cancel'): void // 取消按钮点击事件
|
||||
}>()
|
||||
|
||||
// 主按钮点击处理函数
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
// 主按钮点击处理函数
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
|
||||
// 取消按钮点击处理函数
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
// 取消按钮点击处理函数
|
||||
const handleCancel = () => {
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
<!-- 返回顶部按钮 -->
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="tad-300 ease-out"
|
||||
leave-active-class="tad-200 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition
|
||||
enter-active-class="tad-300 ease-out"
|
||||
leave-active-class="tad-200 ease-in"
|
||||
enter-from-class="opacity-0 translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 translate-y-2"
|
||||
>
|
||||
<div
|
||||
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"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<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 scrollThreshold = 300
|
||||
const showButton = ref(false)
|
||||
const scrollThreshold = 300
|
||||
|
||||
onMounted(() => {
|
||||
const scrollContainer = document.getElementById('app-main')
|
||||
if (scrollContainer) {
|
||||
const { y } = useScroll(scrollContainer)
|
||||
watch(y, (newY: number) => {
|
||||
showButton.value = newY > scrollThreshold
|
||||
})
|
||||
}
|
||||
})
|
||||
onMounted(() => {
|
||||
const scrollContainer = document.getElementById('app-main')
|
||||
if (scrollContainer) {
|
||||
const { y } = useScroll(scrollContainer)
|
||||
watch(y, (newY: number) => {
|
||||
showButton.value = newY > scrollThreshold
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
<!-- 系统logo -->
|
||||
<template>
|
||||
<div class="flex-cc">
|
||||
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
|
||||
</div>
|
||||
<div class="flex-cc">
|
||||
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtLogo' })
|
||||
defineOptions({ name: 'ArtLogo' })
|
||||
|
||||
interface Props {
|
||||
/** logo 大小 */
|
||||
size?: number | string
|
||||
}
|
||||
interface Props {
|
||||
/** logo 大小 */
|
||||
size?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 36
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 36
|
||||
})
|
||||
|
||||
const logoStyle = computed(() => ({ width: `${props.size}px` }))
|
||||
const logoStyle = computed(() => ({ width: `${props.size}px` }))
|
||||
</script>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<!-- 图标组件 -->
|
||||
<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>
|
||||
|
||||
<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 {
|
||||
/** Iconify icon name */
|
||||
icon?: string
|
||||
}
|
||||
interface Props {
|
||||
/** Iconify icon name */
|
||||
icon?: string
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineProps<Props>()
|
||||
|
||||
const attrs = useAttrs()
|
||||
const attrs = useAttrs()
|
||||
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || ''
|
||||
}))
|
||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||
class: (attrs.class as string) || '',
|
||||
style: (attrs.style as string) || ''
|
||||
}))
|
||||
</script>
|
||||
|
||||
@@ -1,103 +1,108 @@
|
||||
<!-- 柱状图卡片 -->
|
||||
<template>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-5 flex-b items-start px-5 pt-5">
|
||||
<div>
|
||||
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium text-danger"
|
||||
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="absolute bottom-0 left-0 right-0 mx-auto"
|
||||
: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>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-5 flex-b items-start px-5 pt-5">
|
||||
<div>
|
||||
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium text-danger"
|
||||
:class="[
|
||||
percentage > 0 ? 'text-success' : '',
|
||||
isMiniChart ? 'absolute bottom-5' : ''
|
||||
]"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="absolute bottom-0 left-0 right-0 mx-auto"
|
||||
: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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
|
||||
defineOptions({ name: 'ArtBarChartCard' })
|
||||
defineOptions({ name: 'ArtBarChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 +(绿色)-(红色) */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 柱状图宽度 */
|
||||
barWidth?: string
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 +(绿色)-(红色) */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 柱状图宽度 */
|
||||
barWidth?: string
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11,
|
||||
barWidth: '26%'
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11,
|
||||
barWidth: '26%'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !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],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !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],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 15,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'bar',
|
||||
barWidth: props.barWidth,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
borderRadius: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 15,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'bar',
|
||||
barWidth: props.barWidth,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
borderRadius: 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
<!-- 数据列表卡片 -->
|
||||
<template>
|
||||
<div class="art-card p-5">
|
||||
<div class="pb-3.5">
|
||||
<p class="text-lg font-medium">{{ title }}</p>
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<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">
|
||||
<ArtSvgIcon :icon="item.icon" class="text-xl" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 text-sm">{{ item.title }}</div>
|
||||
<div class="text-xs text-g-500">{{ item.status }}</div>
|
||||
</div>
|
||||
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<ElButton
|
||||
class="mt-[25px] w-full text-center"
|
||||
v-if="showMoreButton"
|
||||
v-ripple
|
||||
@click="handleMore"
|
||||
>查看更多</ElButton
|
||||
>
|
||||
</div>
|
||||
<div class="art-card p-5">
|
||||
<div class="pb-3.5">
|
||||
<p class="text-lg font-medium">{{ title }}</p>
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<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">
|
||||
<ArtSvgIcon :icon="item.icon" class="text-xl" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="mb-1 text-sm">{{ item.title }}</div>
|
||||
<div class="text-xs text-g-500">{{ item.status }}</div>
|
||||
</div>
|
||||
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<ElButton
|
||||
class="mt-[25px] w-full text-center"
|
||||
v-if="showMoreButton"
|
||||
v-ripple
|
||||
@click="handleMore"
|
||||
>查看更多</ElButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtDataListCard' })
|
||||
defineOptions({ name: 'ArtDataListCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数据列表 */
|
||||
list: Activity[]
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle?: string
|
||||
/** 最大显示数量 */
|
||||
maxCount?: number
|
||||
/** 是否显示更多按钮 */
|
||||
showMoreButton?: boolean
|
||||
}
|
||||
interface Props {
|
||||
/** 数据列表 */
|
||||
list: Activity[]
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle?: string
|
||||
/** 最大显示数量 */
|
||||
maxCount?: number
|
||||
/** 是否显示更多按钮 */
|
||||
showMoreButton?: boolean
|
||||
}
|
||||
|
||||
interface Activity {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 状态 */
|
||||
status: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 样式类名 */
|
||||
class: string
|
||||
/** 图标 */
|
||||
icon: string
|
||||
}
|
||||
interface Activity {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 状态 */
|
||||
status: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 样式类名 */
|
||||
class: string
|
||||
/** 图标 */
|
||||
icon: string
|
||||
}
|
||||
|
||||
const ITEM_HEIGHT = 66
|
||||
const DEFAULT_MAX_COUNT = 5
|
||||
const ITEM_HEIGHT = 66
|
||||
const DEFAULT_MAX_COUNT = 5
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxCount: DEFAULT_MAX_COUNT
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
maxCount: DEFAULT_MAX_COUNT
|
||||
})
|
||||
|
||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击更多按钮事件 */
|
||||
(e: 'more'): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
/** 点击更多按钮事件 */
|
||||
(e: 'more'): void
|
||||
}>()
|
||||
|
||||
const handleMore = () => emit('more')
|
||||
const handleMore = () => emit('more')
|
||||
</script>
|
||||
|
||||
@@ -1,124 +1,124 @@
|
||||
<!-- 环型图卡片 -->
|
||||
<template>
|
||||
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="flex box-border h-full p-5 pr-2">
|
||||
<div class="flex w-full items-start gap-5">
|
||||
<div class="flex-b h-full flex-1 flex-col">
|
||||
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div>
|
||||
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
|
||||
{{ formatNumber(value) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1.5 text-xs font-medium"
|
||||
:class="percentage > 0 ? 'text-success' : 'text-danger'"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
<span v-if="percentageLabel">{{ percentageLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
||||
<div v-if="currentValue" class="flex-cc">
|
||||
<div class="size-2 bg-theme/100 rounded mr-2"></div>
|
||||
{{ currentValue }}
|
||||
</div>
|
||||
<div v-if="previousValue" class="flex-cc">
|
||||
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
||||
{{ previousValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c h-full max-w-40 flex-1">
|
||||
<div ref="chartRef" class="h-30 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="flex box-border h-full p-5 pr-2">
|
||||
<div class="flex w-full items-start gap-5">
|
||||
<div class="flex-b h-full flex-1 flex-col">
|
||||
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div>
|
||||
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
|
||||
{{ formatNumber(value) }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-1.5 text-xs font-medium"
|
||||
:class="percentage > 0 ? 'text-success' : 'text-danger'"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
<span v-if="percentageLabel">{{ percentageLabel }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
||||
<div v-if="currentValue" class="flex-cc">
|
||||
<div class="size-2 bg-theme/100 rounded mr-2"></div>
|
||||
{{ currentValue }}
|
||||
</div>
|
||||
<div v-if="previousValue" class="flex-cc">
|
||||
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
||||
{{ previousValue }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c h-full max-w-40 flex-1">
|
||||
<div ref="chartRef" class="h-30 w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { type EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
|
||||
defineOptions({ name: 'ArtDonutChartCard' })
|
||||
defineOptions({ name: 'ArtDonutChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 百分比标签 */
|
||||
percentageLabel?: string
|
||||
/** 当前年份 */
|
||||
currentValue?: string
|
||||
/** 去年年份 */
|
||||
previousValue?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 半径 */
|
||||
radius?: [string, string]
|
||||
/** 数据 */
|
||||
data: [number, number]
|
||||
}
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 百分比标签 */
|
||||
percentageLabel?: string
|
||||
/** 当前年份 */
|
||||
currentValue?: string
|
||||
/** 去年年份 */
|
||||
previousValue?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 半径 */
|
||||
radius?: [string, string]
|
||||
/** 数据 */
|
||||
data: [number, number]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 9,
|
||||
radius: () => ['70%', '90%'],
|
||||
data: () => [0, 0]
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 9,
|
||||
radius: () => ['70%', '90%'],
|
||||
data: () => [0, 0]
|
||||
})
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
const formatNumber = (num: number) => {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: props.data.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => props.data.every((val) => val === 0),
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.color,
|
||||
() => props.radius,
|
||||
() => props.currentValue,
|
||||
() => props.previousValue
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: props.data.every((val) => val === 0)
|
||||
},
|
||||
checkEmpty: () => props.data.every((val) => val === 0),
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.color,
|
||||
() => props.radius,
|
||||
() => props.currentValue,
|
||||
() => props.previousValue
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.data[0],
|
||||
name: props.currentValue,
|
||||
itemStyle: { color: computedColor }
|
||||
},
|
||||
{
|
||||
value: props.data[1],
|
||||
name: props.previousValue,
|
||||
itemStyle: { color: '#e6e8f7' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
avoidLabelOverlap: false,
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: props.data[0],
|
||||
name: props.currentValue,
|
||||
itemStyle: { color: computedColor }
|
||||
},
|
||||
{
|
||||
value: props.data[1],
|
||||
name: props.previousValue,
|
||||
itemStyle: { color: '#e6e8f7' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,89 +1,89 @@
|
||||
<!-- 图片卡片 -->
|
||||
<template>
|
||||
<div class="w-full c-p" @click="handleClick">
|
||||
<div class="art-card overflow-hidden">
|
||||
<div class="relative w-full aspect-[16/10] overflow-hidden">
|
||||
<ElImage
|
||||
:src="props.imageUrl"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
|
||||
<ElIcon><Picture /></ElIcon>
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
<div
|
||||
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
|
||||
v-if="props.readTime"
|
||||
>
|
||||
{{ props.readTime }} 阅读
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full c-p" @click="handleClick">
|
||||
<div class="art-card overflow-hidden">
|
||||
<div class="relative w-full aspect-[16/10] overflow-hidden">
|
||||
<ElImage
|
||||
:src="props.imageUrl"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
||||
>
|
||||
<template #placeholder>
|
||||
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
|
||||
<ElIcon><Picture /></ElIcon>
|
||||
</div>
|
||||
</template>
|
||||
</ElImage>
|
||||
<div
|
||||
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
|
||||
v-if="props.readTime"
|
||||
>
|
||||
{{ props.readTime }} 阅读
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<div
|
||||
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
|
||||
v-if="props.category"
|
||||
>
|
||||
{{ props.category }}
|
||||
</div>
|
||||
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
|
||||
<div class="flex-c gap-4 text-xs text-g-600">
|
||||
<span class="flex-c gap-1" v-if="props.views">
|
||||
<ElIcon class="text-base"><View /></ElIcon>
|
||||
{{ props.views }}
|
||||
</span>
|
||||
<span class="flex-c gap-1" v-if="props.comments">
|
||||
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
|
||||
{{ props.comments }}
|
||||
</span>
|
||||
<span>{{ props.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div
|
||||
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
|
||||
v-if="props.category"
|
||||
>
|
||||
{{ props.category }}
|
||||
</div>
|
||||
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
|
||||
<div class="flex-c gap-4 text-xs text-g-600">
|
||||
<span class="flex-c gap-1" v-if="props.views">
|
||||
<ElIcon class="text-base"><View /></ElIcon>
|
||||
{{ props.views }}
|
||||
</span>
|
||||
<span class="flex-c gap-1" v-if="props.comments">
|
||||
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
|
||||
{{ props.comments }}
|
||||
</span>
|
||||
<span>{{ props.date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
/** 图片地址 */
|
||||
imageUrl: string
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 分类 */
|
||||
category?: string
|
||||
/** 阅读时间 */
|
||||
readTime?: string
|
||||
/** 浏览量 */
|
||||
views?: number
|
||||
/** 评论数 */
|
||||
comments?: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
}
|
||||
interface Props {
|
||||
/** 图片地址 */
|
||||
imageUrl: string
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 分类 */
|
||||
category?: string
|
||||
/** 阅读时间 */
|
||||
readTime?: string
|
||||
/** 浏览量 */
|
||||
views?: number
|
||||
/** 评论数 */
|
||||
comments?: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
imageUrl: '',
|
||||
title: '',
|
||||
category: '',
|
||||
readTime: '',
|
||||
views: 0,
|
||||
comments: 0,
|
||||
date: ''
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
imageUrl: '',
|
||||
title: '',
|
||||
category: '',
|
||||
readTime: '',
|
||||
views: 0,
|
||||
comments: 0,
|
||||
date: ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', card: Props): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', card: Props): void
|
||||
}>()
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props)
|
||||
}
|
||||
const handleClick = () => {
|
||||
emit('click', props)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,126 +1,130 @@
|
||||
<!-- 折线图卡片 -->
|
||||
<template>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-2.5 flex-b items-start p-5">
|
||||
<div>
|
||||
<p class="text-2xl font-medium leading-none">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="[
|
||||
percentage > 0 ? 'text-success' : 'text-danger',
|
||||
isMiniChart ? 'absolute bottom-5' : ''
|
||||
]"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
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' : ''"
|
||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||
<div class="mb-2.5 flex-b items-start p-5">
|
||||
<div>
|
||||
<p class="text-2xl font-medium leading-none">
|
||||
{{ value }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="text-sm font-medium"
|
||||
:class="[
|
||||
percentage > 0 ? 'text-success' : 'text-danger',
|
||||
isMiniChart ? 'absolute bottom-5' : ''
|
||||
]"
|
||||
>
|
||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||
</div>
|
||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
|
||||
{{ date }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
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' : ''
|
||||
"
|
||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
|
||||
defineOptions({ name: 'ArtLineChartCard' })
|
||||
defineOptions({ name: 'ArtLineChartCard' })
|
||||
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 是否显示区域颜色 */
|
||||
showAreaColor?: boolean
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
interface Props {
|
||||
/** 数值 */
|
||||
value: number
|
||||
/** 标签 */
|
||||
label: string
|
||||
/** 百分比 */
|
||||
percentage: number
|
||||
/** 日期 */
|
||||
date?: string
|
||||
/** 高度 */
|
||||
height?: number
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 是否显示区域颜色 */
|
||||
showAreaColor?: boolean
|
||||
/** 图表数据 */
|
||||
chartData: number[]
|
||||
/** 是否为迷你图表 */
|
||||
isMiniChart?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 11
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !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],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef } = useChartComponent({
|
||||
props: {
|
||||
height: `${props.height}rem`,
|
||||
loading: false,
|
||||
isEmpty: !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],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.color || useChartOps().themeColor
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: computedColor
|
||||
},
|
||||
areaStyle: props.showAreaColor
|
||||
? {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.2).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.01).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
grid: {
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
show: false,
|
||||
boundaryGap: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: props.chartData,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showSymbol: false,
|
||||
lineStyle: {
|
||||
width: 3,
|
||||
color: computedColor
|
||||
},
|
||||
areaStyle: props.showAreaColor
|
||||
? {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.2).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2)
|
||||
.rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: props.color
|
||||
? hexToRgba(props.color, 0.01).rgba
|
||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01)
|
||||
.rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,86 +1,89 @@
|
||||
<!-- 进度条卡片 -->
|
||||
<template>
|
||||
<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 v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
|
||||
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
|
||||
</div>
|
||||
<div>
|
||||
<ArtCountTo
|
||||
class="mb-1 block text-2xl font-semibold"
|
||||
:target="percentage"
|
||||
:duration="2000"
|
||||
suffix="%"
|
||||
:style="{ textAlign: icon ? 'right' : 'left' }"
|
||||
/>
|
||||
<p class="text-sm text-g-500">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ElProgress
|
||||
:percentage="currentPercentage"
|
||||
:stroke-width="strokeWidth"
|
||||
:show-text="false"
|
||||
:color="color"
|
||||
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
|
||||
/>
|
||||
</div>
|
||||
<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 v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
|
||||
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
|
||||
</div>
|
||||
<div>
|
||||
<ArtCountTo
|
||||
class="mb-1 block text-2xl font-semibold"
|
||||
:target="percentage"
|
||||
:duration="2000"
|
||||
suffix="%"
|
||||
:style="{ textAlign: icon ? 'right' : 'left' }"
|
||||
/>
|
||||
<p class="text-sm text-g-500">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ElProgress
|
||||
:percentage="currentPercentage"
|
||||
:stroke-width="strokeWidth"
|
||||
:show-text="false"
|
||||
:color="color"
|
||||
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtProgressCard' })
|
||||
defineOptions({ name: 'ArtProgressCard' })
|
||||
|
||||
interface Props {
|
||||
/** 进度百分比 */
|
||||
percentage: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 图标样式 */
|
||||
iconStyle?: string
|
||||
/** 进度条宽度 */
|
||||
strokeWidth?: number
|
||||
}
|
||||
interface Props {
|
||||
/** 进度百分比 */
|
||||
percentage: number
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 颜色 */
|
||||
color?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 图标样式 */
|
||||
iconStyle?: string
|
||||
/** 进度条宽度 */
|
||||
strokeWidth?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
strokeWidth: 5,
|
||||
color: '#67C23A'
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
strokeWidth: 5,
|
||||
color: '#67C23A'
|
||||
})
|
||||
|
||||
const animationDuration = 500
|
||||
const currentPercentage = ref(0)
|
||||
const animationDuration = 500
|
||||
const currentPercentage = ref(0)
|
||||
|
||||
const animateProgress = () => {
|
||||
const startTime = Date.now()
|
||||
const startValue = currentPercentage.value
|
||||
const endValue = props.percentage
|
||||
const animateProgress = () => {
|
||||
const startTime = Date.now()
|
||||
const startValue = currentPercentage.value
|
||||
const endValue = props.percentage
|
||||
|
||||
const animate = () => {
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / animationDuration, 1)
|
||||
const animate = () => {
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / animationDuration, 1)
|
||||
|
||||
currentPercentage.value = startValue + (endValue - startValue) * progress
|
||||
currentPercentage.value = startValue + (endValue - startValue) * progress
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateProgress()
|
||||
})
|
||||
onMounted(() => {
|
||||
animateProgress()
|
||||
})
|
||||
|
||||
// 当 percentage 属性变化时重新执行动画
|
||||
watch(
|
||||
() => props.percentage,
|
||||
() => {
|
||||
animateProgress()
|
||||
}
|
||||
)
|
||||
// 当 percentage 属性变化时重新执行动画
|
||||
watch(
|
||||
() => props.percentage,
|
||||
() => {
|
||||
animateProgress()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -1,67 +1,71 @@
|
||||
<!-- 统计卡片 -->
|
||||
<template>
|
||||
<div
|
||||
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="boxStyle"
|
||||
>
|
||||
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle">
|
||||
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<ArtCountTo
|
||||
class="m-0 text-2xl font-medium"
|
||||
v-if="count !== undefined"
|
||||
:target="count"
|
||||
:duration="2000"
|
||||
:decimals="decimals"
|
||||
:separator="separator"
|
||||
/>
|
||||
<p
|
||||
class="mt-1 text-sm text-g-500 opacity-90"
|
||||
:style="{ color: textColor }"
|
||||
v-if="description"
|
||||
>{{ description }}</p
|
||||
>
|
||||
</div>
|
||||
<div v-if="showArrow">
|
||||
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||
:class="boxStyle"
|
||||
>
|
||||
<div
|
||||
v-if="icon"
|
||||
class="mr-4 size-11 flex-cc rounded-lg text-xl text-white"
|
||||
:class="iconStyle"
|
||||
>
|
||||
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
|
||||
{{ title }}
|
||||
</p>
|
||||
<ArtCountTo
|
||||
class="m-0 text-2xl font-medium"
|
||||
v-if="count !== undefined"
|
||||
:target="count"
|
||||
:duration="2000"
|
||||
:decimals="decimals"
|
||||
:separator="separator"
|
||||
/>
|
||||
<p
|
||||
class="mt-1 text-sm text-g-500 opacity-90"
|
||||
:style="{ color: textColor }"
|
||||
v-if="description"
|
||||
>{{ description }}</p
|
||||
>
|
||||
</div>
|
||||
<div v-if="showArrow">
|
||||
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtStatsCard' })
|
||||
defineOptions({ name: 'ArtStatsCard' })
|
||||
|
||||
interface StatsCardProps {
|
||||
/** 盒子样式 */
|
||||
boxStyle?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 图标样式 */
|
||||
iconStyle?: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 数值 */
|
||||
count?: number
|
||||
/** 小数位 */
|
||||
decimals?: number
|
||||
/** 分隔符 */
|
||||
separator?: string
|
||||
/** 描述 */
|
||||
description: string
|
||||
/** 文本颜色 */
|
||||
textColor?: string
|
||||
/** 是否显示箭头 */
|
||||
showArrow?: boolean
|
||||
}
|
||||
interface StatsCardProps {
|
||||
/** 盒子样式 */
|
||||
boxStyle?: string
|
||||
/** 图标 */
|
||||
icon?: string
|
||||
/** 图标样式 */
|
||||
iconStyle?: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 数值 */
|
||||
count?: number
|
||||
/** 小数位 */
|
||||
decimals?: number
|
||||
/** 分隔符 */
|
||||
separator?: string
|
||||
/** 描述 */
|
||||
description: string
|
||||
/** 文本颜色 */
|
||||
textColor?: string
|
||||
/** 是否显示箭头 */
|
||||
showArrow?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<StatsCardProps>(), {
|
||||
iconSize: 30,
|
||||
iconBgRadius: 50,
|
||||
decimals: 0,
|
||||
separator: ','
|
||||
})
|
||||
withDefaults(defineProps<StatsCardProps>(), {
|
||||
iconSize: 30,
|
||||
iconBgRadius: 50,
|
||||
decimals: 0,
|
||||
separator: ','
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,69 +1,71 @@
|
||||
<!-- 时间轴列表卡片 -->
|
||||
<template>
|
||||
<div class="art-card p-5">
|
||||
<div class="pb-3.5">
|
||||
<p class="text-lg font-medium">{{ title }}</p>
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<ElTimeline class="!pl-0.5">
|
||||
<ElTimelineItem
|
||||
v-for="item in list"
|
||||
:key="item.time"
|
||||
:timestamp="item.time"
|
||||
:placement="TIMELINE_PLACEMENT"
|
||||
:color="item.status"
|
||||
:center="true"
|
||||
>
|
||||
<div class="flex-c gap-3">
|
||||
<div class="flex-c gap-2">
|
||||
<span class="text-sm">{{ item.content }}</span>
|
||||
<span v-if="item.code" class="text-sm text-theme"> #{{ item.code }} </span>
|
||||
</div>
|
||||
</div>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
<div class="art-card p-5">
|
||||
<div class="pb-3.5">
|
||||
<p class="text-lg font-medium">{{ title }}</p>
|
||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ElScrollbar :style="{ height: maxHeight }">
|
||||
<ElTimeline class="!pl-0.5">
|
||||
<ElTimelineItem
|
||||
v-for="item in list"
|
||||
:key="item.time"
|
||||
:timestamp="item.time"
|
||||
:placement="TIMELINE_PLACEMENT"
|
||||
:color="item.status"
|
||||
:center="true"
|
||||
>
|
||||
<div class="flex-c gap-3">
|
||||
<div class="flex-c gap-2">
|
||||
<span class="text-sm">{{ item.content }}</span>
|
||||
<span v-if="item.code" class="text-sm text-theme">
|
||||
#{{ item.code }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</ElTimelineItem>
|
||||
</ElTimeline>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtTimelineListCard' })
|
||||
defineOptions({ name: 'ArtTimelineListCard' })
|
||||
|
||||
// 常量配置
|
||||
const ITEM_HEIGHT = 65
|
||||
const TIMELINE_PLACEMENT = 'top'
|
||||
const DEFAULT_MAX_COUNT = 5
|
||||
// 常量配置
|
||||
const ITEM_HEIGHT = 65
|
||||
const TIMELINE_PLACEMENT = 'top'
|
||||
const DEFAULT_MAX_COUNT = 5
|
||||
|
||||
interface TimelineItem {
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 状态颜色 */
|
||||
status: string
|
||||
/** 内容 */
|
||||
content: string
|
||||
/** 代码标识 */
|
||||
code?: string
|
||||
}
|
||||
interface TimelineItem {
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 状态颜色 */
|
||||
status: string
|
||||
/** 内容 */
|
||||
content: string
|
||||
/** 代码标识 */
|
||||
code?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 时间轴列表数据 */
|
||||
list: TimelineItem[]
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle?: string
|
||||
/** 最大显示数量 */
|
||||
maxCount?: number
|
||||
}
|
||||
interface Props {
|
||||
/** 时间轴列表数据 */
|
||||
list: TimelineItem[]
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 副标题 */
|
||||
subtitle?: string
|
||||
/** 最大显示数量 */
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
// Props 定义和验证
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
maxCount: DEFAULT_MAX_COUNT
|
||||
})
|
||||
// Props 定义和验证
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
maxCount: DEFAULT_MAX_COUNT
|
||||
})
|
||||
|
||||
// 计算最大高度
|
||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||
// 计算最大高度
|
||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||
</script>
|
||||
|
||||
@@ -1,203 +1,209 @@
|
||||
<!-- 柱状图 -->
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtBarChart' })
|
||||
defineOptions({ name: 'ArtBarChart' })
|
||||
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
borderRadius: 4,
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
borderRadius: 4,
|
||||
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
barWidth: '40%',
|
||||
stack: false,
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
barWidth: '40%',
|
||||
stack: false,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 判断是否为多数据
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
// 判断是否为多数据
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
|
||||
// 获取颜色配置
|
||||
const getColor = (customColor?: string, index?: number) => {
|
||||
if (customColor) return customColor
|
||||
// 获取颜色配置
|
||||
const getColor = (customColor?: string, index?: number) => {
|
||||
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 new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary')
|
||||
}
|
||||
])
|
||||
}
|
||||
// 默认渐变色
|
||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary')
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 创建渐变色
|
||||
const createGradientColor = (color: string) => {
|
||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: color
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: color
|
||||
}
|
||||
])
|
||||
}
|
||||
// 创建渐变色
|
||||
const createGradientColor = (color: string) => {
|
||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: color
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: color
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 获取基础样式配置
|
||||
const getBaseItemStyle = (
|
||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||
) => ({
|
||||
borderRadius: props.borderRadius,
|
||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||
})
|
||||
// 获取基础样式配置
|
||||
const getBaseItemStyle = (
|
||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||
) => ({
|
||||
borderRadius: props.borderRadius,
|
||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||
})
|
||||
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||
barWidth?: string | number
|
||||
stack?: string
|
||||
}) => {
|
||||
const animationConfig = getAnimationConfig()
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||
barWidth?: string | number
|
||||
stack?: string
|
||||
}) => {
|
||||
const animationConfig = getAnimationConfig()
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'bar' as const,
|
||||
stack: config.stack,
|
||||
itemStyle: getBaseItemStyle(config.color),
|
||||
barWidth: config.barWidth || props.barWidth,
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'bar' as const,
|
||||
stack: config.stack,
|
||||
itemStyle: getBaseItemStyle(config.color),
|
||||
barWidth: config.barWidth || props.barWidth,
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every(
|
||||
(item) => !item.data?.length || item.data.every((val) => val === 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
||||
top: 15,
|
||||
right: 0,
|
||||
left: 0
|
||||
}),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
grid: getGridWithLegend(
|
||||
props.showLegend && isMultipleData.value,
|
||||
props.legendPosition,
|
||||
{
|
||||
top: 15,
|
||||
right: 0,
|
||||
left: 0
|
||||
}
|
||||
),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const computedColor = getColor(props.colors[index], index)
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const computedColor = getColor(props.colors[index], index)
|
||||
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: computedColor,
|
||||
barWidth: item.barWidth,
|
||||
stack: props.stack ? item.stack || 'total' : undefined
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = props.data as number[]
|
||||
const computedColor = getColor()
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: computedColor,
|
||||
barWidth: item.barWidth,
|
||||
stack: props.stack ? item.stack || 'total' : undefined
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = props.data as number[]
|
||||
const computedColor = getColor()
|
||||
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor
|
||||
})
|
||||
]
|
||||
}
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
<!-- 双向堆叠柱状图 -->
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
|
||||
import type { BidirectionalBarChartProps } from '@/types/component/chart'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
|
||||
import type { BidirectionalBarChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtDualBarCompareChart' })
|
||||
defineOptions({ name: 'ArtDualBarCompareChart' })
|
||||
|
||||
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
positiveData: () => [],
|
||||
negativeData: () => [],
|
||||
xAxisData: () => [],
|
||||
positiveName: '正向数据',
|
||||
negativeName: '负向数据',
|
||||
barWidth: 16,
|
||||
yAxisMin: -100,
|
||||
yAxisMax: 100,
|
||||
// 数据配置
|
||||
positiveData: () => [],
|
||||
negativeData: () => [],
|
||||
xAxisData: () => [],
|
||||
positiveName: '正向数据',
|
||||
negativeName: '负向数据',
|
||||
barWidth: 16,
|
||||
yAxisMin: -100,
|
||||
yAxisMax: 100,
|
||||
|
||||
// 样式配置
|
||||
showDataLabel: false,
|
||||
positiveBorderRadius: () => [10, 10, 0, 0],
|
||||
negativeBorderRadius: () => [0, 0, 10, 10],
|
||||
// 样式配置
|
||||
showDataLabel: false,
|
||||
positiveBorderRadius: () => [10, 10, 0, 0],
|
||||
negativeBorderRadius: () => [0, 0, 10, 10],
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: false,
|
||||
showSplitLine: false,
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: false,
|
||||
showSplitLine: false,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 创建系列配置的辅助函数
|
||||
const createSeriesConfig = (config: {
|
||||
name: string
|
||||
data: number[]
|
||||
borderRadius: number | number[]
|
||||
labelPosition: 'top' | 'bottom'
|
||||
colorIndex: number
|
||||
formatter?: (params: unknown) => string
|
||||
}): BarSeriesOption => {
|
||||
const { fontColor } = useChartOps()
|
||||
const animationConfig = getAnimationConfig()
|
||||
// 创建系列配置的辅助函数
|
||||
const createSeriesConfig = (config: {
|
||||
name: string
|
||||
data: number[]
|
||||
borderRadius: number | number[]
|
||||
labelPosition: 'top' | 'bottom'
|
||||
colorIndex: number
|
||||
formatter?: (params: unknown) => string
|
||||
}): BarSeriesOption => {
|
||||
const { fontColor } = useChartOps()
|
||||
const animationConfig = getAnimationConfig()
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
barWidth: props.barWidth,
|
||||
barGap: '-100%',
|
||||
data: config.data,
|
||||
itemStyle: {
|
||||
borderRadius: config.borderRadius,
|
||||
color: props.colors[config.colorIndex]
|
||||
},
|
||||
label: {
|
||||
show: props.showDataLabel,
|
||||
position: config.labelPosition,
|
||||
formatter:
|
||||
config.formatter ||
|
||||
((params: unknown) => String((params as Record<string, unknown>).value)),
|
||||
color: fontColor,
|
||||
fontSize: 12
|
||||
},
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: config.name,
|
||||
type: 'bar',
|
||||
stack: 'total',
|
||||
barWidth: props.barWidth,
|
||||
barGap: '-100%',
|
||||
data: config.data,
|
||||
itemStyle: {
|
||||
borderRadius: config.borderRadius,
|
||||
color: props.colors[config.colorIndex]
|
||||
},
|
||||
label: {
|
||||
show: props.showDataLabel,
|
||||
position: config.labelPosition,
|
||||
formatter:
|
||||
config.formatter ||
|
||||
((params: unknown) => String((params as Record<string, unknown>).value)),
|
||||
color: fontColor,
|
||||
fontSize: 12
|
||||
},
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 使用图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
props.isEmpty ||
|
||||
!props.positiveData.length ||
|
||||
!props.negativeData.length ||
|
||||
(props.positiveData.every((val) => val === 0) &&
|
||||
props.negativeData.every((val) => val === 0))
|
||||
)
|
||||
},
|
||||
watchSources: [
|
||||
() => props.positiveData,
|
||||
() => props.negativeData,
|
||||
() => props.xAxisData,
|
||||
() => props.colors
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
// 处理负向数据,确保为负值
|
||||
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
|
||||
// 使用图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
props.isEmpty ||
|
||||
!props.positiveData.length ||
|
||||
!props.negativeData.length ||
|
||||
(props.positiveData.every((val) => val === 0) &&
|
||||
props.negativeData.every((val) => val === 0))
|
||||
)
|
||||
},
|
||||
watchSources: [
|
||||
() => props.positiveData,
|
||||
() => props.negativeData,
|
||||
() => props.xAxisData,
|
||||
() => props.colors
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
// 处理负向数据,确保为负值
|
||||
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
|
||||
|
||||
// 优化的Grid配置
|
||||
const gridConfig = {
|
||||
top: props.showLegend ? 50 : 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0, // 增加底部间距
|
||||
containLabel: true
|
||||
}
|
||||
// 优化的Grid配置
|
||||
const gridConfig = {
|
||||
top: props.showLegend ? 50 : 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
bottom: 0, // 增加底部间距
|
||||
containLabel: true
|
||||
}
|
||||
|
||||
const options: EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 1000,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
|
||||
const options: EChartsOption = {
|
||||
backgroundColor: 'transparent',
|
||||
animation: true,
|
||||
animationDuration: 1000,
|
||||
animationEasing: 'cubicOut',
|
||||
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
|
||||
|
||||
// 优化的提示框配置
|
||||
tooltip: props.showTooltip
|
||||
? {
|
||||
...getTooltipStyle(),
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'none' // 去除指示线
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
// 优化的提示框配置
|
||||
tooltip: props.showTooltip
|
||||
? {
|
||||
...getTooltipStyle(),
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'none' // 去除指示线
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
|
||||
// 图例配置
|
||||
legend: props.showLegend
|
||||
? {
|
||||
...getLegendStyle(props.legendPosition),
|
||||
data: [props.negativeName, props.positiveName]
|
||||
}
|
||||
: undefined,
|
||||
// 图例配置
|
||||
legend: props.showLegend
|
||||
? {
|
||||
...getLegendStyle(props.legendPosition),
|
||||
data: [props.negativeName, props.positiveName]
|
||||
}
|
||||
: undefined,
|
||||
|
||||
// X轴配置
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
boundaryGap: true
|
||||
},
|
||||
// X轴配置
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
boundaryGap: true
|
||||
},
|
||||
|
||||
// Y轴配置
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: props.yAxisMin,
|
||||
max: props.yAxisMax,
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
// Y轴配置
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: props.yAxisMin,
|
||||
max: props.yAxisMax,
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
|
||||
// 系列配置
|
||||
series: [
|
||||
// 负向数据系列
|
||||
createSeriesConfig({
|
||||
name: props.negativeName,
|
||||
data: processedNegativeData,
|
||||
borderRadius: props.negativeBorderRadius,
|
||||
labelPosition: 'bottom',
|
||||
colorIndex: 1,
|
||||
formatter: (params: unknown) =>
|
||||
String(Math.abs((params as Record<string, unknown>).value as number))
|
||||
}),
|
||||
// 正向数据系列
|
||||
createSeriesConfig({
|
||||
name: props.positiveName,
|
||||
data: props.positiveData,
|
||||
borderRadius: props.positiveBorderRadius,
|
||||
labelPosition: 'top',
|
||||
colorIndex: 0
|
||||
})
|
||||
]
|
||||
}
|
||||
// 系列配置
|
||||
series: [
|
||||
// 负向数据系列
|
||||
createSeriesConfig({
|
||||
name: props.negativeName,
|
||||
data: processedNegativeData,
|
||||
borderRadius: props.negativeBorderRadius,
|
||||
labelPosition: 'bottom',
|
||||
colorIndex: 1,
|
||||
formatter: (params: unknown) =>
|
||||
String(Math.abs((params as Record<string, unknown>).value as number))
|
||||
}),
|
||||
// 正向数据系列
|
||||
createSeriesConfig({
|
||||
name: props.positiveName,
|
||||
data: props.positiveData,
|
||||
borderRadius: props.positiveBorderRadius,
|
||||
labelPosition: 'top',
|
||||
colorIndex: 0
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,208 +1,214 @@
|
||||
<!-- 水平柱状图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtHBarChart' })
|
||||
defineOptions({ name: 'ArtHBarChart' })
|
||||
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
barWidth: '36%',
|
||||
stack: false,
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
barWidth: '36%',
|
||||
stack: false,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 判断是否为多数据
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
// 判断是否为多数据
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
|
||||
// 获取颜色配置
|
||||
const getColor = (customColor?: string, index?: number) => {
|
||||
if (customColor) return customColor
|
||||
// 获取颜色配置
|
||||
const getColor = (customColor?: string, index?: number) => {
|
||||
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 new graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
}
|
||||
])
|
||||
}
|
||||
// 默认渐变色
|
||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: getCssVar('--el-color-primary')
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: getCssVar('--el-color-primary-light-4')
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 创建渐变色
|
||||
const createGradientColor = (color: string) => {
|
||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: color
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: color
|
||||
}
|
||||
])
|
||||
}
|
||||
// 创建渐变色
|
||||
const createGradientColor = (color: string) => {
|
||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||
{
|
||||
offset: 0,
|
||||
color: color
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: color
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
// 获取基础样式配置
|
||||
const getBaseItemStyle = (
|
||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||
) => ({
|
||||
borderRadius: 4,
|
||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||
})
|
||||
// 获取基础样式配置
|
||||
const getBaseItemStyle = (
|
||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||
) => ({
|
||||
borderRadius: 4,
|
||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||
})
|
||||
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||
barWidth?: string | number
|
||||
stack?: string
|
||||
}) => {
|
||||
const animationConfig = getAnimationConfig()
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||
barWidth?: string | number
|
||||
stack?: string
|
||||
}) => {
|
||||
const animationConfig = getAnimationConfig()
|
||||
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'bar' as const,
|
||||
stack: config.stack,
|
||||
itemStyle: getBaseItemStyle(config.color),
|
||||
barWidth: config.barWidth || props.barWidth,
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'bar' as const,
|
||||
stack: config.stack,
|
||||
itemStyle: getBaseItemStyle(config.color),
|
||||
barWidth: config.barWidth || props.barWidth,
|
||||
...animationConfig
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every(
|
||||
(item) => !item.data?.length || item.data.every((val) => val === 0)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
||||
top: 15,
|
||||
right: 0,
|
||||
left: 0
|
||||
}),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine)
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
grid: getGridWithLegend(
|
||||
props.showLegend && isMultipleData.value,
|
||||
props.legendPosition,
|
||||
{
|
||||
top: 15,
|
||||
right: 0,
|
||||
left: 0
|
||||
}
|
||||
),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const computedColor = getColor(props.colors[index], index)
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as BarDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const computedColor = getColor(props.colors[index], index)
|
||||
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: computedColor,
|
||||
barWidth: item.barWidth,
|
||||
stack: props.stack ? item.stack || 'total' : undefined
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = props.data as number[]
|
||||
const computedColor = getColor()
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: computedColor,
|
||||
barWidth: item.barWidth,
|
||||
stack: props.stack ? item.stack || 'total' : undefined
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = props.data as number[]
|
||||
const computedColor = getColor()
|
||||
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor
|
||||
})
|
||||
]
|
||||
}
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
})
|
||||
return options
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,90 +1,91 @@
|
||||
<!-- k线图表 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { KLineChartProps } from '@/types/component/chart'
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { KLineChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtKLineChart' })
|
||||
defineOptions({ name: 'ArtKLineChart' })
|
||||
|
||||
const props = withDefaults(defineProps<KLineChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<KLineChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [],
|
||||
showDataZoom: false,
|
||||
dataZoomStart: 0,
|
||||
dataZoomEnd: 100
|
||||
})
|
||||
// 数据配置
|
||||
data: () => [],
|
||||
showDataZoom: false,
|
||||
dataZoomStart: 0,
|
||||
dataZoomEnd: 100
|
||||
})
|
||||
|
||||
// 获取实际使用的颜色
|
||||
const getActualColors = () => {
|
||||
const defaultUpColor = '#4C87F3'
|
||||
const defaultDownColor = '#8BD8FC'
|
||||
// 获取实际使用的颜色
|
||||
const getActualColors = () => {
|
||||
const defaultUpColor = '#4C87F3'
|
||||
const defaultDownColor = '#8BD8FC'
|
||||
|
||||
return {
|
||||
upColor: props.colors?.[0] || defaultUpColor,
|
||||
downColor: props.colors?.[1] || defaultDownColor
|
||||
}
|
||||
}
|
||||
return {
|
||||
upColor: props.colors?.[0] || defaultUpColor,
|
||||
downColor: props.colors?.[1] || defaultDownColor
|
||||
}
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
!props.data?.length ||
|
||||
props.data.every(
|
||||
(item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
|
||||
)
|
||||
)
|
||||
},
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.colors,
|
||||
() => props.showDataZoom,
|
||||
() => props.dataZoomStart,
|
||||
() => props.dataZoomEnd
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const { upColor, downColor } = getActualColors()
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
!props.data?.length ||
|
||||
props.data.every(
|
||||
(item) =>
|
||||
item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
|
||||
)
|
||||
)
|
||||
},
|
||||
watchSources: [
|
||||
() => props.data,
|
||||
() => props.colors,
|
||||
() => props.showDataZoom,
|
||||
() => props.dataZoomStart,
|
||||
() => props.dataZoomEnd
|
||||
],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const { upColor, downColor } = getActualColors()
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: props.showDataZoom ? 80 : 20,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: getTooltipStyle('axis', {
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
},
|
||||
formatter: (params: Array<{ name: string; data: number[] }>) => {
|
||||
const param = params[0]
|
||||
const data = param.data
|
||||
return `
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: props.showDataZoom ? 80 : 20,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: getTooltipStyle('axis', {
|
||||
axisPointer: {
|
||||
type: 'cross'
|
||||
},
|
||||
formatter: (params: Array<{ name: string; data: number[] }>) => {
|
||||
const param = params[0]
|
||||
const data = param.data
|
||||
return `
|
||||
<div style="padding: 5px;">
|
||||
<div><strong>时间:</strong>${param.name}</div>
|
||||
<div><strong>开盘:</strong>${data[0]}</div>
|
||||
@@ -93,60 +94,65 @@
|
||||
<div><strong>最高:</strong>${data[3]}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}),
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.data.map((item) => item.time),
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
axisLabel: getAxisLabelStyle(true)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: getAxisLabelStyle(true),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
splitLine: getSplitLineStyle(true)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'candlestick',
|
||||
data: props.data.map((item) => [item.open, item.close, item.low, item.high]),
|
||||
itemStyle: {
|
||||
color: upColor,
|
||||
color0: downColor,
|
||||
borderColor: upColor,
|
||||
borderColor0: downColor,
|
||||
borderWidth: 1
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
},
|
||||
...getAnimationConfig()
|
||||
}
|
||||
],
|
||||
dataZoom: props.showDataZoom
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
start: props.dataZoomStart,
|
||||
end: props.dataZoomEnd
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
type: 'slider',
|
||||
top: '90%',
|
||||
start: props.dataZoomStart,
|
||||
end: props.dataZoomEnd
|
||||
}
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}),
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: props.data.map((item) => item.time),
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
axisLabel: getAxisLabelStyle(true)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
scale: true,
|
||||
axisLabel: getAxisLabelStyle(true),
|
||||
axisLine: getAxisLineStyle(true),
|
||||
splitLine: getSplitLineStyle(true)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'candlestick',
|
||||
data: props.data.map((item) => [
|
||||
item.open,
|
||||
item.close,
|
||||
item.low,
|
||||
item.high
|
||||
]),
|
||||
itemStyle: {
|
||||
color: upColor,
|
||||
color0: downColor,
|
||||
borderColor: upColor,
|
||||
borderColor0: downColor,
|
||||
borderWidth: 1
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
borderWidth: 2,
|
||||
shadowBlur: 10,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
},
|
||||
...getAnimationConfig()
|
||||
}
|
||||
],
|
||||
dataZoom: props.showDataZoom
|
||||
? [
|
||||
{
|
||||
type: 'inside',
|
||||
start: props.dataZoomStart,
|
||||
end: props.dataZoomEnd
|
||||
},
|
||||
{
|
||||
show: true,
|
||||
type: 'slider',
|
||||
top: '90%',
|
||||
start: props.dataZoomStart,
|
||||
end: props.dataZoomEnd
|
||||
}
|
||||
]
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,371 +1,377 @@
|
||||
<!-- 折线图,支持多组数据,支持阶梯式动画效果 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-[calc(100%+10px)]"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-[calc(100%+10px)]"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
|
||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtLineChart' })
|
||||
defineOptions({ name: 'ArtLineChart' })
|
||||
|
||||
const props = withDefaults(defineProps<LineChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<LineChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
lineWidth: 2.5,
|
||||
showAreaColor: false,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
symbolSize: 6,
|
||||
animationDelay: 200,
|
||||
// 数据配置
|
||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||
xAxisData: () => [],
|
||||
lineWidth: 2.5,
|
||||
showAreaColor: false,
|
||||
smooth: true,
|
||||
symbol: 'none',
|
||||
symbolSize: 6,
|
||||
animationDelay: 200,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 动画状态管理
|
||||
const isAnimating = ref(false)
|
||||
const animationTimers = ref<number[]>([])
|
||||
const animatedData = ref<number[] | LineDataItem[]>([])
|
||||
// 动画状态管理
|
||||
const isAnimating = ref(false)
|
||||
const animationTimers = ref<number[]>([])
|
||||
const animatedData = ref<number[] | LineDataItem[]>([])
|
||||
|
||||
// 清理所有定时器
|
||||
const clearAnimationTimers = () => {
|
||||
animationTimers.value.forEach((timer) => clearTimeout(timer))
|
||||
animationTimers.value = []
|
||||
}
|
||||
// 清理所有定时器
|
||||
const clearAnimationTimers = () => {
|
||||
animationTimers.value.forEach((timer) => clearTimeout(timer))
|
||||
animationTimers.value = []
|
||||
}
|
||||
|
||||
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
|
||||
const isMultipleData = computed(() => {
|
||||
return (
|
||||
Array.isArray(props.data) &&
|
||||
props.data.length > 0 &&
|
||||
typeof props.data[0] === 'object' &&
|
||||
'name' in props.data[0]
|
||||
)
|
||||
})
|
||||
|
||||
// 缓存计算的最大值,避免重复计算
|
||||
const maxValue = computed(() => {
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return multiData.reduce((max, item) => {
|
||||
if (item.data?.length) {
|
||||
const itemMax = Math.max(...item.data)
|
||||
return Math.max(max, itemMax)
|
||||
}
|
||||
return max
|
||||
}, 0)
|
||||
} else {
|
||||
const singleData = props.data as number[]
|
||||
return singleData?.length ? Math.max(...singleData) : 0
|
||||
}
|
||||
})
|
||||
// 缓存计算的最大值,避免重复计算
|
||||
const maxValue = computed(() => {
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return multiData.reduce((max, item) => {
|
||||
if (item.data?.length) {
|
||||
const itemMax = Math.max(...item.data)
|
||||
return Math.max(max, itemMax)
|
||||
}
|
||||
return max
|
||||
}, 0)
|
||||
} else {
|
||||
const singleData = props.data as number[]
|
||||
return singleData?.length ? Math.max(...singleData) : 0
|
||||
}
|
||||
})
|
||||
|
||||
// 初始化动画数据(优化:减少条件判断)
|
||||
const initAnimationData = (): number[] | LineDataItem[] => {
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return multiData.map((item) => ({
|
||||
...item,
|
||||
data: Array(item.data.length).fill(0)
|
||||
}))
|
||||
}
|
||||
const singleData = props.data as number[]
|
||||
return Array(singleData.length).fill(0)
|
||||
}
|
||||
// 初始化动画数据(优化:减少条件判断)
|
||||
const initAnimationData = (): number[] | LineDataItem[] => {
|
||||
if (isMultipleData.value) {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return multiData.map((item) => ({
|
||||
...item,
|
||||
data: Array(item.data.length).fill(0)
|
||||
}))
|
||||
}
|
||||
const singleData = props.data as number[]
|
||||
return Array(singleData.length).fill(0)
|
||||
}
|
||||
|
||||
// 复制真实数据(优化:使用结构化克隆)
|
||||
const copyRealData = (): number[] | LineDataItem[] => {
|
||||
if (isMultipleData.value) {
|
||||
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
|
||||
}
|
||||
return [...(props.data as number[])]
|
||||
}
|
||||
// 复制真实数据(优化:使用结构化克隆)
|
||||
const copyRealData = (): number[] | LineDataItem[] => {
|
||||
if (isMultipleData.value) {
|
||||
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
|
||||
}
|
||||
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 => {
|
||||
if (customColor) return customColor
|
||||
if (index !== undefined) return props.colors![index % props.colors!.length]
|
||||
return primaryColor.value
|
||||
}
|
||||
const getColor = (customColor?: string, index?: number): string => {
|
||||
if (customColor) return customColor
|
||||
if (index !== undefined) return props.colors![index % props.colors!.length]
|
||||
return primaryColor.value
|
||||
}
|
||||
|
||||
// 生成区域样式
|
||||
const generateAreaStyle = (item: LineDataItem, color: string) => {
|
||||
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
|
||||
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
|
||||
// 生成区域样式
|
||||
const generateAreaStyle = (item: LineDataItem, color: string) => {
|
||||
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
|
||||
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
|
||||
|
||||
const areaConfig = item.areaStyle || {}
|
||||
if (areaConfig.custom) return areaConfig.custom
|
||||
const areaConfig = item.areaStyle || {}
|
||||
if (areaConfig.custom) return areaConfig.custom
|
||||
|
||||
return {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
return {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// 生成单数据区域样式
|
||||
const generateSingleAreaStyle = () => {
|
||||
if (!props.showAreaColor) return undefined
|
||||
// 生成单数据区域样式
|
||||
const generateSingleAreaStyle = () => {
|
||||
if (!props.showAreaColor) return undefined
|
||||
|
||||
const color = getColor(props.colors[0])
|
||||
return {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(color, 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(color, 0.02).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
const color = getColor(props.colors[0])
|
||||
return {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: hexToRgba(color, 0.2).rgba
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: hexToRgba(color, 0.02).rgba
|
||||
}
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string
|
||||
smooth?: boolean
|
||||
symbol?: string
|
||||
symbolSize?: number
|
||||
lineWidth?: number
|
||||
areaStyle?: any
|
||||
}) => {
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'line' as const,
|
||||
color: config.color,
|
||||
smooth: config.smooth ?? props.smooth,
|
||||
symbol: config.symbol ?? props.symbol,
|
||||
symbolSize: config.symbolSize ?? props.symbolSize,
|
||||
lineStyle: {
|
||||
width: config.lineWidth ?? props.lineWidth,
|
||||
color: config.color
|
||||
},
|
||||
areaStyle: config.areaStyle,
|
||||
emphasis: {
|
||||
focus: 'series' as const,
|
||||
lineStyle: {
|
||||
width: (config.lineWidth ?? props.lineWidth) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 创建系列配置
|
||||
const createSeriesItem = (config: {
|
||||
name?: string
|
||||
data: number[]
|
||||
color?: string
|
||||
smooth?: boolean
|
||||
symbol?: string
|
||||
symbolSize?: number
|
||||
lineWidth?: number
|
||||
areaStyle?: any
|
||||
}) => {
|
||||
return {
|
||||
name: config.name,
|
||||
data: config.data,
|
||||
type: 'line' as const,
|
||||
color: config.color,
|
||||
smooth: config.smooth ?? props.smooth,
|
||||
symbol: config.symbol ?? props.symbol,
|
||||
symbolSize: config.symbolSize ?? props.symbolSize,
|
||||
lineStyle: {
|
||||
width: config.lineWidth ?? props.lineWidth,
|
||||
color: config.color
|
||||
},
|
||||
areaStyle: config.areaStyle,
|
||||
emphasis: {
|
||||
focus: 'series' as const,
|
||||
lineStyle: {
|
||||
width: (config.lineWidth ?? props.lineWidth) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生成图表配置
|
||||
const generateChartOptions = (isInitial = false): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
animation: true,
|
||||
animationDuration: isInitial ? 0 : 1300,
|
||||
animationDurationUpdate: isInitial ? 0 : 1300,
|
||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
||||
top: 15,
|
||||
right: 15,
|
||||
left: 0
|
||||
}),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue.value,
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
}
|
||||
}
|
||||
// 生成图表配置
|
||||
const generateChartOptions = (isInitial = false): EChartsOption => {
|
||||
const options: EChartsOption = {
|
||||
animation: true,
|
||||
animationDuration: isInitial ? 0 : 1300,
|
||||
animationDurationUpdate: isInitial ? 0 : 1300,
|
||||
grid: getGridWithLegend(
|
||||
props.showLegend && isMultipleData.value,
|
||||
props.legendPosition,
|
||||
{
|
||||
top: 15,
|
||||
right: 15,
|
||||
left: 0
|
||||
}
|
||||
),
|
||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: props.xAxisData,
|
||||
axisTick: getAxisTickStyle(),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
max: maxValue.value,
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
// 添加图例配置
|
||||
if (props.showLegend && isMultipleData.value) {
|
||||
options.legend = getLegendStyle(props.legendPosition)
|
||||
}
|
||||
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = animatedData.value as LineDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const itemColor = getColor(props.colors[index], index)
|
||||
const areaStyle = generateAreaStyle(item, itemColor)
|
||||
// 生成系列数据
|
||||
if (isMultipleData.value) {
|
||||
const multiData = animatedData.value as LineDataItem[]
|
||||
options.series = multiData.map((item, index) => {
|
||||
const itemColor = getColor(props.colors[index], index)
|
||||
const areaStyle = generateAreaStyle(item, itemColor)
|
||||
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: itemColor,
|
||||
smooth: item.smooth,
|
||||
symbol: item.symbol,
|
||||
lineWidth: item.lineWidth,
|
||||
areaStyle
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = animatedData.value as number[]
|
||||
const computedColor = getColor(props.colors[0])
|
||||
const areaStyle = generateSingleAreaStyle()
|
||||
return createSeriesItem({
|
||||
name: item.name,
|
||||
data: item.data,
|
||||
color: itemColor,
|
||||
smooth: item.smooth,
|
||||
symbol: item.symbol,
|
||||
lineWidth: item.lineWidth,
|
||||
areaStyle
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 单数据情况
|
||||
const singleData = animatedData.value as number[]
|
||||
const computedColor = getColor(props.colors[0])
|
||||
const areaStyle = generateSingleAreaStyle()
|
||||
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor,
|
||||
areaStyle
|
||||
})
|
||||
]
|
||||
}
|
||||
options.series = [
|
||||
createSeriesItem({
|
||||
data: singleData,
|
||||
color: computedColor,
|
||||
areaStyle
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
const updateChartOptions = (options: EChartsOption) => {
|
||||
initChart(options)
|
||||
}
|
||||
// 更新图表
|
||||
const updateChartOptions = (options: EChartsOption) => {
|
||||
initChart(options)
|
||||
}
|
||||
|
||||
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
|
||||
const initChartWithAnimation = () => {
|
||||
clearAnimationTimers()
|
||||
isAnimating.value = true
|
||||
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
|
||||
const initChartWithAnimation = () => {
|
||||
clearAnimationTimers()
|
||||
isAnimating.value = true
|
||||
|
||||
// 初始化为0值数据
|
||||
animatedData.value = initAnimationData()
|
||||
updateChartOptions(generateChartOptions(true))
|
||||
// 初始化为0值数据
|
||||
animatedData.value = initAnimationData()
|
||||
updateChartOptions(generateChartOptions(true))
|
||||
|
||||
if (isMultipleData.value) {
|
||||
// 多数据阶梯式动画
|
||||
const multiData = props.data as LineDataItem[]
|
||||
const currentAnimatedData = animatedData.value as LineDataItem[]
|
||||
if (isMultipleData.value) {
|
||||
// 多数据阶梯式动画
|
||||
const multiData = props.data as LineDataItem[]
|
||||
const currentAnimatedData = animatedData.value as LineDataItem[]
|
||||
|
||||
multiData.forEach((item, index) => {
|
||||
const timer = window.setTimeout(
|
||||
() => {
|
||||
currentAnimatedData[index] = { ...item, data: [...item.data] }
|
||||
animatedData.value = [...currentAnimatedData]
|
||||
updateChartOptions(generateChartOptions(false))
|
||||
},
|
||||
index * props.animationDelay + 100
|
||||
)
|
||||
multiData.forEach((item, index) => {
|
||||
const timer = window.setTimeout(
|
||||
() => {
|
||||
currentAnimatedData[index] = { ...item, data: [...item.data] }
|
||||
animatedData.value = [...currentAnimatedData]
|
||||
updateChartOptions(generateChartOptions(false))
|
||||
},
|
||||
index * props.animationDelay + 100
|
||||
)
|
||||
|
||||
animationTimers.value.push(timer)
|
||||
})
|
||||
animationTimers.value.push(timer)
|
||||
})
|
||||
|
||||
// 标记动画完成
|
||||
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
|
||||
const finishTimer = window.setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, totalDelay)
|
||||
animationTimers.value.push(finishTimer)
|
||||
} else {
|
||||
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
|
||||
nextTick(() => {
|
||||
animatedData.value = copyRealData()
|
||||
updateChartOptions(generateChartOptions(false))
|
||||
isAnimating.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
// 标记动画完成
|
||||
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
|
||||
const finishTimer = window.setTimeout(() => {
|
||||
isAnimating.value = false
|
||||
}, totalDelay)
|
||||
animationTimers.value.push(finishTimer)
|
||||
} else {
|
||||
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
|
||||
nextTick(() => {
|
||||
animatedData.value = copyRealData()
|
||||
updateChartOptions(generateChartOptions(false))
|
||||
isAnimating.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 空数据检查函数
|
||||
const checkIsEmpty = () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
// 空数据检查函数
|
||||
const checkIsEmpty = () => {
|
||||
// 检查单数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||
const singleData = props.data as number[]
|
||||
return !singleData.length || singleData.every((val) => val === 0)
|
||||
}
|
||||
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
// 检查多数据情况
|
||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||
const multiData = props.data as LineDataItem[]
|
||||
return (
|
||||
!multiData.length ||
|
||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend,
|
||||
isEmpty
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: checkIsEmpty,
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
onVisible: () => {
|
||||
// 当图表变为可见时,检查是否为空数据
|
||||
if (!isEmpty.value) {
|
||||
initChartWithAnimation()
|
||||
}
|
||||
},
|
||||
generateOptions: () => generateChartOptions(false)
|
||||
})
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
initChart,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getTooltipStyle,
|
||||
getLegendStyle,
|
||||
getGridWithLegend,
|
||||
isEmpty
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: checkIsEmpty,
|
||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||
onVisible: () => {
|
||||
// 当图表变为可见时,检查是否为空数据
|
||||
if (!isEmpty.value) {
|
||||
initChartWithAnimation()
|
||||
}
|
||||
},
|
||||
generateOptions: () => generateChartOptions(false)
|
||||
})
|
||||
|
||||
// 图表渲染函数(优化:防止动画期间重复触发)
|
||||
const renderChart = () => {
|
||||
if (!isAnimating.value && !isEmpty.value) {
|
||||
initChartWithAnimation()
|
||||
}
|
||||
}
|
||||
// 图表渲染函数(优化:防止动画期间重复触发)
|
||||
const renderChart = () => {
|
||||
if (!isAnimating.value && !isEmpty.value) {
|
||||
initChartWithAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
||||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true })
|
||||
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
||||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, {
|
||||
deep: true
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
renderChart()
|
||||
})
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
renderChart()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearAnimationTimers()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
clearAnimationTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,105 +1,108 @@
|
||||
<!-- 雷达图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { RadarChartProps } from '@/types/component/chart'
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { RadarChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtRadarChart' })
|
||||
defineOptions({ name: 'ArtRadarChart' })
|
||||
|
||||
const props = withDefaults(defineProps<RadarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<RadarChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
indicator: () => [],
|
||||
data: () => [],
|
||||
// 数据配置
|
||||
indicator: () => [],
|
||||
data: () => [],
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
|
||||
},
|
||||
watchSources: [() => props.data, () => props.indicator, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
return {
|
||||
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
|
||||
radar: {
|
||||
indicator: props.indicator,
|
||||
center: ['50%', '50%'],
|
||||
radius: '70%',
|
||||
axisName: {
|
||||
color: isDark.value ? '#ccc' : '#666',
|
||||
fontSize: 12
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? '#444' : '#e6e6e6'
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? '#444' : '#e6e6e6'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
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) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: props.colors[index % props.colors.length]
|
||||
},
|
||||
itemStyle: {
|
||||
color: props.colors[index % props.colors.length]
|
||||
},
|
||||
areaStyle: {
|
||||
color: props.colors[index % props.colors.length],
|
||||
opacity: 0.1
|
||||
},
|
||||
emphasis: {
|
||||
areaStyle: {
|
||||
opacity: 0.25
|
||||
},
|
||||
lineStyle: {
|
||||
width: 3
|
||||
}
|
||||
}
|
||||
})),
|
||||
...getAnimationConfig(200, 1800)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
!props.data?.length ||
|
||||
props.data.every((item) => item.value.every((val) => val === 0))
|
||||
)
|
||||
},
|
||||
watchSources: [() => props.data, () => props.indicator, () => props.colors],
|
||||
generateOptions: (): EChartsOption => {
|
||||
return {
|
||||
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
|
||||
radar: {
|
||||
indicator: props.indicator,
|
||||
center: ['50%', '50%'],
|
||||
radius: '70%',
|
||||
axisName: {
|
||||
color: isDark.value ? '#ccc' : '#666',
|
||||
fontSize: 12
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? '#444' : '#e6e6e6'
|
||||
}
|
||||
},
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: isDark.value ? '#444' : '#e6e6e6'
|
||||
}
|
||||
},
|
||||
splitArea: {
|
||||
show: true,
|
||||
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) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
symbolSize: 4,
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: props.colors[index % props.colors.length]
|
||||
},
|
||||
itemStyle: {
|
||||
color: props.colors[index % props.colors.length]
|
||||
},
|
||||
areaStyle: {
|
||||
color: props.colors[index % props.colors.length],
|
||||
opacity: 0.1
|
||||
},
|
||||
emphasis: {
|
||||
areaStyle: {
|
||||
opacity: 0.25
|
||||
},
|
||||
lineStyle: {
|
||||
width: 3
|
||||
}
|
||||
}
|
||||
})),
|
||||
...getAnimationConfig(200, 1800)
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,133 +1,133 @@
|
||||
<!-- 环形图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { RingChartProps } from '@/types/component/chart'
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { RingChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtRingChart' })
|
||||
defineOptions({ name: 'ArtRingChart' })
|
||||
|
||||
const props = withDefaults(defineProps<RingChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<RingChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [],
|
||||
radius: () => ['50%', '80%'],
|
||||
borderRadius: 10,
|
||||
centerText: '',
|
||||
showLabel: false,
|
||||
// 数据配置
|
||||
data: () => [],
|
||||
radius: () => ['50%', '80%'],
|
||||
borderRadius: 10,
|
||||
centerText: '',
|
||||
showLabel: false,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'right'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'right'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
|
||||
useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value === 0)
|
||||
},
|
||||
watchSources: [() => props.data, () => props.centerText],
|
||||
generateOptions: (): EChartsOption => {
|
||||
// 根据图例位置计算环形图中心位置
|
||||
const getCenterPosition = (): [string, string] => {
|
||||
if (!props.showLegend) return ['50%', '50%']
|
||||
// 使用新的图表组件抽象
|
||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
|
||||
useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value === 0)
|
||||
},
|
||||
watchSources: [() => props.data, () => props.centerText],
|
||||
generateOptions: (): EChartsOption => {
|
||||
// 根据图例位置计算环形图中心位置
|
||||
const getCenterPosition = (): [string, string] => {
|
||||
if (!props.showLegend) return ['50%', '50%']
|
||||
|
||||
switch (props.legendPosition) {
|
||||
case 'left':
|
||||
return ['60%', '50%']
|
||||
case 'right':
|
||||
return ['40%', '50%']
|
||||
case 'top':
|
||||
return ['50%', '60%']
|
||||
case 'bottom':
|
||||
return ['50%', '40%']
|
||||
default:
|
||||
return ['50%', '50%']
|
||||
}
|
||||
}
|
||||
switch (props.legendPosition) {
|
||||
case 'left':
|
||||
return ['60%', '50%']
|
||||
case 'right':
|
||||
return ['40%', '50%']
|
||||
case 'top':
|
||||
return ['50%', '60%']
|
||||
case 'bottom':
|
||||
return ['50%', '40%']
|
||||
default:
|
||||
return ['50%', '50%']
|
||||
}
|
||||
}
|
||||
|
||||
const option: EChartsOption = {
|
||||
tooltip: props.showTooltip
|
||||
? getTooltipStyle('item', {
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
})
|
||||
: undefined,
|
||||
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
|
||||
series: [
|
||||
{
|
||||
name: '数据占比',
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
center: getCenterPosition(),
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: props.borderRadius,
|
||||
borderColor: isDark.value ? '#2c2c2c' : '#fff',
|
||||
borderWidth: 0
|
||||
},
|
||||
label: {
|
||||
show: props.showLabel,
|
||||
formatter: '{b}\n{d}%',
|
||||
position: 'outside',
|
||||
color: isDark.value ? '#ccc' : '#999',
|
||||
fontSize: 12
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: props.showLabel,
|
||||
length: 15,
|
||||
length2: 25,
|
||||
smooth: true
|
||||
},
|
||||
data: props.data,
|
||||
color: props.colors,
|
||||
...getAnimationConfig(),
|
||||
animationType: 'expansion'
|
||||
}
|
||||
]
|
||||
}
|
||||
const option: EChartsOption = {
|
||||
tooltip: props.showTooltip
|
||||
? getTooltipStyle('item', {
|
||||
formatter: '{b}: {c} ({d}%)'
|
||||
})
|
||||
: undefined,
|
||||
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
|
||||
series: [
|
||||
{
|
||||
name: '数据占比',
|
||||
type: 'pie',
|
||||
radius: props.radius,
|
||||
center: getCenterPosition(),
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: props.borderRadius,
|
||||
borderColor: isDark.value ? '#2c2c2c' : '#fff',
|
||||
borderWidth: 0
|
||||
},
|
||||
label: {
|
||||
show: props.showLabel,
|
||||
formatter: '{b}\n{d}%',
|
||||
position: 'outside',
|
||||
color: isDark.value ? '#ccc' : '#999',
|
||||
fontSize: 12
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: false,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
labelLine: {
|
||||
show: props.showLabel,
|
||||
length: 15,
|
||||
length2: 25,
|
||||
smooth: true
|
||||
},
|
||||
data: props.data,
|
||||
color: props.colors,
|
||||
...getAnimationConfig(),
|
||||
animationType: 'expansion'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// 添加中心文字
|
||||
if (props.centerText) {
|
||||
const centerPos = getCenterPosition()
|
||||
option.title = {
|
||||
text: props.centerText,
|
||||
left: centerPos[0],
|
||||
top: centerPos[1],
|
||||
textAlign: 'center',
|
||||
textVerticalAlign: 'middle',
|
||||
textStyle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
color: isDark.value ? '#999' : '#ADB0BC'
|
||||
}
|
||||
}
|
||||
}
|
||||
// 添加中心文字
|
||||
if (props.centerText) {
|
||||
const centerPos = getCenterPosition()
|
||||
option.title = {
|
||||
text: props.centerText,
|
||||
left: centerPos[0],
|
||||
top: centerPos[1],
|
||||
textAlign: 'center',
|
||||
textVerticalAlign: 'middle',
|
||||
textStyle: {
|
||||
fontSize: 18,
|
||||
fontWeight: 500,
|
||||
color: isDark.value ? '#999' : '#ADB0BC'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return option
|
||||
}
|
||||
})
|
||||
return option
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,115 +1,122 @@
|
||||
<!-- 散点图 -->
|
||||
<template>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="chartRef"
|
||||
class="relative w-full"
|
||||
:style="{ height: props.height }"
|
||||
v-loading="props.loading"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { ScatterChartProps } from '@/types/component/chart'
|
||||
import type { EChartsOption } from '@/plugins/echarts'
|
||||
import { getCssVar } from '@/utils/ui'
|
||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||
import type { ScatterChartProps } from '@/types/component/chart'
|
||||
|
||||
defineOptions({ name: 'ArtScatterChart' })
|
||||
defineOptions({ name: 'ArtScatterChart' })
|
||||
|
||||
const props = withDefaults(defineProps<ScatterChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
const props = withDefaults(defineProps<ScatterChartProps>(), {
|
||||
// 基础配置
|
||||
height: useChartOps().chartHeight,
|
||||
loading: false,
|
||||
isEmpty: false,
|
||||
colors: () => useChartOps().colors,
|
||||
|
||||
// 数据配置
|
||||
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
|
||||
symbolSize: 14,
|
||||
// 数据配置
|
||||
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
|
||||
symbolSize: 14,
|
||||
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
// 轴线显示配置
|
||||
showAxisLabel: true,
|
||||
showAxisLine: true,
|
||||
showSplitLine: true,
|
||||
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
// 交互配置
|
||||
showTooltip: true,
|
||||
showLegend: false,
|
||||
legendPosition: 'bottom'
|
||||
})
|
||||
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
|
||||
},
|
||||
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
|
||||
// 使用新的图表组件抽象
|
||||
const {
|
||||
chartRef,
|
||||
isDark,
|
||||
getAxisLineStyle,
|
||||
getAxisLabelStyle,
|
||||
getAxisTickStyle,
|
||||
getSplitLineStyle,
|
||||
getAnimationConfig,
|
||||
getTooltipStyle
|
||||
} = useChartComponent({
|
||||
props,
|
||||
checkEmpty: () => {
|
||||
return (
|
||||
!props.data?.length ||
|
||||
props.data.every((item) => item.value.every((val) => val === 0))
|
||||
)
|
||||
},
|
||||
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
|
||||
generateOptions: (): EChartsOption => {
|
||||
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
|
||||
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: props.showTooltip
|
||||
? getTooltipStyle('item', {
|
||||
formatter: (params: { value: [number, number] }) => {
|
||||
const [x, y] = params.value
|
||||
return `X: ${x}<br/>Y: ${y}`
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'scatter',
|
||||
data: props.data,
|
||||
symbolSize: props.symbolSize,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
shadowBlur: 6,
|
||||
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 12,
|
||||
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'
|
||||
},
|
||||
scale: true
|
||||
},
|
||||
...getAnimationConfig()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
return {
|
||||
grid: {
|
||||
top: 20,
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
left: 20,
|
||||
containLabel: true
|
||||
},
|
||||
tooltip: props.showTooltip
|
||||
? getTooltipStyle('item', {
|
||||
formatter: (params: { value: [number, number] }) => {
|
||||
const [x, y] = params.value
|
||||
return `X: ${x}<br/>Y: ${y}`
|
||||
}
|
||||
})
|
||||
: undefined,
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||
axisTick: getAxisTickStyle(),
|
||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'scatter',
|
||||
data: props.data,
|
||||
symbolSize: props.symbolSize,
|
||||
itemStyle: {
|
||||
color: computedColor,
|
||||
shadowBlur: 6,
|
||||
shadowColor: isDark.value
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 12,
|
||||
shadowColor: isDark.value
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(0, 0, 0, 0.2)'
|
||||
},
|
||||
scale: true
|
||||
},
|
||||
...getAnimationConfig()
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,71 +1,74 @@
|
||||
<!-- 更多按钮 -->
|
||||
<template>
|
||||
<div>
|
||||
<ElDropdown v-if="hasAnyAuthItem">
|
||||
<ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<template v-for="item in list" :key="item.key">
|
||||
<ElDropdownItem
|
||||
v-if="!item.auth || hasAuth(item.auth)"
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="flex-c gap-2" :style="{ color: item.color }">
|
||||
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</template>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
<div>
|
||||
<ElDropdown v-if="hasAnyAuthItem">
|
||||
<ArtIconButton
|
||||
icon="ri:more-2-fill"
|
||||
class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm"
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<template v-for="item in list" :key="item.key">
|
||||
<ElDropdownItem
|
||||
v-if="!item.auth || hasAuth(item.auth)"
|
||||
:disabled="item.disabled"
|
||||
@click="handleClick(item)"
|
||||
>
|
||||
<div class="flex-c gap-2" :style="{ color: item.color }">
|
||||
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</template>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
/** 按钮标识,可用于点击事件 */
|
||||
key: string | number
|
||||
/** 按钮文本 */
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 权限标识 */
|
||||
auth?: string
|
||||
/** 图标组件 */
|
||||
icon?: string
|
||||
/** 文本颜色 */
|
||||
color?: string
|
||||
/** 图标颜色(优先级高于 color) */
|
||||
iconColor?: string
|
||||
}
|
||||
export interface ButtonMoreItem {
|
||||
/** 按钮标识,可用于点击事件 */
|
||||
key: string | number
|
||||
/** 按钮文本 */
|
||||
label: string
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 权限标识 */
|
||||
auth?: string
|
||||
/** 图标组件 */
|
||||
icon?: string
|
||||
/** 文本颜色 */
|
||||
color?: string
|
||||
/** 图标颜色(优先级高于 color) */
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 下拉项列表 */
|
||||
list: ButtonMoreItem[]
|
||||
/** 整体权限控制 */
|
||||
auth?: string
|
||||
}
|
||||
interface Props {
|
||||
/** 下拉项列表 */
|
||||
list: ButtonMoreItem[]
|
||||
/** 整体权限控制 */
|
||||
auth?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
|
||||
// 检查是否有任何有权限的 item
|
||||
const hasAnyAuthItem = computed(() => {
|
||||
return props.list.some((item) => !item.auth || hasAuth(item.auth))
|
||||
})
|
||||
// 检查是否有任何有权限的 item
|
||||
const hasAnyAuthItem = computed(() => {
|
||||
return props.list.some((item) => !item.auth || hasAuth(item.auth))
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', item: ButtonMoreItem): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'click', item: ButtonMoreItem): void
|
||||
}>()
|
||||
|
||||
const handleClick = (item: ButtonMoreItem) => {
|
||||
emit('click', item)
|
||||
}
|
||||
const handleClick = (item: ButtonMoreItem) => {
|
||||
emit('click', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
<!-- 表格按钮 -->
|
||||
<template>
|
||||
<div
|
||||
: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',
|
||||
buttonClass
|
||||
]"
|
||||
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
<ArtSvgIcon :icon="iconContent" />
|
||||
</div>
|
||||
<div
|
||||
: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',
|
||||
buttonClass
|
||||
]"
|
||||
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
|
||||
@click="handleClick"
|
||||
>
|
||||
<ArtSvgIcon :icon="iconContent" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineOptions({ name: 'ArtButtonTable' })
|
||||
defineOptions({ name: 'ArtButtonTable' })
|
||||
|
||||
interface Props {
|
||||
/** 按钮类型 */
|
||||
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
|
||||
/** 按钮图标 */
|
||||
icon?: string
|
||||
/** 按钮样式类 */
|
||||
iconClass?: string
|
||||
/** icon 颜色 */
|
||||
iconColor?: string
|
||||
/** 按钮背景色 */
|
||||
buttonBgColor?: string
|
||||
}
|
||||
interface Props {
|
||||
/** 按钮类型 */
|
||||
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
|
||||
/** 按钮图标 */
|
||||
icon?: string
|
||||
/** 按钮样式类 */
|
||||
iconClass?: string
|
||||
/** icon 颜色 */
|
||||
iconColor?: string
|
||||
/** 按钮背景色 */
|
||||
buttonBgColor?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
const props = withDefaults(defineProps<Props>(), {})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'click'): void
|
||||
}>()
|
||||
|
||||
// 默认按钮配置
|
||||
const defaultButtons = {
|
||||
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
|
||||
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
|
||||
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
|
||||
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
|
||||
more: { icon: 'ri:more-2-fill', class: '' }
|
||||
} as const
|
||||
// 默认按钮配置
|
||||
const defaultButtons = {
|
||||
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
|
||||
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
|
||||
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
|
||||
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
|
||||
more: { icon: 'ri:more-2-fill', class: '' }
|
||||
} as const
|
||||
|
||||
// 获取图标内容
|
||||
const iconContent = computed(() => {
|
||||
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
|
||||
})
|
||||
// 获取图标内容
|
||||
const iconContent = computed(() => {
|
||||
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
|
||||
})
|
||||
|
||||
// 获取按钮样式类
|
||||
const buttonClass = computed(() => {
|
||||
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
|
||||
})
|
||||
// 获取按钮样式类
|
||||
const buttonClass = computed(() => {
|
||||
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
|
||||
})
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
const handleClick = () => {
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,430 +1,431 @@
|
||||
<!-- 拖拽验证组件 -->
|
||||
<template>
|
||||
<div
|
||||
ref="dragVerify"
|
||||
class="drag_verify"
|
||||
:style="dragVerifyStyle"
|
||||
@mousemove="dragMoving"
|
||||
@mouseup="dragFinish"
|
||||
@mouseleave="dragFinish"
|
||||
@touchmove="dragMoving"
|
||||
@touchend="dragFinish"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="dv_progress_bar"
|
||||
:class="{ goFirst2: isOk }"
|
||||
ref="progressBar"
|
||||
:style="progressBarStyle"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
ref="dragVerify"
|
||||
class="drag_verify"
|
||||
:style="dragVerifyStyle"
|
||||
@mousemove="dragMoving"
|
||||
@mouseup="dragFinish"
|
||||
@mouseleave="dragFinish"
|
||||
@touchmove="dragMoving"
|
||||
@touchend="dragFinish"
|
||||
>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="dv_progress_bar"
|
||||
:class="{ goFirst2: isOk }"
|
||||
ref="progressBar"
|
||||
:style="progressBarStyle"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 提示文本 -->
|
||||
<div class="dv_text" :style="textStyle" ref="messageRef">
|
||||
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
||||
{{ message }}
|
||||
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
||||
</div>
|
||||
<!-- 提示文本 -->
|
||||
<div class="dv_text" :style="textStyle" ref="messageRef">
|
||||
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
||||
{{ message }}
|
||||
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
||||
</div>
|
||||
|
||||
<!-- 滑块处理器 -->
|
||||
<div
|
||||
class="dv_handler dv_handler_bg"
|
||||
:class="{ goFirst: isOk }"
|
||||
@mousedown="dragStart"
|
||||
@touchstart="dragStart"
|
||||
ref="handler"
|
||||
:style="handlerStyle"
|
||||
>
|
||||
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 滑块处理器 -->
|
||||
<div
|
||||
class="dv_handler dv_handler_bg"
|
||||
:class="{ goFirst: isOk }"
|
||||
@mousedown="dragStart"
|
||||
@touchstart="dragStart"
|
||||
ref="handler"
|
||||
:style="handlerStyle"
|
||||
>
|
||||
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
/** 是否通过验证 */
|
||||
value: boolean
|
||||
/** 组件宽度 */
|
||||
width?: number | string
|
||||
/** 组件高度 */
|
||||
height?: number
|
||||
/** 默认提示文本 */
|
||||
text?: string
|
||||
/** 成功提示文本 */
|
||||
successText?: string
|
||||
/** 背景色 */
|
||||
background?: string
|
||||
/** 进度条背景色 */
|
||||
progressBarBg?: string
|
||||
/** 完成状态背景色 */
|
||||
completedBg?: string
|
||||
/** 是否圆角 */
|
||||
circle?: boolean
|
||||
/** 圆角大小 */
|
||||
radius?: string
|
||||
/** 滑块图标 */
|
||||
handlerIcon?: string
|
||||
/** 成功图标 */
|
||||
successIcon?: string
|
||||
/** 滑块背景色 */
|
||||
handlerBg?: string
|
||||
/** 文本大小 */
|
||||
textSize?: string
|
||||
/** 文本颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
// 组件属性接口定义
|
||||
interface PropsType {
|
||||
/** 是否通过验证 */
|
||||
value: boolean
|
||||
/** 组件宽度 */
|
||||
width?: number | string
|
||||
/** 组件高度 */
|
||||
height?: number
|
||||
/** 默认提示文本 */
|
||||
text?: string
|
||||
/** 成功提示文本 */
|
||||
successText?: string
|
||||
/** 背景色 */
|
||||
background?: string
|
||||
/** 进度条背景色 */
|
||||
progressBarBg?: string
|
||||
/** 完成状态背景色 */
|
||||
completedBg?: string
|
||||
/** 是否圆角 */
|
||||
circle?: boolean
|
||||
/** 圆角大小 */
|
||||
radius?: string
|
||||
/** 滑块图标 */
|
||||
handlerIcon?: string
|
||||
/** 成功图标 */
|
||||
successIcon?: string
|
||||
/** 滑块背景色 */
|
||||
handlerBg?: string
|
||||
/** 文本大小 */
|
||||
textSize?: string
|
||||
/** 文本颜色 */
|
||||
textColor?: string
|
||||
}
|
||||
|
||||
// 属性默认值设置
|
||||
const props = withDefaults(defineProps<PropsType>(), {
|
||||
value: false,
|
||||
width: '100%',
|
||||
height: 40,
|
||||
text: '按住滑块拖动',
|
||||
successText: 'success',
|
||||
background: '#eee',
|
||||
progressBarBg: '#1385FF',
|
||||
completedBg: '#57D187',
|
||||
circle: false,
|
||||
radius: 'calc(var(--custom-radius) / 3 + 2px)',
|
||||
handlerIcon: 'solar:double-alt-arrow-right-linear',
|
||||
successIcon: 'ri:check-fill',
|
||||
handlerBg: '#fff',
|
||||
textSize: '13px',
|
||||
textColor: '#333'
|
||||
})
|
||||
// 属性默认值设置
|
||||
const props = withDefaults(defineProps<PropsType>(), {
|
||||
value: false,
|
||||
width: '100%',
|
||||
height: 40,
|
||||
text: '按住滑块拖动',
|
||||
successText: 'success',
|
||||
background: '#eee',
|
||||
progressBarBg: '#1385FF',
|
||||
completedBg: '#57D187',
|
||||
circle: false,
|
||||
radius: 'calc(var(--custom-radius) / 3 + 2px)',
|
||||
handlerIcon: 'solar:double-alt-arrow-right-linear',
|
||||
successIcon: 'ri:check-fill',
|
||||
handlerBg: '#fff',
|
||||
textSize: '13px',
|
||||
textColor: '#333'
|
||||
})
|
||||
|
||||
// 组件状态接口定义
|
||||
interface StateType {
|
||||
isMoving: boolean // 是否正在拖拽
|
||||
x: number // 拖拽起始位置
|
||||
isOk: boolean // 是否验证成功
|
||||
}
|
||||
// 组件状态接口定义
|
||||
interface StateType {
|
||||
isMoving: boolean // 是否正在拖拽
|
||||
x: number // 拖拽起始位置
|
||||
isOk: boolean // 是否验证成功
|
||||
}
|
||||
|
||||
// 响应式状态定义
|
||||
const state = reactive(<StateType>{
|
||||
isMoving: false,
|
||||
x: 0,
|
||||
isOk: false
|
||||
})
|
||||
// 响应式状态定义
|
||||
const state = reactive(<StateType>{
|
||||
isMoving: false,
|
||||
x: 0,
|
||||
isOk: false
|
||||
})
|
||||
|
||||
// 解构响应式状态
|
||||
const { isOk } = toRefs(state)
|
||||
// 解构响应式状态
|
||||
const { isOk } = toRefs(state)
|
||||
|
||||
// DOM 元素引用
|
||||
const dragVerify = ref()
|
||||
const messageRef = ref()
|
||||
const handler = ref()
|
||||
const progressBar = ref()
|
||||
// DOM 元素引用
|
||||
const dragVerify = ref()
|
||||
const messageRef = ref()
|
||||
const handler = ref()
|
||||
const progressBar = ref()
|
||||
|
||||
// 触摸事件变量 - 用于禁止页面滑动
|
||||
let startX: number, startY: number, moveX: number, moveY: number
|
||||
// 触摸事件变量 - 用于禁止页面滑动
|
||||
let startX: number, startY: number, moveX: number, moveY: number
|
||||
|
||||
/**
|
||||
* 触摸开始事件处理
|
||||
* @param e 触摸事件对象
|
||||
*/
|
||||
const onTouchStart = (e: any) => {
|
||||
startX = e.targetTouches[0].pageX
|
||||
startY = e.targetTouches[0].pageY
|
||||
}
|
||||
/**
|
||||
* 触摸开始事件处理
|
||||
* @param e 触摸事件对象
|
||||
*/
|
||||
const onTouchStart = (e: any) => {
|
||||
startX = e.targetTouches[0].pageX
|
||||
startY = e.targetTouches[0].pageY
|
||||
}
|
||||
|
||||
/**
|
||||
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
|
||||
* @param e 触摸事件对象
|
||||
*/
|
||||
const onTouchMove = (e: any) => {
|
||||
moveX = e.targetTouches[0].pageX
|
||||
moveY = e.targetTouches[0].pageY
|
||||
/**
|
||||
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
|
||||
* @param e 触摸事件对象
|
||||
*/
|
||||
const onTouchMove = (e: any) => {
|
||||
moveX = e.targetTouches[0].pageX
|
||||
moveY = e.targetTouches[0].pageY
|
||||
|
||||
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
|
||||
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
|
||||
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// 全局事件监听器添加
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
// 全局事件监听器添加
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
|
||||
// 获取数值形式的宽度
|
||||
const getNumericWidth = (): number => {
|
||||
if (typeof props.width === 'string') {
|
||||
// 如果是字符串,尝试从DOM元素获取实际宽度
|
||||
return dragVerify.value?.offsetWidth || 260
|
||||
}
|
||||
return props.width
|
||||
}
|
||||
// 获取数值形式的宽度
|
||||
const getNumericWidth = (): number => {
|
||||
if (typeof props.width === 'string') {
|
||||
// 如果是字符串,尝试从DOM元素获取实际宽度
|
||||
return dragVerify.value?.offsetWidth || 260
|
||||
}
|
||||
return props.width
|
||||
}
|
||||
|
||||
// 获取样式字符串形式的宽度
|
||||
const getStyleWidth = (): string => {
|
||||
if (typeof props.width === 'string') {
|
||||
return props.width
|
||||
}
|
||||
return props.width + 'px'
|
||||
}
|
||||
// 获取样式字符串形式的宽度
|
||||
const getStyleWidth = (): string => {
|
||||
if (typeof props.width === 'string') {
|
||||
return props.width
|
||||
}
|
||||
return props.width + 'px'
|
||||
}
|
||||
|
||||
// 组件挂载后的初始化
|
||||
onMounted(() => {
|
||||
// 设置 CSS 自定义属性
|
||||
dragVerify.value?.style.setProperty('--textColor', props.textColor)
|
||||
// 组件挂载后的初始化
|
||||
onMounted(() => {
|
||||
// 设置 CSS 自定义属性
|
||||
dragVerify.value?.style.setProperty('--textColor', props.textColor)
|
||||
|
||||
// 等待DOM更新后设置宽度相关属性
|
||||
nextTick(() => {
|
||||
const numericWidth = getNumericWidth()
|
||||
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
|
||||
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
|
||||
})
|
||||
// 等待DOM更新后设置宽度相关属性
|
||||
nextTick(() => {
|
||||
const numericWidth = getNumericWidth()
|
||||
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
|
||||
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
|
||||
})
|
||||
|
||||
// 重复添加事件监听器(确保事件绑定)
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
})
|
||||
// 重复添加事件监听器(确保事件绑定)
|
||||
document.addEventListener('touchstart', onTouchStart)
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||
})
|
||||
|
||||
// 组件卸载前清理事件监听器
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('touchstart', onTouchStart)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
})
|
||||
// 组件卸载前清理事件监听器
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('touchstart', onTouchStart)
|
||||
document.removeEventListener('touchmove', onTouchMove)
|
||||
})
|
||||
|
||||
// 滑块样式计算
|
||||
const handlerStyle = {
|
||||
left: '0',
|
||||
width: props.height + 'px',
|
||||
height: props.height + 'px',
|
||||
background: props.handlerBg
|
||||
}
|
||||
// 滑块样式计算
|
||||
const handlerStyle = {
|
||||
left: '0',
|
||||
width: props.height + 'px',
|
||||
height: props.height + 'px',
|
||||
background: props.handlerBg
|
||||
}
|
||||
|
||||
// 主容器样式计算
|
||||
const dragVerifyStyle = computed(() => ({
|
||||
width: getStyleWidth(),
|
||||
height: props.height + 'px',
|
||||
lineHeight: props.height + 'px',
|
||||
background: props.background,
|
||||
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
|
||||
}))
|
||||
// 主容器样式计算
|
||||
const dragVerifyStyle = computed(() => ({
|
||||
width: getStyleWidth(),
|
||||
height: props.height + 'px',
|
||||
lineHeight: props.height + 'px',
|
||||
background: props.background,
|
||||
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
|
||||
}))
|
||||
|
||||
// 进度条样式计算
|
||||
const progressBarStyle = {
|
||||
background: props.progressBarBg,
|
||||
height: props.height + 'px',
|
||||
borderRadius: props.circle
|
||||
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
|
||||
: props.radius
|
||||
}
|
||||
// 进度条样式计算
|
||||
const progressBarStyle = {
|
||||
background: props.progressBarBg,
|
||||
height: props.height + 'px',
|
||||
borderRadius: props.circle
|
||||
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
|
||||
: props.radius
|
||||
}
|
||||
|
||||
// 文本样式计算
|
||||
const textStyle = computed(() => ({
|
||||
fontSize: props.textSize
|
||||
}))
|
||||
// 文本样式计算
|
||||
const textStyle = computed(() => ({
|
||||
fontSize: props.textSize
|
||||
}))
|
||||
|
||||
// 显示消息计算属性
|
||||
const message = computed(() => {
|
||||
return props.value ? props.successText : props.text
|
||||
})
|
||||
// 显示消息计算属性
|
||||
const message = computed(() => {
|
||||
return props.value ? props.successText : props.text
|
||||
})
|
||||
|
||||
/**
|
||||
* 拖拽开始处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragStart = (e: any) => {
|
||||
if (!props.value) {
|
||||
state.isMoving = true
|
||||
handler.value.style.transition = 'none'
|
||||
// 计算拖拽起始位置
|
||||
state.x =
|
||||
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10)
|
||||
}
|
||||
emit('handlerMove')
|
||||
}
|
||||
/**
|
||||
* 拖拽开始处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragStart = (e: any) => {
|
||||
if (!props.value) {
|
||||
state.isMoving = true
|
||||
handler.value.style.transition = 'none'
|
||||
// 计算拖拽起始位置
|
||||
state.x =
|
||||
(e.pageX || e.touches[0].pageX) -
|
||||
parseInt(handler.value.style.left.replace('px', ''), 10)
|
||||
}
|
||||
emit('handlerMove')
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽移动处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragMoving = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
const numericWidth = getNumericWidth()
|
||||
// 计算当前位置
|
||||
let _x = (e.pageX || e.touches[0].pageX) - state.x
|
||||
/**
|
||||
* 拖拽移动处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragMoving = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
const numericWidth = getNumericWidth()
|
||||
// 计算当前位置
|
||||
let _x = (e.pageX || e.touches[0].pageX) - state.x
|
||||
|
||||
// 在有效范围内移动
|
||||
if (_x > 0 && _x <= numericWidth - props.height) {
|
||||
handler.value.style.left = _x + 'px'
|
||||
progressBar.value.style.width = _x + props.height / 2 + 'px'
|
||||
} else if (_x > numericWidth - props.height) {
|
||||
// 拖拽到末端,触发验证成功
|
||||
handler.value.style.left = numericWidth - props.height + 'px'
|
||||
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 在有效范围内移动
|
||||
if (_x > 0 && _x <= numericWidth - props.height) {
|
||||
handler.value.style.left = _x + 'px'
|
||||
progressBar.value.style.width = _x + props.height / 2 + 'px'
|
||||
} else if (_x > numericWidth - props.height) {
|
||||
// 拖拽到末端,触发验证成功
|
||||
handler.value.style.left = numericWidth - props.height + 'px'
|
||||
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽结束处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragFinish = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
const numericWidth = getNumericWidth()
|
||||
// 计算最终位置
|
||||
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
|
||||
/**
|
||||
* 拖拽结束处理函数
|
||||
* @param e 鼠标或触摸事件对象
|
||||
*/
|
||||
const dragFinish = (e: any) => {
|
||||
if (state.isMoving && !props.value) {
|
||||
const numericWidth = getNumericWidth()
|
||||
// 计算最终位置
|
||||
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
|
||||
|
||||
if (_x < numericWidth - props.height) {
|
||||
// 未拖拽到末端,重置位置
|
||||
state.isOk = true
|
||||
handler.value.style.left = '0'
|
||||
handler.value.style.transition = 'all 0.2s'
|
||||
progressBar.value.style.width = '0'
|
||||
state.isOk = false
|
||||
} else {
|
||||
// 拖拽到末端,保持验证成功状态
|
||||
handler.value.style.transition = 'none'
|
||||
handler.value.style.left = numericWidth - props.height + 'px'
|
||||
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
state.isMoving = false
|
||||
}
|
||||
}
|
||||
if (_x < numericWidth - props.height) {
|
||||
// 未拖拽到末端,重置位置
|
||||
state.isOk = true
|
||||
handler.value.style.left = '0'
|
||||
handler.value.style.transition = 'all 0.2s'
|
||||
progressBar.value.style.width = '0'
|
||||
state.isOk = false
|
||||
} else {
|
||||
// 拖拽到末端,保持验证成功状态
|
||||
handler.value.style.transition = 'none'
|
||||
handler.value.style.left = numericWidth - props.height + 'px'
|
||||
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
||||
passVerify()
|
||||
}
|
||||
state.isMoving = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证通过处理函数
|
||||
*/
|
||||
const passVerify = () => {
|
||||
emit('update:value', true)
|
||||
state.isMoving = false
|
||||
// 更新样式为成功状态
|
||||
progressBar.value.style.background = props.completedBg
|
||||
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.color = '#fff'
|
||||
emit('passCallback')
|
||||
}
|
||||
/**
|
||||
* 验证通过处理函数
|
||||
*/
|
||||
const passVerify = () => {
|
||||
emit('update:value', true)
|
||||
state.isMoving = false
|
||||
// 更新样式为成功状态
|
||||
progressBar.value.style.background = props.completedBg
|
||||
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.color = '#fff'
|
||||
emit('passCallback')
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置验证状态函数
|
||||
*/
|
||||
const reset = () => {
|
||||
// 重置滑块位置
|
||||
handler.value.style.left = '0'
|
||||
progressBar.value.style.width = '0'
|
||||
progressBar.value.style.background = props.progressBarBg
|
||||
// 重置文本样式
|
||||
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.color = props.background
|
||||
// 重置状态
|
||||
emit('update:value', false)
|
||||
state.isOk = false
|
||||
state.isMoving = false
|
||||
state.x = 0
|
||||
}
|
||||
/**
|
||||
* 重置验证状态函数
|
||||
*/
|
||||
const reset = () => {
|
||||
// 重置滑块位置
|
||||
handler.value.style.left = '0'
|
||||
progressBar.value.style.width = '0'
|
||||
progressBar.value.style.background = props.progressBarBg
|
||||
// 重置文本样式
|
||||
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.color = props.background
|
||||
// 重置状态
|
||||
emit('update:value', false)
|
||||
state.isOk = false
|
||||
state.isMoving = false
|
||||
state.x = 0
|
||||
}
|
||||
|
||||
// 暴露重置方法给父组件
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
// 暴露重置方法给父组件
|
||||
defineExpose({
|
||||
reset
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.drag_verify {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
border: 1px solid var(--default-border-dashed);
|
||||
.drag_verify {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
border: 1px solid var(--default-border-dashed);
|
||||
|
||||
.dv_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
.dv_handler {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: move;
|
||||
|
||||
i {
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
i {
|
||||
padding-left: 0;
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.el-icon-circle-check {
|
||||
margin-top: 9px;
|
||||
color: #6c6;
|
||||
}
|
||||
}
|
||||
.el-icon-circle-check {
|
||||
margin-top: 9px;
|
||||
color: #6c6;
|
||||
}
|
||||
}
|
||||
|
||||
.dv_progress_bar {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 34px;
|
||||
}
|
||||
.dv_progress_bar {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.dv_text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--textColor) 0%,
|
||||
var(--textColor) 40%,
|
||||
#fff 50%,
|
||||
var(--textColor) 60%,
|
||||
var(--textColor) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-size-adjust: none;
|
||||
.dv_text {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: transparent;
|
||||
user-select: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
var(--textColor) 0%,
|
||||
var(--textColor) 40%,
|
||||
#fff 50%,
|
||||
var(--textColor) 60%,
|
||||
var(--textColor) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-size-adjust: none;
|
||||
|
||||
* {
|
||||
-webkit-text-fill-color: var(--textColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
* {
|
||||
-webkit-text-fill-color: var(--textColor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goFirst {
|
||||
left: 0 !important;
|
||||
transition: left 0.5s;
|
||||
}
|
||||
.goFirst {
|
||||
left: 0 !important;
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.goFirst2 {
|
||||
width: 0 !important;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
.goFirst2 {
|
||||
width: 0 !important;
|
||||
transition: width 0.5s;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes slidetounlock {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
@keyframes slidetounlock {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--width) 0;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
background-position: var(--width) 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slidetounlock2 {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
@keyframes slidetounlock2 {
|
||||
0% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
background-position: var(--pwidth) 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,389 +1,399 @@
|
||||
<!-- 导出 Excel 文件 -->
|
||||
<template>
|
||||
<ElButton
|
||||
:type="type"
|
||||
:size="size"
|
||||
:loading="isExporting"
|
||||
:disabled="disabled || !hasData"
|
||||
v-ripple
|
||||
@click="handleExport"
|
||||
>
|
||||
<template #loading>
|
||||
<ElIcon class="is-loading">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
{{ loadingText }}
|
||||
</template>
|
||||
<slot>{{ buttonText }}</slot>
|
||||
</ElButton>
|
||||
<ElButton
|
||||
:type="type"
|
||||
:size="size"
|
||||
:loading="isExporting"
|
||||
:disabled="disabled || !hasData"
|
||||
v-ripple
|
||||
@click="handleExport"
|
||||
>
|
||||
<template #loading>
|
||||
<ElIcon class="is-loading">
|
||||
<Loading />
|
||||
</ElIcon>
|
||||
{{ loadingText }}
|
||||
</template>
|
||||
<slot>{{ buttonText }}</slot>
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import FileSaver from 'file-saver'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import type { ButtonType } from 'element-plus'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import * as XLSX from 'xlsx'
|
||||
import FileSaver from 'file-saver'
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import type { ButtonType } from 'element-plus'
|
||||
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 {
|
||||
[key: string]: ExportValue
|
||||
}
|
||||
interface ExportData {
|
||||
[key: string]: ExportValue
|
||||
}
|
||||
|
||||
/** 列配置 */
|
||||
interface ColumnConfig {
|
||||
/** 列标题 */
|
||||
title: string
|
||||
/** 列宽度 */
|
||||
width?: number
|
||||
/** 数据格式化函数 */
|
||||
formatter?: (value: ExportValue, row: ExportData, index: number) => string
|
||||
}
|
||||
/** 列配置 */
|
||||
interface ColumnConfig {
|
||||
/** 列标题 */
|
||||
title: string
|
||||
/** 列宽度 */
|
||||
width?: number
|
||||
/** 数据格式化函数 */
|
||||
formatter?: (value: ExportValue, row: ExportData, index: number) => string
|
||||
}
|
||||
|
||||
/** 导出配置选项 */
|
||||
interface ExportOptions {
|
||||
/** 数据源 */
|
||||
data: ExportData[]
|
||||
/** 文件名(不含扩展名) */
|
||||
filename?: string
|
||||
/** 工作表名称 */
|
||||
sheetName?: string
|
||||
/** 按钮类型 */
|
||||
type?: ButtonType
|
||||
/** 按钮尺寸 */
|
||||
size?: 'large' | 'default' | 'small'
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 按钮文本 */
|
||||
buttonText?: string
|
||||
/** 加载中文本 */
|
||||
loadingText?: string
|
||||
/** 是否自动添加序号列 */
|
||||
autoIndex?: boolean
|
||||
/** 序号列标题 */
|
||||
indexColumnTitle?: string
|
||||
/** 列配置映射 */
|
||||
columns?: Record<string, ColumnConfig>
|
||||
/** 表头映射(简化版本,向后兼容) */
|
||||
headers?: Record<string, string>
|
||||
/** 最大导出行数 */
|
||||
maxRows?: number
|
||||
/** 是否显示成功消息 */
|
||||
showSuccessMessage?: boolean
|
||||
/** 是否显示错误消息 */
|
||||
showErrorMessage?: boolean
|
||||
/** 工作簿配置 */
|
||||
workbookOptions?: {
|
||||
/** 创建者 */
|
||||
creator?: string
|
||||
/** 最后修改者 */
|
||||
lastModifiedBy?: string
|
||||
/** 创建时间 */
|
||||
created?: Date
|
||||
/** 修改时间 */
|
||||
modified?: Date
|
||||
}
|
||||
}
|
||||
/** 导出配置选项 */
|
||||
interface ExportOptions {
|
||||
/** 数据源 */
|
||||
data: ExportData[]
|
||||
/** 文件名(不含扩展名) */
|
||||
filename?: string
|
||||
/** 工作表名称 */
|
||||
sheetName?: string
|
||||
/** 按钮类型 */
|
||||
type?: ButtonType
|
||||
/** 按钮尺寸 */
|
||||
size?: 'large' | 'default' | 'small'
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 按钮文本 */
|
||||
buttonText?: string
|
||||
/** 加载中文本 */
|
||||
loadingText?: string
|
||||
/** 是否自动添加序号列 */
|
||||
autoIndex?: boolean
|
||||
/** 序号列标题 */
|
||||
indexColumnTitle?: string
|
||||
/** 列配置映射 */
|
||||
columns?: Record<string, ColumnConfig>
|
||||
/** 表头映射(简化版本,向后兼容) */
|
||||
headers?: Record<string, string>
|
||||
/** 最大导出行数 */
|
||||
maxRows?: number
|
||||
/** 是否显示成功消息 */
|
||||
showSuccessMessage?: boolean
|
||||
/** 是否显示错误消息 */
|
||||
showErrorMessage?: boolean
|
||||
/** 工作簿配置 */
|
||||
workbookOptions?: {
|
||||
/** 创建者 */
|
||||
creator?: string
|
||||
/** 最后修改者 */
|
||||
lastModifiedBy?: string
|
||||
/** 创建时间 */
|
||||
created?: Date
|
||||
/** 修改时间 */
|
||||
modified?: Date
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
|
||||
sheetName: 'Sheet1',
|
||||
type: 'primary',
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
buttonText: '导出 Excel',
|
||||
loadingText: '导出中...',
|
||||
autoIndex: false,
|
||||
indexColumnTitle: '序号',
|
||||
columns: () => ({}),
|
||||
headers: () => ({}),
|
||||
maxRows: 100000,
|
||||
showSuccessMessage: true,
|
||||
showErrorMessage: true,
|
||||
workbookOptions: () => ({})
|
||||
})
|
||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
|
||||
sheetName: 'Sheet1',
|
||||
type: 'primary',
|
||||
size: 'default',
|
||||
disabled: false,
|
||||
buttonText: '导出 Excel',
|
||||
loadingText: '导出中...',
|
||||
autoIndex: false,
|
||||
indexColumnTitle: '序号',
|
||||
columns: () => ({}),
|
||||
headers: () => ({}),
|
||||
maxRows: 100000,
|
||||
showSuccessMessage: true,
|
||||
showErrorMessage: true,
|
||||
workbookOptions: () => ({})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'before-export': [data: ExportData[]]
|
||||
'export-success': [filename: string, rowCount: number]
|
||||
'export-error': [error: ExportError]
|
||||
'export-progress': [progress: number]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'before-export': [data: ExportData[]]
|
||||
'export-success': [filename: string, rowCount: number]
|
||||
'export-error': [error: ExportError]
|
||||
'export-progress': [progress: number]
|
||||
}>()
|
||||
|
||||
/** 导出错误类型 */
|
||||
class ExportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ExportError'
|
||||
}
|
||||
}
|
||||
/** 导出错误类型 */
|
||||
class ExportError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public details?: any
|
||||
) {
|
||||
super(message)
|
||||
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 => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
|
||||
}
|
||||
/** 验证导出数据 */
|
||||
const validateData = (data: ExportData[]): void => {
|
||||
if (!Array.isArray(data)) {
|
||||
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
throw new ExportError('没有可导出的数据', 'NO_DATA')
|
||||
}
|
||||
if (data.length === 0) {
|
||||
throw new ExportError('没有可导出的数据', 'NO_DATA')
|
||||
}
|
||||
|
||||
if (data.length > props.maxRows) {
|
||||
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
|
||||
currentRows: data.length,
|
||||
maxRows: props.maxRows
|
||||
})
|
||||
}
|
||||
}
|
||||
if (data.length > props.maxRows) {
|
||||
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
|
||||
currentRows: data.length,
|
||||
maxRows: props.maxRows
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** 格式化单元格值 */
|
||||
const formatCellValue = (
|
||||
value: ExportValue,
|
||||
key: string,
|
||||
row: ExportData,
|
||||
index: number
|
||||
): string => {
|
||||
// 使用列配置的格式化函数
|
||||
const column = props.columns[key]
|
||||
if (column?.formatter) {
|
||||
return column.formatter(value, row, index)
|
||||
}
|
||||
/** 格式化单元格值 */
|
||||
const formatCellValue = (
|
||||
value: ExportValue,
|
||||
key: string,
|
||||
row: ExportData,
|
||||
index: number
|
||||
): string => {
|
||||
// 使用列配置的格式化函数
|
||||
const column = props.columns[key]
|
||||
if (column?.formatter) {
|
||||
return column.formatter(value, row, index)
|
||||
}
|
||||
|
||||
// 处理特殊值
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
// 处理特殊值
|
||||
if (value === null || value === undefined) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString('zh-CN')
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? '是' : '否'
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** 处理数据 */
|
||||
const processData = (data: ExportData[]): Record<string, string>[] => {
|
||||
const processedData = data.map((item, index) => {
|
||||
const processedItem: Record<string, string> = {}
|
||||
/** 处理数据 */
|
||||
const processData = (data: ExportData[]): Record<string, string>[] => {
|
||||
const processedData = data.map((item, index) => {
|
||||
const processedItem: Record<string, string> = {}
|
||||
|
||||
// 添加序号列
|
||||
if (props.autoIndex) {
|
||||
processedItem[props.indexColumnTitle] = String(index + 1)
|
||||
}
|
||||
// 添加序号列
|
||||
if (props.autoIndex) {
|
||||
processedItem[props.indexColumnTitle] = String(index + 1)
|
||||
}
|
||||
|
||||
// 处理数据列
|
||||
Object.entries(item).forEach(([key, value]) => {
|
||||
// 获取列标题
|
||||
let columnTitle = key
|
||||
if (props.columns[key]?.title) {
|
||||
columnTitle = props.columns[key].title
|
||||
} else if (props.headers[key]) {
|
||||
columnTitle = props.headers[key]
|
||||
}
|
||||
// 处理数据列
|
||||
Object.entries(item).forEach(([key, value]) => {
|
||||
// 获取列标题
|
||||
let columnTitle = key
|
||||
if (props.columns[key]?.title) {
|
||||
columnTitle = props.columns[key].title
|
||||
} else if (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[] => {
|
||||
if (data.length === 0) return []
|
||||
/** 计算列宽度 */
|
||||
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
|
||||
if (data.length === 0) return []
|
||||
|
||||
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
|
||||
const columns = Object.keys(data[0])
|
||||
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
|
||||
const columns = Object.keys(data[0])
|
||||
|
||||
return columns.map((column) => {
|
||||
// 使用配置的列宽度
|
||||
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width
|
||||
return columns.map((column) => {
|
||||
// 使用配置的列宽度
|
||||
const configWidth = Object.values(props.columns).find(
|
||||
(col) => col.title === column
|
||||
)?.width
|
||||
|
||||
if (configWidth) {
|
||||
return { wch: configWidth }
|
||||
}
|
||||
if (configWidth) {
|
||||
return { wch: configWidth }
|
||||
}
|
||||
|
||||
// 自动计算列宽度
|
||||
const maxLength = Math.max(
|
||||
column.length, // 标题长度
|
||||
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
|
||||
)
|
||||
// 自动计算列宽度
|
||||
const maxLength = Math.max(
|
||||
column.length, // 标题长度
|
||||
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
|
||||
)
|
||||
|
||||
// 限制最小和最大宽度
|
||||
const width = Math.min(Math.max(maxLength + 2, 8), 50)
|
||||
return { wch: width }
|
||||
})
|
||||
}
|
||||
// 限制最小和最大宽度
|
||||
const width = Math.min(Math.max(maxLength + 2, 8), 50)
|
||||
return { wch: width }
|
||||
})
|
||||
}
|
||||
|
||||
/** 导出到 Excel */
|
||||
const exportToExcel = async (
|
||||
data: ExportData[],
|
||||
filename: string,
|
||||
sheetName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
emit('export-progress', 10)
|
||||
/** 导出到 Excel */
|
||||
const exportToExcel = async (
|
||||
data: ExportData[],
|
||||
filename: string,
|
||||
sheetName: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
emit('export-progress', 10)
|
||||
|
||||
// 处理数据
|
||||
const processedData = processData(data)
|
||||
emit('export-progress', 30)
|
||||
// 处理数据
|
||||
const processedData = processData(data)
|
||||
emit('export-progress', 30)
|
||||
|
||||
// 创建工作簿
|
||||
const workbook = XLSX.utils.book_new()
|
||||
// 创建工作簿
|
||||
const workbook = XLSX.utils.book_new()
|
||||
|
||||
// 设置工作簿属性
|
||||
if (props.workbookOptions) {
|
||||
workbook.Props = {
|
||||
Title: filename,
|
||||
Subject: '数据导出',
|
||||
Author: props.workbookOptions.creator || 'Art Design Pro',
|
||||
Manager: props.workbookOptions.lastModifiedBy || '',
|
||||
Company: '系统导出',
|
||||
Category: '数据',
|
||||
Keywords: 'excel,export,data',
|
||||
Comments: '由系统自动生成',
|
||||
CreatedDate: props.workbookOptions.created || new Date(),
|
||||
ModifiedDate: props.workbookOptions.modified || new Date()
|
||||
}
|
||||
}
|
||||
// 设置工作簿属性
|
||||
if (props.workbookOptions) {
|
||||
workbook.Props = {
|
||||
Title: filename,
|
||||
Subject: '数据导出',
|
||||
Author: props.workbookOptions.creator || 'Art Design Pro',
|
||||
Manager: props.workbookOptions.lastModifiedBy || '',
|
||||
Company: '系统导出',
|
||||
Category: '数据',
|
||||
Keywords: 'excel,export,data',
|
||||
Comments: '由系统自动生成',
|
||||
CreatedDate: props.workbookOptions.created || 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 文件
|
||||
const excelBuffer = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'array',
|
||||
compression: true
|
||||
})
|
||||
// 生成 Excel 文件
|
||||
const excelBuffer = XLSX.write(workbook, {
|
||||
bookType: 'xlsx',
|
||||
type: 'array',
|
||||
compression: true
|
||||
})
|
||||
|
||||
// 创建 Blob 并下载
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
// 创建 Blob 并下载
|
||||
const blob = new Blob([excelBuffer], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
})
|
||||
|
||||
emit('export-progress', 95)
|
||||
emit('export-progress', 95)
|
||||
|
||||
// 使用时间戳确保文件名唯一
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const finalFilename = `${filename}_${timestamp}.xlsx`
|
||||
// 使用时间戳确保文件名唯一
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
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()
|
||||
} catch (error) {
|
||||
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, 'EXPORT_FAILED', error)
|
||||
}
|
||||
}
|
||||
return Promise.resolve()
|
||||
} catch (error) {
|
||||
throw new ExportError(
|
||||
`Excel 导出失败: ${(error as Error).message}`,
|
||||
'EXPORT_FAILED',
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理导出 */
|
||||
const handleExport = useThrottleFn(async () => {
|
||||
if (isExporting.value) return
|
||||
/** 处理导出 */
|
||||
const handleExport = useThrottleFn(async () => {
|
||||
if (isExporting.value) return
|
||||
|
||||
isExporting.value = true
|
||||
isExporting.value = true
|
||||
|
||||
try {
|
||||
// 验证数据
|
||||
validateData(props.data)
|
||||
try {
|
||||
// 验证数据
|
||||
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) {
|
||||
ElMessage.success({
|
||||
message: `成功导出 ${props.data.length} 条数据`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const exportError =
|
||||
error instanceof ExportError
|
||||
? error
|
||||
: new ExportError(`导出失败: ${(error as Error).message}`, 'UNKNOWN_ERROR', error)
|
||||
// 显示成功消息
|
||||
if (props.showSuccessMessage) {
|
||||
ElMessage.success({
|
||||
message: `成功导出 ${props.data.length} 条数据`,
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const exportError =
|
||||
error instanceof ExportError
|
||||
? error
|
||||
: new ExportError(
|
||||
`导出失败: ${(error as Error).message}`,
|
||||
'UNKNOWN_ERROR',
|
||||
error
|
||||
)
|
||||
|
||||
// 触发错误事件
|
||||
emit('export-error', exportError)
|
||||
// 触发错误事件
|
||||
emit('export-error', exportError)
|
||||
|
||||
// 显示错误消息
|
||||
if (props.showErrorMessage) {
|
||||
ElMessage.error({
|
||||
message: exportError.message,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
// 显示错误消息
|
||||
if (props.showErrorMessage) {
|
||||
ElMessage.error({
|
||||
message: exportError.message,
|
||||
duration: 5000
|
||||
})
|
||||
}
|
||||
|
||||
console.error('Excel 导出错误:', exportError)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
emit('export-progress', 0)
|
||||
}
|
||||
}, 1000)
|
||||
console.error('Excel 导出错误:', exportError)
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
emit('export-progress', 0)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
exportData: handleExport,
|
||||
isExporting: readonly(isExporting),
|
||||
hasData
|
||||
})
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
exportData: handleExport,
|
||||
isExporting: readonly(isExporting),
|
||||
hasData
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
.is-loading {
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
@keyframes rotating {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
<!-- 导入 Excel 文件 -->
|
||||
<template>
|
||||
<div class="inline-block">
|
||||
<ElUpload
|
||||
:auto-upload="false"
|
||||
accept=".xlsx, .xls"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<ElButton type="primary" v-ripple>
|
||||
<slot>导入 Excel</slot>
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
<div class="inline-block">
|
||||
<ElUpload
|
||||
:auto-upload="false"
|
||||
accept=".xlsx, .xls"
|
||||
:show-file-list="false"
|
||||
@change="handleFileChange"
|
||||
>
|
||||
<ElButton type="primary" v-ripple>
|
||||
<slot>导入 Excel</slot>
|
||||
</ElButton>
|
||||
</ElUpload>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
import * as XLSX from 'xlsx'
|
||||
import type { UploadFile } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'ArtExcelImport' })
|
||||
defineOptions({ name: 'ArtExcelImport' })
|
||||
|
||||
// Excel 导入工具函数
|
||||
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
// Excel 导入工具函数
|
||||
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
const results = XLSX.utils.sheet_to_json(worksheet)
|
||||
resolve(results as Array<Record<string, unknown>>)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result
|
||||
const workbook = XLSX.read(data, { type: 'array' })
|
||||
const firstSheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[firstSheetName]
|
||||
const results = XLSX.utils.sheet_to_json(worksheet)
|
||||
resolve(results as Array<Record<string, unknown>>)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
reader.onerror = (error) => reject(error)
|
||||
reader.readAsArrayBuffer(file)
|
||||
})
|
||||
}
|
||||
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
'import-success': [data: Array<Record<string, unknown>>]
|
||||
'import-error': [error: Error]
|
||||
}>()
|
||||
// 定义 emits
|
||||
const emit = defineEmits<{
|
||||
'import-success': [data: Array<Record<string, unknown>>]
|
||||
'import-error': [error: Error]
|
||||
}>()
|
||||
|
||||
// 处理文件导入
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
try {
|
||||
if (!uploadFile.raw) return
|
||||
const results = await importExcel(uploadFile.raw)
|
||||
emit('import-success', results)
|
||||
} catch (error) {
|
||||
emit('import-error', error as Error)
|
||||
}
|
||||
}
|
||||
// 处理文件导入
|
||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||
try {
|
||||
if (!uploadFile.raw) return
|
||||
const results = await importExcel(uploadFile.raw)
|
||||
emit('import-success', results)
|
||||
} catch (error) {
|
||||
emit('import-error', error as Error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,310 +2,323 @@
|
||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||
<template>
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow class="flex flex-wrap" :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
v-model="modelValue[item.key]"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow class="flex flex-wrap" :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
v-model="modelValue[item.key]"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 复选框组 -->
|
||||
<template
|
||||
v-if="item.type === 'checkboxgroup' && getProps(item)?.options"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 单选框组 -->
|
||||
<template
|
||||
v-if="item.type === 'radiogroup' && getProps(item)?.options"
|
||||
>
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||
<div
|
||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
:style="actionButtonsStyle"
|
||||
>
|
||||
<div class="flex gap-2 md:justify-center">
|
||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
||||
{{ t('table.form.reset') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSubmit"
|
||||
type="primary"
|
||||
class="submit-button"
|
||||
@click="handleSubmit"
|
||||
v-ripple
|
||||
:disabled="disabledSubmit"
|
||||
>
|
||||
{{ t('table.form.submit') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
<!-- 动态插槽支持 -->
|
||||
<template
|
||||
v-for="(slotFn, slotName) in getSlots(item)"
|
||||
:key="slotName"
|
||||
#[slotName]
|
||||
>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||
<div
|
||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||
:style="actionButtonsStyle"
|
||||
>
|
||||
<div class="flex gap-2 md:justify-center">
|
||||
<ElButton
|
||||
v-if="showReset"
|
||||
class="reset-button"
|
||||
@click="handleReset"
|
||||
v-ripple
|
||||
>
|
||||
{{ t('table.form.reset') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSubmit"
|
||||
type="primary"
|
||||
class="submit-button"
|
||||
@click="handleSubmit"
|
||||
v-ripple
|
||||
:disabled="disabledSubmit"
|
||||
>
|
||||
{{ t('table.form.submit') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputTag,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTreeSelect,
|
||||
type FormInstance
|
||||
} from 'element-plus'
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputTag,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTreeSelect,
|
||||
type FormInstance
|
||||
} from 'element-plus'
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
||||
|
||||
defineOptions({ name: 'ArtForm' })
|
||||
defineOptions({ name: 'ArtForm' })
|
||||
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputtag: ElInputTag, // 标签输入框
|
||||
number: ElInputNumber, // 数字输入框
|
||||
select: ElSelect, // 选择器
|
||||
switch: ElSwitch, // 开关
|
||||
checkbox: ElCheckbox, // 复选框
|
||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||
radiogroup: ElRadioGroup, // 单选框组
|
||||
date: ElDatePicker, // 日期选择器
|
||||
daterange: ElDatePicker, // 日期范围选择器
|
||||
datetime: ElDatePicker, // 日期时间选择器
|
||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||
rate: ElRate, // 评分
|
||||
slider: ElSlider, // 滑块
|
||||
cascader: ElCascader, // 级联选择器
|
||||
timepicker: ElTimePicker, // 时间选择器
|
||||
timeselect: ElTimeSelect, // 时间选择
|
||||
treeselect: ElTreeSelect // 树选择器
|
||||
}
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputtag: ElInputTag, // 标签输入框
|
||||
number: ElInputNumber, // 数字输入框
|
||||
select: ElSelect, // 选择器
|
||||
switch: ElSwitch, // 开关
|
||||
checkbox: ElCheckbox, // 复选框
|
||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||
radiogroup: ElRadioGroup, // 单选框组
|
||||
date: ElDatePicker, // 日期选择器
|
||||
daterange: ElDatePicker, // 日期范围选择器
|
||||
datetime: ElDatePicker, // 日期时间选择器
|
||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||
rate: ElRate, // 评分
|
||||
slider: ElSlider, // 滑块
|
||||
cascader: ElCascader, // 级联选择器
|
||||
timepicker: ElTimePicker, // 时间选择器
|
||||
timeselect: ElTimeSelect, // 时间选择
|
||||
treeselect: ElTreeSelect // 树选择器
|
||||
}
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
const formInstance = useTemplateRef<FormInstance>('formRef')
|
||||
const formInstance = useTemplateRef<FormInstance>('formRef')
|
||||
|
||||
// 表单项配置
|
||||
export interface FormItem {
|
||||
/** 表单项的唯一标识 */
|
||||
key: string
|
||||
/** 表单项的标签文本或自定义渲染函数 */
|
||||
label: string | (() => VNode) | Component
|
||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||
labelWidth?: string | number
|
||||
/** 表单项类型,支持预定义的组件类型 */
|
||||
type?: keyof typeof componentMap | string
|
||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||
render?: (() => VNode) | Component
|
||||
/** 是否隐藏该表单项 */
|
||||
hidden?: boolean
|
||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||
span?: number
|
||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||
options?: Record<string, any>
|
||||
/** 传递给表单项组件的属性 */
|
||||
props?: Record<string, any>
|
||||
/** 表单项的插槽配置 */
|
||||
slots?: Record<string, (() => any) | undefined>
|
||||
/** 表单项的占位符文本 */
|
||||
placeholder?: string
|
||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||
}
|
||||
// 表单项配置
|
||||
export interface FormItem {
|
||||
/** 表单项的唯一标识 */
|
||||
key: string
|
||||
/** 表单项的标签文本或自定义渲染函数 */
|
||||
label: string | (() => VNode) | Component
|
||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||
labelWidth?: string | number
|
||||
/** 表单项类型,支持预定义的组件类型 */
|
||||
type?: keyof typeof componentMap | string
|
||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||
render?: (() => VNode) | Component
|
||||
/** 是否隐藏该表单项 */
|
||||
hidden?: boolean
|
||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||
span?: number
|
||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||
options?: Record<string, any>
|
||||
/** 传递给表单项组件的属性 */
|
||||
props?: Record<string, any>
|
||||
/** 表单项的插槽配置 */
|
||||
slots?: Record<string, (() => any) | undefined>
|
||||
/** 表单项的占位符文本 */
|
||||
placeholder?: string
|
||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||
}
|
||||
|
||||
// 表单配置
|
||||
interface FormProps {
|
||||
/** 表单数据 */
|
||||
items: FormItem[]
|
||||
/** 每列的宽度(基于 24 格布局) */
|
||||
span?: number
|
||||
/** 表单控件间隙 */
|
||||
gutter?: number
|
||||
/** 表单域标签的位置 */
|
||||
labelPosition?: 'left' | 'right' | 'top'
|
||||
/** 文字宽度 */
|
||||
labelWidth?: string | number
|
||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||
buttonLeftLimit?: number
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmit?: boolean
|
||||
/** 是否禁用提交按钮 */
|
||||
disabledSubmit?: boolean
|
||||
}
|
||||
// 表单配置
|
||||
interface FormProps {
|
||||
/** 表单数据 */
|
||||
items: FormItem[]
|
||||
/** 每列的宽度(基于 24 格布局) */
|
||||
span?: number
|
||||
/** 表单控件间隙 */
|
||||
gutter?: number
|
||||
/** 表单域标签的位置 */
|
||||
labelPosition?: 'left' | 'right' | 'top'
|
||||
/** 文字宽度 */
|
||||
labelWidth?: string | number
|
||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||
buttonLeftLimit?: number
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean
|
||||
/** 是否显示提交按钮 */
|
||||
showSubmit?: boolean
|
||||
/** 是否禁用提交按钮 */
|
||||
disabledSubmit?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<FormProps>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
gutter: 12,
|
||||
labelPosition: 'right',
|
||||
labelWidth: '70px',
|
||||
buttonLeftLimit: 2,
|
||||
showReset: true,
|
||||
showSubmit: true,
|
||||
disabledSubmit: false
|
||||
})
|
||||
const props = withDefaults(defineProps<FormProps>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
gutter: 12,
|
||||
labelPosition: 'right',
|
||||
labelWidth: '70px',
|
||||
buttonLeftLimit: 2,
|
||||
showReset: true,
|
||||
showSubmit: true,
|
||||
disabledSubmit: false
|
||||
})
|
||||
|
||||
interface FormEmits {
|
||||
reset: []
|
||||
submit: []
|
||||
}
|
||||
interface FormEmits {
|
||||
reset: []
|
||||
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) => {
|
||||
if (item.props) return item.props
|
||||
const props = { ...item }
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
||||
return props
|
||||
}
|
||||
const getProps = (item: FormItem) => {
|
||||
if (item.props) return item.props
|
||||
const props = { ...item }
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
||||
return props
|
||||
}
|
||||
|
||||
// 获取插槽
|
||||
const getSlots = (item: FormItem) => {
|
||||
if (!item.slots) return {}
|
||||
const validSlots: Record<string, () => any> = {}
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn
|
||||
}
|
||||
})
|
||||
return validSlots
|
||||
}
|
||||
// 获取插槽
|
||||
const getSlots = (item: FormItem) => {
|
||||
if (!item.slots) return {}
|
||||
const validSlots: Record<string, () => any> = {}
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn
|
||||
}
|
||||
})
|
||||
return validSlots
|
||||
}
|
||||
|
||||
// 组件
|
||||
const getComponent = (item: FormItem) => {
|
||||
// 优先使用 render 函数或组件渲染自定义组件
|
||||
if (item.render) {
|
||||
return item.render
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
||||
}
|
||||
// 组件
|
||||
const getComponent = (item: FormItem) => {
|
||||
// 优先使用 render 函数或组件渲染自定义组件
|
||||
if (item.render) {
|
||||
return item.render
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
||||
}
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
return props.items.filter((item) => !item.hidden)
|
||||
})
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
return props.items.filter((item) => !item.hidden)
|
||||
})
|
||||
|
||||
/**
|
||||
* 操作按钮样式
|
||||
*/
|
||||
const actionButtonsStyle = computed(() => ({
|
||||
'justify-content': isMobile.value
|
||||
? 'flex-end'
|
||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}))
|
||||
/**
|
||||
* 操作按钮样式
|
||||
*/
|
||||
const actionButtonsStyle = computed(() => ({
|
||||
'justify-content': isMobile.value
|
||||
? 'flex-end'
|
||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}))
|
||||
|
||||
/**
|
||||
* 处理重置事件
|
||||
*/
|
||||
const handleReset = () => {
|
||||
// 重置表单字段(UI 层)
|
||||
formInstance.value?.resetFields()
|
||||
/**
|
||||
* 处理重置事件
|
||||
*/
|
||||
const handleReset = () => {
|
||||
// 重置表单字段(UI 层)
|
||||
formInstance.value?.resetFields()
|
||||
|
||||
// 清空所有表单项值(包含隐藏项)
|
||||
Object.assign(
|
||||
modelValue.value,
|
||||
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
||||
)
|
||||
// 清空所有表单项值(包含隐藏项)
|
||||
Object.assign(
|
||||
modelValue.value,
|
||||
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
||||
)
|
||||
|
||||
// 触发 reset 事件
|
||||
emit('reset')
|
||||
}
|
||||
// 触发 reset 事件
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理提交事件
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
/**
|
||||
* 处理提交事件
|
||||
*/
|
||||
const handleSubmit = () => {
|
||||
emit('submit')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
reset: handleReset
|
||||
})
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
reset: handleReset
|
||||
})
|
||||
|
||||
// 解构 props 以便在模板中直接使用
|
||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
||||
// 解构 props 以便在模板中直接使用
|
||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
||||
</script>
|
||||
|
||||
@@ -2,436 +2,455 @@
|
||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||
<template>
|
||||
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
v-model="modelValue[item.key]"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="modelValue"
|
||||
:label-position="labelPosition"
|
||||
v-bind="{ ...$attrs }"
|
||||
>
|
||||
<ElRow :gutter="gutter">
|
||||
<ElCol
|
||||
v-for="item in visibleFormItems"
|
||||
:key="item.key"
|
||||
:xs="getColSpan(item.span, 'xs')"
|
||||
:sm="getColSpan(item.span, 'sm')"
|
||||
:md="getColSpan(item.span, 'md')"
|
||||
:lg="getColSpan(item.span, 'lg')"
|
||||
:xl="getColSpan(item.span, 'xl')"
|
||||
>
|
||||
<ElFormItem
|
||||
:prop="item.key"
|
||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||
>
|
||||
<template #label v-if="item.label">
|
||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||
<span v-else>{{ item.label }}</span>
|
||||
</template>
|
||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||
<component
|
||||
:is="getComponent(item)"
|
||||
v-model="modelValue[item.key]"
|
||||
v-bind="getProps(item)"
|
||||
>
|
||||
<!-- 下拉选择 -->
|
||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||
<ElOption
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 复选框组 -->
|
||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 复选框组 -->
|
||||
<template
|
||||
v-if="item.type === 'checkboxgroup' && getProps(item)?.options"
|
||||
>
|
||||
<ElCheckbox
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 单选框组 -->
|
||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
<!-- 单选框组 -->
|
||||
<template
|
||||
v-if="item.type === 'radiogroup' && getProps(item)?.options"
|
||||
>
|
||||
<ElRadio
|
||||
v-for="option in getProps(item).options"
|
||||
v-bind="option"
|
||||
:key="option.value"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 动态插槽支持 -->
|
||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
||||
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
||||
<div class="form-buttons">
|
||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
||||
{{ t('table.searchBar.reset') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSearch"
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="handleSearch"
|
||||
v-ripple
|
||||
:disabled="disabledSearch"
|
||||
>
|
||||
{{ t('table.searchBar.search') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
|
||||
<span>{{ expandToggleText }}</span>
|
||||
<div class="icon-wrapper">
|
||||
<ElIcon>
|
||||
<ArrowUpBold v-if="isExpanded" />
|
||||
<ArrowDownBold v-else />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
<!-- 动态插槽支持 -->
|
||||
<template
|
||||
v-for="(slotFn, slotName) in getSlots(item)"
|
||||
:key="slotName"
|
||||
#[slotName]
|
||||
>
|
||||
<component :is="slotFn" />
|
||||
</template>
|
||||
</component>
|
||||
</slot>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
||||
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
||||
<div class="form-buttons">
|
||||
<ElButton
|
||||
v-if="showReset"
|
||||
class="reset-button"
|
||||
@click="handleReset"
|
||||
v-ripple
|
||||
>
|
||||
{{ t('table.searchBar.reset') }}
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="showSearch"
|
||||
type="primary"
|
||||
class="search-button"
|
||||
@click="handleSearch"
|
||||
v-ripple
|
||||
:disabled="disabledSearch"
|
||||
>
|
||||
{{ t('table.searchBar.search') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShowExpandToggle"
|
||||
class="filter-toggle"
|
||||
@click="toggleExpand"
|
||||
>
|
||||
<span>{{ expandToggleText }}</span>
|
||||
<div class="icon-wrapper">
|
||||
<ElIcon>
|
||||
<ArrowUpBold v-if="isExpanded" />
|
||||
<ArrowDownBold v-else />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputTag,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTreeSelect,
|
||||
type FormInstance
|
||||
} from 'element-plus'
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
||||
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
ElCascader,
|
||||
ElCheckbox,
|
||||
ElCheckboxGroup,
|
||||
ElDatePicker,
|
||||
ElInput,
|
||||
ElInputTag,
|
||||
ElInputNumber,
|
||||
ElRadioGroup,
|
||||
ElRate,
|
||||
ElSelect,
|
||||
ElSlider,
|
||||
ElSwitch,
|
||||
ElTimePicker,
|
||||
ElTimeSelect,
|
||||
ElTreeSelect,
|
||||
type FormInstance
|
||||
} from 'element-plus'
|
||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
||||
|
||||
defineOptions({ name: 'ArtSearchBar' })
|
||||
defineOptions({ name: 'ArtSearchBar' })
|
||||
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputTag: ElInputTag, // 标签输入框
|
||||
number: ElInputNumber, // 数字输入框
|
||||
select: ElSelect, // 选择器
|
||||
switch: ElSwitch, // 开关
|
||||
checkbox: ElCheckbox, // 复选框
|
||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||
radiogroup: ElRadioGroup, // 单选框组
|
||||
date: ElDatePicker, // 日期选择器
|
||||
daterange: ElDatePicker, // 日期范围选择器
|
||||
datetime: ElDatePicker, // 日期时间选择器
|
||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||
rate: ElRate, // 评分
|
||||
slider: ElSlider, // 滑块
|
||||
cascader: ElCascader, // 级联选择器
|
||||
timepicker: ElTimePicker, // 时间选择器
|
||||
timeselect: ElTimeSelect, // 时间选择
|
||||
treeselect: ElTreeSelect // 树选择器
|
||||
}
|
||||
const componentMap = {
|
||||
input: ElInput, // 输入框
|
||||
inputTag: ElInputTag, // 标签输入框
|
||||
number: ElInputNumber, // 数字输入框
|
||||
select: ElSelect, // 选择器
|
||||
switch: ElSwitch, // 开关
|
||||
checkbox: ElCheckbox, // 复选框
|
||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||
radiogroup: ElRadioGroup, // 单选框组
|
||||
date: ElDatePicker, // 日期选择器
|
||||
daterange: ElDatePicker, // 日期范围选择器
|
||||
datetime: ElDatePicker, // 日期时间选择器
|
||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||
rate: ElRate, // 评分
|
||||
slider: ElSlider, // 滑块
|
||||
cascader: ElCascader, // 级联选择器
|
||||
timepicker: ElTimePicker, // 时间选择器
|
||||
timeselect: ElTimeSelect, // 时间选择
|
||||
treeselect: ElTreeSelect // 树选择器
|
||||
}
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
const { width } = useWindowSize()
|
||||
const { t } = useI18n()
|
||||
const isMobile = computed(() => width.value < 500)
|
||||
|
||||
const formInstance = useTemplateRef<FormInstance>('formRef')
|
||||
const formInstance = useTemplateRef<FormInstance>('formRef')
|
||||
|
||||
// 表单项配置
|
||||
export interface SearchFormItem {
|
||||
/** 表单项的唯一标识 */
|
||||
key: string
|
||||
/** 表单项的标签文本或自定义渲染函数 */
|
||||
label: string | (() => VNode) | Component
|
||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||
labelWidth?: string | number
|
||||
/** 表单项类型,支持预定义的组件类型 */
|
||||
type?: keyof typeof componentMap | string
|
||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||
render?: (() => VNode) | Component
|
||||
/** 是否隐藏该表单项 */
|
||||
hidden?: boolean
|
||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||
span?: number
|
||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||
options?: Record<string, any>
|
||||
/** 传递给表单项组件的属性 */
|
||||
props?: Record<string, any>
|
||||
/** 表单项的插槽配置 */
|
||||
slots?: Record<string, (() => any) | undefined>
|
||||
/** 表单项的占位符文本 */
|
||||
placeholder?: string
|
||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||
}
|
||||
// 表单项配置
|
||||
export interface SearchFormItem {
|
||||
/** 表单项的唯一标识 */
|
||||
key: string
|
||||
/** 表单项的标签文本或自定义渲染函数 */
|
||||
label: string | (() => VNode) | Component
|
||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||
labelWidth?: string | number
|
||||
/** 表单项类型,支持预定义的组件类型 */
|
||||
type?: keyof typeof componentMap | string
|
||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||
render?: (() => VNode) | Component
|
||||
/** 是否隐藏该表单项 */
|
||||
hidden?: boolean
|
||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||
span?: number
|
||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||
options?: Record<string, any>
|
||||
/** 传递给表单项组件的属性 */
|
||||
props?: Record<string, any>
|
||||
/** 表单项的插槽配置 */
|
||||
slots?: Record<string, (() => any) | undefined>
|
||||
/** 表单项的占位符文本 */
|
||||
placeholder?: string
|
||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||
}
|
||||
|
||||
// 表单配置
|
||||
interface SearchBarProps {
|
||||
/** 表单数据 */
|
||||
items: SearchFormItem[]
|
||||
/** 每列的宽度(基于 24 格布局) */
|
||||
span?: number
|
||||
/** 表单控件间隙 */
|
||||
gutter?: number
|
||||
/** 展开/收起 */
|
||||
isExpand?: boolean
|
||||
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
|
||||
defaultExpanded?: boolean
|
||||
/** 表单域标签的位置 */
|
||||
labelPosition?: 'left' | 'right' | 'top'
|
||||
/** 文字宽度 */
|
||||
labelWidth?: string | number
|
||||
/** 是否需要展示,收起 */
|
||||
showExpand?: boolean
|
||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||
buttonLeftLimit?: number
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean
|
||||
/** 是否显示搜索按钮 */
|
||||
showSearch?: boolean
|
||||
/** 是否禁用搜索按钮 */
|
||||
disabledSearch?: boolean
|
||||
}
|
||||
// 表单配置
|
||||
interface SearchBarProps {
|
||||
/** 表单数据 */
|
||||
items: SearchFormItem[]
|
||||
/** 每列的宽度(基于 24 格布局) */
|
||||
span?: number
|
||||
/** 表单控件间隙 */
|
||||
gutter?: number
|
||||
/** 展开/收起 */
|
||||
isExpand?: boolean
|
||||
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
|
||||
defaultExpanded?: boolean
|
||||
/** 表单域标签的位置 */
|
||||
labelPosition?: 'left' | 'right' | 'top'
|
||||
/** 文字宽度 */
|
||||
labelWidth?: string | number
|
||||
/** 是否需要展示,收起 */
|
||||
showExpand?: boolean
|
||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||
buttonLeftLimit?: number
|
||||
/** 是否显示重置按钮 */
|
||||
showReset?: boolean
|
||||
/** 是否显示搜索按钮 */
|
||||
showSearch?: boolean
|
||||
/** 是否禁用搜索按钮 */
|
||||
disabledSearch?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SearchBarProps>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
gutter: 12,
|
||||
isExpand: false,
|
||||
labelPosition: 'right',
|
||||
labelWidth: '70px',
|
||||
showExpand: true,
|
||||
defaultExpanded: false,
|
||||
buttonLeftLimit: 2,
|
||||
showReset: true,
|
||||
showSearch: true,
|
||||
disabledSearch: false
|
||||
})
|
||||
const props = withDefaults(defineProps<SearchBarProps>(), {
|
||||
items: () => [],
|
||||
span: 6,
|
||||
gutter: 12,
|
||||
isExpand: false,
|
||||
labelPosition: 'right',
|
||||
labelWidth: '70px',
|
||||
showExpand: true,
|
||||
defaultExpanded: false,
|
||||
buttonLeftLimit: 2,
|
||||
showReset: true,
|
||||
showSearch: true,
|
||||
disabledSearch: false
|
||||
})
|
||||
|
||||
interface SearchBarEmits {
|
||||
reset: []
|
||||
search: []
|
||||
}
|
||||
interface SearchBarEmits {
|
||||
reset: []
|
||||
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) => {
|
||||
if (item.props) return item.props
|
||||
const props = { ...item }
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
||||
return props
|
||||
}
|
||||
const getProps = (item: SearchFormItem) => {
|
||||
if (item.props) return item.props
|
||||
const props = { ...item }
|
||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
||||
return props
|
||||
}
|
||||
|
||||
// 获取插槽
|
||||
const getSlots = (item: SearchFormItem) => {
|
||||
if (!item.slots) return {}
|
||||
const validSlots: Record<string, () => any> = {}
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn
|
||||
}
|
||||
})
|
||||
return validSlots
|
||||
}
|
||||
// 获取插槽
|
||||
const getSlots = (item: SearchFormItem) => {
|
||||
if (!item.slots) return {}
|
||||
const validSlots: Record<string, () => any> = {}
|
||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||
if (slotFn) {
|
||||
validSlots[key] = slotFn
|
||||
}
|
||||
})
|
||||
return validSlots
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
||||
}
|
||||
/**
|
||||
* 获取列宽 span 值
|
||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||
*/
|
||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
||||
}
|
||||
|
||||
// 组件
|
||||
const getComponent = (item: SearchFormItem) => {
|
||||
// 优先使用 render 函数或组件渲染自定义组件
|
||||
if (item.render) {
|
||||
return item.render
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
||||
}
|
||||
// 组件
|
||||
const getComponent = (item: SearchFormItem) => {
|
||||
// 优先使用 render 函数或组件渲染自定义组件
|
||||
if (item.render) {
|
||||
return item.render
|
||||
}
|
||||
// 使用 type 获取预定义组件
|
||||
const { type } = item
|
||||
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden)
|
||||
const shouldShowLess = !props.isExpand && !isExpanded.value
|
||||
if (shouldShowLess) {
|
||||
const maxItemsPerRow = Math.floor(24 / props.span) - 1
|
||||
return filteredItems.slice(0, maxItemsPerRow)
|
||||
}
|
||||
return filteredItems
|
||||
})
|
||||
/**
|
||||
* 可见的表单项
|
||||
*/
|
||||
const visibleFormItems = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden)
|
||||
const shouldShowLess = !props.isExpand && !isExpanded.value
|
||||
if (shouldShowLess) {
|
||||
const maxItemsPerRow = Math.floor(24 / props.span) - 1
|
||||
return filteredItems.slice(0, maxItemsPerRow)
|
||||
}
|
||||
return filteredItems
|
||||
})
|
||||
|
||||
/**
|
||||
* 是否应该显示展开/收起按钮
|
||||
*/
|
||||
const shouldShowExpandToggle = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden)
|
||||
return (
|
||||
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
|
||||
)
|
||||
})
|
||||
/**
|
||||
* 是否应该显示展开/收起按钮
|
||||
*/
|
||||
const shouldShowExpandToggle = computed(() => {
|
||||
const filteredItems = props.items.filter((item) => !item.hidden)
|
||||
return (
|
||||
!props.isExpand &&
|
||||
props.showExpand &&
|
||||
filteredItems.length > Math.floor(24 / props.span) - 1
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 展开/收起按钮文本
|
||||
*/
|
||||
const expandToggleText = computed(() => {
|
||||
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
|
||||
})
|
||||
/**
|
||||
* 展开/收起按钮文本
|
||||
*/
|
||||
const expandToggleText = computed(() => {
|
||||
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
|
||||
})
|
||||
|
||||
/**
|
||||
* 操作按钮样式
|
||||
*/
|
||||
const actionButtonsStyle = computed(() => ({
|
||||
'justify-content': isMobile.value
|
||||
? 'flex-end'
|
||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}))
|
||||
/**
|
||||
* 操作按钮样式
|
||||
*/
|
||||
const actionButtonsStyle = computed(() => ({
|
||||
'justify-content': isMobile.value
|
||||
? 'flex-end'
|
||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||
? 'flex-start'
|
||||
: 'flex-end'
|
||||
}))
|
||||
|
||||
/**
|
||||
* 切换展开/收起状态
|
||||
*/
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
/**
|
||||
* 切换展开/收起状态
|
||||
*/
|
||||
const toggleExpand = () => {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理重置事件
|
||||
*/
|
||||
const handleReset = () => {
|
||||
// 重置表单字段(UI 层)
|
||||
formInstance.value?.resetFields()
|
||||
/**
|
||||
* 处理重置事件
|
||||
*/
|
||||
const handleReset = () => {
|
||||
// 重置表单字段(UI 层)
|
||||
formInstance.value?.resetFields()
|
||||
|
||||
// 清空所有表单项值(包含隐藏项)
|
||||
Object.assign(
|
||||
modelValue.value,
|
||||
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
||||
)
|
||||
// 清空所有表单项值(包含隐藏项)
|
||||
Object.assign(
|
||||
modelValue.value,
|
||||
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
||||
)
|
||||
|
||||
// 触发 reset 事件
|
||||
emit('reset')
|
||||
}
|
||||
// 触发 reset 事件
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理搜索事件
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
emit('search')
|
||||
}
|
||||
/**
|
||||
* 处理搜索事件
|
||||
*/
|
||||
const handleSearch = () => {
|
||||
emit('search')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
reset: handleReset
|
||||
})
|
||||
defineExpose({
|
||||
ref: formInstance,
|
||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||
reset: handleReset
|
||||
})
|
||||
|
||||
// 解构 props 以便在模板中直接使用
|
||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
||||
// 解构 props 以便在模板中直接使用
|
||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.art-search-bar {
|
||||
padding: 15px 20px 0;
|
||||
.art-search-bar {
|
||||
padding: 15px 20px 0;
|
||||
|
||||
.action-column {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
.action-column {
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
|
||||
.action-buttons-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.action-buttons-wrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.form-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
line-height: 32px;
|
||||
color: var(--theme-color);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
.filter-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 10px;
|
||||
line-height: 32px;
|
||||
color: var(--theme-color);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--ElColor-primary);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--ElColor-primary);
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
span {
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式优化
|
||||
@media (width <= 768px) {
|
||||
.art-search-bar {
|
||||
padding: 16px 16px 0;
|
||||
// 响应式优化
|
||||
@media (width <= 768px) {
|
||||
.art-search-bar {
|
||||
padding: 16px 16px 0;
|
||||
|
||||
.action-column {
|
||||
.action-buttons-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
.action-column {
|
||||
.action-buttons-wrapper {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
|
||||
.form-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
.form-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.filter-toggle {
|
||||
justify-content: center;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,219 +1,222 @@
|
||||
<!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
||||
<template>
|
||||
<div class="editor-wrapper">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:mode="mode"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
v-model="modelValue"
|
||||
:mode="mode"
|
||||
:defaultConfig="editorConfig"
|
||||
@onCreated="onCreateEditor"
|
||||
/>
|
||||
</div>
|
||||
<div class="editor-wrapper">
|
||||
<Toolbar
|
||||
class="editor-toolbar"
|
||||
:editor="editorRef"
|
||||
:mode="mode"
|
||||
:defaultConfig="toolbarConfig"
|
||||
/>
|
||||
<Editor
|
||||
:style="{ height: height, overflowY: 'hidden' }"
|
||||
v-model="modelValue"
|
||||
:mode="mode"
|
||||
:defaultConfig="editorConfig"
|
||||
@onCreated="onCreateEditor"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
|
||||
import '@wangeditor/editor/dist/css/style.css'
|
||||
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import EmojiText from '@/utils/ui/emojo'
|
||||
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
|
||||
|
||||
defineOptions({ name: 'ArtWangEditor' })
|
||||
defineOptions({ name: 'ArtWangEditor' })
|
||||
|
||||
// Props 定义
|
||||
interface Props {
|
||||
/** 编辑器高度 */
|
||||
height?: string
|
||||
/** 自定义工具栏配置 */
|
||||
toolbarKeys?: string[]
|
||||
/** 插入新工具到指定位置 */
|
||||
insertKeys?: { index: number; keys: string[] }
|
||||
/** 排除的工具栏项 */
|
||||
excludeKeys?: string[]
|
||||
/** 编辑器模式 */
|
||||
mode?: 'default' | 'simple'
|
||||
/** 占位符文本 */
|
||||
placeholder?: string
|
||||
/** 上传配置 */
|
||||
uploadConfig?: {
|
||||
maxFileSize?: number
|
||||
maxNumberOfFiles?: number
|
||||
server?: string
|
||||
}
|
||||
}
|
||||
// Props 定义
|
||||
interface Props {
|
||||
/** 编辑器高度 */
|
||||
height?: string
|
||||
/** 自定义工具栏配置 */
|
||||
toolbarKeys?: string[]
|
||||
/** 插入新工具到指定位置 */
|
||||
insertKeys?: { index: number; keys: string[] }
|
||||
/** 排除的工具栏项 */
|
||||
excludeKeys?: string[]
|
||||
/** 编辑器模式 */
|
||||
mode?: 'default' | 'simple'
|
||||
/** 占位符文本 */
|
||||
placeholder?: string
|
||||
/** 上传配置 */
|
||||
uploadConfig?: {
|
||||
maxFileSize?: number
|
||||
maxNumberOfFiles?: number
|
||||
server?: string
|
||||
}
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '500px',
|
||||
mode: 'default',
|
||||
placeholder: '请输入内容...',
|
||||
excludeKeys: () => ['fontFamily']
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: '500px',
|
||||
mode: 'default',
|
||||
placeholder: '请输入内容...',
|
||||
excludeKeys: () => ['fontFamily']
|
||||
})
|
||||
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
const modelValue = defineModel<string>({ required: true })
|
||||
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
const userStore = useUserStore()
|
||||
// 编辑器实例
|
||||
const editorRef = shallowRef<IDomEditor>()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// 常量配置
|
||||
const DEFAULT_UPLOAD_CONFIG = {
|
||||
maxFileSize: 3 * 1024 * 1024, // 3MB
|
||||
maxNumberOfFiles: 10,
|
||||
fieldName: 'file',
|
||||
allowedFileTypes: ['image/*']
|
||||
} as const
|
||||
// 常量配置
|
||||
const DEFAULT_UPLOAD_CONFIG = {
|
||||
maxFileSize: 3 * 1024 * 1024, // 3MB
|
||||
maxNumberOfFiles: 10,
|
||||
fieldName: 'file',
|
||||
allowedFileTypes: ['image/*']
|
||||
} as const
|
||||
|
||||
// 计算属性:上传服务器地址
|
||||
const uploadServer = computed(
|
||||
() =>
|
||||
props.uploadConfig?.server || `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
|
||||
)
|
||||
// 计算属性:上传服务器地址
|
||||
const uploadServer = computed(
|
||||
() =>
|
||||
props.uploadConfig?.server ||
|
||||
`${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
|
||||
)
|
||||
|
||||
// 合并上传配置
|
||||
const mergedUploadConfig = computed(() => ({
|
||||
...DEFAULT_UPLOAD_CONFIG,
|
||||
...props.uploadConfig
|
||||
}))
|
||||
// 合并上传配置
|
||||
const mergedUploadConfig = computed(() => ({
|
||||
...DEFAULT_UPLOAD_CONFIG,
|
||||
...props.uploadConfig
|
||||
}))
|
||||
|
||||
// 工具栏配置
|
||||
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
|
||||
const config: Partial<IToolbarConfig> = {}
|
||||
// 工具栏配置
|
||||
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
|
||||
const config: Partial<IToolbarConfig> = {}
|
||||
|
||||
// 完全自定义工具栏
|
||||
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
|
||||
config.toolbarKeys = props.toolbarKeys
|
||||
}
|
||||
// 完全自定义工具栏
|
||||
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
|
||||
config.toolbarKeys = props.toolbarKeys
|
||||
}
|
||||
|
||||
// 插入新工具
|
||||
if (props.insertKeys) {
|
||||
config.insertKeys = props.insertKeys
|
||||
}
|
||||
// 插入新工具
|
||||
if (props.insertKeys) {
|
||||
config.insertKeys = props.insertKeys
|
||||
}
|
||||
|
||||
// 排除工具
|
||||
if (props.excludeKeys && props.excludeKeys.length > 0) {
|
||||
config.excludeKeys = props.excludeKeys
|
||||
}
|
||||
// 排除工具
|
||||
if (props.excludeKeys && props.excludeKeys.length > 0) {
|
||||
config.excludeKeys = props.excludeKeys
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
return config
|
||||
})
|
||||
|
||||
// 编辑器配置
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
fieldName: mergedUploadConfig.value.fieldName,
|
||||
maxFileSize: mergedUploadConfig.value.maxFileSize,
|
||||
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
|
||||
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
|
||||
server: uploadServer.value,
|
||||
headers: {
|
||||
Authorization: userStore.accessToken
|
||||
},
|
||||
onSuccess() {
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
console.error('图片上传失败:', err, res)
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 编辑器配置
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: props.placeholder,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
fieldName: mergedUploadConfig.value.fieldName,
|
||||
maxFileSize: mergedUploadConfig.value.maxFileSize,
|
||||
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
|
||||
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
|
||||
server: uploadServer.value,
|
||||
headers: {
|
||||
Authorization: userStore.accessToken
|
||||
},
|
||||
onSuccess() {
|
||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||
},
|
||||
onError(file: File, err: any, res: any) {
|
||||
console.error('图片上传失败:', err, res)
|
||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑器创建回调
|
||||
const onCreateEditor = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
// 编辑器创建回调
|
||||
const onCreateEditor = (editor: IDomEditor) => {
|
||||
editorRef.value = editor
|
||||
|
||||
// 监听全屏事件
|
||||
editor.on('fullScreen', () => {
|
||||
console.log('编辑器进入全屏模式')
|
||||
})
|
||||
// 监听全屏事件
|
||||
editor.on('fullScreen', () => {
|
||||
console.log('编辑器进入全屏模式')
|
||||
})
|
||||
|
||||
// 确保在编辑器创建后应用自定义图标
|
||||
applyCustomIcons()
|
||||
}
|
||||
// 确保在编辑器创建后应用自定义图标
|
||||
applyCustomIcons()
|
||||
}
|
||||
|
||||
// 应用自定义图标(带重试机制)
|
||||
const applyCustomIcons = () => {
|
||||
let retryCount = 0
|
||||
const maxRetries = 10
|
||||
const retryDelay = 100
|
||||
// 应用自定义图标(带重试机制)
|
||||
const applyCustomIcons = () => {
|
||||
let retryCount = 0
|
||||
const maxRetries = 10
|
||||
const retryDelay = 100
|
||||
|
||||
const tryApplyIcons = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
const tryApplyIcons = () => {
|
||||
const editor = editorRef.value
|
||||
if (!editor) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 获取当前编辑器的工具栏容器
|
||||
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
|
||||
if (!editorContainer) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 获取当前编辑器的工具栏容器
|
||||
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
|
||||
if (!editorContainer) {
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const toolbar = editorContainer.querySelector('.w-e-toolbar')
|
||||
const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]')
|
||||
const toolbar = editorContainer.querySelector('.w-e-toolbar')
|
||||
const toolbarButtons = editorContainer.querySelectorAll(
|
||||
'.w-e-bar-item button[data-menu-key]'
|
||||
)
|
||||
|
||||
if (toolbar && toolbarButtons.length > 0) {
|
||||
return
|
||||
}
|
||||
if (toolbar && toolbarButtons.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果工具栏还没渲染完成,继续重试
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
} else {
|
||||
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
|
||||
}
|
||||
}
|
||||
// 如果工具栏还没渲染完成,继续重试
|
||||
if (retryCount < maxRetries) {
|
||||
retryCount++
|
||||
setTimeout(tryApplyIcons, retryDelay)
|
||||
} else {
|
||||
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 requestAnimationFrame 确保在下一帧执行
|
||||
requestAnimationFrame(tryApplyIcons)
|
||||
}
|
||||
// 使用 requestAnimationFrame 确保在下一帧执行
|
||||
requestAnimationFrame(tryApplyIcons)
|
||||
}
|
||||
|
||||
// 暴露编辑器实例和方法
|
||||
defineExpose({
|
||||
/** 获取编辑器实例 */
|
||||
getEditor: () => editorRef.value,
|
||||
/** 设置编辑器内容 */
|
||||
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
||||
/** 获取编辑器内容 */
|
||||
getHtml: () => editorRef.value?.getHtml(),
|
||||
/** 清空编辑器 */
|
||||
clear: () => editorRef.value?.clear(),
|
||||
/** 聚焦编辑器 */
|
||||
focus: () => editorRef.value?.focus()
|
||||
})
|
||||
// 暴露编辑器实例和方法
|
||||
defineExpose({
|
||||
/** 获取编辑器实例 */
|
||||
getEditor: () => editorRef.value,
|
||||
/** 设置编辑器内容 */
|
||||
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
||||
/** 获取编辑器内容 */
|
||||
getHtml: () => editorRef.value?.getHtml(),
|
||||
/** 清空编辑器 */
|
||||
clear: () => editorRef.value?.clear(),
|
||||
/** 聚焦编辑器 */
|
||||
focus: () => editorRef.value?.focus()
|
||||
})
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 图标替换已在 onCreateEditor 中处理
|
||||
})
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
// 图标替换已在 onCreateEditor 中处理
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
const editor = editorRef.value
|
||||
if (editor) {
|
||||
editor.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use './style';
|
||||
@use './style';
|
||||
</style>
|
||||
|
||||
@@ -2,209 +2,209 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
||||
|
||||
// 全屏容器 z-index 调整
|
||||
.w-e-full-screen-container {
|
||||
z-index: 100 !important;
|
||||
z-index: 100 !important;
|
||||
}
|
||||
|
||||
/* 编辑器容器 */
|
||||
.editor-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border-radius: $box-radius !important;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
border-radius: $box-radius !important;
|
||||
|
||||
.w-e-bar {
|
||||
border-radius: $box-radius $box-radius 0 0 !important;
|
||||
}
|
||||
.w-e-bar {
|
||||
border-radius: $box-radius $box-radius 0 0 !important;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
.menu-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--default-border);
|
||||
}
|
||||
/* 工具栏 */
|
||||
.editor-toolbar {
|
||||
border-bottom: 1px solid var(--default-border);
|
||||
}
|
||||
|
||||
/* 下拉选择框配置 */
|
||||
.w-e-select-list {
|
||||
min-width: 140px;
|
||||
padding: 5px 10px 10px;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
/* 下拉选择框配置 */
|
||||
.w-e-select-list {
|
||||
min-width: 140px;
|
||||
padding: 5px 10px 10px;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 下拉选择框元素配置 */
|
||||
.w-e-select-list ul li {
|
||||
margin-top: 5px;
|
||||
font-size: 15px !important;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
/* 下拉选择框元素配置 */
|
||||
.w-e-select-list ul li {
|
||||
margin-top: 5px;
|
||||
font-size: 15px !important;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 下拉选择框 正文文字大小调整 */
|
||||
.w-e-select-list ul li:last-of-type {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
/* 下拉选择框 正文文字大小调整 */
|
||||
.w-e-select-list ul li:last-of-type {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
/* 下拉选择框 hover 样式调整 */
|
||||
.w-e-select-list ul li:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
:root {
|
||||
/* 激活颜色 */
|
||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--w-e-toolbar-color: #000;
|
||||
/* toolbar 图标和文字颜色 */
|
||||
--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 {
|
||||
fill: var(--art-gray-800);
|
||||
}
|
||||
/* 工具栏按钮样式 */
|
||||
.w-e-bar-item svg {
|
||||
fill: var(--art-gray-800);
|
||||
}
|
||||
|
||||
.w-e-bar-item button {
|
||||
color: var(--art-gray-800);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
.w-e-bar-item button {
|
||||
color: var(--art-gray-800);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
/* 工具栏 hover 按钮背景颜色 */
|
||||
.w-e-bar-item button:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
|
||||
/* 工具栏分割线 */
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
/* 工具栏分割线 */
|
||||
.w-e-bar-divider {
|
||||
height: 20px;
|
||||
margin-top: 10px;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
/* 工具栏菜单 */
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||
min-width: 120px;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
/* 工具栏菜单 */
|
||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||
min-width: 120px;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
|
||||
.w-e-bar-item {
|
||||
button {
|
||||
width: 100%;
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.w-e-bar-item {
|
||||
button {
|
||||
width: 100%;
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-50);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
/* 代码块 */
|
||||
.w-e-text-container [data-slate-editor] pre > code {
|
||||
padding: 0.6rem 1rem;
|
||||
background-color: var(--art-gray-50);
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 弹出框 */
|
||||
.w-e-drop-panel {
|
||||
border: 0;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
/* 弹出框 */
|
||||
.w-e-drop-panel {
|
||||
border: 0;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #318ef4;
|
||||
}
|
||||
a {
|
||||
color: #318ef4;
|
||||
}
|
||||
|
||||
.w-e-text-container {
|
||||
strong,
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
.w-e-text-container {
|
||||
strong,
|
||||
b {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
i,
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
i,
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
.w-e-text-container [data-slate-editor] .table-container th {
|
||||
border-right: none;
|
||||
}
|
||||
/* 表格样式优化 */
|
||||
.w-e-text-container [data-slate-editor] .table-container th {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||
border-right: 1px solid #ccc !important;
|
||||
}
|
||||
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: var(--art-gray-200);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
}
|
||||
/* 引用 */
|
||||
.w-e-text-container [data-slate-editor] blockquote {
|
||||
background-color: var(--art-gray-200);
|
||||
border-left: 4px solid var(--art-gray-300);
|
||||
}
|
||||
|
||||
/* 输入区域弹出 bar */
|
||||
.w-e-hover-bar {
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
/* 输入区域弹出 bar */
|
||||
.w-e-hover-bar {
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 超链接弹窗 */
|
||||
.w-e-modal {
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
/* 超链接弹窗 */
|
||||
.w-e-modal {
|
||||
border: none;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
/* 图片样式调整 */
|
||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||
overflow: inherit;
|
||||
/* 图片样式调整 */
|
||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||
overflow: inherit;
|
||||
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
&:hover {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 1px solid transparent;
|
||||
transition: border 0.3s;
|
||||
img {
|
||||
border: 1px solid transparent;
|
||||
transition: border 0.3s;
|
||||
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
border: 1px solid #318ef4 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
border: 2px solid #fff;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
.w-e-image-dragger {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background-color: #318ef4;
|
||||
border: 2px solid #fff;
|
||||
border-radius: $box-radius;
|
||||
}
|
||||
|
||||
.left-top {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
.left-top {
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-top {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
.right-top {
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
}
|
||||
|
||||
.left-bottom {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
.left-bottom {
|
||||
bottom: -6px;
|
||||
left: -6px;
|
||||
}
|
||||
|
||||
.right-bottom {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
.right-bottom {
|
||||
right: -6px;
|
||||
bottom: -6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,142 +1,142 @@
|
||||
<!-- 面包屑导航 -->
|
||||
<template>
|
||||
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
|
||||
<ul class="flex-c h-full">
|
||||
<li
|
||||
v-for="(item, index) in breadcrumbItems"
|
||||
:key="item.path"
|
||||
class="box-border flex-c h-7 text-sm leading-7"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
isClickable(item, index)
|
||||
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
|
||||
: ''
|
||||
"
|
||||
@click="handleBreadcrumbClick(item, index)"
|
||||
>
|
||||
<span
|
||||
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
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLastItem(index) && item.meta?.title"
|
||||
class="mx-1 text-sm not-italic text-g-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
|
||||
<ul class="flex-c h-full">
|
||||
<li
|
||||
v-for="(item, index) in breadcrumbItems"
|
||||
:key="item.path"
|
||||
class="box-border flex-c h-7 text-sm leading-7"
|
||||
>
|
||||
<div
|
||||
:class="
|
||||
isClickable(item, index)
|
||||
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
|
||||
: ''
|
||||
"
|
||||
@click="handleBreadcrumbClick(item, index)"
|
||||
>
|
||||
<span
|
||||
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
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isLastItem(index) && item.meta?.title"
|
||||
class="mx-1 text-sm not-italic text-g-500"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
|
||||
defineOptions({ name: 'ArtBreadcrumb' })
|
||||
defineOptions({ name: 'ArtBreadcrumb' })
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
path: string
|
||||
meta: RouteRecordRaw['meta']
|
||||
}
|
||||
export interface BreadcrumbItem {
|
||||
path: string
|
||||
meta: RouteRecordRaw['meta']
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 使用computed替代watch,提高性能
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
|
||||
const { matched } = route
|
||||
const matchedLength = matched.length
|
||||
// 使用computed替代watch,提高性能
|
||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
|
||||
const { matched } = route
|
||||
const matchedLength = matched.length
|
||||
|
||||
// 处理首页情况
|
||||
if (!matchedLength || isHomeRoute(matched[0])) {
|
||||
return []
|
||||
}
|
||||
// 处理首页情况
|
||||
if (!matchedLength || isHomeRoute(matched[0])) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 处理一级菜单和普通路由
|
||||
const firstRoute = matched[0]
|
||||
const isFirstLevel = firstRoute.meta?.isFirstLevel
|
||||
const lastIndex = matchedLength - 1
|
||||
const currentRoute = matched[lastIndex]
|
||||
const currentRouteMeta = currentRoute.meta
|
||||
// 处理一级菜单和普通路由
|
||||
const firstRoute = matched[0]
|
||||
const isFirstLevel = firstRoute.meta?.isFirstLevel
|
||||
const lastIndex = matchedLength - 1
|
||||
const currentRoute = matched[lastIndex]
|
||||
const currentRouteMeta = currentRoute.meta
|
||||
|
||||
let items = isFirstLevel
|
||||
? [createBreadcrumbItem(currentRoute)]
|
||||
: matched.map(createBreadcrumbItem)
|
||||
let items = isFirstLevel
|
||||
? [createBreadcrumbItem(currentRoute)]
|
||||
: matched.map(createBreadcrumbItem)
|
||||
|
||||
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
|
||||
if (items.length > 1 && isWrapperContainer(items[0])) {
|
||||
items = items.slice(1)
|
||||
}
|
||||
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
|
||||
if (items.length > 1 && isWrapperContainer(items[0])) {
|
||||
items = items.slice(1)
|
||||
}
|
||||
|
||||
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
|
||||
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
|
||||
return [createBreadcrumbItem(currentRoute)]
|
||||
}
|
||||
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
|
||||
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
|
||||
return [createBreadcrumbItem(currentRoute)]
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
return items
|
||||
})
|
||||
|
||||
// 辅助函数:判断是否为包裹容器路由
|
||||
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
|
||||
item.path === '/outside' && !!item.meta?.isIframe
|
||||
// 辅助函数:判断是否为包裹容器路由
|
||||
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
|
||||
item.path === '/outside' && !!item.meta?.isIframe
|
||||
|
||||
// 辅助函数:创建面包屑项目
|
||||
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
|
||||
path: route.path,
|
||||
meta: route.meta
|
||||
})
|
||||
// 辅助函数:创建面包屑项目
|
||||
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
|
||||
path: route.path,
|
||||
meta: route.meta
|
||||
})
|
||||
|
||||
// 辅助函数:判断是否为首页
|
||||
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
|
||||
// 辅助函数:判断是否为首页
|
||||
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
|
||||
|
||||
// 辅助函数:判断是否为最后一项
|
||||
const isLastItem = (index: number): boolean => {
|
||||
const itemsLength = breadcrumbItems.value.length
|
||||
return index === itemsLength - 1
|
||||
}
|
||||
// 辅助函数:判断是否为最后一项
|
||||
const isLastItem = (index: number): boolean => {
|
||||
const itemsLength = breadcrumbItems.value.length
|
||||
return index === itemsLength - 1
|
||||
}
|
||||
|
||||
// 辅助函数:判断是否可点击
|
||||
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
|
||||
item.path !== '/outside' && !isLastItem(index)
|
||||
// 辅助函数:判断是否可点击
|
||||
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
|
||||
item.path !== '/outside' && !isLastItem(index)
|
||||
|
||||
// 辅助函数:查找路由的第一个有效子路由
|
||||
const findFirstValidChild = (route: RouteRecordRaw) =>
|
||||
route.children?.find((child) => !child.redirect && !child.meta?.isHide)
|
||||
// 辅助函数:查找路由的第一个有效子路由
|
||||
const findFirstValidChild = (route: RouteRecordRaw) =>
|
||||
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> {
|
||||
// 如果是最后一项或外部链接,不处理
|
||||
if (isLastItem(index) || item.path === '/outside') {
|
||||
return
|
||||
}
|
||||
// 处理面包屑点击事件
|
||||
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
|
||||
// 如果是最后一项或外部链接,不处理
|
||||
if (isLastItem(index) || item.path === '/outside') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 缓存路由表查找结果
|
||||
const routes = router.getRoutes()
|
||||
const targetRoute = routes.find((route) => route.path === item.path)
|
||||
try {
|
||||
// 缓存路由表查找结果
|
||||
const routes = router.getRoutes()
|
||||
const targetRoute = routes.find((route) => route.path === item.path)
|
||||
|
||||
if (!targetRoute?.children?.length) {
|
||||
await router.push(item.path)
|
||||
return
|
||||
}
|
||||
if (!targetRoute?.children?.length) {
|
||||
await router.push(item.path)
|
||||
return
|
||||
}
|
||||
|
||||
const firstValidChild = findFirstValidChild(targetRoute)
|
||||
if (firstValidChild) {
|
||||
await router.push(buildFullPath(firstValidChild.path))
|
||||
} else {
|
||||
await router.push(item.path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导航失败:', error)
|
||||
}
|
||||
}
|
||||
const firstValidChild = findFirstValidChild(targetRoute)
|
||||
if (firstValidChild) {
|
||||
await router.push(buildFullPath(firstValidChild.path))
|
||||
} else {
|
||||
await router.push(item.path)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导航失败:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,262 +1,279 @@
|
||||
<!-- 系统聊天窗口 -->
|
||||
<template>
|
||||
<div>
|
||||
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
|
||||
<div class="mb-5 flex-cb">
|
||||
<div>
|
||||
<span class="text-base font-medium">Art Bot</span>
|
||||
<div class="mt-1.5 flex-c gap-1">
|
||||
<div
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
|
||||
></div>
|
||||
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ElIcon class="c-p" :size="20" @click="closeChat">
|
||||
<Close />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-[calc(100%-70px)] flex-col">
|
||||
<!-- 聊天消息区域 -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
|
||||
ref="messageContainer"
|
||||
>
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<div
|
||||
:class="[
|
||||
'mb-7.5 flex w-full items-start gap-2',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
||||
]"
|
||||
>
|
||||
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
|
||||
<div
|
||||
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mb-1 flex gap-2 text-xs',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
||||
]"
|
||||
>
|
||||
<span class="font-medium">{{ message.sender }}</span>
|
||||
<span class="text-g-600">{{ message.time }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'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'
|
||||
]"
|
||||
>{{ message.content }}</div
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<ElDrawer
|
||||
v-model="isDrawerVisible"
|
||||
:size="isMobile ? '100%' : '480px'"
|
||||
:with-header="false"
|
||||
>
|
||||
<div class="mb-5 flex-cb">
|
||||
<div>
|
||||
<span class="text-base font-medium">Art Bot</span>
|
||||
<div class="mt-1.5 flex-c gap-1">
|
||||
<div
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
|
||||
></div>
|
||||
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ElIcon class="c-p" :size="20" @click="closeChat">
|
||||
<Close />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex h-[calc(100%-70px)] flex-col">
|
||||
<!-- 聊天消息区域 -->
|
||||
<div
|
||||
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
|
||||
ref="messageContainer"
|
||||
>
|
||||
<template v-for="(message, index) in messages" :key="index">
|
||||
<div
|
||||
:class="[
|
||||
'mb-7.5 flex w-full items-start gap-2',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
||||
]"
|
||||
>
|
||||
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
|
||||
<div
|
||||
:class="[
|
||||
'flex max-w-[70%] flex-col',
|
||||
message.isMe ? 'items-end' : 'items-start'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'mb-1 flex gap-2 text-xs',
|
||||
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
||||
]"
|
||||
>
|
||||
<span class="font-medium">{{ message.sender }}</span>
|
||||
<span class="text-g-600">{{ message.time }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'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'
|
||||
]"
|
||||
>{{ message.content }}</div
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="px-4 pt-4">
|
||||
<ElInput
|
||||
v-model="messageText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入消息"
|
||||
resize="none"
|
||||
@keyup.enter.prevent="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<div class="flex gap-2 py-2">
|
||||
<ElButton :icon="Paperclip" circle plain />
|
||||
<ElButton :icon="Picture" circle plain />
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="mt-3 flex-cb">
|
||||
<div class="flex-c">
|
||||
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
<ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
</div>
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</div>
|
||||
<!-- 聊天输入区域 -->
|
||||
<div class="px-4 pt-4">
|
||||
<ElInput
|
||||
v-model="messageText"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="输入消息"
|
||||
resize="none"
|
||||
@keyup.enter.prevent="sendMessage"
|
||||
>
|
||||
<template #append>
|
||||
<div class="flex gap-2 py-2">
|
||||
<ElButton :icon="Paperclip" circle plain />
|
||||
<ElButton :icon="Picture" circle plain />
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple
|
||||
>发送</ElButton
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="mt-3 flex-cb">
|
||||
<div class="flex-c">
|
||||
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||
<ArtSvgIcon
|
||||
icon="ri:emotion-happy-line"
|
||||
class="mr-5 c-p text-g-600 text-lg"
|
||||
/>
|
||||
</div>
|
||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20"
|
||||
>发送</ElButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import meAvatar from '@/assets/images/avatar/avatar5.webp'
|
||||
import aiAvatar from '@/assets/images/avatar/avatar10.webp'
|
||||
import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import meAvatar from '@/assets/images/avatar/avatar5.webp'
|
||||
import aiAvatar from '@/assets/images/avatar/avatar10.webp'
|
||||
|
||||
defineOptions({ name: 'ArtChatWindow' })
|
||||
defineOptions({ name: 'ArtChatWindow' })
|
||||
|
||||
// 类型定义
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
sender: string
|
||||
content: string
|
||||
time: string
|
||||
isMe: boolean
|
||||
avatar: string
|
||||
}
|
||||
// 类型定义
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
sender: string
|
||||
content: string
|
||||
time: string
|
||||
isMe: boolean
|
||||
avatar: string
|
||||
}
|
||||
|
||||
// 常量定义
|
||||
const MOBILE_BREAKPOINT = 640
|
||||
const SCROLL_DELAY = 100
|
||||
const BOT_NAME = 'Art Bot'
|
||||
const USER_NAME = 'Ricky'
|
||||
// 常量定义
|
||||
const MOBILE_BREAKPOINT = 640
|
||||
const SCROLL_DELAY = 100
|
||||
const BOT_NAME = 'Art Bot'
|
||||
const USER_NAME = 'Ricky'
|
||||
|
||||
// 响应式布局
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
|
||||
// 响应式布局
|
||||
const { width } = useWindowSize()
|
||||
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
|
||||
|
||||
// 组件状态
|
||||
const isDrawerVisible = ref(false)
|
||||
const isOnline = ref(true)
|
||||
// 组件状态
|
||||
const isDrawerVisible = ref(false)
|
||||
const isOnline = ref(true)
|
||||
|
||||
// 消息相关状态
|
||||
const messageText = ref('')
|
||||
const messageId = ref(10)
|
||||
const messageContainer = ref<HTMLElement | null>(null)
|
||||
// 消息相关状态
|
||||
const messageText = ref('')
|
||||
const messageId = ref(10)
|
||||
const messageContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// 初始化聊天消息数据
|
||||
const initializeMessages = (): ChatMessage[] => [
|
||||
{
|
||||
id: 1,
|
||||
sender: BOT_NAME,
|
||||
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
|
||||
time: '10:00',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: USER_NAME,
|
||||
content: '我想了解一下系统的使用方法。',
|
||||
time: '10:01',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sender: BOT_NAME,
|
||||
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
|
||||
time: '10:02',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sender: USER_NAME,
|
||||
content: '听起来很不错,能具体讲讲数据分析部分吗?',
|
||||
time: '10:05',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
sender: BOT_NAME,
|
||||
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
|
||||
time: '10:06',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
sender: USER_NAME,
|
||||
content: '太好了,那我如何开始使用呢?',
|
||||
time: '10:08',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
sender: BOT_NAME,
|
||||
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
|
||||
time: '10:09',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
sender: USER_NAME,
|
||||
content: '明白了,谢谢你的帮助!',
|
||||
time: '10:10',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
sender: BOT_NAME,
|
||||
content: '不客气,有任何问题随时联系我。',
|
||||
time: '10:11',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
}
|
||||
]
|
||||
// 初始化聊天消息数据
|
||||
const initializeMessages = (): ChatMessage[] => [
|
||||
{
|
||||
id: 1,
|
||||
sender: BOT_NAME,
|
||||
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
|
||||
time: '10:00',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
sender: USER_NAME,
|
||||
content: '我想了解一下系统的使用方法。',
|
||||
time: '10:01',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
sender: BOT_NAME,
|
||||
content:
|
||||
'好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
|
||||
time: '10:02',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
sender: USER_NAME,
|
||||
content: '听起来很不错,能具体讲讲数据分析部分吗?',
|
||||
time: '10:05',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
sender: BOT_NAME,
|
||||
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
|
||||
time: '10:06',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
sender: USER_NAME,
|
||||
content: '太好了,那我如何开始使用呢?',
|
||||
time: '10:08',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
sender: BOT_NAME,
|
||||
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
|
||||
time: '10:09',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
sender: USER_NAME,
|
||||
content: '明白了,谢谢你的帮助!',
|
||||
time: '10:10',
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
sender: BOT_NAME,
|
||||
content: '不客气,有任何问题随时联系我。',
|
||||
time: '10:11',
|
||||
isMe: false,
|
||||
avatar: aiAvatar
|
||||
}
|
||||
]
|
||||
|
||||
const messages = ref<ChatMessage[]>(initializeMessages())
|
||||
const messages = ref<ChatMessage[]>(initializeMessages())
|
||||
|
||||
// 工具函数
|
||||
const formatCurrentTime = (): string => {
|
||||
return new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
// 工具函数
|
||||
const formatCurrentTime = (): string => {
|
||||
return new Date().toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = (): void => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||
}
|
||||
}, SCROLL_DELAY)
|
||||
})
|
||||
}
|
||||
const scrollToBottom = (): void => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
if (messageContainer.value) {
|
||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||
}
|
||||
}, SCROLL_DELAY)
|
||||
})
|
||||
}
|
||||
|
||||
// 消息处理方法
|
||||
const sendMessage = (): void => {
|
||||
const text = messageText.value.trim()
|
||||
if (!text) return
|
||||
// 消息处理方法
|
||||
const sendMessage = (): void => {
|
||||
const text = messageText.value.trim()
|
||||
if (!text) return
|
||||
|
||||
const newMessage: ChatMessage = {
|
||||
id: messageId.value++,
|
||||
sender: USER_NAME,
|
||||
content: text,
|
||||
time: formatCurrentTime(),
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
}
|
||||
const newMessage: ChatMessage = {
|
||||
id: messageId.value++,
|
||||
sender: USER_NAME,
|
||||
content: text,
|
||||
time: formatCurrentTime(),
|
||||
isMe: true,
|
||||
avatar: meAvatar
|
||||
}
|
||||
|
||||
messages.value.push(newMessage)
|
||||
messageText.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
messages.value.push(newMessage)
|
||||
messageText.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
// 聊天窗口控制方法
|
||||
const openChat = (): void => {
|
||||
isDrawerVisible.value = true
|
||||
scrollToBottom()
|
||||
}
|
||||
// 聊天窗口控制方法
|
||||
const openChat = (): void => {
|
||||
isDrawerVisible.value = true
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const closeChat = (): void => {
|
||||
isDrawerVisible.value = false
|
||||
}
|
||||
const closeChat = (): void => {
|
||||
isDrawerVisible.value = false
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
mittBus.on('openChat', openChat)
|
||||
})
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
scrollToBottom()
|
||||
mittBus.on('openChat', openChat)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
mittBus.off('openChat', openChat)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
mittBus.off('openChat', openChat)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,113 +1,117 @@
|
||||
<!-- 顶部快速入口面板 -->
|
||||
<template>
|
||||
<ElPopover
|
||||
ref="popoverRef"
|
||||
:width="700"
|
||||
:offset="0"
|
||||
:show-arrow="false"
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
popper-class="fast-enter-popover"
|
||||
:popper-style="{
|
||||
border: '1px solid var(--default-border)',
|
||||
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
|
||||
}"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="flex-c gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
<ElPopover
|
||||
ref="popoverRef"
|
||||
:width="700"
|
||||
:offset="0"
|
||||
:show-arrow="false"
|
||||
trigger="hover"
|
||||
placement="bottom-start"
|
||||
popper-class="fast-enter-popover"
|
||||
:popper-style="{
|
||||
border: '1px solid var(--default-border)',
|
||||
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
|
||||
}"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="flex-c gap-2">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-[2fr_0.8fr]">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<!-- 应用列表 -->
|
||||
<div
|
||||
v-for="application in enabledApplications"
|
||||
: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"
|
||||
@click="handleApplicationClick(application)"
|
||||
>
|
||||
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30">
|
||||
<ArtSvgIcon
|
||||
class="text-xl"
|
||||
:icon="application.icon"
|
||||
:style="{ color: application.iconColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3>
|
||||
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-[2fr_0.8fr]">
|
||||
<div>
|
||||
<div class="grid grid-cols-2 gap-1.5">
|
||||
<!-- 应用列表 -->
|
||||
<div
|
||||
v-for="application in enabledApplications"
|
||||
: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"
|
||||
@click="handleApplicationClick(application)"
|
||||
>
|
||||
<div
|
||||
class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
class="text-xl"
|
||||
:icon="application.icon"
|
||||
:style="{ color: application.iconColor }"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="m-0 text-sm font-medium text-g-800">{{
|
||||
application.name
|
||||
}}</h3>
|
||||
<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">
|
||||
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
|
||||
<ul>
|
||||
<li
|
||||
v-for="quickLink in enabledQuickLinks"
|
||||
:key="quickLink.name"
|
||||
class="c-p py-2 hover:[&_span]:text-theme"
|
||||
@click="handleQuickLinkClick(quickLink)"
|
||||
>
|
||||
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<div class="border-l-d pl-6 pt-2">
|
||||
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
|
||||
<ul>
|
||||
<li
|
||||
v-for="quickLink in enabledQuickLinks"
|
||||
:key="quickLink.name"
|
||||
class="c-p py-2 hover:[&_span]:text-theme"
|
||||
@click="handleQuickLinkClick(quickLink)"
|
||||
>
|
||||
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useFastEnter } from '@/hooks/core/useFastEnter'
|
||||
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
|
||||
import { useFastEnter } from '@/hooks/core/useFastEnter'
|
||||
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
|
||||
|
||||
defineOptions({ name: 'ArtFastEnter' })
|
||||
defineOptions({ name: 'ArtFastEnter' })
|
||||
|
||||
const router = useRouter()
|
||||
const popoverRef = ref()
|
||||
const router = useRouter()
|
||||
const popoverRef = ref()
|
||||
|
||||
// 使用快速入口配置
|
||||
const { enabledApplications, enabledQuickLinks } = useFastEnter()
|
||||
// 使用快速入口配置
|
||||
const { enabledApplications, enabledQuickLinks } = useFastEnter()
|
||||
|
||||
/**
|
||||
* 处理导航跳转
|
||||
* @param routeName 路由名称
|
||||
* @param link 外部链接
|
||||
*/
|
||||
const handleNavigate = (routeName?: string, link?: string): void => {
|
||||
const targetPath = routeName || link
|
||||
/**
|
||||
* 处理导航跳转
|
||||
* @param routeName 路由名称
|
||||
* @param link 外部链接
|
||||
*/
|
||||
const handleNavigate = (routeName?: string, link?: string): void => {
|
||||
const targetPath = routeName || link
|
||||
|
||||
if (!targetPath) {
|
||||
console.warn('导航配置无效:缺少路由名称或链接')
|
||||
return
|
||||
}
|
||||
if (!targetPath) {
|
||||
console.warn('导航配置无效:缺少路由名称或链接')
|
||||
return
|
||||
}
|
||||
|
||||
if (targetPath.startsWith('http')) {
|
||||
window.open(targetPath, '_blank')
|
||||
} else {
|
||||
router.push({ name: targetPath })
|
||||
}
|
||||
if (targetPath.startsWith('http')) {
|
||||
window.open(targetPath, '_blank')
|
||||
} else {
|
||||
router.push({ name: targetPath })
|
||||
}
|
||||
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
popoverRef.value?.hide()
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理应用项点击
|
||||
* @param application 应用配置对象
|
||||
*/
|
||||
const handleApplicationClick = (application: FastEnterApplication): void => {
|
||||
handleNavigate(application.routeName, application.link)
|
||||
}
|
||||
/**
|
||||
* 处理应用项点击
|
||||
* @param application 应用配置对象
|
||||
*/
|
||||
const handleApplicationClick = (application: FastEnterApplication): void => {
|
||||
handleNavigate(application.routeName, application.link)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理快速链接点击
|
||||
* @param quickLink 快速链接配置对象
|
||||
*/
|
||||
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
|
||||
handleNavigate(quickLink.routeName, quickLink.link)
|
||||
}
|
||||
/**
|
||||
* 处理快速链接点击
|
||||
* @param quickLink 快速链接配置对象
|
||||
*/
|
||||
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
|
||||
handleNavigate(quickLink.routeName, quickLink.link)
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
||||
<!-- 全局组件 -->
|
||||
<template>
|
||||
<component
|
||||
v-for="componentConfig in enabledComponents"
|
||||
:key="componentConfig.key"
|
||||
:is="componentConfig.component"
|
||||
/>
|
||||
<component
|
||||
v-for="componentConfig in enabledComponents"
|
||||
:key="componentConfig.key"
|
||||
:is="componentConfig.component"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getEnabledGlobalComponents } from '@/config/modules/component'
|
||||
defineOptions({ name: 'ArtGlobalComponent' })
|
||||
const enabledComponents = computed(() => getEnabledGlobalComponents())
|
||||
import { getEnabledGlobalComponents } from '@/config/modules/component'
|
||||
defineOptions({ name: 'ArtGlobalComponent' })
|
||||
const enabledComponents = computed(() => getEnabledGlobalComponents())
|
||||
</script>
|
||||
|
||||
@@ -1,417 +1,430 @@
|
||||
<!-- 全局搜索组件 -->
|
||||
<template>
|
||||
<div class="layout-search">
|
||||
<ElDialog
|
||||
v-model="showSearchDialog"
|
||||
width="600"
|
||||
:show-close="false"
|
||||
:lock-scroll="false"
|
||||
modal-class="search-modal"
|
||||
@close="closeSearchDialog"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="searchVal"
|
||||
:placeholder="$t('search.placeholder')"
|
||||
@input="search"
|
||||
@blur="searchBlur"
|
||||
ref="searchInput"
|
||||
:prefix-icon="Search"
|
||||
class="h-12"
|
||||
>
|
||||
<template #suffix>
|
||||
<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"
|
||||
>
|
||||
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
|
||||
<div class="result w-full" v-show="searchResult.length">
|
||||
<div
|
||||
class="box !mt-0 c-p text-base leading-none"
|
||||
v-for="(item, index) in searchResult"
|
||||
:key="index"
|
||||
>
|
||||
<div
|
||||
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' : ''"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHover(index)"
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-search">
|
||||
<ElDialog
|
||||
v-model="showSearchDialog"
|
||||
width="600"
|
||||
:show-close="false"
|
||||
:lock-scroll="false"
|
||||
modal-class="search-modal"
|
||||
@close="closeSearchDialog"
|
||||
>
|
||||
<ElInput
|
||||
v-model.trim="searchVal"
|
||||
:placeholder="$t('search.placeholder')"
|
||||
@input="search"
|
||||
@blur="searchBlur"
|
||||
ref="searchInput"
|
||||
:prefix-icon="Search"
|
||||
class="h-12"
|
||||
>
|
||||
<template #suffix>
|
||||
<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"
|
||||
>
|
||||
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
|
||||
</div>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
|
||||
<div class="result w-full" v-show="searchResult.length">
|
||||
<div
|
||||
class="box !mt-0 c-p text-base leading-none"
|
||||
v-for="(item, index) in searchResult"
|
||||
:key="index"
|
||||
>
|
||||
<div
|
||||
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' : ''
|
||||
"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHover(index)"
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<ArtSvgIcon
|
||||
v-show="isHighlighted(index)"
|
||||
icon="fluent:arrow-enter-left-20-filled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
|
||||
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
|
||||
<div class="mt-1.5 w-full">
|
||||
<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"
|
||||
v-for="(item, index) in historyResult"
|
||||
:key="index"
|
||||
:class="
|
||||
historyHIndex === index
|
||||
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
|
||||
: ''
|
||||
"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHoverHistory(index)"
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<div
|
||||
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
|
||||
@click.stop="deleteHistory(index)"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
|
||||
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
|
||||
<div class="mt-1.5 w-full">
|
||||
<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"
|
||||
v-for="(item, index) in historyResult"
|
||||
:key="index"
|
||||
:class="
|
||||
historyHIndex === index
|
||||
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
|
||||
: ''
|
||||
"
|
||||
@click="searchGoPage(item)"
|
||||
@mouseenter="highlightOnHoverHistory(index)"
|
||||
>
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
<div
|
||||
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
|
||||
@click.stop="deleteHistory(index)"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
|
||||
<div class="flex-cc">
|
||||
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
|
||||
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.selectKeydown') }}</span>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
|
||||
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
|
||||
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.switchKeydown') }}</span>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i>
|
||||
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.exitKeydown') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
|
||||
<div class="flex-cc">
|
||||
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
|
||||
<span class="mr-3.5 text-xs text-g-700">{{
|
||||
$t('search.selectKeydown')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
|
||||
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
|
||||
<span class="mr-3.5 text-xs text-g-700">{{
|
||||
$t('search.switchKeydown')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex-c">
|
||||
<i class="keyboard !w-8 flex-cc"
|
||||
><p class="text-[10px] font-medium">ESC</p></i
|
||||
>
|
||||
<span class="mr-3.5 text-xs text-g-700">{{
|
||||
$t('search.exitKeydown')
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { type ScrollbarInstance } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { Search } from '@element-plus/icons-vue'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { type ScrollbarInstance } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'ArtGlobalSearch' })
|
||||
defineOptions({ name: 'ArtGlobalSearch' })
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const { menuList } = storeToRefs(useMenuStore())
|
||||
|
||||
const showSearchDialog = ref(false)
|
||||
const searchVal = ref('')
|
||||
const searchResult = ref<AppRouteRecord[]>([])
|
||||
const historyMaxLength = 10
|
||||
const showSearchDialog = ref(false)
|
||||
const searchVal = ref('')
|
||||
const searchResult = ref<AppRouteRecord[]>([])
|
||||
const historyMaxLength = 10
|
||||
|
||||
const { searchHistory: historyResult } = storeToRefs(userStore)
|
||||
const { searchHistory: historyResult } = storeToRefs(userStore)
|
||||
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const highlightedIndex = ref(0)
|
||||
const historyHIndex = ref(0)
|
||||
const searchResultScrollbar = ref<ScrollbarInstance>()
|
||||
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const highlightedIndex = ref(0)
|
||||
const historyHIndex = ref(0)
|
||||
const searchResultScrollbar = ref<ScrollbarInstance>()
|
||||
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
mittBus.on('openSearchDialog', openSearchDialog)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
mittBus.on('openSearchDialog', openSearchDialog)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
// 键盘快捷键处理
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
|
||||
// 键盘快捷键处理
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
|
||||
|
||||
if (isCommandKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
showSearchDialog.value = true
|
||||
focusInput()
|
||||
}
|
||||
if (isCommandKey && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault()
|
||||
showSearchDialog.value = true
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// 当搜索对话框打开时,处理方向键和回车键
|
||||
if (showSearchDialog.value) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
highlightPrevious()
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
highlightNext()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
selectHighlighted()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
showSearchDialog.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
// 当搜索对话框打开时,处理方向键和回车键
|
||||
if (showSearchDialog.value) {
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
highlightPrevious()
|
||||
} else if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
highlightNext()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
selectHighlighted()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
showSearchDialog.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const focusInput = () => {
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus()
|
||||
}, 100)
|
||||
}
|
||||
const focusInput = () => {
|
||||
setTimeout(() => {
|
||||
searchInput.value?.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 搜索逻辑
|
||||
const search = (val: string) => {
|
||||
if (val) {
|
||||
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
|
||||
} else {
|
||||
searchResult.value = []
|
||||
}
|
||||
}
|
||||
// 搜索逻辑
|
||||
const search = (val: string) => {
|
||||
if (val) {
|
||||
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
|
||||
} else {
|
||||
searchResult.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
|
||||
const lowerVal = val.toLowerCase()
|
||||
const result: AppRouteRecord[] = []
|
||||
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
|
||||
const lowerVal = val.toLowerCase()
|
||||
const result: AppRouteRecord[] = []
|
||||
|
||||
const flattenAndMatch = (item: AppRouteRecord) => {
|
||||
if (item.meta?.isHide) return
|
||||
const flattenAndMatch = (item: AppRouteRecord) => {
|
||||
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) {
|
||||
item.children.forEach(flattenAndMatch)
|
||||
return
|
||||
}
|
||||
if (item.children && item.children.length > 0) {
|
||||
item.children.forEach(flattenAndMatch)
|
||||
return
|
||||
}
|
||||
|
||||
if (lowerItemTitle.includes(lowerVal) && item.path) {
|
||||
result.push({ ...item, children: undefined })
|
||||
}
|
||||
}
|
||||
if (lowerItemTitle.includes(lowerVal) && item.path) {
|
||||
result.push({ ...item, children: undefined })
|
||||
}
|
||||
}
|
||||
|
||||
items.forEach(flattenAndMatch)
|
||||
return result
|
||||
}
|
||||
items.forEach(flattenAndMatch)
|
||||
return result
|
||||
}
|
||||
|
||||
// 高亮控制并实现滚动条跟随
|
||||
const highlightPrevious = () => {
|
||||
isKeyboardNavigating.value = true
|
||||
if (searchVal.value) {
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
|
||||
scrollToHighlightedItem()
|
||||
} else {
|
||||
historyHIndex.value =
|
||||
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
|
||||
scrollToHighlightedHistoryItem()
|
||||
}
|
||||
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
|
||||
setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 100)
|
||||
}
|
||||
// 高亮控制并实现滚动条跟随
|
||||
const highlightPrevious = () => {
|
||||
isKeyboardNavigating.value = true
|
||||
if (searchVal.value) {
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
|
||||
scrollToHighlightedItem()
|
||||
} else {
|
||||
historyHIndex.value =
|
||||
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
|
||||
scrollToHighlightedHistoryItem()
|
||||
}
|
||||
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
|
||||
setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const highlightNext = () => {
|
||||
isKeyboardNavigating.value = true
|
||||
if (searchVal.value) {
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
|
||||
scrollToHighlightedItem()
|
||||
} else {
|
||||
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
|
||||
scrollToHighlightedHistoryItem()
|
||||
}
|
||||
setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 100)
|
||||
}
|
||||
const highlightNext = () => {
|
||||
isKeyboardNavigating.value = true
|
||||
if (searchVal.value) {
|
||||
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
|
||||
scrollToHighlightedItem()
|
||||
} else {
|
||||
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
|
||||
scrollToHighlightedHistoryItem()
|
||||
}
|
||||
setTimeout(() => {
|
||||
isKeyboardNavigating.value = false
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const scrollToHighlightedItem = () => {
|
||||
nextTick(() => {
|
||||
if (!searchResultScrollbar.value || !searchResult.value.length) return
|
||||
const scrollToHighlightedItem = () => {
|
||||
nextTick(() => {
|
||||
if (!searchResultScrollbar.value || !searchResult.value.length) return
|
||||
|
||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
|
||||
if (!highlightedElements[highlightedIndex.value]) return
|
||||
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
|
||||
if (!highlightedElements[highlightedIndex.value]) return
|
||||
|
||||
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
|
||||
const itemHeight = highlightedElement.offsetHeight
|
||||
const scrollTop = scrollWrapper.scrollTop
|
||||
const containerHeight = scrollWrapper.clientHeight
|
||||
const itemTop = highlightedElement.offsetTop
|
||||
const itemBottom = itemTop + itemHeight
|
||||
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
|
||||
const itemHeight = highlightedElement.offsetHeight
|
||||
const scrollTop = scrollWrapper.scrollTop
|
||||
const containerHeight = scrollWrapper.clientHeight
|
||||
const itemTop = highlightedElement.offsetTop
|
||||
const itemBottom = itemTop + itemHeight
|
||||
|
||||
if (itemTop < scrollTop) {
|
||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||
} else if (itemBottom > scrollTop + containerHeight) {
|
||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (itemTop < scrollTop) {
|
||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||
} else if (itemBottom > scrollTop + containerHeight) {
|
||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToHighlightedHistoryItem = () => {
|
||||
nextTick(() => {
|
||||
if (!searchResultScrollbar.value || !historyResult.value.length) return
|
||||
const scrollToHighlightedHistoryItem = () => {
|
||||
nextTick(() => {
|
||||
if (!searchResultScrollbar.value || !historyResult.value.length) return
|
||||
|
||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||
if (!scrollWrapper) return
|
||||
|
||||
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
|
||||
if (!historyItems[historyHIndex.value]) return
|
||||
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
|
||||
if (!historyItems[historyHIndex.value]) return
|
||||
|
||||
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
|
||||
const itemHeight = highlightedElement.offsetHeight
|
||||
const scrollTop = scrollWrapper.scrollTop
|
||||
const containerHeight = scrollWrapper.clientHeight
|
||||
const itemTop = highlightedElement.offsetTop
|
||||
const itemBottom = itemTop + itemHeight
|
||||
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
|
||||
const itemHeight = highlightedElement.offsetHeight
|
||||
const scrollTop = scrollWrapper.scrollTop
|
||||
const containerHeight = scrollWrapper.clientHeight
|
||||
const itemTop = highlightedElement.offsetTop
|
||||
const itemBottom = itemTop + itemHeight
|
||||
|
||||
if (itemTop < scrollTop) {
|
||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||
} else if (itemBottom > scrollTop + containerHeight) {
|
||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (itemTop < scrollTop) {
|
||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||
} else if (itemBottom > scrollTop + containerHeight) {
|
||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectHighlighted = () => {
|
||||
if (searchVal.value && searchResult.value.length) {
|
||||
searchGoPage(searchResult.value[highlightedIndex.value])
|
||||
} else if (!searchVal.value && historyResult.value.length) {
|
||||
searchGoPage(historyResult.value[historyHIndex.value])
|
||||
}
|
||||
}
|
||||
const selectHighlighted = () => {
|
||||
if (searchVal.value && searchResult.value.length) {
|
||||
searchGoPage(searchResult.value[highlightedIndex.value])
|
||||
} else if (!searchVal.value && historyResult.value.length) {
|
||||
searchGoPage(historyResult.value[historyHIndex.value])
|
||||
}
|
||||
}
|
||||
|
||||
const isHighlighted = (index: number) => {
|
||||
return highlightedIndex.value === index
|
||||
}
|
||||
const isHighlighted = (index: number) => {
|
||||
return highlightedIndex.value === index
|
||||
}
|
||||
|
||||
const searchBlur = () => {
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
const searchBlur = () => {
|
||||
highlightedIndex.value = 0
|
||||
}
|
||||
|
||||
const searchGoPage = (item: AppRouteRecord) => {
|
||||
showSearchDialog.value = false
|
||||
addHistory(item)
|
||||
router.push(item.path)
|
||||
searchVal.value = ''
|
||||
searchResult.value = []
|
||||
}
|
||||
const searchGoPage = (item: AppRouteRecord) => {
|
||||
showSearchDialog.value = false
|
||||
addHistory(item)
|
||||
router.push(item.path)
|
||||
searchVal.value = ''
|
||||
searchResult.value = []
|
||||
}
|
||||
|
||||
// 历史记录管理
|
||||
const updateHistory = () => {
|
||||
if (Array.isArray(historyResult.value)) {
|
||||
userStore.setSearchHistory(historyResult.value)
|
||||
}
|
||||
}
|
||||
// 历史记录管理
|
||||
const updateHistory = () => {
|
||||
if (Array.isArray(historyResult.value)) {
|
||||
userStore.setSearchHistory(historyResult.value)
|
||||
}
|
||||
}
|
||||
|
||||
const addHistory = (item: AppRouteRecord) => {
|
||||
const hasItemIndex = historyResult.value.findIndex(
|
||||
(historyItem: AppRouteRecord) => historyItem.path === item.path
|
||||
)
|
||||
const addHistory = (item: AppRouteRecord) => {
|
||||
const hasItemIndex = historyResult.value.findIndex(
|
||||
(historyItem: AppRouteRecord) => historyItem.path === item.path
|
||||
)
|
||||
|
||||
if (hasItemIndex !== -1) {
|
||||
historyResult.value.splice(hasItemIndex, 1)
|
||||
} else if (historyResult.value.length >= historyMaxLength) {
|
||||
historyResult.value.pop()
|
||||
}
|
||||
if (hasItemIndex !== -1) {
|
||||
historyResult.value.splice(hasItemIndex, 1)
|
||||
} else if (historyResult.value.length >= historyMaxLength) {
|
||||
historyResult.value.pop()
|
||||
}
|
||||
|
||||
const cleanedItem = { ...item }
|
||||
delete cleanedItem.children
|
||||
delete cleanedItem.meta.authList
|
||||
historyResult.value.unshift(cleanedItem)
|
||||
updateHistory()
|
||||
}
|
||||
const cleanedItem = { ...item }
|
||||
delete cleanedItem.children
|
||||
delete cleanedItem.meta.authList
|
||||
historyResult.value.unshift(cleanedItem)
|
||||
updateHistory()
|
||||
}
|
||||
|
||||
const deleteHistory = (index: number) => {
|
||||
historyResult.value.splice(index, 1)
|
||||
updateHistory()
|
||||
}
|
||||
const deleteHistory = (index: number) => {
|
||||
historyResult.value.splice(index, 1)
|
||||
updateHistory()
|
||||
}
|
||||
|
||||
// 对话框控制
|
||||
const openSearchDialog = () => {
|
||||
showSearchDialog.value = true
|
||||
focusInput()
|
||||
}
|
||||
// 对话框控制
|
||||
const openSearchDialog = () => {
|
||||
showSearchDialog.value = true
|
||||
focusInput()
|
||||
}
|
||||
|
||||
const closeSearchDialog = () => {
|
||||
searchVal.value = ''
|
||||
searchResult.value = []
|
||||
highlightedIndex.value = 0
|
||||
historyHIndex.value = 0
|
||||
}
|
||||
const closeSearchDialog = () => {
|
||||
searchVal.value = ''
|
||||
searchResult.value = []
|
||||
highlightedIndex.value = 0
|
||||
historyHIndex.value = 0
|
||||
}
|
||||
|
||||
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
|
||||
const highlightOnHover = (index: number) => {
|
||||
if (!isKeyboardNavigating.value && searchVal.value) {
|
||||
highlightedIndex.value = index
|
||||
}
|
||||
}
|
||||
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
|
||||
const highlightOnHover = (index: number) => {
|
||||
if (!isKeyboardNavigating.value && searchVal.value) {
|
||||
highlightedIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
const highlightOnHoverHistory = (index: number) => {
|
||||
if (!isKeyboardNavigating.value && !searchVal.value) {
|
||||
historyHIndex.value = index
|
||||
}
|
||||
}
|
||||
const highlightOnHoverHistory = (index: number) => {
|
||||
if (!isKeyboardNavigating.value && !searchVal.value) {
|
||||
historyHIndex.value = index
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.layout-search {
|
||||
:deep(.search-modal) {
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
}
|
||||
.layout-search {
|
||||
:deep(.search-modal) {
|
||||
background-color: rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 5px 0 0 !important;
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 5px 0 0 !important;
|
||||
}
|
||||
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: var(--art-gray-200);
|
||||
border: 1px solid var(--default-border-dashed);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: var(--art-gray-200);
|
||||
border: 1px solid var(--default-border-dashed);
|
||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--art-gray-800) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
:deep(.el-input__inner) {
|
||||
color: var(--art-gray-800) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark .layout-search {
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: #333;
|
||||
border: 1px solid #4c4d50;
|
||||
}
|
||||
}
|
||||
.dark .layout-search {
|
||||
.el-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
background-color: #333;
|
||||
border: 1px solid #4c4d50;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.search-modal) {
|
||||
background-color: rgb(23 23 26 / 60%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
:deep(.search-modal) {
|
||||
background-color: rgb(23 23 26 / 60%);
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
:deep(.el-dialog) {
|
||||
background-color: #252526;
|
||||
}
|
||||
}
|
||||
:deep(.el-dialog) {
|
||||
background-color: #252526;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.keyboard {
|
||||
@apply mr-2
|
||||
.keyboard {
|
||||
@apply mr-2
|
||||
box-border
|
||||
h-5
|
||||
w-5.5
|
||||
@@ -422,5 +435,5 @@
|
||||
text-g-500
|
||||
shadow-[0_2px_0_var(--default-border-dashed)]
|
||||
last-of-type:mr-1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,485 +1,509 @@
|
||||
<!-- 顶部栏 -->
|
||||
<template>
|
||||
<div
|
||||
class="w-full bg-[var(--default-bg-color)]"
|
||||
:class="[
|
||||
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="relative box-border flex-b h-15 leading-15 select-none"
|
||||
:class="[
|
||||
tabStyle === 'tab-card' || tabStyle === 'tab-google'
|
||||
? '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 c-p" @click="toHome" v-if="isTopMenu">
|
||||
<ArtLogo class="pl-4.5" />
|
||||
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ AppConfig.systemInfo.name }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-full bg-[var(--default-bg-color)]"
|
||||
:class="[
|
||||
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
|
||||
]"
|
||||
>
|
||||
<div
|
||||
class="relative box-border flex-b h-15 leading-15 select-none"
|
||||
:class="[
|
||||
tabStyle === 'tab-card' || tabStyle === 'tab-google'
|
||||
? '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 c-p" @click="toHome" v-if="isTopMenu">
|
||||
<ArtLogo class="pl-4.5" />
|
||||
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{
|
||||
AppConfig.systemInfo.name
|
||||
}}</p>
|
||||
</div>
|
||||
|
||||
<ArtLogo
|
||||
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
|
||||
@click="toHome"
|
||||
/>
|
||||
<ArtLogo
|
||||
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
|
||||
@click="toHome"
|
||||
/>
|
||||
|
||||
<!-- 菜单按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="isLeftMenu && shouldShowMenuButton"
|
||||
icon="ri:menu-2-fill"
|
||||
class="ml-3 max-sm:ml-[7px]"
|
||||
@click="visibleMenu"
|
||||
/>
|
||||
<!-- 菜单按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="isLeftMenu && shouldShowMenuButton"
|
||||
icon="ri:menu-2-fill"
|
||||
class="ml-3 max-sm:ml-[7px]"
|
||||
@click="visibleMenu"
|
||||
/>
|
||||
|
||||
<!-- 刷新按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowRefreshButton"
|
||||
icon="ri:refresh-line"
|
||||
class="!ml-3 refresh-btn max-sm:!hidden"
|
||||
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
|
||||
@click="reload"
|
||||
/>
|
||||
<!-- 刷新按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowRefreshButton"
|
||||
icon="ri:refresh-line"
|
||||
class="!ml-3 refresh-btn max-sm:!hidden"
|
||||
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
|
||||
@click="reload"
|
||||
/>
|
||||
|
||||
<!-- 快速入口 -->
|
||||
<ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
|
||||
<ArtIconButton icon="ri:function-line" class="ml-3" />
|
||||
</ArtFastEnter>
|
||||
<!-- 快速入口 -->
|
||||
<ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
|
||||
<ArtIconButton icon="ri:function-line" class="ml-3" />
|
||||
</ArtFastEnter>
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<ArtBreadcrumb
|
||||
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)"
|
||||
/>
|
||||
<!-- 面包屑 -->
|
||||
<ArtBreadcrumb
|
||||
v-if="
|
||||
(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)
|
||||
"
|
||||
/>
|
||||
|
||||
<!-- 顶部菜单 -->
|
||||
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
|
||||
<!-- 顶部菜单 -->
|
||||
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
|
||||
|
||||
<!-- 混合菜单-顶部 -->
|
||||
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
|
||||
</div>
|
||||
<!-- 混合菜单-顶部 -->
|
||||
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
|
||||
</div>
|
||||
|
||||
<div class="flex-c gap-2.5">
|
||||
<!-- 搜索 -->
|
||||
<div
|
||||
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"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<div class="flex-c">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
|
||||
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
|
||||
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
|
||||
<span class="ml-0.5 text-xs">k</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-c gap-2.5">
|
||||
<!-- 搜索 -->
|
||||
<div
|
||||
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"
|
||||
@click="openSearchDialog"
|
||||
>
|
||||
<div class="flex-c">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
|
||||
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
|
||||
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
|
||||
<span class="ml-0.5 text-xs">k</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 全屏按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowFullscreen"
|
||||
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
|
||||
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
|
||||
class="max-md:!hidden"
|
||||
@click="toggleFullScreen"
|
||||
/>
|
||||
<!-- 全屏按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowFullscreen"
|
||||
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
|
||||
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
|
||||
class="max-md:!hidden"
|
||||
@click="toggleFullScreen"
|
||||
/>
|
||||
|
||||
<!-- 国际化按钮 -->
|
||||
<ElDropdown
|
||||
@command="changeLanguage"
|
||||
popper-class="langDropDownStyle"
|
||||
v-if="shouldShowLanguage"
|
||||
>
|
||||
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item">
|
||||
<ElDropdownItem
|
||||
:command="item.value"
|
||||
:class="{ 'is-selected': locale === item.value }"
|
||||
>
|
||||
<span class="menu-txt">{{ item.label }}</span>
|
||||
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<!-- 国际化按钮 -->
|
||||
<ElDropdown
|
||||
@command="changeLanguage"
|
||||
popper-class="langDropDownStyle"
|
||||
v-if="shouldShowLanguage"
|
||||
>
|
||||
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div
|
||||
v-for="item in languageOptions"
|
||||
:key="item.value"
|
||||
class="lang-btn-item"
|
||||
>
|
||||
<ElDropdownItem
|
||||
:command="item.value"
|
||||
:class="{ 'is-selected': locale === item.value }"
|
||||
>
|
||||
<span class="menu-txt">{{ item.label }}</span>
|
||||
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<!-- 通知按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowNotification"
|
||||
icon="ri:notification-2-line"
|
||||
class="notice-button relative"
|
||||
@click="visibleNotice"
|
||||
>
|
||||
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
|
||||
</ArtIconButton>
|
||||
<!-- 通知按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowNotification"
|
||||
icon="ri:notification-2-line"
|
||||
class="notice-button relative"
|
||||
@click="visibleNotice"
|
||||
>
|
||||
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
|
||||
</ArtIconButton>
|
||||
|
||||
<!-- 聊天按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowChat"
|
||||
icon="ri:message-3-line"
|
||||
class="chat-button relative"
|
||||
@click="openChat"
|
||||
>
|
||||
<div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div>
|
||||
</ArtIconButton>
|
||||
<!-- 聊天按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowChat"
|
||||
icon="ri:message-3-line"
|
||||
class="chat-button relative"
|
||||
@click="openChat"
|
||||
>
|
||||
<div
|
||||
class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"
|
||||
></div>
|
||||
</ArtIconButton>
|
||||
|
||||
<!-- 设置按钮 -->
|
||||
<div v-if="shouldShowSettings">
|
||||
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
|
||||
<template #reference>
|
||||
<div class="flex-cc">
|
||||
<ArtIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" />
|
||||
</div>
|
||||
</template>
|
||||
<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>
|
||||
<!-- 设置按钮 -->
|
||||
<div v-if="shouldShowSettings">
|
||||
<ElPopover
|
||||
:visible="showSettingGuide"
|
||||
placement="bottom-start"
|
||||
:width="190"
|
||||
:offset="0"
|
||||
>
|
||||
<template #reference>
|
||||
<div class="flex-cc">
|
||||
<ArtIconButton
|
||||
icon="ri:settings-line"
|
||||
class="setting-btn"
|
||||
@click="openSetting"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<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
|
||||
v-if="shouldShowThemeToggle"
|
||||
@click="themeAnimation"
|
||||
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
|
||||
/>
|
||||
<!-- 主题切换按钮 -->
|
||||
<ArtIconButton
|
||||
v-if="shouldShowThemeToggle"
|
||||
@click="themeAnimation"
|
||||
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
|
||||
/>
|
||||
|
||||
<!-- 用户头像、菜单 -->
|
||||
<ArtUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 用户头像、菜单 -->
|
||||
<ArtUserMenu />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<ArtWorkTab />
|
||||
<!-- 标签页 -->
|
||||
<ArtWorkTab />
|
||||
|
||||
<!-- 通知 -->
|
||||
<ArtNotification v-model:value="showNotice" ref="notice" />
|
||||
</div>
|
||||
<!-- 通知 -->
|
||||
<ArtNotification v-model:value="showNotice" ref="notice" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFullscreen, useWindowSize } from '@vueuse/core'
|
||||
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import AppConfig from '@/config'
|
||||
import { languageOptions } from '@/locales'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { themeAnimation } from '@/utils/ui/animation'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||
import ArtUserMenu from './widget/ArtUserMenu.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFullscreen, useWindowSize } from '@vueuse/core'
|
||||
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import AppConfig from '@/config'
|
||||
import { languageOptions } from '@/locales'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { themeAnimation } from '@/utils/ui/animation'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||
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 { locale } = useI18n()
|
||||
const { width } = useWindowSize()
|
||||
const router = useRouter()
|
||||
const { locale } = useI18n()
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const menuStore = useMenuStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
// 顶部栏功能配置
|
||||
const {
|
||||
shouldShowMenuButton,
|
||||
shouldShowRefreshButton,
|
||||
shouldShowFastEnter,
|
||||
shouldShowBreadcrumb,
|
||||
shouldShowGlobalSearch,
|
||||
shouldShowFullscreen,
|
||||
shouldShowNotification,
|
||||
shouldShowChat,
|
||||
shouldShowLanguage,
|
||||
shouldShowSettings,
|
||||
shouldShowThemeToggle,
|
||||
fastEnterMinWidth: headerBarFastEnterMinWidth
|
||||
} = useHeaderBar()
|
||||
// 顶部栏功能配置
|
||||
const {
|
||||
shouldShowMenuButton,
|
||||
shouldShowRefreshButton,
|
||||
shouldShowFastEnter,
|
||||
shouldShowBreadcrumb,
|
||||
shouldShowGlobalSearch,
|
||||
shouldShowFullscreen,
|
||||
shouldShowNotification,
|
||||
shouldShowChat,
|
||||
shouldShowLanguage,
|
||||
shouldShowSettings,
|
||||
shouldShowThemeToggle,
|
||||
fastEnterMinWidth: headerBarFastEnterMinWidth
|
||||
} = useHeaderBar()
|
||||
|
||||
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
|
||||
storeToRefs(settingStore)
|
||||
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
|
||||
storeToRefs(settingStore)
|
||||
|
||||
const { language } = storeToRefs(userStore)
|
||||
const { menuList } = storeToRefs(menuStore)
|
||||
const { language } = storeToRefs(userStore)
|
||||
const { menuList } = storeToRefs(menuStore)
|
||||
|
||||
const showNotice = ref(false)
|
||||
const notice = ref(null)
|
||||
const showNotice = ref(false)
|
||||
const notice = ref(null)
|
||||
|
||||
// 菜单类型判断
|
||||
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||
// 菜单类型判断
|
||||
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||
|
||||
onMounted(() => {
|
||||
initLanguage()
|
||||
document.addEventListener('click', bodyCloseNotice)
|
||||
})
|
||||
onMounted(() => {
|
||||
initLanguage()
|
||||
document.addEventListener('click', bodyCloseNotice)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', bodyCloseNotice)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', bodyCloseNotice)
|
||||
})
|
||||
|
||||
/**
|
||||
* 切换全屏状态
|
||||
*/
|
||||
const toggleFullScreen = (): void => {
|
||||
toggleFullscreen()
|
||||
}
|
||||
/**
|
||||
* 切换全屏状态
|
||||
*/
|
||||
const toggleFullScreen = (): void => {
|
||||
toggleFullscreen()
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换菜单显示/隐藏状态
|
||||
*/
|
||||
const visibleMenu = (): void => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
}
|
||||
/**
|
||||
* 切换菜单显示/隐藏状态
|
||||
*/
|
||||
const visibleMenu = (): void => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
}
|
||||
|
||||
const { homePath } = useCommon()
|
||||
const { refresh } = useCommon()
|
||||
const { homePath } = useCommon()
|
||||
const { refresh } = useCommon()
|
||||
|
||||
/**
|
||||
* 跳转到首页
|
||||
*/
|
||||
const toHome = (): void => {
|
||||
router.push(homePath.value)
|
||||
}
|
||||
/**
|
||||
* 跳转到首页
|
||||
*/
|
||||
const toHome = (): void => {
|
||||
router.push(homePath.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新页面
|
||||
* @param {number} time - 延迟时间,默认为0毫秒
|
||||
*/
|
||||
const reload = (time: number = 0): void => {
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
}, time)
|
||||
}
|
||||
/**
|
||||
* 刷新页面
|
||||
* @param {number} time - 延迟时间,默认为0毫秒
|
||||
*/
|
||||
const reload = (time: number = 0): void => {
|
||||
setTimeout(() => {
|
||||
refresh()
|
||||
}, time)
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化语言设置
|
||||
*/
|
||||
const initLanguage = (): void => {
|
||||
locale.value = language.value
|
||||
}
|
||||
/**
|
||||
* 初始化语言设置
|
||||
*/
|
||||
const initLanguage = (): void => {
|
||||
locale.value = language.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换系统语言
|
||||
* @param {LanguageEnum} lang - 目标语言类型
|
||||
*/
|
||||
const changeLanguage = (lang: LanguageEnum): void => {
|
||||
if (locale.value === lang) return
|
||||
locale.value = lang
|
||||
userStore.setLanguage(lang)
|
||||
reload(50)
|
||||
}
|
||||
/**
|
||||
* 切换系统语言
|
||||
* @param {LanguageEnum} lang - 目标语言类型
|
||||
*/
|
||||
const changeLanguage = (lang: LanguageEnum): void => {
|
||||
if (locale.value === lang) return
|
||||
locale.value = lang
|
||||
userStore.setLanguage(lang)
|
||||
reload(50)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开设置面板
|
||||
*/
|
||||
const openSetting = (): void => {
|
||||
mittBus.emit('openSetting')
|
||||
/**
|
||||
* 打开设置面板
|
||||
*/
|
||||
const openSetting = (): void => {
|
||||
mittBus.emit('openSetting')
|
||||
|
||||
// 隐藏设置引导提示
|
||||
if (showSettingGuide.value) {
|
||||
settingStore.hideSettingGuide()
|
||||
}
|
||||
}
|
||||
// 隐藏设置引导提示
|
||||
if (showSettingGuide.value) {
|
||||
settingStore.hideSettingGuide()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开全局搜索对话框
|
||||
*/
|
||||
const openSearchDialog = (): void => {
|
||||
mittBus.emit('openSearchDialog')
|
||||
}
|
||||
/**
|
||||
* 打开全局搜索对话框
|
||||
*/
|
||||
const openSearchDialog = (): void => {
|
||||
mittBus.emit('openSearchDialog')
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击页面其他区域关闭通知面板
|
||||
* @param {Event} e - 点击事件对象
|
||||
*/
|
||||
const bodyCloseNotice = (e: any): void => {
|
||||
if (!showNotice.value) return
|
||||
/**
|
||||
* 点击页面其他区域关闭通知面板
|
||||
* @param {Event} e - 点击事件对象
|
||||
*/
|
||||
const bodyCloseNotice = (e: any): void => {
|
||||
if (!showNotice.value) return
|
||||
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement
|
||||
|
||||
// 检查是否点击了通知按钮或通知面板内部
|
||||
const isNoticeButton = target.closest('.notice-button')
|
||||
const isNoticePanel = target.closest('.art-notification-panel')
|
||||
// 检查是否点击了通知按钮或通知面板内部
|
||||
const isNoticeButton = target.closest('.notice-button')
|
||||
const isNoticePanel = target.closest('.art-notification-panel')
|
||||
|
||||
if (!isNoticeButton && !isNoticePanel) {
|
||||
showNotice.value = false
|
||||
}
|
||||
}
|
||||
if (!isNoticeButton && !isNoticePanel) {
|
||||
showNotice.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换通知面板显示状态
|
||||
*/
|
||||
const visibleNotice = (): void => {
|
||||
showNotice.value = !showNotice.value
|
||||
}
|
||||
/**
|
||||
* 切换通知面板显示状态
|
||||
*/
|
||||
const visibleNotice = (): void => {
|
||||
showNotice.value = !showNotice.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开聊天窗口
|
||||
*/
|
||||
const openChat = (): void => {
|
||||
mittBus.emit('openChat')
|
||||
}
|
||||
/**
|
||||
* 打开聊天窗口
|
||||
*/
|
||||
const openChat = (): void => {
|
||||
mittBus.emit('openChat')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* Custom animations */
|
||||
@keyframes rotate180 {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
/* Custom animations */
|
||||
@keyframes rotate180 {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
|
||||
25% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
50% {
|
||||
transform: rotate(5deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: rotate(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expand {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
@keyframes expand {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shrink {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
@keyframes shrink {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes moveUp {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
@keyframes moveUp {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes breathing {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
@keyframes breathing {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover animation classes */
|
||||
.refresh-btn:hover :deep(.art-svg-icon) {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
/* Hover animation classes */
|
||||
.refresh-btn:hover :deep(.art-svg-icon) {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
|
||||
.language-btn:hover :deep(.art-svg-icon) {
|
||||
animation: moveUp 0.4s;
|
||||
}
|
||||
.language-btn:hover :deep(.art-svg-icon) {
|
||||
animation: moveUp 0.4s;
|
||||
}
|
||||
|
||||
.setting-btn:hover :deep(.art-svg-icon) {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
.setting-btn:hover :deep(.art-svg-icon) {
|
||||
animation: rotate180 0.5s;
|
||||
}
|
||||
|
||||
.full-screen-btn:hover :deep(.art-svg-icon) {
|
||||
animation: expand 0.6s forwards;
|
||||
}
|
||||
.full-screen-btn:hover :deep(.art-svg-icon) {
|
||||
animation: expand 0.6s forwards;
|
||||
}
|
||||
|
||||
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
|
||||
animation: shrink 0.6s forwards;
|
||||
}
|
||||
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
|
||||
animation: shrink 0.6s forwards;
|
||||
}
|
||||
|
||||
.notice-button:hover :deep(.art-svg-icon) {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
.notice-button:hover :deep(.art-svg-icon) {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.chat-button:hover :deep(.art-svg-icon) {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
.chat-button:hover :deep(.art-svg-icon) {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Breathing animation for chat dot */
|
||||
.breathing-dot {
|
||||
animation: breathing 1.5s ease-in-out infinite;
|
||||
}
|
||||
/* Breathing animation for chat dot */
|
||||
.breathing-dot {
|
||||
animation: breathing 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* iPad breakpoint adjustments */
|
||||
@media screen and (width <= 768px) {
|
||||
.logo2 {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
/* iPad breakpoint adjustments */
|
||||
@media screen and (width <= 768px) {
|
||||
.logo2 {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width <= 640px) {
|
||||
.btn-box {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 640px) {
|
||||
.btn-box {
|
||||
width: 40px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,159 +1,161 @@
|
||||
<!-- 用户菜单 -->
|
||||
<template>
|
||||
<ElPopover
|
||||
ref="userMenuPopover"
|
||||
placement="bottom-end"
|
||||
:width="240"
|
||||
:hide-after="0"
|
||||
:offset="10"
|
||||
trigger="hover"
|
||||
:show-arrow="false"
|
||||
popper-class="user-menu-popover"
|
||||
popper-style="padding: 5px 16px;"
|
||||
>
|
||||
<template #reference>
|
||||
<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]"
|
||||
src="@imgs/user/avatar.webp"
|
||||
alt="avatar"
|
||||
/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="pt-3">
|
||||
<div class="flex-c pb-1 px-0">
|
||||
<img
|
||||
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
|
||||
src="@imgs/user/avatar.webp"
|
||||
/>
|
||||
<div class="w-[calc(100%-60px)] h-full">
|
||||
<span class="block text-sm font-medium text-g-800 truncate">{{
|
||||
userInfo.userName
|
||||
}}</span>
|
||||
<span class="block mt-0.5 text-xs text-g-500 truncate">{{ userInfo.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="py-4 mt-3 border-t border-g-300/80">
|
||||
<li class="btn-item" @click="goPage('/system/user-center')">
|
||||
<ArtSvgIcon icon="ri:user-3-line" />
|
||||
<span>{{ $t('topBar.user.userCenter') }}</span>
|
||||
</li>
|
||||
<li class="btn-item" @click="toDocs()">
|
||||
<ArtSvgIcon icon="ri:book-2-line" />
|
||||
<span>{{ $t('topBar.user.docs') }}</span>
|
||||
</li>
|
||||
<li class="btn-item" @click="toGithub()">
|
||||
<ArtSvgIcon icon="ri:github-line" />
|
||||
<span>{{ $t('topBar.user.github') }}</span>
|
||||
</li>
|
||||
<li class="btn-item" @click="lockScreen()">
|
||||
<ArtSvgIcon icon="ri:lock-line" />
|
||||
<span>{{ $t('topBar.user.lockScreen') }}</span>
|
||||
</li>
|
||||
<div class="w-full h-px my-2 bg-g-300/80"></div>
|
||||
<div class="log-out c-p" @click="loginOut">
|
||||
{{ $t('topBar.user.logout') }}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</ElPopover>
|
||||
<ElPopover
|
||||
ref="userMenuPopover"
|
||||
placement="bottom-end"
|
||||
:width="240"
|
||||
:hide-after="0"
|
||||
:offset="10"
|
||||
trigger="hover"
|
||||
:show-arrow="false"
|
||||
popper-class="user-menu-popover"
|
||||
popper-style="padding: 5px 16px;"
|
||||
>
|
||||
<template #reference>
|
||||
<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]"
|
||||
src="@imgs/user/avatar.webp"
|
||||
alt="avatar"
|
||||
/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div class="pt-3">
|
||||
<div class="flex-c pb-1 px-0">
|
||||
<img
|
||||
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
|
||||
src="@imgs/user/avatar.webp"
|
||||
/>
|
||||
<div class="w-[calc(100%-60px)] h-full">
|
||||
<span class="block text-sm font-medium text-g-800 truncate">{{
|
||||
userInfo.userName
|
||||
}}</span>
|
||||
<span class="block mt-0.5 text-xs text-g-500 truncate">{{
|
||||
userInfo.email
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="py-4 mt-3 border-t border-g-300/80">
|
||||
<li class="btn-item" @click="goPage('/system/user-center')">
|
||||
<ArtSvgIcon icon="ri:user-3-line" />
|
||||
<span>{{ $t('topBar.user.userCenter') }}</span>
|
||||
</li>
|
||||
<li class="btn-item" @click="toDocs()">
|
||||
<ArtSvgIcon icon="ri:book-2-line" />
|
||||
<span>{{ $t('topBar.user.docs') }}</span>
|
||||
</li>
|
||||
<li class="btn-item" @click="toGithub()">
|
||||
<ArtSvgIcon icon="ri:github-line" />
|
||||
<span>{{ $t('topBar.user.github') }}</span>
|
||||
</li>
|
||||
<li class="btn-item" @click="lockScreen()">
|
||||
<ArtSvgIcon icon="ri:lock-line" />
|
||||
<span>{{ $t('topBar.user.lockScreen') }}</span>
|
||||
</li>
|
||||
<div class="w-full h-px my-2 bg-g-300/80"></div>
|
||||
<div class="log-out c-p" @click="loginOut">
|
||||
{{ $t('topBar.user.logout') }}
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { WEB_LINKS } from '@/utils/constants'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
|
||||
defineOptions({ name: 'ArtUserMenu' })
|
||||
defineOptions({ name: 'ArtUserMenu' })
|
||||
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { getUserInfo: userInfo } = storeToRefs(userStore)
|
||||
const userMenuPopover = ref()
|
||||
const { getUserInfo: userInfo } = storeToRefs(userStore)
|
||||
const userMenuPopover = ref()
|
||||
|
||||
/**
|
||||
* 页面跳转
|
||||
* @param {string} path - 目标路径
|
||||
*/
|
||||
const goPage = (path: string): void => {
|
||||
router.push(path)
|
||||
}
|
||||
/**
|
||||
* 页面跳转
|
||||
* @param {string} path - 目标路径
|
||||
*/
|
||||
const goPage = (path: string): void => {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开文档页面
|
||||
*/
|
||||
const toDocs = (): void => {
|
||||
window.open(WEB_LINKS.DOCS)
|
||||
}
|
||||
/**
|
||||
* 打开文档页面
|
||||
*/
|
||||
const toDocs = (): void => {
|
||||
window.open(WEB_LINKS.DOCS)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开 GitHub 页面
|
||||
*/
|
||||
const toGithub = (): void => {
|
||||
window.open(WEB_LINKS.GITHUB)
|
||||
}
|
||||
/**
|
||||
* 打开 GitHub 页面
|
||||
*/
|
||||
const toGithub = (): void => {
|
||||
window.open(WEB_LINKS.GITHUB)
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开锁屏功能
|
||||
*/
|
||||
const lockScreen = (): void => {
|
||||
mittBus.emit('openLockScreen')
|
||||
}
|
||||
/**
|
||||
* 打开锁屏功能
|
||||
*/
|
||||
const lockScreen = (): void => {
|
||||
mittBus.emit('openLockScreen')
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登出确认
|
||||
*/
|
||||
const loginOut = (): void => {
|
||||
closeUserMenu()
|
||||
setTimeout(() => {
|
||||
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
customClass: 'login-out-dialog'
|
||||
}).then(() => {
|
||||
userStore.logOut()
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
/**
|
||||
* 用户登出确认
|
||||
*/
|
||||
const loginOut = (): void => {
|
||||
closeUserMenu()
|
||||
setTimeout(() => {
|
||||
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
|
||||
confirmButtonText: t('common.confirm'),
|
||||
cancelButtonText: t('common.cancel'),
|
||||
customClass: 'login-out-dialog'
|
||||
}).then(() => {
|
||||
userStore.logOut()
|
||||
})
|
||||
}, 200)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭用户菜单弹出层
|
||||
*/
|
||||
const closeUserMenu = (): void => {
|
||||
setTimeout(() => {
|
||||
userMenuPopover.value.hide()
|
||||
}, 100)
|
||||
}
|
||||
/**
|
||||
* 关闭用户菜单弹出层
|
||||
*/
|
||||
const closeUserMenu = (): void => {
|
||||
setTimeout(() => {
|
||||
userMenuPopover.value.hide()
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
@layer components {
|
||||
.btn-item {
|
||||
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
|
||||
@layer components {
|
||||
.btn-item {
|
||||
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
|
||||
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.art-svg-icon {
|
||||
@apply mr-2 text-base;
|
||||
}
|
||||
.art-svg-icon {
|
||||
@apply mr-2 text-base;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background-color: var(--art-gray-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.log-out {
|
||||
@apply py-1.5
|
||||
.log-out {
|
||||
@apply py-1.5
|
||||
mt-5
|
||||
text-xs
|
||||
text-center
|
||||
@@ -163,5 +165,5 @@
|
||||
transition-all
|
||||
duration-200
|
||||
hover:shadow-xl;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,110 +1,110 @@
|
||||
<!-- 水平菜单 -->
|
||||
<template>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<ElMenu
|
||||
:ellipsis="true"
|
||||
mode="horizontal"
|
||||
:default-active="routerPath"
|
||||
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
|
||||
:popper-offset="-6"
|
||||
background-color="transparent"
|
||||
:show-timeout="50"
|
||||
:hide-timeout="50"
|
||||
popper-class="horizontal-menu-popper"
|
||||
class="w-full border-none"
|
||||
>
|
||||
<HorizontalSubmenu
|
||||
v-for="item in filteredMenuItems"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
:isMobile="false"
|
||||
:level="0"
|
||||
/>
|
||||
</ElMenu>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<ElMenu
|
||||
:ellipsis="true"
|
||||
mode="horizontal"
|
||||
:default-active="routerPath"
|
||||
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
|
||||
:popper-offset="-6"
|
||||
background-color="transparent"
|
||||
:show-timeout="50"
|
||||
:hide-timeout="50"
|
||||
popper-class="horizontal-menu-popper"
|
||||
class="w-full border-none"
|
||||
>
|
||||
<HorizontalSubmenu
|
||||
v-for="item in filteredMenuItems"
|
||||
:key="item.path"
|
||||
:item="item"
|
||||
:isMobile="false"
|
||||
:level="0"
|
||||
/>
|
||||
</ElMenu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
defineOptions({ name: 'ArtHorizontalMenu' })
|
||||
defineOptions({ name: 'ArtHorizontalMenu' })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
interface Props {
|
||||
/** 菜单列表数据 */
|
||||
list: AppRouteRecord[]
|
||||
}
|
||||
interface Props {
|
||||
/** 菜单列表数据 */
|
||||
list: AppRouteRecord[]
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const route = useRoute()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
list: () => []
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
list: () => []
|
||||
})
|
||||
|
||||
/**
|
||||
* 过滤后的菜单项列表
|
||||
* 只显示未隐藏的菜单项
|
||||
*/
|
||||
const filteredMenuItems = computed(() => {
|
||||
return filterMenuItems(props.list)
|
||||
})
|
||||
/**
|
||||
* 过滤后的菜单项列表
|
||||
* 只显示未隐藏的菜单项
|
||||
*/
|
||||
const filteredMenuItems = computed(() => {
|
||||
return filterMenuItems(props.list)
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前激活的路由路径
|
||||
* 用于菜单高亮显示
|
||||
*/
|
||||
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
||||
/**
|
||||
* 当前激活的路由路径
|
||||
* 用于菜单高亮显示
|
||||
*/
|
||||
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
||||
|
||||
/**
|
||||
* 递归过滤菜单项,移除隐藏的菜单
|
||||
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
||||
* @param items 菜单项数组
|
||||
* @returns 过滤后的菜单项数组
|
||||
*/
|
||||
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items
|
||||
.filter((item) => {
|
||||
// 如果当前项被隐藏,直接过滤掉
|
||||
if (item.meta.isHide) {
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* 递归过滤菜单项,移除隐藏的菜单
|
||||
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
||||
* @param items 菜单项数组
|
||||
* @returns 过滤后的菜单项数组
|
||||
*/
|
||||
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items
|
||||
.filter((item) => {
|
||||
// 如果当前项被隐藏,直接过滤掉
|
||||
if (item.meta.isHide) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有子菜单,递归过滤子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
const filteredChildren = filterMenuItems(item.children)
|
||||
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
||||
return filteredChildren.length > 0
|
||||
}
|
||||
// 如果有子菜单,递归过滤子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
const filteredChildren = filterMenuItems(item.children)
|
||||
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
||||
return filteredChildren.length > 0
|
||||
}
|
||||
|
||||
// 叶子节点且未被隐藏,保留
|
||||
return true
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterMenuItems(item.children) : undefined
|
||||
}))
|
||||
}
|
||||
// 叶子节点且未被隐藏,保留
|
||||
return true
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterMenuItems(item.children) : undefined
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Remove el-menu bottom border */
|
||||
:deep(.el-menu) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
/* Remove el-menu bottom border */
|
||||
:deep(.el-menu) {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
/* Remove default styles for first-level menu items */
|
||||
:deep(.el-menu-item[tabindex='0']) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
/* Remove default styles for first-level menu items */
|
||||
:deep(.el-menu-item[tabindex='0']) {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Remove bottom border from submenu titles */
|
||||
:deep(.el-menu--horizontal .el-sub-menu__title) {
|
||||
padding: 0 30px 0 10px !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
/* Remove bottom border from submenu titles */
|
||||
:deep(.el-menu--horizontal .el-sub-menu__title) {
|
||||
padding: 0 30px 0 10px !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
+79
-79
@@ -1,95 +1,95 @@
|
||||
<template>
|
||||
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
|
||||
<template #title>
|
||||
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
|
||||
<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.showTextBadge" class="art-text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</template>
|
||||
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
|
||||
<template #title>
|
||||
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
|
||||
<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.showTextBadge" class="art-text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 递归调用自身处理子菜单 -->
|
||||
<HorizontalSubmenu
|
||||
v-for="child in filteredChildren"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:theme="theme"
|
||||
:is-mobile="isMobile"
|
||||
:level="level + 1"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</ElSubMenu>
|
||||
<!-- 递归调用自身处理子菜单 -->
|
||||
<HorizontalSubmenu
|
||||
v-for="child in filteredChildren"
|
||||
:key="child.path"
|
||||
:item="child"
|
||||
:theme="theme"
|
||||
:is-mobile="isMobile"
|
||||
:level="level + 1"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</ElSubMenu>
|
||||
|
||||
<ElMenuItem
|
||||
v-else-if="!item.meta.isHide"
|
||||
:index="item.path || item.meta.title"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
class="mr-1 text-lg"
|
||||
:style="{ color: theme.iconColor }"
|
||||
/>
|
||||
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div
|
||||
v-if="item.meta.showBadge"
|
||||
class="art-badge"
|
||||
:style="{ right: level === 0 ? '10px' : '20px' }"
|
||||
/>
|
||||
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</ElMenuItem>
|
||||
<ElMenuItem
|
||||
v-else-if="!item.meta.isHide"
|
||||
:index="item.path || item.meta.title"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
class="mr-1 text-lg"
|
||||
:style="{ color: theme.iconColor }"
|
||||
/>
|
||||
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||
<div
|
||||
v-if="item.meta.showBadge"
|
||||
class="art-badge"
|
||||
:style="{ right: level === 0 ? '10px' : '20px' }"
|
||||
/>
|
||||
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</ElMenuItem>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, type PropType } from 'vue'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { computed, type PropType } from 'vue'
|
||||
import { AppRouteRecord } from '@/types/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<AppRouteRecord>,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isMobile: Boolean,
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object as PropType<AppRouteRecord>,
|
||||
required: true
|
||||
},
|
||||
theme: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
isMobile: Boolean,
|
||||
level: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 过滤后的子菜单项(不包含隐藏的)
|
||||
const filteredChildren = computed(() => {
|
||||
return props.item.children?.filter((child) => !child.meta.isHide) || []
|
||||
})
|
||||
// 过滤后的子菜单项(不包含隐藏的)
|
||||
const filteredChildren = computed(() => {
|
||||
return props.item.children?.filter((child) => !child.meta.isHide) || []
|
||||
})
|
||||
|
||||
// 计算当前项是否有可见的子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return filteredChildren.value.length > 0
|
||||
})
|
||||
// 计算当前项是否有可见的子菜单
|
||||
const hasChildren = computed(() => {
|
||||
return filteredChildren.value.length > 0
|
||||
})
|
||||
|
||||
const goPage = (item: AppRouteRecord) => {
|
||||
closeMenu()
|
||||
handleMenuJump(item)
|
||||
}
|
||||
const goPage = (item: AppRouteRecord) => {
|
||||
closeMenu()
|
||||
handleMenuJump(item)
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
emit('close')
|
||||
}
|
||||
const closeMenu = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||
right: 10px !important;
|
||||
}
|
||||
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||
right: 10px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,234 +1,237 @@
|
||||
<!-- 混合菜单 -->
|
||||
<template>
|
||||
<div class="relative box-border flex-c w-full overflow-hidden">
|
||||
<!-- 左侧滚动按钮 -->
|
||||
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
|
||||
<ElIcon>
|
||||
<ArrowLeft />
|
||||
</ElIcon>
|
||||
</div>
|
||||
<div class="relative box-border flex-c w-full overflow-hidden">
|
||||
<!-- 左侧滚动按钮 -->
|
||||
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
|
||||
<ElIcon>
|
||||
<ArrowLeft />
|
||||
</ElIcon>
|
||||
</div>
|
||||
|
||||
<!-- 滚动容器 -->
|
||||
<ElScrollbar
|
||||
ref="scrollbarRef"
|
||||
wrap-class="scrollbar-wrapper"
|
||||
:horizontal="true"
|
||||
@scroll="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<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">
|
||||
<div
|
||||
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-active text-theme': item.isActive
|
||||
}"
|
||||
@click="handleMenuJump(item, true)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
class="text-lg text-g-700 dark:text-g-800 mr-1"
|
||||
:class="item.isActive && '!text-theme'"
|
||||
/>
|
||||
<span
|
||||
class="text-md text-g-700 dark:text-g-800"
|
||||
:class="item.isActive && '!text-theme'"
|
||||
>
|
||||
{{ item.formattedTitle }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
<!-- 滚动容器 -->
|
||||
<ElScrollbar
|
||||
ref="scrollbarRef"
|
||||
wrap-class="scrollbar-wrapper"
|
||||
:horizontal="true"
|
||||
@scroll="handleScroll"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<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">
|
||||
<div
|
||||
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-active text-theme': item.isActive
|
||||
}"
|
||||
@click="handleMenuJump(item, true)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
class="text-lg text-g-700 dark:text-g-800 mr-1"
|
||||
:class="item.isActive && '!text-theme'"
|
||||
/>
|
||||
<span
|
||||
class="text-md text-g-700 dark:text-g-800"
|
||||
:class="item.isActive && '!text-theme'"
|
||||
>
|
||||
{{ item.formattedTitle }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<!-- 右侧滚动按钮 -->
|
||||
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
|
||||
<ElIcon>
|
||||
<ArrowRight />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 右侧滚动按钮 -->
|
||||
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
|
||||
<ElIcon>
|
||||
<ArrowRight />
|
||||
</ElIcon>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
|
||||
defineOptions({ name: 'ArtMixedMenu' })
|
||||
defineOptions({ name: 'ArtMixedMenu' })
|
||||
|
||||
interface Props {
|
||||
/** 菜单列表数据 */
|
||||
list: AppRouteRecord[]
|
||||
}
|
||||
interface Props {
|
||||
/** 菜单列表数据 */
|
||||
list: AppRouteRecord[]
|
||||
}
|
||||
|
||||
interface ProcessedMenuItem extends AppRouteRecord {
|
||||
isActive: boolean
|
||||
formattedTitle: string
|
||||
}
|
||||
interface ProcessedMenuItem extends AppRouteRecord {
|
||||
isActive: boolean
|
||||
formattedTitle: string
|
||||
}
|
||||
|
||||
type ScrollDirection = 'left' | 'right'
|
||||
type ScrollDirection = 'left' | 'right'
|
||||
|
||||
const route = useRoute()
|
||||
const route = useRoute()
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
list: () => []
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
list: () => []
|
||||
})
|
||||
|
||||
const scrollbarRef = ref<any>()
|
||||
const showLeftArrow = ref(false)
|
||||
const showRightArrow = ref(false)
|
||||
const scrollbarRef = ref<any>()
|
||||
const showLeftArrow = ref(false)
|
||||
const showRightArrow = ref(false)
|
||||
|
||||
/** 滚动配置 */
|
||||
const SCROLL_CONFIG = {
|
||||
/** 点击按钮时的滚动距离 */
|
||||
BUTTON_SCROLL_DISTANCE: 200,
|
||||
/** 鼠标滚轮快速滚动时的步长 */
|
||||
WHEEL_FAST_STEP: 35,
|
||||
/** 鼠标滚轮慢速滚动时的步长 */
|
||||
WHEEL_SLOW_STEP: 30,
|
||||
/** 区分快慢滚动的阈值 */
|
||||
WHEEL_FAST_THRESHOLD: 100
|
||||
}
|
||||
/** 滚动配置 */
|
||||
const SCROLL_CONFIG = {
|
||||
/** 点击按钮时的滚动距离 */
|
||||
BUTTON_SCROLL_DISTANCE: 200,
|
||||
/** 鼠标滚轮快速滚动时的步长 */
|
||||
WHEEL_FAST_STEP: 35,
|
||||
/** 鼠标滚轮慢速滚动时的步长 */
|
||||
WHEEL_SLOW_STEP: 30,
|
||||
/** 区分快慢滚动的阈值 */
|
||||
WHEEL_FAST_THRESHOLD: 100
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前激活路径
|
||||
* 使用computed缓存,避免重复计算
|
||||
*/
|
||||
const currentActivePath = computed(() => {
|
||||
return String(route.meta.activePath || route.path)
|
||||
})
|
||||
/**
|
||||
* 获取当前激活路径
|
||||
* 使用computed缓存,避免重复计算
|
||||
*/
|
||||
const currentActivePath = computed(() => {
|
||||
return String(route.meta.activePath || route.path)
|
||||
})
|
||||
|
||||
/**
|
||||
* 判断菜单项是否为激活状态
|
||||
* 递归检查子菜单中是否包含当前路径
|
||||
* @param item 菜单项数据
|
||||
* @returns 是否为激活状态
|
||||
*/
|
||||
const isMenuItemActive = (item: AppRouteRecord): boolean => {
|
||||
const activePath = currentActivePath.value
|
||||
/**
|
||||
* 判断菜单项是否为激活状态
|
||||
* 递归检查子菜单中是否包含当前路径
|
||||
* @param item 菜单项数据
|
||||
* @returns 是否为激活状态
|
||||
*/
|
||||
const isMenuItemActive = (item: AppRouteRecord): boolean => {
|
||||
const activePath = currentActivePath.value
|
||||
|
||||
// 如果有子菜单,递归检查子菜单
|
||||
if (item.children?.length) {
|
||||
return item.children.some((child) => {
|
||||
if (child.children?.length) {
|
||||
return isMenuItemActive(child)
|
||||
}
|
||||
return child.path === activePath
|
||||
})
|
||||
}
|
||||
// 如果有子菜单,递归检查子菜单
|
||||
if (item.children?.length) {
|
||||
return item.children.some((child) => {
|
||||
if (child.children?.length) {
|
||||
return isMenuItemActive(child)
|
||||
}
|
||||
return child.path === activePath
|
||||
})
|
||||
}
|
||||
|
||||
// 直接比较路径
|
||||
return item.path === activePath
|
||||
}
|
||||
// 直接比较路径
|
||||
return item.path === activePath
|
||||
}
|
||||
|
||||
/**
|
||||
* 预处理菜单列表
|
||||
* 缓存每个菜单项的激活状态和格式化标题
|
||||
*/
|
||||
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
|
||||
return props.list.map((item) => ({
|
||||
...item,
|
||||
isActive: isMenuItemActive(item),
|
||||
formattedTitle: formatMenuTitle(item.meta.title)
|
||||
}))
|
||||
})
|
||||
/**
|
||||
* 预处理菜单列表
|
||||
* 缓存每个菜单项的激活状态和格式化标题
|
||||
*/
|
||||
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
|
||||
return props.list.map((item) => ({
|
||||
...item,
|
||||
isActive: isMenuItemActive(item),
|
||||
formattedTitle: formatMenuTitle(item.meta.title)
|
||||
}))
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理滚动事件的核心逻辑
|
||||
* 根据滚动位置显示/隐藏滚动按钮
|
||||
*/
|
||||
const handleScrollCore = (): void => {
|
||||
if (!scrollbarRef.value?.wrapRef) return
|
||||
/**
|
||||
* 处理滚动事件的核心逻辑
|
||||
* 根据滚动位置显示/隐藏滚动按钮
|
||||
*/
|
||||
const handleScrollCore = (): void => {
|
||||
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
|
||||
*/
|
||||
const handleScroll = useThrottleFn(handleScrollCore, 16)
|
||||
/**
|
||||
* 节流后的滚动事件处理函数
|
||||
* 调整节流间隔为16ms,约等于60fps
|
||||
*/
|
||||
const handleScroll = useThrottleFn(handleScrollCore, 16)
|
||||
|
||||
/**
|
||||
* 滚动菜单容器
|
||||
* @param direction 滚动方向,left 或 right
|
||||
*/
|
||||
const scroll = (direction: ScrollDirection): void => {
|
||||
if (!scrollbarRef.value?.wrapRef) return
|
||||
/**
|
||||
* 滚动菜单容器
|
||||
* @param direction 滚动方向,left 或 right
|
||||
*/
|
||||
const scroll = (direction: ScrollDirection): void => {
|
||||
if (!scrollbarRef.value?.wrapRef) return
|
||||
|
||||
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
|
||||
const targetScroll =
|
||||
direction === 'left'
|
||||
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
||||
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
||||
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
|
||||
const targetScroll =
|
||||
direction === 'left'
|
||||
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
||||
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
||||
|
||||
// 平滑滚动到目标位置
|
||||
scrollbarRef.value.wrapRef.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
// 平滑滚动到目标位置
|
||||
scrollbarRef.value.wrapRef.scrollTo({
|
||||
left: targetScroll,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标滚轮事件
|
||||
* 优化滚轮响应性能
|
||||
* @param event 滚轮事件
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent): void => {
|
||||
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
/**
|
||||
* 处理鼠标滚轮事件
|
||||
* 优化滚轮响应性能
|
||||
* @param event 滚轮事件
|
||||
*/
|
||||
const handleWheel = (event: WheelEvent): void => {
|
||||
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// 直接处理滚动,提升响应性
|
||||
if (!scrollbarRef.value?.wrapRef) return
|
||||
// 直接处理滚动,提升响应性
|
||||
if (!scrollbarRef.value?.wrapRef) return
|
||||
|
||||
const { wrapRef } = scrollbarRef.value
|
||||
const { scrollLeft, scrollWidth, clientWidth } = wrapRef
|
||||
const { wrapRef } = scrollbarRef.value
|
||||
const { scrollLeft, scrollWidth, clientWidth } = wrapRef
|
||||
|
||||
// 使用更小的滚动步长,让滚动更平滑
|
||||
const scrollStep =
|
||||
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
|
||||
? SCROLL_CONFIG.WHEEL_FAST_STEP
|
||||
: SCROLL_CONFIG.WHEEL_SLOW_STEP
|
||||
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
|
||||
const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth))
|
||||
// 使用更小的滚动步长,让滚动更平滑
|
||||
const scrollStep =
|
||||
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
|
||||
? SCROLL_CONFIG.WHEEL_FAST_STEP
|
||||
: SCROLL_CONFIG.WHEEL_SLOW_STEP
|
||||
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
|
||||
const targetScroll = Math.max(
|
||||
0,
|
||||
Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth)
|
||||
)
|
||||
|
||||
// 立即滚动,无动画
|
||||
wrapRef.scrollLeft = targetScroll
|
||||
// 立即滚动,无动画
|
||||
wrapRef.scrollLeft = targetScroll
|
||||
|
||||
// 更新滚动按钮状态
|
||||
handleScrollCore()
|
||||
}
|
||||
// 更新滚动按钮状态
|
||||
handleScrollCore()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化滚动状态
|
||||
*/
|
||||
const initScrollState = (): void => {
|
||||
nextTick(() => {
|
||||
handleScrollCore()
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 初始化滚动状态
|
||||
*/
|
||||
const initScrollState = (): void => {
|
||||
nextTick(() => {
|
||||
handleScrollCore()
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(initScrollState)
|
||||
onMounted(initScrollState)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.button-arrow {
|
||||
@apply absolute
|
||||
.button-arrow {
|
||||
@apply absolute
|
||||
top-1/2
|
||||
z-2
|
||||
flex
|
||||
@@ -243,37 +246,37 @@
|
||||
-translate-y-1/2
|
||||
hover:text-g-900
|
||||
hover:bg-g-200;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-scrollbar__bar.is-horizontal) {
|
||||
bottom: 5px;
|
||||
display: none;
|
||||
height: 2px;
|
||||
}
|
||||
:deep(.el-scrollbar__bar.is-horizontal) {
|
||||
bottom: 5px;
|
||||
display: none;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
:deep(.scrollbar-wrapper) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 50px 0 30px;
|
||||
}
|
||||
:deep(.scrollbar-wrapper) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0 50px 0 30px;
|
||||
}
|
||||
|
||||
.menu-item-active::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
margin: auto;
|
||||
content: '';
|
||||
background-color: var(--theme-color);
|
||||
}
|
||||
.menu-item-active::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
margin: auto;
|
||||
content: '';
|
||||
background-color: var(--theme-color);
|
||||
}
|
||||
|
||||
@media (width <= 1440px) {
|
||||
:deep(.scrollbar-wrapper) {
|
||||
margin: 0 45px;
|
||||
}
|
||||
}
|
||||
@media (width <= 1440px) {
|
||||
:deep(.scrollbar-wrapper) {
|
||||
margin: 0 45px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,355 +1,362 @@
|
||||
<!-- 左侧菜单 或 双列菜单 -->
|
||||
<template>
|
||||
<div
|
||||
class="layout-sidebar"
|
||||
v-if="showLeftMenu || isDualMenu"
|
||||
:class="{ 'no-border': menuList.length === 0 }"
|
||||
>
|
||||
<!-- 双列菜单(左侧) -->
|
||||
<div
|
||||
v-if="isDualMenu"
|
||||
class="dual-menu-left"
|
||||
:style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }"
|
||||
>
|
||||
<ArtLogo class="logo" @click="navigateToHome" />
|
||||
<div
|
||||
class="layout-sidebar"
|
||||
v-if="showLeftMenu || isDualMenu"
|
||||
:class="{ 'no-border': menuList.length === 0 }"
|
||||
>
|
||||
<!-- 双列菜单(左侧) -->
|
||||
<div
|
||||
v-if="isDualMenu"
|
||||
class="dual-menu-left"
|
||||
:style="{
|
||||
width: dualMenuShowText ? '80px' : '64px',
|
||||
background: getMenuTheme.background
|
||||
}"
|
||||
>
|
||||
<ArtLogo class="logo" @click="navigateToHome" />
|
||||
|
||||
<ElScrollbar style="height: calc(100% - 135px)">
|
||||
<ul>
|
||||
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)">
|
||||
<ElTooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
:content="$t(menu.meta.title)"
|
||||
placement="right"
|
||||
:offset="15"
|
||||
:hide-after="0"
|
||||
:disabled="dualMenuShowText"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'is-active': menu.meta.isFirstLevel
|
||||
? menu.path === route.path
|
||||
: menu.path === firstLevelMenuPath
|
||||
}"
|
||||
:style="{
|
||||
height: dualMenuShowText ? '60px' : '46px'
|
||||
}"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
class="menu-icon text-g-700 dark:text-g-800"
|
||||
:icon="menu.meta.icon"
|
||||
:style="{
|
||||
marginBottom: dualMenuShowText ? '5px' : '0'
|
||||
}"
|
||||
/>
|
||||
<span v-if="dualMenuShowText" class="text-md text-g-700">
|
||||
{{ $t(menu.meta.title) }}
|
||||
</span>
|
||||
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</li>
|
||||
</ul>
|
||||
</ElScrollbar>
|
||||
<ElScrollbar style="height: calc(100% - 135px)">
|
||||
<ul>
|
||||
<li
|
||||
v-for="menu in firstLevelMenus"
|
||||
:key="menu.path"
|
||||
@click="handleMenuJump(menu, true)"
|
||||
>
|
||||
<ElTooltip
|
||||
class="box-item"
|
||||
effect="dark"
|
||||
:content="$t(menu.meta.title)"
|
||||
placement="right"
|
||||
:offset="15"
|
||||
:hide-after="0"
|
||||
:disabled="dualMenuShowText"
|
||||
>
|
||||
<div
|
||||
:class="{
|
||||
'is-active': menu.meta.isFirstLevel
|
||||
? menu.path === route.path
|
||||
: menu.path === firstLevelMenuPath
|
||||
}"
|
||||
:style="{
|
||||
height: dualMenuShowText ? '60px' : '46px'
|
||||
}"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
class="menu-icon text-g-700 dark:text-g-800"
|
||||
:icon="menu.meta.icon"
|
||||
:style="{
|
||||
marginBottom: dualMenuShowText ? '5px' : '0'
|
||||
}"
|
||||
/>
|
||||
<span v-if="dualMenuShowText" class="text-md text-g-700">
|
||||
{{ $t(menu.meta.title) }}
|
||||
</span>
|
||||
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
|
||||
</div>
|
||||
</ElTooltip>
|
||||
</li>
|
||||
</ul>
|
||||
</ElScrollbar>
|
||||
|
||||
<ArtIconButton
|
||||
class="switch-btn size-10"
|
||||
icon="ri:arrow-left-right-fill"
|
||||
@click="toggleDualMenuMode"
|
||||
/>
|
||||
</div>
|
||||
<ArtIconButton
|
||||
class="switch-btn size-10"
|
||||
icon="ri:arrow-left-right-fill"
|
||||
@click="toggleDualMenuMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 左侧菜单 || 双列菜单(右侧) -->
|
||||
<div
|
||||
v-show="menuList.length > 0"
|
||||
class="menu-left"
|
||||
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
|
||||
:style="{ background: getMenuTheme.background }"
|
||||
>
|
||||
<ElScrollbar :style="scrollbarStyle">
|
||||
<!-- Logo、系统名称 -->
|
||||
<div
|
||||
class="header"
|
||||
@click="navigateToHome"
|
||||
:style="{
|
||||
background: getMenuTheme.background
|
||||
}"
|
||||
>
|
||||
<ArtLogo v-if="!isDualMenu" class="logo" />
|
||||
<!-- 左侧菜单 || 双列菜单(右侧) -->
|
||||
<div
|
||||
v-show="menuList.length > 0"
|
||||
class="menu-left"
|
||||
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
|
||||
:style="{ background: getMenuTheme.background }"
|
||||
>
|
||||
<ElScrollbar :style="scrollbarStyle">
|
||||
<!-- Logo、系统名称 -->
|
||||
<div
|
||||
class="header"
|
||||
@click="navigateToHome"
|
||||
:style="{
|
||||
background: getMenuTheme.background
|
||||
}"
|
||||
>
|
||||
<ArtLogo v-if="!isDualMenu" class="logo" />
|
||||
|
||||
<p
|
||||
:class="{ 'is-dual-menu-name': isDualMenu }"
|
||||
:style="{
|
||||
color: getMenuTheme.systemNameColor,
|
||||
opacity: !menuOpen ? 0 : 1
|
||||
}"
|
||||
>
|
||||
{{ AppConfig.systemInfo.name }}
|
||||
</p>
|
||||
</div>
|
||||
<p
|
||||
:class="{ 'is-dual-menu-name': isDualMenu }"
|
||||
:style="{
|
||||
color: getMenuTheme.systemNameColor,
|
||||
opacity: !menuOpen ? 0 : 1
|
||||
}"
|
||||
>
|
||||
{{ AppConfig.systemInfo.name }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ElMenu
|
||||
:class="'el-menu-' + getMenuTheme.theme"
|
||||
:collapse="!menuOpen"
|
||||
:default-active="routerPath"
|
||||
:text-color="getMenuTheme.textColor"
|
||||
:unique-opened="uniqueOpened"
|
||||
:background-color="getMenuTheme.background"
|
||||
:default-openeds="defaultOpenedMenus"
|
||||
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
|
||||
:show-timeout="50"
|
||||
:hide-timeout="50"
|
||||
>
|
||||
<SidebarSubmenu
|
||||
:list="menuList"
|
||||
:isMobile="isMobileMode"
|
||||
:theme="getMenuTheme"
|
||||
@close="handleMenuClose"
|
||||
/>
|
||||
</ElMenu>
|
||||
</ElScrollbar>
|
||||
<ElMenu
|
||||
:class="'el-menu-' + getMenuTheme.theme"
|
||||
:collapse="!menuOpen"
|
||||
:default-active="routerPath"
|
||||
:text-color="getMenuTheme.textColor"
|
||||
:unique-opened="uniqueOpened"
|
||||
:background-color="getMenuTheme.background"
|
||||
:default-openeds="defaultOpenedMenus"
|
||||
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
|
||||
:show-timeout="50"
|
||||
:hide-timeout="50"
|
||||
>
|
||||
<SidebarSubmenu
|
||||
:list="menuList"
|
||||
:isMobile="isMobileMode"
|
||||
:theme="getMenuTheme"
|
||||
@close="handleMenuClose"
|
||||
/>
|
||||
</ElMenu>
|
||||
</ElScrollbar>
|
||||
|
||||
<!-- 双列菜单右侧折叠按钮 -->
|
||||
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
|
||||
<ArtSvgIcon
|
||||
class="text-g-500/70"
|
||||
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
|
||||
/>
|
||||
</div>
|
||||
<!-- 双列菜单右侧折叠按钮 -->
|
||||
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
|
||||
<ArtSvgIcon
|
||||
class="text-g-500/70"
|
||||
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="menu-model"
|
||||
@click="toggleMenuVisibility"
|
||||
:style="{
|
||||
opacity: !menuOpen ? 0 : 1,
|
||||
transform: showMobileModal ? 'scale(1)' : 'scale(0)'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="menu-model"
|
||||
@click="toggleMenuVisibility"
|
||||
:style="{
|
||||
opacity: !menuOpen ? 0 : 1,
|
||||
transform: showMobileModal ? 'scale(1)' : 'scale(0)'
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { isIframe } from '@/utils/navigation'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
|
||||
import AppConfig from '@/config'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
|
||||
import { useMenuStore } from '@/store/modules/menu'
|
||||
import { isIframe } from '@/utils/navigation'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
|
||||
|
||||
defineOptions({ name: 'ArtSidebarMenu' })
|
||||
defineOptions({ name: 'ArtSidebarMenu' })
|
||||
|
||||
const MOBILE_BREAKPOINT = 800
|
||||
const ANIMATION_DELAY = 350
|
||||
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
|
||||
const MOBILE_BREAKPOINT = 800
|
||||
const ANIMATION_DELAY = 350
|
||||
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const settingStore = useSettingStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
|
||||
storeToRefs(settingStore)
|
||||
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
|
||||
storeToRefs(settingStore)
|
||||
|
||||
// 组件内部状态
|
||||
const defaultOpenedMenus = ref<string[]>([])
|
||||
const isMobileMode = ref(false)
|
||||
const showMobileModal = ref(false)
|
||||
// 组件内部状态
|
||||
const defaultOpenedMenus = ref<string[]>([])
|
||||
const isMobileMode = ref(false)
|
||||
const showMobileModal = ref(false)
|
||||
|
||||
// 使用 VueUse 的窗口尺寸监听
|
||||
const { width } = useWindowSize()
|
||||
// 使用 VueUse 的窗口尺寸监听
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// 菜单宽度相关
|
||||
const menuopenwidth = computed(() => getMenuOpenWidth.value)
|
||||
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
|
||||
// 菜单宽度相关
|
||||
const menuopenwidth = computed(() => getMenuOpenWidth.value)
|
||||
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
|
||||
|
||||
// 菜单类型判断
|
||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||
const showLeftMenu = computed(
|
||||
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
|
||||
)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
// 菜单类型判断
|
||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||
const showLeftMenu = computed(
|
||||
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
|
||||
)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
|
||||
// 移动端屏幕判断(使用 computed 避免重复计算)
|
||||
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
|
||||
// 移动端屏幕判断(使用 computed 避免重复计算)
|
||||
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
|
||||
|
||||
// 路由相关
|
||||
const firstLevelMenuPath = computed(() => route.matched[0]?.path)
|
||||
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
||||
// 路由相关
|
||||
const firstLevelMenuPath = computed(() => route.matched[0]?.path)
|
||||
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
||||
|
||||
// 菜单数据
|
||||
const firstLevelMenus = computed(() => {
|
||||
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
|
||||
})
|
||||
// 菜单数据
|
||||
const firstLevelMenus = computed(() => {
|
||||
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
|
||||
})
|
||||
|
||||
const menuList = computed(() => {
|
||||
const menuStore = useMenuStore()
|
||||
const allMenus = menuStore.menuList
|
||||
const menuList = computed(() => {
|
||||
const menuStore = useMenuStore()
|
||||
const allMenus = menuStore.menuList
|
||||
|
||||
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
|
||||
if (!isTopLeftMenu.value && !isDualMenu.value) {
|
||||
return allMenus
|
||||
}
|
||||
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
|
||||
if (!isTopLeftMenu.value && !isDualMenu.value) {
|
||||
return allMenus
|
||||
}
|
||||
|
||||
// 处理 iframe 路径
|
||||
if (isIframe(route.path)) {
|
||||
return findIframeMenuList(route.path, allMenus)
|
||||
}
|
||||
// 处理 iframe 路径
|
||||
if (isIframe(route.path)) {
|
||||
return findIframeMenuList(route.path, allMenus)
|
||||
}
|
||||
|
||||
// 处理一级菜单
|
||||
if (route.meta.isFirstLevel) {
|
||||
return []
|
||||
}
|
||||
// 处理一级菜单
|
||||
if (route.meta.isFirstLevel) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 返回当前顶级路径对应的子菜单
|
||||
const currentTopPath = `/${route.path.split('/')[1]}`
|
||||
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
|
||||
return currentMenu?.children ?? []
|
||||
})
|
||||
// 返回当前顶级路径对应的子菜单
|
||||
const currentTopPath = `/${route.path.split('/')[1]}`
|
||||
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
|
||||
return currentMenu?.children ?? []
|
||||
})
|
||||
|
||||
// 双列菜单收起时的滚动条样式
|
||||
const scrollbarStyle = computed(() => {
|
||||
const isCollapsed = isDualMenu.value && !menuOpen.value
|
||||
return {
|
||||
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
|
||||
height: isCollapsed ? 'calc(100% + 50px)' : '100%',
|
||||
transition: 'transform 0.3s ease'
|
||||
}
|
||||
})
|
||||
// 双列菜单收起时的滚动条样式
|
||||
const scrollbarStyle = computed(() => {
|
||||
const isCollapsed = isDualMenu.value && !menuOpen.value
|
||||
return {
|
||||
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
|
||||
height: isCollapsed ? 'calc(100% + 50px)' : '100%',
|
||||
transition: 'transform 0.3s ease'
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn)
|
||||
*/
|
||||
const { start: delayHideMobileModal } = useTimeoutFn(
|
||||
() => {
|
||||
showMobileModal.value = false
|
||||
},
|
||||
ANIMATION_DELAY,
|
||||
{ immediate: false }
|
||||
)
|
||||
/**
|
||||
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn)
|
||||
*/
|
||||
const { start: delayHideMobileModal } = useTimeoutFn(
|
||||
() => {
|
||||
showMobileModal.value = false
|
||||
},
|
||||
ANIMATION_DELAY,
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
/**
|
||||
* 查找 iframe 对应的二级菜单列表
|
||||
*/
|
||||
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
|
||||
// 递归查找包含当前路径的菜单项
|
||||
const hasPath = (items: any[]): boolean => {
|
||||
for (const item of items) {
|
||||
if (item.path === currentPath) {
|
||||
return true
|
||||
}
|
||||
if (item.children && hasPath(item.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* 查找 iframe 对应的二级菜单列表
|
||||
*/
|
||||
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
|
||||
// 递归查找包含当前路径的菜单项
|
||||
const hasPath = (items: any[]): boolean => {
|
||||
for (const item of items) {
|
||||
if (item.path === currentPath) {
|
||||
return true
|
||||
}
|
||||
if (item.children && hasPath(item.children)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 遍历一级菜单查找匹配的子菜单
|
||||
for (const menu of menuList) {
|
||||
if (menu.children && hasPath(menu.children)) {
|
||||
return menu.children
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
// 遍历一级菜单查找匹配的子菜单
|
||||
for (const menu of menuList) {
|
||||
if (menu.children && hasPath(menu.children)) {
|
||||
return menu.children
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const { homePath } = useCommon()
|
||||
const { homePath } = useCommon()
|
||||
|
||||
/**
|
||||
* 导航到首页
|
||||
*/
|
||||
const navigateToHome = (): void => {
|
||||
router.push(homePath.value)
|
||||
}
|
||||
/**
|
||||
* 导航到首页
|
||||
*/
|
||||
const navigateToHome = (): void => {
|
||||
router.push(homePath.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换菜单显示/隐藏
|
||||
*/
|
||||
const toggleMenuVisibility = (): void => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
/**
|
||||
* 切换菜单显示/隐藏
|
||||
*/
|
||||
const toggleMenuVisibility = (): void => {
|
||||
settingStore.setMenuOpen(!menuOpen.value)
|
||||
|
||||
// 移动端模态框控制逻辑
|
||||
if (isMobileScreen.value) {
|
||||
if (!menuOpen.value) {
|
||||
// 菜单即将打开,立即显示模态框
|
||||
showMobileModal.value = true
|
||||
} else {
|
||||
// 菜单即将关闭,延迟隐藏模态框确保动画完成
|
||||
delayHideMobileModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
// 移动端模态框控制逻辑
|
||||
if (isMobileScreen.value) {
|
||||
if (!menuOpen.value) {
|
||||
// 菜单即将打开,立即显示模态框
|
||||
showMobileModal.value = true
|
||||
} else {
|
||||
// 菜单即将关闭,延迟隐藏模态框确保动画完成
|
||||
delayHideMobileModal()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理菜单关闭(来自子组件)
|
||||
*/
|
||||
const handleMenuClose = (): void => {
|
||||
if (isMobileScreen.value) {
|
||||
settingStore.setMenuOpen(false)
|
||||
delayHideMobileModal()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 处理菜单关闭(来自子组件)
|
||||
*/
|
||||
const handleMenuClose = (): void => {
|
||||
if (isMobileScreen.value) {
|
||||
settingStore.setMenuOpen(false)
|
||||
delayHideMobileModal()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换双列菜单模式
|
||||
*/
|
||||
const toggleDualMenuMode = (): void => {
|
||||
settingStore.setDualMenuShowText(!dualMenuShowText.value)
|
||||
}
|
||||
/**
|
||||
* 切换双列菜单模式
|
||||
*/
|
||||
const toggleDualMenuMode = (): void => {
|
||||
settingStore.setDualMenuShowText(!dualMenuShowText.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听窗口尺寸变化,自动处理移动端菜单
|
||||
*/
|
||||
watch(width, (newWidth) => {
|
||||
if (newWidth < MOBILE_BREAKPOINT) {
|
||||
settingStore.setMenuOpen(false)
|
||||
if (!menuOpen.value) {
|
||||
showMobileModal.value = false
|
||||
}
|
||||
} else {
|
||||
showMobileModal.value = false
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 监听窗口尺寸变化,自动处理移动端菜单
|
||||
*/
|
||||
watch(width, (newWidth) => {
|
||||
if (newWidth < MOBILE_BREAKPOINT) {
|
||||
settingStore.setMenuOpen(false)
|
||||
if (!menuOpen.value) {
|
||||
showMobileModal.value = false
|
||||
}
|
||||
} else {
|
||||
showMobileModal.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 监听菜单开关状态变化
|
||||
*/
|
||||
watch(menuOpen, (isMenuOpen: boolean) => {
|
||||
if (!isMobileScreen.value) {
|
||||
// 大屏幕设备上,模态框始终隐藏
|
||||
showMobileModal.value = false
|
||||
} else {
|
||||
// 小屏幕设备上,根据菜单状态控制模态框
|
||||
if (isMenuOpen) {
|
||||
// 菜单打开时立即显示模态框
|
||||
showMobileModal.value = true
|
||||
} else {
|
||||
// 菜单关闭时延迟隐藏模态框,确保动画完成
|
||||
delayHideMobileModal()
|
||||
}
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 监听菜单开关状态变化
|
||||
*/
|
||||
watch(menuOpen, (isMenuOpen: boolean) => {
|
||||
if (!isMobileScreen.value) {
|
||||
// 大屏幕设备上,模态框始终隐藏
|
||||
showMobileModal.value = false
|
||||
} else {
|
||||
// 小屏幕设备上,根据菜单状态控制模态框
|
||||
if (isMenuOpen) {
|
||||
// 菜单打开时立即显示模态框
|
||||
showMobileModal.value = true
|
||||
} else {
|
||||
// 菜单关闭时延迟隐藏模态框,确保动画完成
|
||||
delayHideMobileModal()
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
@use './style';
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
@use './theme';
|
||||
@use './theme';
|
||||
|
||||
.layout-sidebar {
|
||||
// 展开的宽度
|
||||
.el-menu:not(.el-menu--collapse) {
|
||||
width: v-bind(menuopenwidth);
|
||||
}
|
||||
.layout-sidebar {
|
||||
// 展开的宽度
|
||||
.el-menu:not(.el-menu--collapse) {
|
||||
width: v-bind(menuopenwidth);
|
||||
}
|
||||
|
||||
// 折叠后宽度
|
||||
.el-menu--collapse {
|
||||
width: v-bind(menuclosewidth);
|
||||
}
|
||||
}
|
||||
// 折叠后宽度
|
||||
.el-menu--collapse {
|
||||
width: v-bind(menuclosewidth);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,253 +1,253 @@
|
||||
.layout-sidebar {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
border-right: 1px solid var(--art-card-border);
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
user-select: none;
|
||||
scrollbar-width: none;
|
||||
border-right: 1px solid var(--art-card-border);
|
||||
|
||||
&.no-border {
|
||||
border-right: none !important;
|
||||
}
|
||||
&.no-border {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
// 自定义滚动条宽度
|
||||
:deep(.el-scrollbar__bar.is-vertical) {
|
||||
width: 4px;
|
||||
}
|
||||
// 自定义滚动条宽度
|
||||
:deep(.el-scrollbar__bar.is-vertical) {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
right: -2px;
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
right: -2px;
|
||||
background-color: #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.dual-menu-left {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
transition: width 0.25s;
|
||||
.dual-menu-left {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 100%;
|
||||
border-right: 1px solid var(--art-card-border) !important;
|
||||
transition: width 0.25s;
|
||||
|
||||
.logo {
|
||||
margin: auto;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.logo {
|
||||
margin: auto;
|
||||
margin-top: 12px;
|
||||
margin-bottom: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
ul {
|
||||
li {
|
||||
> div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 8px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
ul {
|
||||
li {
|
||||
> div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 8px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
|
||||
.art-svg-icon {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
font-size: 20px;
|
||||
}
|
||||
.art-svg-icon {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
span {
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
span {
|
||||
display: -webkit-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 1;
|
||||
line-clamp: 1;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
&.is-active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
|
||||
.art-svg-icon,
|
||||
span {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.art-svg-icon,
|
||||
span {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.switch-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 15px;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
.switch-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 15px;
|
||||
left: 0;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-left {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
height: 100vh;
|
||||
.menu-left {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
height: 100vh;
|
||||
|
||||
@media only screen and (width <= 640px) {
|
||||
height: 100dvh;
|
||||
}
|
||||
@media only screen and (width <= 640px) {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
height: 100%;
|
||||
}
|
||||
.el-menu {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.dual-menu-collapse-btn {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.dual-menu-collapse-btn {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dual-menu-collapse-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -11px;
|
||||
z-index: 10;
|
||||
width: 11px;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
background-color: var(--default-box-color);
|
||||
border: 1px solid var(--art-card-border);
|
||||
border-radius: 0 15px 15px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
transform: translateY(-50%);
|
||||
.dual-menu-collapse-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: -11px;
|
||||
z-index: 10;
|
||||
width: 11px;
|
||||
height: 50px;
|
||||
cursor: pointer;
|
||||
background-color: var(--default-box-color);
|
||||
border: 1px solid var(--art-card-border);
|
||||
border-radius: 0 15px 15px 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
transform: translateY(-50%);
|
||||
|
||||
&:hover {
|
||||
.art-svg-icon {
|
||||
color: var(--art-gray-800) !important;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.art-svg-icon {
|
||||
color: var(--art-gray-800) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.art-svg-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
margin: auto;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
.art-svg-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: -4px;
|
||||
margin: auto;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 60px;
|
||||
cursor: pointer;
|
||||
.header {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
line-height: 60px;
|
||||
cursor: pointer;
|
||||
|
||||
.logo {
|
||||
margin-left: 22px;
|
||||
}
|
||||
.logo {
|
||||
margin-left: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 58px;
|
||||
box-sizing: border-box;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
p {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 58px;
|
||||
box-sizing: border-box;
|
||||
margin-left: 10px;
|
||||
font-size: 18px;
|
||||
|
||||
&.is-dual-menu-name {
|
||||
left: 25px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.is-dual-menu-name {
|
||||
left: 25px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
// 防止菜单内的滚动影响整个页面滚动
|
||||
overscroll-behavior: contain;
|
||||
border-right: 0;
|
||||
scrollbar-width: none;
|
||||
-ms-scroll-chaining: contain;
|
||||
.el-menu {
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
// 防止菜单内的滚动影响整个页面滚动
|
||||
overscroll-behavior: contain;
|
||||
border-right: 0;
|
||||
scrollbar-width: none;
|
||||
-ms-scroll-chaining: contain;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-model {
|
||||
display: none;
|
||||
}
|
||||
.menu-model {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 800px) {
|
||||
.layout-sidebar {
|
||||
width: 0;
|
||||
.layout-sidebar {
|
||||
width: 0;
|
||||
|
||||
.header {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
.header {
|
||||
height: 50px;
|
||||
line-height: 50px;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
.el-menu {
|
||||
height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.el-menu--collapse {
|
||||
width: 0;
|
||||
}
|
||||
.el-menu--collapse {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
// 折叠状态下的header样式
|
||||
.menu-left-close .header {
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
// 折叠状态下的header样式
|
||||
.menu-left-close .header {
|
||||
.logo {
|
||||
display: none;
|
||||
}
|
||||
|
||||
p {
|
||||
left: 16px;
|
||||
font-size: 0;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
p {
|
||||
left: 16px;
|
||||
font-size: 0;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-model {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba($color: #000, $alpha: 50%);
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
.menu-model {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: -1;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba($color: #000, $alpha: 50%);
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 640px) {
|
||||
.layout-sidebar {
|
||||
border-right: 0 !important;
|
||||
}
|
||||
.layout-sidebar {
|
||||
border-right: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid rgb(255 255 255 / 13%);
|
||||
.layout-sidebar {
|
||||
border-right: 1px solid rgb(255 255 255 / 13%);
|
||||
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: #777;
|
||||
}
|
||||
:deep(.el-scrollbar__thumb) {
|
||||
background-color: #777;
|
||||
}
|
||||
|
||||
.dual-menu-left {
|
||||
border-right: 1px solid rgb(255 255 255 / 9%) !important;
|
||||
}
|
||||
}
|
||||
.dual-menu-left {
|
||||
border-right: 1px solid rgb(255 255 255 / 9%) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,247 +12,247 @@ $popup-menu-radius: 6px;
|
||||
|
||||
// 通用菜单项样式
|
||||
@mixin menu-item-base {
|
||||
width: calc(100% - 16px);
|
||||
margin-left: 8px;
|
||||
border-radius: 6px;
|
||||
width: calc(100% - 16px);
|
||||
margin-left: 8px;
|
||||
border-radius: 6px;
|
||||
|
||||
.menu-icon {
|
||||
margin-left: -7px;
|
||||
}
|
||||
.menu-icon {
|
||||
margin-left: -7px;
|
||||
}
|
||||
}
|
||||
|
||||
// 通用 hover 样式
|
||||
@mixin menu-hover($bg-color) {
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:not(.is-active):hover {
|
||||
background: $bg-color !important;
|
||||
}
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:not(.is-active):hover {
|
||||
background: $bg-color !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 通用选中样式
|
||||
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
|
||||
.el-menu-item.is-active {
|
||||
color: $color !important;
|
||||
background-color: $bg-color;
|
||||
.el-menu-item.is-active {
|
||||
color: $color !important;
|
||||
background-color: $bg-color;
|
||||
|
||||
.menu-icon {
|
||||
.art-svg-icon {
|
||||
color: $icon-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.menu-icon {
|
||||
.art-svg-icon {
|
||||
color: $icon-color !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗菜单项样式
|
||||
@mixin popup-menu-item {
|
||||
height: $popup-menu-height;
|
||||
margin-bottom: $popup-menu-margin;
|
||||
border-radius: $popup-menu-radius;
|
||||
height: $popup-menu-height;
|
||||
margin-bottom: $popup-menu-margin;
|
||||
border-radius: $popup-menu-radius;
|
||||
|
||||
.menu-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
.menu-icon {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
&:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑)
|
||||
@mixin theme-menu-base {
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
@include menu-item-base;
|
||||
}
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
@include menu-item-base;
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗菜单通用样式
|
||||
@mixin popup-menu-base($hover-bg, $active-color, $active-bg) {
|
||||
.el-menu--popup {
|
||||
padding: $popup-menu-padding;
|
||||
.el-menu--popup {
|
||||
padding: $popup-menu-padding;
|
||||
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:hover {
|
||||
background-color: $hover-bg !important;
|
||||
border-radius: $popup-menu-radius;
|
||||
}
|
||||
.el-sub-menu__title:hover,
|
||||
.el-menu-item:hover {
|
||||
background-color: $hover-bg !important;
|
||||
border-radius: $popup-menu-radius;
|
||||
}
|
||||
|
||||
.el-menu-item {
|
||||
@include popup-menu-item;
|
||||
.el-menu-item {
|
||||
@include popup-menu-item;
|
||||
|
||||
&.is-active {
|
||||
color: $active-color !important;
|
||||
background-color: $active-bg !important;
|
||||
}
|
||||
}
|
||||
&.is-active {
|
||||
color: $active-color !important;
|
||||
background-color: $active-bg !important;
|
||||
}
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
@include popup-menu-item;
|
||||
.el-sub-menu {
|
||||
@include popup-menu-item;
|
||||
|
||||
height: $popup-menu-height !important;
|
||||
height: $popup-menu-height !important;
|
||||
|
||||
.el-sub-menu__title {
|
||||
height: $popup-menu-height !important;
|
||||
border-radius: $popup-menu-radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
.el-sub-menu__title {
|
||||
height: $popup-menu-height !important;
|
||||
border-radius: $popup-menu-radius;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
// ---------------------- Modify default style ----------------------
|
||||
// ---------------------- Modify default style ----------------------
|
||||
|
||||
// 菜单折叠样式
|
||||
.menu-left-close {
|
||||
.header {
|
||||
.logo {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 菜单折叠样式
|
||||
.menu-left-close {
|
||||
.header {
|
||||
.logo {
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单图标
|
||||
.menu-icon {
|
||||
margin-right: 8px;
|
||||
font-size: $menu-icon-size;
|
||||
}
|
||||
// 菜单图标
|
||||
.menu-icon {
|
||||
margin-right: 8px;
|
||||
font-size: $menu-icon-size;
|
||||
}
|
||||
|
||||
// 菜单高度
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
height: $menu-height !important;
|
||||
margin-bottom: 4px;
|
||||
line-height: $menu-height !important;
|
||||
// 菜单高度
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
height: $menu-height !important;
|
||||
margin-bottom: 4px;
|
||||
line-height: $menu-height !important;
|
||||
|
||||
span {
|
||||
font-size: $menu-font-size !important;
|
||||
span {
|
||||
font-size: $menu-font-size !important;
|
||||
|
||||
@include ellipsis();
|
||||
}
|
||||
}
|
||||
@include ellipsis();
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧箭头
|
||||
.el-sub-menu__icon-arrow {
|
||||
width: 13px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
// 右侧箭头
|
||||
.el-sub-menu__icon-arrow {
|
||||
width: 13px !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
// 菜单折叠
|
||||
.el-menu--collapse {
|
||||
.el-sub-menu.is-active {
|
||||
.el-sub-menu__title {
|
||||
.menu-icon {
|
||||
.art-svg-icon {
|
||||
// 选中菜单图标颜色
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 菜单折叠
|
||||
.el-menu--collapse {
|
||||
.el-sub-menu.is-active {
|
||||
.el-sub-menu__title {
|
||||
.menu-icon {
|
||||
.art-svg-icon {
|
||||
// 选中菜单图标颜色
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Design theme menu ----------------------
|
||||
.el-menu-design {
|
||||
@include theme-menu-base;
|
||||
@include menu-active(var(--theme-color), var(--el-color-primary-light-9));
|
||||
@include menu-hover($hover-bg-color);
|
||||
// ---------------------- Design theme menu ----------------------
|
||||
.el-menu-design {
|
||||
@include theme-menu-base;
|
||||
@include menu-active(var(--theme-color), var(--el-color-primary-light-9));
|
||||
@include menu-hover($hover-bg-color);
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Dark theme menu ----------------------
|
||||
.el-menu-dark {
|
||||
@include theme-menu-base;
|
||||
@include menu-active(#fff, #27282d, #fff);
|
||||
@include menu-hover(#0f1015);
|
||||
// ---------------------- Dark theme menu ----------------------
|
||||
.el-menu-dark {
|
||||
@include theme-menu-base;
|
||||
@include menu-active(#fff, #27282d, #fff);
|
||||
@include menu-hover(#0f1015);
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Light theme menu ----------------------
|
||||
.el-menu-light {
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
.menu-icon {
|
||||
margin-left: 1px;
|
||||
}
|
||||
}
|
||||
// ---------------------- Light theme menu ----------------------
|
||||
.el-menu-light {
|
||||
.el-sub-menu__title,
|
||||
.el-menu-item {
|
||||
.menu-icon {
|
||||
margin-left: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
.el-menu-item.is-active {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
|
||||
.art-svg-icon {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
.art-svg-icon {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background: var(--theme-color);
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
content: '';
|
||||
background: var(--theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
@include menu-hover($hover-bg-color);
|
||||
@include menu-hover($hover-bg-color);
|
||||
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: var(--art-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (width <= 640px) {
|
||||
.layout-sidebar {
|
||||
.el-menu-design {
|
||||
> .el-sub-menu {
|
||||
margin-left: 0;
|
||||
}
|
||||
.layout-sidebar {
|
||||
.el-menu-design {
|
||||
> .el-sub-menu {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.el-sub-menu {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.el-sub-menu {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单折叠 hover 弹窗样式(浅色主题)
|
||||
.el-menu--vertical,
|
||||
.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 {
|
||||
.el-menu--vertical,
|
||||
.el-menu--popup-container {
|
||||
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
|
||||
}
|
||||
.el-menu--vertical,
|
||||
.el-menu--popup-container {
|
||||
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
// 图标颜色、文字颜色
|
||||
.menu-icon .art-svg-icon,
|
||||
.menu-name {
|
||||
color: var(--art-gray-800) !important;
|
||||
}
|
||||
.layout-sidebar {
|
||||
// 图标颜色、文字颜色
|
||||
.menu-icon .art-svg-icon,
|
||||
.menu-name {
|
||||
color: var(--art-gray-800) !important;
|
||||
}
|
||||
|
||||
// 选中的文字颜色跟图标颜色
|
||||
.el-menu-item.is-active {
|
||||
span,
|
||||
.menu-icon .art-svg-icon {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
// 选中的文字颜色跟图标颜色
|
||||
.el-menu-item.is-active {
|
||||
span,
|
||||
.menu-icon .art-svg-icon {
|
||||
color: var(--theme-color) !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧箭头颜色
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
// 右侧箭头颜色
|
||||
.el-sub-menu__icon-arrow {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+167
-164
@@ -1,188 +1,191 @@
|
||||
<template>
|
||||
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
|
||||
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
|
||||
<template #title>
|
||||
<div class="menu-icon flex-cc">
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
:style="{ color: theme.iconColor }"
|
||||
/>
|
||||
</div>
|
||||
<span class="menu-name">
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
|
||||
</template>
|
||||
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
|
||||
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
|
||||
<template #title>
|
||||
<div class="menu-icon flex-cc">
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
:style="{ color: theme.iconColor }"
|
||||
/>
|
||||
</div>
|
||||
<span class="menu-name">
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
|
||||
</template>
|
||||
|
||||
<SidebarSubmenu
|
||||
:list="item.children"
|
||||
:is-mobile="isMobile"
|
||||
:level="level + 1"
|
||||
:theme="theme"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</ElSubMenu>
|
||||
<SidebarSubmenu
|
||||
:list="item.children"
|
||||
:is-mobile="isMobile"
|
||||
:level="level + 1"
|
||||
:theme="theme"
|
||||
@close="closeMenu"
|
||||
/>
|
||||
</ElSubMenu>
|
||||
|
||||
<ElMenuItem
|
||||
v-else
|
||||
:index="isExternalLink(item) ? undefined : item.path || item.meta.title"
|
||||
:level-item="level + 1"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<div class="menu-icon flex-cc">
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
:style="{ color: theme.iconColor }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="item.meta.showBadge && level === 0 && !menuOpen"
|
||||
class="art-badge"
|
||||
style="right: 5px"
|
||||
/>
|
||||
<ElMenuItem
|
||||
v-else
|
||||
:index="isExternalLink(item) ? undefined : item.path || item.meta.title"
|
||||
:level-item="level + 1"
|
||||
@click="goPage(item)"
|
||||
>
|
||||
<div class="menu-icon flex-cc">
|
||||
<ArtSvgIcon
|
||||
:icon="item.meta.icon"
|
||||
:color="theme?.iconColor"
|
||||
:style="{ color: theme.iconColor }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="item.meta.showBadge && level === 0 && !menuOpen"
|
||||
class="art-badge"
|
||||
style="right: 5px"
|
||||
/>
|
||||
|
||||
<template #title>
|
||||
<span class="menu-name">
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge" class="art-badge" />
|
||||
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge">
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</template>
|
||||
</ElMenuItem>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="menu-name">
|
||||
{{ formatMenuTitle(item.meta.title) }}
|
||||
</span>
|
||||
<div v-if="item.meta.showBadge" class="art-badge" />
|
||||
<div
|
||||
v-if="item.meta.showTextBadge && (level > 0 || menuOpen)"
|
||||
class="art-text-badge"
|
||||
>
|
||||
{{ item.meta.showTextBadge }}
|
||||
</div>
|
||||
</template>
|
||||
</ElMenuItem>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { computed } from 'vue'
|
||||
import type { AppRouteRecord } from '@/types/router'
|
||||
import { formatMenuTitle } from '@/utils/router'
|
||||
import { handleMenuJump } from '@/utils/navigation'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
interface MenuTheme {
|
||||
iconColor?: string
|
||||
}
|
||||
interface MenuTheme {
|
||||
iconColor?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
/** 菜单标题 */
|
||||
title?: string
|
||||
/** 菜单列表 */
|
||||
list?: AppRouteRecord[]
|
||||
/** 主题配置 */
|
||||
theme?: MenuTheme
|
||||
/** 是否为移动端模式 */
|
||||
isMobile?: boolean
|
||||
/** 菜单层级 */
|
||||
level?: number
|
||||
}
|
||||
interface Props {
|
||||
/** 菜单标题 */
|
||||
title?: string
|
||||
/** 菜单列表 */
|
||||
list?: AppRouteRecord[]
|
||||
/** 主题配置 */
|
||||
theme?: MenuTheme
|
||||
/** 是否为移动端模式 */
|
||||
isMobile?: boolean
|
||||
/** 菜单层级 */
|
||||
level?: number
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
/** 关闭菜单事件 */
|
||||
(e: 'close'): void
|
||||
}
|
||||
interface Emits {
|
||||
/** 关闭菜单事件 */
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
list: () => [],
|
||||
theme: () => ({}),
|
||||
isMobile: false,
|
||||
level: 0
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '',
|
||||
list: () => [],
|
||||
theme: () => ({}),
|
||||
isMobile: false,
|
||||
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 菜单项数据
|
||||
*/
|
||||
const goPage = (item: AppRouteRecord): void => {
|
||||
closeMenu()
|
||||
handleMenuJump(item)
|
||||
}
|
||||
/**
|
||||
* 跳转到指定页面
|
||||
* @param item 菜单项数据
|
||||
*/
|
||||
const goPage = (item: AppRouteRecord): void => {
|
||||
closeMenu()
|
||||
handleMenuJump(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭菜单
|
||||
* 触发父组件的关闭事件
|
||||
*/
|
||||
const closeMenu = (): void => {
|
||||
emit('close')
|
||||
}
|
||||
/**
|
||||
* 关闭菜单
|
||||
* 触发父组件的关闭事件
|
||||
*/
|
||||
const closeMenu = (): void => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归过滤菜单路由,移除隐藏的菜单项
|
||||
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
||||
* @param items 菜单项数组
|
||||
* @returns 过滤后的菜单项数组
|
||||
*/
|
||||
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items
|
||||
.filter((item) => {
|
||||
// 如果当前项被隐藏,直接过滤掉
|
||||
if (item.meta.isHide) {
|
||||
return false
|
||||
}
|
||||
/**
|
||||
* 递归过滤菜单路由,移除隐藏的菜单项
|
||||
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
||||
* @param items 菜单项数组
|
||||
* @returns 过滤后的菜单项数组
|
||||
*/
|
||||
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||
return items
|
||||
.filter((item) => {
|
||||
// 如果当前项被隐藏,直接过滤掉
|
||||
if (item.meta.isHide) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 如果有子菜单,递归过滤子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
const filteredChildren = filterRoutes(item.children)
|
||||
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
||||
return filteredChildren.length > 0
|
||||
}
|
||||
// 如果有子菜单,递归过滤子菜单
|
||||
if (item.children && item.children.length > 0) {
|
||||
const filteredChildren = filterRoutes(item.children)
|
||||
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
||||
return filteredChildren.length > 0
|
||||
}
|
||||
|
||||
// 叶子节点且未被隐藏,保留
|
||||
return true
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterRoutes(item.children) : undefined
|
||||
}))
|
||||
}
|
||||
// 叶子节点且未被隐藏,保留
|
||||
return true
|
||||
})
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children ? filterRoutes(item.children) : undefined
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断菜单项是否包含可见的子菜单
|
||||
* @param item 菜单项数据
|
||||
* @returns 是否包含可见的子菜单
|
||||
*/
|
||||
const hasChildren = (item: AppRouteRecord): boolean => {
|
||||
if (!item.children || item.children.length === 0) {
|
||||
return false
|
||||
}
|
||||
// 递归检查是否有可见的子菜单
|
||||
const filteredChildren = filterRoutes(item.children)
|
||||
return filteredChildren.length > 0
|
||||
}
|
||||
/**
|
||||
* 判断菜单项是否包含可见的子菜单
|
||||
* @param item 菜单项数据
|
||||
* @returns 是否包含可见的子菜单
|
||||
*/
|
||||
const hasChildren = (item: AppRouteRecord): boolean => {
|
||||
if (!item.children || item.children.length === 0) {
|
||||
return false
|
||||
}
|
||||
// 递归检查是否有可见的子菜单
|
||||
const filteredChildren = filterRoutes(item.children)
|
||||
return filteredChildren.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为外部链接
|
||||
* @param item 菜单项数据
|
||||
* @returns 是否为外部链接
|
||||
*/
|
||||
const isExternalLink = (item: AppRouteRecord): boolean => {
|
||||
return !!(item.meta.link && !item.meta.isIframe)
|
||||
}
|
||||
/**
|
||||
* 判断是否为外部链接
|
||||
* @param item 菜单项数据
|
||||
* @returns 是否为外部链接
|
||||
*/
|
||||
const isExternalLink = (item: AppRouteRecord): boolean => {
|
||||
return !!(item.meta.link && !item.meta.isIframe)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一的 key
|
||||
* 使用 path、title 和 index 组合确保唯一性
|
||||
* @param item 菜单项数据
|
||||
* @param index 索引
|
||||
* @returns 唯一的 key
|
||||
*/
|
||||
const getUniqueKey = (item: AppRouteRecord, index: number): string => {
|
||||
return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}`
|
||||
}
|
||||
/**
|
||||
* 生成唯一的 key
|
||||
* 使用 path、title 和 index 组合确保唯一性
|
||||
* @param item 菜单项数据
|
||||
* @param index 索引
|
||||
* @returns 唯一的 key
|
||||
*/
|
||||
const getUniqueKey = (item: AppRouteRecord, index: number): string => {
|
||||
return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,427 +1,432 @@
|
||||
<!-- 通知组件 -->
|
||||
<template>
|
||||
<div
|
||||
class="art-notification-panel art-card-sm !shadow-xl"
|
||||
:style="{
|
||||
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
||||
opacity: show ? 1 : 0
|
||||
}"
|
||||
v-show="visible"
|
||||
@click.stop
|
||||
>
|
||||
<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-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
|
||||
{{ $t('notice.btnRead') }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="art-notification-panel art-card-sm !shadow-xl"
|
||||
:style="{
|
||||
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
||||
opacity: show ? 1 : 0
|
||||
}"
|
||||
v-show="visible"
|
||||
@click.stop
|
||||
>
|
||||
<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-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
|
||||
{{ $t('notice.btnRead') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
|
||||
:class="{ 'bar-active': barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
{{ item.name }} ({{ item.num }})
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
|
||||
<li
|
||||
v-for="(item, index) in barList"
|
||||
:key="index"
|
||||
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
|
||||
:class="{ 'bar-active': barActiveIndex === index }"
|
||||
@click="changeBar(index)"
|
||||
>
|
||||
{{ item.name }} ({{ item.num }})
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="w-full h-[calc(100%-95px)]">
|
||||
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
|
||||
<!-- 通知 -->
|
||||
<ul v-show="barActiveIndex === 0">
|
||||
<li
|
||||
v-for="(item, index) in noticeList"
|
||||
:key="index"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
>
|
||||
<div
|
||||
class="size-9 leading-9 text-center rounded-lg flex-cc"
|
||||
:class="[getNoticeStyle(item.type).iconClass]"
|
||||
>
|
||||
<ArtSvgIcon class="text-lg !bg-transparent" :icon="getNoticeStyle(item.type).icon" />
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="w-full h-[calc(100%-95px)]">
|
||||
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
|
||||
<!-- 通知 -->
|
||||
<ul v-show="barActiveIndex === 0">
|
||||
<li
|
||||
v-for="(item, index) in noticeList"
|
||||
:key="index"
|
||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||
>
|
||||
<div
|
||||
class="size-9 leading-9 text-center rounded-lg flex-cc"
|
||||
:class="[getNoticeStyle(item.type).iconClass]"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
class="text-lg !bg-transparent"
|
||||
:icon="getNoticeStyle(item.type).icon"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
<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>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 消息 -->
|
||||
<ul v-show="barActiveIndex === 1">
|
||||
<li
|
||||
v-for="(item, index) in msgList"
|
||||
:key="index"
|
||||
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">
|
||||
<img :src="item.avatar" class="w-full h-full rounded-lg" />
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 消息 -->
|
||||
<ul v-show="barActiveIndex === 1">
|
||||
<li
|
||||
v-for="(item, index) in msgList"
|
||||
:key="index"
|
||||
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">
|
||||
<img :src="item.avatar" class="w-full h-full rounded-lg" />
|
||||
</div>
|
||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 待办 -->
|
||||
<ul v-show="barActiveIndex === 2">
|
||||
<li
|
||||
v-for="(item, index) in pendingList"
|
||||
:key="index"
|
||||
class="box-border px-5 py-3.5 last:border-b-0"
|
||||
>
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p class="text-xs text-g-500">{{ item.time }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- 待办 -->
|
||||
<ul v-show="barActiveIndex === 2">
|
||||
<li
|
||||
v-for="(item, index) in pendingList"
|
||||
:key="index"
|
||||
class="box-border px-5 py-3.5 last:border-b-0"
|
||||
>
|
||||
<h4>{{ item.title }}</h4>
|
||||
<p class="text-xs text-g-500">{{ item.time }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-show="currentTabIsEmpty"
|
||||
class="relative top-25 h-full text-g-500 text-center !bg-transparent"
|
||||
>
|
||||
<ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" />
|
||||
<p class="mt-3.5 text-xs !bg-transparent"
|
||||
>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-show="currentTabIsEmpty"
|
||||
class="relative top-25 h-full text-g-500 text-center !bg-transparent"
|
||||
>
|
||||
<ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" />
|
||||
<p class="mt-3.5 text-xs !bg-transparent"
|
||||
>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative box-border w-full px-3.5">
|
||||
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
|
||||
{{ $t('notice.viewAll') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative box-border w-full px-3.5">
|
||||
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
|
||||
{{ $t('notice.viewAll') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-25"></div>
|
||||
</div>
|
||||
<div class="h-25"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
// 导入头像图片
|
||||
import avatar1 from '@/assets/images/avatar/avatar1.webp'
|
||||
import avatar2 from '@/assets/images/avatar/avatar2.webp'
|
||||
import avatar3 from '@/assets/images/avatar/avatar3.webp'
|
||||
import avatar4 from '@/assets/images/avatar/avatar4.webp'
|
||||
import avatar5 from '@/assets/images/avatar/avatar5.webp'
|
||||
import avatar6 from '@/assets/images/avatar/avatar6.webp'
|
||||
// 导入头像图片
|
||||
import avatar1 from '@/assets/images/avatar/avatar1.webp'
|
||||
import avatar2 from '@/assets/images/avatar/avatar2.webp'
|
||||
import avatar3 from '@/assets/images/avatar/avatar3.webp'
|
||||
import avatar4 from '@/assets/images/avatar/avatar4.webp'
|
||||
import avatar5 from '@/assets/images/avatar/avatar5.webp'
|
||||
import avatar6 from '@/assets/images/avatar/avatar6.webp'
|
||||
|
||||
defineOptions({ name: 'ArtNotification' })
|
||||
defineOptions({ name: 'ArtNotification' })
|
||||
|
||||
interface NoticeItem {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 类型 */
|
||||
type: NoticeType
|
||||
}
|
||||
interface NoticeItem {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 类型 */
|
||||
type: NoticeType
|
||||
}
|
||||
|
||||
interface MessageItem {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 头像 */
|
||||
avatar: string
|
||||
}
|
||||
interface MessageItem {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
/** 头像 */
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface PendingItem {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
}
|
||||
interface PendingItem {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 时间 */
|
||||
time: string
|
||||
}
|
||||
|
||||
interface BarItem {
|
||||
/** 名称 */
|
||||
name: ComputedRef<string>
|
||||
/** 数量 */
|
||||
num: number
|
||||
}
|
||||
interface BarItem {
|
||||
/** 名称 */
|
||||
name: ComputedRef<string>
|
||||
/** 数量 */
|
||||
num: number
|
||||
}
|
||||
|
||||
interface NoticeStyle {
|
||||
/** 图标 */
|
||||
icon: string
|
||||
/** icon 样式 */
|
||||
iconClass: string
|
||||
}
|
||||
interface NoticeStyle {
|
||||
/** 图标 */
|
||||
icon: string
|
||||
/** icon 样式 */
|
||||
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<{
|
||||
value: boolean
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
value: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: boolean]
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:value': [value: boolean]
|
||||
}>()
|
||||
|
||||
const show = ref(false)
|
||||
const visible = ref(false)
|
||||
const barActiveIndex = ref(0)
|
||||
const show = ref(false)
|
||||
const visible = ref(false)
|
||||
const barActiveIndex = ref(0)
|
||||
|
||||
const useNotificationData = () => {
|
||||
// 通知数据
|
||||
const noticeList = ref<NoticeItem[]>([
|
||||
{
|
||||
title: '新增国际化',
|
||||
time: '2024-6-13 0:10',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆给你发了一条消息',
|
||||
time: '2024-4-21 8:05',
|
||||
type: 'message'
|
||||
},
|
||||
{
|
||||
title: '小肥猪关注了你',
|
||||
time: '2020-3-17 21:12',
|
||||
type: 'collection'
|
||||
},
|
||||
{
|
||||
title: '新增使用文档',
|
||||
time: '2024-02-14 0:20',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '小肥猪给你发了一封邮件',
|
||||
time: '2024-1-20 0:15',
|
||||
type: 'email'
|
||||
},
|
||||
{
|
||||
title: '菜单mock本地真实数据',
|
||||
time: '2024-1-17 22:06',
|
||||
type: 'notice'
|
||||
}
|
||||
])
|
||||
const useNotificationData = () => {
|
||||
// 通知数据
|
||||
const noticeList = ref<NoticeItem[]>([
|
||||
{
|
||||
title: '新增国际化',
|
||||
time: '2024-6-13 0:10',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆给你发了一条消息',
|
||||
time: '2024-4-21 8:05',
|
||||
type: 'message'
|
||||
},
|
||||
{
|
||||
title: '小肥猪关注了你',
|
||||
time: '2020-3-17 21:12',
|
||||
type: 'collection'
|
||||
},
|
||||
{
|
||||
title: '新增使用文档',
|
||||
time: '2024-02-14 0:20',
|
||||
type: 'notice'
|
||||
},
|
||||
{
|
||||
title: '小肥猪给你发了一封邮件',
|
||||
time: '2024-1-20 0:15',
|
||||
type: 'email'
|
||||
},
|
||||
{
|
||||
title: '菜单mock本地真实数据',
|
||||
time: '2024-1-17 22:06',
|
||||
type: 'notice'
|
||||
}
|
||||
])
|
||||
|
||||
// 消息数据
|
||||
const msgList = ref<MessageItem[]>([
|
||||
{
|
||||
title: '池不胖 关注了你',
|
||||
time: '2021-2-26 23:50',
|
||||
avatar: avatar1
|
||||
},
|
||||
{
|
||||
title: '唐不苦 关注了你',
|
||||
time: '2021-2-21 8:05',
|
||||
avatar: avatar2
|
||||
},
|
||||
{
|
||||
title: '中小鱼 关注了你',
|
||||
time: '2020-1-17 21:12',
|
||||
avatar: avatar3
|
||||
},
|
||||
{
|
||||
title: '何小荷 关注了你',
|
||||
time: '2021-01-14 0:20',
|
||||
avatar: avatar4
|
||||
},
|
||||
{
|
||||
title: '誶誶淰 关注了你',
|
||||
time: '2020-12-20 0:15',
|
||||
avatar: avatar5
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆 关注了你',
|
||||
time: '2020-12-17 22:06',
|
||||
avatar: avatar6
|
||||
}
|
||||
])
|
||||
// 消息数据
|
||||
const msgList = ref<MessageItem[]>([
|
||||
{
|
||||
title: '池不胖 关注了你',
|
||||
time: '2021-2-26 23:50',
|
||||
avatar: avatar1
|
||||
},
|
||||
{
|
||||
title: '唐不苦 关注了你',
|
||||
time: '2021-2-21 8:05',
|
||||
avatar: avatar2
|
||||
},
|
||||
{
|
||||
title: '中小鱼 关注了你',
|
||||
time: '2020-1-17 21:12',
|
||||
avatar: avatar3
|
||||
},
|
||||
{
|
||||
title: '何小荷 关注了你',
|
||||
time: '2021-01-14 0:20',
|
||||
avatar: avatar4
|
||||
},
|
||||
{
|
||||
title: '誶誶淰 关注了你',
|
||||
time: '2020-12-20 0:15',
|
||||
avatar: avatar5
|
||||
},
|
||||
{
|
||||
title: '冷月呆呆 关注了你',
|
||||
time: '2020-12-17 22:06',
|
||||
avatar: avatar6
|
||||
}
|
||||
])
|
||||
|
||||
// 待办数据
|
||||
const pendingList = ref<PendingItem[]>([])
|
||||
// 待办数据
|
||||
const pendingList = ref<PendingItem[]>([])
|
||||
|
||||
// 标签栏数据
|
||||
const barList = computed<BarItem[]>(() => [
|
||||
{
|
||||
name: computed(() => t('notice.bar[0]')),
|
||||
num: noticeList.value.length
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[1]')),
|
||||
num: msgList.value.length
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[2]')),
|
||||
num: pendingList.value.length
|
||||
}
|
||||
])
|
||||
// 标签栏数据
|
||||
const barList = computed<BarItem[]>(() => [
|
||||
{
|
||||
name: computed(() => t('notice.bar[0]')),
|
||||
num: noticeList.value.length
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[1]')),
|
||||
num: msgList.value.length
|
||||
},
|
||||
{
|
||||
name: computed(() => t('notice.bar[2]')),
|
||||
num: pendingList.value.length
|
||||
}
|
||||
])
|
||||
|
||||
return {
|
||||
noticeList,
|
||||
msgList,
|
||||
pendingList,
|
||||
barList
|
||||
}
|
||||
}
|
||||
return {
|
||||
noticeList,
|
||||
msgList,
|
||||
pendingList,
|
||||
barList
|
||||
}
|
||||
}
|
||||
|
||||
// 样式管理
|
||||
const useNotificationStyles = () => {
|
||||
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
|
||||
email: {
|
||||
icon: 'ri:mail-line',
|
||||
iconClass: 'bg-warning/12 text-warning'
|
||||
},
|
||||
message: {
|
||||
icon: 'ri:volume-down-line',
|
||||
iconClass: 'bg-success/12 text-success'
|
||||
},
|
||||
collection: {
|
||||
icon: 'ri:heart-3-line',
|
||||
iconClass: 'bg-danger/12 text-danger'
|
||||
},
|
||||
user: {
|
||||
icon: 'ri:volume-down-line',
|
||||
iconClass: 'bg-info/12 text-info'
|
||||
},
|
||||
notice: {
|
||||
icon: 'ri:notification-3-line',
|
||||
iconClass: 'bg-theme/12 text-theme'
|
||||
}
|
||||
}
|
||||
// 样式管理
|
||||
const useNotificationStyles = () => {
|
||||
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
|
||||
email: {
|
||||
icon: 'ri:mail-line',
|
||||
iconClass: 'bg-warning/12 text-warning'
|
||||
},
|
||||
message: {
|
||||
icon: 'ri:volume-down-line',
|
||||
iconClass: 'bg-success/12 text-success'
|
||||
},
|
||||
collection: {
|
||||
icon: 'ri:heart-3-line',
|
||||
iconClass: 'bg-danger/12 text-danger'
|
||||
},
|
||||
user: {
|
||||
icon: 'ri:volume-down-line',
|
||||
iconClass: 'bg-info/12 text-info'
|
||||
},
|
||||
notice: {
|
||||
icon: 'ri:notification-3-line',
|
||||
iconClass: 'bg-theme/12 text-theme'
|
||||
}
|
||||
}
|
||||
|
||||
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
|
||||
const defaultStyle: NoticeStyle = {
|
||||
icon: 'ri:arrow-right-circle-line',
|
||||
iconClass: 'bg-theme/12 text-theme'
|
||||
}
|
||||
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
|
||||
const defaultStyle: NoticeStyle = {
|
||||
icon: 'ri:arrow-right-circle-line',
|
||||
iconClass: 'bg-theme/12 text-theme'
|
||||
}
|
||||
|
||||
return noticeStyleMap[type] || defaultStyle
|
||||
}
|
||||
return noticeStyleMap[type] || defaultStyle
|
||||
}
|
||||
|
||||
return {
|
||||
getNoticeStyle
|
||||
}
|
||||
}
|
||||
return {
|
||||
getNoticeStyle
|
||||
}
|
||||
}
|
||||
|
||||
// 动画管理
|
||||
const useNotificationAnimation = () => {
|
||||
const showNotice = (open: boolean) => {
|
||||
if (open) {
|
||||
visible.value = true
|
||||
setTimeout(() => {
|
||||
show.value = true
|
||||
}, 5)
|
||||
} else {
|
||||
show.value = false
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
// 动画管理
|
||||
const useNotificationAnimation = () => {
|
||||
const showNotice = (open: boolean) => {
|
||||
if (open) {
|
||||
visible.value = true
|
||||
setTimeout(() => {
|
||||
show.value = true
|
||||
}, 5)
|
||||
} else {
|
||||
show.value = false
|
||||
setTimeout(() => {
|
||||
visible.value = false
|
||||
}, 350)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showNotice
|
||||
}
|
||||
}
|
||||
return {
|
||||
showNotice
|
||||
}
|
||||
}
|
||||
|
||||
// 标签页管理
|
||||
const useTabManagement = (
|
||||
noticeList: Ref<NoticeItem[]>,
|
||||
msgList: Ref<MessageItem[]>,
|
||||
pendingList: Ref<PendingItem[]>,
|
||||
businessHandlers: {
|
||||
handleNoticeAll: () => void
|
||||
handleMsgAll: () => void
|
||||
handlePendingAll: () => void
|
||||
}
|
||||
) => {
|
||||
const changeBar = (index: number) => {
|
||||
barActiveIndex.value = index
|
||||
}
|
||||
// 标签页管理
|
||||
const useTabManagement = (
|
||||
noticeList: Ref<NoticeItem[]>,
|
||||
msgList: Ref<MessageItem[]>,
|
||||
pendingList: Ref<PendingItem[]>,
|
||||
businessHandlers: {
|
||||
handleNoticeAll: () => void
|
||||
handleMsgAll: () => void
|
||||
handlePendingAll: () => void
|
||||
}
|
||||
) => {
|
||||
const changeBar = (index: number) => {
|
||||
barActiveIndex.value = index
|
||||
}
|
||||
|
||||
// 检查当前标签页是否为空
|
||||
const currentTabIsEmpty = computed(() => {
|
||||
const tabDataMap = [noticeList.value, msgList.value, pendingList.value]
|
||||
// 检查当前标签页是否为空
|
||||
const currentTabIsEmpty = computed(() => {
|
||||
const tabDataMap = [noticeList.value, msgList.value, pendingList.value]
|
||||
|
||||
const currentData = tabDataMap[barActiveIndex.value]
|
||||
return currentData && currentData.length === 0
|
||||
})
|
||||
const currentData = tabDataMap[barActiveIndex.value]
|
||||
return currentData && currentData.length === 0
|
||||
})
|
||||
|
||||
const handleViewAll = () => {
|
||||
// 查看全部处理器映射
|
||||
const viewAllHandlers: Record<number, () => void> = {
|
||||
0: businessHandlers.handleNoticeAll,
|
||||
1: businessHandlers.handleMsgAll,
|
||||
2: businessHandlers.handlePendingAll
|
||||
}
|
||||
const handleViewAll = () => {
|
||||
// 查看全部处理器映射
|
||||
const viewAllHandlers: Record<number, () => void> = {
|
||||
0: businessHandlers.handleNoticeAll,
|
||||
1: businessHandlers.handleMsgAll,
|
||||
2: businessHandlers.handlePendingAll
|
||||
}
|
||||
|
||||
const handler = viewAllHandlers[barActiveIndex.value]
|
||||
handler?.()
|
||||
const handler = viewAllHandlers[barActiveIndex.value]
|
||||
handler?.()
|
||||
|
||||
// 关闭通知面板
|
||||
emit('update:value', false)
|
||||
}
|
||||
// 关闭通知面板
|
||||
emit('update:value', false)
|
||||
}
|
||||
|
||||
return {
|
||||
changeBar,
|
||||
currentTabIsEmpty,
|
||||
handleViewAll
|
||||
}
|
||||
}
|
||||
return {
|
||||
changeBar,
|
||||
currentTabIsEmpty,
|
||||
handleViewAll
|
||||
}
|
||||
}
|
||||
|
||||
// 业务逻辑处理
|
||||
const useBusinessLogic = () => {
|
||||
const handleNoticeAll = () => {
|
||||
// 处理查看全部通知
|
||||
console.log('查看全部通知')
|
||||
}
|
||||
// 业务逻辑处理
|
||||
const useBusinessLogic = () => {
|
||||
const handleNoticeAll = () => {
|
||||
// 处理查看全部通知
|
||||
console.log('查看全部通知')
|
||||
}
|
||||
|
||||
const handleMsgAll = () => {
|
||||
// 处理查看全部消息
|
||||
console.log('查看全部消息')
|
||||
}
|
||||
const handleMsgAll = () => {
|
||||
// 处理查看全部消息
|
||||
console.log('查看全部消息')
|
||||
}
|
||||
|
||||
const handlePendingAll = () => {
|
||||
// 处理查看全部待办
|
||||
console.log('查看全部待办')
|
||||
}
|
||||
const handlePendingAll = () => {
|
||||
// 处理查看全部待办
|
||||
console.log('查看全部待办')
|
||||
}
|
||||
|
||||
return {
|
||||
handleNoticeAll,
|
||||
handleMsgAll,
|
||||
handlePendingAll
|
||||
}
|
||||
}
|
||||
return {
|
||||
handleNoticeAll,
|
||||
handleMsgAll,
|
||||
handlePendingAll
|
||||
}
|
||||
}
|
||||
|
||||
// 组合所有逻辑
|
||||
const { noticeList, msgList, pendingList, barList } = useNotificationData()
|
||||
const { getNoticeStyle } = useNotificationStyles()
|
||||
const { showNotice } = useNotificationAnimation()
|
||||
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic()
|
||||
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
|
||||
noticeList,
|
||||
msgList,
|
||||
pendingList,
|
||||
{ handleNoticeAll, handleMsgAll, handlePendingAll }
|
||||
)
|
||||
// 组合所有逻辑
|
||||
const { noticeList, msgList, pendingList, barList } = useNotificationData()
|
||||
const { getNoticeStyle } = useNotificationStyles()
|
||||
const { showNotice } = useNotificationAnimation()
|
||||
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic()
|
||||
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
|
||||
noticeList,
|
||||
msgList,
|
||||
pendingList,
|
||||
{ handleNoticeAll, handleMsgAll, handlePendingAll }
|
||||
)
|
||||
|
||||
// 监听属性变化
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
showNotice(newValue)
|
||||
}
|
||||
)
|
||||
// 监听属性变化
|
||||
watch(
|
||||
() => props.value,
|
||||
(newValue) => {
|
||||
showNotice(newValue)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.art-notification-panel {
|
||||
@apply absolute
|
||||
.art-notification-panel {
|
||||
@apply absolute
|
||||
top-14.5
|
||||
right-5
|
||||
w-90
|
||||
@@ -435,22 +440,22 @@
|
||||
max-[640px]:right-0
|
||||
max-[640px]:w-full
|
||||
max-[640px]:h-[80vh];
|
||||
}
|
||||
}
|
||||
|
||||
.bar-active {
|
||||
color: var(--theme-color) !important;
|
||||
border-bottom: 2px solid var(--theme-color);
|
||||
}
|
||||
.bar-active {
|
||||
color: var(--theme-color) !important;
|
||||
border-bottom: 2px solid var(--theme-color);
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 5px !important;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: var(--default-box-color);
|
||||
}
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background-color: var(--default-box-color);
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,136 +1,136 @@
|
||||
<!-- 布局内容 -->
|
||||
<template>
|
||||
<div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle">
|
||||
<div id="app-content-header">
|
||||
<!-- 节日滚动 -->
|
||||
<ArtFestivalTextScroll v-if="!isFullPage" />
|
||||
<div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle">
|
||||
<div id="app-content-header">
|
||||
<!-- 节日滚动 -->
|
||||
<ArtFestivalTextScroll v-if="!isFullPage" />
|
||||
|
||||
<!-- 路由信息调试 -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
router meta:{{ route.meta }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 路由信息调试 -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
router meta:{{ route.meta }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
|
||||
<!-- 缓存路由动画 -->
|
||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
||||
<component
|
||||
class="art-page-view"
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
v-if="route.meta.keepAlive"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
|
||||
<!-- 缓存路由动画 -->
|
||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
||||
<component
|
||||
class="art-page-view"
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
v-if="route.meta.keepAlive"
|
||||
/>
|
||||
</KeepAlive>
|
||||
</Transition>
|
||||
|
||||
<!-- 非缓存路由动画 -->
|
||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||
<component
|
||||
class="art-page-view"
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
v-if="!route.meta.keepAlive"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
<!-- 非缓存路由动画 -->
|
||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||
<component
|
||||
class="art-page-view"
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
v-if="!route.meta.keepAlive"
|
||||
/>
|
||||
</Transition>
|
||||
</RouterView>
|
||||
|
||||
<!-- 全屏页面切换过渡遮罩(用于提升页面切换视觉体验) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="showTransitionMask"
|
||||
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
<!-- 全屏页面切换过渡遮罩(用于提升页面切换视觉体验) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="showTransitionMask"
|
||||
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
import type { CSSProperties } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useWorktabStore } from '@/store/modules/worktab'
|
||||
|
||||
defineOptions({ name: 'ArtPageContent' })
|
||||
defineOptions({ name: 'ArtPageContent' })
|
||||
|
||||
const route = useRoute()
|
||||
const { containerMinHeight } = useAutoLayoutHeight()
|
||||
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
|
||||
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
||||
const route = useRoute()
|
||||
const { containerMinHeight } = useAutoLayoutHeight()
|
||||
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
|
||||
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
||||
|
||||
const isRefresh = shallowRef(true)
|
||||
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
|
||||
const showTransitionMask = ref(false)
|
||||
const isRefresh = shallowRef(true)
|
||||
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
|
||||
const showTransitionMask = ref(false)
|
||||
|
||||
// 标记是否是首次加载(浏览器刷新)
|
||||
const isFirstLoad = ref(true)
|
||||
// 标记是否是首次加载(浏览器刷新)
|
||||
const isFirstLoad = ref(true)
|
||||
|
||||
// 检查当前路由是否需要使用无基础布局模式
|
||||
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage))
|
||||
const prevIsFullPage = ref(isFullPage.value)
|
||||
// 检查当前路由是否需要使用无基础布局模式
|
||||
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage))
|
||||
const prevIsFullPage = ref(isFullPage.value)
|
||||
|
||||
// 切换动画名称:首次加载、从全屏返回时不使用动画
|
||||
const actualTransition = computed(() => {
|
||||
if (isFirstLoad.value) return ''
|
||||
if (prevIsFullPage.value && !isFullPage.value) return ''
|
||||
return pageTransition.value
|
||||
})
|
||||
// 切换动画名称:首次加载、从全屏返回时不使用动画
|
||||
const actualTransition = computed(() => {
|
||||
if (isFirstLoad.value) return ''
|
||||
if (prevIsFullPage.value && !isFullPage.value) return ''
|
||||
return pageTransition.value
|
||||
})
|
||||
|
||||
// 监听全屏状态变化,显示过渡遮罩
|
||||
watch(isFullPage, (val, oldVal) => {
|
||||
if (val !== oldVal) {
|
||||
showTransitionMask.value = true
|
||||
// 延迟隐藏遮罩,给足时间让页面完成切换
|
||||
setTimeout(() => {
|
||||
showTransitionMask.value = false
|
||||
}, 50)
|
||||
}
|
||||
// 监听全屏状态变化,显示过渡遮罩
|
||||
watch(isFullPage, (val, oldVal) => {
|
||||
if (val !== oldVal) {
|
||||
showTransitionMask.value = true
|
||||
// 延迟隐藏遮罩,给足时间让页面完成切换
|
||||
setTimeout(() => {
|
||||
showTransitionMask.value = false
|
||||
}, 50)
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
prevIsFullPage.value = val
|
||||
})
|
||||
})
|
||||
nextTick(() => {
|
||||
prevIsFullPage.value = val
|
||||
})
|
||||
})
|
||||
|
||||
const containerStyle = computed(
|
||||
(): CSSProperties =>
|
||||
isFullPage.value
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
zIndex: 2500,
|
||||
background: 'var(--default-bg-color)'
|
||||
}
|
||||
: {
|
||||
maxWidth: containerWidth.value
|
||||
}
|
||||
)
|
||||
const containerStyle = computed(
|
||||
(): CSSProperties =>
|
||||
isFullPage.value
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
zIndex: 2500,
|
||||
background: 'var(--default-bg-color)'
|
||||
}
|
||||
: {
|
||||
maxWidth: containerWidth.value
|
||||
}
|
||||
)
|
||||
|
||||
const contentStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
minHeight: containerMinHeight.value
|
||||
})
|
||||
)
|
||||
const contentStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
minHeight: containerMinHeight.value
|
||||
})
|
||||
)
|
||||
|
||||
const reload = () => {
|
||||
isRefresh.value = false
|
||||
nextTick(() => {
|
||||
isRefresh.value = true
|
||||
})
|
||||
}
|
||||
const reload = () => {
|
||||
isRefresh.value = false
|
||||
nextTick(() => {
|
||||
isRefresh.value = true
|
||||
})
|
||||
}
|
||||
|
||||
watch(refresh, reload, { flush: 'post' })
|
||||
watch(refresh, reload, { flush: 'post' })
|
||||
|
||||
// 组件挂载后标记首次加载完成
|
||||
onMounted(() => {
|
||||
// 延迟一帧,确保首次渲染完成
|
||||
nextTick(() => {
|
||||
isFirstLoad.value = false
|
||||
})
|
||||
})
|
||||
// 组件挂载后标记首次加载完成
|
||||
onMounted(() => {
|
||||
// 延迟一帧,确保首次渲染完成
|
||||
nextTick(() => {
|
||||
isFirstLoad.value = false
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,519 +1,530 @@
|
||||
<!-- 锁屏 -->
|
||||
<template>
|
||||
<div class="layout-lock-screen">
|
||||
<!-- 开发者工具警告覆盖层 -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div class="p-5 text-center select-none">
|
||||
<div class="mb-7.5 text-5xl">🔒</div>
|
||||
<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">
|
||||
检测到开发者工具已打开<br />
|
||||
为了系统安全,请关闭开发者工具后继续使用
|
||||
</p>
|
||||
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layout-lock-screen">
|
||||
<!-- 开发者工具警告覆盖层 -->
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<div class="p-5 text-center select-none">
|
||||
<div class="mb-7.5 text-5xl">🔒</div>
|
||||
<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">
|
||||
检测到开发者工具已打开<br />
|
||||
为了系统安全,请关闭开发者工具后继续使用
|
||||
</p>
|
||||
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 锁屏弹窗 -->
|
||||
<div v-if="!isLock">
|
||||
<ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
|
||||
<div class="flex-c flex-col">
|
||||
<img class="w-16 h-16 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" />
|
||||
<div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
class="w-[90%]"
|
||||
@submit.prevent="handleLock"
|
||||
>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
:placeholder="$t('lockScreen.lock.inputPlaceholder')"
|
||||
:show-password="true"
|
||||
autocomplete="new-password"
|
||||
ref="lockInputRef"
|
||||
class="w-full mt-9"
|
||||
@keyup.enter="handleLock"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon class="c-p" @click="handleLock">
|
||||
<Lock />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple>
|
||||
{{ $t('lockScreen.lock.btnText') }}
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
<!-- 锁屏弹窗 -->
|
||||
<div v-if="!isLock">
|
||||
<ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
|
||||
<div class="flex-c flex-col">
|
||||
<img
|
||||
class="w-16 h-16 rounded-full"
|
||||
src="@imgs/user/avatar.webp"
|
||||
alt="用户头像"
|
||||
/>
|
||||
<div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div>
|
||||
<ElForm
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
class="w-[90%]"
|
||||
@submit.prevent="handleLock"
|
||||
>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
:placeholder="$t('lockScreen.lock.inputPlaceholder')"
|
||||
:show-password="true"
|
||||
autocomplete="new-password"
|
||||
ref="lockInputRef"
|
||||
class="w-full mt-9"
|
||||
@keyup.enter="handleLock"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon class="c-p" @click="handleLock">
|
||||
<Lock />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple>
|
||||
{{ $t('lockScreen.lock.btnText') }}
|
||||
</ElButton>
|
||||
</ElForm>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
|
||||
<!-- 解锁界面 -->
|
||||
<div v-else class="unlock-content">
|
||||
<div class="flex-c flex-col w-80">
|
||||
<img class="w-16 h-16 mt-5 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" />
|
||||
<div class="mt-3 mb-3.5 text-base font-medium">
|
||||
{{ userInfo.userName }}
|
||||
</div>
|
||||
<ElForm
|
||||
ref="unlockFormRef"
|
||||
:model="unlockForm"
|
||||
:rules="rules"
|
||||
class="w-full !px-2.5"
|
||||
@submit.prevent="handleUnlock"
|
||||
>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="unlockForm.password"
|
||||
type="password"
|
||||
:placeholder="$t('lockScreen.unlock.inputPlaceholder')"
|
||||
:show-password="true"
|
||||
autocomplete="new-password"
|
||||
ref="unlockInputRef"
|
||||
class="mt-5"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon class="c-p" @click="handleUnlock">
|
||||
<Unlock />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
<!-- 解锁界面 -->
|
||||
<div v-else class="unlock-content">
|
||||
<div class="flex-c flex-col w-80">
|
||||
<img
|
||||
class="w-16 h-16 mt-5 rounded-full"
|
||||
src="@imgs/user/avatar.webp"
|
||||
alt="用户头像"
|
||||
/>
|
||||
<div class="mt-3 mb-3.5 text-base font-medium">
|
||||
{{ userInfo.userName }}
|
||||
</div>
|
||||
<ElForm
|
||||
ref="unlockFormRef"
|
||||
:model="unlockForm"
|
||||
:rules="rules"
|
||||
class="w-full !px-2.5"
|
||||
@submit.prevent="handleUnlock"
|
||||
>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="unlockForm.password"
|
||||
type="password"
|
||||
:placeholder="$t('lockScreen.unlock.inputPlaceholder')"
|
||||
:show-password="true"
|
||||
autocomplete="new-password"
|
||||
ref="unlockInputRef"
|
||||
class="mt-5"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon class="c-p" @click="handleUnlock">
|
||||
<Unlock />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElFormItem>
|
||||
|
||||
<ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple>
|
||||
{{ $t('lockScreen.unlock.btnText') }}
|
||||
</ElButton>
|
||||
<div class="w-full text-center">
|
||||
<ElButton
|
||||
text
|
||||
class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent"
|
||||
@click="toLogin"
|
||||
>
|
||||
{{ $t('lockScreen.unlock.backBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple>
|
||||
{{ $t('lockScreen.unlock.btnText') }}
|
||||
</ElButton>
|
||||
<div class="w-full text-center">
|
||||
<ElButton
|
||||
text
|
||||
class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent"
|
||||
@click="toLogin"
|
||||
>
|
||||
{{ $t('lockScreen.unlock.backBtnText') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</ElForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Lock, Unlock } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { mittBus } from '@/utils/sys'
|
||||
import { Lock, Unlock } from '@element-plus/icons-vue'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
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
|
||||
const userStore = useUserStore()
|
||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
||||
// Store
|
||||
const userStore = useUserStore()
|
||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
||||
|
||||
// 响应式数据
|
||||
const visible = ref<boolean>(false)
|
||||
const lockInputRef = ref<any>(null)
|
||||
const unlockInputRef = ref<any>(null)
|
||||
const showDevToolsWarning = ref<boolean>(false)
|
||||
// 响应式数据
|
||||
const visible = ref<boolean>(false)
|
||||
const lockInputRef = ref<any>(null)
|
||||
const unlockInputRef = ref<any>(null)
|
||||
const showDevToolsWarning = ref<boolean>(false)
|
||||
|
||||
// 表单相关
|
||||
const formRef = ref<FormInstance>()
|
||||
const unlockFormRef = ref<FormInstance>()
|
||||
// 表单相关
|
||||
const formRef = ref<FormInstance>()
|
||||
const unlockFormRef = ref<FormInstance>()
|
||||
|
||||
const formData = reactive({
|
||||
password: ''
|
||||
})
|
||||
const formData = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
const unlockForm = reactive({
|
||||
password: ''
|
||||
})
|
||||
const unlockForm = reactive({
|
||||
password: ''
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules = computed<FormRules>(() => ({
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: t('lockScreen.lock.inputPlaceholder'),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}))
|
||||
// 表单验证规则
|
||||
const rules = computed<FormRules>(() => ({
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: t('lockScreen.lock.inputPlaceholder'),
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
// 检测是否为移动设备
|
||||
const isMobile = () => {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
)
|
||||
}
|
||||
// 检测是否为移动设备
|
||||
const isMobile = () => {
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
)
|
||||
}
|
||||
|
||||
// 添加禁用控制台的函数
|
||||
const disableDevTools = () => {
|
||||
// 禁用右键菜单
|
||||
const handleContextMenu = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('contextmenu', handleContextMenu, true)
|
||||
// 添加禁用控制台的函数
|
||||
const disableDevTools = () => {
|
||||
// 禁用右键菜单
|
||||
const handleContextMenu = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('contextmenu', handleContextMenu, true)
|
||||
|
||||
// 禁用开发者工具相关快捷键
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isLock.value) return
|
||||
// 禁用开发者工具相关快捷键
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isLock.value) return
|
||||
|
||||
// 禁用 F12
|
||||
if (e.key === 'F12') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 F12
|
||||
if (e.key === 'F12') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const key = e.key.toLowerCase()
|
||||
if (['i', 'j', 'c', 'k'].includes(key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
|
||||
if (e.ctrlKey && e.shiftKey) {
|
||||
const key = e.key.toLowerCase()
|
||||
if (['i', 'j', 'c', 'k'].includes(key)) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+U (查看源代码)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'u') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+U (查看源代码)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'u') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+S (保存页面)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+S (保存页面)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+A (全选)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'a') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+A (全选)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'a') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+P (打印)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+P (打印)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+F (查找)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+F (查找)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Alt+Tab (切换窗口)
|
||||
if (e.altKey && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Alt+Tab (切换窗口)
|
||||
if (e.altKey && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+Tab (切换标签页)
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+Tab (切换标签页)
|
||||
if (e.ctrlKey && e.key === 'Tab') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+W (关闭标签页)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'w') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+W (关闭标签页)
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'w') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+R 和 F5 (刷新页面)
|
||||
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
// 禁用 Ctrl+R 和 F5 (刷新页面)
|
||||
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
|
||||
// 禁用 Ctrl+Shift+R (强制刷新)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
// 禁用 Ctrl+Shift+R (强制刷新)
|
||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
|
||||
// 禁用选择文本
|
||||
const handleSelectStart = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('selectstart', handleSelectStart, true)
|
||||
// 禁用选择文本
|
||||
const handleSelectStart = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('selectstart', handleSelectStart, true)
|
||||
|
||||
// 禁用拖拽
|
||||
const handleDragStart = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('dragstart', handleDragStart, true)
|
||||
// 禁用拖拽
|
||||
const handleDragStart = (e: Event) => {
|
||||
if (isLock.value) {
|
||||
e.preventDefault()
|
||||
return false
|
||||
}
|
||||
}
|
||||
document.addEventListener('dragstart', handleDragStart, true)
|
||||
|
||||
// 监听开发者工具打开状态(仅在桌面端启用)
|
||||
let devtools = { open: false }
|
||||
const threshold = 160
|
||||
let devToolsInterval: ReturnType<typeof setInterval> | null = null
|
||||
// 监听开发者工具打开状态(仅在桌面端启用)
|
||||
let devtools = { open: false }
|
||||
const threshold = 160
|
||||
let devToolsInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const checkDevTools = () => {
|
||||
if (!isLock.value || isMobile()) return
|
||||
const checkDevTools = () => {
|
||||
if (!isLock.value || isMobile()) return
|
||||
|
||||
const isDevToolsOpen =
|
||||
window.outerHeight - window.innerHeight > threshold ||
|
||||
window.outerWidth - window.innerWidth > threshold
|
||||
const isDevToolsOpen =
|
||||
window.outerHeight - window.innerHeight > threshold ||
|
||||
window.outerWidth - window.innerWidth > threshold
|
||||
|
||||
if (isDevToolsOpen && !devtools.open) {
|
||||
devtools.open = true
|
||||
showDevToolsWarning.value = true
|
||||
} else if (!isDevToolsOpen && devtools.open) {
|
||||
devtools.open = false
|
||||
showDevToolsWarning.value = false
|
||||
}
|
||||
}
|
||||
if (isDevToolsOpen && !devtools.open) {
|
||||
devtools.open = true
|
||||
showDevToolsWarning.value = true
|
||||
} else if (!isDevToolsOpen && devtools.open) {
|
||||
devtools.open = false
|
||||
showDevToolsWarning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 仅在桌面端启用开发者工具检测
|
||||
if (!isMobile()) {
|
||||
devToolsInterval = setInterval(checkDevTools, 500)
|
||||
}
|
||||
// 仅在桌面端启用开发者工具检测
|
||||
if (!isMobile()) {
|
||||
devToolsInterval = setInterval(checkDevTools, 500)
|
||||
}
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu, true)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('selectstart', handleSelectStart, true)
|
||||
document.removeEventListener('dragstart', handleDragStart, true)
|
||||
if (devToolsInterval) {
|
||||
clearInterval(devToolsInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu, true)
|
||||
document.removeEventListener('keydown', handleKeyDown, true)
|
||||
document.removeEventListener('selectstart', handleSelectStart, true)
|
||||
document.removeEventListener('dragstart', handleDragStart, true)
|
||||
if (devToolsInterval) {
|
||||
clearInterval(devToolsInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
|
||||
try {
|
||||
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
|
||||
CryptoJS.enc.Utf8
|
||||
)
|
||||
return inputPassword === decryptedPassword
|
||||
} catch (error) {
|
||||
console.error('密码解密失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// 工具函数
|
||||
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
|
||||
try {
|
||||
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
|
||||
CryptoJS.enc.Utf8
|
||||
)
|
||||
return inputPassword === decryptedPassword
|
||||
} catch (error) {
|
||||
console.error('密码解密失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 事件处理函数
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.altKey && event.key.toLowerCase() === '¬') {
|
||||
event.preventDefault()
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
// 事件处理函数
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.altKey && event.key.toLowerCase() === '¬') {
|
||||
event.preventDefault()
|
||||
visible.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleDialogOpen = () => {
|
||||
setTimeout(() => {
|
||||
lockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
}
|
||||
const handleDialogOpen = () => {
|
||||
setTimeout(() => {
|
||||
lockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleLock = async () => {
|
||||
if (!formRef.value) return
|
||||
const handleLock = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
await formRef.value.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString()
|
||||
userStore.setLockStatus(true)
|
||||
userStore.setLockPassword(encryptedPassword)
|
||||
visible.value = false
|
||||
formData.password = ''
|
||||
} else {
|
||||
console.error('表单验证失败:', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
await formRef.value.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const encryptedPassword = CryptoJS.AES.encrypt(
|
||||
formData.password,
|
||||
ENCRYPT_KEY
|
||||
).toString()
|
||||
userStore.setLockStatus(true)
|
||||
userStore.setLockPassword(encryptedPassword)
|
||||
visible.value = false
|
||||
formData.password = ''
|
||||
} else {
|
||||
console.error('表单验证失败:', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUnlock = async () => {
|
||||
if (!unlockFormRef.value) return
|
||||
const handleUnlock = async () => {
|
||||
if (!unlockFormRef.value) return
|
||||
|
||||
await unlockFormRef.value.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const isValid = verifyPassword(unlockForm.password, lockPassword.value)
|
||||
await unlockFormRef.value.validate((valid, fields) => {
|
||||
if (valid) {
|
||||
const isValid = verifyPassword(unlockForm.password, lockPassword.value)
|
||||
|
||||
if (isValid) {
|
||||
try {
|
||||
userStore.setLockStatus(false)
|
||||
userStore.setLockPassword('')
|
||||
unlockForm.password = ''
|
||||
visible.value = false
|
||||
showDevToolsWarning.value = false
|
||||
} catch (error) {
|
||||
console.error('更新store失败:', error)
|
||||
}
|
||||
} else {
|
||||
// 触发抖动动画
|
||||
const inputElement = unlockInputRef.value?.$el
|
||||
if (inputElement) {
|
||||
inputElement.classList.add('shake-animation')
|
||||
setTimeout(() => {
|
||||
inputElement.classList.remove('shake-animation')
|
||||
}, 300)
|
||||
}
|
||||
ElMessage.error(t('lockScreen.pwdError'))
|
||||
unlockForm.password = ''
|
||||
}
|
||||
} else {
|
||||
console.error('表单验证失败:', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
if (isValid) {
|
||||
try {
|
||||
userStore.setLockStatus(false)
|
||||
userStore.setLockPassword('')
|
||||
unlockForm.password = ''
|
||||
visible.value = false
|
||||
showDevToolsWarning.value = false
|
||||
} catch (error) {
|
||||
console.error('更新store失败:', error)
|
||||
}
|
||||
} else {
|
||||
// 触发抖动动画
|
||||
const inputElement = unlockInputRef.value?.$el
|
||||
if (inputElement) {
|
||||
inputElement.classList.add('shake-animation')
|
||||
setTimeout(() => {
|
||||
inputElement.classList.remove('shake-animation')
|
||||
}, 300)
|
||||
}
|
||||
ElMessage.error(t('lockScreen.pwdError'))
|
||||
unlockForm.password = ''
|
||||
}
|
||||
} else {
|
||||
console.error('表单验证失败:', fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const toLogin = () => {
|
||||
userStore.logOut()
|
||||
}
|
||||
const toLogin = () => {
|
||||
userStore.logOut()
|
||||
}
|
||||
|
||||
const openLockScreen = () => {
|
||||
visible.value = true
|
||||
}
|
||||
const openLockScreen = () => {
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
// 监听锁屏状态变化
|
||||
watch(isLock, (newValue) => {
|
||||
if (newValue) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
setTimeout(() => {
|
||||
unlockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
} else {
|
||||
document.body.style.overflow = 'auto'
|
||||
showDevToolsWarning.value = false
|
||||
}
|
||||
})
|
||||
// 监听锁屏状态变化
|
||||
watch(isLock, (newValue) => {
|
||||
if (newValue) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
setTimeout(() => {
|
||||
unlockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
} else {
|
||||
document.body.style.overflow = 'auto'
|
||||
showDevToolsWarning.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 存储清理函数
|
||||
let cleanupDevTools: (() => void) | null = null
|
||||
// 存储清理函数
|
||||
let cleanupDevTools: (() => void) | null = null
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
mittBus.on('openLockScreen', openLockScreen)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
mittBus.on('openLockScreen', openLockScreen)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isLock.value) {
|
||||
visible.value = true
|
||||
setTimeout(() => {
|
||||
unlockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
}
|
||||
if (isLock.value) {
|
||||
visible.value = true
|
||||
setTimeout(() => {
|
||||
unlockInputRef.value?.input?.focus()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 初始化禁用开发者工具功能
|
||||
cleanupDevTools = disableDevTools()
|
||||
})
|
||||
// 初始化禁用开发者工具功能
|
||||
cleanupDevTools = disableDevTools()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.style.overflow = 'auto'
|
||||
// 清理禁用开发者工具的事件监听器
|
||||
if (cleanupDevTools) {
|
||||
cleanupDevTools()
|
||||
cleanupDevTools = null
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
document.body.style.overflow = 'auto'
|
||||
// 清理禁用开发者工具的事件监听器
|
||||
if (cleanupDevTools) {
|
||||
cleanupDevTools()
|
||||
cleanupDevTools = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.layout-lock-screen :deep(.el-dialog) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.layout-lock-screen :deep(.el-dialog) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.unlock-content {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
background-image: url('@imgs/lock/bg_light.webp');
|
||||
background-size: cover;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
.unlock-content {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: #fff;
|
||||
background-image: url('@imgs/lock/bg_light.webp');
|
||||
background-size: cover;
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.dark {
|
||||
.unlock-content {
|
||||
background-image: url('@imgs/lock/bg_dark.webp');
|
||||
}
|
||||
}
|
||||
.dark {
|
||||
.unlock-content {
|
||||
background-image: url('@imgs/lock/bg_dark.webp');
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-in-out;
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fade-in 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.shake-animation {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
.shake-animation {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,241 +8,241 @@ import { headerBarConfig } from '@/config/modules/headerBar'
|
||||
* 设置项配置选项管理
|
||||
*/
|
||||
export function useSettingsConfig() {
|
||||
const { t } = useI18n()
|
||||
const { t } = useI18n()
|
||||
|
||||
// 标签页风格选项
|
||||
const tabStyleOptions = computed(() => [
|
||||
{
|
||||
value: 'tab-default',
|
||||
label: t('setting.tabStyle.default')
|
||||
},
|
||||
{
|
||||
value: 'tab-card',
|
||||
label: t('setting.tabStyle.card')
|
||||
},
|
||||
{
|
||||
value: 'tab-google',
|
||||
label: t('setting.tabStyle.google')
|
||||
}
|
||||
])
|
||||
// 标签页风格选项
|
||||
const tabStyleOptions = computed(() => [
|
||||
{
|
||||
value: 'tab-default',
|
||||
label: t('setting.tabStyle.default')
|
||||
},
|
||||
{
|
||||
value: 'tab-card',
|
||||
label: t('setting.tabStyle.card')
|
||||
},
|
||||
{
|
||||
value: 'tab-google',
|
||||
label: t('setting.tabStyle.google')
|
||||
}
|
||||
])
|
||||
|
||||
// 页面切换动画选项
|
||||
const pageTransitionOptions = computed(() => [
|
||||
{
|
||||
value: '',
|
||||
label: t('setting.transition.list.none')
|
||||
},
|
||||
{
|
||||
value: 'fade',
|
||||
label: t('setting.transition.list.fade')
|
||||
},
|
||||
{
|
||||
value: 'slide-left',
|
||||
label: t('setting.transition.list.slideLeft')
|
||||
},
|
||||
{
|
||||
value: 'slide-bottom',
|
||||
label: t('setting.transition.list.slideBottom')
|
||||
},
|
||||
{
|
||||
value: 'slide-top',
|
||||
label: t('setting.transition.list.slideTop')
|
||||
}
|
||||
])
|
||||
// 页面切换动画选项
|
||||
const pageTransitionOptions = computed(() => [
|
||||
{
|
||||
value: '',
|
||||
label: t('setting.transition.list.none')
|
||||
},
|
||||
{
|
||||
value: 'fade',
|
||||
label: t('setting.transition.list.fade')
|
||||
},
|
||||
{
|
||||
value: 'slide-left',
|
||||
label: t('setting.transition.list.slideLeft')
|
||||
},
|
||||
{
|
||||
value: 'slide-bottom',
|
||||
label: t('setting.transition.list.slideBottom')
|
||||
},
|
||||
{
|
||||
value: 'slide-top',
|
||||
label: t('setting.transition.list.slideTop')
|
||||
}
|
||||
])
|
||||
|
||||
// 圆角大小选项
|
||||
const customRadiusOptions = [
|
||||
{ value: '0', label: '0' },
|
||||
{ value: '0.25', label: '0.25' },
|
||||
{ value: '0.5', label: '0.5' },
|
||||
{ value: '0.75', label: '0.75' },
|
||||
{ value: '1', label: '1' }
|
||||
]
|
||||
// 圆角大小选项
|
||||
const customRadiusOptions = [
|
||||
{ value: '0', label: '0' },
|
||||
{ value: '0.25', label: '0.25' },
|
||||
{ value: '0.5', label: '0.5' },
|
||||
{ value: '0.75', label: '0.75' },
|
||||
{ value: '1', label: '1' }
|
||||
]
|
||||
|
||||
// 容器宽度选项
|
||||
const containerWidthOptions = computed(() => [
|
||||
{
|
||||
value: ContainerWidthEnum.FULL,
|
||||
label: t('setting.container.list[0]'),
|
||||
icon: 'icon-park-outline:auto-width'
|
||||
},
|
||||
{
|
||||
value: ContainerWidthEnum.BOXED,
|
||||
label: t('setting.container.list[1]'),
|
||||
icon: 'ix:width'
|
||||
}
|
||||
])
|
||||
// 容器宽度选项
|
||||
const containerWidthOptions = computed(() => [
|
||||
{
|
||||
value: ContainerWidthEnum.FULL,
|
||||
label: t('setting.container.list[0]'),
|
||||
icon: 'icon-park-outline:auto-width'
|
||||
},
|
||||
{
|
||||
value: ContainerWidthEnum.BOXED,
|
||||
label: t('setting.container.list[1]'),
|
||||
icon: 'ix:width'
|
||||
}
|
||||
])
|
||||
|
||||
// 盒子样式选项
|
||||
const boxStyleOptions = computed(() => [
|
||||
{
|
||||
value: 'border-mode',
|
||||
label: t('setting.box.list[0]'),
|
||||
type: 'border-mode' as const
|
||||
},
|
||||
{
|
||||
value: 'shadow-mode',
|
||||
label: t('setting.box.list[1]'),
|
||||
type: 'shadow-mode' as const
|
||||
}
|
||||
])
|
||||
// 盒子样式选项
|
||||
const boxStyleOptions = computed(() => [
|
||||
{
|
||||
value: 'border-mode',
|
||||
label: t('setting.box.list[0]'),
|
||||
type: 'border-mode' as const
|
||||
},
|
||||
{
|
||||
value: 'shadow-mode',
|
||||
label: t('setting.box.list[1]'),
|
||||
type: 'shadow-mode' as const
|
||||
}
|
||||
])
|
||||
|
||||
// 从配置文件获取的选项
|
||||
const configOptions = {
|
||||
// 主题色彩选项
|
||||
mainColors: AppConfig.systemMainColor,
|
||||
// 从配置文件获取的选项
|
||||
const configOptions = {
|
||||
// 主题色彩选项
|
||||
mainColors: AppConfig.systemMainColor,
|
||||
|
||||
// 主题风格选项
|
||||
themeList: AppConfig.settingThemeList,
|
||||
// 主题风格选项
|
||||
themeList: AppConfig.settingThemeList,
|
||||
|
||||
// 菜单布局选项
|
||||
menuLayoutList: AppConfig.menuLayoutList
|
||||
}
|
||||
// 菜单布局选项
|
||||
menuLayoutList: AppConfig.menuLayoutList
|
||||
}
|
||||
|
||||
// 基础设置项配置
|
||||
const basicSettingsConfig = computed(() => {
|
||||
// 定义所有基础设置项
|
||||
const allSettings = [
|
||||
{
|
||||
key: 'showWorkTab',
|
||||
label: t('setting.basics.list.multiTab'),
|
||||
type: 'switch' as const,
|
||||
handler: 'workTab',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'uniqueOpened',
|
||||
label: t('setting.basics.list.accordion'),
|
||||
type: 'switch' as const,
|
||||
handler: 'uniqueOpened',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'showMenuButton',
|
||||
label: t('setting.basics.list.collapseSidebar'),
|
||||
type: 'switch' as const,
|
||||
handler: 'menuButton',
|
||||
headerBarKey: 'menuButton' as const
|
||||
},
|
||||
{
|
||||
key: 'showFastEnter',
|
||||
label: t('setting.basics.list.fastEnter'),
|
||||
type: 'switch' as const,
|
||||
handler: 'fastEnter',
|
||||
headerBarKey: 'fastEnter' as const
|
||||
},
|
||||
{
|
||||
key: 'showRefreshButton',
|
||||
label: t('setting.basics.list.reloadPage'),
|
||||
type: 'switch' as const,
|
||||
handler: 'refreshButton',
|
||||
headerBarKey: 'refreshButton' as const
|
||||
},
|
||||
{
|
||||
key: 'showCrumbs',
|
||||
label: t('setting.basics.list.breadcrumb'),
|
||||
type: 'switch' as const,
|
||||
handler: 'crumbs',
|
||||
mobileHide: true,
|
||||
headerBarKey: 'breadcrumb' as const
|
||||
},
|
||||
{
|
||||
key: 'showLanguage',
|
||||
label: t('setting.basics.list.language'),
|
||||
type: 'switch' as const,
|
||||
handler: 'language',
|
||||
headerBarKey: 'language' as const
|
||||
},
|
||||
{
|
||||
key: 'showNprogress',
|
||||
label: t('setting.basics.list.progressBar'),
|
||||
type: 'switch' as const,
|
||||
handler: 'nprogress',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'colorWeak',
|
||||
label: t('setting.basics.list.weakMode'),
|
||||
type: 'switch' as const,
|
||||
handler: 'colorWeak',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'watermarkVisible',
|
||||
label: t('setting.basics.list.watermark'),
|
||||
type: 'switch' as const,
|
||||
handler: 'watermark',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'menuOpenWidth',
|
||||
label: t('setting.basics.list.menuWidth'),
|
||||
type: 'input-number' as const,
|
||||
handler: 'menuOpenWidth',
|
||||
min: 180,
|
||||
max: 320,
|
||||
step: 10,
|
||||
style: { width: '120px' },
|
||||
controlsPosition: 'right' as const,
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'tabStyle',
|
||||
label: t('setting.basics.list.tabStyle'),
|
||||
type: 'select' as const,
|
||||
handler: 'tabStyle',
|
||||
options: tabStyleOptions.value,
|
||||
style: { width: '120px' },
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'pageTransition',
|
||||
label: t('setting.basics.list.pageTransition'),
|
||||
type: 'select' as const,
|
||||
handler: 'pageTransition',
|
||||
options: pageTransitionOptions.value,
|
||||
style: { width: '120px' },
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'customRadius',
|
||||
label: t('setting.basics.list.borderRadius'),
|
||||
type: 'select' as const,
|
||||
handler: 'customRadius',
|
||||
options: customRadiusOptions,
|
||||
style: { width: '120px' },
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
}
|
||||
]
|
||||
// 基础设置项配置
|
||||
const basicSettingsConfig = computed(() => {
|
||||
// 定义所有基础设置项
|
||||
const allSettings = [
|
||||
{
|
||||
key: 'showWorkTab',
|
||||
label: t('setting.basics.list.multiTab'),
|
||||
type: 'switch' as const,
|
||||
handler: 'workTab',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'uniqueOpened',
|
||||
label: t('setting.basics.list.accordion'),
|
||||
type: 'switch' as const,
|
||||
handler: 'uniqueOpened',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'showMenuButton',
|
||||
label: t('setting.basics.list.collapseSidebar'),
|
||||
type: 'switch' as const,
|
||||
handler: 'menuButton',
|
||||
headerBarKey: 'menuButton' as const
|
||||
},
|
||||
{
|
||||
key: 'showFastEnter',
|
||||
label: t('setting.basics.list.fastEnter'),
|
||||
type: 'switch' as const,
|
||||
handler: 'fastEnter',
|
||||
headerBarKey: 'fastEnter' as const
|
||||
},
|
||||
{
|
||||
key: 'showRefreshButton',
|
||||
label: t('setting.basics.list.reloadPage'),
|
||||
type: 'switch' as const,
|
||||
handler: 'refreshButton',
|
||||
headerBarKey: 'refreshButton' as const
|
||||
},
|
||||
{
|
||||
key: 'showCrumbs',
|
||||
label: t('setting.basics.list.breadcrumb'),
|
||||
type: 'switch' as const,
|
||||
handler: 'crumbs',
|
||||
mobileHide: true,
|
||||
headerBarKey: 'breadcrumb' as const
|
||||
},
|
||||
{
|
||||
key: 'showLanguage',
|
||||
label: t('setting.basics.list.language'),
|
||||
type: 'switch' as const,
|
||||
handler: 'language',
|
||||
headerBarKey: 'language' as const
|
||||
},
|
||||
{
|
||||
key: 'showNprogress',
|
||||
label: t('setting.basics.list.progressBar'),
|
||||
type: 'switch' as const,
|
||||
handler: 'nprogress',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'colorWeak',
|
||||
label: t('setting.basics.list.weakMode'),
|
||||
type: 'switch' as const,
|
||||
handler: 'colorWeak',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'watermarkVisible',
|
||||
label: t('setting.basics.list.watermark'),
|
||||
type: 'switch' as const,
|
||||
handler: 'watermark',
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'menuOpenWidth',
|
||||
label: t('setting.basics.list.menuWidth'),
|
||||
type: 'input-number' as const,
|
||||
handler: 'menuOpenWidth',
|
||||
min: 180,
|
||||
max: 320,
|
||||
step: 10,
|
||||
style: { width: '120px' },
|
||||
controlsPosition: 'right' as const,
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'tabStyle',
|
||||
label: t('setting.basics.list.tabStyle'),
|
||||
type: 'select' as const,
|
||||
handler: 'tabStyle',
|
||||
options: tabStyleOptions.value,
|
||||
style: { width: '120px' },
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'pageTransition',
|
||||
label: t('setting.basics.list.pageTransition'),
|
||||
type: 'select' as const,
|
||||
handler: 'pageTransition',
|
||||
options: pageTransitionOptions.value,
|
||||
style: { width: '120px' },
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
},
|
||||
{
|
||||
key: 'customRadius',
|
||||
label: t('setting.basics.list.borderRadius'),
|
||||
type: 'select' as const,
|
||||
handler: 'customRadius',
|
||||
options: customRadiusOptions,
|
||||
style: { width: '120px' },
|
||||
headerBarKey: null // 不依赖headerBar配置
|
||||
}
|
||||
]
|
||||
|
||||
// 根据 headerBarConfig 过滤设置项
|
||||
return (
|
||||
allSettings
|
||||
.filter((setting) => {
|
||||
// 如果设置项不依赖headerBar配置,则始终显示
|
||||
if (setting.headerBarKey === null) {
|
||||
return true
|
||||
}
|
||||
// 根据 headerBarConfig 过滤设置项
|
||||
return (
|
||||
allSettings
|
||||
.filter((setting) => {
|
||||
// 如果设置项不依赖headerBar配置,则始终显示
|
||||
if (setting.headerBarKey === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 如果依赖headerBar配置,检查对应的功能是否启用
|
||||
const headerBarFeature = headerBarConfig[setting.headerBarKey]
|
||||
return headerBarFeature?.enabled !== false
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
|
||||
)
|
||||
})
|
||||
// 如果依赖headerBar配置,检查对应的功能是否启用
|
||||
const headerBarFeature = headerBarConfig[setting.headerBarKey]
|
||||
return headerBarFeature?.enabled !== false
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
// 选项配置
|
||||
tabStyleOptions,
|
||||
pageTransitionOptions,
|
||||
customRadiusOptions,
|
||||
containerWidthOptions,
|
||||
boxStyleOptions,
|
||||
configOptions,
|
||||
return {
|
||||
// 选项配置
|
||||
tabStyleOptions,
|
||||
pageTransitionOptions,
|
||||
customRadiusOptions,
|
||||
containerWidthOptions,
|
||||
boxStyleOptions,
|
||||
configOptions,
|
||||
|
||||
// 设置项配置
|
||||
basicSettingsConfig
|
||||
}
|
||||
// 设置项配置
|
||||
basicSettingsConfig
|
||||
}
|
||||
}
|
||||
|
||||
+133
-133
@@ -6,162 +6,162 @@ import type { ContainerWidthEnum } from '@/enums/appEnum'
|
||||
* 设置项通用处理逻辑
|
||||
*/
|
||||
export function useSettingsHandlers() {
|
||||
const settingStore = useSettingStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// DOM 操作相关
|
||||
const domOperations = {
|
||||
// 设置HTML类名
|
||||
setHtmlClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName('html')[0]
|
||||
if (add) {
|
||||
el.classList.add(className)
|
||||
} else {
|
||||
el.classList.remove(className)
|
||||
}
|
||||
},
|
||||
// DOM 操作相关
|
||||
const domOperations = {
|
||||
// 设置HTML类名
|
||||
setHtmlClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName('html')[0]
|
||||
if (add) {
|
||||
el.classList.add(className)
|
||||
} else {
|
||||
el.classList.remove(className)
|
||||
}
|
||||
},
|
||||
|
||||
// 设置根元素属性
|
||||
setRootAttribute: (attribute: string, value: string) => {
|
||||
const el = document.documentElement
|
||||
el.setAttribute(attribute, value)
|
||||
},
|
||||
// 设置根元素属性
|
||||
setRootAttribute: (attribute: string, value: string) => {
|
||||
const el = document.documentElement
|
||||
el.setAttribute(attribute, value)
|
||||
},
|
||||
|
||||
// 设置body类名
|
||||
setBodyClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName('body')[0]
|
||||
if (add) {
|
||||
el.classList.add(className)
|
||||
} else {
|
||||
el.classList.remove(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 设置body类名
|
||||
setBodyClass: (className: string, add: boolean) => {
|
||||
const el = document.getElementsByTagName('body')[0]
|
||||
if (add) {
|
||||
el.classList.add(className)
|
||||
} else {
|
||||
el.classList.remove(className)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 通用切换处理器
|
||||
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
|
||||
return () => {
|
||||
storeMethod()
|
||||
callback?.()
|
||||
}
|
||||
}
|
||||
// 通用切换处理器
|
||||
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
|
||||
return () => {
|
||||
storeMethod()
|
||||
callback?.()
|
||||
}
|
||||
}
|
||||
|
||||
// 通用值变更处理器
|
||||
const createValueHandler = <T>(
|
||||
storeMethod: (value: T) => void,
|
||||
callback?: (value: T) => void
|
||||
) => {
|
||||
return (value: T) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
storeMethod(value)
|
||||
callback?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 通用值变更处理器
|
||||
const createValueHandler = <T>(
|
||||
storeMethod: (value: T) => void,
|
||||
callback?: (value: T) => void
|
||||
) => {
|
||||
return (value: T) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
storeMethod(value)
|
||||
callback?.(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 基础设置处理器
|
||||
const basicHandlers = {
|
||||
// 工作台标签页
|
||||
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
|
||||
// 基础设置处理器
|
||||
const basicHandlers = {
|
||||
// 工作台标签页
|
||||
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(
|
||||
() => settingStore.setColorWeak(),
|
||||
() => {
|
||||
domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
|
||||
}
|
||||
),
|
||||
// 色弱模式
|
||||
colorWeak: createToggleHandler(
|
||||
() => settingStore.setColorWeak(),
|
||||
() => {
|
||||
domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
|
||||
}
|
||||
),
|
||||
|
||||
// 水印显示
|
||||
watermark: createToggleHandler(() =>
|
||||
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
|
||||
),
|
||||
// 水印显示
|
||||
watermark: createToggleHandler(() =>
|
||||
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
|
||||
),
|
||||
|
||||
// 菜单展开宽度
|
||||
menuOpenWidth: createValueHandler<number>((width: number) =>
|
||||
settingStore.setMenuOpenWidth(width)
|
||||
),
|
||||
// 菜单展开宽度
|
||||
menuOpenWidth: createValueHandler<number>((width: number) =>
|
||||
settingStore.setMenuOpenWidth(width)
|
||||
),
|
||||
|
||||
// 标签页风格
|
||||
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
|
||||
// 标签页风格
|
||||
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
|
||||
|
||||
// 页面切换动画
|
||||
pageTransition: createValueHandler<string>((transition: string) =>
|
||||
settingStore.setPageTransition(transition)
|
||||
),
|
||||
// 页面切换动画
|
||||
pageTransition: createValueHandler<string>((transition: string) =>
|
||||
settingStore.setPageTransition(transition)
|
||||
),
|
||||
|
||||
// 圆角大小
|
||||
customRadius: createValueHandler<string>((radius: string) =>
|
||||
settingStore.setCustomRadius(radius)
|
||||
)
|
||||
}
|
||||
// 圆角大小
|
||||
customRadius: createValueHandler<string>((radius: string) =>
|
||||
settingStore.setCustomRadius(radius)
|
||||
)
|
||||
}
|
||||
|
||||
// 盒子样式处理器
|
||||
const boxStyleHandlers = {
|
||||
// 设置盒子模式
|
||||
setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
|
||||
const { boxBorderMode } = storeToRefs(settingStore)
|
||||
// 盒子样式处理器
|
||||
const boxStyleHandlers = {
|
||||
// 设置盒子模式
|
||||
setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
|
||||
const { boxBorderMode } = storeToRefs(settingStore)
|
||||
|
||||
// 防止重复设置
|
||||
if (
|
||||
(type === 'shadow-mode' && boxBorderMode.value === false) ||
|
||||
(type === 'border-mode' && boxBorderMode.value === true)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// 防止重复设置
|
||||
if (
|
||||
(type === 'shadow-mode' && boxBorderMode.value === false) ||
|
||||
(type === 'border-mode' && boxBorderMode.value === true)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
domOperations.setRootAttribute('data-box-mode', type)
|
||||
settingStore.setBorderMode()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
setTimeout(() => {
|
||||
domOperations.setRootAttribute('data-box-mode', type)
|
||||
settingStore.setBorderMode()
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// 颜色设置处理器
|
||||
const colorHandlers = {
|
||||
// 选择主题色
|
||||
selectColor: (theme: string) => {
|
||||
settingStore.setElementTheme(theme)
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
// 颜色设置处理器
|
||||
const colorHandlers = {
|
||||
// 选择主题色
|
||||
selectColor: (theme: string) => {
|
||||
settingStore.setElementTheme(theme)
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// 容器设置处理器
|
||||
const containerHandlers = {
|
||||
// 设置容器宽度
|
||||
setWidth: (type: ContainerWidthEnum) => {
|
||||
settingStore.setContainerWidth(type)
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
// 容器设置处理器
|
||||
const containerHandlers = {
|
||||
// 设置容器宽度
|
||||
setWidth: (type: ContainerWidthEnum) => {
|
||||
settingStore.setContainerWidth(type)
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
domOperations,
|
||||
basicHandlers,
|
||||
boxStyleHandlers,
|
||||
colorHandlers,
|
||||
containerHandlers,
|
||||
createToggleHandler,
|
||||
createValueHandler
|
||||
}
|
||||
return {
|
||||
domOperations,
|
||||
basicHandlers,
|
||||
boxStyleHandlers,
|
||||
colorHandlers,
|
||||
containerHandlers,
|
||||
createToggleHandler,
|
||||
createValueHandler
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,194 +14,194 @@ import { useSettingsHandlers } from './useSettingsHandlers'
|
||||
* 设置面板核心逻辑管理
|
||||
*/
|
||||
export function useSettingsPanel() {
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
|
||||
|
||||
// Composables
|
||||
const { openFestival, cleanup } = useCeremony()
|
||||
const { setSystemTheme, setSystemAutoTheme } = useTheme()
|
||||
const { initColorWeak } = useSettingsState()
|
||||
const { domOperations } = useSettingsHandlers()
|
||||
// Composables
|
||||
const { openFestival, cleanup } = useCeremony()
|
||||
const { setSystemTheme, setSystemAutoTheme } = useTheme()
|
||||
const { initColorWeak } = useSettingsState()
|
||||
const { domOperations } = useSettingsHandlers()
|
||||
|
||||
// 响应式状态
|
||||
const showDrawer = ref(false)
|
||||
// 响应式状态
|
||||
const showDrawer = ref(false)
|
||||
|
||||
// 使用 VueUse breakpoints 优化性能
|
||||
const breakpoints = useBreakpoints({ tablet: 1000 })
|
||||
const isMobile = breakpoints.smaller('tablet')
|
||||
// 使用 VueUse breakpoints 优化性能
|
||||
const breakpoints = useBreakpoints({ tablet: 1000 })
|
||||
const isMobile = breakpoints.smaller('tablet')
|
||||
|
||||
// 记录窗口宽度变化前的菜单类型
|
||||
const beforeMenuType = ref<MenuTypeEnum>()
|
||||
const hasChangedMenu = ref(false)
|
||||
// 记录窗口宽度变化前的菜单类型
|
||||
const beforeMenuType = ref<MenuTypeEnum>()
|
||||
const hasChangedMenu = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
|
||||
// 计算属性
|
||||
const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
|
||||
|
||||
// 主题相关处理
|
||||
const useThemeHandlers = () => {
|
||||
// 初始化系统颜色
|
||||
const initSystemColor = () => {
|
||||
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
|
||||
settingStore.setElementTheme(AppConfig.systemMainColor[0])
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
// 主题相关处理
|
||||
const useThemeHandlers = () => {
|
||||
// 初始化系统颜色
|
||||
const initSystemColor = () => {
|
||||
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
|
||||
settingStore.setElementTheme(AppConfig.systemMainColor[0])
|
||||
settingStore.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化系统主题
|
||||
const initSystemTheme = () => {
|
||||
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
|
||||
setSystemAutoTheme()
|
||||
} else {
|
||||
setSystemTheme(systemThemeType.value)
|
||||
}
|
||||
}
|
||||
// 初始化系统主题
|
||||
const initSystemTheme = () => {
|
||||
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
|
||||
setSystemAutoTheme()
|
||||
} else {
|
||||
setSystemTheme(systemThemeType.value)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听系统主题变化
|
||||
const listenerSystemTheme = () => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', initSystemTheme)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', initSystemTheme)
|
||||
}
|
||||
}
|
||||
// 监听系统主题变化
|
||||
const listenerSystemTheme = () => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
mediaQuery.addEventListener('change', initSystemTheme)
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', initSystemTheme)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initSystemColor,
|
||||
initSystemTheme,
|
||||
listenerSystemTheme
|
||||
}
|
||||
}
|
||||
return {
|
||||
initSystemColor,
|
||||
initSystemTheme,
|
||||
listenerSystemTheme
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式布局处理
|
||||
const useResponsiveLayout = () => {
|
||||
// 使用 watch 监听断点变化,性能更优
|
||||
const stopWatch = watch(
|
||||
isMobile,
|
||||
(mobile: boolean) => {
|
||||
if (mobile) {
|
||||
// 切换到移动端布局
|
||||
if (!hasChangedMenu.value) {
|
||||
beforeMenuType.value = menuType.value
|
||||
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
|
||||
settingStore.setMenuOpen(false)
|
||||
hasChangedMenu.value = true
|
||||
}
|
||||
} else {
|
||||
// 恢复桌面端布局
|
||||
if (hasChangedMenu.value && beforeMenuType.value) {
|
||||
useSettingsState().switchMenuLayouts(beforeMenuType.value)
|
||||
settingStore.setMenuOpen(true)
|
||||
hasChangedMenu.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
// 响应式布局处理
|
||||
const useResponsiveLayout = () => {
|
||||
// 使用 watch 监听断点变化,性能更优
|
||||
const stopWatch = watch(
|
||||
isMobile,
|
||||
(mobile: boolean) => {
|
||||
if (mobile) {
|
||||
// 切换到移动端布局
|
||||
if (!hasChangedMenu.value) {
|
||||
beforeMenuType.value = menuType.value
|
||||
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
|
||||
settingStore.setMenuOpen(false)
|
||||
hasChangedMenu.value = true
|
||||
}
|
||||
} else {
|
||||
// 恢复桌面端布局
|
||||
if (hasChangedMenu.value && beforeMenuType.value) {
|
||||
useSettingsState().switchMenuLayouts(beforeMenuType.value)
|
||||
settingStore.setMenuOpen(true)
|
||||
hasChangedMenu.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
return { stopWatch }
|
||||
}
|
||||
return { stopWatch }
|
||||
}
|
||||
|
||||
// 抽屉控制
|
||||
const useDrawerControl = () => {
|
||||
// 用于存储 setTimeout 的 ID,以便在需要时清除
|
||||
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 抽屉控制
|
||||
const useDrawerControl = () => {
|
||||
// 用于存储 setTimeout 的 ID,以便在需要时清除
|
||||
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 打开抽屉
|
||||
const handleOpen = () => {
|
||||
// 清除可能存在的旧定时器
|
||||
if (themeChangeTimer) {
|
||||
clearTimeout(themeChangeTimer)
|
||||
}
|
||||
// 延迟添加 theme-change class,避免抽屉打开动画受影响
|
||||
themeChangeTimer = setTimeout(() => {
|
||||
domOperations.setBodyClass('theme-change', true)
|
||||
themeChangeTimer = null
|
||||
}, 500)
|
||||
}
|
||||
// 打开抽屉
|
||||
const handleOpen = () => {
|
||||
// 清除可能存在的旧定时器
|
||||
if (themeChangeTimer) {
|
||||
clearTimeout(themeChangeTimer)
|
||||
}
|
||||
// 延迟添加 theme-change class,避免抽屉打开动画受影响
|
||||
themeChangeTimer = setTimeout(() => {
|
||||
domOperations.setBodyClass('theme-change', true)
|
||||
themeChangeTimer = null
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
// 清除未执行的定时器,防止关闭后才添加 class
|
||||
if (themeChangeTimer) {
|
||||
clearTimeout(themeChangeTimer)
|
||||
themeChangeTimer = null
|
||||
}
|
||||
// 立即移除 theme-change class
|
||||
domOperations.setBodyClass('theme-change', false)
|
||||
}
|
||||
// 关闭抽屉
|
||||
const handleClose = () => {
|
||||
// 清除未执行的定时器,防止关闭后才添加 class
|
||||
if (themeChangeTimer) {
|
||||
clearTimeout(themeChangeTimer)
|
||||
themeChangeTimer = null
|
||||
}
|
||||
// 立即移除 theme-change class
|
||||
domOperations.setBodyClass('theme-change', false)
|
||||
}
|
||||
|
||||
// 打开设置
|
||||
const openSetting = () => {
|
||||
showDrawer.value = true
|
||||
}
|
||||
// 打开设置
|
||||
const openSetting = () => {
|
||||
showDrawer.value = true
|
||||
}
|
||||
|
||||
// 关闭设置
|
||||
const closeDrawer = () => {
|
||||
showDrawer.value = false
|
||||
}
|
||||
// 关闭设置
|
||||
const closeDrawer = () => {
|
||||
showDrawer.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
handleOpen,
|
||||
handleClose,
|
||||
openSetting,
|
||||
closeDrawer
|
||||
}
|
||||
}
|
||||
return {
|
||||
handleOpen,
|
||||
handleClose,
|
||||
openSetting,
|
||||
closeDrawer
|
||||
}
|
||||
}
|
||||
|
||||
// Props 变化监听
|
||||
const usePropsWatcher = (props: { open?: boolean }) => {
|
||||
watch(
|
||||
() => props.open,
|
||||
(val: boolean | undefined) => {
|
||||
if (val !== undefined) {
|
||||
showDrawer.value = val
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Props 变化监听
|
||||
const usePropsWatcher = (props: { open?: boolean }) => {
|
||||
watch(
|
||||
() => props.open,
|
||||
(val: boolean | undefined) => {
|
||||
if (val !== undefined) {
|
||||
showDrawer.value = val
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化设置
|
||||
const useSettingsInitializer = () => {
|
||||
const themeHandlers = useThemeHandlers()
|
||||
const { openSetting } = useDrawerControl()
|
||||
const { stopWatch } = useResponsiveLayout()
|
||||
let themeCleanup: (() => void) | null = null
|
||||
// 初始化设置
|
||||
const useSettingsInitializer = () => {
|
||||
const themeHandlers = useThemeHandlers()
|
||||
const { openSetting } = useDrawerControl()
|
||||
const { stopWatch } = useResponsiveLayout()
|
||||
let themeCleanup: (() => void) | null = null
|
||||
|
||||
const initializeSettings = () => {
|
||||
mittBus.on('openSetting', openSetting)
|
||||
themeHandlers.initSystemColor()
|
||||
themeCleanup = themeHandlers.listenerSystemTheme()
|
||||
initColorWeak()
|
||||
const initializeSettings = () => {
|
||||
mittBus.on('openSetting', openSetting)
|
||||
themeHandlers.initSystemColor()
|
||||
themeCleanup = themeHandlers.listenerSystemTheme()
|
||||
initColorWeak()
|
||||
|
||||
// 设置盒子模式
|
||||
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
|
||||
domOperations.setRootAttribute('data-box-mode', boxMode)
|
||||
// 设置盒子模式
|
||||
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
|
||||
domOperations.setRootAttribute('data-box-mode', boxMode)
|
||||
|
||||
themeHandlers.initSystemTheme()
|
||||
openFestival()
|
||||
}
|
||||
themeHandlers.initSystemTheme()
|
||||
openFestival()
|
||||
}
|
||||
|
||||
const cleanupSettings = () => {
|
||||
stopWatch()
|
||||
themeCleanup?.()
|
||||
cleanup()
|
||||
}
|
||||
const cleanupSettings = () => {
|
||||
stopWatch()
|
||||
themeCleanup?.()
|
||||
cleanup()
|
||||
}
|
||||
|
||||
return {
|
||||
initializeSettings,
|
||||
cleanupSettings
|
||||
}
|
||||
}
|
||||
return {
|
||||
initializeSettings,
|
||||
cleanupSettings
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
showDrawer,
|
||||
return {
|
||||
// 状态
|
||||
showDrawer,
|
||||
|
||||
// 方法组合
|
||||
useThemeHandlers,
|
||||
useResponsiveLayout,
|
||||
useDrawerControl,
|
||||
usePropsWatcher,
|
||||
useSettingsInitializer
|
||||
}
|
||||
// 方法组合
|
||||
useThemeHandlers,
|
||||
useResponsiveLayout,
|
||||
useDrawerControl,
|
||||
usePropsWatcher,
|
||||
useSettingsInitializer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,33 @@ import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
|
||||
* 设置状态管理
|
||||
*/
|
||||
export function useSettingsState() {
|
||||
const settingStore = useSettingStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
// 色弱模式初始化
|
||||
const initColorWeak = () => {
|
||||
if (settingStore.colorWeak) {
|
||||
const el = document.getElementsByTagName('html')[0]
|
||||
setTimeout(() => {
|
||||
el.classList.add('color-weak')
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
// 色弱模式初始化
|
||||
const initColorWeak = () => {
|
||||
if (settingStore.colorWeak) {
|
||||
const el = document.getElementsByTagName('html')[0]
|
||||
setTimeout(() => {
|
||||
el.classList.add('color-weak')
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
// 菜单布局切换
|
||||
const switchMenuLayouts = (type: MenuTypeEnum) => {
|
||||
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
|
||||
settingStore.setMenuOpen(true)
|
||||
}
|
||||
settingStore.switchMenuLayouts(type)
|
||||
if (type === MenuTypeEnum.DUAL_MENU) {
|
||||
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
|
||||
settingStore.setMenuOpen(true)
|
||||
}
|
||||
}
|
||||
// 菜单布局切换
|
||||
const switchMenuLayouts = (type: MenuTypeEnum) => {
|
||||
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
|
||||
settingStore.setMenuOpen(true)
|
||||
}
|
||||
settingStore.switchMenuLayouts(type)
|
||||
if (type === MenuTypeEnum.DUAL_MENU) {
|
||||
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
|
||||
settingStore.setMenuOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// 方法
|
||||
initColorWeak,
|
||||
switchMenuLayouts
|
||||
}
|
||||
return {
|
||||
// 方法
|
||||
initColorWeak,
|
||||
switchMenuLayouts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,72 +1,72 @@
|
||||
<!-- 设置面板 -->
|
||||
<template>
|
||||
<div class="layout-settings">
|
||||
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
|
||||
<!-- 头部关闭按钮 -->
|
||||
<SettingHeader @close="closeDrawer" />
|
||||
<!-- 主题风格 -->
|
||||
<ThemeSettings />
|
||||
<!-- 菜单布局 -->
|
||||
<MenuLayoutSettings />
|
||||
<!-- 菜单风格 -->
|
||||
<MenuStyleSettings />
|
||||
<!-- 系统主题色 -->
|
||||
<ColorSettings />
|
||||
<!-- 盒子样式 -->
|
||||
<BoxStyleSettings />
|
||||
<!-- 容器宽度 -->
|
||||
<ContainerSettings />
|
||||
<!-- 基础配置 -->
|
||||
<BasicSettings />
|
||||
<!-- 操作按钮 -->
|
||||
<SettingActions />
|
||||
</SettingDrawer>
|
||||
</div>
|
||||
<div class="layout-settings">
|
||||
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
|
||||
<!-- 头部关闭按钮 -->
|
||||
<SettingHeader @close="closeDrawer" />
|
||||
<!-- 主题风格 -->
|
||||
<ThemeSettings />
|
||||
<!-- 菜单布局 -->
|
||||
<MenuLayoutSettings />
|
||||
<!-- 菜单风格 -->
|
||||
<MenuStyleSettings />
|
||||
<!-- 系统主题色 -->
|
||||
<ColorSettings />
|
||||
<!-- 盒子样式 -->
|
||||
<BoxStyleSettings />
|
||||
<!-- 容器宽度 -->
|
||||
<ContainerSettings />
|
||||
<!-- 基础配置 -->
|
||||
<BasicSettings />
|
||||
<!-- 操作按钮 -->
|
||||
<SettingActions />
|
||||
</SettingDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingsPanel } from './composables/useSettingsPanel'
|
||||
import { useSettingsPanel } from './composables/useSettingsPanel'
|
||||
|
||||
import SettingDrawer from './widget/SettingDrawer.vue'
|
||||
import SettingHeader from './widget/SettingHeader.vue'
|
||||
import ThemeSettings from './widget/ThemeSettings.vue'
|
||||
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
|
||||
import MenuStyleSettings from './widget/MenuStyleSettings.vue'
|
||||
import ColorSettings from './widget/ColorSettings.vue'
|
||||
import BoxStyleSettings from './widget/BoxStyleSettings.vue'
|
||||
import ContainerSettings from './widget/ContainerSettings.vue'
|
||||
import BasicSettings from './widget/BasicSettings.vue'
|
||||
import SettingActions from './widget/SettingActions.vue'
|
||||
import SettingDrawer from './widget/SettingDrawer.vue'
|
||||
import SettingHeader from './widget/SettingHeader.vue'
|
||||
import ThemeSettings from './widget/ThemeSettings.vue'
|
||||
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
|
||||
import MenuStyleSettings from './widget/MenuStyleSettings.vue'
|
||||
import ColorSettings from './widget/ColorSettings.vue'
|
||||
import BoxStyleSettings from './widget/BoxStyleSettings.vue'
|
||||
import ContainerSettings from './widget/ContainerSettings.vue'
|
||||
import BasicSettings from './widget/BasicSettings.vue'
|
||||
import SettingActions from './widget/SettingActions.vue'
|
||||
|
||||
defineOptions({ name: 'ArtSettingsPanel' })
|
||||
defineOptions({ name: 'ArtSettingsPanel' })
|
||||
|
||||
interface Props {
|
||||
/** 是否打开 */
|
||||
open?: boolean
|
||||
}
|
||||
interface Props {
|
||||
/** 是否打开 */
|
||||
open?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 使用设置面板逻辑
|
||||
const settingsPanel = useSettingsPanel()
|
||||
const { showDrawer } = settingsPanel
|
||||
// 使用设置面板逻辑
|
||||
const settingsPanel = useSettingsPanel()
|
||||
const { showDrawer } = settingsPanel
|
||||
|
||||
// 获取各种处理器
|
||||
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
|
||||
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
|
||||
// 获取各种处理器
|
||||
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
|
||||
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
|
||||
|
||||
// 监听 props 变化
|
||||
settingsPanel.usePropsWatcher(props)
|
||||
// 监听 props 变化
|
||||
settingsPanel.usePropsWatcher(props)
|
||||
|
||||
onMounted(() => {
|
||||
initializeSettings()
|
||||
})
|
||||
onMounted(() => {
|
||||
initializeSettings()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
cleanupSettings()
|
||||
})
|
||||
onUnmounted(() => {
|
||||
cleanupSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@use './style';
|
||||
@use './style';
|
||||
</style>
|
||||
|
||||
@@ -2,91 +2,91 @@
|
||||
|
||||
// 设置抽屉模态框样式
|
||||
.setting-modal {
|
||||
background: transparent !important;
|
||||
background: transparent !important;
|
||||
|
||||
.el-drawer {
|
||||
// 背景滤镜效果
|
||||
background: rgba($color: #fff, $alpha: 50%) !important;
|
||||
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
|
||||
.el-drawer {
|
||||
// 背景滤镜效果
|
||||
background: rgba($color: #fff, $alpha: 50%) !important;
|
||||
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
|
||||
|
||||
@include backdropBlur();
|
||||
@include backdropBlur();
|
||||
|
||||
.setting-box-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: calc(100% + 15px);
|
||||
margin-bottom: 10px;
|
||||
.setting-box-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
width: calc(100% + 15px);
|
||||
margin-bottom: 10px;
|
||||
|
||||
.setting-item {
|
||||
box-sizing: border-box;
|
||||
width: calc(33.333% - 15px);
|
||||
margin-right: 15px;
|
||||
text-align: center;
|
||||
.setting-item {
|
||||
box-sizing: border-box;
|
||||
width: calc(33.333% - 15px);
|
||||
margin-right: 15px;
|
||||
text-align: center;
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--default-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
|
||||
transition: box-shadow 0.1s;
|
||||
.box {
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
height: 52px;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--default-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
|
||||
transition: box-shadow 0.1s;
|
||||
|
||||
&.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
&.mt-16 {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border: 2px solid var(--theme-color);
|
||||
}
|
||||
&.is-active {
|
||||
border: 2px solid var(--theme-color);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.name {
|
||||
margin-top: 6px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去除滚动条
|
||||
.el-drawer__body::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
// 去除滚动条
|
||||
.el-drawer__body::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.setting-modal {
|
||||
.el-drawer {
|
||||
background: rgba($color: #000, $alpha: 50%) !important;
|
||||
.setting-modal {
|
||||
.el-drawer {
|
||||
background: rgba($color: #000, $alpha: 50%) !important;
|
||||
|
||||
.setting-item {
|
||||
.box {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.setting-item {
|
||||
.box {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去除火狐浏览器滚动条
|
||||
:deep(.el-drawer__body) {
|
||||
scrollbar-width: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
// 移动端隐藏
|
||||
@media screen and (width <= 800px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
<template>
|
||||
<div>
|
||||
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
|
||||
<SettingItem
|
||||
v-for="config in basicSettingsConfig"
|
||||
:key="config.key"
|
||||
:config="config"
|
||||
:model-value="getSettingValue(config.key)"
|
||||
@change="handleSettingChange(config.handler, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
|
||||
<SettingItem
|
||||
v-for="config in basicSettingsConfig"
|
||||
:key="config.key"
|
||||
:config="config"
|
||||
:model-value="getSettingValue(config.key)"
|
||||
@change="handleSettingChange(config.handler, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import SettingItem from './SettingItem.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import SettingItem from './SettingItem.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { basicSettingsConfig } = useSettingsConfig()
|
||||
const { basicHandlers } = useSettingsHandlers()
|
||||
const settingStore = useSettingStore()
|
||||
const { basicSettingsConfig } = useSettingsConfig()
|
||||
const { basicHandlers } = useSettingsHandlers()
|
||||
|
||||
// 获取store的响应式状态
|
||||
const {
|
||||
uniqueOpened,
|
||||
showMenuButton,
|
||||
showFastEnter,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
colorWeak,
|
||||
watermarkVisible,
|
||||
menuOpenWidth,
|
||||
tabStyle,
|
||||
pageTransition,
|
||||
customRadius
|
||||
} = storeToRefs(settingStore)
|
||||
// 获取store的响应式状态
|
||||
const {
|
||||
uniqueOpened,
|
||||
showMenuButton,
|
||||
showFastEnter,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
colorWeak,
|
||||
watermarkVisible,
|
||||
menuOpenWidth,
|
||||
tabStyle,
|
||||
pageTransition,
|
||||
customRadius
|
||||
} = storeToRefs(settingStore)
|
||||
|
||||
// 创建设置值映射
|
||||
const settingValueMap = {
|
||||
uniqueOpened,
|
||||
showMenuButton,
|
||||
showFastEnter,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
colorWeak,
|
||||
watermarkVisible,
|
||||
menuOpenWidth,
|
||||
tabStyle,
|
||||
pageTransition,
|
||||
customRadius
|
||||
}
|
||||
// 创建设置值映射
|
||||
const settingValueMap = {
|
||||
uniqueOpened,
|
||||
showMenuButton,
|
||||
showFastEnter,
|
||||
showRefreshButton,
|
||||
showCrumbs,
|
||||
showWorkTab,
|
||||
showLanguage,
|
||||
showNprogress,
|
||||
colorWeak,
|
||||
watermarkVisible,
|
||||
menuOpenWidth,
|
||||
tabStyle,
|
||||
pageTransition,
|
||||
customRadius
|
||||
}
|
||||
|
||||
// 获取设置值的方法
|
||||
const getSettingValue = (key: string) => {
|
||||
const settingRef = settingValueMap[key as keyof typeof settingValueMap]
|
||||
return settingRef?.value ?? null
|
||||
}
|
||||
// 获取设置值的方法
|
||||
const getSettingValue = (key: string) => {
|
||||
const settingRef = settingValueMap[key as keyof typeof settingValueMap]
|
||||
return settingRef?.value ?? null
|
||||
}
|
||||
|
||||
// 统一的设置变更处理
|
||||
const handleSettingChange = (handlerName: string, value: any) => {
|
||||
const handler = (basicHandlers as any)[handlerName]
|
||||
if (typeof handler === 'function') {
|
||||
handler(value)
|
||||
} else {
|
||||
console.warn(`Handler "${handlerName}" not found in basicHandlers`)
|
||||
}
|
||||
}
|
||||
// 统一的设置变更处理
|
||||
const handleSettingChange = (handlerName: string, value: any) => {
|
||||
const handler = (basicHandlers as any)[handlerName]
|
||||
if (typeof handler === 'function') {
|
||||
handler(value)
|
||||
} else {
|
||||
console.warn(`Handler "${handlerName}" not found in basicHandlers`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<template>
|
||||
<div>
|
||||
<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
|
||||
v-for="option in boxStyleOptions"
|
||||
: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="
|
||||
isActive(option.type)
|
||||
? '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'
|
||||
"
|
||||
@click="boxStyleHandlers.setBoxMode(option.type)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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
|
||||
v-for="option in boxStyleOptions"
|
||||
: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="
|
||||
isActive(option.type)
|
||||
? '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'
|
||||
"
|
||||
@click="boxStyleHandlers.setBoxMode(option.type)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { boxBorderMode } = storeToRefs(settingStore)
|
||||
const { boxStyleOptions } = useSettingsConfig()
|
||||
const { boxStyleHandlers } = useSettingsHandlers()
|
||||
const settingStore = useSettingStore()
|
||||
const { boxBorderMode } = storeToRefs(settingStore)
|
||||
const { boxStyleOptions } = useSettingsConfig()
|
||||
const { boxStyleHandlers } = useSettingsHandlers()
|
||||
|
||||
// 判断当前选项是否激活
|
||||
const isActive = (type: 'border-mode' | 'shadow-mode') => {
|
||||
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
|
||||
}
|
||||
// 判断当前选项是否激活
|
||||
const isActive = (type: 'border-mode' | 'shadow-mode') => {
|
||||
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,35 +1,35 @@
|
||||
<template>
|
||||
<div>
|
||||
<SectionTitle :title="$t('setting.color.title')" class="mt-10" />
|
||||
<div class="-mr-4">
|
||||
<div class="flex flex-wrap">
|
||||
<div
|
||||
v-for="color in configOptions.mainColors"
|
||||
: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"
|
||||
:style="{ background: `${color} !important` }"
|
||||
@click="colorHandlers.selectColor(color)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:check-fill"
|
||||
class="text-base !text-white"
|
||||
v-show="color === systemThemeColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SectionTitle :title="$t('setting.color.title')" class="mt-10" />
|
||||
<div class="-mr-4">
|
||||
<div class="flex flex-wrap">
|
||||
<div
|
||||
v-for="color in configOptions.mainColors"
|
||||
: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"
|
||||
:style="{ background: `${color} !important` }"
|
||||
@click="colorHandlers.selectColor(color)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:check-fill"
|
||||
class="text-base !text-white"
|
||||
v-show="color === systemThemeColor"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeColor } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { colorHandlers } = useSettingsHandlers()
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeColor } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { colorHandlers } = useSettingsHandlers()
|
||||
</script>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<template>
|
||||
<div>
|
||||
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
|
||||
<div class="flex">
|
||||
<div
|
||||
v-for="option in containerWidthOptions"
|
||||
: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="{
|
||||
'border-theme [&_i]:!text-theme': containerWidth === option.value,
|
||||
'border-full-d': containerWidth !== option.value
|
||||
}"
|
||||
@click="containerHandlers.setWidth(option.value)"
|
||||
>
|
||||
<ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" />
|
||||
<span class="text-sm">{{ option.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
|
||||
<div class="flex">
|
||||
<div
|
||||
v-for="option in containerWidthOptions"
|
||||
: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="{
|
||||
'border-theme [&_i]:!text-theme': containerWidth === option.value,
|
||||
'border-full-d': containerWidth !== option.value
|
||||
}"
|
||||
@click="containerHandlers.setWidth(option.value)"
|
||||
>
|
||||
<ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" />
|
||||
<span class="text-sm">{{ option.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { containerWidth } = storeToRefs(settingStore)
|
||||
const { containerWidthOptions } = useSettingsConfig()
|
||||
const { containerHandlers } = useSettingsHandlers()
|
||||
const settingStore = useSettingStore()
|
||||
const { containerWidth } = storeToRefs(settingStore)
|
||||
const { containerWidthOptions } = useSettingsConfig()
|
||||
const { containerHandlers } = useSettingsHandlers()
|
||||
</script>
|
||||
|
||||
@@ -1,31 +1,34 @@
|
||||
<template>
|
||||
<div v-if="width > 1000">
|
||||
<SectionTitle :title="$t('setting.menuType.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="(item, index) in configOptions.menuLayoutList"
|
||||
:key="item.value"
|
||||
@click="switchMenuLayouts(item.value)"
|
||||
>
|
||||
<div class="box" :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }">
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="width > 1000">
|
||||
<SectionTitle :title="$t('setting.menuType.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="(item, index) in configOptions.menuLayoutList"
|
||||
:key="item.value"
|
||||
@click="switchMenuLayouts(item.value)"
|
||||
>
|
||||
<div
|
||||
class="box"
|
||||
:class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }"
|
||||
>
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsState } from '../composables/useSettingsState'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useSettingsState } from '../composables/useSettingsState'
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const settingStore = useSettingStore()
|
||||
const { menuType } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { switchMenuLayouts } = useSettingsState()
|
||||
const { width } = useWindowSize()
|
||||
const settingStore = useSettingStore()
|
||||
const { menuType } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { switchMenuLayouts } = useSettingsState()
|
||||
</script>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<template>
|
||||
<SectionTitle :title="$t('setting.menu.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="item in menuThemeList"
|
||||
:key="item.theme"
|
||||
@click="switchMenuStyles(item.theme)"
|
||||
>
|
||||
<div
|
||||
class="box"
|
||||
:class="{ 'is-active': item.theme === menuThemeType }"
|
||||
:style="{
|
||||
cursor: disabled ? 'no-drop' : 'pointer'
|
||||
}"
|
||||
>
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SectionTitle :title="$t('setting.menu.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="item in menuThemeList"
|
||||
:key="item.theme"
|
||||
@click="switchMenuStyles(item.theme)"
|
||||
>
|
||||
<div
|
||||
class="box"
|
||||
:class="{ 'is-active': item.theme === menuThemeType }"
|
||||
:style="{
|
||||
cursor: disabled ? 'no-drop' : 'pointer'
|
||||
}"
|
||||
>
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import AppConfig from '@/config'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
const menuThemeList = AppConfig.themeList
|
||||
const settingStore = useSettingStore()
|
||||
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
|
||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||
const menuThemeList = AppConfig.themeList
|
||||
const settingStore = useSettingStore()
|
||||
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
|
||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||
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) => {
|
||||
if (isDualMenu.value || isTopMenu.value || isDark.value) {
|
||||
return
|
||||
}
|
||||
settingStore.switchMenuStyles(theme)
|
||||
}
|
||||
// 菜单样式切换
|
||||
const switchMenuStyles = (theme: MenuThemeEnum) => {
|
||||
if (isDualMenu.value || isTopMenu.value || isDark.value) {
|
||||
return
|
||||
}
|
||||
settingStore.switchMenuStyles(theme)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<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"
|
||||
:style="style"
|
||||
>
|
||||
{{ title }}
|
||||
</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"
|
||||
:style="style"
|
||||
>
|
||||
{{ title }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
style?: Record<string, any>
|
||||
}
|
||||
interface Props {
|
||||
title: string
|
||||
style?: Record<string, any>
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
@@ -1,235 +1,241 @@
|
||||
<!-- 设置操作按钮 -->
|
||||
<template>
|
||||
<div
|
||||
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">
|
||||
{{ $t('setting.actions.copyConfig') }}
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
|
||||
{{ $t('setting.actions.resetConfig') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
<div
|
||||
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">
|
||||
{{ $t('setting.actions.copyConfig') }}
|
||||
</ElButton>
|
||||
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
|
||||
{{ $t('setting.actions.resetConfig') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MenuThemeEnum } from '@/enums/appEnum'
|
||||
import { useTheme } from '@/hooks/core/useTheme'
|
||||
import { nextTick } from 'vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { MenuThemeEnum } from '@/enums/appEnum'
|
||||
import { useTheme } from '@/hooks/core/useTheme'
|
||||
|
||||
defineOptions({ name: 'SettingActions' })
|
||||
defineOptions({ name: 'SettingActions' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const { copy, copied } = useClipboard()
|
||||
const { switchThemeStyles } = useTheme()
|
||||
const { t } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const { copy, copied } = useClipboard()
|
||||
const { switchThemeStyles } = useTheme()
|
||||
|
||||
/** 枚举映射表 */
|
||||
const ENUM_MAPS = {
|
||||
menuType: {
|
||||
left: 'MenuTypeEnum.LEFT',
|
||||
top: 'MenuTypeEnum.TOP',
|
||||
'top-left': 'MenuTypeEnum.TOP_LEFT',
|
||||
'dual-menu': 'MenuTypeEnum.DUAL_MENU'
|
||||
},
|
||||
systemTheme: {
|
||||
auto: 'SystemThemeEnum.AUTO',
|
||||
light: 'SystemThemeEnum.LIGHT',
|
||||
dark: 'SystemThemeEnum.DARK'
|
||||
},
|
||||
menuTheme: {
|
||||
design: 'MenuThemeEnum.DESIGN',
|
||||
light: 'MenuThemeEnum.LIGHT',
|
||||
dark: 'MenuThemeEnum.DARK'
|
||||
},
|
||||
containerWidth: {
|
||||
'100%': 'ContainerWidthEnum.FULL',
|
||||
'1200px': 'ContainerWidthEnum.BOXED'
|
||||
}
|
||||
} as const
|
||||
/** 枚举映射表 */
|
||||
const ENUM_MAPS = {
|
||||
menuType: {
|
||||
left: 'MenuTypeEnum.LEFT',
|
||||
top: 'MenuTypeEnum.TOP',
|
||||
'top-left': 'MenuTypeEnum.TOP_LEFT',
|
||||
'dual-menu': 'MenuTypeEnum.DUAL_MENU'
|
||||
},
|
||||
systemTheme: {
|
||||
auto: 'SystemThemeEnum.AUTO',
|
||||
light: 'SystemThemeEnum.LIGHT',
|
||||
dark: 'SystemThemeEnum.DARK'
|
||||
},
|
||||
menuTheme: {
|
||||
design: 'MenuThemeEnum.DESIGN',
|
||||
light: 'MenuThemeEnum.LIGHT',
|
||||
dark: 'MenuThemeEnum.DARK'
|
||||
},
|
||||
containerWidth: {
|
||||
'100%': 'ContainerWidthEnum.FULL',
|
||||
'1200px': 'ContainerWidthEnum.BOXED'
|
||||
}
|
||||
} as const
|
||||
|
||||
/** 配置项定义 */
|
||||
interface ConfigItem {
|
||||
comment: string
|
||||
key: keyof typeof settingStore
|
||||
enumMap?: Record<string, string>
|
||||
forceValue?: any
|
||||
}
|
||||
/** 配置项定义 */
|
||||
interface ConfigItem {
|
||||
comment: string
|
||||
key: keyof typeof settingStore
|
||||
enumMap?: Record<string, string>
|
||||
forceValue?: any
|
||||
}
|
||||
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{ comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType },
|
||||
{ comment: '菜单展开宽度', key: 'menuOpenWidth' },
|
||||
{ comment: '菜单是否展开', key: 'menuOpen' },
|
||||
{ comment: '双菜单是否显示文本', key: 'dualMenuShowText' },
|
||||
{ comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme },
|
||||
{ comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme },
|
||||
{ comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme },
|
||||
{ comment: '系统主题颜色', key: 'systemThemeColor' },
|
||||
{ comment: '是否显示菜单按钮', key: 'showMenuButton' },
|
||||
{ comment: '是否显示快速入口', key: 'showFastEnter' },
|
||||
{ comment: '是否显示刷新按钮', key: 'showRefreshButton' },
|
||||
{ comment: '是否显示面包屑', key: 'showCrumbs' },
|
||||
{ comment: '是否显示工作台标签', key: 'showWorkTab' },
|
||||
{ comment: '是否显示语言切换', key: 'showLanguage' },
|
||||
{ comment: '是否显示进度条', key: 'showNprogress' },
|
||||
{ comment: '是否显示设置引导', key: 'showSettingGuide' },
|
||||
{ comment: '是否显示节日文本', key: 'showFestivalText' },
|
||||
{ comment: '是否显示水印', key: 'watermarkVisible' },
|
||||
{ comment: '是否自动关闭', key: 'autoClose' },
|
||||
{ comment: '是否唯一展开', key: 'uniqueOpened' },
|
||||
{ comment: '是否色弱模式', key: 'colorWeak' },
|
||||
{ comment: '是否刷新', key: 'refresh' },
|
||||
{ comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' },
|
||||
{ comment: '边框模式', key: 'boxBorderMode' },
|
||||
{ comment: '页面过渡效果', key: 'pageTransition' },
|
||||
{ comment: '标签页样式', key: 'tabStyle' },
|
||||
{ comment: '自定义圆角', key: 'customRadius' },
|
||||
{ comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth },
|
||||
{ comment: '节日日期', key: 'festivalDate', forceValue: '' }
|
||||
]
|
||||
const CONFIG_ITEMS: ConfigItem[] = [
|
||||
{ comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType },
|
||||
{ comment: '菜单展开宽度', key: 'menuOpenWidth' },
|
||||
{ comment: '菜单是否展开', key: 'menuOpen' },
|
||||
{ comment: '双菜单是否显示文本', key: 'dualMenuShowText' },
|
||||
{ comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme },
|
||||
{ comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme },
|
||||
{ comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme },
|
||||
{ comment: '系统主题颜色', key: 'systemThemeColor' },
|
||||
{ comment: '是否显示菜单按钮', key: 'showMenuButton' },
|
||||
{ comment: '是否显示快速入口', key: 'showFastEnter' },
|
||||
{ comment: '是否显示刷新按钮', key: 'showRefreshButton' },
|
||||
{ comment: '是否显示面包屑', key: 'showCrumbs' },
|
||||
{ comment: '是否显示工作台标签', key: 'showWorkTab' },
|
||||
{ comment: '是否显示语言切换', key: 'showLanguage' },
|
||||
{ comment: '是否显示进度条', key: 'showNprogress' },
|
||||
{ comment: '是否显示设置引导', key: 'showSettingGuide' },
|
||||
{ comment: '是否显示节日文本', key: 'showFestivalText' },
|
||||
{ comment: '是否显示水印', key: 'watermarkVisible' },
|
||||
{ comment: '是否自动关闭', key: 'autoClose' },
|
||||
{ comment: '是否唯一展开', key: 'uniqueOpened' },
|
||||
{ comment: '是否色弱模式', key: 'colorWeak' },
|
||||
{ comment: '是否刷新', key: 'refresh' },
|
||||
{ comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' },
|
||||
{ comment: '边框模式', key: 'boxBorderMode' },
|
||||
{ comment: '页面过渡效果', key: 'pageTransition' },
|
||||
{ comment: '标签页样式', key: 'tabStyle' },
|
||||
{ comment: '自定义圆角', key: 'customRadius' },
|
||||
{ comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth },
|
||||
{ comment: '节日日期', key: 'festivalDate', forceValue: '' }
|
||||
]
|
||||
|
||||
/**
|
||||
* 将值转换为代码字符串
|
||||
*/
|
||||
const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
/**
|
||||
* 将值转换为代码字符串
|
||||
*/
|
||||
const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
|
||||
if (value === null) return 'null'
|
||||
if (value === undefined) return 'undefined'
|
||||
|
||||
// 优先查找枚举映射
|
||||
if (enumMap && typeof value === 'string' && enumMap[value]) {
|
||||
return enumMap[value]
|
||||
}
|
||||
// 优先查找枚举映射
|
||||
if (enumMap && typeof value === 'string' && enumMap[value]) {
|
||||
return enumMap[value]
|
||||
}
|
||||
|
||||
// 其他类型处理
|
||||
if (typeof value === 'string') return `'${value}'`
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
||||
// 其他类型处理
|
||||
if (typeof value === 'string') return `'${value}'`
|
||||
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成配置代码
|
||||
*/
|
||||
const generateConfigCode = (): string => {
|
||||
const lines = ['export const SETTING_DEFAULT_CONFIG = {']
|
||||
/**
|
||||
* 生成配置代码
|
||||
*/
|
||||
const generateConfigCode = (): string => {
|
||||
const lines = ['export const SETTING_DEFAULT_CONFIG = {']
|
||||
|
||||
CONFIG_ITEMS.forEach((item) => {
|
||||
lines.push(` /** ${item.comment} */`)
|
||||
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key]
|
||||
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`)
|
||||
})
|
||||
CONFIG_ITEMS.forEach((item) => {
|
||||
lines.push(` /** ${item.comment} */`)
|
||||
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key]
|
||||
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`)
|
||||
})
|
||||
|
||||
lines.push('}')
|
||||
return lines.join('\n')
|
||||
}
|
||||
lines.push('}')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制配置到剪贴板
|
||||
*/
|
||||
const handleCopyConfig = async () => {
|
||||
try {
|
||||
const configText = generateConfigCode()
|
||||
await copy(configText)
|
||||
/**
|
||||
* 复制配置到剪贴板
|
||||
*/
|
||||
const handleCopyConfig = async () => {
|
||||
try {
|
||||
const configText = generateConfigCode()
|
||||
await copy(configText)
|
||||
|
||||
if (copied.value) {
|
||||
ElMessage.success({
|
||||
message: t('setting.actions.copySuccess'),
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制配置失败:', error)
|
||||
ElMessage.error(t('setting.actions.copyFailed'))
|
||||
}
|
||||
}
|
||||
if (copied.value) {
|
||||
ElMessage.success({
|
||||
message: t('setting.actions.copySuccess'),
|
||||
duration: 3000
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制配置失败:', error)
|
||||
ElMessage.error(t('setting.actions.copyFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换布尔值配置(如果当前值与默认值不同)
|
||||
*/
|
||||
const toggleIfDifferent = (
|
||||
currentValue: boolean,
|
||||
defaultValue: boolean,
|
||||
toggleFn: () => void
|
||||
) => {
|
||||
if (currentValue !== defaultValue) {
|
||||
toggleFn()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 切换布尔值配置(如果当前值与默认值不同)
|
||||
*/
|
||||
const toggleIfDifferent = (
|
||||
currentValue: boolean,
|
||||
defaultValue: boolean,
|
||||
toggleFn: () => void
|
||||
) => {
|
||||
if (currentValue !== defaultValue) {
|
||||
toggleFn()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置配置为默认值
|
||||
*/
|
||||
const handleResetConfig = async () => {
|
||||
try {
|
||||
const config = SETTING_DEFAULT_CONFIG
|
||||
/**
|
||||
* 重置配置为默认值
|
||||
*/
|
||||
const handleResetConfig = async () => {
|
||||
try {
|
||||
const config = SETTING_DEFAULT_CONFIG
|
||||
|
||||
// 菜单相关
|
||||
settingStore.switchMenuLayouts(config.menuType)
|
||||
settingStore.setMenuOpenWidth(config.menuOpenWidth)
|
||||
settingStore.setMenuOpen(config.menuOpen)
|
||||
settingStore.setDualMenuShowText(config.dualMenuShowText)
|
||||
// 菜单相关
|
||||
settingStore.switchMenuLayouts(config.menuType)
|
||||
settingStore.setMenuOpenWidth(config.menuOpenWidth)
|
||||
settingStore.setMenuOpen(config.menuOpen)
|
||||
settingStore.setDualMenuShowText(config.dualMenuShowText)
|
||||
|
||||
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
|
||||
switchThemeStyles(config.systemThemeMode)
|
||||
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
|
||||
switchThemeStyles(config.systemThemeMode)
|
||||
|
||||
// 等待主题切换完成后,根据实际应用的主题设置菜单主题
|
||||
await nextTick()
|
||||
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType
|
||||
settingStore.switchMenuStyles(menuTheme)
|
||||
// 等待主题切换完成后,根据实际应用的主题设置菜单主题
|
||||
await nextTick()
|
||||
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType
|
||||
settingStore.switchMenuStyles(menuTheme)
|
||||
|
||||
settingStore.setElementTheme(config.systemThemeColor)
|
||||
settingStore.setElementTheme(config.systemThemeColor)
|
||||
|
||||
// 界面显示(切换类方法)
|
||||
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
|
||||
settingStore.setButton()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
|
||||
settingStore.setFastEnter()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
|
||||
settingStore.setShowRefreshButton()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () => settingStore.setCrumbs())
|
||||
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
|
||||
settingStore.setLanguage()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
|
||||
settingStore.setNprogress()
|
||||
)
|
||||
// 界面显示(切换类方法)
|
||||
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
|
||||
settingStore.setButton()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
|
||||
settingStore.setFastEnter()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
|
||||
settingStore.setShowRefreshButton()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () =>
|
||||
settingStore.setCrumbs()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
|
||||
settingStore.setLanguage()
|
||||
)
|
||||
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
|
||||
settingStore.setNprogress()
|
||||
)
|
||||
|
||||
// 界面显示(直接设置类方法)
|
||||
settingStore.setWorkTab(config.showWorkTab)
|
||||
settingStore.setShowFestivalText(config.showFestivalText)
|
||||
settingStore.setWatermarkVisible(config.watermarkVisible)
|
||||
// 界面显示(直接设置类方法)
|
||||
settingStore.setWorkTab(config.showWorkTab)
|
||||
settingStore.setShowFestivalText(config.showFestivalText)
|
||||
settingStore.setWatermarkVisible(config.watermarkVisible)
|
||||
|
||||
// 功能设置
|
||||
toggleIfDifferent(settingStore.autoClose, config.autoClose, () => settingStore.setAutoClose())
|
||||
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
|
||||
settingStore.setUniqueOpened()
|
||||
)
|
||||
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () => settingStore.setColorWeak())
|
||||
// 功能设置
|
||||
toggleIfDifferent(settingStore.autoClose, config.autoClose, () =>
|
||||
settingStore.setAutoClose()
|
||||
)
|
||||
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
|
||||
settingStore.setUniqueOpened()
|
||||
)
|
||||
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () =>
|
||||
settingStore.setColorWeak()
|
||||
)
|
||||
|
||||
// 样式设置
|
||||
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
|
||||
settingStore.setBorderMode()
|
||||
)
|
||||
settingStore.setPageTransition(config.pageTransition)
|
||||
settingStore.setTabStyle(config.tabStyle)
|
||||
settingStore.setCustomRadius(config.customRadius)
|
||||
settingStore.setContainerWidth(config.containerWidth)
|
||||
// 样式设置
|
||||
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
|
||||
settingStore.setBorderMode()
|
||||
)
|
||||
settingStore.setPageTransition(config.pageTransition)
|
||||
settingStore.setTabStyle(config.tabStyle)
|
||||
settingStore.setCustomRadius(config.customRadius)
|
||||
settingStore.setContainerWidth(config.containerWidth)
|
||||
|
||||
// 节日相关
|
||||
settingStore.setFestivalDate(config.festivalDate)
|
||||
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded)
|
||||
// 节日相关
|
||||
settingStore.setFestivalDate(config.festivalDate)
|
||||
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded)
|
||||
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
console.error('重置配置失败:', error)
|
||||
ElMessage.error(t('setting.actions.resetFailed'))
|
||||
}
|
||||
}
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
console.error('重置配置失败:', error)
|
||||
ElMessage.error(t('setting.actions.resetFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<template>
|
||||
<div class="setting-drawer">
|
||||
<ElDrawer
|
||||
size="300px"
|
||||
v-model="visible"
|
||||
:lock-scroll="true"
|
||||
:with-header="false"
|
||||
:before-close="handleClose"
|
||||
:destroy-on-close="false"
|
||||
modal-class="setting-modal"
|
||||
@open="handleOpen"
|
||||
@close="handleDrawerClose"
|
||||
>
|
||||
<div class="drawer-con">
|
||||
<slot />
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</div>
|
||||
<div class="setting-drawer">
|
||||
<ElDrawer
|
||||
size="300px"
|
||||
v-model="visible"
|
||||
:lock-scroll="true"
|
||||
:with-header="false"
|
||||
:before-close="handleClose"
|
||||
:destroy-on-close="false"
|
||||
modal-class="setting-modal"
|
||||
@open="handleOpen"
|
||||
@close="handleDrawerClose"
|
||||
>
|
||||
<div class="drawer-con">
|
||||
<slot />
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
interface Props {
|
||||
modelValue: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'open'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'open'): void
|
||||
(e: 'close'): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const handleOpen = () => {
|
||||
emit('open')
|
||||
}
|
||||
const handleOpen = () => {
|
||||
emit('open')
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
const handleDrawerClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const handleClose = () => {
|
||||
visible.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex justify-end">
|
||||
<div
|
||||
@click="$emit('close')"
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex justify-end">
|
||||
<div
|
||||
@click="$emit('close')"
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,101 +1,105 @@
|
||||
<template>
|
||||
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
|
||||
<span class="text-sm">{{ config.label }}</span>
|
||||
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
|
||||
<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
|
||||
v-else-if="config.type === 'input-number'"
|
||||
:model-value="modelValue"
|
||||
:min="config.min"
|
||||
:max="config.max"
|
||||
:step="config.step"
|
||||
:style="config.style"
|
||||
:controls-position="config.controlsPosition"
|
||||
@change="handleChange"
|
||||
/>
|
||||
<!-- 数字输入类型 -->
|
||||
<ElInputNumber
|
||||
v-else-if="config.type === 'input-number'"
|
||||
:model-value="modelValue"
|
||||
:min="config.min"
|
||||
:max="config.max"
|
||||
:step="config.step"
|
||||
:style="config.style"
|
||||
:controls-position="config.controlsPosition"
|
||||
@change="handleChange"
|
||||
/>
|
||||
|
||||
<!-- 选择器类型 -->
|
||||
<ElSelect
|
||||
v-else-if="config.type === 'select'"
|
||||
:model-value="modelValue"
|
||||
:style="config.style"
|
||||
@change="handleChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
<!-- 选择器类型 -->
|
||||
<ElSelect
|
||||
v-else-if="config.type === 'select'"
|
||||
:model-value="modelValue"
|
||||
:style="config.style"
|
||||
@change="handleChange"
|
||||
>
|
||||
<ElOption
|
||||
v-for="option in normalizedOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ComputedRef } from 'vue'
|
||||
import type { ComputedRef } from 'vue'
|
||||
|
||||
interface SettingItemConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: 'switch' | 'input-number' | 'select'
|
||||
handler: string
|
||||
mobileHide?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
style?: Record<string, string>
|
||||
controlsPosition?: '' | 'right'
|
||||
options?:
|
||||
| Array<{ value: any; label: string }>
|
||||
| ComputedRef<Array<{ value: any; label: string }>>
|
||||
}
|
||||
interface SettingItemConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: 'switch' | 'input-number' | 'select'
|
||||
handler: string
|
||||
mobileHide?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
style?: Record<string, string>
|
||||
controlsPosition?: '' | 'right'
|
||||
options?:
|
||||
| Array<{ value: any; label: string }>
|
||||
| ComputedRef<Array<{ value: any; label: string }>>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
config: SettingItemConfig
|
||||
modelValue: any
|
||||
}
|
||||
interface Props {
|
||||
config: SettingItemConfig
|
||||
modelValue: any
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', value: any): void
|
||||
}
|
||||
interface Emits {
|
||||
(e: 'change', value: any): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// 标准化选项,处理computed和普通数组
|
||||
const normalizedOptions = computed(() => {
|
||||
if (!props.config.options) return []
|
||||
// 标准化选项,处理computed和普通数组
|
||||
const normalizedOptions = computed(() => {
|
||||
if (!props.config.options) return []
|
||||
|
||||
try {
|
||||
// 如果是 ComputedRef,则返回其值
|
||||
if (typeof props.config.options === 'object' && 'value' in props.config.options) {
|
||||
return props.config.options.value || []
|
||||
}
|
||||
try {
|
||||
// 如果是 ComputedRef,则返回其值
|
||||
if (typeof props.config.options === 'object' && 'value' in props.config.options) {
|
||||
return props.config.options.value || []
|
||||
}
|
||||
|
||||
// 如果是普通数组,直接返回
|
||||
return Array.isArray(props.config.options) ? props.config.options : []
|
||||
} catch (error) {
|
||||
console.warn('Error processing options for config:', props.config.key, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
// 如果是普通数组,直接返回
|
||||
return Array.isArray(props.config.options) ? props.config.options : []
|
||||
} catch (error) {
|
||||
console.warn('Error processing options for config:', props.config.key, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const handleChange = (value: any) => {
|
||||
try {
|
||||
emit('change', value)
|
||||
} catch (error) {
|
||||
console.error('Error handling change for config:', props.config.key, error)
|
||||
}
|
||||
}
|
||||
const handleChange = (value: any) => {
|
||||
try {
|
||||
emit('change', value)
|
||||
} catch (error) {
|
||||
console.error('Error handling change for config:', props.config.key, error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@media screen and (width <= 768px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@media screen and (width <= 768px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
<template>
|
||||
<SectionTitle :title="$t('setting.theme.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="(item, index) in configOptions.themeList"
|
||||
:key="item.theme"
|
||||
@click="switchThemeStyles(item.theme)"
|
||||
>
|
||||
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionTitle :title="$t('setting.theme.title')" />
|
||||
<div class="setting-box-wrap">
|
||||
<div
|
||||
class="setting-item"
|
||||
v-for="(item, index) in configOptions.themeList"
|
||||
:key="item.theme"
|
||||
@click="switchThemeStyles(item.theme)"
|
||||
>
|
||||
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
|
||||
<img :src="item.img" />
|
||||
</div>
|
||||
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useTheme } from '@/hooks/core/useTheme'
|
||||
import SectionTitle from './SectionTitle.vue'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||
import { useTheme } from '@/hooks/core/useTheme'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeMode } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { switchThemeStyles } = useTheme()
|
||||
const settingStore = useSettingStore()
|
||||
const { systemThemeMode } = storeToRefs(settingStore)
|
||||
const { configOptions } = useSettingsConfig()
|
||||
const { switchThemeStyles } = useTheme()
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,350 +1,350 @@
|
||||
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
|
||||
<template>
|
||||
<div class="cutter-container">
|
||||
<div class="cutter-component">
|
||||
<div class="title">{{ title }}</div>
|
||||
<ImgCutter
|
||||
ref="imgCutterModal"
|
||||
@cutDown="cutDownImg"
|
||||
@onPrintImg="cutterPrintImg"
|
||||
@onImageLoadComplete="handleImageLoadComplete"
|
||||
@onImageLoadError="handleImageLoadError"
|
||||
@onClearAll="handleClearAll"
|
||||
v-bind="cutterProps"
|
||||
class="img-cutter"
|
||||
>
|
||||
<template #choose>
|
||||
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
|
||||
</template>
|
||||
<template #cancel>
|
||||
<ElButton type="danger" plain v-ripple>清除</ElButton>
|
||||
</template>
|
||||
<template #confirm>
|
||||
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
|
||||
<div></div>
|
||||
</template>
|
||||
</ImgCutter>
|
||||
</div>
|
||||
<div class="cutter-container">
|
||||
<div class="cutter-component">
|
||||
<div class="title">{{ title }}</div>
|
||||
<ImgCutter
|
||||
ref="imgCutterModal"
|
||||
@cutDown="cutDownImg"
|
||||
@onPrintImg="cutterPrintImg"
|
||||
@onImageLoadComplete="handleImageLoadComplete"
|
||||
@onImageLoadError="handleImageLoadError"
|
||||
@onClearAll="handleClearAll"
|
||||
v-bind="cutterProps"
|
||||
class="img-cutter"
|
||||
>
|
||||
<template #choose>
|
||||
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
|
||||
</template>
|
||||
<template #cancel>
|
||||
<ElButton type="danger" plain v-ripple>清除</ElButton>
|
||||
</template>
|
||||
<template #confirm>
|
||||
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
|
||||
<div></div>
|
||||
</template>
|
||||
</ImgCutter>
|
||||
</div>
|
||||
|
||||
<div v-if="showPreview" class="preview-container">
|
||||
<div class="title">{{ previewTitle }}</div>
|
||||
<div
|
||||
class="preview-box"
|
||||
:style="{
|
||||
width: `${cutterProps.cutWidth}px`,
|
||||
height: `${cutterProps.cutHeight}px`
|
||||
}"
|
||||
>
|
||||
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
|
||||
</div>
|
||||
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
|
||||
>下载图片</ElButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showPreview" class="preview-container">
|
||||
<div class="title">{{ previewTitle }}</div>
|
||||
<div
|
||||
class="preview-box"
|
||||
:style="{
|
||||
width: `${cutterProps.cutWidth}px`,
|
||||
height: `${cutterProps.cutHeight}px`
|
||||
}"
|
||||
>
|
||||
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
|
||||
</div>
|
||||
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
|
||||
>下载图片</ElButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ImgCutter from 'vue-img-cutter'
|
||||
import ImgCutter from 'vue-img-cutter'
|
||||
|
||||
defineOptions({ name: 'ArtCutterImg' })
|
||||
defineOptions({ name: 'ArtCutterImg' })
|
||||
|
||||
interface CutterProps {
|
||||
// 基础配置
|
||||
/** 是否模态框 */
|
||||
isModal?: boolean
|
||||
/** 是否显示工具栏 */
|
||||
tool?: boolean
|
||||
/** 工具栏背景色 */
|
||||
toolBgc?: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 预览标题 */
|
||||
previewTitle?: string
|
||||
/** 是否显示预览 */
|
||||
showPreview?: boolean
|
||||
interface CutterProps {
|
||||
// 基础配置
|
||||
/** 是否模态框 */
|
||||
isModal?: boolean
|
||||
/** 是否显示工具栏 */
|
||||
tool?: boolean
|
||||
/** 工具栏背景色 */
|
||||
toolBgc?: string
|
||||
/** 标题 */
|
||||
title?: string
|
||||
/** 预览标题 */
|
||||
previewTitle?: string
|
||||
/** 是否显示预览 */
|
||||
showPreview?: boolean
|
||||
|
||||
// 尺寸相关
|
||||
/** 容器宽度 */
|
||||
boxWidth?: number
|
||||
/** 容器高度 */
|
||||
boxHeight?: number
|
||||
/** 裁剪宽度 */
|
||||
cutWidth?: number
|
||||
/** 裁剪高度 */
|
||||
cutHeight?: number
|
||||
/** 是否允许大小调整 */
|
||||
sizeChange?: boolean
|
||||
// 尺寸相关
|
||||
/** 容器宽度 */
|
||||
boxWidth?: number
|
||||
/** 容器高度 */
|
||||
boxHeight?: number
|
||||
/** 裁剪宽度 */
|
||||
cutWidth?: number
|
||||
/** 裁剪高度 */
|
||||
cutHeight?: number
|
||||
/** 是否允许大小调整 */
|
||||
sizeChange?: boolean
|
||||
|
||||
// 移动和缩放
|
||||
/** 是否允许移动 */
|
||||
moveAble?: boolean
|
||||
/** 是否允许图片移动 */
|
||||
imgMove?: boolean
|
||||
/** 是否允许缩放 */
|
||||
scaleAble?: boolean
|
||||
// 移动和缩放
|
||||
/** 是否允许移动 */
|
||||
moveAble?: boolean
|
||||
/** 是否允许图片移动 */
|
||||
imgMove?: boolean
|
||||
/** 是否允许缩放 */
|
||||
scaleAble?: boolean
|
||||
|
||||
// 图片相关
|
||||
/** 是否显示原始图片 */
|
||||
originalGraph?: boolean
|
||||
/** 是否允许跨域 */
|
||||
crossOrigin?: boolean
|
||||
/** 文件类型 */
|
||||
fileType?: 'png' | 'jpeg' | 'webp'
|
||||
/** 质量 */
|
||||
quality?: number
|
||||
// 图片相关
|
||||
/** 是否显示原始图片 */
|
||||
originalGraph?: boolean
|
||||
/** 是否允许跨域 */
|
||||
crossOrigin?: boolean
|
||||
/** 文件类型 */
|
||||
fileType?: 'png' | 'jpeg' | 'webp'
|
||||
/** 质量 */
|
||||
quality?: number
|
||||
|
||||
// 水印
|
||||
/** 水印文本 */
|
||||
watermarkText?: string
|
||||
/** 水印字体大小 */
|
||||
watermarkFontSize?: number
|
||||
/** 水印颜色 */
|
||||
watermarkColor?: string
|
||||
// 水印
|
||||
/** 水印文本 */
|
||||
watermarkText?: string
|
||||
/** 水印字体大小 */
|
||||
watermarkFontSize?: number
|
||||
/** 水印颜色 */
|
||||
watermarkColor?: string
|
||||
|
||||
// 其他功能
|
||||
/** 是否保存裁剪位置 */
|
||||
saveCutPosition?: boolean
|
||||
/** 是否预览模式 */
|
||||
previewMode?: boolean
|
||||
// 其他功能
|
||||
/** 是否保存裁剪位置 */
|
||||
saveCutPosition?: boolean
|
||||
/** 是否预览模式 */
|
||||
previewMode?: boolean
|
||||
|
||||
// 输入图片
|
||||
imgUrl?: string
|
||||
}
|
||||
// 输入图片
|
||||
imgUrl?: string
|
||||
}
|
||||
|
||||
interface CutterResult {
|
||||
fileName: string
|
||||
file: File
|
||||
blob: Blob
|
||||
dataURL: string
|
||||
}
|
||||
interface CutterResult {
|
||||
fileName: string
|
||||
file: File
|
||||
blob: Blob
|
||||
dataURL: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CutterProps>(), {
|
||||
// 基础配置默认值
|
||||
isModal: false,
|
||||
tool: true,
|
||||
toolBgc: '#fff',
|
||||
title: '',
|
||||
previewTitle: '',
|
||||
showPreview: true,
|
||||
const props = withDefaults(defineProps<CutterProps>(), {
|
||||
// 基础配置默认值
|
||||
isModal: false,
|
||||
tool: true,
|
||||
toolBgc: '#fff',
|
||||
title: '',
|
||||
previewTitle: '',
|
||||
showPreview: true,
|
||||
|
||||
// 尺寸相关默认值
|
||||
boxWidth: 700,
|
||||
boxHeight: 458,
|
||||
cutWidth: 470,
|
||||
cutHeight: 270,
|
||||
sizeChange: true,
|
||||
// 尺寸相关默认值
|
||||
boxWidth: 700,
|
||||
boxHeight: 458,
|
||||
cutWidth: 470,
|
||||
cutHeight: 270,
|
||||
sizeChange: true,
|
||||
|
||||
// 移动和缩放默认值
|
||||
moveAble: true,
|
||||
imgMove: true,
|
||||
scaleAble: true,
|
||||
// 移动和缩放默认值
|
||||
moveAble: true,
|
||||
imgMove: true,
|
||||
scaleAble: true,
|
||||
|
||||
// 图片相关默认值
|
||||
originalGraph: true,
|
||||
crossOrigin: true,
|
||||
fileType: 'png',
|
||||
quality: 0.9,
|
||||
// 图片相关默认值
|
||||
originalGraph: true,
|
||||
crossOrigin: true,
|
||||
fileType: 'png',
|
||||
quality: 0.9,
|
||||
|
||||
// 水印默认值
|
||||
watermarkText: '',
|
||||
watermarkFontSize: 20,
|
||||
watermarkColor: '#ffffff',
|
||||
// 水印默认值
|
||||
watermarkText: '',
|
||||
watermarkFontSize: 20,
|
||||
watermarkColor: '#ffffff',
|
||||
|
||||
// 其他功能默认值
|
||||
saveCutPosition: true,
|
||||
previewMode: true
|
||||
})
|
||||
// 其他功能默认值
|
||||
saveCutPosition: true,
|
||||
previewMode: true
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
|
||||
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
|
||||
|
||||
const temImgPath = ref('')
|
||||
const imgCutterModal = ref()
|
||||
const temImgPath = ref('')
|
||||
const imgCutterModal = ref()
|
||||
|
||||
// 计算属性:整合所有ImgCutter的props
|
||||
const cutterProps = computed(() => ({
|
||||
...props,
|
||||
WatermarkText: props.watermarkText,
|
||||
WatermarkFontSize: props.watermarkFontSize,
|
||||
WatermarkColor: props.watermarkColor
|
||||
}))
|
||||
// 计算属性:整合所有ImgCutter的props
|
||||
const cutterProps = computed(() => ({
|
||||
...props,
|
||||
WatermarkText: props.watermarkText,
|
||||
WatermarkFontSize: props.watermarkFontSize,
|
||||
WatermarkColor: props.watermarkColor
|
||||
}))
|
||||
|
||||
// 图片预加载
|
||||
function preloadImage(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve()
|
||||
img.onerror = reject
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
// 图片预加载
|
||||
function preloadImage(url: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve()
|
||||
img.onerror = reject
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// 初始化裁剪器
|
||||
async function initImgCutter() {
|
||||
if (props.imgUrl) {
|
||||
try {
|
||||
await preloadImage(props.imgUrl)
|
||||
imgCutterModal.value?.handleOpen({
|
||||
name: '封面图片',
|
||||
src: props.imgUrl
|
||||
})
|
||||
} catch (error) {
|
||||
emit('error', error)
|
||||
console.error('图片加载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 初始化裁剪器
|
||||
async function initImgCutter() {
|
||||
if (props.imgUrl) {
|
||||
try {
|
||||
await preloadImage(props.imgUrl)
|
||||
imgCutterModal.value?.handleOpen({
|
||||
name: '封面图片',
|
||||
src: props.imgUrl
|
||||
})
|
||||
} catch (error) {
|
||||
emit('error', error)
|
||||
console.error('图片加载失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
if (props.imgUrl) {
|
||||
temImgPath.value = props.imgUrl
|
||||
initImgCutter()
|
||||
}
|
||||
})
|
||||
// 生命周期钩子
|
||||
onMounted(() => {
|
||||
if (props.imgUrl) {
|
||||
temImgPath.value = props.imgUrl
|
||||
initImgCutter()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听图片URL变化
|
||||
watch(
|
||||
() => props.imgUrl,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
temImgPath.value = newVal
|
||||
initImgCutter()
|
||||
}
|
||||
}
|
||||
)
|
||||
// 监听图片URL变化
|
||||
watch(
|
||||
() => props.imgUrl,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
temImgPath.value = newVal
|
||||
initImgCutter()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 实时预览
|
||||
function cutterPrintImg(result: { dataURL: string }) {
|
||||
temImgPath.value = result.dataURL
|
||||
}
|
||||
// 实时预览
|
||||
function cutterPrintImg(result: { dataURL: string }) {
|
||||
temImgPath.value = result.dataURL
|
||||
}
|
||||
|
||||
// 裁剪完成
|
||||
function cutDownImg(result: CutterResult) {
|
||||
emit('update:imgUrl', result.dataURL)
|
||||
}
|
||||
// 裁剪完成
|
||||
function cutDownImg(result: CutterResult) {
|
||||
emit('update:imgUrl', result.dataURL)
|
||||
}
|
||||
|
||||
// 图片加载完成
|
||||
function handleImageLoadComplete(result: any) {
|
||||
emit('imageLoadComplete', result)
|
||||
}
|
||||
// 图片加载完成
|
||||
function handleImageLoadComplete(result: any) {
|
||||
emit('imageLoadComplete', result)
|
||||
}
|
||||
|
||||
// 图片加载失败
|
||||
function handleImageLoadError(error: any) {
|
||||
emit('error', error)
|
||||
emit('imageLoadError', error)
|
||||
}
|
||||
// 图片加载失败
|
||||
function handleImageLoadError(error: any) {
|
||||
emit('error', error)
|
||||
emit('imageLoadError', error)
|
||||
}
|
||||
|
||||
// 清除所有
|
||||
function handleClearAll() {
|
||||
temImgPath.value = ''
|
||||
}
|
||||
// 清除所有
|
||||
function handleClearAll() {
|
||||
temImgPath.value = ''
|
||||
}
|
||||
|
||||
// 下载图片
|
||||
function downloadImg() {
|
||||
console.log('下载图片')
|
||||
const a = document.createElement('a')
|
||||
a.href = temImgPath.value
|
||||
a.download = 'image.png'
|
||||
a.click()
|
||||
}
|
||||
// 下载图片
|
||||
function downloadImg() {
|
||||
console.log('下载图片')
|
||||
const a = document.createElement('a')
|
||||
a.href = temImgPath.value
|
||||
a.download = 'image.png'
|
||||
a.click()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cutter-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
.cutter-container {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
|
||||
.title {
|
||||
padding-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.title {
|
||||
padding-bottom: 10px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cutter-component {
|
||||
margin-right: 30px;
|
||||
}
|
||||
.cutter-component {
|
||||
margin-right: 30px;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
.preview-box {
|
||||
background-color: var(--art-active-color) !important;
|
||||
.preview-container {
|
||||
.preview-box {
|
||||
background-color: var(--art-active-color) !important;
|
||||
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
.preview-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
}
|
||||
}
|
||||
.download-btn {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.toolBoxControl) {
|
||||
z-index: 100;
|
||||
}
|
||||
:deep(.toolBoxControl) {
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
:deep(.dockMain) {
|
||||
right: 0;
|
||||
bottom: -40px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
background-color: transparent !important;
|
||||
opacity: 1;
|
||||
}
|
||||
:deep(.dockMain) {
|
||||
right: 0;
|
||||
bottom: -40px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
padding: 0;
|
||||
background-color: transparent !important;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:deep(.copyright) {
|
||||
display: none !important;
|
||||
}
|
||||
:deep(.copyright) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:deep(.i-dialog-footer) {
|
||||
margin-top: 60px !important;
|
||||
}
|
||||
:deep(.i-dialog-footer) {
|
||||
margin-top: 60px !important;
|
||||
}
|
||||
|
||||
:deep(.dockBtn) {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 26px;
|
||||
color: var(--el-color-primary) !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border: 1px solid var(--el-color-primary-light-4) !important;
|
||||
}
|
||||
:deep(.dockBtn) {
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
line-height: 26px;
|
||||
color: var(--el-color-primary) !important;
|
||||
background-color: var(--el-color-primary-light-9) !important;
|
||||
border: 1px solid var(--el-color-primary-light-4) !important;
|
||||
}
|
||||
|
||||
:deep(.dockBtnScrollBar) {
|
||||
margin: 0 10px 0 6px;
|
||||
background-color: var(--el-color-primary-light-1);
|
||||
}
|
||||
:deep(.dockBtnScrollBar) {
|
||||
margin: 0 10px 0 6px;
|
||||
background-color: var(--el-color-primary-light-1);
|
||||
}
|
||||
|
||||
:deep(.scrollBarControl) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
:deep(.scrollBarControl) {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
:deep(.closeIcon) {
|
||||
line-height: 15px !important;
|
||||
}
|
||||
}
|
||||
:deep(.closeIcon) {
|
||||
line-height: 15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.cutter-container {
|
||||
:deep(.toolBox) {
|
||||
border: transparent;
|
||||
}
|
||||
.dark {
|
||||
.cutter-container {
|
||||
:deep(.toolBox) {
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
:deep(.dialogMain) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
:deep(.dialogMain) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.i-dialog-footer) {
|
||||
.btn {
|
||||
background-color: var(--el-color-primary) !important;
|
||||
border: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
:deep(.i-dialog-footer) {
|
||||
.btn {
|
||||
background-color: var(--el-color-primary) !important;
|
||||
border: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,111 +1,111 @@
|
||||
<!-- 视频播放器组件:https://h5player.bytedance.com/-->
|
||||
<template>
|
||||
<div :id="playerId" />
|
||||
<div :id="playerId" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Player from 'xgplayer'
|
||||
import 'xgplayer/dist/index.min.css'
|
||||
import Player from 'xgplayer'
|
||||
import 'xgplayer/dist/index.min.css'
|
||||
|
||||
defineOptions({ name: 'ArtVideoPlayer' })
|
||||
defineOptions({ name: 'ArtVideoPlayer' })
|
||||
|
||||
interface Props {
|
||||
/** 播放器容器 ID */
|
||||
playerId: string
|
||||
/** 视频源URL */
|
||||
videoUrl: string
|
||||
/** 视频封面图URL */
|
||||
posterUrl: string
|
||||
/** 是否自动播放 */
|
||||
autoplay?: boolean
|
||||
/** 音量大小(0-1) */
|
||||
volume?: number
|
||||
/** 可选的播放速率 */
|
||||
playbackRates?: number[]
|
||||
/** 是否循环播放 */
|
||||
loop?: boolean
|
||||
/** 是否静音 */
|
||||
muted?: boolean
|
||||
commonStyle?: VideoPlayerStyle
|
||||
}
|
||||
interface Props {
|
||||
/** 播放器容器 ID */
|
||||
playerId: string
|
||||
/** 视频源URL */
|
||||
videoUrl: string
|
||||
/** 视频封面图URL */
|
||||
posterUrl: string
|
||||
/** 是否自动播放 */
|
||||
autoplay?: boolean
|
||||
/** 音量大小(0-1) */
|
||||
volume?: number
|
||||
/** 可选的播放速率 */
|
||||
playbackRates?: number[]
|
||||
/** 是否循环播放 */
|
||||
loop?: boolean
|
||||
/** 是否静音 */
|
||||
muted?: boolean
|
||||
commonStyle?: VideoPlayerStyle
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
playerId: '',
|
||||
videoUrl: '',
|
||||
posterUrl: '',
|
||||
autoplay: false,
|
||||
volume: 1,
|
||||
loop: false,
|
||||
muted: false
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
playerId: '',
|
||||
videoUrl: '',
|
||||
posterUrl: '',
|
||||
autoplay: false,
|
||||
volume: 1,
|
||||
loop: false,
|
||||
muted: false
|
||||
})
|
||||
|
||||
// 设置属性默认值
|
||||
// 设置属性默认值
|
||||
|
||||
// 播放器实例引用
|
||||
const playerInstance = ref<Player | null>(null)
|
||||
// 播放器实例引用
|
||||
const playerInstance = ref<Player | null>(null)
|
||||
|
||||
// 播放器样式接口定义
|
||||
interface VideoPlayerStyle {
|
||||
progressColor?: string // 进度条背景色
|
||||
playedColor?: string // 已播放部分颜色
|
||||
cachedColor?: string // 缓存部分颜色
|
||||
sliderBtnStyle?: Record<string, string> // 滑块按钮样式
|
||||
volumeColor?: string // 音量控制器颜色
|
||||
}
|
||||
// 播放器样式接口定义
|
||||
interface VideoPlayerStyle {
|
||||
progressColor?: string // 进度条背景色
|
||||
playedColor?: string // 已播放部分颜色
|
||||
cachedColor?: string // 缓存部分颜色
|
||||
sliderBtnStyle?: Record<string, string> // 滑块按钮样式
|
||||
volumeColor?: string // 音量控制器颜色
|
||||
}
|
||||
|
||||
// 默认样式配置
|
||||
const defaultStyle: VideoPlayerStyle = {
|
||||
progressColor: 'rgba(255, 255, 255, 0.3)',
|
||||
playedColor: '#00AEED',
|
||||
cachedColor: 'rgba(255, 255, 255, 0.6)',
|
||||
sliderBtnStyle: {
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
backgroundColor: '#00AEED'
|
||||
},
|
||||
volumeColor: '#00AEED'
|
||||
}
|
||||
// 默认样式配置
|
||||
const defaultStyle: VideoPlayerStyle = {
|
||||
progressColor: 'rgba(255, 255, 255, 0.3)',
|
||||
playedColor: '#00AEED',
|
||||
cachedColor: 'rgba(255, 255, 255, 0.6)',
|
||||
sliderBtnStyle: {
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
backgroundColor: '#00AEED'
|
||||
},
|
||||
volumeColor: '#00AEED'
|
||||
}
|
||||
|
||||
// 组件挂载时初始化播放器
|
||||
onMounted(() => {
|
||||
playerInstance.value = new Player({
|
||||
id: props.playerId,
|
||||
lang: 'zh', // 设置界面语言为中文
|
||||
volume: props.volume,
|
||||
autoplay: props.autoplay,
|
||||
screenShot: true, // 启用截图功能
|
||||
url: props.videoUrl,
|
||||
poster: props.posterUrl,
|
||||
fluid: true, // 启用流式布局,自适应容器大小
|
||||
playbackRate: props.playbackRates,
|
||||
loop: props.loop,
|
||||
muted: props.muted,
|
||||
commonStyle: {
|
||||
...defaultStyle,
|
||||
...props.commonStyle
|
||||
}
|
||||
})
|
||||
// 组件挂载时初始化播放器
|
||||
onMounted(() => {
|
||||
playerInstance.value = new Player({
|
||||
id: props.playerId,
|
||||
lang: 'zh', // 设置界面语言为中文
|
||||
volume: props.volume,
|
||||
autoplay: props.autoplay,
|
||||
screenShot: true, // 启用截图功能
|
||||
url: props.videoUrl,
|
||||
poster: props.posterUrl,
|
||||
fluid: true, // 启用流式布局,自适应容器大小
|
||||
playbackRate: props.playbackRates,
|
||||
loop: props.loop,
|
||||
muted: props.muted,
|
||||
commonStyle: {
|
||||
...defaultStyle,
|
||||
...props.commonStyle
|
||||
}
|
||||
})
|
||||
|
||||
// 播放事件监听器
|
||||
playerInstance.value.on('play', () => {
|
||||
console.log('Video is playing')
|
||||
})
|
||||
// 播放事件监听器
|
||||
playerInstance.value.on('play', () => {
|
||||
console.log('Video is playing')
|
||||
})
|
||||
|
||||
// 暂停事件监听器
|
||||
playerInstance.value.on('pause', () => {
|
||||
console.log('Video is paused')
|
||||
})
|
||||
// 暂停事件监听器
|
||||
playerInstance.value.on('pause', () => {
|
||||
console.log('Video is paused')
|
||||
})
|
||||
|
||||
// 错误事件监听器
|
||||
playerInstance.value.on('error', (error) => {
|
||||
console.error('Error occurred:', error)
|
||||
})
|
||||
})
|
||||
// 错误事件监听器
|
||||
playerInstance.value.on('error', (error) => {
|
||||
console.error('Error occurred:', error)
|
||||
})
|
||||
})
|
||||
|
||||
// 组件卸载前清理播放器实例
|
||||
onBeforeUnmount(() => {
|
||||
if (playerInstance.value) {
|
||||
playerInstance.value.destroy()
|
||||
}
|
||||
})
|
||||
// 组件卸载前清理播放器实例
|
||||
onBeforeUnmount(() => {
|
||||
if (playerInstance.value) {
|
||||
playerInstance.value.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,415 +1,418 @@
|
||||
<!-- 右键菜单 -->
|
||||
<template>
|
||||
<div class="menu-right">
|
||||
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
||||
<div
|
||||
v-show="visible"
|
||||
:style="menuStyle"
|
||||
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">
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<!-- 普通菜单项 -->
|
||||
<li
|
||||
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="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
v-if="item.icon"
|
||||
class="mr-2 shrink-0 text-base text-g-800"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
<span
|
||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
</li>
|
||||
<div class="menu-right">
|
||||
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
||||
<div
|
||||
v-show="visible"
|
||||
:style="menuStyle"
|
||||
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">
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<!-- 普通菜单项 -->
|
||||
<li
|
||||
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="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(item)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
v-if="item.icon"
|
||||
class="mr-2 shrink-0 text-base text-g-800"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
<span
|
||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
</li>
|
||||
|
||||
<!-- 子菜单 -->
|
||||
<li
|
||||
v-else
|
||||
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
||||
:style="menuItemStyle"
|
||||
>
|
||||
<div class="submenu-title flex-c w-full">
|
||||
<ArtSvgIcon
|
||||
v-if="item.icon"
|
||||
class="mr-2 shrink-0 text-base text-g-800"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
<span
|
||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:arrow-right-s-line"
|
||||
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
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"
|
||||
>
|
||||
<li
|
||||
v-for="child in item.children"
|
||||
: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="{ 'is-disabled': child.disabled, 'has-line': child.showLine }"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(child)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
v-if="child.icon"
|
||||
class="r-2 shrink-0 text-base text-g-800 mr-1"
|
||||
:icon="child.icon"
|
||||
/>
|
||||
<span
|
||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||
>{{ child.label }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<!-- 子菜单 -->
|
||||
<li
|
||||
v-else
|
||||
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
||||
:style="menuItemStyle"
|
||||
>
|
||||
<div class="submenu-title flex-c w-full">
|
||||
<ArtSvgIcon
|
||||
v-if="item.icon"
|
||||
class="mr-2 shrink-0 text-base text-g-800"
|
||||
:icon="item.icon"
|
||||
/>
|
||||
<span
|
||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||
>{{ item.label }}</span
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:arrow-right-s-line"
|
||||
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
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"
|
||||
>
|
||||
<li
|
||||
v-for="child in item.children"
|
||||
: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="{
|
||||
'is-disabled': child.disabled,
|
||||
'has-line': child.showLine
|
||||
}"
|
||||
:style="menuItemStyle"
|
||||
@click="handleMenuClick(child)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
v-if="child.icon"
|
||||
class="r-2 shrink-0 text-base text-g-800 mr-1"
|
||||
:icon="child.icon"
|
||||
/>
|
||||
<span
|
||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||
>{{ child.label }}</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { CSSProperties } from 'vue'
|
||||
import type { CSSProperties } from 'vue'
|
||||
|
||||
defineOptions({ name: 'ArtMenuRight' })
|
||||
defineOptions({ name: 'ArtMenuRight' })
|
||||
|
||||
export interface MenuItemType {
|
||||
/** 菜单项唯一标识 */
|
||||
key: string
|
||||
/** 菜单项标签 */
|
||||
label: string
|
||||
/** 菜单项图标 */
|
||||
icon?: string
|
||||
/** 菜单项是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 菜单项是否显示分割线 */
|
||||
showLine?: boolean
|
||||
/** 子菜单 */
|
||||
children?: MenuItemType[]
|
||||
[key: string]: any
|
||||
}
|
||||
export interface MenuItemType {
|
||||
/** 菜单项唯一标识 */
|
||||
key: string
|
||||
/** 菜单项标签 */
|
||||
label: string
|
||||
/** 菜单项图标 */
|
||||
icon?: string
|
||||
/** 菜单项是否禁用 */
|
||||
disabled?: boolean
|
||||
/** 菜单项是否显示分割线 */
|
||||
showLine?: boolean
|
||||
/** 子菜单 */
|
||||
children?: MenuItemType[]
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
interface Props {
|
||||
menuItems: MenuItemType[]
|
||||
/** 菜单宽度 */
|
||||
menuWidth?: number
|
||||
/** 子菜单宽度 */
|
||||
submenuWidth?: number
|
||||
/** 菜单项高度 */
|
||||
itemHeight?: number
|
||||
/** 边界距离 */
|
||||
boundaryDistance?: number
|
||||
/** 菜单内边距 */
|
||||
menuPadding?: number
|
||||
/** 菜单项水平内边距 */
|
||||
itemPaddingX?: number
|
||||
/** 菜单圆角 */
|
||||
borderRadius?: number
|
||||
/** 动画持续时间 */
|
||||
animationDuration?: number
|
||||
}
|
||||
interface Props {
|
||||
menuItems: MenuItemType[]
|
||||
/** 菜单宽度 */
|
||||
menuWidth?: number
|
||||
/** 子菜单宽度 */
|
||||
submenuWidth?: number
|
||||
/** 菜单项高度 */
|
||||
itemHeight?: number
|
||||
/** 边界距离 */
|
||||
boundaryDistance?: number
|
||||
/** 菜单内边距 */
|
||||
menuPadding?: number
|
||||
/** 菜单项水平内边距 */
|
||||
itemPaddingX?: number
|
||||
/** 菜单圆角 */
|
||||
borderRadius?: number
|
||||
/** 动画持续时间 */
|
||||
animationDuration?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
menuWidth: 120,
|
||||
submenuWidth: 150,
|
||||
itemHeight: 32,
|
||||
boundaryDistance: 10,
|
||||
menuPadding: 5,
|
||||
itemPaddingX: 6,
|
||||
borderRadius: 6,
|
||||
animationDuration: 100
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
menuWidth: 120,
|
||||
submenuWidth: 150,
|
||||
itemHeight: 32,
|
||||
boundaryDistance: 10,
|
||||
menuPadding: 5,
|
||||
itemPaddingX: 6,
|
||||
borderRadius: 6,
|
||||
animationDuration: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: MenuItemType): void
|
||||
(e: 'show'): void
|
||||
(e: 'hide'): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', item: MenuItemType): void
|
||||
(e: 'show'): void
|
||||
(e: 'hide'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const visible = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
|
||||
// 用于清理定时器和事件监听器
|
||||
let showTimer: number | null = null
|
||||
let eventListenersAdded = false
|
||||
// 用于清理定时器和事件监听器
|
||||
let showTimer: number | null = null
|
||||
let eventListenersAdded = false
|
||||
|
||||
// 计算菜单样式
|
||||
const menuStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'fixed' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
zIndex: 2000,
|
||||
width: `${props.menuWidth}px`
|
||||
})
|
||||
)
|
||||
// 计算菜单样式
|
||||
const menuStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'fixed' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
zIndex: 2000,
|
||||
width: `${props.menuWidth}px`
|
||||
})
|
||||
)
|
||||
|
||||
// 计算菜单列表样式
|
||||
const menuListStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
padding: `${props.menuPadding}px`
|
||||
})
|
||||
)
|
||||
// 计算菜单列表样式
|
||||
const menuListStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
padding: `${props.menuPadding}px`
|
||||
})
|
||||
)
|
||||
|
||||
// 计算菜单项样式
|
||||
const menuItemStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
height: `${props.itemHeight}px`,
|
||||
padding: `0 ${props.itemPaddingX}px`,
|
||||
borderRadius: '4px'
|
||||
})
|
||||
)
|
||||
// 计算菜单项样式
|
||||
const menuItemStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
height: `${props.itemHeight}px`,
|
||||
padding: `0 ${props.itemPaddingX}px`,
|
||||
borderRadius: '4px'
|
||||
})
|
||||
)
|
||||
|
||||
// 计算子菜单列表样式
|
||||
const submenuListStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
minWidth: `${props.submenuWidth}px`,
|
||||
padding: `${props.menuPadding}px 0`,
|
||||
borderRadius: `${props.borderRadius}px`
|
||||
})
|
||||
)
|
||||
// 计算子菜单列表样式
|
||||
const submenuListStyle = computed(
|
||||
(): CSSProperties => ({
|
||||
minWidth: `${props.submenuWidth}px`,
|
||||
padding: `${props.menuPadding}px 0`,
|
||||
borderRadius: `${props.borderRadius}px`
|
||||
})
|
||||
)
|
||||
|
||||
// 计算菜单高度(用于边界检测)
|
||||
const calculateMenuHeight = (): number => {
|
||||
let totalHeight = props.menuPadding * 2 // 上下内边距
|
||||
// 计算菜单高度(用于边界检测)
|
||||
const calculateMenuHeight = (): number => {
|
||||
let totalHeight = props.menuPadding * 2 // 上下内边距
|
||||
|
||||
props.menuItems.forEach((item) => {
|
||||
totalHeight += props.itemHeight
|
||||
if (item.showLine) {
|
||||
totalHeight += 10 // 分割线额外高度
|
||||
}
|
||||
})
|
||||
props.menuItems.forEach((item) => {
|
||||
totalHeight += props.itemHeight
|
||||
if (item.showLine) {
|
||||
totalHeight += 10 // 分割线额外高度
|
||||
}
|
||||
})
|
||||
|
||||
return totalHeight
|
||||
}
|
||||
return totalHeight
|
||||
}
|
||||
|
||||
// 优化的位置计算函数
|
||||
const calculatePosition = (e: MouseEvent) => {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuHeight = calculateMenuHeight()
|
||||
// 优化的位置计算函数
|
||||
const calculatePosition = (e: MouseEvent) => {
|
||||
const screenWidth = window.innerWidth
|
||||
const screenHeight = window.innerHeight
|
||||
const menuHeight = calculateMenuHeight()
|
||||
|
||||
let x = e.clientX
|
||||
let y = e.clientY
|
||||
let x = e.clientX
|
||||
let y = e.clientY
|
||||
|
||||
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
|
||||
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
|
||||
x = Math.max(props.boundaryDistance, x - props.menuWidth)
|
||||
}
|
||||
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
|
||||
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
|
||||
x = Math.max(props.boundaryDistance, x - props.menuWidth)
|
||||
}
|
||||
|
||||
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
|
||||
if (y + menuHeight > screenHeight - props.boundaryDistance) {
|
||||
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
|
||||
}
|
||||
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
|
||||
if (y + menuHeight > screenHeight - props.boundaryDistance) {
|
||||
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
|
||||
}
|
||||
|
||||
// 确保不会超出边界
|
||||
x = Math.max(
|
||||
props.boundaryDistance,
|
||||
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
|
||||
)
|
||||
y = Math.max(
|
||||
props.boundaryDistance,
|
||||
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
|
||||
)
|
||||
// 确保不会超出边界
|
||||
x = Math.max(
|
||||
props.boundaryDistance,
|
||||
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
|
||||
)
|
||||
y = Math.max(
|
||||
props.boundaryDistance,
|
||||
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
|
||||
)
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
// 添加事件监听器
|
||||
const addEventListeners = () => {
|
||||
if (eventListenersAdded) return
|
||||
// 添加事件监听器
|
||||
const addEventListeners = () => {
|
||||
if (eventListenersAdded) return
|
||||
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
document.addEventListener('contextmenu', handleDocumentContextmenu)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
eventListenersAdded = true
|
||||
}
|
||||
document.addEventListener('click', handleDocumentClick)
|
||||
document.addEventListener('contextmenu', handleDocumentContextmenu)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
eventListenersAdded = true
|
||||
}
|
||||
|
||||
// 移除事件监听器
|
||||
const removeEventListeners = () => {
|
||||
if (!eventListenersAdded) return
|
||||
// 移除事件监听器
|
||||
const removeEventListeners = () => {
|
||||
if (!eventListenersAdded) return
|
||||
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
document.removeEventListener('contextmenu', handleDocumentContextmenu)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
eventListenersAdded = false
|
||||
}
|
||||
document.removeEventListener('click', handleDocumentClick)
|
||||
document.removeEventListener('contextmenu', handleDocumentContextmenu)
|
||||
document.removeEventListener('keydown', handleKeydown)
|
||||
eventListenersAdded = false
|
||||
}
|
||||
|
||||
// 处理文档点击事件
|
||||
const handleDocumentClick = (e: Event) => {
|
||||
// 检查点击是否在菜单内部
|
||||
const target = e.target as Element
|
||||
const menuElement = document.querySelector('.context-menu')
|
||||
if (menuElement && menuElement.contains(target)) {
|
||||
return
|
||||
}
|
||||
hide()
|
||||
}
|
||||
// 处理文档点击事件
|
||||
const handleDocumentClick = (e: Event) => {
|
||||
// 检查点击是否在菜单内部
|
||||
const target = e.target as Element
|
||||
const menuElement = document.querySelector('.context-menu')
|
||||
if (menuElement && menuElement.contains(target)) {
|
||||
return
|
||||
}
|
||||
hide()
|
||||
}
|
||||
|
||||
// 处理文档右键事件
|
||||
const handleDocumentContextmenu = () => {
|
||||
hide()
|
||||
}
|
||||
// 处理文档右键事件
|
||||
const handleDocumentContextmenu = () => {
|
||||
hide()
|
||||
}
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
// 处理键盘事件
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const show = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
// 清理之前的定时器
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
// 清理之前的定时器
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
|
||||
// 计算位置
|
||||
position.value = calculatePosition(e)
|
||||
visible.value = true
|
||||
// 计算位置
|
||||
position.value = calculatePosition(e)
|
||||
visible.value = true
|
||||
|
||||
emit('show')
|
||||
emit('show')
|
||||
|
||||
// 延迟添加事件监听器,避免立即触发关闭
|
||||
showTimer = window.setTimeout(() => {
|
||||
if (visible.value) {
|
||||
addEventListeners()
|
||||
}
|
||||
showTimer = null
|
||||
}, 50) // 减少延迟时间,提升响应性
|
||||
}
|
||||
// 延迟添加事件监听器,避免立即触发关闭
|
||||
showTimer = window.setTimeout(() => {
|
||||
if (visible.value) {
|
||||
addEventListeners()
|
||||
}
|
||||
showTimer = null
|
||||
}, 50) // 减少延迟时间,提升响应性
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
if (!visible.value) return
|
||||
const hide = () => {
|
||||
if (!visible.value) return
|
||||
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
visible.value = false
|
||||
emit('hide')
|
||||
|
||||
// 清理定时器
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
// 清理定时器
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
|
||||
// 移除事件监听器
|
||||
removeEventListeners()
|
||||
}
|
||||
// 移除事件监听器
|
||||
removeEventListeners()
|
||||
}
|
||||
|
||||
const handleMenuClick = (item: MenuItemType) => {
|
||||
if (item.disabled) return
|
||||
emit('select', item)
|
||||
hide()
|
||||
}
|
||||
const handleMenuClick = (item: MenuItemType) => {
|
||||
if (item.disabled) return
|
||||
emit('select', item)
|
||||
hide()
|
||||
}
|
||||
|
||||
// 动画钩子函数
|
||||
const onBeforeEnter = (el: Element) => {
|
||||
const element = el as HTMLElement
|
||||
element.style.transformOrigin = 'top left'
|
||||
}
|
||||
// 动画钩子函数
|
||||
const onBeforeEnter = (el: Element) => {
|
||||
const element = el as HTMLElement
|
||||
element.style.transformOrigin = 'top left'
|
||||
}
|
||||
|
||||
const onAfterLeave = () => {
|
||||
// 确保清理所有资源
|
||||
removeEventListeners()
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
}
|
||||
const onAfterLeave = () => {
|
||||
// 确保清理所有资源
|
||||
removeEventListeners()
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
removeEventListeners()
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
})
|
||||
// 组件卸载时清理资源
|
||||
onUnmounted(() => {
|
||||
removeEventListeners()
|
||||
if (showTimer) {
|
||||
window.clearTimeout(showTimer)
|
||||
showTimer = null
|
||||
}
|
||||
})
|
||||
|
||||
// 导出方法供父组件调用
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
visible: computed(() => visible.value)
|
||||
})
|
||||
// 导出方法供父组件调用
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
visible: computed(() => visible.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu-right {
|
||||
--menu-width: v-bind('props.menuWidth + "px"');
|
||||
--border-radius: v-bind('props.borderRadius + "px"');
|
||||
}
|
||||
.menu-right {
|
||||
--menu-width: v-bind('props.menuWidth + "px"');
|
||||
--border-radius: v-bind('props.borderRadius + "px"');
|
||||
}
|
||||
|
||||
.menu-item.has-line {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.menu-item.has-line {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.menu-item.has-line::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background-color: var(--art-gray-300);
|
||||
}
|
||||
.menu-item.has-line::after {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
height: 1px;
|
||||
content: '';
|
||||
background-color: var(--art-gray-300);
|
||||
}
|
||||
|
||||
.menu-item.is-disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.menu-item.is-disabled {
|
||||
color: var(--el-text-color-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menu-item.is-disabled:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
.menu-item.is-disabled:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.menu-item.is-disabled i:not(.submenu-arrow),
|
||||
.menu-item.is-disabled :deep(.art-svg-icon) {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
.menu-item.is-disabled i:not(.submenu-arrow),
|
||||
.menu-item.is-disabled :deep(.art-svg-icon) {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
|
||||
.menu-item.is-disabled .menu-label {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
.menu-item.is-disabled .menu-label {
|
||||
color: var(--el-text-color-disabled) !important;
|
||||
}
|
||||
|
||||
.menu-item.submenu:hover .submenu-list {
|
||||
display: block;
|
||||
}
|
||||
.menu-item.submenu:hover .submenu-list {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-item.submenu:hover .submenu-title .submenu-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.menu-item.submenu:hover .submenu-title .submenu-arrow {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
/* 动画样式 */
|
||||
.context-menu-enter-active,
|
||||
.context-menu-leave-active {
|
||||
transition: all v-bind('props.animationDuration + "ms"') ease-out;
|
||||
}
|
||||
/* 动画样式 */
|
||||
.context-menu-enter-active,
|
||||
.context-menu-leave-active {
|
||||
transition: all v-bind('props.animationDuration + "ms"') ease-out;
|
||||
}
|
||||
|
||||
.context-menu-enter-from,
|
||||
.context-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
.context-menu-enter-from,
|
||||
.context-menu-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.context-menu-enter-to,
|
||||
.context-menu-leave-from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
.context-menu-enter-to,
|
||||
.context-menu-leave-from {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,64 +1,64 @@
|
||||
<!-- 水印组件 -->
|
||||
<template>
|
||||
<div
|
||||
v-if="watermarkVisible"
|
||||
class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
|
||||
:style="{ zIndex: zIndex }"
|
||||
>
|
||||
<ElWatermark
|
||||
:content="content"
|
||||
:font="{ fontSize: fontSize, color: fontColor }"
|
||||
:rotate="rotate"
|
||||
:gap="[gapX, gapY]"
|
||||
:offset="[offsetX, offsetY]"
|
||||
>
|
||||
<div style="height: 100vh"></div>
|
||||
</ElWatermark>
|
||||
</div>
|
||||
<div
|
||||
v-if="watermarkVisible"
|
||||
class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
|
||||
:style="{ zIndex: zIndex }"
|
||||
>
|
||||
<ElWatermark
|
||||
:content="content"
|
||||
:font="{ fontSize: fontSize, color: fontColor }"
|
||||
:rotate="rotate"
|
||||
:gap="[gapX, gapY]"
|
||||
:offset="[offsetX, offsetY]"
|
||||
>
|
||||
<div style="height: 100vh"></div>
|
||||
</ElWatermark>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppConfig from '@/config'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import AppConfig from '@/config'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
defineOptions({ name: 'ArtWatermark' })
|
||||
defineOptions({ name: 'ArtWatermark' })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { watermarkVisible } = storeToRefs(settingStore)
|
||||
const settingStore = useSettingStore()
|
||||
const { watermarkVisible } = storeToRefs(settingStore)
|
||||
|
||||
interface WatermarkProps {
|
||||
/** 水印内容 */
|
||||
content?: string
|
||||
/** 水印是否可见 */
|
||||
visible?: boolean
|
||||
/** 水印字体大小 */
|
||||
fontSize?: number
|
||||
/** 水印字体颜色 */
|
||||
fontColor?: string
|
||||
/** 水印旋转角度 */
|
||||
rotate?: number
|
||||
/** 水印间距X */
|
||||
gapX?: number
|
||||
/** 水印间距Y */
|
||||
gapY?: number
|
||||
/** 水印偏移X */
|
||||
offsetX?: number
|
||||
/** 水印偏移Y */
|
||||
offsetY?: number
|
||||
/** 水印层级 */
|
||||
zIndex?: number
|
||||
}
|
||||
interface WatermarkProps {
|
||||
/** 水印内容 */
|
||||
content?: string
|
||||
/** 水印是否可见 */
|
||||
visible?: boolean
|
||||
/** 水印字体大小 */
|
||||
fontSize?: number
|
||||
/** 水印字体颜色 */
|
||||
fontColor?: string
|
||||
/** 水印旋转角度 */
|
||||
rotate?: number
|
||||
/** 水印间距X */
|
||||
gapX?: number
|
||||
/** 水印间距Y */
|
||||
gapY?: number
|
||||
/** 水印偏移X */
|
||||
offsetX?: number
|
||||
/** 水印偏移Y */
|
||||
offsetY?: number
|
||||
/** 水印层级 */
|
||||
zIndex?: number
|
||||
}
|
||||
|
||||
withDefaults(defineProps<WatermarkProps>(), {
|
||||
content: AppConfig.systemInfo.name,
|
||||
visible: false,
|
||||
fontSize: 16,
|
||||
fontColor: 'rgba(128, 128, 128, 0.2)',
|
||||
rotate: -22,
|
||||
gapX: 100,
|
||||
gapY: 100,
|
||||
offsetX: 50,
|
||||
offsetY: 50,
|
||||
zIndex: 3100
|
||||
})
|
||||
withDefaults(defineProps<WatermarkProps>(), {
|
||||
content: AppConfig.systemInfo.name,
|
||||
visible: false,
|
||||
fontSize: 16,
|
||||
fontColor: 'rgba(128, 128, 128, 0.2)',
|
||||
rotate: -22,
|
||||
gapX: 100,
|
||||
gapY: 100,
|
||||
offsetX: 50,
|
||||
offsetY: 50,
|
||||
zIndex: 3100
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,328 +1,339 @@
|
||||
<!-- 表格头部,包含表格大小、刷新、全屏、列设置、其他设置 -->
|
||||
<template>
|
||||
<div class="flex-cb max-md:!block" id="art-table-header">
|
||||
<div class="flex-wrap">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
<div class="flex-cb max-md:!block" id="art-table-header">
|
||||
<div class="flex-wrap">
|
||||
<slot name="left"></slot>
|
||||
</div>
|
||||
|
||||
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
|
||||
<div
|
||||
v-if="showSearchBar != null"
|
||||
class="button"
|
||||
@click="search"
|
||||
:class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:search-line" :class="showSearchBar ? 'text-white' : 'text-g-700'" />
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShow('refresh')"
|
||||
class="button"
|
||||
@click="refresh"
|
||||
:class="{ loading: loading && isManualRefresh }"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:refresh-line"
|
||||
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
|
||||
<div
|
||||
v-if="showSearchBar != null"
|
||||
class="button"
|
||||
@click="search"
|
||||
:class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:search-line"
|
||||
:class="showSearchBar ? 'text-white' : 'text-g-700'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="shouldShow('refresh')"
|
||||
class="button"
|
||||
@click="refresh"
|
||||
:class="{ loading: loading && isManualRefresh }"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
icon="ri:refresh-line"
|
||||
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
|
||||
<div class="button">
|
||||
<ArtSvgIcon icon="ri:arrow-up-down-fill" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div
|
||||
v-for="item in tableSizeOptions"
|
||||
:key="item.value"
|
||||
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
|
||||
>
|
||||
<ElDropdownItem
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
|
||||
<div class="button">
|
||||
<ArtSvgIcon icon="ri:arrow-up-down-fill" />
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div
|
||||
v-for="item in tableSizeOptions"
|
||||
:key="item.value"
|
||||
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
|
||||
>
|
||||
<ElDropdownItem
|
||||
:key="item.value"
|
||||
:command="item.value"
|
||||
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
|
||||
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
|
||||
<ArtSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
|
||||
</div>
|
||||
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
|
||||
<ArtSvgIcon
|
||||
:icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 列设置 -->
|
||||
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<div class="button">
|
||||
<ArtSvgIcon icon="ri:align-right" />
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<ElScrollbar max-height="380px">
|
||||
<VueDraggable
|
||||
v-model="columns"
|
||||
:disabled="false"
|
||||
filter=".fixed-column"
|
||||
:prevent-on-filter="false"
|
||||
@move="checkColumnMove"
|
||||
>
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.prop || item.type"
|
||||
class="column-option flex-c"
|
||||
:class="{ 'fixed-column': item.fixed }"
|
||||
>
|
||||
<div
|
||||
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
<ElCheckbox
|
||||
:model-value="getColumnVisibility(item)"
|
||||
@update:model-value="(val) => updateColumnVisibility(item, val)"
|
||||
:disabled="item.disabled"
|
||||
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
|
||||
>{{
|
||||
item.label || (item.type === 'selection' ? t('table.selection') : '')
|
||||
}}</ElCheckbox
|
||||
>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<!-- 其他设置 -->
|
||||
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<div class="button">
|
||||
<ArtSvgIcon icon="ri:settings-line" />
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
|
||||
t('table.zebra')
|
||||
}}</ElCheckbox>
|
||||
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
|
||||
t('table.border')
|
||||
}}</ElCheckbox>
|
||||
<ElCheckbox v-if="showHeaderBackground" v-model="isHeaderBackground" :value="true">{{
|
||||
t('table.headerBackground')
|
||||
}}</ElCheckbox>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 列设置 -->
|
||||
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<div class="button">
|
||||
<ArtSvgIcon icon="ri:align-right" />
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<ElScrollbar max-height="380px">
|
||||
<VueDraggable
|
||||
v-model="columns"
|
||||
:disabled="false"
|
||||
filter=".fixed-column"
|
||||
:prevent-on-filter="false"
|
||||
@move="checkColumnMove"
|
||||
>
|
||||
<div
|
||||
v-for="item in columns"
|
||||
:key="item.prop || item.type"
|
||||
class="column-option flex-c"
|
||||
:class="{ 'fixed-column': item.fixed }"
|
||||
>
|
||||
<div
|
||||
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
<ElCheckbox
|
||||
:model-value="getColumnVisibility(item)"
|
||||
@update:model-value="(val) => updateColumnVisibility(item, val)"
|
||||
:disabled="item.disabled"
|
||||
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
|
||||
>{{
|
||||
item.label ||
|
||||
(item.type === 'selection' ? t('table.selection') : '')
|
||||
}}</ElCheckbox
|
||||
>
|
||||
</div>
|
||||
</VueDraggable>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<!-- 其他设置 -->
|
||||
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
|
||||
<template #reference>
|
||||
<div class="button">
|
||||
<ArtSvgIcon icon="ri:settings-line" />
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
|
||||
t('table.zebra')
|
||||
}}</ElCheckbox>
|
||||
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
|
||||
t('table.border')
|
||||
}}</ElCheckbox>
|
||||
<ElCheckbox
|
||||
v-if="showHeaderBackground"
|
||||
v-model="isHeaderBackground"
|
||||
:value="true"
|
||||
>{{ t('table.headerBackground') }}</ElCheckbox
|
||||
>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<slot name="right"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TableSizeEnum } from '@/enums/formEnum'
|
||||
import { useTableStore } from '@/store/modules/table'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { ColumnOption } from '@/types/component'
|
||||
import { ElScrollbar } from 'element-plus'
|
||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { TableSizeEnum } from '@/enums/formEnum'
|
||||
import { useTableStore } from '@/store/modules/table'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { ColumnOption } from '@/types/component'
|
||||
import { ElScrollbar } from 'element-plus'
|
||||
|
||||
defineOptions({ name: 'ArtTableHeader' })
|
||||
defineOptions({ name: 'ArtTableHeader' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t } = useI18n()
|
||||
|
||||
interface Props {
|
||||
/** 斑马纹 */
|
||||
showZebra?: boolean
|
||||
/** 边框 */
|
||||
showBorder?: boolean
|
||||
/** 表头背景 */
|
||||
showHeaderBackground?: boolean
|
||||
/** 全屏 class */
|
||||
fullClass?: string
|
||||
/** 组件布局,子组件名用逗号分隔 */
|
||||
layout?: string
|
||||
/** 加载中 */
|
||||
loading?: boolean
|
||||
/** 搜索栏显示状态 */
|
||||
showSearchBar?: boolean
|
||||
}
|
||||
interface Props {
|
||||
/** 斑马纹 */
|
||||
showZebra?: boolean
|
||||
/** 边框 */
|
||||
showBorder?: boolean
|
||||
/** 表头背景 */
|
||||
showHeaderBackground?: boolean
|
||||
/** 全屏 class */
|
||||
fullClass?: string
|
||||
/** 组件布局,子组件名用逗号分隔 */
|
||||
layout?: string
|
||||
/** 加载中 */
|
||||
loading?: boolean
|
||||
/** 搜索栏显示状态 */
|
||||
showSearchBar?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showZebra: true,
|
||||
showBorder: true,
|
||||
showHeaderBackground: true,
|
||||
fullClass: 'art-page-view',
|
||||
layout: 'search,refresh,size,fullscreen,columns,settings',
|
||||
showSearchBar: undefined
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
showZebra: true,
|
||||
showBorder: true,
|
||||
showHeaderBackground: true,
|
||||
fullClass: 'art-page-view',
|
||||
layout: 'search,refresh,size,fullscreen,columns,settings',
|
||||
showSearchBar: undefined
|
||||
})
|
||||
|
||||
const columns = defineModel<ColumnOption[]>('columns', {
|
||||
required: false,
|
||||
default: () => []
|
||||
})
|
||||
const columns = defineModel<ColumnOption[]>('columns', {
|
||||
required: false,
|
||||
default: () => []
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'search'): void
|
||||
(e: 'update:showSearchBar', value: boolean): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'search'): void
|
||||
(e: 'update:showSearchBar', value: boolean): void
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 获取列的显示状态
|
||||
* 优先使用 visible 字段,如果不存在则使用 checked 字段
|
||||
*/
|
||||
const getColumnVisibility = (col: ColumnOption): boolean => {
|
||||
if (col.visible !== undefined) {
|
||||
return col.visible
|
||||
}
|
||||
return col.checked ?? true
|
||||
}
|
||||
/**
|
||||
* 获取列的显示状态
|
||||
* 优先使用 visible 字段,如果不存在则使用 checked 字段
|
||||
*/
|
||||
const getColumnVisibility = (col: ColumnOption): boolean => {
|
||||
if (col.visible !== undefined) {
|
||||
return col.visible
|
||||
}
|
||||
return col.checked ?? true
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新列的显示状态
|
||||
* 同时更新 checked 和 visible 字段以保持兼容性
|
||||
*/
|
||||
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
|
||||
const boolValue = !!value
|
||||
col.checked = boolValue
|
||||
col.visible = boolValue
|
||||
}
|
||||
/**
|
||||
* 更新列的显示状态
|
||||
* 同时更新 checked 和 visible 字段以保持兼容性
|
||||
*/
|
||||
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
|
||||
const boolValue = !!value
|
||||
col.checked = boolValue
|
||||
col.visible = boolValue
|
||||
}
|
||||
|
||||
/** 表格大小选项配置 */
|
||||
const tableSizeOptions = [
|
||||
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
|
||||
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
|
||||
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
|
||||
]
|
||||
/** 表格大小选项配置 */
|
||||
const tableSizeOptions = [
|
||||
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
|
||||
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
|
||||
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
|
||||
]
|
||||
|
||||
const tableStore = useTableStore()
|
||||
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
|
||||
const tableStore = useTableStore()
|
||||
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
|
||||
|
||||
/** 解析 layout 属性,转换为数组 */
|
||||
const layoutItems = computed(() => {
|
||||
return props.layout.split(',').map((item) => item.trim())
|
||||
})
|
||||
/** 解析 layout 属性,转换为数组 */
|
||||
const layoutItems = computed(() => {
|
||||
return props.layout.split(',').map((item) => item.trim())
|
||||
})
|
||||
|
||||
/**
|
||||
* 检查组件是否应该显示
|
||||
* @param componentName 组件名称
|
||||
* @returns 是否显示
|
||||
*/
|
||||
const shouldShow = (componentName: string) => {
|
||||
return layoutItems.value.includes(componentName)
|
||||
}
|
||||
/**
|
||||
* 检查组件是否应该显示
|
||||
* @param componentName 组件名称
|
||||
* @returns 是否显示
|
||||
*/
|
||||
const shouldShow = (componentName: string) => {
|
||||
return layoutItems.value.includes(componentName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 拖拽移动事件处理 - 防止固定列位置改变
|
||||
* @param evt move事件对象
|
||||
* @returns 是否允许移动
|
||||
*/
|
||||
const checkColumnMove = (event: any) => {
|
||||
// 拖拽进入的目标 DOM 元素
|
||||
const toElement = event.related as HTMLElement
|
||||
// 如果目标位置是 fixed 列,则不允许移动
|
||||
if (toElement && toElement.classList.contains('fixed-column')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
/**
|
||||
* 拖拽移动事件处理 - 防止固定列位置改变
|
||||
* @param evt move事件对象
|
||||
* @returns 是否允许移动
|
||||
*/
|
||||
const checkColumnMove = (event: any) => {
|
||||
// 拖拽进入的目标 DOM 元素
|
||||
const toElement = event.related as HTMLElement
|
||||
// 如果目标位置是 fixed 列,则不允许移动
|
||||
if (toElement && toElement.classList.contains('fixed-column')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 搜索事件处理 */
|
||||
const search = () => {
|
||||
// 切换搜索栏显示状态
|
||||
emit('update:showSearchBar', !props.showSearchBar)
|
||||
emit('search')
|
||||
}
|
||||
/** 搜索事件处理 */
|
||||
const search = () => {
|
||||
// 切换搜索栏显示状态
|
||||
emit('update:showSearchBar', !props.showSearchBar)
|
||||
emit('search')
|
||||
}
|
||||
|
||||
/** 刷新事件处理 */
|
||||
const refresh = () => {
|
||||
isManualRefresh.value = true
|
||||
emit('refresh')
|
||||
}
|
||||
/** 刷新事件处理 */
|
||||
const refresh = () => {
|
||||
isManualRefresh.value = true
|
||||
emit('refresh')
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格大小变化处理
|
||||
* @param command 表格大小枚举值
|
||||
*/
|
||||
const handleTableSizeChange = (command: TableSizeEnum) => {
|
||||
useTableStore().setTableSize(command)
|
||||
}
|
||||
/**
|
||||
* 表格大小变化处理
|
||||
* @param command 表格大小枚举值
|
||||
*/
|
||||
const handleTableSizeChange = (command: TableSizeEnum) => {
|
||||
useTableStore().setTableSize(command)
|
||||
}
|
||||
|
||||
/** 是否手动点击刷新 */
|
||||
const isManualRefresh = ref(false)
|
||||
/** 是否手动点击刷新 */
|
||||
const isManualRefresh = ref(false)
|
||||
|
||||
/** 加载中 */
|
||||
const isFullScreen = ref(false)
|
||||
/** 加载中 */
|
||||
const isFullScreen = ref(false)
|
||||
|
||||
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
|
||||
const originalOverflow = ref('')
|
||||
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
|
||||
const originalOverflow = ref('')
|
||||
|
||||
/**
|
||||
* 切换全屏状态
|
||||
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
|
||||
*/
|
||||
const toggleFullScreen = () => {
|
||||
const el = document.querySelector(`.${props.fullClass}`)
|
||||
if (!el) return
|
||||
/**
|
||||
* 切换全屏状态
|
||||
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
|
||||
*/
|
||||
const toggleFullScreen = () => {
|
||||
const el = document.querySelector(`.${props.fullClass}`)
|
||||
if (!el) return
|
||||
|
||||
isFullScreen.value = !isFullScreen.value
|
||||
isFullScreen.value = !isFullScreen.value
|
||||
|
||||
if (isFullScreen.value) {
|
||||
// 进入全屏:保存原始样式并隐藏滚动条
|
||||
originalOverflow.value = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
el.classList.add('el-full-screen')
|
||||
tableStore.setIsFullScreen(true)
|
||||
} else {
|
||||
// 退出全屏:恢复原始样式
|
||||
document.body.style.overflow = originalOverflow.value
|
||||
el.classList.remove('el-full-screen')
|
||||
tableStore.setIsFullScreen(false)
|
||||
}
|
||||
}
|
||||
if (isFullScreen.value) {
|
||||
// 进入全屏:保存原始样式并隐藏滚动条
|
||||
originalOverflow.value = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
el.classList.add('el-full-screen')
|
||||
tableStore.setIsFullScreen(true)
|
||||
} else {
|
||||
// 退出全屏:恢复原始样式
|
||||
document.body.style.overflow = originalOverflow.value
|
||||
el.classList.remove('el-full-screen')
|
||||
tableStore.setIsFullScreen(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ESC键退出全屏的事件处理器
|
||||
* 需要保存引用以便在组件卸载时正确移除监听器
|
||||
*/
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullScreen.value) {
|
||||
toggleFullScreen()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* ESC键退出全屏的事件处理器
|
||||
* 需要保存引用以便在组件卸载时正确移除监听器
|
||||
*/
|
||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullScreen.value) {
|
||||
toggleFullScreen()
|
||||
}
|
||||
}
|
||||
|
||||
/** 组件挂载时注册全局事件监听器 */
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
})
|
||||
/** 组件挂载时注册全局事件监听器 */
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleEscapeKey)
|
||||
})
|
||||
|
||||
/** 组件卸载时清理资源 */
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听器
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
/** 组件卸载时清理资源 */
|
||||
onUnmounted(() => {
|
||||
// 移除事件监听器
|
||||
document.removeEventListener('keydown', handleEscapeKey)
|
||||
|
||||
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
|
||||
if (isFullScreen.value) {
|
||||
document.body.style.overflow = originalOverflow.value
|
||||
const el = document.querySelector(`.${props.fullClass}`)
|
||||
if (el) {
|
||||
el.classList.remove('el-full-screen')
|
||||
}
|
||||
}
|
||||
})
|
||||
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
|
||||
if (isFullScreen.value) {
|
||||
document.body.style.overflow = originalOverflow.value
|
||||
const el = document.querySelector(`.${props.fullClass}`)
|
||||
if (el) {
|
||||
el.classList.remove('el-full-screen')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '@styles/core/tailwind.css';
|
||||
@reference '@styles/core/tailwind.css';
|
||||
|
||||
.button {
|
||||
@apply ml-2
|
||||
.button {
|
||||
@apply ml-2
|
||||
size-8
|
||||
flex
|
||||
items-center
|
||||
@@ -335,5 +346,5 @@
|
||||
hover:bg-g-300
|
||||
md:ml-0
|
||||
md:mr-2.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,340 +3,341 @@
|
||||
<!-- 扩展功能:分页组件、渲染自定义列、loading、表格全局边框、斑马纹、表格尺寸、表头背景配置 -->
|
||||
<!-- 获取 ref:默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
|
||||
<template>
|
||||
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
|
||||
<ElTable
|
||||
ref="elTableRef"
|
||||
v-loading="!!loading"
|
||||
v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }"
|
||||
>
|
||||
<template v-for="col in columns" :key="col.prop || col.type">
|
||||
<!-- 渲染全局序号列 -->
|
||||
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
||||
<template #default="{ $index }">
|
||||
<span>{{ getGlobalIndex($index) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
|
||||
<ElTable
|
||||
ref="elTableRef"
|
||||
v-loading="!!loading"
|
||||
v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }"
|
||||
>
|
||||
<template v-for="col in columns" :key="col.prop || col.type">
|
||||
<!-- 渲染全局序号列 -->
|
||||
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
||||
<template #default="{ $index }">
|
||||
<span>{{ getGlobalIndex($index) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<!-- 渲染展开行 -->
|
||||
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
||||
<template #default="{ row }">
|
||||
<component :is="col.formatter ? col.formatter(row) : null" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<!-- 渲染展开行 -->
|
||||
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
||||
<template #default="{ row }">
|
||||
<component :is="col.formatter ? col.formatter(row) : null" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
|
||||
<!-- 渲染普通列 -->
|
||||
<ElTableColumn v-else v-bind="cleanColumnProps(col)">
|
||||
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
||||
<slot
|
||||
:name="col.headerSlotName || `${col.prop}-header`"
|
||||
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
||||
>
|
||||
{{ col.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<template v-if="col.useSlot && col.prop" #default="slotScope">
|
||||
<slot
|
||||
:name="col.slotName || col.prop"
|
||||
v-bind="{
|
||||
...slotScope,
|
||||
prop: col.prop,
|
||||
value: col.prop ? slotScope.row[col.prop] : undefined
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
<!-- 渲染普通列 -->
|
||||
<ElTableColumn v-else v-bind="cleanColumnProps(col)">
|
||||
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
||||
<slot
|
||||
:name="col.headerSlotName || `${col.prop}-header`"
|
||||
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
||||
>
|
||||
{{ col.label }}
|
||||
</slot>
|
||||
</template>
|
||||
<template v-if="col.useSlot && col.prop" #default="slotScope">
|
||||
<slot
|
||||
:name="col.slotName || col.prop"
|
||||
v-bind="{
|
||||
...slotScope,
|
||||
prop: col.prop,
|
||||
value: col.prop ? slotScope.row[col.prop] : undefined
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</template>
|
||||
|
||||
<template v-if="$slots.default" #default><slot /></template>
|
||||
<template v-if="$slots.default" #default><slot /></template>
|
||||
|
||||
<template #empty>
|
||||
<div v-if="loading"></div>
|
||||
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
||||
</template>
|
||||
</ElTable>
|
||||
<template #empty>
|
||||
<div v-if="loading"></div>
|
||||
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
||||
</template>
|
||||
</ElTable>
|
||||
|
||||
<div
|
||||
class="pagination custom-pagination"
|
||||
v-if="showPagination"
|
||||
:class="mergedPaginationOptions?.align"
|
||||
ref="paginationRef"
|
||||
>
|
||||
<ElPagination
|
||||
v-bind="mergedPaginationOptions"
|
||||
:total="pagination?.total"
|
||||
:disabled="loading"
|
||||
:page-size="pagination?.size"
|
||||
:current-page="pagination?.current"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="pagination custom-pagination"
|
||||
v-if="showPagination"
|
||||
:class="mergedPaginationOptions?.align"
|
||||
ref="paginationRef"
|
||||
>
|
||||
<ElPagination
|
||||
v-bind="mergedPaginationOptions"
|
||||
:total="pagination?.total"
|
||||
:disabled="loading"
|
||||
:page-size="pagination?.size"
|
||||
:current-page="pagination?.current"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, watchEffect } from 'vue'
|
||||
import type { ElTable, TableProps } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ColumnOption } from '@/types'
|
||||
import { useTableStore } from '@/store/modules/table'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useTableHeight } from '@/hooks/core/useTableHeight'
|
||||
import { useResizeObserver, useWindowSize } from '@vueuse/core'
|
||||
import { ref, computed, nextTick, watchEffect } from 'vue'
|
||||
import type { ElTable, TableProps } from 'element-plus'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { ColumnOption } from '@/types'
|
||||
import { useTableStore } from '@/store/modules/table'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useTableHeight } from '@/hooks/core/useTableHeight'
|
||||
import { useResizeObserver, useWindowSize } from '@vueuse/core'
|
||||
|
||||
defineOptions({ name: 'ArtTable' })
|
||||
defineOptions({ name: 'ArtTable' })
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null)
|
||||
const paginationRef = ref<HTMLElement>()
|
||||
const tableHeaderRef = ref<HTMLElement>()
|
||||
const tableStore = useTableStore()
|
||||
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } = storeToRefs(tableStore)
|
||||
const { width } = useWindowSize()
|
||||
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null)
|
||||
const paginationRef = ref<HTMLElement>()
|
||||
const tableHeaderRef = ref<HTMLElement>()
|
||||
const tableStore = useTableStore()
|
||||
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } =
|
||||
storeToRefs(tableStore)
|
||||
|
||||
/** 分页配置接口 */
|
||||
interface PaginationConfig {
|
||||
/** 当前页码 */
|
||||
current: number
|
||||
/** 每页显示条目个数 */
|
||||
size: number
|
||||
/** 总条目数 */
|
||||
total: number
|
||||
}
|
||||
/** 分页配置接口 */
|
||||
interface PaginationConfig {
|
||||
/** 当前页码 */
|
||||
current: number
|
||||
/** 每页显示条目个数 */
|
||||
size: number
|
||||
/** 总条目数 */
|
||||
total: number
|
||||
}
|
||||
|
||||
/** 分页器配置选项接口 */
|
||||
interface PaginationOptions {
|
||||
/** 每页显示个数选择器的选项列表 */
|
||||
pageSizes?: number[]
|
||||
/** 分页器的对齐方式 */
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** 分页器的布局 */
|
||||
layout?: string
|
||||
/** 是否显示分页器背景 */
|
||||
background?: boolean
|
||||
/** 只有一页时是否隐藏分页器 */
|
||||
hideOnSinglePage?: boolean
|
||||
/** 分页器的大小 */
|
||||
size?: 'small' | 'default' | 'large'
|
||||
/** 分页器的页码数量 */
|
||||
pagerCount?: number
|
||||
}
|
||||
/** 分页器配置选项接口 */
|
||||
interface PaginationOptions {
|
||||
/** 每页显示个数选择器的选项列表 */
|
||||
pageSizes?: number[]
|
||||
/** 分页器的对齐方式 */
|
||||
align?: 'left' | 'center' | 'right'
|
||||
/** 分页器的布局 */
|
||||
layout?: string
|
||||
/** 是否显示分页器背景 */
|
||||
background?: boolean
|
||||
/** 只有一页时是否隐藏分页器 */
|
||||
hideOnSinglePage?: boolean
|
||||
/** 分页器的大小 */
|
||||
size?: 'small' | 'default' | 'large'
|
||||
/** 分页器的页码数量 */
|
||||
pagerCount?: number
|
||||
}
|
||||
|
||||
/** ArtTable 组件的 Props 接口 */
|
||||
interface ArtTableProps extends TableProps<Record<string, any>> {
|
||||
/** 加载状态 */
|
||||
loading?: boolean
|
||||
/** 列渲染配置 */
|
||||
columns?: ColumnOption[]
|
||||
/** 分页状态 */
|
||||
pagination?: PaginationConfig
|
||||
/** 分页配置 */
|
||||
paginationOptions?: PaginationOptions
|
||||
/** 空数据表格高度 */
|
||||
emptyHeight?: string
|
||||
/** 空数据时显示的文本 */
|
||||
emptyText?: string
|
||||
/** 是否开启 ArtTableHeader,解决表格高度自适应问题 */
|
||||
showTableHeader?: boolean
|
||||
}
|
||||
/** ArtTable 组件的 Props 接口 */
|
||||
interface ArtTableProps extends TableProps<Record<string, any>> {
|
||||
/** 加载状态 */
|
||||
loading?: boolean
|
||||
/** 列渲染配置 */
|
||||
columns?: ColumnOption[]
|
||||
/** 分页状态 */
|
||||
pagination?: PaginationConfig
|
||||
/** 分页配置 */
|
||||
paginationOptions?: PaginationOptions
|
||||
/** 空数据表格高度 */
|
||||
emptyHeight?: string
|
||||
/** 空数据时显示的文本 */
|
||||
emptyText?: string
|
||||
/** 是否开启 ArtTableHeader,解决表格高度自适应问题 */
|
||||
showTableHeader?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<ArtTableProps>(), {
|
||||
columns: () => [],
|
||||
fit: true,
|
||||
showHeader: true,
|
||||
stripe: undefined,
|
||||
border: undefined,
|
||||
size: undefined,
|
||||
emptyHeight: '100%',
|
||||
emptyText: '暂无数据',
|
||||
showTableHeader: true
|
||||
})
|
||||
const props = withDefaults(defineProps<ArtTableProps>(), {
|
||||
columns: () => [],
|
||||
fit: true,
|
||||
showHeader: true,
|
||||
stripe: undefined,
|
||||
border: undefined,
|
||||
size: undefined,
|
||||
emptyHeight: '100%',
|
||||
emptyText: '暂无数据',
|
||||
showTableHeader: true
|
||||
})
|
||||
|
||||
const LAYOUT = {
|
||||
MOBILE: 'prev, pager, next, sizes, jumper, total',
|
||||
IPAD: 'prev, pager, next, jumper, total',
|
||||
DESKTOP: 'total, prev, pager, next, sizes, jumper'
|
||||
}
|
||||
const LAYOUT = {
|
||||
MOBILE: 'prev, pager, next, sizes, jumper, total',
|
||||
IPAD: 'prev, pager, next, jumper, total',
|
||||
DESKTOP: 'total, prev, pager, next, sizes, jumper'
|
||||
}
|
||||
|
||||
const layout = computed(() => {
|
||||
if (width.value < 768) {
|
||||
return LAYOUT.MOBILE
|
||||
} else if (width.value < 1024) {
|
||||
return LAYOUT.IPAD
|
||||
} else {
|
||||
return LAYOUT.DESKTOP
|
||||
}
|
||||
})
|
||||
const layout = computed(() => {
|
||||
if (width.value < 768) {
|
||||
return LAYOUT.MOBILE
|
||||
} else if (width.value < 1024) {
|
||||
return LAYOUT.IPAD
|
||||
} else {
|
||||
return LAYOUT.DESKTOP
|
||||
}
|
||||
})
|
||||
|
||||
// 默认分页常量
|
||||
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
|
||||
pageSizes: [10, 20, 30, 50, 100],
|
||||
align: 'center',
|
||||
background: true,
|
||||
layout: layout.value,
|
||||
hideOnSinglePage: false,
|
||||
size: 'default',
|
||||
pagerCount: width.value > 1200 ? 7 : 5
|
||||
}
|
||||
// 默认分页常量
|
||||
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
|
||||
pageSizes: [10, 20, 30, 50, 100],
|
||||
align: 'center',
|
||||
background: true,
|
||||
layout: layout.value,
|
||||
hideOnSinglePage: false,
|
||||
size: 'default',
|
||||
pagerCount: width.value > 1200 ? 7 : 5
|
||||
}
|
||||
|
||||
// 合并分页配置
|
||||
const mergedPaginationOptions = computed(() => ({
|
||||
...DEFAULT_PAGINATION_OPTIONS,
|
||||
...props.paginationOptions
|
||||
}))
|
||||
// 合并分页配置
|
||||
const mergedPaginationOptions = computed(() => ({
|
||||
...DEFAULT_PAGINATION_OPTIONS,
|
||||
...props.paginationOptions
|
||||
}))
|
||||
|
||||
// 边框 (优先级:props > store)
|
||||
const border = computed(() => props.border ?? isBorder.value)
|
||||
// 斑马纹
|
||||
const stripe = computed(() => props.stripe ?? isZebra.value)
|
||||
// 表格尺寸
|
||||
const size = computed(() => props.size ?? tableSize.value)
|
||||
// 数据是否为空
|
||||
const isEmpty = computed(() => props.data?.length === 0)
|
||||
// 边框 (优先级:props > store)
|
||||
const border = computed(() => props.border ?? isBorder.value)
|
||||
// 斑马纹
|
||||
const stripe = computed(() => props.stripe ?? isZebra.value)
|
||||
// 表格尺寸
|
||||
const size = computed(() => props.size ?? tableSize.value)
|
||||
// 数据是否为空
|
||||
const isEmpty = computed(() => props.data?.length === 0)
|
||||
|
||||
const paginationHeight = ref(0)
|
||||
const tableHeaderHeight = ref(0)
|
||||
const paginationHeight = ref(0)
|
||||
const tableHeaderHeight = ref(0)
|
||||
|
||||
// 使用 useResizeObserver 监听分页器高度变化
|
||||
useResizeObserver(paginationRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
||||
requestAnimationFrame(() => {
|
||||
paginationHeight.value = entry.contentRect.height
|
||||
})
|
||||
}
|
||||
})
|
||||
// 使用 useResizeObserver 监听分页器高度变化
|
||||
useResizeObserver(paginationRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
||||
requestAnimationFrame(() => {
|
||||
paginationHeight.value = entry.contentRect.height
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 使用 useResizeObserver 监听表格头部高度变化
|
||||
useResizeObserver(tableHeaderRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
||||
requestAnimationFrame(() => {
|
||||
tableHeaderHeight.value = entry.contentRect.height
|
||||
})
|
||||
}
|
||||
})
|
||||
// 使用 useResizeObserver 监听表格头部高度变化
|
||||
useResizeObserver(tableHeaderRef, (entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry) {
|
||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
||||
requestAnimationFrame(() => {
|
||||
tableHeaderHeight.value = entry.contentRect.height
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
|
||||
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
|
||||
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
|
||||
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
|
||||
|
||||
// 使用表格高度计算 Hook
|
||||
const { containerHeight } = useTableHeight({
|
||||
showTableHeader: computed(() => props.showTableHeader),
|
||||
paginationHeight,
|
||||
tableHeaderHeight,
|
||||
paginationSpacing: PAGINATION_SPACING
|
||||
})
|
||||
// 使用表格高度计算 Hook
|
||||
const { containerHeight } = useTableHeight({
|
||||
showTableHeader: computed(() => props.showTableHeader),
|
||||
paginationHeight,
|
||||
tableHeaderHeight,
|
||||
paginationSpacing: PAGINATION_SPACING
|
||||
})
|
||||
|
||||
// 表格高度逻辑
|
||||
const height = computed(() => {
|
||||
// 全屏模式下占满全屏
|
||||
if (isFullScreen.value) return '100%'
|
||||
// 空数据且非加载状态时固定高度
|
||||
if (isEmpty.value && !props.loading) return props.emptyHeight
|
||||
// 使用传入的高度
|
||||
if (props.height) return props.height
|
||||
// 默认占满容器高度
|
||||
return '100%'
|
||||
})
|
||||
// 表格高度逻辑
|
||||
const height = computed(() => {
|
||||
// 全屏模式下占满全屏
|
||||
if (isFullScreen.value) return '100%'
|
||||
// 空数据且非加载状态时固定高度
|
||||
if (isEmpty.value && !props.loading) return props.emptyHeight
|
||||
// 使用传入的高度
|
||||
if (props.height) return props.height
|
||||
// 默认占满容器高度
|
||||
return '100%'
|
||||
})
|
||||
|
||||
// 表头背景颜色样式
|
||||
const headerCellStyle = computed(() => ({
|
||||
background: isHeaderBackground.value
|
||||
? 'var(--el-fill-color-lighter)'
|
||||
: 'var(--default-box-color)',
|
||||
...(props.headerCellStyle || {}) // 合并用户传入的样式
|
||||
}))
|
||||
// 表头背景颜色样式
|
||||
const headerCellStyle = computed(() => ({
|
||||
background: isHeaderBackground.value
|
||||
? 'var(--el-fill-color-lighter)'
|
||||
: 'var(--default-box-color)',
|
||||
...(props.headerCellStyle || {}) // 合并用户传入的样式
|
||||
}))
|
||||
|
||||
// 是否显示分页器
|
||||
const showPagination = computed(() => props.pagination && !isEmpty.value)
|
||||
// 是否显示分页器
|
||||
const showPagination = computed(() => props.pagination && !isEmpty.value)
|
||||
|
||||
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
|
||||
const cleanColumnProps = (col: ColumnOption) => {
|
||||
const columnProps = { ...col }
|
||||
// 删除自定义的插槽控制属性
|
||||
delete columnProps.useHeaderSlot
|
||||
delete columnProps.headerSlotName
|
||||
delete columnProps.useSlot
|
||||
delete columnProps.slotName
|
||||
return columnProps
|
||||
}
|
||||
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
|
||||
const cleanColumnProps = (col: ColumnOption) => {
|
||||
const columnProps = { ...col }
|
||||
// 删除自定义的插槽控制属性
|
||||
delete columnProps.useHeaderSlot
|
||||
delete columnProps.headerSlotName
|
||||
delete columnProps.useSlot
|
||||
delete columnProps.slotName
|
||||
return columnProps
|
||||
}
|
||||
|
||||
// 分页大小变化
|
||||
const handleSizeChange = (val: number) => {
|
||||
emit('pagination:size-change', val)
|
||||
}
|
||||
// 分页大小变化
|
||||
const handleSizeChange = (val: number) => {
|
||||
emit('pagination:size-change', val)
|
||||
}
|
||||
|
||||
// 分页当前页变化
|
||||
const handleCurrentChange = (val: number) => {
|
||||
emit('pagination:current-change', val)
|
||||
scrollToTop() // 页码改变后滚动到表格顶部
|
||||
}
|
||||
// 分页当前页变化
|
||||
const handleCurrentChange = (val: number) => {
|
||||
emit('pagination:current-change', val)
|
||||
scrollToTop() // 页码改变后滚动到表格顶部
|
||||
}
|
||||
|
||||
const { scrollToTop: scrollPageToTop } = useCommon()
|
||||
const { scrollToTop: scrollPageToTop } = useCommon()
|
||||
|
||||
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
|
||||
const scrollToTop = () => {
|
||||
nextTick(() => {
|
||||
elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部
|
||||
scrollPageToTop() // 调用公共 composable 滚动页面到顶部
|
||||
})
|
||||
}
|
||||
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
|
||||
const scrollToTop = () => {
|
||||
nextTick(() => {
|
||||
elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部
|
||||
scrollPageToTop() // 调用公共 composable 滚动页面到顶部
|
||||
})
|
||||
}
|
||||
|
||||
// 全局序号
|
||||
const getGlobalIndex = (index: number) => {
|
||||
if (!props.pagination) return index + 1
|
||||
const { current, size } = props.pagination
|
||||
return (current - 1) * size + index + 1
|
||||
}
|
||||
// 全局序号
|
||||
const getGlobalIndex = (index: number) => {
|
||||
if (!props.pagination) return index + 1
|
||||
const { current, size } = props.pagination
|
||||
return (current - 1) * size + index + 1
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'pagination:size-change', val: number): void
|
||||
(e: 'pagination:current-change', val: number): void
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'pagination:size-change', val: number): void
|
||||
(e: 'pagination:current-change', val: number): void
|
||||
}>()
|
||||
|
||||
// 查找并绑定表格头部元素 - 使用 VueUse 优化
|
||||
const findTableHeader = () => {
|
||||
if (!props.showTableHeader) {
|
||||
tableHeaderRef.value = undefined
|
||||
return
|
||||
}
|
||||
// 查找并绑定表格头部元素 - 使用 VueUse 优化
|
||||
const findTableHeader = () => {
|
||||
if (!props.showTableHeader) {
|
||||
tableHeaderRef.value = undefined
|
||||
return
|
||||
}
|
||||
|
||||
const tableHeader = document.getElementById('art-table-header')
|
||||
if (tableHeader) {
|
||||
tableHeaderRef.value = tableHeader
|
||||
} else {
|
||||
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0
|
||||
tableHeaderRef.value = undefined
|
||||
}
|
||||
}
|
||||
const tableHeader = document.getElementById('art-table-header')
|
||||
if (tableHeader) {
|
||||
tableHeaderRef.value = tableHeader
|
||||
} else {
|
||||
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0
|
||||
tableHeaderRef.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(
|
||||
() => {
|
||||
// 访问响应式数据以建立依赖追踪
|
||||
void props.data?.length // 追踪数据变化
|
||||
const shouldShow = props.showTableHeader
|
||||
watchEffect(
|
||||
() => {
|
||||
// 访问响应式数据以建立依赖追踪
|
||||
void props.data?.length // 追踪数据变化
|
||||
const shouldShow = props.showTableHeader
|
||||
|
||||
// 只有在需要显示表格头部时才查找
|
||||
if (shouldShow) {
|
||||
nextTick(() => {
|
||||
findTableHeader()
|
||||
})
|
||||
} else {
|
||||
// 不显示时清空引用
|
||||
tableHeaderRef.value = undefined
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
// 只有在需要显示表格头部时才查找
|
||||
if (shouldShow) {
|
||||
nextTick(() => {
|
||||
findTableHeader()
|
||||
})
|
||||
} else {
|
||||
// 不显示时清空引用
|
||||
tableHeaderRef.value = undefined
|
||||
}
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
defineExpose({
|
||||
scrollToTop,
|
||||
elTableRef
|
||||
})
|
||||
defineExpose({
|
||||
scrollToTop,
|
||||
elTableRef
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use './style';
|
||||
@use './style';
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,99 @@
|
||||
.art-table {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
|
||||
.el-table {
|
||||
height: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.el-table {
|
||||
height: 100%;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
:deep(.el-loading-mask) {
|
||||
z-index: 100;
|
||||
background-color: var(--default-box-color) !important;
|
||||
}
|
||||
:deep(.el-loading-mask) {
|
||||
z-index: 100;
|
||||
background-color: var(--default-box-color) !important;
|
||||
}
|
||||
|
||||
// Loading 过渡动画 - 消失时淡出
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
// Loading 过渡动画 - 消失时淡出
|
||||
.loading-fade-leave-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.loading-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 空状态垂直居中
|
||||
&.is-empty {
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
// 空状态垂直居中
|
||||
&.is-empty {
|
||||
:deep(.el-scrollbar__wrap) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
margin-top: 13px;
|
||||
.pagination {
|
||||
display: flex;
|
||||
margin-top: 13px;
|
||||
|
||||
:deep(.el-select) {
|
||||
width: 102px !important;
|
||||
}
|
||||
:deep(.el-select) {
|
||||
width: 102px !important;
|
||||
}
|
||||
|
||||
// 分页对齐方式
|
||||
&.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
// 分页对齐方式
|
||||
&.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
// 自定义分页组件样式
|
||||
&.custom-pagination {
|
||||
:deep(.el-pagination) {
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
transition: border-color 0.15s;
|
||||
// 自定义分页组件样式
|
||||
&.custom-pagination {
|
||||
:deep(.el-pagination) {
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
color: var(--theme-color);
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
&:hover:not(.is-disabled) {
|
||||
color: var(--theme-color);
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
font-weight: 400 !important;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
transition: border-color 0.15s;
|
||||
li {
|
||||
box-sizing: border-box;
|
||||
font-weight: 400 !important;
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--art-gray-300);
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&.is-active {
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
background-color: var(--theme-color);
|
||||
border: 1px solid var(--theme-color);
|
||||
}
|
||||
&.is-active {
|
||||
font-weight: 400;
|
||||
color: #fff;
|
||||
background-color: var(--theme-color);
|
||||
border: 1px solid var(--theme-color);
|
||||
}
|
||||
|
||||
&:hover:not(.is-disabled) {
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(.is-disabled) {
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端分页
|
||||
@media (width <= 640px) {
|
||||
:deep(.el-pagination) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
:deep(.el-pagination) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,310 +1,319 @@
|
||||
<!-- 数字滚动 -->
|
||||
<template>
|
||||
<span
|
||||
class="text-g-900 tabular-nums"
|
||||
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</span>
|
||||
<span
|
||||
class="text-g-900 tabular-nums"
|
||||
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue'
|
||||
import { useTransition, TransitionPresets } from '@vueuse/core'
|
||||
import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue'
|
||||
import { useTransition, TransitionPresets } from '@vueuse/core'
|
||||
|
||||
// 类型定义
|
||||
interface CountToProps {
|
||||
/** 目标值 */
|
||||
target: number
|
||||
/** 动画持续时间(毫秒) */
|
||||
duration?: number
|
||||
/** 是否自动开始 */
|
||||
autoStart?: boolean
|
||||
/** 小数位数 */
|
||||
decimals?: number
|
||||
/** 小数点符号 */
|
||||
decimal?: string
|
||||
/** 千分位分隔符 */
|
||||
separator?: string
|
||||
/** 前缀 */
|
||||
prefix?: string
|
||||
/** 后缀 */
|
||||
suffix?: string
|
||||
/** 缓动函数 */
|
||||
easing?: keyof typeof TransitionPresets
|
||||
/** 是否禁用动画 */
|
||||
disabled?: boolean
|
||||
}
|
||||
// 类型定义
|
||||
interface CountToProps {
|
||||
/** 目标值 */
|
||||
target: number
|
||||
/** 动画持续时间(毫秒) */
|
||||
duration?: number
|
||||
/** 是否自动开始 */
|
||||
autoStart?: boolean
|
||||
/** 小数位数 */
|
||||
decimals?: number
|
||||
/** 小数点符号 */
|
||||
decimal?: string
|
||||
/** 千分位分隔符 */
|
||||
separator?: string
|
||||
/** 前缀 */
|
||||
prefix?: string
|
||||
/** 后缀 */
|
||||
suffix?: string
|
||||
/** 缓动函数 */
|
||||
easing?: keyof typeof TransitionPresets
|
||||
/** 是否禁用动画 */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface CountToEmits {
|
||||
started: [value: number]
|
||||
finished: [value: number]
|
||||
paused: [value: number]
|
||||
reset: []
|
||||
}
|
||||
interface CountToEmits {
|
||||
started: [value: number]
|
||||
finished: [value: number]
|
||||
paused: [value: number]
|
||||
reset: []
|
||||
}
|
||||
|
||||
interface CountToExpose {
|
||||
start: (target?: number) => void
|
||||
pause: () => void
|
||||
reset: (newTarget?: number) => void
|
||||
stop: () => void
|
||||
setTarget: (target: number) => void
|
||||
readonly isRunning: boolean
|
||||
readonly isPaused: boolean
|
||||
readonly currentValue: number
|
||||
readonly targetValue: number
|
||||
readonly progress: number
|
||||
}
|
||||
interface CountToExpose {
|
||||
start: (target?: number) => void
|
||||
pause: () => void
|
||||
reset: (newTarget?: number) => void
|
||||
stop: () => void
|
||||
setTarget: (target: number) => void
|
||||
readonly isRunning: boolean
|
||||
readonly isPaused: boolean
|
||||
readonly currentValue: number
|
||||
readonly targetValue: number
|
||||
readonly progress: number
|
||||
}
|
||||
|
||||
// 常量定义
|
||||
const EPSILON = Number.EPSILON
|
||||
const MIN_DURATION = 100
|
||||
const MAX_DURATION = 60000
|
||||
const MAX_DECIMALS = 10
|
||||
const DEFAULT_EASING = 'easeOutExpo'
|
||||
const DEFAULT_DURATION = 2000
|
||||
// 常量定义
|
||||
const EPSILON = Number.EPSILON
|
||||
const MIN_DURATION = 100
|
||||
const MAX_DURATION = 60000
|
||||
const MAX_DECIMALS = 10
|
||||
const DEFAULT_EASING = 'easeOutExpo'
|
||||
const DEFAULT_DURATION = 2000
|
||||
|
||||
const props = withDefaults(defineProps<CountToProps>(), {
|
||||
target: 0,
|
||||
duration: DEFAULT_DURATION,
|
||||
autoStart: true,
|
||||
decimals: 0,
|
||||
decimal: '.',
|
||||
separator: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
easing: DEFAULT_EASING,
|
||||
disabled: false
|
||||
})
|
||||
const props = withDefaults(defineProps<CountToProps>(), {
|
||||
target: 0,
|
||||
duration: DEFAULT_DURATION,
|
||||
autoStart: true,
|
||||
decimals: 0,
|
||||
decimal: '.',
|
||||
separator: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
easing: DEFAULT_EASING,
|
||||
disabled: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<CountToEmits>()
|
||||
const emit = defineEmits<CountToEmits>()
|
||||
|
||||
// 工具函数
|
||||
const validateNumber = (value: number, name: string, defaultValue: number): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
console.warn(`[CountTo] Invalid ${name} value:`, value)
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
// 工具函数
|
||||
const validateNumber = (value: number, name: string, defaultValue: number): number => {
|
||||
if (!Number.isFinite(value)) {
|
||||
console.warn(`[CountTo] Invalid ${name} value:`, value)
|
||||
return defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
const clamp = (value: number, min: number, max: number): number => {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
const formatNumber = (
|
||||
value: number,
|
||||
decimals: number,
|
||||
decimal: string,
|
||||
separator: string
|
||||
): string => {
|
||||
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString()
|
||||
const formatNumber = (
|
||||
value: number,
|
||||
decimals: number,
|
||||
decimal: string,
|
||||
separator: string
|
||||
): string => {
|
||||
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString()
|
||||
|
||||
// 处理小数点符号
|
||||
if (decimal !== '.' && result.includes('.')) {
|
||||
result = result.replace('.', decimal)
|
||||
}
|
||||
// 处理小数点符号
|
||||
if (decimal !== '.' && result.includes('.')) {
|
||||
result = result.replace('.', decimal)
|
||||
}
|
||||
|
||||
// 处理千分位分隔符
|
||||
if (separator) {
|
||||
const parts = result.split(decimal)
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
|
||||
result = parts.join(decimal)
|
||||
}
|
||||
// 处理千分位分隔符
|
||||
if (separator) {
|
||||
const parts = result.split(decimal)
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
|
||||
result = parts.join(decimal)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// 安全计算值
|
||||
const safeTarget = computed(() => validateNumber(props.target, 'target', 0))
|
||||
const safeDuration = computed(() =>
|
||||
clamp(validateNumber(props.duration, 'duration', DEFAULT_DURATION), MIN_DURATION, MAX_DURATION)
|
||||
)
|
||||
const safeDecimals = computed(() =>
|
||||
clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS)
|
||||
)
|
||||
const safeEasing = computed(() => {
|
||||
const easing = props.easing
|
||||
if (!(easing in TransitionPresets)) {
|
||||
console.warn('[CountTo] Invalid easing value:', easing)
|
||||
return DEFAULT_EASING
|
||||
}
|
||||
return easing
|
||||
})
|
||||
// 安全计算值
|
||||
const safeTarget = computed(() => validateNumber(props.target, 'target', 0))
|
||||
const safeDuration = computed(() =>
|
||||
clamp(
|
||||
validateNumber(props.duration, 'duration', DEFAULT_DURATION),
|
||||
MIN_DURATION,
|
||||
MAX_DURATION
|
||||
)
|
||||
)
|
||||
const safeDecimals = computed(() =>
|
||||
clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS)
|
||||
)
|
||||
const safeEasing = computed(() => {
|
||||
const easing = props.easing
|
||||
if (!(easing in TransitionPresets)) {
|
||||
console.warn('[CountTo] Invalid easing value:', easing)
|
||||
return DEFAULT_EASING
|
||||
}
|
||||
return easing
|
||||
})
|
||||
|
||||
// 状态管理
|
||||
const currentValue = shallowRef(0)
|
||||
const targetValue = shallowRef(safeTarget.value)
|
||||
const isRunning = shallowRef(false)
|
||||
const isPaused = shallowRef(false)
|
||||
const pausedValue = shallowRef(0)
|
||||
// 状态管理
|
||||
const currentValue = shallowRef(0)
|
||||
const targetValue = shallowRef(safeTarget.value)
|
||||
const isRunning = shallowRef(false)
|
||||
const isPaused = shallowRef(false)
|
||||
const pausedValue = shallowRef(0)
|
||||
|
||||
// 动画控制
|
||||
const transitionValue = useTransition(currentValue, {
|
||||
duration: safeDuration,
|
||||
transition: computed(() => TransitionPresets[safeEasing.value]),
|
||||
onStarted: () => {
|
||||
isRunning.value = true
|
||||
isPaused.value = false
|
||||
emit('started', targetValue.value)
|
||||
},
|
||||
onFinished: () => {
|
||||
isRunning.value = false
|
||||
isPaused.value = false
|
||||
emit('finished', targetValue.value)
|
||||
}
|
||||
})
|
||||
// 动画控制
|
||||
const transitionValue = useTransition(currentValue, {
|
||||
duration: safeDuration,
|
||||
transition: computed(() => TransitionPresets[safeEasing.value]),
|
||||
onStarted: () => {
|
||||
isRunning.value = true
|
||||
isPaused.value = false
|
||||
emit('started', targetValue.value)
|
||||
},
|
||||
onFinished: () => {
|
||||
isRunning.value = false
|
||||
isPaused.value = false
|
||||
emit('finished', targetValue.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 格式化显示值
|
||||
const formattedValue = computed(() => {
|
||||
const value = isPaused.value ? pausedValue.value : transitionValue.value
|
||||
// 格式化显示值
|
||||
const formattedValue = computed(() => {
|
||||
const value = isPaused.value ? pausedValue.value : transitionValue.value
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
return `${props.prefix}0${props.suffix}`
|
||||
}
|
||||
if (!Number.isFinite(value)) {
|
||||
return `${props.prefix}0${props.suffix}`
|
||||
}
|
||||
|
||||
const formattedNumber = formatNumber(value, safeDecimals.value, props.decimal, props.separator)
|
||||
return `${props.prefix}${formattedNumber}${props.suffix}`
|
||||
})
|
||||
const formattedNumber = formatNumber(
|
||||
value,
|
||||
safeDecimals.value,
|
||||
props.decimal,
|
||||
props.separator
|
||||
)
|
||||
return `${props.prefix}${formattedNumber}${props.suffix}`
|
||||
})
|
||||
|
||||
// 私有方法
|
||||
const shouldSkipAnimation = (target: number): boolean => {
|
||||
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
||||
return Math.abs(current - target) < EPSILON
|
||||
}
|
||||
// 私有方法
|
||||
const shouldSkipAnimation = (target: number): boolean => {
|
||||
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
||||
return Math.abs(current - target) < EPSILON
|
||||
}
|
||||
|
||||
const resetPauseState = (): void => {
|
||||
isPaused.value = false
|
||||
pausedValue.value = 0
|
||||
}
|
||||
const resetPauseState = (): void => {
|
||||
isPaused.value = false
|
||||
pausedValue.value = 0
|
||||
}
|
||||
|
||||
// 公共方法
|
||||
const start = (target?: number): void => {
|
||||
if (props.disabled) {
|
||||
console.warn('[CountTo] Animation is disabled')
|
||||
return
|
||||
}
|
||||
// 公共方法
|
||||
const start = (target?: number): void => {
|
||||
if (props.disabled) {
|
||||
console.warn('[CountTo] Animation is disabled')
|
||||
return
|
||||
}
|
||||
|
||||
const finalTarget = target !== undefined ? target : targetValue.value
|
||||
const finalTarget = target !== undefined ? target : targetValue.value
|
||||
|
||||
if (!Number.isFinite(finalTarget)) {
|
||||
console.warn('[CountTo] Invalid target value for start:', finalTarget)
|
||||
return
|
||||
}
|
||||
if (!Number.isFinite(finalTarget)) {
|
||||
console.warn('[CountTo] Invalid target value for start:', finalTarget)
|
||||
return
|
||||
}
|
||||
|
||||
targetValue.value = finalTarget
|
||||
targetValue.value = finalTarget
|
||||
|
||||
if (shouldSkipAnimation(finalTarget)) {
|
||||
return
|
||||
}
|
||||
if (shouldSkipAnimation(finalTarget)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 从暂停值开始(如果存在)
|
||||
if (isPaused.value) {
|
||||
currentValue.value = pausedValue.value
|
||||
resetPauseState()
|
||||
}
|
||||
// 从暂停值开始(如果存在)
|
||||
if (isPaused.value) {
|
||||
currentValue.value = pausedValue.value
|
||||
resetPauseState()
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
currentValue.value = finalTarget
|
||||
})
|
||||
}
|
||||
nextTick(() => {
|
||||
currentValue.value = finalTarget
|
||||
})
|
||||
}
|
||||
|
||||
const pause = (): void => {
|
||||
if (!isRunning.value || isPaused.value) {
|
||||
return
|
||||
}
|
||||
const pause = (): void => {
|
||||
if (!isRunning.value || isPaused.value) {
|
||||
return
|
||||
}
|
||||
|
||||
isPaused.value = true
|
||||
pausedValue.value = transitionValue.value
|
||||
currentValue.value = pausedValue.value
|
||||
isPaused.value = true
|
||||
pausedValue.value = transitionValue.value
|
||||
currentValue.value = pausedValue.value
|
||||
|
||||
emit('paused', pausedValue.value)
|
||||
}
|
||||
emit('paused', pausedValue.value)
|
||||
}
|
||||
|
||||
const reset = (newTarget = 0): void => {
|
||||
const target = validateNumber(newTarget, 'reset target', 0)
|
||||
const reset = (newTarget = 0): void => {
|
||||
const target = validateNumber(newTarget, 'reset target', 0)
|
||||
|
||||
currentValue.value = target
|
||||
targetValue.value = target
|
||||
resetPauseState()
|
||||
currentValue.value = target
|
||||
targetValue.value = target
|
||||
resetPauseState()
|
||||
|
||||
emit('reset')
|
||||
}
|
||||
emit('reset')
|
||||
}
|
||||
|
||||
const setTarget = (target: number): void => {
|
||||
if (!Number.isFinite(target)) {
|
||||
console.warn('[CountTo] Invalid target value for setTarget:', target)
|
||||
return
|
||||
}
|
||||
const setTarget = (target: number): void => {
|
||||
if (!Number.isFinite(target)) {
|
||||
console.warn('[CountTo] Invalid target value for setTarget:', target)
|
||||
return
|
||||
}
|
||||
|
||||
targetValue.value = target
|
||||
targetValue.value = target
|
||||
|
||||
if ((isRunning.value || props.autoStart) && !props.disabled) {
|
||||
start(target)
|
||||
}
|
||||
}
|
||||
if ((isRunning.value || props.autoStart) && !props.disabled) {
|
||||
start(target)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = (): void => {
|
||||
if (isRunning.value || isPaused.value) {
|
||||
currentValue.value = 0
|
||||
resetPauseState()
|
||||
emit('paused', 0)
|
||||
}
|
||||
}
|
||||
const stop = (): void => {
|
||||
if (isRunning.value || isPaused.value) {
|
||||
currentValue.value = 0
|
||||
resetPauseState()
|
||||
emit('paused', 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
safeTarget,
|
||||
(newTarget) => {
|
||||
if (props.autoStart && !props.disabled) {
|
||||
start(newTarget)
|
||||
} else {
|
||||
targetValue.value = newTarget
|
||||
}
|
||||
},
|
||||
{ immediate: props.autoStart && !props.disabled }
|
||||
)
|
||||
// 监听器
|
||||
watch(
|
||||
safeTarget,
|
||||
(newTarget) => {
|
||||
if (props.autoStart && !props.disabled) {
|
||||
start(newTarget)
|
||||
} else {
|
||||
targetValue.value = newTarget
|
||||
}
|
||||
},
|
||||
{ immediate: props.autoStart && !props.disabled }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled && isRunning.value) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => props.disabled,
|
||||
(disabled) => {
|
||||
if (disabled && isRunning.value) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (isRunning.value) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
// 清理
|
||||
onUnmounted(() => {
|
||||
if (isRunning.value) {
|
||||
stop()
|
||||
}
|
||||
})
|
||||
|
||||
// 暴露 API
|
||||
defineExpose<CountToExpose>({
|
||||
start,
|
||||
pause,
|
||||
reset,
|
||||
stop,
|
||||
setTarget,
|
||||
get isRunning() {
|
||||
return isRunning.value
|
||||
},
|
||||
get isPaused() {
|
||||
return isPaused.value
|
||||
},
|
||||
get currentValue() {
|
||||
return isPaused.value ? pausedValue.value : transitionValue.value
|
||||
},
|
||||
get targetValue() {
|
||||
return targetValue.value
|
||||
},
|
||||
get progress() {
|
||||
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
||||
const target = targetValue.value
|
||||
if (target === 0) return current === 0 ? 1 : 0
|
||||
return Math.abs(current / target)
|
||||
}
|
||||
})
|
||||
// 暴露 API
|
||||
defineExpose<CountToExpose>({
|
||||
start,
|
||||
pause,
|
||||
reset,
|
||||
stop,
|
||||
setTarget,
|
||||
get isRunning() {
|
||||
return isRunning.value
|
||||
},
|
||||
get isPaused() {
|
||||
return isPaused.value
|
||||
},
|
||||
get currentValue() {
|
||||
return isPaused.value ? pausedValue.value : transitionValue.value
|
||||
},
|
||||
get targetValue() {
|
||||
return targetValue.value
|
||||
},
|
||||
get progress() {
|
||||
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
||||
const target = targetValue.value
|
||||
if (target === 0) return current === 0 ? 1 : 0
|
||||
return Math.abs(current / target)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
<!-- 节日文本滚动 -->
|
||||
<template>
|
||||
<div
|
||||
class="overflow-hidden transition-[height] duration-600 ease-in-out"
|
||||
:style="{
|
||||
height: showFestivalText ? '48px' : '0'
|
||||
}"
|
||||
>
|
||||
<ArtTextScroll
|
||||
v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
|
||||
:text="currentFestivalData?.scrollText || ''"
|
||||
style="margin-bottom: 12px"
|
||||
showClose
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="overflow-hidden transition-[height] duration-600 ease-in-out"
|
||||
:style="{
|
||||
height: showFestivalText ? '48px' : '0'
|
||||
}"
|
||||
>
|
||||
<ArtTextScroll
|
||||
v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
|
||||
:text="currentFestivalData?.scrollText || ''"
|
||||
style="margin-bottom: 12px"
|
||||
showClose
|
||||
@close="handleClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useCeremony } from '@/hooks/core/useCeremony'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useCeremony } from '@/hooks/core/useCeremony'
|
||||
|
||||
defineOptions({ name: 'ArtFestivalTextScroll' })
|
||||
defineOptions({ name: 'ArtFestivalTextScroll' })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { showFestivalText } = storeToRefs(settingStore)
|
||||
const { currentFestivalData } = useCeremony()
|
||||
const settingStore = useSettingStore()
|
||||
const { showFestivalText } = storeToRefs(settingStore)
|
||||
const { currentFestivalData } = useCeremony()
|
||||
|
||||
const handleClose = () => {
|
||||
settingStore.setShowFestivalText(false)
|
||||
}
|
||||
const handleClose = () => {
|
||||
settingStore.setShowFestivalText(false)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,285 +1,285 @@
|
||||
<!-- 文字滚动 -->
|
||||
<template>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
|
||||
:class="themeClasses"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
<div
|
||||
ref="containerRef"
|
||||
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
|
||||
:class="themeClasses"
|
||||
:style="containerStyle"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
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="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
|
||||
:style="contentStyle"
|
||||
@click="handleContentClick"
|
||||
>
|
||||
<!-- 原始内容 -->
|
||||
<span ref="textRef" class="inline-block">
|
||||
<slot>
|
||||
<span v-html="text"></span>
|
||||
</slot>
|
||||
</span>
|
||||
<!-- 克隆内容用于无缝循环 -->
|
||||
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
|
||||
<slot>
|
||||
<span v-html="text"></span>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
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="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
|
||||
:style="contentStyle"
|
||||
@click="handleContentClick"
|
||||
>
|
||||
<!-- 原始内容 -->
|
||||
<span ref="textRef" class="inline-block">
|
||||
<slot>
|
||||
<span v-html="text"></span>
|
||||
</slot>
|
||||
</span>
|
||||
<!-- 克隆内容用于无缝循环 -->
|
||||
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
|
||||
<slot>
|
||||
<span v-html="text"></span>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showClose"
|
||||
class="flex-cc absolute right-0 h-full w-9 c-p"
|
||||
:style="{ backgroundColor: bgColor }"
|
||||
@click="handleClose"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:close-fill" class="text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showClose"
|
||||
class="flex-cc absolute right-0 h-full w-9 c-p"
|
||||
:style="{ backgroundColor: bgColor }"
|
||||
@click="handleClose"
|
||||
>
|
||||
<ArtSvgIcon icon="ri:close-fill" class="text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useElementSize,
|
||||
useRafFn,
|
||||
useElementHover,
|
||||
useDebounceFn,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import {
|
||||
useElementSize,
|
||||
useRafFn,
|
||||
useElementHover,
|
||||
useDebounceFn,
|
||||
useTimeoutFn
|
||||
} from '@vueuse/core'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
|
||||
type ThemeType =
|
||||
| 'theme'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'error'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
type ThemeType =
|
||||
| 'theme'
|
||||
| 'primary'
|
||||
| 'secondary'
|
||||
| 'error'
|
||||
| 'info'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
|
||||
/**
|
||||
* 文本滚动组件属性接口
|
||||
*/
|
||||
export interface TextScrollProps {
|
||||
/** 滚动文本内容 */
|
||||
text?: string
|
||||
/** 主题类型 */
|
||||
type?: ThemeType
|
||||
/** 滚动方向 */
|
||||
direction?: 'left' | 'right' | 'up' | 'down'
|
||||
/** 滚动速度,单位:像素/秒 */
|
||||
speed?: number
|
||||
/** 容器宽度 */
|
||||
width?: string
|
||||
/** 容器高度 */
|
||||
height?: string
|
||||
/** 鼠标悬停时是否暂停滚动 */
|
||||
pauseOnHover?: boolean
|
||||
/** 是否显示关闭按钮 */
|
||||
showClose?: boolean
|
||||
/** 始终滚动(即使文字未溢出) */
|
||||
alwaysScroll?: boolean
|
||||
}
|
||||
/**
|
||||
* 文本滚动组件属性接口
|
||||
*/
|
||||
export interface TextScrollProps {
|
||||
/** 滚动文本内容 */
|
||||
text?: string
|
||||
/** 主题类型 */
|
||||
type?: ThemeType
|
||||
/** 滚动方向 */
|
||||
direction?: 'left' | 'right' | 'up' | 'down'
|
||||
/** 滚动速度,单位:像素/秒 */
|
||||
speed?: number
|
||||
/** 容器宽度 */
|
||||
width?: string
|
||||
/** 容器高度 */
|
||||
height?: string
|
||||
/** 鼠标悬停时是否暂停滚动 */
|
||||
pauseOnHover?: boolean
|
||||
/** 是否显示关闭按钮 */
|
||||
showClose?: boolean
|
||||
/** 始终滚动(即使文字未溢出) */
|
||||
alwaysScroll?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<TextScrollProps>(), {
|
||||
text: '',
|
||||
direction: 'left',
|
||||
speed: 80,
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
pauseOnHover: true,
|
||||
type: 'theme',
|
||||
showClose: false,
|
||||
alwaysScroll: true
|
||||
})
|
||||
const props = withDefaults(defineProps<TextScrollProps>(), {
|
||||
text: '',
|
||||
direction: 'left',
|
||||
speed: 80,
|
||||
width: '100%',
|
||||
height: '36px',
|
||||
pauseOnHover: true,
|
||||
type: 'theme',
|
||||
showClose: false,
|
||||
alwaysScroll: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
const settingStore = useSettingStore()
|
||||
const { isDark } = storeToRefs(settingStore)
|
||||
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const textRef = ref<HTMLElement>()
|
||||
const isReady = ref(false)
|
||||
const containerRef = ref<HTMLElement>()
|
||||
const contentRef = ref<HTMLElement>()
|
||||
const textRef = ref<HTMLElement>()
|
||||
const isReady = ref(false)
|
||||
|
||||
const currentPosition = ref(0)
|
||||
const textSize = ref(0)
|
||||
const containerSize = ref(0)
|
||||
const shouldClone = ref(false)
|
||||
const currentPosition = ref(0)
|
||||
const textSize = ref(0)
|
||||
const containerSize = ref(0)
|
||||
const shouldClone = ref(false)
|
||||
|
||||
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
|
||||
const isReverse = computed(() => props.direction === 'right' || props.direction === 'down')
|
||||
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
|
||||
const isReverse = computed(() => props.direction === 'right' || props.direction === 'down')
|
||||
|
||||
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
|
||||
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef)
|
||||
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
|
||||
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef)
|
||||
|
||||
// 使用 VueUse 的 useElementHover 检测鼠标悬停
|
||||
const isHovered = useElementHover(containerRef)
|
||||
// 使用 VueUse 的 useElementHover 检测鼠标悬停
|
||||
const isHovered = useElementHover(containerRef)
|
||||
|
||||
// 计算是否应该暂停动画
|
||||
const isPaused = computed(() => {
|
||||
// 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动
|
||||
if (!props.alwaysScroll && textSize.value <= containerSize.value) {
|
||||
return true
|
||||
}
|
||||
return props.pauseOnHover && isHovered.value
|
||||
})
|
||||
// 计算是否应该暂停动画
|
||||
const isPaused = computed(() => {
|
||||
// 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动
|
||||
if (!props.alwaysScroll && textSize.value <= containerSize.value) {
|
||||
return true
|
||||
}
|
||||
return props.pauseOnHover && isHovered.value
|
||||
})
|
||||
|
||||
// 主题样式映射
|
||||
const themeClasses = computed(() => {
|
||||
const themeMap: Record<ThemeType, string> = {
|
||||
theme: 'text-theme/90 !border-theme/50',
|
||||
primary: 'text-primary/90 !border-primary/50',
|
||||
secondary: 'text-secondary/90 !border-secondary/50',
|
||||
error: 'text-error/90 !border-error/50',
|
||||
info: 'text-info/90 !border-info/50',
|
||||
success: 'text-success/90 !border-success/50',
|
||||
warning: 'text-warning/90 !border-warning/50',
|
||||
danger: 'text-danger/90 !border-danger/50'
|
||||
}
|
||||
return themeMap[props.type] || themeMap.theme
|
||||
})
|
||||
// 主题样式映射
|
||||
const themeClasses = computed(() => {
|
||||
const themeMap: Record<ThemeType, string> = {
|
||||
theme: 'text-theme/90 !border-theme/50',
|
||||
primary: 'text-primary/90 !border-primary/50',
|
||||
secondary: 'text-secondary/90 !border-secondary/50',
|
||||
error: 'text-error/90 !border-error/50',
|
||||
info: 'text-info/90 !border-info/50',
|
||||
success: 'text-success/90 !border-success/50',
|
||||
warning: 'text-warning/90 !border-warning/50',
|
||||
danger: 'text-danger/90 !border-danger/50'
|
||||
}
|
||||
return themeMap[props.type] || themeMap.theme
|
||||
})
|
||||
|
||||
// 背景色
|
||||
const bgColor = computed(
|
||||
() =>
|
||||
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))`
|
||||
)
|
||||
// 背景色
|
||||
const bgColor = computed(
|
||||
() =>
|
||||
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))`
|
||||
)
|
||||
|
||||
const containerStyle = computed(() => ({
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
backgroundColor: bgColor.value
|
||||
}))
|
||||
const containerStyle = computed(() => ({
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
backgroundColor: bgColor.value
|
||||
}))
|
||||
|
||||
const contentClass = computed(() => {
|
||||
if (!isHorizontal.value) {
|
||||
return 'flex flex-col'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
const contentClass = computed(() => {
|
||||
if (!isHorizontal.value) {
|
||||
return 'flex flex-col'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const contentStyle = computed(() => {
|
||||
const transform = isHorizontal.value
|
||||
? `translateX(${currentPosition.value}px)`
|
||||
: `translateY(${currentPosition.value}px)`
|
||||
const contentStyle = computed(() => {
|
||||
const transform = isHorizontal.value
|
||||
? `translateX(${currentPosition.value}px)`
|
||||
: `translateY(${currentPosition.value}px)`
|
||||
|
||||
return {
|
||||
transform,
|
||||
willChange: 'transform'
|
||||
}
|
||||
})
|
||||
return {
|
||||
transform,
|
||||
willChange: 'transform'
|
||||
}
|
||||
})
|
||||
|
||||
// 克隆元素的间距
|
||||
const cloneSpacing = computed(() => {
|
||||
const spacing = '2em'
|
||||
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing }
|
||||
})
|
||||
// 克隆元素的间距
|
||||
const cloneSpacing = computed(() => {
|
||||
const spacing = '2em'
|
||||
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing }
|
||||
})
|
||||
|
||||
const measureSizes = () => {
|
||||
if (!containerRef.value || !textRef.value) return
|
||||
const measureSizes = () => {
|
||||
if (!containerRef.value || !textRef.value) return
|
||||
|
||||
const text = textRef.value
|
||||
const text = textRef.value
|
||||
|
||||
if (isHorizontal.value) {
|
||||
containerSize.value = containerWidth.value
|
||||
textSize.value = text.offsetWidth
|
||||
} else {
|
||||
containerSize.value = containerHeight.value
|
||||
textSize.value = text.offsetHeight
|
||||
}
|
||||
if (isHorizontal.value) {
|
||||
containerSize.value = containerWidth.value
|
||||
textSize.value = text.offsetWidth
|
||||
} else {
|
||||
containerSize.value = containerHeight.value
|
||||
textSize.value = text.offsetHeight
|
||||
}
|
||||
|
||||
const isOverflow = textSize.value > containerSize.value
|
||||
shouldClone.value = isOverflow
|
||||
const isOverflow = textSize.value > containerSize.value
|
||||
shouldClone.value = isOverflow
|
||||
|
||||
// 居中显示
|
||||
currentPosition.value = (containerSize.value - textSize.value) / 2
|
||||
// 居中显示
|
||||
currentPosition.value = (containerSize.value - textSize.value) / 2
|
||||
|
||||
// 测量完成后才显示内容
|
||||
if (!isReady.value) {
|
||||
isReady.value = true
|
||||
}
|
||||
}
|
||||
// 测量完成后才显示内容
|
||||
if (!isReady.value) {
|
||||
isReady.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 VueUse 的 useDebounceFn 防抖测量
|
||||
const debouncedMeasure = useDebounceFn(measureSizes, 150)
|
||||
// 使用 VueUse 的 useDebounceFn 防抖测量
|
||||
const debouncedMeasure = useDebounceFn(measureSizes, 150)
|
||||
|
||||
let lastTimestamp = 0
|
||||
let lastTimestamp = 0
|
||||
|
||||
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
|
||||
const { pause, resume } = useRafFn(
|
||||
({ timestamp }) => {
|
||||
if (!lastTimestamp) lastTimestamp = timestamp
|
||||
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
|
||||
const { pause, resume } = useRafFn(
|
||||
({ timestamp }) => {
|
||||
if (!lastTimestamp) lastTimestamp = timestamp
|
||||
|
||||
if (!isPaused.value) {
|
||||
const delta = (timestamp - lastTimestamp) / 1000
|
||||
const distance = props.speed * delta
|
||||
const spacing = textSize.value * 0.1
|
||||
if (!isPaused.value) {
|
||||
const delta = (timestamp - lastTimestamp) / 1000
|
||||
const distance = props.speed * delta
|
||||
const spacing = textSize.value * 0.1
|
||||
|
||||
currentPosition.value += isReverse.value ? distance : -distance
|
||||
currentPosition.value += isReverse.value ? distance : -distance
|
||||
|
||||
// 循环边界检测
|
||||
if (isReverse.value) {
|
||||
if (currentPosition.value > containerSize.value) {
|
||||
currentPosition.value = -(textSize.value + spacing)
|
||||
}
|
||||
} else {
|
||||
if (currentPosition.value < -(textSize.value + spacing)) {
|
||||
currentPosition.value = containerSize.value
|
||||
}
|
||||
}
|
||||
}
|
||||
// 循环边界检测
|
||||
if (isReverse.value) {
|
||||
if (currentPosition.value > containerSize.value) {
|
||||
currentPosition.value = -(textSize.value + spacing)
|
||||
}
|
||||
} else {
|
||||
if (currentPosition.value < -(textSize.value + spacing)) {
|
||||
currentPosition.value = containerSize.value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastTimestamp = timestamp
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
lastTimestamp = timestamp
|
||||
},
|
||||
{ immediate: false }
|
||||
)
|
||||
|
||||
const handleContentClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'A') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
const handleContentClick = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'A') {
|
||||
e.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
// 监听容器尺寸变化
|
||||
watch([containerWidth, containerHeight], () => {
|
||||
debouncedMeasure()
|
||||
})
|
||||
// 监听容器尺寸变化
|
||||
watch([containerWidth, containerHeight], () => {
|
||||
debouncedMeasure()
|
||||
})
|
||||
|
||||
// 监听属性变化
|
||||
watch(
|
||||
() => [props.direction, props.speed, props.text],
|
||||
() => {
|
||||
measureSizes()
|
||||
lastTimestamp = 0
|
||||
}
|
||||
)
|
||||
// 监听属性变化
|
||||
watch(
|
||||
() => [props.direction, props.speed, props.text],
|
||||
() => {
|
||||
measureSizes()
|
||||
lastTimestamp = 0
|
||||
}
|
||||
)
|
||||
|
||||
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout
|
||||
const { start: startMeasure } = useTimeoutFn(() => {
|
||||
measureSizes()
|
||||
// 测量完成后立即开始动画
|
||||
resume()
|
||||
}, 100)
|
||||
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout
|
||||
const { start: startMeasure } = useTimeoutFn(() => {
|
||||
measureSizes()
|
||||
// 测量完成后立即开始动画
|
||||
resume()
|
||||
}, 100)
|
||||
|
||||
onMounted(() => {
|
||||
startMeasure()
|
||||
})
|
||||
onMounted(() => {
|
||||
startMeasure()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pause()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
pause()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,100 +1,100 @@
|
||||
<!-- 一个让 SVG 图片跟随主题的组件,只对特定 svg 图片生效,不建议开发者使用 -->
|
||||
<!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 -->
|
||||
<template>
|
||||
<div class="theme-svg" :style="sizeStyle">
|
||||
<div v-if="src" class="svg-container" v-html="svgContent"></div>
|
||||
</div>
|
||||
<div class="theme-svg" :style="sizeStyle">
|
||||
<div v-if="src" class="svg-container" v-html="svgContent"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
|
||||
interface Props {
|
||||
size?: string | number
|
||||
themeColor?: string
|
||||
src?: string
|
||||
}
|
||||
interface Props {
|
||||
size?: string | number
|
||||
themeColor?: string
|
||||
src?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 500,
|
||||
themeColor: 'var(--el-color-primary)'
|
||||
})
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
size: 500,
|
||||
themeColor: 'var(--el-color-primary)'
|
||||
})
|
||||
|
||||
const svgContent = ref('')
|
||||
const svgContent = ref('')
|
||||
|
||||
// 计算样式
|
||||
const sizeStyle = computed(() => {
|
||||
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
|
||||
return {
|
||||
width: sizeValue,
|
||||
height: sizeValue
|
||||
}
|
||||
})
|
||||
// 计算样式
|
||||
const sizeStyle = computed(() => {
|
||||
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
|
||||
return {
|
||||
width: sizeValue,
|
||||
height: sizeValue
|
||||
}
|
||||
})
|
||||
|
||||
// 颜色映射配置
|
||||
const COLOR_MAPPINGS = {
|
||||
'#C7DEFF': 'var(--el-color-primary-light-6)',
|
||||
'#071F4D': 'var(--el-color-primary-dark-2)',
|
||||
'#00E4E5': 'var(--el-color-primary-light-1)',
|
||||
'#006EFF': 'var(--el-color-primary)',
|
||||
'#fff': 'var(--default-box-color)',
|
||||
'#ffffff': 'var(--default-box-color)',
|
||||
'#DEEBFC': 'var(--el-color-primary-light-7)'
|
||||
} as const
|
||||
// 颜色映射配置
|
||||
const COLOR_MAPPINGS = {
|
||||
'#C7DEFF': 'var(--el-color-primary-light-6)',
|
||||
'#071F4D': 'var(--el-color-primary-dark-2)',
|
||||
'#00E4E5': 'var(--el-color-primary-light-1)',
|
||||
'#006EFF': 'var(--el-color-primary)',
|
||||
'#fff': 'var(--default-box-color)',
|
||||
'#ffffff': 'var(--default-box-color)',
|
||||
'#DEEBFC': 'var(--el-color-primary-light-7)'
|
||||
} as const
|
||||
|
||||
// 将主题色应用到 SVG 内容
|
||||
const applyThemeToSvg = (content: string): string => {
|
||||
return Object.entries(COLOR_MAPPINGS).reduce(
|
||||
(processedContent, [originalColor, themeColor]) => {
|
||||
const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi')
|
||||
const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi')
|
||||
// 将主题色应用到 SVG 内容
|
||||
const applyThemeToSvg = (content: string): string => {
|
||||
return Object.entries(COLOR_MAPPINGS).reduce(
|
||||
(processedContent, [originalColor, themeColor]) => {
|
||||
const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi')
|
||||
const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi')
|
||||
|
||||
return processedContent
|
||||
.replace(fillRegex, `fill="${themeColor}"`)
|
||||
.replace(strokeRegex, `stroke="${themeColor}"`)
|
||||
},
|
||||
content
|
||||
)
|
||||
}
|
||||
return processedContent
|
||||
.replace(fillRegex, `fill="${themeColor}"`)
|
||||
.replace(strokeRegex, `stroke="${themeColor}"`)
|
||||
},
|
||||
content
|
||||
)
|
||||
}
|
||||
|
||||
// 加载 SVG 文件内容
|
||||
const loadSvgContent = async () => {
|
||||
if (!props.src) {
|
||||
svgContent.value = ''
|
||||
return
|
||||
}
|
||||
// 加载 SVG 文件内容
|
||||
const loadSvgContent = async () => {
|
||||
if (!props.src) {
|
||||
svgContent.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(props.src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
try {
|
||||
const response = await fetch(props.src)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const content = await response.text()
|
||||
svgContent.value = applyThemeToSvg(content)
|
||||
} catch (error) {
|
||||
console.error('Failed to load SVG:', error)
|
||||
svgContent.value = ''
|
||||
}
|
||||
}
|
||||
const content = await response.text()
|
||||
svgContent.value = applyThemeToSvg(content)
|
||||
} catch (error) {
|
||||
console.error('Failed to load SVG:', error)
|
||||
svgContent.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
loadSvgContent()
|
||||
})
|
||||
watchEffect(() => {
|
||||
loadSvgContent()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.theme-svg {
|
||||
display: inline-block;
|
||||
.theme-svg {
|
||||
display: inline-block;
|
||||
|
||||
.svg-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
.svg-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
:deep(svg) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,43 +1,43 @@
|
||||
<template>
|
||||
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
|
||||
<div class="flex-cc max-md:!block max-md:text-center">
|
||||
<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">
|
||||
<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">{{
|
||||
data.btnText
|
||||
}}</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
|
||||
<div class="flex-cc max-md:!block max-md:text-center">
|
||||
<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">
|
||||
<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">{{
|
||||
data.btnText
|
||||
}}</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
import { useCommon } from '@/hooks/core/useCommon'
|
||||
|
||||
const router = useRouter()
|
||||
const router = useRouter()
|
||||
|
||||
interface ExceptionData {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 描述 */
|
||||
desc: string
|
||||
/** 按钮文本 */
|
||||
btnText: string
|
||||
/** 图片地址 */
|
||||
imgUrl: string
|
||||
}
|
||||
interface ExceptionData {
|
||||
/** 标题 */
|
||||
title: string
|
||||
/** 描述 */
|
||||
desc: string
|
||||
/** 按钮文本 */
|
||||
btnText: string
|
||||
/** 图片地址 */
|
||||
imgUrl: string
|
||||
}
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
data: ExceptionData
|
||||
}>(),
|
||||
{}
|
||||
)
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
data: ExceptionData
|
||||
}>(),
|
||||
{}
|
||||
)
|
||||
|
||||
const { homePath } = useCommon()
|
||||
const { homePath } = useCommon()
|
||||
|
||||
const backHome = () => {
|
||||
router.push(homePath.value)
|
||||
}
|
||||
const backHome = () => {
|
||||
router.push(homePath.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,149 +1,161 @@
|
||||
<!-- 授权页右上角组件 -->
|
||||
<template>
|
||||
<div
|
||||
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">
|
||||
<ArtLogo class="icon" size="46" />
|
||||
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
|
||||
</div>
|
||||
<div
|
||||
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">
|
||||
<ArtLogo class="icon" size="46" />
|
||||
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
|
||||
</div>
|
||||
|
||||
<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-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
|
||||
v-for="(color, index) in mainColors"
|
||||
:key="color"
|
||||
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
|
||||
:class="{ active: color === systemThemeColor }"
|
||||
:style="{ background: color, '--index': index }"
|
||||
@click="changeThemeColor(color)"
|
||||
>
|
||||
<ArtSvgIcon v-if="color === systemThemeColor" icon="ri:check-fill" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
|
||||
<ArtSvgIcon
|
||||
icon="ri:palette-line"
|
||||
class="text-xl text-g-800 transition-colors duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="shouldShowLanguage"
|
||||
@command="changeLanguage"
|
||||
popper-class="langDropDownStyle"
|
||||
>
|
||||
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
|
||||
<ArtSvgIcon
|
||||
icon="ri:translate-2"
|
||||
class="text-[19px] text-g-800 transition-colors duration-300"
|
||||
/>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
|
||||
<ElDropdownItem
|
||||
:command="lang.value"
|
||||
:class="{ 'is-selected': locale === lang.value }"
|
||||
>
|
||||
<span class="menu-txt">{{ lang.label }}</span>
|
||||
<ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</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>
|
||||
<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-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
|
||||
v-for="(color, index) in mainColors"
|
||||
:key="color"
|
||||
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
|
||||
:class="{ active: color === systemThemeColor }"
|
||||
:style="{ background: color, '--index': index }"
|
||||
@click="changeThemeColor(color)"
|
||||
>
|
||||
<ArtSvgIcon
|
||||
v-if="color === systemThemeColor"
|
||||
icon="ri:check-fill"
|
||||
class="text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
|
||||
<ArtSvgIcon
|
||||
icon="ri:palette-line"
|
||||
class="text-xl text-g-800 transition-colors duration-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ElDropdown
|
||||
v-if="shouldShowLanguage"
|
||||
@command="changeLanguage"
|
||||
popper-class="langDropDownStyle"
|
||||
>
|
||||
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
|
||||
<ArtSvgIcon
|
||||
icon="ri:translate-2"
|
||||
class="text-[19px] text-g-800 transition-colors duration-300"
|
||||
/>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<div
|
||||
v-for="lang in languageOptions"
|
||||
:key="lang.value"
|
||||
class="lang-btn-item"
|
||||
>
|
||||
<ElDropdownItem
|
||||
:command="lang.value"
|
||||
:class="{ 'is-selected': locale === lang.value }"
|
||||
>
|
||||
<span class="menu-txt">{{ lang.label }}</span>
|
||||
<ArtSvgIcon
|
||||
icon="ri:check-fill"
|
||||
class="text-base"
|
||||
v-if="locale === lang.value"
|
||||
/>
|
||||
</ElDropdownItem>
|
||||
</div>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||
import { themeAnimation } from '@/utils/ui/animation'
|
||||
import { languageOptions } from '@/locales'
|
||||
import { LanguageEnum } from '@/enums/appEnum'
|
||||
import AppConfig from '@/config'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useSettingStore } from '@/store/modules/setting'
|
||||
import { useUserStore } from '@/store/modules/user'
|
||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||
import { themeAnimation } from '@/utils/ui/animation'
|
||||
import { languageOptions } from '@/locales'
|
||||
import { LanguageEnum } from '@/enums/appEnum'
|
||||
import AppConfig from '@/config'
|
||||
|
||||
defineOptions({ name: 'AuthTopBar' })
|
||||
defineOptions({ name: 'AuthTopBar' })
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const { isDark, systemThemeColor } = storeToRefs(settingStore)
|
||||
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
|
||||
const { locale } = useI18n()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const { isDark, systemThemeColor } = storeToRefs(settingStore)
|
||||
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
|
||||
const { locale } = useI18n()
|
||||
|
||||
const mainColors = AppConfig.systemMainColor
|
||||
const color = systemThemeColor // css v-bind 使用
|
||||
const mainColors = AppConfig.systemMainColor
|
||||
const color = systemThemeColor // css v-bind 使用
|
||||
|
||||
const changeLanguage = (lang: LanguageEnum) => {
|
||||
if (locale.value === lang) return
|
||||
locale.value = lang
|
||||
userStore.setLanguage(lang)
|
||||
}
|
||||
const changeLanguage = (lang: LanguageEnum) => {
|
||||
if (locale.value === lang) return
|
||||
locale.value = lang
|
||||
userStore.setLanguage(lang)
|
||||
}
|
||||
|
||||
const changeThemeColor = (color: string) => {
|
||||
if (systemThemeColor.value === color) return
|
||||
settingStore.setElementTheme(color)
|
||||
settingStore.reload()
|
||||
}
|
||||
const changeThemeColor = (color: string) => {
|
||||
if (systemThemeColor.value === color) return
|
||||
settingStore.setElementTheme(color)
|
||||
settingStore.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.color-dots {
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 12px var(--art-gray-300);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
.color-dots {
|
||||
pointer-events: none;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 2px 12px var(--art-gray-300);
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-delay: calc(var(--index) * 0.05s);
|
||||
transform: translateX(20px) scale(0.8);
|
||||
}
|
||||
.color-dot {
|
||||
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-delay: calc(var(--index) * 0.05s);
|
||||
transform: translateX(20px) scale(0.8);
|
||||
}
|
||||
|
||||
.color-dot:hover {
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
|
||||
transform: translateX(0) scale(1.1);
|
||||
}
|
||||
.color-dot:hover {
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
|
||||
transform: translateX(0) scale(1.1);
|
||||
}
|
||||
|
||||
.color-picker-expandable:hover .color-dots {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
.color-picker-expandable:hover .color-dots {
|
||||
pointer-events: auto;
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.color-picker-expandable:hover .color-dot {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
.color-picker-expandable:hover .color-dot {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scale(1);
|
||||
}
|
||||
|
||||
.dark .color-dots {
|
||||
background-color: var(--art-gray-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
.dark .color-dots {
|
||||
background-color: var(--art-gray-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
|
||||
color: v-bind(color);
|
||||
}
|
||||
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
|
||||
color: v-bind(color);
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user