Compare commits
2 Commits
1cc427cbb0
...
2f5ee49594
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f5ee49594 | |||
| a0afedf5f3 |
+18
-18
@@ -1,20 +1,20 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 100,
|
"printWidth": 100,
|
||||||
"tabWidth": 2,
|
"tabWidth": 4,
|
||||||
"useTabs": false,
|
"useTabs": true,
|
||||||
"semi": false,
|
"semi": false,
|
||||||
"vueIndentScriptAndStyle": true,
|
"vueIndentScriptAndStyle": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"quoteProps": "as-needed",
|
"quoteProps": "as-needed",
|
||||||
"bracketSpacing": true,
|
"bracketSpacing": true,
|
||||||
"trailingComma": "none",
|
"trailingComma": "none",
|
||||||
"bracketSameLine": false,
|
"bracketSameLine": false,
|
||||||
"jsxSingleQuote": false,
|
"jsxSingleQuote": false,
|
||||||
"arrowParens": "always",
|
"arrowParens": "always",
|
||||||
"insertPragma": false,
|
"insertPragma": false,
|
||||||
"requirePragma": false,
|
"requirePragma": false,
|
||||||
"proseWrap": "never",
|
"proseWrap": "never",
|
||||||
"htmlWhitespaceSensitivity": "strict",
|
"htmlWhitespaceSensitivity": "strict",
|
||||||
"endOfLine": "auto",
|
"endOfLine": "auto",
|
||||||
"rangeStart": 0
|
"rangeStart": 0
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-80
@@ -1,82 +1,82 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
// 继承推荐规范配置
|
// 继承推荐规范配置
|
||||||
extends: [
|
extends: [
|
||||||
'stylelint-config-standard',
|
'stylelint-config-standard',
|
||||||
'stylelint-config-recommended-scss',
|
'stylelint-config-recommended-scss',
|
||||||
'stylelint-config-recommended-vue/scss',
|
'stylelint-config-recommended-vue/scss',
|
||||||
'stylelint-config-html/vue',
|
'stylelint-config-html/vue',
|
||||||
'stylelint-config-recess-order'
|
'stylelint-config-recess-order'
|
||||||
],
|
],
|
||||||
// 指定不同文件对应的解析器
|
// 指定不同文件对应的解析器
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['**/*.{vue,html}'],
|
files: ['**/*.{vue,html}'],
|
||||||
customSyntax: 'postcss-html'
|
customSyntax: 'postcss-html'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.{css,scss}'],
|
files: ['**/*.{css,scss}'],
|
||||||
customSyntax: 'postcss-scss'
|
customSyntax: 'postcss-scss'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// 自定义规则
|
// 自定义规则
|
||||||
rules: {
|
rules: {
|
||||||
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
|
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
|
||||||
'selector-class-pattern': null, // 选择器类名命名规则
|
'selector-class-pattern': null, // 选择器类名命名规则
|
||||||
'custom-property-pattern': null, // 自定义属性命名规则
|
'custom-property-pattern': null, // 自定义属性命名规则
|
||||||
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
|
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
|
||||||
'no-descending-specificity': null, // 允许无降序特异性
|
'no-descending-specificity': null, // 允许无降序特异性
|
||||||
'no-empty-source': null, // 允许空样式
|
'no-empty-source': null, // 允许空样式
|
||||||
'property-no-vendor-prefix': null, // 允许属性前缀
|
'property-no-vendor-prefix': null, // 允许属性前缀
|
||||||
// 允许 global 、export 、deep伪类
|
// 允许 global 、export 、deep伪类
|
||||||
'selector-pseudo-class-no-unknown': [
|
'selector-pseudo-class-no-unknown': [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ignorePseudoClasses: ['global', 'export', 'deep']
|
ignorePseudoClasses: ['global', 'export', 'deep']
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// 允许未知属性
|
// 允许未知属性
|
||||||
'property-no-unknown': [
|
'property-no-unknown': [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ignoreProperties: []
|
ignoreProperties: []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// 允许未知规则
|
// 允许未知规则
|
||||||
'at-rule-no-unknown': [
|
'at-rule-no-unknown': [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ignoreAtRules: [
|
ignoreAtRules: [
|
||||||
'apply',
|
'apply',
|
||||||
'use',
|
'use',
|
||||||
'mixin',
|
'mixin',
|
||||||
'include',
|
'include',
|
||||||
'extend',
|
'extend',
|
||||||
'each',
|
'each',
|
||||||
'if',
|
'if',
|
||||||
'else',
|
'else',
|
||||||
'for',
|
'for',
|
||||||
'while',
|
'while',
|
||||||
'reference'
|
'reference'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
'scss/at-rule-no-unknown': [
|
'scss/at-rule-no-unknown': [
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
ignoreAtRules: [
|
ignoreAtRules: [
|
||||||
'apply',
|
'apply',
|
||||||
'use',
|
'use',
|
||||||
'mixin',
|
'mixin',
|
||||||
'include',
|
'include',
|
||||||
'extend',
|
'extend',
|
||||||
'each',
|
'each',
|
||||||
'if',
|
'if',
|
||||||
'else',
|
'else',
|
||||||
'for',
|
'for',
|
||||||
'while',
|
'while',
|
||||||
'reference'
|
'reference'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-39
@@ -1,47 +1,47 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Art Design Pro</title>
|
<title>Art Design Pro</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Art Design Pro - A modern admin dashboard template built with Vue 3, TypeScript, and Element Plus."
|
content="Art Design Pro - A modern admin dashboard template built with Vue 3, TypeScript, and Element Plus."
|
||||||
/>
|
/>
|
||||||
<link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" />
|
<link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* 防止页面刷新时白屏的初始样式 */
|
/* 防止页面刷新时白屏的初始样式 */
|
||||||
html {
|
html {
|
||||||
background-color: #fafbfc;
|
background-color: #fafbfc;
|
||||||
}
|
}
|
||||||
|
|
||||||
html.dark {
|
html.dark {
|
||||||
background-color: #070707;
|
background-color: #070707;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// 初始化 html class 主题属性
|
// 初始化 html class 主题属性
|
||||||
;(function () {
|
;(function () {
|
||||||
try {
|
try {
|
||||||
if (typeof Storage === 'undefined' || !window.localStorage) {
|
if (typeof Storage === 'undefined' || !window.localStorage) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeType = localStorage.getItem('sys-theme')
|
const themeType = localStorage.getItem('sys-theme')
|
||||||
if (themeType === 'dark') {
|
if (themeType === 'dark') {
|
||||||
document.documentElement.classList.add('dark')
|
document.documentElement.classList.add('dark')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Failed to apply initial theme:', e)
|
console.warn('Failed to apply initial theme:', e)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+106
-116
@@ -1,118 +1,108 @@
|
|||||||
{
|
{
|
||||||
"name": "art-design-pro",
|
"name": "art-design-pro",
|
||||||
"version": "0.0.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0",
|
"node": ">=20.19.0"
|
||||||
"pnpm": ">=8.8.0"
|
},
|
||||||
},
|
"scripts": {
|
||||||
"scripts": {
|
"dev": "vite --open",
|
||||||
"dev": "vite --open",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"build": "vue-tsc --noEmit && vite build",
|
"serve": "vite preview",
|
||||||
"serve": "vite preview",
|
"lint": "eslint",
|
||||||
"lint": "eslint",
|
"fix": "eslint --fix",
|
||||||
"fix": "eslint --fix",
|
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
||||||
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
|
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix"
|
||||||
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
|
},
|
||||||
"lint:lint-staged": "lint-staged",
|
"lint-staged": {
|
||||||
"prepare": "husky",
|
"*.{js,ts,mjs,mts,tsx}": [
|
||||||
"commit": "git-cz",
|
"eslint --fix",
|
||||||
"clean:dev": "tsx scripts/clean-dev.ts"
|
"prettier --write"
|
||||||
},
|
],
|
||||||
"config": {
|
"*.{cjs,json,jsonc}": [
|
||||||
"commitizen": {
|
"prettier --write"
|
||||||
"path": "node_modules/cz-git"
|
],
|
||||||
}
|
"*.vue": [
|
||||||
},
|
"eslint --fix",
|
||||||
"lint-staged": {
|
"stylelint --fix --allow-empty-input",
|
||||||
"*.{js,ts,mjs,mts,tsx}": [
|
"prettier --write"
|
||||||
"eslint --fix",
|
],
|
||||||
"prettier --write"
|
"*.{html,htm}": [
|
||||||
],
|
"prettier --write"
|
||||||
"*.{cjs,json,jsonc}": [
|
],
|
||||||
"prettier --write"
|
"*.{scss,css,less}": [
|
||||||
],
|
"stylelint --fix --allow-empty-input",
|
||||||
"*.vue": [
|
"prettier --write"
|
||||||
"eslint --fix",
|
],
|
||||||
"stylelint --fix --allow-empty-input",
|
"*.{md,mdx}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
],
|
],
|
||||||
"*.{html,htm}": [
|
"*.{yaml,yml}": [
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
],
|
]
|
||||||
"*.{scss,css,less}": [
|
},
|
||||||
"stylelint --fix --allow-empty-input",
|
"dependencies": {
|
||||||
"prettier --write"
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
],
|
"@iconify/vue": "^5.0.0",
|
||||||
"*.{md,mdx}": [
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"prettier --write"
|
"@vue/reactivity": "^3.5.21",
|
||||||
],
|
"@vueuse/core": "^13.9.0",
|
||||||
"*.{yaml,yml}": [
|
"@wangeditor/editor": "^5.1.23",
|
||||||
"prettier --write"
|
"@wangeditor/editor-for-vue": "next",
|
||||||
]
|
"axios": "^1.12.2",
|
||||||
},
|
"crypto-js": "^4.2.0",
|
||||||
"dependencies": {
|
"echarts": "^6.0.0",
|
||||||
"@element-plus/icons-vue": "^2.3.2",
|
"element-plus": "^2.11.2",
|
||||||
"@iconify/vue": "^5.0.0",
|
"file-saver": "^2.0.5",
|
||||||
"@tailwindcss/vite": "^4.1.14",
|
"highlight.js": "^11.10.0",
|
||||||
"@vue/reactivity": "^3.5.21",
|
"mitt": "^3.0.1",
|
||||||
"@vueuse/core": "^13.9.0",
|
"nprogress": "^0.2.0",
|
||||||
"@wangeditor/editor": "^5.1.23",
|
"ohash": "^2.0.11",
|
||||||
"@wangeditor/editor-for-vue": "next",
|
"pinia": "^3.0.3",
|
||||||
"axios": "^1.12.2",
|
"pinia-plugin-persistedstate": "^4.3.0",
|
||||||
"crypto-js": "^4.2.0",
|
"qrcode.vue": "^3.6.0",
|
||||||
"echarts": "^6.0.0",
|
"tailwindcss": "^4.1.14",
|
||||||
"element-plus": "^2.11.2",
|
"vue": "^3.5.21",
|
||||||
"file-saver": "^2.0.5",
|
"vue-draggable-plus": "^0.6.0",
|
||||||
"highlight.js": "^11.10.0",
|
"vue-i18n": "^9.14.0",
|
||||||
"mitt": "^3.0.1",
|
"vue-router": "^4.5.1",
|
||||||
"nprogress": "^0.2.0",
|
"xgplayer": "^3.0.20",
|
||||||
"ohash": "^2.0.11",
|
"xlsx": "^0.18.5"
|
||||||
"pinia": "^3.0.3",
|
},
|
||||||
"pinia-plugin-persistedstate": "^4.3.0",
|
"devDependencies": {
|
||||||
"qrcode.vue": "^3.6.0",
|
"@eslint/js": "^9.9.1",
|
||||||
"tailwindcss": "^4.1.14",
|
"@types/node": "^24.0.5",
|
||||||
"vue": "^3.5.21",
|
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
||||||
"vue-draggable-plus": "^0.6.0",
|
"@typescript-eslint/parser": "^8.3.0",
|
||||||
"vue-i18n": "^9.14.0",
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
"vue-router": "^4.5.1",
|
"@vue/compiler-sfc": "^3.0.5",
|
||||||
"xgplayer": "^3.0.20",
|
"eslint": "^9.9.1",
|
||||||
"xlsx": "^0.18.5"
|
"eslint-config-prettier": "^9.1.0",
|
||||||
},
|
"eslint-plugin-prettier": "^5.2.1",
|
||||||
"devDependencies": {
|
"eslint-plugin-vue": "^9.27.0",
|
||||||
"@eslint/js": "^9.9.1",
|
"globals": "^15.9.0",
|
||||||
"@types/node": "^24.0.5",
|
"lint-staged": "^15.5.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.3.0",
|
"prettier": "^3.5.3",
|
||||||
"@typescript-eslint/parser": "^8.3.0",
|
"rollup-plugin-visualizer": "^5.12.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.1",
|
"sass": "^1.81.0",
|
||||||
"@vue/compiler-sfc": "^3.0.5",
|
"stylelint": "^16.20.0",
|
||||||
"eslint": "^9.9.1",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"stylelint-config-recess-order": "^4.6.0",
|
||||||
"eslint-plugin-prettier": "^5.2.1",
|
"stylelint-config-recommended-scss": "^14.1.0",
|
||||||
"eslint-plugin-vue": "^9.27.0",
|
"stylelint-config-recommended-vue": "^1.5.0",
|
||||||
"globals": "^15.9.0",
|
"stylelint-config-standard": "^36.0.1",
|
||||||
"lint-staged": "^15.5.2",
|
"terser": "^5.36.0",
|
||||||
"prettier": "^3.5.3",
|
"tsx": "^4.20.3",
|
||||||
"rollup-plugin-visualizer": "^5.12.0",
|
"typescript": "~5.6.3",
|
||||||
"sass": "^1.81.0",
|
"typescript-eslint": "^8.9.0",
|
||||||
"stylelint": "^16.20.0",
|
"unplugin-auto-import": "^20.2.0",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"unplugin-element-plus": "^0.10.0",
|
||||||
"stylelint-config-recess-order": "^4.6.0",
|
"unplugin-vue-components": "^29.1.0",
|
||||||
"stylelint-config-recommended-scss": "^14.1.0",
|
"vite": "^7.1.5",
|
||||||
"stylelint-config-recommended-vue": "^1.5.0",
|
"vite-plugin-compression": "^0.5.1",
|
||||||
"stylelint-config-standard": "^36.0.1",
|
"vite-plugin-vue-devtools": "^7.7.6",
|
||||||
"terser": "^5.36.0",
|
"vue-demi": "^0.14.9",
|
||||||
"tsx": "^4.20.3",
|
"vue-img-cutter": "^3.0.5",
|
||||||
"typescript": "~5.6.3",
|
"vue-tsc": "~2.1.6"
|
||||||
"typescript-eslint": "^8.9.0",
|
}
|
||||||
"unplugin-auto-import": "^20.2.0",
|
|
||||||
"unplugin-element-plus": "^0.10.0",
|
|
||||||
"unplugin-vue-components": "^29.1.0",
|
|
||||||
"vite": "^7.1.5",
|
|
||||||
"vite-plugin-compression": "^0.5.1",
|
|
||||||
"vite-plugin-vue-devtools": "^7.7.6",
|
|
||||||
"vue-demi": "^0.14.9",
|
|
||||||
"vue-img-cutter": "^3.0.5",
|
|
||||||
"vue-tsc": "~2.1.6"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-25
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
</ElConfigProvider>
|
</ElConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUserStore } from './store/modules/user'
|
import { useUserStore } from './store/modules/user'
|
||||||
import zh from 'element-plus/es/locale/lang/zh-cn'
|
import zh from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import en from 'element-plus/es/locale/lang/en'
|
import en from 'element-plus/es/locale/lang/en'
|
||||||
import { systemUpgrade } from './utils/sys'
|
import { systemUpgrade } from './utils/sys'
|
||||||
import { toggleTransition } from './utils/ui/animation'
|
import { toggleTransition } from './utils/ui/animation'
|
||||||
import { checkStorageCompatibility } from './utils/storage'
|
import { checkStorageCompatibility } from './utils/storage'
|
||||||
import { initializeTheme } from './hooks/core/useTheme'
|
import { initializeTheme } from './hooks/core/useTheme'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { language } = storeToRefs(userStore)
|
const { language } = storeToRefs(userStore)
|
||||||
|
|
||||||
const locales = {
|
const locales = {
|
||||||
zh: zh,
|
zh: zh,
|
||||||
en: en
|
en: en
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
toggleTransition(true)
|
toggleTransition(true)
|
||||||
initializeTheme()
|
initializeTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkStorageCompatibility()
|
checkStorageCompatibility()
|
||||||
toggleTransition(false)
|
toggleTransition(false)
|
||||||
systemUpgrade()
|
systemUpgrade()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+13
-13
@@ -6,12 +6,12 @@ import request from '@/utils/http'
|
|||||||
* @returns 登录响应
|
* @returns 登录响应
|
||||||
*/
|
*/
|
||||||
export function fetchLogin(params: Api.Auth.LoginParams) {
|
export function fetchLogin(params: Api.Auth.LoginParams) {
|
||||||
return request.post<Api.Auth.LoginResponse>({
|
return request.post<Api.Auth.LoginResponse>({
|
||||||
url: '/api/auth/login',
|
url: '/api/auth/login',
|
||||||
params
|
params
|
||||||
// showSuccessMessage: true // 显示成功消息
|
// showSuccessMessage: true // 显示成功消息
|
||||||
// showErrorMessage: false // 不显示错误消息
|
// showErrorMessage: false // 不显示错误消息
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,11 +19,11 @@ export function fetchLogin(params: Api.Auth.LoginParams) {
|
|||||||
* @returns 用户信息
|
* @returns 用户信息
|
||||||
*/
|
*/
|
||||||
export function fetchGetUserInfo() {
|
export function fetchGetUserInfo() {
|
||||||
return request.get<Api.Auth.UserInfo>({
|
return request.get<Api.Auth.UserInfo>({
|
||||||
url: '/api/user/info'
|
url: '/api/user/info'
|
||||||
// 自定义请求头
|
// 自定义请求头
|
||||||
// headers: {
|
// headers: {
|
||||||
// 'X-Custom-Header': 'your-custom-value'
|
// 'X-Custom-Header': 'your-custom-value'
|
||||||
// }
|
// }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-11
@@ -3,23 +3,23 @@ import { AppRouteRecord } from '@/types/router'
|
|||||||
|
|
||||||
// 获取用户列表
|
// 获取用户列表
|
||||||
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
|
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
|
||||||
return request.get<Api.SystemManage.UserList>({
|
return request.get<Api.SystemManage.UserList>({
|
||||||
url: '/api/user/list',
|
url: '/api/user/list',
|
||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取角色列表
|
// 获取角色列表
|
||||||
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
|
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
|
||||||
return request.get<Api.SystemManage.RoleList>({
|
return request.get<Api.SystemManage.RoleList>({
|
||||||
url: '/api/role/list',
|
url: '/api/role/list',
|
||||||
params
|
params
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取菜单列表
|
// 获取菜单列表
|
||||||
export function fetchGetMenuList() {
|
export function fetchGetMenuList() {
|
||||||
return request.get<AppRouteRecord[]>({
|
return request.get<AppRouteRecord[]>({
|
||||||
url: '/api/v3/system/menus/simple'
|
url: '/api/v3/system/menus/simple'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+188
-188
@@ -1,292 +1,292 @@
|
|||||||
// 全局样式
|
// 全局样式
|
||||||
// 顶部进度条颜色
|
// 顶部进度条颜色
|
||||||
#nprogress .bar {
|
#nprogress .bar {
|
||||||
z-index: 2400;
|
z-index: 2400;
|
||||||
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
|
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
|
||||||
}
|
}
|
||||||
|
|
||||||
#nprogress .peg {
|
#nprogress .peg {
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 10px var(--theme-color),
|
0 0 10px var(--theme-color),
|
||||||
0 0 5px var(--theme-color) !important;
|
0 0 5px var(--theme-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#nprogress .spinner-icon {
|
#nprogress .spinner-icon {
|
||||||
border-top-color: var(--theme-color) !important;
|
border-top-color: var(--theme-color) !important;
|
||||||
border-left-color: var(--theme-color) !important;
|
border-left-color: var(--theme-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理移动端组件兼容性
|
// 处理移动端组件兼容性
|
||||||
@media screen and (max-width: 640px) {
|
@media screen and (max-width: 640px) {
|
||||||
* {
|
* {
|
||||||
cursor: default !important;
|
cursor: default !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 背景滤镜
|
// 背景滤镜
|
||||||
*,
|
*,
|
||||||
::before,
|
::before,
|
||||||
::after {
|
::after {
|
||||||
--tw-backdrop-blur: ;
|
--tw-backdrop-blur: ;
|
||||||
--tw-backdrop-brightness: ;
|
--tw-backdrop-brightness: ;
|
||||||
--tw-backdrop-contrast: ;
|
--tw-backdrop-contrast: ;
|
||||||
--tw-backdrop-grayscale: ;
|
--tw-backdrop-grayscale: ;
|
||||||
--tw-backdrop-hue-rotate: ;
|
--tw-backdrop-hue-rotate: ;
|
||||||
--tw-backdrop-invert: ;
|
--tw-backdrop-invert: ;
|
||||||
--tw-backdrop-opacity: ;
|
--tw-backdrop-opacity: ;
|
||||||
--tw-backdrop-saturate: ;
|
--tw-backdrop-saturate: ;
|
||||||
--tw-backdrop-sepia: ;
|
--tw-backdrop-sepia: ;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 色弱模式
|
// 色弱模式
|
||||||
.color-weak {
|
.color-weak {
|
||||||
filter: invert(80%);
|
filter: invert(80%);
|
||||||
-webkit-filter: invert(80%);
|
-webkit-filter: invert(80%);
|
||||||
}
|
}
|
||||||
|
|
||||||
#noop {
|
#noop {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言切换选中样式
|
// 语言切换选中样式
|
||||||
.langDropDownStyle {
|
.langDropDownStyle {
|
||||||
// 选中项背景颜色
|
// 选中项背景颜色
|
||||||
.is-selected {
|
.is-selected {
|
||||||
background-color: var(--art-el-active-color) !important;
|
background-color: var(--art-el-active-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 语言切换按钮菜单样式优化
|
// 语言切换按钮菜单样式优化
|
||||||
.lang-btn-item {
|
.lang-btn-item {
|
||||||
.el-dropdown-menu__item {
|
.el-dropdown-menu__item {
|
||||||
padding-left: 13px !important;
|
padding-left: 13px !important;
|
||||||
padding-right: 6px !important;
|
padding-right: 6px !important;
|
||||||
margin-bottom: 3px !important;
|
margin-bottom: 3px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
.el-dropdown-menu__item {
|
.el-dropdown-menu__item {
|
||||||
margin-bottom: 0 !important;
|
margin-bottom: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-txt {
|
.menu-txt {
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
i {
|
i {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 盒子默认边框
|
// 盒子默认边框
|
||||||
.page-content {
|
.page-content {
|
||||||
border: 1px solid var(--art-card-border) !important;
|
border: 1px solid var(--art-card-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
|
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
|
||||||
background: var(--default-box-color);
|
background: var(--default-box-color);
|
||||||
border: 1px solid #{$border-color} !important;
|
border: 1px solid #{$border-color} !important;
|
||||||
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
|
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
|
||||||
box-shadow: #{$shadow} !important;
|
box-shadow: #{$shadow} !important;
|
||||||
|
|
||||||
--el-card-border-color: var(--default-border) !important;
|
--el-card-border-color: var(--default-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card,
|
.art-card,
|
||||||
.art-card-sm,
|
.art-card-sm,
|
||||||
.art-card-xs {
|
.art-card-xs {
|
||||||
border: 1px solid var(--art-card-border);
|
border: 1px solid var(--art-card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 盒子边框
|
// 盒子边框
|
||||||
[data-box-mode='border-mode'] {
|
[data-box-mode='border-mode'] {
|
||||||
.page-content,
|
.page-content,
|
||||||
.art-table-card {
|
.art-table-card {
|
||||||
border: 1px solid var(--art-card-border) !important;
|
border: 1px solid var(--art-card-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card {
|
.art-card {
|
||||||
@include art-card-base(var(--art-card-border), none, 4px);
|
@include art-card-base(var(--art-card-border), none, 4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card-sm {
|
.art-card-sm {
|
||||||
@include art-card-base(var(--art-card-border), none, 0px);
|
@include art-card-base(var(--art-card-border), none, 0px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card-xs {
|
.art-card-xs {
|
||||||
@include art-card-base(var(--art-card-border), none, -4px);
|
@include art-card-base(var(--art-card-border), none, -4px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 盒子阴影
|
// 盒子阴影
|
||||||
[data-box-mode='shadow-mode'] {
|
[data-box-mode='shadow-mode'] {
|
||||||
.page-content,
|
.page-content,
|
||||||
.art-table-card {
|
.art-table-card {
|
||||||
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
|
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
|
||||||
border: 1px solid var(--art-gray-200) !important;
|
border: 1px solid var(--art-gray-200) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
border-right: 1px solid var(--art-card-border) !important;
|
border-right: 1px solid var(--art-card-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card {
|
.art-card {
|
||||||
@include art-card-base(
|
@include art-card-base(
|
||||||
var(--art-gray-200),
|
var(--art-gray-200),
|
||||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||||
4px
|
4px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card-sm {
|
.art-card-sm {
|
||||||
@include art-card-base(
|
@include art-card-base(
|
||||||
var(--art-gray-200),
|
var(--art-gray-200),
|
||||||
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
|
||||||
2px
|
2px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-card-xs {
|
.art-card-xs {
|
||||||
@include art-card-base(
|
@include art-card-base(
|
||||||
var(--art-gray-200),
|
var(--art-gray-200),
|
||||||
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
|
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
|
||||||
-4px
|
-4px
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 元素全屏
|
// 元素全屏
|
||||||
.el-full-screen {
|
.el-full-screen {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
z-index: 2300;
|
z-index: 2300;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
background-color: var(--default-box-color);
|
background-color: var(--default-box-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格卡片
|
// 表格卡片
|
||||||
.art-table-card {
|
.art-table-card {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||||
|
|
||||||
.el-card__body {
|
.el-card__body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 容器全高
|
// 容器全高
|
||||||
.art-full-height {
|
.art-full-height {
|
||||||
height: var(--art-full-height);
|
height: var(--art-full-height);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 徽章样式
|
// 徽章样式
|
||||||
.art-badge {
|
.art-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background: #ff3860;
|
background: #ff3860;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: breathe 1.5s ease-in-out infinite;
|
animation: breathe 1.5s ease-in-out infinite;
|
||||||
|
|
||||||
&.art-badge-horizontal {
|
&.art-badge-horizontal {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.art-badge-mixed {
|
&.art-badge-mixed {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.art-badge-dual {
|
&.art-badge-dual {
|
||||||
right: 5px;
|
right: 5px;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文字徽章样式
|
// 文字徽章样式
|
||||||
.art-text-badge {
|
.art-text-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
line-height: 17px;
|
line-height: 17px;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: #fd4e4e;
|
background: #fd4e4e;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes breathe {
|
@keyframes breathe {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修复老机型 loading 定位问题
|
// 修复老机型 loading 定位问题
|
||||||
.art-loading-fix {
|
.art-loading-fix {
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 0 !important;
|
top: 0 !important;
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
right: 0 !important;
|
right: 0 !important;
|
||||||
bottom: 0 !important;
|
bottom: 0 !important;
|
||||||
width: 100vw !important;
|
width: 100vw !important;
|
||||||
height: 100vh !important;
|
height: 100vh !important;
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-loading-fix .el-loading-spinner {
|
.art-loading-fix .el-loading-spinner {
|
||||||
position: static !important;
|
position: static !important;
|
||||||
top: auto !important;
|
top: auto !important;
|
||||||
left: auto !important;
|
left: auto !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 去除移动端点击背景色
|
// 去除移动端点击背景色
|
||||||
@media screen and (max-width: 1180px) {
|
@media screen and (max-width: 1180px) {
|
||||||
* {
|
* {
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,87 +7,87 @@ $font-color: rgba(#ffffff, 0.85);
|
|||||||
|
|
||||||
/* 覆盖element-plus默认深色背景色 */
|
/* 覆盖element-plus默认深色背景色 */
|
||||||
html.dark {
|
html.dark {
|
||||||
// element-plus
|
// element-plus
|
||||||
--el-bg-color: var(--default-box-color);
|
--el-bg-color: var(--default-box-color);
|
||||||
--el-text-color-regular: #{$font-color};
|
--el-text-color-regular: #{$font-color};
|
||||||
|
|
||||||
// 富文本编辑器
|
// 富文本编辑器
|
||||||
// 工具栏背景颜色
|
// 工具栏背景颜色
|
||||||
--w-e-toolbar-bg-color: #18191c;
|
--w-e-toolbar-bg-color: #18191c;
|
||||||
// 输入区域背景颜色
|
// 输入区域背景颜色
|
||||||
--w-e-textarea-bg-color: #090909;
|
--w-e-textarea-bg-color: #090909;
|
||||||
// 工具栏文字颜色
|
// 工具栏文字颜色
|
||||||
--w-e-toolbar-color: var(--art-gray-600);
|
--w-e-toolbar-color: var(--art-gray-600);
|
||||||
// 选中菜单颜色
|
// 选中菜单颜色
|
||||||
--w-e-toolbar-active-bg-color: #25262b;
|
--w-e-toolbar-active-bg-color: #25262b;
|
||||||
// 弹窗边框颜色
|
// 弹窗边框颜色
|
||||||
--w-e-toolbar-border-color: var(--default-border-dashed);
|
--w-e-toolbar-border-color: var(--default-border-dashed);
|
||||||
// 分割线颜色
|
// 分割线颜色
|
||||||
--w-e-textarea-border-color: var(--default-border-dashed);
|
--w-e-textarea-border-color: var(--default-border-dashed);
|
||||||
// 链接输入框边框颜色
|
// 链接输入框边框颜色
|
||||||
--w-e-modal-button-border-color: var(--default-border-dashed);
|
--w-e-modal-button-border-color: var(--default-border-dashed);
|
||||||
// 表格头颜色
|
// 表格头颜色
|
||||||
--w-e-textarea-slight-bg-color: #090909;
|
--w-e-textarea-slight-bg-color: #090909;
|
||||||
// 按钮背景颜色
|
// 按钮背景颜色
|
||||||
--w-e-modal-button-bg-color: #090909;
|
--w-e-modal-button-bg-color: #090909;
|
||||||
// hover toolbar 背景颜色
|
// hover toolbar 背景颜色
|
||||||
--w-e-toolbar-active-color: var(--art-gray-800);
|
--w-e-toolbar-active-color: var(--art-gray-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.page-content .article-list .item .left .outer > div {
|
.page-content .article-list .item .left .outer > div {
|
||||||
border-right-color: var(--dark-border-color) !important;
|
border-right-color: var(--dark-border-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 富文本编辑器
|
// 富文本编辑器
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
*:not(pre code *) {
|
*:not(pre code *) {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 分隔线
|
// 分隔线
|
||||||
.w-e-bar-divider {
|
.w-e-bar-divider {
|
||||||
background-color: var(--art-gray-300) !important;
|
background-color: var(--art-gray-300) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-e-select-list,
|
.w-e-select-list,
|
||||||
.w-e-drop-panel,
|
.w-e-drop-panel,
|
||||||
.w-e-bar-item-group .w-e-bar-item-menus-container,
|
.w-e-bar-item-group .w-e-bar-item-menus-container,
|
||||||
.w-e-text-container [data-slate-editor] pre > code {
|
.w-e-text-container [data-slate-editor] pre > code {
|
||||||
border: 1px solid var(--default-border) !important;
|
border: 1px solid var(--default-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下拉选择框
|
// 下拉选择框
|
||||||
.w-e-select-list {
|
.w-e-select-list {
|
||||||
background-color: var(--default-box-color) !important;
|
background-color: var(--default-box-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下拉选择框 hover 样式调整 */
|
/* 下拉选择框 hover 样式调整 */
|
||||||
.w-e-select-list ul li:hover,
|
.w-e-select-list ul li:hover,
|
||||||
/* 工具栏 hover 按钮背景颜色 */
|
/* 工具栏 hover 按钮背景颜色 */
|
||||||
.w-e-bar-item button:hover {
|
.w-e-bar-item button:hover {
|
||||||
background-color: #090909 !important;
|
background-color: #090909 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 代码块 */
|
/* 代码块 */
|
||||||
.w-e-text-container [data-slate-editor] pre > code {
|
.w-e-text-container [data-slate-editor] pre > code {
|
||||||
background-color: #25262b !important;
|
background-color: #25262b !important;
|
||||||
text-shadow: none !important;
|
text-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 引用 */
|
/* 引用 */
|
||||||
.w-e-text-container [data-slate-editor] blockquote {
|
.w-e-text-container [data-slate-editor] blockquote {
|
||||||
border-left: 4px solid var(--default-border-dashed) !important;
|
border-left: 4px solid var(--default-border-dashed) !important;
|
||||||
background-color: var(--art-color);
|
background-color: var(--art-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||||
border-right: 1px solid var(--default-border-dashed) !important;
|
border-right: 1px solid var(--default-border-dashed) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-e-modal {
|
.w-e-modal {
|
||||||
background-color: var(--art-color);
|
background-color: var(--art-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,33 +2,33 @@
|
|||||||
// 自定义Element 亮色主题
|
// 自定义Element 亮色主题
|
||||||
|
|
||||||
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||||
$colors: (
|
$colors: (
|
||||||
'white': #ffffff,
|
'white': #ffffff,
|
||||||
'black': #000000,
|
'black': #000000,
|
||||||
'success': (
|
'success': (
|
||||||
'base': #13deb9
|
'base': #13deb9
|
||||||
),
|
),
|
||||||
'warning': (
|
'warning': (
|
||||||
'base': #ffae1f
|
'base': #ffae1f
|
||||||
),
|
),
|
||||||
'danger': (
|
'danger': (
|
||||||
'base': #ff4d4f
|
'base': #ff4d4f
|
||||||
),
|
),
|
||||||
'error': (
|
'error': (
|
||||||
'base': #fa896b
|
'base': #fa896b
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
$button: (
|
$button: (
|
||||||
'hover-bg-color': var(--el-color-primary-light-9),
|
'hover-bg-color': var(--el-color-primary-light-9),
|
||||||
'hover-border-color': var(--el-color-primary),
|
'hover-border-color': var(--el-color-primary),
|
||||||
'border-color': var(--el-color-primary),
|
'border-color': var(--el-color-primary),
|
||||||
'text-color': var(--el-color-primary)
|
'text-color': var(--el-color-primary)
|
||||||
),
|
),
|
||||||
$messagebox: (
|
$messagebox: (
|
||||||
'border-radius': '12px'
|
'border-radius': '12px'
|
||||||
),
|
),
|
||||||
$popover: (
|
$popover: (
|
||||||
'padding': '14px',
|
'padding': '14px',
|
||||||
'border-radius': '10px'
|
'border-radius': '10px'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
+286
-286
@@ -1,519 +1,519 @@
|
|||||||
// 优化 Element Plus 组件库默认样式
|
// 优化 Element Plus 组件库默认样式
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
// 系统主色
|
// 系统主色
|
||||||
--main-color: var(--el-color-primary);
|
--main-color: var(--el-color-primary);
|
||||||
--el-color-white: white !important;
|
--el-color-white: white !important;
|
||||||
--el-color-black: white !important;
|
--el-color-black: white !important;
|
||||||
// 输入框边框颜色
|
// 输入框边框颜色
|
||||||
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
// --el-border-color: #E4E4E7 !important; // DCDFE6
|
||||||
// 按钮粗度
|
// 按钮粗度
|
||||||
--el-font-weight-primary: 400 !important;
|
--el-font-weight-primary: 400 !important;
|
||||||
|
|
||||||
--el-component-custom-height: 36px !important;
|
--el-component-custom-height: 36px !important;
|
||||||
|
|
||||||
--el-component-size: var(--el-component-custom-height) !important;
|
--el-component-size: var(--el-component-custom-height) !important;
|
||||||
|
|
||||||
// 边框、按钮圆角...
|
// 边框、按钮圆角...
|
||||||
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
|
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
|
||||||
|
|
||||||
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
|
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||||
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||||
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
|
||||||
|
|
||||||
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
|
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
|
||||||
color: var(--theme-color);
|
color: var(--theme-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化 el-form-item 标签高度
|
// 优化 el-form-item 标签高度
|
||||||
.el-form-item__label {
|
.el-form-item__label {
|
||||||
height: var(--el-component-custom-height) !important;
|
height: var(--el-component-custom-height) !important;
|
||||||
line-height: var(--el-component-custom-height) !important;
|
line-height: var(--el-component-custom-height) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 日期选择器
|
// 日期选择器
|
||||||
.el-date-range-picker {
|
.el-date-range-picker {
|
||||||
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
|
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// el-card 背景色跟系统背景色保持一致
|
// el-card 背景色跟系统背景色保持一致
|
||||||
html.dark .el-card {
|
html.dark .el-card {
|
||||||
--el-card-bg-color: var(--default-box-color) !important;
|
--el-card-bg-color: var(--default-box-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-pagination 大小
|
// 修改 el-pagination 大小
|
||||||
.el-pagination--default {
|
.el-pagination--default {
|
||||||
& {
|
& {
|
||||||
--el-pagination-button-width: 32px !important;
|
--el-pagination-button-width: 32px !important;
|
||||||
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
--el-pagination-button-height: var(--el-pagination-button-width) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
& {
|
& {
|
||||||
--el-pagination-button-width: 28px !important;
|
--el-pagination-button-width: 28px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select--default .el-select__wrapper {
|
.el-select--default .el-select__wrapper {
|
||||||
min-height: var(--el-pagination-button-width) !important;
|
min-height: var(--el-pagination-button-width) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-pagination__jump .el-input {
|
.el-pagination__jump .el-input {
|
||||||
height: var(--el-pagination-button-width) !important;
|
height: var(--el-pagination-button-width) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-pager li {
|
.el-pager li {
|
||||||
padding: 0 10px !important;
|
padding: 0 10px !important;
|
||||||
// border: 1px solid red !important;
|
// border: 1px solid red !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化菜单折叠展开动画(提升动画流畅度)
|
// 优化菜单折叠展开动画(提升动画流畅度)
|
||||||
.el-menu.el-menu--inline {
|
.el-menu.el-menu--inline {
|
||||||
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化菜单 item hover 动画(提升鼠标跟手感)
|
// 优化菜单 item hover 动画(提升鼠标跟手感)
|
||||||
.el-sub-menu__title,
|
.el-sub-menu__title,
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
transition: background-color 0s !important;
|
transition: background-color 0s !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
|
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
|
||||||
// 修改 el-button 高度
|
// 修改 el-button 高度
|
||||||
.el-button--default {
|
.el-button--default {
|
||||||
height: var(--el-component-custom-height) !important;
|
height: var(--el-component-custom-height) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// circle 按钮宽度优化
|
// circle 按钮宽度优化
|
||||||
.el-button--default.is-circle {
|
.el-button--default.is-circle {
|
||||||
width: var(--el-component-custom-height) !important;
|
width: var(--el-component-custom-height) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-select 高度
|
// 修改 el-select 高度
|
||||||
.el-select--default {
|
.el-select--default {
|
||||||
.el-select__wrapper {
|
.el-select__wrapper {
|
||||||
min-height: var(--el-component-custom-height) !important;
|
min-height: var(--el-component-custom-height) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-checkbox-button 高度
|
// 修改 el-checkbox-button 高度
|
||||||
.el-checkbox-button--default .el-checkbox-button__inner,
|
.el-checkbox-button--default .el-checkbox-button__inner,
|
||||||
// 修改 el-radio-button 高度
|
// 修改 el-radio-button 高度
|
||||||
.el-radio-button--default .el-radio-button__inner {
|
.el-radio-button--default .el-radio-button__inner {
|
||||||
padding: 10px 15px !important;
|
padding: 10px 15px !important;
|
||||||
}
|
}
|
||||||
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
|
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
|
||||||
|
|
||||||
.el-pagination.is-background .btn-next,
|
.el-pagination.is-background .btn-next,
|
||||||
.el-pagination.is-background .btn-prev,
|
.el-pagination.is-background .btn-prev,
|
||||||
.el-pagination.is-background .el-pager li {
|
.el-pagination.is-background .el-pager li {
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-popover {
|
.el-popover {
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
border-radius: var(--el-border-radius-small) !important;
|
border-radius: var(--el-border-radius-small) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog {
|
.el-dialog {
|
||||||
border-radius: 100px !important;
|
border-radius: 100px !important;
|
||||||
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__header {
|
.el-dialog__header {
|
||||||
.el-dialog__title {
|
.el-dialog__title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog__body {
|
.el-dialog__body {
|
||||||
padding: 25px 0 !important;
|
padding: 25px 0 !important;
|
||||||
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
|
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dialog.el-dialog-border {
|
.el-dialog.el-dialog-border {
|
||||||
.el-dialog__body {
|
.el-dialog__body {
|
||||||
// 上边框
|
// 上边框
|
||||||
&::before,
|
&::before,
|
||||||
// 下边框
|
// 下边框
|
||||||
&::after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -16px;
|
left: -16px;
|
||||||
width: calc(100% + 32px);
|
width: calc(100% + 32px);
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: var(--art-gray-300);
|
background-color: var(--art-gray-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// el-message 样式优化
|
// el-message 样式优化
|
||||||
.el-message {
|
.el-message {
|
||||||
background-color: var(--default-box-color) !important;
|
background-color: var(--default-box-color) !important;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
0 6px 16px 0 rgba(0, 0, 0, 0.08),
|
||||||
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||||
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
|
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-dropdown 样式
|
// 修改 el-dropdown 样式
|
||||||
.el-dropdown-menu {
|
.el-dropdown-menu {
|
||||||
padding: 6px !important;
|
padding: 6px !important;
|
||||||
border-radius: 10px !important;
|
border-radius: 10px !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
|
|
||||||
.el-dropdown-menu__item {
|
.el-dropdown-menu__item {
|
||||||
padding: 6px 16px !important;
|
padding: 6px 16px !important;
|
||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
|
|
||||||
&:hover:not(.is-disabled) {
|
&:hover:not(.is-disabled) {
|
||||||
color: var(--art-gray-900) !important;
|
color: var(--art-gray-900) !important;
|
||||||
background-color: var(--art-el-active-color) !important;
|
background-color: var(--art-el-active-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus:not(.is-disabled) {
|
&:focus:not(.is-disabled) {
|
||||||
color: var(--art-gray-900) !important;
|
color: var(--art-gray-900) !important;
|
||||||
background-color: var(--art-gray-200) !important;
|
background-color: var(--art-gray-200) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏 select、dropdown 的三角
|
// 隐藏 select、dropdown 的三角
|
||||||
.el-select__popper,
|
.el-select__popper,
|
||||||
.el-dropdown__popper {
|
.el-dropdown__popper {
|
||||||
margin-top: -6px !important;
|
margin-top: -6px !important;
|
||||||
|
|
||||||
.el-popper__arrow {
|
.el-popper__arrow {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-dropdown-selfdefine:focus {
|
.el-dropdown-selfdefine:focus {
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理移动端组件兼容性
|
// 处理移动端组件兼容性
|
||||||
@media screen and (max-width: 640px) {
|
@media screen and (max-width: 640px) {
|
||||||
.el-message-box,
|
.el-message-box,
|
||||||
.el-dialog {
|
.el-dialog {
|
||||||
width: calc(100% - 24px) !important;
|
width: calc(100% - 24px) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-date-picker.has-sidebar.has-time {
|
.el-date-picker.has-sidebar.has-time {
|
||||||
width: calc(100% - 24px);
|
width: calc(100% - 24px);
|
||||||
left: 12px !important;
|
left: 12px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-picker-panel *[slot='sidebar'],
|
.el-picker-panel *[slot='sidebar'],
|
||||||
.el-picker-panel__sidebar {
|
.el-picker-panel__sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
|
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
|
||||||
.el-picker-panel__sidebar + .el-picker-panel__body {
|
.el-picker-panel__sidebar + .el-picker-panel__body {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改el-button样式
|
// 修改el-button样式
|
||||||
.el-button {
|
.el-button {
|
||||||
&.el-button--text {
|
&.el-button--text {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
margin-left: 0 !important;
|
margin-left: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改el-tag样式
|
// 修改el-tag样式
|
||||||
.el-tag {
|
.el-tag {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0s !important;
|
transition: all 0s !important;
|
||||||
|
|
||||||
&.el-tag--default {
|
&.el-tag--default {
|
||||||
height: 26px !important;
|
height: 26px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-checkbox-group {
|
.el-checkbox-group {
|
||||||
&.el-table-filter__checkbox-group label.el-checkbox {
|
&.el-table-filter__checkbox-group label.el-checkbox {
|
||||||
height: 17px !important;
|
height: 17px !important;
|
||||||
|
|
||||||
.el-checkbox__label {
|
.el-checkbox__label {
|
||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-radio--default {
|
.el-radio--default {
|
||||||
// 优化单选按钮大小
|
// 优化单选按钮大小
|
||||||
.el-radio__input {
|
.el-radio__input {
|
||||||
.el-radio__inner {
|
.el-radio__inner {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-checkbox {
|
.el-checkbox {
|
||||||
.el-checkbox__inner {
|
.el-checkbox__inner {
|
||||||
border-radius: 2px !important;
|
border-radius: 2px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化复选框样式
|
// 优化复选框样式
|
||||||
.el-checkbox--default {
|
.el-checkbox--default {
|
||||||
.el-checkbox__inner {
|
.el-checkbox__inner {
|
||||||
width: 16px !important;
|
width: 16px !important;
|
||||||
height: 16px !important;
|
height: 16px !important;
|
||||||
border-radius: 4px !important;
|
border-radius: 4px !important;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
height: 4px !important;
|
height: 4px !important;
|
||||||
top: 5px !important;
|
top: 5px !important;
|
||||||
background-color: #fff !important;
|
background-color: #fff !important;
|
||||||
transform: scale(0.6) !important;
|
transform: scale(0.6) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-checked {
|
.is-checked {
|
||||||
.el-checkbox__inner {
|
.el-checkbox__inner {
|
||||||
&::after {
|
&::after {
|
||||||
width: 3px;
|
width: 3px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
border: 2px solid var(--el-checkbox-checked-icon-color);
|
border: 2px solid var(--el-checkbox-checked-icon-color);
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
|
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-notification .el-notification__icon {
|
.el-notification .el-notification__icon {
|
||||||
font-size: 22px !important;
|
font-size: 22px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-message-box 样式
|
// 修改 el-message-box 样式
|
||||||
.el-message-box__headerbtn .el-message-box__close,
|
.el-message-box__headerbtn .el-message-box__close,
|
||||||
.el-dialog__headerbtn .el-dialog__close {
|
.el-dialog__headerbtn .el-dialog__close {
|
||||||
top: 7px;
|
top: 7px;
|
||||||
right: 7px;
|
right: 7px;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--art-hover-color) !important;
|
background-color: var(--art-hover-color) !important;
|
||||||
color: var(--art-gray-900) !important;
|
color: var(--art-gray-900) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box {
|
.el-message-box {
|
||||||
padding: 25px 20px !important;
|
padding: 25px 20px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-message-box__title {
|
.el-message-box__title {
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-table__column-filter-trigger i {
|
.el-table__column-filter-trigger i {
|
||||||
color: var(--theme-color) !important;
|
color: var(--theme-color) !important;
|
||||||
margin: -3px 0 0 2px;
|
margin: -3px 0 0 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 去除 el-dropdown 鼠标放上去出现的边框
|
// 去除 el-dropdown 鼠标放上去出现的边框
|
||||||
.el-tooltip__trigger:focus-visible {
|
.el-tooltip__trigger:focus-visible {
|
||||||
outline: unset;
|
outline: unset;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ipad 表单右侧按钮优化
|
// ipad 表单右侧按钮优化
|
||||||
@media screen and (max-width: 1180px) {
|
@media screen and (max-width: 1180px) {
|
||||||
.el-table-fixed-column--right {
|
.el-table-fixed-column--right {
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-out-dialog {
|
.login-out-dialog {
|
||||||
padding: 30px 20px !important;
|
padding: 30px 20px !important;
|
||||||
border-radius: 10px !important;
|
border-radius: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 dialog 动画
|
// 修改 dialog 动画
|
||||||
.dialog-fade-enter-active {
|
.dialog-fade-enter-active {
|
||||||
.el-dialog:not(.is-draggable) {
|
.el-dialog:not(.is-draggable) {
|
||||||
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
|
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
|
||||||
|
|
||||||
// 修复 el-dialog 动画后宽度不自适应问题
|
// 修复 el-dialog 动画后宽度不自适应问题
|
||||||
.el-select__selected-item {
|
.el-select__selected-item {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-fade-leave-active {
|
.dialog-fade-leave-active {
|
||||||
animation: fade-out 0.2s linear;
|
animation: fade-out 0.2s linear;
|
||||||
|
|
||||||
.el-dialog:not(.is-draggable) {
|
.el-dialog:not(.is-draggable) {
|
||||||
animation: dialog-close 0.5s;
|
animation: dialog-close 0.5s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dialog-open {
|
@keyframes dialog-open {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.2);
|
transform: scale(0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dialog-close {
|
@keyframes dialog-close {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.2);
|
transform: scale(0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遮罩层动画
|
// 遮罩层动画
|
||||||
@keyframes fade-out {
|
@keyframes fade-out {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-select 样式
|
// 修改 el-select 样式
|
||||||
.el-select__popper:not(.el-tree-select__popper) {
|
.el-select__popper:not(.el-tree-select__popper) {
|
||||||
.el-select-dropdown__list {
|
.el-select-dropdown__list {
|
||||||
padding: 5px !important;
|
padding: 5px !important;
|
||||||
|
|
||||||
.el-select-dropdown__item {
|
.el-select-dropdown__item {
|
||||||
height: 34px !important;
|
height: 34px !important;
|
||||||
line-height: 34px !important;
|
line-height: 34px !important;
|
||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
|
|
||||||
&.is-selected {
|
&.is-selected {
|
||||||
color: var(--art-gray-900) !important;
|
color: var(--art-gray-900) !important;
|
||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
background-color: var(--art-el-active-color) !important;
|
background-color: var(--art-el-active-color) !important;
|
||||||
margin-bottom: 4px !important;
|
margin-bottom: 4px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--art-hover-color) !important;
|
background-color: var(--art-hover-color) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-select-dropdown__item:hover ~ .is-selected,
|
.el-select-dropdown__item:hover ~ .is-selected,
|
||||||
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
|
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 el-tree-select 样式
|
// 修改 el-tree-select 样式
|
||||||
.el-tree-select__popper {
|
.el-tree-select__popper {
|
||||||
.el-select-dropdown__list {
|
.el-select-dropdown__list {
|
||||||
padding: 5px !important;
|
padding: 5px !important;
|
||||||
|
|
||||||
.el-tree-node {
|
.el-tree-node {
|
||||||
.el-tree-node__content {
|
.el-tree-node__content {
|
||||||
height: 36px !important;
|
height: 36px !important;
|
||||||
border-radius: 6px !important;
|
border-radius: 6px !important;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--art-gray-200) !important;
|
background-color: var(--art-gray-200) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 实现水波纹在文字下面效果
|
// 实现水波纹在文字下面效果
|
||||||
.el-button > span {
|
.el-button > span {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化颜色选择器圆角
|
// 优化颜色选择器圆角
|
||||||
.el-color-picker__color {
|
.el-color-picker__color {
|
||||||
border-radius: 2px !important;
|
border-radius: 2px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化日期时间选择器底部圆角
|
// 优化日期时间选择器底部圆角
|
||||||
.el-picker-panel {
|
.el-picker-panel {
|
||||||
.el-picker-panel__footer {
|
.el-picker-panel__footer {
|
||||||
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
|
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化树型菜单样式
|
// 优化树型菜单样式
|
||||||
.el-tree-node__content {
|
.el-tree-node__content {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
padding: 1px 0;
|
padding: 1px 0;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--art-hover-color) !important;
|
background-color: var(--art-hover-color) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
|
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
|
||||||
background-color: var(--art-gray-300) !important;
|
background-color: var(--art-gray-300) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 隐藏折叠菜单弹窗 hover 出现的边框
|
// 隐藏折叠菜单弹窗 hover 出现的边框
|
||||||
.menu-left-popper:focus-within,
|
.menu-left-popper:focus-within,
|
||||||
.horizontal-menu-popper:focus-within {
|
.horizontal-menu-popper:focus-within {
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
outline: none !important;
|
outline: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 数字输入组件右侧按钮高度跟随自定义组件高度
|
// 数字输入组件右侧按钮高度跟随自定义组件高度
|
||||||
.el-input-number--default.is-controls-right {
|
.el-input-number--default.is-controls-right {
|
||||||
.el-input-number__decrease,
|
.el-input-number__decrease,
|
||||||
.el-input-number__increase {
|
.el-input-number__increase {
|
||||||
height: calc((var(--el-component-size) / 2)) !important;
|
height: calc((var(--el-component-size) / 2)) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+417
-417
File diff suppressed because it is too large
Load Diff
@@ -5,18 +5,18 @@
|
|||||||
* @param {Number} 行数
|
* @param {Number} 行数
|
||||||
*/
|
*/
|
||||||
@mixin ellipsis($rowCount: 1) {
|
@mixin ellipsis($rowCount: 1) {
|
||||||
@if $rowCount <=1 {
|
@if $rowCount <=1 {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
} @else {
|
} @else {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: $rowCount;
|
-webkit-line-clamp: $rowCount;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,20 +24,20 @@
|
|||||||
* @param {String} 类型
|
* @param {String} 类型
|
||||||
*/
|
*/
|
||||||
@mixin userSelect($value: none) {
|
@mixin userSelect($value: none) {
|
||||||
user-select: $value;
|
user-select: $value;
|
||||||
-moz-user-select: $value;
|
-moz-user-select: $value;
|
||||||
-ms-user-select: $value;
|
-ms-user-select: $value;
|
||||||
-webkit-user-select: $value;
|
-webkit-user-select: $value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绝对定位居中
|
// 绝对定位居中
|
||||||
@mixin absoluteCenter() {
|
@mixin absoluteCenter() {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,113 +45,114 @@
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@mixin animation(
|
@mixin animation(
|
||||||
$from: (
|
$from: (
|
||||||
width: 0px
|
width: 0px
|
||||||
),
|
),
|
||||||
$to: (
|
$to: (
|
||||||
width: 100px
|
width: 100px
|
||||||
),
|
),
|
||||||
$name: mymove,
|
$name: mymove,
|
||||||
$animate: mymove 2s 1 linear infinite
|
$animate: mymove 2s 1 linear infinite
|
||||||
) {
|
) {
|
||||||
-webkit-animation: $animate;
|
-webkit-animation: $animate;
|
||||||
-o-animation: $animate;
|
-o-animation: $animate;
|
||||||
animation: $animate;
|
animation: $animate;
|
||||||
|
|
||||||
@keyframes #{$name} {
|
@keyframes #{$name} {
|
||||||
from {
|
from {
|
||||||
@each $key, $value in $from {
|
@each $key, $value in $from {
|
||||||
#{$key}: #{$value};
|
#{$key}: #{$value};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
@each $key, $value in $to {
|
@each $key, $value in $to {
|
||||||
#{$key}: #{$value};
|
#{$key}: #{$value};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@-webkit-keyframes #{$name} {
|
@-webkit-keyframes #{$name} {
|
||||||
from {
|
from {
|
||||||
@each $key, $value in $from {
|
@each $key, $value in $from {
|
||||||
$key: $value;
|
$key: $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
@each $key, $value in $to {
|
@each $key, $value in $to {
|
||||||
$key: $value;
|
$key: $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 圆形盒子
|
// 圆形盒子
|
||||||
@mixin circle($size: 11px, $bg: #fff) {
|
@mixin circle($size: 11px, $bg: #fff) {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: $size;
|
width: $size;
|
||||||
height: $size;
|
height: $size;
|
||||||
line-height: $size;
|
line-height: $size;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: $bg;
|
background: $bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// placeholder
|
// placeholder
|
||||||
@mixin placeholder($color: #bbb) {
|
@mixin placeholder($color: #bbb) {
|
||||||
// Firefox
|
// Firefox
|
||||||
&::-moz-placeholder {
|
&::-moz-placeholder {
|
||||||
color: $color;
|
color: $color;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internet Explorer 10+
|
// Internet Explorer 10+
|
||||||
&:-ms-input-placeholder {
|
&:-ms-input-placeholder {
|
||||||
color: $color;
|
color: $color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Safari and Chrome
|
// Safari and Chrome
|
||||||
&::-webkit-input-placeholder {
|
&::-webkit-input-placeholder {
|
||||||
color: $color;
|
color: $color;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:placeholder-shown {
|
&:placeholder-shown {
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//背景透明,文字不透明。兼容IE8
|
//背景透明,文字不透明。兼容IE8
|
||||||
@mixin betterTransparentize($color, $alpha) {
|
@mixin betterTransparentize($color, $alpha) {
|
||||||
$c: rgba($color, $alpha);
|
$c: rgba($color, $alpha);
|
||||||
$ie_c: ie_hex_str($c);
|
$ie_c: ie_hex_str($c);
|
||||||
background: rgba($color, 1);
|
background: rgba($color, 1);
|
||||||
background: $c;
|
background: $c;
|
||||||
background: transparent \9;
|
background: transparent \9;
|
||||||
zoom: 1;
|
zoom: 1;
|
||||||
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
|
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
|
||||||
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
|
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
|
||||||
}
|
}
|
||||||
|
|
||||||
//添加浏览器前缀
|
//添加浏览器前缀
|
||||||
@mixin browserPrefix($propertyName, $value) {
|
@mixin browserPrefix($propertyName, $value) {
|
||||||
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
|
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
|
||||||
#{$prefix}#{$propertyName}: $value;
|
#{$prefix}#{$propertyName}: $value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 边框
|
// 边框
|
||||||
@mixin border($color: red) {
|
@mixin border($color: red) {
|
||||||
border: 1px solid $color;
|
border: 1px solid $color;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 背景滤镜
|
// 背景滤镜
|
||||||
@mixin backdropBlur() {
|
@mixin backdropBlur() {
|
||||||
--tw-backdrop-blur: blur(30px);
|
--tw-backdrop-blur: blur(30px);
|
||||||
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
|
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
|
||||||
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
|
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
|
||||||
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||||
var(--tw-backdrop-sepia);
|
var(--tw-backdrop-sepia);
|
||||||
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast)
|
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
|
||||||
var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert)
|
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
|
||||||
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
|
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
|
||||||
|
var(--tw-backdrop-sepia);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,39 @@
|
|||||||
/*滚动条*/
|
/*滚动条*/
|
||||||
/*滚动条整体部分,必须要设置*/
|
/*滚动条整体部分,必须要设置*/
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px !important;
|
width: 8px !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*滚动条的轨道*/
|
/*滚动条的轨道*/
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*滚动条的滑块按钮*/
|
/*滚动条的滑块按钮*/
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background-color: #cccccc !important;
|
background-color: #cccccc !important;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
-webkit-transition: all 0.2s;
|
-webkit-transition: all 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background-color: #b0abab !important;
|
background-color: #b0abab !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*滚动条的上下两端的按钮*/
|
/*滚动条的上下两端的按钮*/
|
||||||
::-webkit-scrollbar-button {
|
::-webkit-scrollbar-button {
|
||||||
height: 0px;
|
height: 0px;
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background-color: var(--default-bg-color);
|
background-color: var(--default-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background-color: var(--art-gray-300) !important;
|
background-color: var(--art-gray-300) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,19 +2,19 @@
|
|||||||
|
|
||||||
// === 变量区域 ===
|
// === 变量区域 ===
|
||||||
$transition: (
|
$transition: (
|
||||||
// 动画持续时间
|
// 动画持续时间
|
||||||
duration: 0.25s,
|
duration: 0.25s,
|
||||||
// 滑动动画的移动距离
|
// 滑动动画的移动距离
|
||||||
distance: 15px,
|
distance: 15px,
|
||||||
// 默认缓动函数
|
// 默认缓动函数
|
||||||
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
|
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
|
||||||
// 淡入淡出专用的缓动函数
|
// 淡入淡出专用的缓动函数
|
||||||
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
|
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 抽取配置值函数,提高可复用性
|
// 抽取配置值函数,提高可复用性
|
||||||
@function transition-config($key) {
|
@function transition-config($key) {
|
||||||
@return map.get($transition, $key);
|
@return map.get($transition, $key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 变量简写
|
// 变量简写
|
||||||
@@ -27,78 +27,78 @@ $fade-easing: transition-config('fade-easing');
|
|||||||
|
|
||||||
// 淡入淡出动画
|
// 淡入淡出动画
|
||||||
.fade {
|
.fade {
|
||||||
&-enter-active,
|
&-enter-active,
|
||||||
&-leave-active {
|
&-leave-active {
|
||||||
transition: opacity $duration $fade-easing;
|
transition: opacity $duration $fade-easing;
|
||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-enter-from,
|
&-enter-from,
|
||||||
&-leave-to {
|
&-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-enter-to,
|
&-enter-to,
|
||||||
&-leave-from {
|
&-leave-from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滑动动画通用样式
|
// 滑动动画通用样式
|
||||||
@mixin slide-transition($direction) {
|
@mixin slide-transition($direction) {
|
||||||
$distance-x: 0;
|
$distance-x: 0;
|
||||||
$distance-y: 0;
|
$distance-y: 0;
|
||||||
|
|
||||||
@if $direction == 'left' {
|
@if $direction == 'left' {
|
||||||
$distance-x: -$distance;
|
$distance-x: -$distance;
|
||||||
} @else if $direction == 'right' {
|
} @else if $direction == 'right' {
|
||||||
$distance-x: $distance;
|
$distance-x: $distance;
|
||||||
} @else if $direction == 'top' {
|
} @else if $direction == 'top' {
|
||||||
$distance-y: -$distance;
|
$distance-y: -$distance;
|
||||||
} @else if $direction == 'bottom' {
|
} @else if $direction == 'bottom' {
|
||||||
$distance-y: $distance;
|
$distance-y: $distance;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-enter-active {
|
&-enter-active {
|
||||||
transition:
|
transition:
|
||||||
opacity $duration $easing,
|
opacity $duration $easing,
|
||||||
transform $duration $easing;
|
transform $duration $easing;
|
||||||
will-change: opacity, transform;
|
will-change: opacity, transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-leave-active {
|
&-leave-active {
|
||||||
transition:
|
transition:
|
||||||
opacity calc($duration * 0.7) $easing,
|
opacity calc($duration * 0.7) $easing,
|
||||||
transform calc($duration * 0.7) $easing;
|
transform calc($duration * 0.7) $easing;
|
||||||
will-change: opacity, transform;
|
will-change: opacity, transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-enter-from {
|
&-enter-from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d($distance-x, $distance-y, 0);
|
transform: translate3d($distance-x, $distance-y, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&-enter-to {
|
&-enter-to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
&-leave-to {
|
&-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate3d(-$distance-x, -$distance-y, 0);
|
transform: translate3d(-$distance-x, -$distance-y, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滑动动画方向类
|
// 滑动动画方向类
|
||||||
.slide-left {
|
.slide-left {
|
||||||
@include slide-transition('left');
|
@include slide-transition('left');
|
||||||
}
|
}
|
||||||
.slide-right {
|
.slide-right {
|
||||||
@include slide-transition('right');
|
@include slide-transition('right');
|
||||||
}
|
}
|
||||||
.slide-top {
|
.slide-top {
|
||||||
@include slide-transition('top');
|
@include slide-transition('top');
|
||||||
}
|
}
|
||||||
.slide-bottom {
|
.slide-bottom {
|
||||||
@include slide-transition('bottom');
|
@include slide-transition('bottom');
|
||||||
}
|
}
|
||||||
|
|||||||
+144
-144
@@ -3,206 +3,206 @@
|
|||||||
|
|
||||||
/* ==================== Light Mode Variables ==================== */
|
/* ==================== Light Mode Variables ==================== */
|
||||||
:root {
|
:root {
|
||||||
/* Base Colors */
|
/* Base Colors */
|
||||||
--art-color: #ffffff;
|
--art-color: #ffffff;
|
||||||
--theme-color: var(--main-color);
|
--theme-color: var(--main-color);
|
||||||
|
|
||||||
/* Theme Colors - OKLCH Format */
|
/* Theme Colors - OKLCH Format */
|
||||||
--art-primary: oklch(0.7 0.23 260);
|
--art-primary: oklch(0.7 0.23 260);
|
||||||
--art-secondary: oklch(0.72 0.19 231.6);
|
--art-secondary: oklch(0.72 0.19 231.6);
|
||||||
--art-error: oklch(0.73 0.15 25.3);
|
--art-error: oklch(0.73 0.15 25.3);
|
||||||
--art-info: oklch(0.58 0.03 254.1);
|
--art-info: oklch(0.58 0.03 254.1);
|
||||||
--art-success: oklch(0.78 0.17 166.1);
|
--art-success: oklch(0.78 0.17 166.1);
|
||||||
--art-warning: oklch(0.78 0.14 75.5);
|
--art-warning: oklch(0.78 0.14 75.5);
|
||||||
--art-danger: oklch(0.68 0.22 25.3);
|
--art-danger: oklch(0.68 0.22 25.3);
|
||||||
|
|
||||||
/* Gray Scale - Light Mode */
|
/* Gray Scale - Light Mode */
|
||||||
--art-gray-100: #f9fafb;
|
--art-gray-100: #f9fafb;
|
||||||
--art-gray-200: #f2f4f5;
|
--art-gray-200: #f2f4f5;
|
||||||
--art-gray-300: #e6eaeb;
|
--art-gray-300: #e6eaeb;
|
||||||
--art-gray-400: #dbdfe1;
|
--art-gray-400: #dbdfe1;
|
||||||
--art-gray-500: #949eb7;
|
--art-gray-500: #949eb7;
|
||||||
--art-gray-600: #7987a1;
|
--art-gray-600: #7987a1;
|
||||||
--art-gray-700: #4d5875;
|
--art-gray-700: #4d5875;
|
||||||
--art-gray-800: #383853;
|
--art-gray-800: #383853;
|
||||||
--art-gray-900: #323251;
|
--art-gray-900: #323251;
|
||||||
|
|
||||||
/* Border Colors */
|
/* Border Colors */
|
||||||
--art-card-border: rgba(0, 0, 0, 0.08);
|
--art-card-border: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
--default-border: #e2e8ee;
|
--default-border: #e2e8ee;
|
||||||
--default-border-dashed: #dbdfe9;
|
--default-border-dashed: #dbdfe9;
|
||||||
|
|
||||||
/* Background Colors */
|
/* Background Colors */
|
||||||
--default-bg-color: #fafbfc;
|
--default-bg-color: #fafbfc;
|
||||||
--default-box-color: #ffffff;
|
--default-box-color: #ffffff;
|
||||||
|
|
||||||
/* Hover Color */
|
/* Hover Color */
|
||||||
--art-hover-color: #edeff0;
|
--art-hover-color: #edeff0;
|
||||||
|
|
||||||
/* Active Color */
|
/* Active Color */
|
||||||
--art-active-color: #f2f4f5;
|
--art-active-color: #f2f4f5;
|
||||||
|
|
||||||
/* Element Component Active Color */
|
/* Element Component Active Color */
|
||||||
--art-el-active-color: #f2f4f5;
|
--art-el-active-color: #f2f4f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== Dark Mode Variables ==================== */
|
/* ==================== Dark Mode Variables ==================== */
|
||||||
.dark {
|
.dark {
|
||||||
/* Base Colors */
|
/* Base Colors */
|
||||||
--art-color: #000000;
|
--art-color: #000000;
|
||||||
|
|
||||||
/* Gray Scale - Dark Mode */
|
/* Gray Scale - Dark Mode */
|
||||||
--art-gray-100: #110f0f;
|
--art-gray-100: #110f0f;
|
||||||
--art-gray-200: #17171c;
|
--art-gray-200: #17171c;
|
||||||
--art-gray-300: #393946;
|
--art-gray-300: #393946;
|
||||||
--art-gray-400: #505062;
|
--art-gray-400: #505062;
|
||||||
--art-gray-500: #73738c;
|
--art-gray-500: #73738c;
|
||||||
--art-gray-600: #8f8fa3;
|
--art-gray-600: #8f8fa3;
|
||||||
--art-gray-700: #ababba;
|
--art-gray-700: #ababba;
|
||||||
--art-gray-800: #c7c7d1;
|
--art-gray-800: #c7c7d1;
|
||||||
--art-gray-900: #e3e3e8;
|
--art-gray-900: #e3e3e8;
|
||||||
|
|
||||||
/* Border Colors */
|
/* Border Colors */
|
||||||
--art-card-border: rgba(255, 255, 255, 0.08);
|
--art-card-border: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
--default-border: rgba(255, 255, 255, 0.1);
|
--default-border: rgba(255, 255, 255, 0.1);
|
||||||
--default-border-dashed: #363843;
|
--default-border-dashed: #363843;
|
||||||
|
|
||||||
/* Background Colors */
|
/* Background Colors */
|
||||||
--default-bg-color: #070707;
|
--default-bg-color: #070707;
|
||||||
--default-box-color: #161618;
|
--default-box-color: #161618;
|
||||||
|
|
||||||
/* Hover Color */
|
/* Hover Color */
|
||||||
--art-hover-color: #252530;
|
--art-hover-color: #252530;
|
||||||
|
|
||||||
/* Active Color */
|
/* Active Color */
|
||||||
--art-active-color: #202226;
|
--art-active-color: #202226;
|
||||||
|
|
||||||
/* Element Component Active Color */
|
/* Element Component Active Color */
|
||||||
--art-el-active-color: #2e2e38;
|
--art-el-active-color: #2e2e38;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== Tailwind Theme Configuration ==================== */
|
/* ==================== Tailwind Theme Configuration ==================== */
|
||||||
@theme {
|
@theme {
|
||||||
/* Box Color (Light: white / Dark: black) */
|
/* Box Color (Light: white / Dark: black) */
|
||||||
--color-box: var(--default-box-color);
|
--color-box: var(--default-box-color);
|
||||||
|
|
||||||
/* System Theme Color */
|
/* System Theme Color */
|
||||||
--color-theme: var(--theme-color);
|
--color-theme: var(--theme-color);
|
||||||
|
|
||||||
/* Hover Color */
|
/* Hover Color */
|
||||||
--color-hover-color: var(--art-hover-color);
|
--color-hover-color: var(--art-hover-color);
|
||||||
|
|
||||||
/* Active Color */
|
/* Active Color */
|
||||||
--color-active-color: var(--art-active-color);
|
--color-active-color: var(--art-active-color);
|
||||||
|
|
||||||
/* Active Color */
|
/* Active Color */
|
||||||
--color-el-active-color: var(--art-active-color);
|
--color-el-active-color: var(--art-active-color);
|
||||||
|
|
||||||
/* ElementPlus Theme Colors */
|
/* ElementPlus Theme Colors */
|
||||||
--color-primary: var(--art-primary);
|
--color-primary: var(--art-primary);
|
||||||
--color-secondary: var(--art-secondary);
|
--color-secondary: var(--art-secondary);
|
||||||
--color-error: var(--art-error);
|
--color-error: var(--art-error);
|
||||||
--color-info: var(--art-info);
|
--color-info: var(--art-info);
|
||||||
--color-success: var(--art-success);
|
--color-success: var(--art-success);
|
||||||
--color-warning: var(--art-warning);
|
--color-warning: var(--art-warning);
|
||||||
--color-danger: var(--art-danger);
|
--color-danger: var(--art-danger);
|
||||||
|
|
||||||
/* Gray Scale Colors (Auto-adapts to dark mode) */
|
/* Gray Scale Colors (Auto-adapts to dark mode) */
|
||||||
--color-g-100: var(--art-gray-100);
|
--color-g-100: var(--art-gray-100);
|
||||||
--color-g-200: var(--art-gray-200);
|
--color-g-200: var(--art-gray-200);
|
||||||
--color-g-300: var(--art-gray-300);
|
--color-g-300: var(--art-gray-300);
|
||||||
--color-g-400: var(--art-gray-400);
|
--color-g-400: var(--art-gray-400);
|
||||||
--color-g-500: var(--art-gray-500);
|
--color-g-500: var(--art-gray-500);
|
||||||
--color-g-600: var(--art-gray-600);
|
--color-g-600: var(--art-gray-600);
|
||||||
--color-g-700: var(--art-gray-700);
|
--color-g-700: var(--art-gray-700);
|
||||||
--color-g-800: var(--art-gray-800);
|
--color-g-800: var(--art-gray-800);
|
||||||
--color-g-900: var(--art-gray-900);
|
--color-g-900: var(--art-gray-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== Custom Border Radius Utilities ==================== */
|
/* ==================== Custom Border Radius Utilities ==================== */
|
||||||
@utility rounded-custom-xs {
|
@utility rounded-custom-xs {
|
||||||
border-radius: calc(var(--custom-radius) / 2);
|
border-radius: calc(var(--custom-radius) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@utility rounded-custom-sm {
|
@utility rounded-custom-sm {
|
||||||
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
border-radius: calc(var(--custom-radius) / 2 + 2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== Custom Utility Classes ==================== */
|
/* ==================== Custom Utility Classes ==================== */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* Flexbox Layout Utilities */
|
/* Flexbox Layout Utilities */
|
||||||
.flex-c {
|
.flex-c {
|
||||||
@apply flex items-center;
|
@apply flex items-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-b {
|
.flex-b {
|
||||||
@apply flex justify-between;
|
@apply flex justify-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-cc {
|
.flex-cc {
|
||||||
@apply flex items-center justify-center;
|
@apply flex items-center justify-center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.flex-cb {
|
.flex-cb {
|
||||||
@apply flex items-center justify-between;
|
@apply flex items-center justify-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Transition Utilities */
|
/* Transition Utilities */
|
||||||
.tad-200 {
|
.tad-200 {
|
||||||
@apply transition-all duration-200;
|
@apply transition-all duration-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tad-300 {
|
.tad-300 {
|
||||||
@apply transition-all duration-300;
|
@apply transition-all duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Border Utilities */
|
/* Border Utilities */
|
||||||
.border-full-d {
|
.border-full-d {
|
||||||
@apply border border-[var(--default-border)];
|
@apply border border-[var(--default-border)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-b-d {
|
.border-b-d {
|
||||||
@apply border-b border-[var(--default-border)];
|
@apply border-b border-[var(--default-border)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-t-d {
|
.border-t-d {
|
||||||
@apply border-t border-[var(--default-border)];
|
@apply border-t border-[var(--default-border)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-l-d {
|
.border-l-d {
|
||||||
@apply border-l border-[var(--default-border)];
|
@apply border-l border-[var(--default-border)];
|
||||||
}
|
}
|
||||||
|
|
||||||
.border-r-d {
|
.border-r-d {
|
||||||
@apply border-r border-[var(--default-border)];
|
@apply border-r border-[var(--default-border)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cursor Utilities */
|
/* Cursor Utilities */
|
||||||
.c-p {
|
.c-p {
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== Custom Component Classes ==================== */
|
/* ==================== Custom Component Classes ==================== */
|
||||||
@layer components {
|
@layer components {
|
||||||
/* Art Card Header Component */
|
/* Art Card Header Component */
|
||||||
.art-card-header {
|
.art-card-header {
|
||||||
@apply flex justify-between pr-6 pb-1;
|
@apply flex justify-between pr-6 pb-1;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
h4 {
|
h4 {
|
||||||
@apply text-lg font-medium text-g-900;
|
@apply text-lg font-medium text-g-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@apply mt-1 text-sm text-g-600;
|
@apply mt-1 text-sm text-g-600;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@apply ml-2 font-medium;
|
@apply ml-2 font-medium;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,60 +4,60 @@ $bg-animation-color-dark: #fff;
|
|||||||
$bg-animation-duration: 0.5s;
|
$bg-animation-duration: 0.5s;
|
||||||
|
|
||||||
html {
|
html {
|
||||||
--bg-animation-color: $bg-animation-color-light;
|
--bg-animation-color: $bg-animation-color-light;
|
||||||
|
|
||||||
&.dark {
|
&.dark {
|
||||||
--bg-animation-color: $bg-animation-color-dark;
|
--bg-animation-color: $bg-animation-color-dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// View transition styles
|
// View transition styles
|
||||||
&::view-transition-old(*) {
|
&::view-transition-old(*) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-new(*) {
|
&::view-transition-new(*) {
|
||||||
animation: clip $bg-animation-duration ease-in both;
|
animation: clip $bg-animation-duration ease-in both;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-old(root) {
|
&::view-transition-old(root) {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-new(root) {
|
&::view-transition-new(root) {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.dark {
|
&.dark {
|
||||||
&::view-transition-old(*) {
|
&::view-transition-old(*) {
|
||||||
animation: clip $bg-animation-duration ease-in reverse both;
|
animation: clip $bg-animation-duration ease-in reverse both;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-new(*) {
|
&::view-transition-new(*) {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-old(root) {
|
&::view-transition-old(root) {
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::view-transition-new(root) {
|
&::view-transition-new(root) {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义动画
|
// 定义动画
|
||||||
@keyframes clip {
|
@keyframes clip {
|
||||||
from {
|
from {
|
||||||
clip-path: circle(0% at var(--x) var(--y));
|
clip-path: circle(0% at var(--x) var(--y));
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
clip-path: circle(var(--r) at var(--x) var(--y));
|
clip-path: circle(var(--r) at var(--x) var(--y));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// body 相关样式
|
// body 相关样式
|
||||||
body {
|
body {
|
||||||
background-color: var(--bg-animation-color);
|
background-color: var(--bg-animation-color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
// 主题切换过渡优化,优化除视觉上的不适感
|
// 主题切换过渡优化,优化除视觉上的不适感
|
||||||
.theme-change {
|
.theme-change {
|
||||||
* {
|
* {
|
||||||
transition: 0s !important;
|
transition: 0s !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-switch__core,
|
.el-switch__core,
|
||||||
.el-switch__action {
|
.el-switch__action {
|
||||||
transition: all 0.3s !important;
|
transition: all 0.3s !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.hljs {
|
.hljs {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 0.5em;
|
padding: 0.5em;
|
||||||
|
|
||||||
color: #a6accd;
|
color: #a6accd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-string,
|
.hljs-string,
|
||||||
@@ -11,18 +11,18 @@
|
|||||||
.hljs-selector-class,
|
.hljs-selector-class,
|
||||||
.hljs-template-variable,
|
.hljs-template-variable,
|
||||||
.hljs-deletion {
|
.hljs-deletion {
|
||||||
color: #aed07e !important;
|
color: #aed07e !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-comment,
|
.hljs-comment,
|
||||||
.hljs-quote {
|
.hljs-quote {
|
||||||
color: #6f747d;
|
color: #6f747d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-doctag,
|
.hljs-doctag,
|
||||||
.hljs-keyword,
|
.hljs-keyword,
|
||||||
.hljs-formula {
|
.hljs-formula {
|
||||||
color: #c792ea;
|
color: #c792ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-section,
|
.hljs-section,
|
||||||
@@ -30,11 +30,11 @@
|
|||||||
.hljs-selector-tag,
|
.hljs-selector-tag,
|
||||||
.hljs-deletion,
|
.hljs-deletion,
|
||||||
.hljs-subst {
|
.hljs-subst {
|
||||||
color: #c86068;
|
color: #c86068;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-literal {
|
.hljs-literal {
|
||||||
color: #56b6c2;
|
color: #56b6c2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-string,
|
.hljs-string,
|
||||||
@@ -42,33 +42,33 @@
|
|||||||
.hljs-addition,
|
.hljs-addition,
|
||||||
.hljs-attribute,
|
.hljs-attribute,
|
||||||
.hljs-meta-string {
|
.hljs-meta-string {
|
||||||
color: #abb2bf;
|
color: #abb2bf;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-attribute {
|
.hljs-attribute {
|
||||||
color: #c792ea;
|
color: #c792ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-function {
|
.hljs-function {
|
||||||
color: #c792ea;
|
color: #c792ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-type {
|
.hljs-type {
|
||||||
color: #f07178;
|
color: #f07178;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-title {
|
.hljs-title {
|
||||||
color: #82aaff !important;
|
color: #82aaff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-built_in,
|
.hljs-built_in,
|
||||||
.hljs-class {
|
.hljs-class {
|
||||||
color: #82aaff;
|
color: #82aaff;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 括号
|
// 括号
|
||||||
.hljs-params {
|
.hljs-params {
|
||||||
color: #a6accd;
|
color: #a6accd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-attr,
|
.hljs-attr,
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
.hljs-selector-attr,
|
.hljs-selector-attr,
|
||||||
.hljs-selector-pseudo,
|
.hljs-selector-pseudo,
|
||||||
.hljs-number {
|
.hljs-number {
|
||||||
color: #de7e61;
|
color: #de7e61;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-symbol,
|
.hljs-symbol,
|
||||||
@@ -86,13 +86,13 @@
|
|||||||
.hljs-link,
|
.hljs-link,
|
||||||
.hljs-meta,
|
.hljs-meta,
|
||||||
.hljs-selector-id {
|
.hljs-selector-id {
|
||||||
color: #61aeee;
|
color: #61aeee;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-strong {
|
.hljs-strong {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hljs-link {
|
.hljs-link {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,343 +1,352 @@
|
|||||||
<!-- 基础横幅组件 -->
|
<!-- 基础横幅组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="art-card basic-banner"
|
class="art-card basic-banner"
|
||||||
:class="[{ 'has-decoration': decoration }, boxStyle]"
|
:class="[{ 'has-decoration': decoration }, boxStyle]"
|
||||||
:style="{ height }"
|
:style="{ height }"
|
||||||
@click="emit('click')"
|
@click="emit('click')"
|
||||||
>
|
>
|
||||||
<!-- 流星效果 -->
|
<!-- 流星效果 -->
|
||||||
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
|
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
|
||||||
<span
|
<span
|
||||||
v-for="(meteor, index) in meteors"
|
v-for="(meteor, index) in meteors"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="meteor"
|
class="meteor"
|
||||||
:style="{
|
:style="{
|
||||||
top: '-60px',
|
top: '-60px',
|
||||||
left: `${meteor.x}%`,
|
left: `${meteor.x}%`,
|
||||||
animationDuration: `${meteor.speed}s`,
|
animationDuration: `${meteor.speed}s`,
|
||||||
animationDelay: `${meteor.delay}s`
|
animationDelay: `${meteor.delay}s`
|
||||||
}"
|
}"
|
||||||
></span>
|
></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="basic-banner__content">
|
<div class="basic-banner__content">
|
||||||
<!-- title slot -->
|
<!-- title slot -->
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
|
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{
|
||||||
</slot>
|
title
|
||||||
|
}}</p>
|
||||||
|
</slot>
|
||||||
|
|
||||||
<!-- subtitle slot -->
|
<!-- subtitle slot -->
|
||||||
<slot name="subtitle">
|
<slot name="subtitle">
|
||||||
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{
|
<p
|
||||||
subtitle
|
v-if="subtitle"
|
||||||
}}</p>
|
class="basic-banner__subtitle"
|
||||||
</slot>
|
:style="{ color: subtitleColor }"
|
||||||
|
>{{ subtitle }}</p
|
||||||
|
>
|
||||||
|
</slot>
|
||||||
|
|
||||||
<!-- button slot -->
|
<!-- button slot -->
|
||||||
<slot name="button">
|
<slot name="button">
|
||||||
<div
|
<div
|
||||||
v-if="buttonConfig?.show"
|
v-if="buttonConfig?.show"
|
||||||
class="basic-banner__button"
|
class="basic-banner__button"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: buttonColor,
|
backgroundColor: buttonColor,
|
||||||
color: buttonTextColor,
|
color: buttonTextColor,
|
||||||
borderRadius: buttonRadius
|
borderRadius: buttonRadius
|
||||||
}"
|
}"
|
||||||
@click.stop="emit('buttonClick')"
|
@click.stop="emit('buttonClick')"
|
||||||
>
|
>
|
||||||
{{ buttonConfig?.text }}
|
{{ buttonConfig?.text }}
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<!-- default slot -->
|
<!-- default slot -->
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
|
|
||||||
<!-- background image -->
|
<!-- background image -->
|
||||||
<img
|
<img
|
||||||
v-if="imageConfig.src"
|
v-if="imageConfig.src"
|
||||||
class="basic-banner__background-image"
|
class="basic-banner__background-image"
|
||||||
:src="imageConfig.src"
|
:src="imageConfig.src"
|
||||||
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
|
:style="{
|
||||||
loading="lazy"
|
width: imageConfig.width,
|
||||||
alt="背景图片"
|
bottom: imageConfig.bottom,
|
||||||
/>
|
right: imageConfig.right
|
||||||
</div>
|
}"
|
||||||
</div>
|
loading="lazy"
|
||||||
|
alt="背景图片"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { isDark } = storeToRefs(settingStore)
|
const { isDark } = storeToRefs(settingStore)
|
||||||
|
|
||||||
defineOptions({ name: 'ArtBasicBanner' })
|
defineOptions({ name: 'ArtBasicBanner' })
|
||||||
|
|
||||||
// 流星对象接口定义
|
// 流星对象接口定义
|
||||||
interface Meteor {
|
interface Meteor {
|
||||||
/** 流星的水平位置(百分比) */
|
/** 流星的水平位置(百分比) */
|
||||||
x: number
|
x: number
|
||||||
/** 流星划过的速度 */
|
/** 流星划过的速度 */
|
||||||
speed: number
|
speed: number
|
||||||
/** 流星出现的延迟时间 */
|
/** 流星出现的延迟时间 */
|
||||||
delay: number
|
delay: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按钮配置接口定义
|
// 按钮配置接口定义
|
||||||
interface ButtonConfig {
|
interface ButtonConfig {
|
||||||
/** 是否启用按钮 */
|
/** 是否启用按钮 */
|
||||||
show: boolean
|
show: boolean
|
||||||
/** 按钮文本 */
|
/** 按钮文本 */
|
||||||
text: string
|
text: string
|
||||||
/** 按钮背景色 */
|
/** 按钮背景色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 按钮文字颜色 */
|
/** 按钮文字颜色 */
|
||||||
textColor?: string
|
textColor?: string
|
||||||
/** 按钮圆角大小 */
|
/** 按钮圆角大小 */
|
||||||
radius?: string
|
radius?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 流星效果配置接口定义
|
// 流星效果配置接口定义
|
||||||
interface MeteorConfig {
|
interface MeteorConfig {
|
||||||
/** 是否启用流星效果 */
|
/** 是否启用流星效果 */
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
/** 流星数量 */
|
/** 流星数量 */
|
||||||
count?: number
|
count?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 背景图片配置接口定义
|
// 背景图片配置接口定义
|
||||||
interface ImageConfig {
|
interface ImageConfig {
|
||||||
/** 图片源地址 */
|
/** 图片源地址 */
|
||||||
src: string
|
src: string
|
||||||
/** 图片宽度 */
|
/** 图片宽度 */
|
||||||
width?: string
|
width?: string
|
||||||
/** 距底部距离 */
|
/** 距底部距离 */
|
||||||
bottom?: string
|
bottom?: string
|
||||||
/** 距右侧距离 */
|
/** 距右侧距离 */
|
||||||
right?: string // 距右侧距离
|
right?: string // 距右侧距离
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件属性接口定义
|
// 组件属性接口定义
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 横幅高度 */
|
/** 横幅高度 */
|
||||||
height?: string
|
height?: string
|
||||||
/** 标题文本 */
|
/** 标题文本 */
|
||||||
title?: string
|
title?: string
|
||||||
/** 副标题文本 */
|
/** 副标题文本 */
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
/** 盒子样式 */
|
/** 盒子样式 */
|
||||||
boxStyle?: string
|
boxStyle?: string
|
||||||
/** 是否显示装饰效果 */
|
/** 是否显示装饰效果 */
|
||||||
decoration?: boolean
|
decoration?: boolean
|
||||||
/** 按钮配置 */
|
/** 按钮配置 */
|
||||||
buttonConfig?: ButtonConfig
|
buttonConfig?: ButtonConfig
|
||||||
/** 流星配置 */
|
/** 流星配置 */
|
||||||
meteorConfig?: MeteorConfig
|
meteorConfig?: MeteorConfig
|
||||||
/** 图片配置 */
|
/** 图片配置 */
|
||||||
imageConfig?: ImageConfig
|
imageConfig?: ImageConfig
|
||||||
/** 标题颜色 */
|
/** 标题颜色 */
|
||||||
titleColor?: string
|
titleColor?: string
|
||||||
/** 副标题颜色 */
|
/** 副标题颜色 */
|
||||||
subtitleColor?: string
|
subtitleColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件属性默认值设置
|
// 组件属性默认值设置
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: '11rem',
|
height: '11rem',
|
||||||
titleColor: 'white',
|
titleColor: 'white',
|
||||||
subtitleColor: 'white',
|
subtitleColor: 'white',
|
||||||
boxStyle: '!bg-theme/60',
|
boxStyle: '!bg-theme/60',
|
||||||
decoration: true,
|
decoration: true,
|
||||||
buttonConfig: () => ({
|
buttonConfig: () => ({
|
||||||
show: true,
|
show: true,
|
||||||
text: '查看',
|
text: '查看',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
textColor: '#333',
|
textColor: '#333',
|
||||||
radius: '6px'
|
radius: '6px'
|
||||||
}),
|
}),
|
||||||
meteorConfig: () => ({ enabled: false, count: 10 }),
|
meteorConfig: () => ({ enabled: false, count: 10 }),
|
||||||
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
|
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义组件事件
|
// 定义组件事件
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click'): void // 整体点击事件
|
(e: 'click'): void // 整体点击事件
|
||||||
(e: 'buttonClick'): void // 按钮点击事件
|
(e: 'buttonClick'): void // 按钮点击事件
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 计算按钮样式属性
|
// 计算按钮样式属性
|
||||||
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
|
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
|
||||||
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
|
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
|
||||||
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
|
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
|
||||||
|
|
||||||
// 流星数据初始化
|
// 流星数据初始化
|
||||||
const meteors = ref<Meteor[]>([])
|
const meteors = ref<Meteor[]>([])
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.meteorConfig?.enabled) {
|
if (props.meteorConfig?.enabled) {
|
||||||
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
|
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成流星数据数组
|
* 生成流星数据数组
|
||||||
* @param count 流星数量
|
* @param count 流星数量
|
||||||
* @returns 流星数据数组
|
* @returns 流星数据数组
|
||||||
*/
|
*/
|
||||||
function generateMeteors(count: number): Meteor[] {
|
function generateMeteors(count: number): Meteor[] {
|
||||||
// 计算每个流星的区域宽度
|
// 计算每个流星的区域宽度
|
||||||
const segmentWidth = 100 / count
|
const segmentWidth = 100 / count
|
||||||
return Array.from({ length: count }, (_, index) => {
|
return Array.from({ length: count }, (_, index) => {
|
||||||
// 计算流星起始位置
|
// 计算流星起始位置
|
||||||
const segmentStart = index * segmentWidth
|
const segmentStart = index * segmentWidth
|
||||||
// 在区域内随机生成x坐标
|
// 在区域内随机生成x坐标
|
||||||
const x = segmentStart + Math.random() * segmentWidth
|
const x = segmentStart + Math.random() * segmentWidth
|
||||||
// 随机决定流星速度快慢
|
// 随机决定流星速度快慢
|
||||||
const isSlow = Math.random() > 0.5
|
const isSlow = Math.random() > 0.5
|
||||||
return {
|
return {
|
||||||
x,
|
x,
|
||||||
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
|
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
|
||||||
delay: Math.random() * 5
|
delay: Math.random() * 5
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.basic-banner {
|
.basic-banner {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 2rem;
|
padding: 0 2rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
color: white;
|
color: white;
|
||||||
border-radius: calc(var(--custom-radius) + 2px) !important;
|
border-radius: calc(var(--custom-radius) + 2px) !important;
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
margin: 0 0 0.5rem;
|
margin: 0 0 0.5rem;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__subtitle {
|
&__subtitle {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
margin: 0 0 1.5rem;
|
margin: 0 0 1.5rem;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__button {
|
&__button {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
height: var(--el-component-custom-height);
|
height: var(--el-component-custom-height);
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: var(--el-component-custom-height);
|
line-height: var(--el-component-custom-height);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__background-image {
|
&__background-image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: -3rem;
|
bottom: -3rem;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
width: 12rem;
|
width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.has-decoration::after {
|
&.has-decoration::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -10%;
|
right: -10%;
|
||||||
bottom: -20%;
|
bottom: -20%;
|
||||||
width: 60%;
|
width: 60%;
|
||||||
height: 140%;
|
height: 140%;
|
||||||
content: '';
|
content: '';
|
||||||
background: rgb(255 255 255 / 10%);
|
background: rgb(255 255 255 / 10%);
|
||||||
border-radius: 30%;
|
border-radius: 30%;
|
||||||
transform: rotate(-20deg);
|
transform: rotate(-20deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
&__meteors {
|
&__meteors {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
.meteor {
|
.meteor {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to top,
|
to top,
|
||||||
rgb(255 255 255 / 40%),
|
rgb(255 255 255 / 40%),
|
||||||
rgb(255 255 255 / 10%),
|
rgb(255 255 255 / 10%),
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform-origin: top left;
|
transform-origin: top left;
|
||||||
animation-name: meteor-fall;
|
animation-name: meteor-fall;
|
||||||
animation-timing-function: linear;
|
animation-timing-function: linear;
|
||||||
animation-iteration-count: infinite;
|
animation-iteration-count: infinite;
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
content: '';
|
content: '';
|
||||||
background: rgb(255 255 255 / 50%);
|
background: rgb(255 255 255 / 50%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes meteor-fall {
|
@keyframes meteor-fall {
|
||||||
0% {
|
0% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translate(0, -60px) rotate(-45deg);
|
transform: translate(0, -60px) rotate(-45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translate(400px, 340px) rotate(-45deg);
|
transform: translate(400px, 340px) rotate(-45deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 640px) {
|
@media (width <= 640px) {
|
||||||
.basic-banner {
|
.basic-banner {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
|
||||||
&__title {
|
&__title {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__background-image {
|
&__background-image {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.has-decoration::after {
|
&.has-decoration::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,114 +1,114 @@
|
|||||||
<!-- 卡片横幅组件 -->
|
<!-- 卡片横幅组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
|
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
|
||||||
<div class="flex-c flex-col gap-4 text-center">
|
<div class="flex-c flex-col gap-4 text-center">
|
||||||
<div class="w-45">
|
<div class="w-45">
|
||||||
<img :src="image" :alt="title" class="w-full h-full object-contain" />
|
<img :src="image" :alt="title" class="w-full h-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="box-border px-4">
|
<div class="box-border px-4">
|
||||||
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
|
||||||
<p class="m-0 text-sm text-g-600">{{ description }}</p>
|
<p class="m-0 text-sm text-g-600">{{ description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-c gap-3">
|
<div class="flex-c gap-3">
|
||||||
<div
|
<div
|
||||||
v-if="cancelButton?.show"
|
v-if="cancelButton?.show"
|
||||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
|
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: cancelButton?.color,
|
backgroundColor: cancelButton?.color,
|
||||||
color: cancelButton?.textColor
|
color: cancelButton?.textColor
|
||||||
}"
|
}"
|
||||||
@click="handleCancel"
|
@click="handleCancel"
|
||||||
>
|
>
|
||||||
{{ cancelButton?.text }}
|
{{ cancelButton?.text }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="button?.show"
|
v-if="button?.show"
|
||||||
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
|
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
|
||||||
:style="{ backgroundColor: button?.color, color: button?.textColor }"
|
:style="{ backgroundColor: button?.color, color: button?.textColor }"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
{{ button?.text }}
|
{{ button?.text }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 导入默认图标
|
// 导入默认图标
|
||||||
import defaultIcon from '@imgs/3d/icon1.webp'
|
import defaultIcon from '@imgs/3d/icon1.webp'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtCardBanner' })
|
defineOptions({ name: 'ArtCardBanner' })
|
||||||
|
|
||||||
// 定义卡片横幅组件的属性接口
|
// 定义卡片横幅组件的属性接口
|
||||||
interface CardBannerProps {
|
interface CardBannerProps {
|
||||||
/** 高度 */
|
/** 高度 */
|
||||||
height?: string
|
height?: string
|
||||||
/** 图片路径 */
|
/** 图片路径 */
|
||||||
image?: string
|
image?: string
|
||||||
/** 标题文本 */
|
/** 标题文本 */
|
||||||
title: string
|
title: string
|
||||||
/** 描述文本 */
|
/** 描述文本 */
|
||||||
description: string
|
description: string
|
||||||
/** 主按钮配置 */
|
/** 主按钮配置 */
|
||||||
button?: {
|
button?: {
|
||||||
/** 是否显示 */
|
/** 是否显示 */
|
||||||
show?: boolean
|
show?: boolean
|
||||||
/** 按钮文本 */
|
/** 按钮文本 */
|
||||||
text?: string
|
text?: string
|
||||||
/** 背景颜色 */
|
/** 背景颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 文字颜色 */
|
/** 文字颜色 */
|
||||||
textColor?: string
|
textColor?: string
|
||||||
}
|
}
|
||||||
/** 取消按钮配置 */
|
/** 取消按钮配置 */
|
||||||
cancelButton?: {
|
cancelButton?: {
|
||||||
/** 是否显示 */
|
/** 是否显示 */
|
||||||
show?: boolean
|
show?: boolean
|
||||||
/** 按钮文本 */
|
/** 按钮文本 */
|
||||||
text?: string
|
text?: string
|
||||||
/** 背景颜色 */
|
/** 背景颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 文字颜色 */
|
/** 文字颜色 */
|
||||||
textColor?: string
|
textColor?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义组件属性默认值
|
// 定义组件属性默认值
|
||||||
withDefaults(defineProps<CardBannerProps>(), {
|
withDefaults(defineProps<CardBannerProps>(), {
|
||||||
height: '24rem',
|
height: '24rem',
|
||||||
image: defaultIcon,
|
image: defaultIcon,
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
// 主按钮默认配置
|
// 主按钮默认配置
|
||||||
button: () => ({
|
button: () => ({
|
||||||
show: true,
|
show: true,
|
||||||
text: '查看详情',
|
text: '查看详情',
|
||||||
color: 'var(--theme-color)',
|
color: 'var(--theme-color)',
|
||||||
textColor: '#fff'
|
textColor: '#fff'
|
||||||
}),
|
}),
|
||||||
// 取消按钮默认配置
|
// 取消按钮默认配置
|
||||||
cancelButton: () => ({
|
cancelButton: () => ({
|
||||||
show: false,
|
show: false,
|
||||||
text: '取消',
|
text: '取消',
|
||||||
color: '#f5f5f5',
|
color: '#f5f5f5',
|
||||||
textColor: '#666'
|
textColor: '#666'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 定义组件事件
|
// 定义组件事件
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click'): void // 主按钮点击事件
|
(e: 'click'): void // 主按钮点击事件
|
||||||
(e: 'cancel'): void // 取消按钮点击事件
|
(e: 'cancel'): void // 取消按钮点击事件
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 主按钮点击处理函数
|
// 主按钮点击处理函数
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
emit('click')
|
emit('click')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 取消按钮点击处理函数
|
// 取消按钮点击处理函数
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
<!-- 返回顶部按钮 -->
|
<!-- 返回顶部按钮 -->
|
||||||
<template>
|
<template>
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="tad-300 ease-out"
|
enter-active-class="tad-300 ease-out"
|
||||||
leave-active-class="tad-200 ease-in"
|
leave-active-class="tad-200 ease-in"
|
||||||
enter-from-class="opacity-0 translate-y-2"
|
enter-from-class="opacity-0 translate-y-2"
|
||||||
enter-to-class="opacity-100 translate-y-0"
|
enter-to-class="opacity-100 translate-y-0"
|
||||||
leave-from-class="opacity-100 translate-y-0"
|
leave-from-class="opacity-100 translate-y-0"
|
||||||
leave-to-class="opacity-0 translate-y-2"
|
leave-to-class="opacity-0 translate-y-2"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-show="showButton"
|
v-show="showButton"
|
||||||
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
|
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
|
||||||
@click="scrollToTop"
|
@click="scrollToTop"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
|
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCommon } from '@/hooks/core/useCommon'
|
import { useCommon } from '@/hooks/core/useCommon'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtBackToTop' })
|
defineOptions({ name: 'ArtBackToTop' })
|
||||||
|
|
||||||
const { scrollToTop } = useCommon()
|
const { scrollToTop } = useCommon()
|
||||||
|
|
||||||
const showButton = ref(false)
|
const showButton = ref(false)
|
||||||
const scrollThreshold = 300
|
const scrollThreshold = 300
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const scrollContainer = document.getElementById('app-main')
|
const scrollContainer = document.getElementById('app-main')
|
||||||
if (scrollContainer) {
|
if (scrollContainer) {
|
||||||
const { y } = useScroll(scrollContainer)
|
const { y } = useScroll(scrollContainer)
|
||||||
watch(y, (newY: number) => {
|
watch(y, (newY: number) => {
|
||||||
showButton.value = newY > scrollThreshold
|
showButton.value = newY > scrollThreshold
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
<!-- 系统logo -->
|
<!-- 系统logo -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-cc">
|
<div class="flex-cc">
|
||||||
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
|
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtLogo' })
|
defineOptions({ name: 'ArtLogo' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** logo 大小 */
|
/** logo 大小 */
|
||||||
size?: number | string
|
size?: number | string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
size: 36
|
size: 36
|
||||||
})
|
})
|
||||||
|
|
||||||
const logoStyle = computed(() => ({ width: `${props.size}px` }))
|
const logoStyle = computed(() => ({ width: `${props.size}px` }))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<!-- 图标组件 -->
|
<!-- 图标组件 -->
|
||||||
<template>
|
<template>
|
||||||
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
|
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue'
|
import { Icon } from '@iconify/vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
|
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** Iconify icon name */
|
/** Iconify icon name */
|
||||||
icon?: string
|
icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
|
|
||||||
const attrs = useAttrs()
|
const attrs = useAttrs()
|
||||||
|
|
||||||
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
const bindAttrs = computed<{ class: string; style: string }>(() => ({
|
||||||
class: (attrs.class as string) || '',
|
class: (attrs.class as string) || '',
|
||||||
style: (attrs.style as string) || ''
|
style: (attrs.style as string) || ''
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,103 +1,108 @@
|
|||||||
<!-- 柱状图卡片 -->
|
<!-- 柱状图卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||||
<div class="mb-5 flex-b items-start px-5 pt-5">
|
<div class="mb-5 flex-b items-start px-5 pt-5">
|
||||||
<div>
|
<div>
|
||||||
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
|
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-sm font-medium text-danger"
|
class="text-sm font-medium text-danger"
|
||||||
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
|
:class="[
|
||||||
>
|
percentage > 0 ? 'text-success' : '',
|
||||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
isMiniChart ? 'absolute bottom-5' : ''
|
||||||
</div>
|
]"
|
||||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
|
>
|
||||||
{{ date }}
|
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
|
||||||
<div
|
{{ date }}
|
||||||
ref="chartRef"
|
</div>
|
||||||
class="absolute bottom-0 left-0 right-0 mx-auto"
|
</div>
|
||||||
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
|
<div
|
||||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
ref="chartRef"
|
||||||
></div>
|
class="absolute bottom-0 left-0 right-0 mx-auto"
|
||||||
</div>
|
:class="
|
||||||
|
isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''
|
||||||
|
"
|
||||||
|
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import { type EChartsOption } from '@/plugins/echarts'
|
import { type EChartsOption } from '@/plugins/echarts'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtBarChartCard' })
|
defineOptions({ name: 'ArtBarChartCard' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 数值 */
|
/** 数值 */
|
||||||
value: number
|
value: number
|
||||||
/** 标签 */
|
/** 标签 */
|
||||||
label: string
|
label: string
|
||||||
/** 百分比 +(绿色)-(红色) */
|
/** 百分比 +(绿色)-(红色) */
|
||||||
percentage: number
|
percentage: number
|
||||||
/** 日期 */
|
/** 日期 */
|
||||||
date?: string
|
date?: string
|
||||||
/** 高度 */
|
/** 高度 */
|
||||||
height?: number
|
height?: number
|
||||||
/** 颜色 */
|
/** 颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 图表数据 */
|
/** 图表数据 */
|
||||||
chartData: number[]
|
chartData: number[]
|
||||||
/** 柱状图宽度 */
|
/** 柱状图宽度 */
|
||||||
barWidth?: string
|
barWidth?: string
|
||||||
/** 是否为迷你图表 */
|
/** 是否为迷你图表 */
|
||||||
isMiniChart?: boolean
|
isMiniChart?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 11,
|
height: 11,
|
||||||
barWidth: '26%'
|
barWidth: '26%'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const { chartRef } = useChartComponent({
|
const { chartRef } = useChartComponent({
|
||||||
props: {
|
props: {
|
||||||
height: `${props.height}rem`,
|
height: `${props.height}rem`,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
||||||
},
|
},
|
||||||
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
||||||
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
|
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
const computedColor = props.color || useChartOps().themeColor
|
const computedColor = props.color || useChartOps().themeColor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 15,
|
bottom: 15,
|
||||||
left: 0
|
left: 0
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
show: false
|
show: false
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
show: false
|
show: false
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
data: props.chartData,
|
data: props.chartData,
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
barWidth: props.barWidth,
|
barWidth: props.barWidth,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: computedColor,
|
color: computedColor,
|
||||||
borderRadius: 2
|
borderRadius: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,74 +1,74 @@
|
|||||||
<!-- 数据列表卡片 -->
|
<!-- 数据列表卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card p-5">
|
<div class="art-card p-5">
|
||||||
<div class="pb-3.5">
|
<div class="pb-3.5">
|
||||||
<p class="text-lg font-medium">{{ title }}</p>
|
<p class="text-lg font-medium">{{ title }}</p>
|
||||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<ElScrollbar :style="{ height: maxHeight }">
|
<ElScrollbar :style="{ height: maxHeight }">
|
||||||
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
|
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
|
||||||
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
|
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
|
||||||
<ArtSvgIcon :icon="item.icon" class="text-xl" />
|
<ArtSvgIcon :icon="item.icon" class="text-xl" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="mb-1 text-sm">{{ item.title }}</div>
|
<div class="mb-1 text-sm">{{ item.title }}</div>
|
||||||
<div class="text-xs text-g-500">{{ item.status }}</div>
|
<div class="text-xs text-g-500">{{ item.status }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
|
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
|
||||||
</div>
|
</div>
|
||||||
</ElScrollbar>
|
</ElScrollbar>
|
||||||
<ElButton
|
<ElButton
|
||||||
class="mt-[25px] w-full text-center"
|
class="mt-[25px] w-full text-center"
|
||||||
v-if="showMoreButton"
|
v-if="showMoreButton"
|
||||||
v-ripple
|
v-ripple
|
||||||
@click="handleMore"
|
@click="handleMore"
|
||||||
>查看更多</ElButton
|
>查看更多</ElButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtDataListCard' })
|
defineOptions({ name: 'ArtDataListCard' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 数据列表 */
|
/** 数据列表 */
|
||||||
list: Activity[]
|
list: Activity[]
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 副标题 */
|
/** 副标题 */
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
/** 最大显示数量 */
|
/** 最大显示数量 */
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
/** 是否显示更多按钮 */
|
/** 是否显示更多按钮 */
|
||||||
showMoreButton?: boolean
|
showMoreButton?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Activity {
|
interface Activity {
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 状态 */
|
/** 状态 */
|
||||||
status: string
|
status: string
|
||||||
/** 时间 */
|
/** 时间 */
|
||||||
time: string
|
time: string
|
||||||
/** 样式类名 */
|
/** 样式类名 */
|
||||||
class: string
|
class: string
|
||||||
/** 图标 */
|
/** 图标 */
|
||||||
icon: string
|
icon: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEM_HEIGHT = 66
|
const ITEM_HEIGHT = 66
|
||||||
const DEFAULT_MAX_COUNT = 5
|
const DEFAULT_MAX_COUNT = 5
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
maxCount: DEFAULT_MAX_COUNT
|
maxCount: DEFAULT_MAX_COUNT
|
||||||
})
|
})
|
||||||
|
|
||||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** 点击更多按钮事件 */
|
/** 点击更多按钮事件 */
|
||||||
(e: 'more'): void
|
(e: 'more'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleMore = () => emit('more')
|
const handleMore = () => emit('more')
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,124 +1,124 @@
|
|||||||
<!-- 环型图卡片 -->
|
<!-- 环型图卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
|
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
|
||||||
<div class="flex box-border h-full p-5 pr-2">
|
<div class="flex box-border h-full p-5 pr-2">
|
||||||
<div class="flex w-full items-start gap-5">
|
<div class="flex w-full items-start gap-5">
|
||||||
<div class="flex-b h-full flex-1 flex-col">
|
<div class="flex-b h-full flex-1 flex-col">
|
||||||
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
<p class="m-0 text-xl font-medium leading-tight text-g-900">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
|
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
|
||||||
{{ formatNumber(value) }}
|
{{ formatNumber(value) }}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
class="mt-1.5 text-xs font-medium"
|
class="mt-1.5 text-xs font-medium"
|
||||||
:class="percentage > 0 ? 'text-success' : 'text-danger'"
|
:class="percentage > 0 ? 'text-success' : 'text-danger'"
|
||||||
>
|
>
|
||||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||||
<span v-if="percentageLabel">{{ percentageLabel }}</span>
|
<span v-if="percentageLabel">{{ percentageLabel }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
<div class="mt-2 flex gap-4 text-xs text-g-600">
|
||||||
<div v-if="currentValue" class="flex-cc">
|
<div v-if="currentValue" class="flex-cc">
|
||||||
<div class="size-2 bg-theme/100 rounded mr-2"></div>
|
<div class="size-2 bg-theme/100 rounded mr-2"></div>
|
||||||
{{ currentValue }}
|
{{ currentValue }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="previousValue" class="flex-cc">
|
<div v-if="previousValue" class="flex-cc">
|
||||||
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
<div class="size-2 bg-g-400 rounded mr-2"></div>
|
||||||
{{ previousValue }}
|
{{ previousValue }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-c h-full max-w-40 flex-1">
|
<div class="flex-c h-full max-w-40 flex-1">
|
||||||
<div ref="chartRef" class="h-30 w-full"></div>
|
<div ref="chartRef" class="h-30 w-full"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type EChartsOption } from '@/plugins/echarts'
|
import { type EChartsOption } from '@/plugins/echarts'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtDonutChartCard' })
|
defineOptions({ name: 'ArtDonutChartCard' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 数值 */
|
/** 数值 */
|
||||||
value: number
|
value: number
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 百分比 */
|
/** 百分比 */
|
||||||
percentage: number
|
percentage: number
|
||||||
/** 百分比标签 */
|
/** 百分比标签 */
|
||||||
percentageLabel?: string
|
percentageLabel?: string
|
||||||
/** 当前年份 */
|
/** 当前年份 */
|
||||||
currentValue?: string
|
currentValue?: string
|
||||||
/** 去年年份 */
|
/** 去年年份 */
|
||||||
previousValue?: string
|
previousValue?: string
|
||||||
/** 高度 */
|
/** 高度 */
|
||||||
height?: number
|
height?: number
|
||||||
/** 颜色 */
|
/** 颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 半径 */
|
/** 半径 */
|
||||||
radius?: [string, string]
|
radius?: [string, string]
|
||||||
/** 数据 */
|
/** 数据 */
|
||||||
data: [number, number]
|
data: [number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 9,
|
height: 9,
|
||||||
radius: () => ['70%', '90%'],
|
radius: () => ['70%', '90%'],
|
||||||
data: () => [0, 0]
|
data: () => [0, 0]
|
||||||
})
|
})
|
||||||
|
|
||||||
const formatNumber = (num: number) => {
|
const formatNumber = (num: number) => {
|
||||||
return num.toLocaleString()
|
return num.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const { chartRef } = useChartComponent({
|
const { chartRef } = useChartComponent({
|
||||||
props: {
|
props: {
|
||||||
height: `${props.height}rem`,
|
height: `${props.height}rem`,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: props.data.every((val) => val === 0)
|
isEmpty: props.data.every((val) => val === 0)
|
||||||
},
|
},
|
||||||
checkEmpty: () => props.data.every((val) => val === 0),
|
checkEmpty: () => props.data.every((val) => val === 0),
|
||||||
watchSources: [
|
watchSources: [
|
||||||
() => props.data,
|
() => props.data,
|
||||||
() => props.color,
|
() => props.color,
|
||||||
() => props.radius,
|
() => props.radius,
|
||||||
() => props.currentValue,
|
() => props.currentValue,
|
||||||
() => props.previousValue
|
() => props.previousValue
|
||||||
],
|
],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
const computedColor = props.color || useChartOps().themeColor
|
const computedColor = props.color || useChartOps().themeColor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: props.radius,
|
radius: props.radius,
|
||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: false,
|
||||||
label: {
|
label: {
|
||||||
show: false
|
show: false
|
||||||
},
|
},
|
||||||
data: [
|
data: [
|
||||||
{
|
{
|
||||||
value: props.data[0],
|
value: props.data[0],
|
||||||
name: props.currentValue,
|
name: props.currentValue,
|
||||||
itemStyle: { color: computedColor }
|
itemStyle: { color: computedColor }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: props.data[1],
|
value: props.data[1],
|
||||||
name: props.previousValue,
|
name: props.previousValue,
|
||||||
itemStyle: { color: '#e6e8f7' }
|
itemStyle: { color: '#e6e8f7' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,89 +1,89 @@
|
|||||||
<!-- 图片卡片 -->
|
<!-- 图片卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full c-p" @click="handleClick">
|
<div class="w-full c-p" @click="handleClick">
|
||||||
<div class="art-card overflow-hidden">
|
<div class="art-card overflow-hidden">
|
||||||
<div class="relative w-full aspect-[16/10] overflow-hidden">
|
<div class="relative w-full aspect-[16/10] overflow-hidden">
|
||||||
<ElImage
|
<ElImage
|
||||||
:src="props.imageUrl"
|
:src="props.imageUrl"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
|
||||||
>
|
>
|
||||||
<template #placeholder>
|
<template #placeholder>
|
||||||
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
|
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
|
||||||
<ElIcon><Picture /></ElIcon>
|
<ElIcon><Picture /></ElIcon>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElImage>
|
</ElImage>
|
||||||
<div
|
<div
|
||||||
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
|
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
|
||||||
v-if="props.readTime"
|
v-if="props.readTime"
|
||||||
>
|
>
|
||||||
{{ props.readTime }} 阅读
|
{{ props.readTime }} 阅读
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div
|
<div
|
||||||
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
|
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
|
||||||
v-if="props.category"
|
v-if="props.category"
|
||||||
>
|
>
|
||||||
{{ props.category }}
|
{{ props.category }}
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
|
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
|
||||||
<div class="flex-c gap-4 text-xs text-g-600">
|
<div class="flex-c gap-4 text-xs text-g-600">
|
||||||
<span class="flex-c gap-1" v-if="props.views">
|
<span class="flex-c gap-1" v-if="props.views">
|
||||||
<ElIcon class="text-base"><View /></ElIcon>
|
<ElIcon class="text-base"><View /></ElIcon>
|
||||||
{{ props.views }}
|
{{ props.views }}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex-c gap-1" v-if="props.comments">
|
<span class="flex-c gap-1" v-if="props.comments">
|
||||||
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
|
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
|
||||||
{{ props.comments }}
|
{{ props.comments }}
|
||||||
</span>
|
</span>
|
||||||
<span>{{ props.date }}</span>
|
<span>{{ props.date }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
|
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtImageCard' })
|
defineOptions({ name: 'ArtImageCard' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 图片地址 */
|
/** 图片地址 */
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 分类 */
|
/** 分类 */
|
||||||
category?: string
|
category?: string
|
||||||
/** 阅读时间 */
|
/** 阅读时间 */
|
||||||
readTime?: string
|
readTime?: string
|
||||||
/** 浏览量 */
|
/** 浏览量 */
|
||||||
views?: number
|
views?: number
|
||||||
/** 评论数 */
|
/** 评论数 */
|
||||||
comments?: number
|
comments?: number
|
||||||
/** 日期 */
|
/** 日期 */
|
||||||
date?: string
|
date?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
title: '',
|
title: '',
|
||||||
category: '',
|
category: '',
|
||||||
readTime: '',
|
readTime: '',
|
||||||
views: 0,
|
views: 0,
|
||||||
comments: 0,
|
comments: 0,
|
||||||
date: ''
|
date: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click', card: Props): void
|
(e: 'click', card: Props): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
emit('click', props)
|
emit('click', props)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,126 +1,130 @@
|
|||||||
<!-- 折线图卡片 -->
|
<!-- 折线图卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
|
||||||
<div class="mb-2.5 flex-b items-start p-5">
|
<div class="mb-2.5 flex-b items-start p-5">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-medium leading-none">
|
<p class="text-2xl font-medium leading-none">
|
||||||
{{ value }}
|
{{ value }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
|
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-sm font-medium"
|
class="text-sm font-medium"
|
||||||
:class="[
|
:class="[
|
||||||
percentage > 0 ? 'text-success' : 'text-danger',
|
percentage > 0 ? 'text-success' : 'text-danger',
|
||||||
isMiniChart ? 'absolute bottom-5' : ''
|
isMiniChart ? 'absolute bottom-5' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
|
||||||
</div>
|
</div>
|
||||||
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
|
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
|
||||||
{{ date }}
|
{{ date }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="absolute bottom-0 left-0 right-0 box-border w-full"
|
class="absolute bottom-0 left-0 right-0 box-border w-full"
|
||||||
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
|
:class="
|
||||||
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''
|
||||||
></div>
|
"
|
||||||
</div>
|
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtLineChartCard' })
|
defineOptions({ name: 'ArtLineChartCard' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 数值 */
|
/** 数值 */
|
||||||
value: number
|
value: number
|
||||||
/** 标签 */
|
/** 标签 */
|
||||||
label: string
|
label: string
|
||||||
/** 百分比 */
|
/** 百分比 */
|
||||||
percentage: number
|
percentage: number
|
||||||
/** 日期 */
|
/** 日期 */
|
||||||
date?: string
|
date?: string
|
||||||
/** 高度 */
|
/** 高度 */
|
||||||
height?: number
|
height?: number
|
||||||
/** 颜色 */
|
/** 颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 是否显示区域颜色 */
|
/** 是否显示区域颜色 */
|
||||||
showAreaColor?: boolean
|
showAreaColor?: boolean
|
||||||
/** 图表数据 */
|
/** 图表数据 */
|
||||||
chartData: number[]
|
chartData: number[]
|
||||||
/** 是否为迷你图表 */
|
/** 是否为迷你图表 */
|
||||||
isMiniChart?: boolean
|
isMiniChart?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 11
|
height: 11
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const { chartRef } = useChartComponent({
|
const { chartRef } = useChartComponent({
|
||||||
props: {
|
props: {
|
||||||
height: `${props.height}rem`,
|
height: `${props.height}rem`,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
|
||||||
},
|
},
|
||||||
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
|
||||||
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
|
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
const computedColor = props.color || useChartOps().themeColor
|
const computedColor = props.color || useChartOps().themeColor
|
||||||
|
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0
|
left: 0
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
show: false,
|
show: false,
|
||||||
boundaryGap: false
|
boundaryGap: false
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
show: false
|
show: false
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
data: props.chartData,
|
data: props.chartData,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: 3,
|
width: 3,
|
||||||
color: computedColor
|
color: computedColor
|
||||||
},
|
},
|
||||||
areaStyle: props.showAreaColor
|
areaStyle: props.showAreaColor
|
||||||
? {
|
? {
|
||||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: props.color
|
color: props.color
|
||||||
? hexToRgba(props.color, 0.2).rgba
|
? hexToRgba(props.color, 0.2).rgba
|
||||||
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
|
: hexToRgba(getCssVar('--el-color-primary'), 0.2)
|
||||||
},
|
.rgba
|
||||||
{
|
},
|
||||||
offset: 1,
|
{
|
||||||
color: props.color
|
offset: 1,
|
||||||
? hexToRgba(props.color, 0.01).rgba
|
color: props.color
|
||||||
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
|
? hexToRgba(props.color, 0.01).rgba
|
||||||
}
|
: hexToRgba(getCssVar('--el-color-primary'), 0.01)
|
||||||
])
|
.rgba
|
||||||
}
|
}
|
||||||
: undefined
|
])
|
||||||
}
|
}
|
||||||
]
|
: undefined
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,86 +1,89 @@
|
|||||||
<!-- 进度条卡片 -->
|
<!-- 进度条卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card h-32 flex flex-col justify-center px-5">
|
<div class="art-card h-32 flex flex-col justify-center px-5">
|
||||||
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
|
<div
|
||||||
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
|
class="mb-3.5 flex-c"
|
||||||
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
|
:style="{ justifyContent: icon ? 'space-between' : 'flex-start' }"
|
||||||
</div>
|
>
|
||||||
<div>
|
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
|
||||||
<ArtCountTo
|
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
|
||||||
class="mb-1 block text-2xl font-semibold"
|
</div>
|
||||||
:target="percentage"
|
<div>
|
||||||
:duration="2000"
|
<ArtCountTo
|
||||||
suffix="%"
|
class="mb-1 block text-2xl font-semibold"
|
||||||
:style="{ textAlign: icon ? 'right' : 'left' }"
|
:target="percentage"
|
||||||
/>
|
:duration="2000"
|
||||||
<p class="text-sm text-g-500">{{ title }}</p>
|
suffix="%"
|
||||||
</div>
|
:style="{ textAlign: icon ? 'right' : 'left' }"
|
||||||
</div>
|
/>
|
||||||
<ElProgress
|
<p class="text-sm text-g-500">{{ title }}</p>
|
||||||
:percentage="currentPercentage"
|
</div>
|
||||||
:stroke-width="strokeWidth"
|
</div>
|
||||||
:show-text="false"
|
<ElProgress
|
||||||
:color="color"
|
:percentage="currentPercentage"
|
||||||
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
|
:stroke-width="strokeWidth"
|
||||||
/>
|
:show-text="false"
|
||||||
</div>
|
:color="color"
|
||||||
|
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtProgressCard' })
|
defineOptions({ name: 'ArtProgressCard' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 进度百分比 */
|
/** 进度百分比 */
|
||||||
percentage: number
|
percentage: number
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 颜色 */
|
/** 颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 图标 */
|
/** 图标 */
|
||||||
icon?: string
|
icon?: string
|
||||||
/** 图标样式 */
|
/** 图标样式 */
|
||||||
iconStyle?: string
|
iconStyle?: string
|
||||||
/** 进度条宽度 */
|
/** 进度条宽度 */
|
||||||
strokeWidth?: number
|
strokeWidth?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
strokeWidth: 5,
|
strokeWidth: 5,
|
||||||
color: '#67C23A'
|
color: '#67C23A'
|
||||||
})
|
})
|
||||||
|
|
||||||
const animationDuration = 500
|
const animationDuration = 500
|
||||||
const currentPercentage = ref(0)
|
const currentPercentage = ref(0)
|
||||||
|
|
||||||
const animateProgress = () => {
|
const animateProgress = () => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
const startValue = currentPercentage.value
|
const startValue = currentPercentage.value
|
||||||
const endValue = props.percentage
|
const endValue = props.percentage
|
||||||
|
|
||||||
const animate = () => {
|
const animate = () => {
|
||||||
const currentTime = Date.now()
|
const currentTime = Date.now()
|
||||||
const elapsed = currentTime - startTime
|
const elapsed = currentTime - startTime
|
||||||
const progress = Math.min(elapsed / animationDuration, 1)
|
const progress = Math.min(elapsed / animationDuration, 1)
|
||||||
|
|
||||||
currentPercentage.value = startValue + (endValue - startValue) * progress
|
currentPercentage.value = startValue + (endValue - startValue) * progress
|
||||||
|
|
||||||
if (progress < 1) {
|
if (progress < 1) {
|
||||||
requestAnimationFrame(animate)
|
requestAnimationFrame(animate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(animate)
|
requestAnimationFrame(animate)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
animateProgress()
|
animateProgress()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 当 percentage 属性变化时重新执行动画
|
// 当 percentage 属性变化时重新执行动画
|
||||||
watch(
|
watch(
|
||||||
() => props.percentage,
|
() => props.percentage,
|
||||||
() => {
|
() => {
|
||||||
animateProgress()
|
animateProgress()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,67 +1,71 @@
|
|||||||
<!-- 统计卡片 -->
|
<!-- 统计卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
|
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
|
||||||
:class="boxStyle"
|
:class="boxStyle"
|
||||||
>
|
>
|
||||||
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle">
|
<div
|
||||||
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
|
v-if="icon"
|
||||||
</div>
|
class="mr-4 size-11 flex-cc rounded-lg text-xl text-white"
|
||||||
<div class="flex-1">
|
:class="iconStyle"
|
||||||
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
|
>
|
||||||
{{ title }}
|
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
|
||||||
</p>
|
</div>
|
||||||
<ArtCountTo
|
<div class="flex-1">
|
||||||
class="m-0 text-2xl font-medium"
|
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
|
||||||
v-if="count !== undefined"
|
{{ title }}
|
||||||
:target="count"
|
</p>
|
||||||
:duration="2000"
|
<ArtCountTo
|
||||||
:decimals="decimals"
|
class="m-0 text-2xl font-medium"
|
||||||
:separator="separator"
|
v-if="count !== undefined"
|
||||||
/>
|
:target="count"
|
||||||
<p
|
:duration="2000"
|
||||||
class="mt-1 text-sm text-g-500 opacity-90"
|
:decimals="decimals"
|
||||||
:style="{ color: textColor }"
|
:separator="separator"
|
||||||
v-if="description"
|
/>
|
||||||
>{{ description }}</p
|
<p
|
||||||
>
|
class="mt-1 text-sm text-g-500 opacity-90"
|
||||||
</div>
|
:style="{ color: textColor }"
|
||||||
<div v-if="showArrow">
|
v-if="description"
|
||||||
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
|
>{{ description }}</p
|
||||||
</div>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showArrow">
|
||||||
|
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtStatsCard' })
|
defineOptions({ name: 'ArtStatsCard' })
|
||||||
|
|
||||||
interface StatsCardProps {
|
interface StatsCardProps {
|
||||||
/** 盒子样式 */
|
/** 盒子样式 */
|
||||||
boxStyle?: string
|
boxStyle?: string
|
||||||
/** 图标 */
|
/** 图标 */
|
||||||
icon?: string
|
icon?: string
|
||||||
/** 图标样式 */
|
/** 图标样式 */
|
||||||
iconStyle?: string
|
iconStyle?: string
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title?: string
|
title?: string
|
||||||
/** 数值 */
|
/** 数值 */
|
||||||
count?: number
|
count?: number
|
||||||
/** 小数位 */
|
/** 小数位 */
|
||||||
decimals?: number
|
decimals?: number
|
||||||
/** 分隔符 */
|
/** 分隔符 */
|
||||||
separator?: string
|
separator?: string
|
||||||
/** 描述 */
|
/** 描述 */
|
||||||
description: string
|
description: string
|
||||||
/** 文本颜色 */
|
/** 文本颜色 */
|
||||||
textColor?: string
|
textColor?: string
|
||||||
/** 是否显示箭头 */
|
/** 是否显示箭头 */
|
||||||
showArrow?: boolean
|
showArrow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<StatsCardProps>(), {
|
withDefaults(defineProps<StatsCardProps>(), {
|
||||||
iconSize: 30,
|
iconSize: 30,
|
||||||
iconBgRadius: 50,
|
iconBgRadius: 50,
|
||||||
decimals: 0,
|
decimals: 0,
|
||||||
separator: ','
|
separator: ','
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,69 +1,71 @@
|
|||||||
<!-- 时间轴列表卡片 -->
|
<!-- 时间轴列表卡片 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-card p-5">
|
<div class="art-card p-5">
|
||||||
<div class="pb-3.5">
|
<div class="pb-3.5">
|
||||||
<p class="text-lg font-medium">{{ title }}</p>
|
<p class="text-lg font-medium">{{ title }}</p>
|
||||||
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
<p class="text-sm text-g-600">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
<ElScrollbar :style="{ height: maxHeight }">
|
<ElScrollbar :style="{ height: maxHeight }">
|
||||||
<ElTimeline class="!pl-0.5">
|
<ElTimeline class="!pl-0.5">
|
||||||
<ElTimelineItem
|
<ElTimelineItem
|
||||||
v-for="item in list"
|
v-for="item in list"
|
||||||
:key="item.time"
|
:key="item.time"
|
||||||
:timestamp="item.time"
|
:timestamp="item.time"
|
||||||
:placement="TIMELINE_PLACEMENT"
|
:placement="TIMELINE_PLACEMENT"
|
||||||
:color="item.status"
|
:color="item.status"
|
||||||
:center="true"
|
:center="true"
|
||||||
>
|
>
|
||||||
<div class="flex-c gap-3">
|
<div class="flex-c gap-3">
|
||||||
<div class="flex-c gap-2">
|
<div class="flex-c gap-2">
|
||||||
<span class="text-sm">{{ item.content }}</span>
|
<span class="text-sm">{{ item.content }}</span>
|
||||||
<span v-if="item.code" class="text-sm text-theme"> #{{ item.code }} </span>
|
<span v-if="item.code" class="text-sm text-theme">
|
||||||
</div>
|
#{{ item.code }}
|
||||||
</div>
|
</span>
|
||||||
</ElTimelineItem>
|
</div>
|
||||||
</ElTimeline>
|
</div>
|
||||||
</ElScrollbar>
|
</ElTimelineItem>
|
||||||
</div>
|
</ElTimeline>
|
||||||
|
</ElScrollbar>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtTimelineListCard' })
|
defineOptions({ name: 'ArtTimelineListCard' })
|
||||||
|
|
||||||
// 常量配置
|
// 常量配置
|
||||||
const ITEM_HEIGHT = 65
|
const ITEM_HEIGHT = 65
|
||||||
const TIMELINE_PLACEMENT = 'top'
|
const TIMELINE_PLACEMENT = 'top'
|
||||||
const DEFAULT_MAX_COUNT = 5
|
const DEFAULT_MAX_COUNT = 5
|
||||||
|
|
||||||
interface TimelineItem {
|
interface TimelineItem {
|
||||||
/** 时间 */
|
/** 时间 */
|
||||||
time: string
|
time: string
|
||||||
/** 状态颜色 */
|
/** 状态颜色 */
|
||||||
status: string
|
status: string
|
||||||
/** 内容 */
|
/** 内容 */
|
||||||
content: string
|
content: string
|
||||||
/** 代码标识 */
|
/** 代码标识 */
|
||||||
code?: string
|
code?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 时间轴列表数据 */
|
/** 时间轴列表数据 */
|
||||||
list: TimelineItem[]
|
list: TimelineItem[]
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 副标题 */
|
/** 副标题 */
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
/** 最大显示数量 */
|
/** 最大显示数量 */
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// Props 定义和验证
|
// Props 定义和验证
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: '',
|
title: '',
|
||||||
subtitle: '',
|
subtitle: '',
|
||||||
maxCount: DEFAULT_MAX_COUNT
|
maxCount: DEFAULT_MAX_COUNT
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算最大高度
|
// 计算最大高度
|
||||||
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,203 +1,209 @@
|
|||||||
<!-- 柱状图 -->
|
<!-- 柱状图 -->
|
||||||
<template>
|
<template>
|
||||||
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
|
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import { getCssVar } from '@/utils/ui'
|
import { getCssVar } from '@/utils/ui'
|
||||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtBarChart' })
|
defineOptions({ name: 'ArtBarChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||||
xAxisData: () => [],
|
xAxisData: () => [],
|
||||||
barWidth: '40%',
|
barWidth: '40%',
|
||||||
stack: false,
|
stack: false,
|
||||||
|
|
||||||
// 轴线显示配置
|
// 轴线显示配置
|
||||||
showAxisLabel: true,
|
showAxisLabel: true,
|
||||||
showAxisLine: true,
|
showAxisLine: true,
|
||||||
showSplitLine: true,
|
showSplitLine: true,
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'bottom'
|
legendPosition: 'bottom'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否为多数据
|
// 判断是否为多数据
|
||||||
const isMultipleData = computed(() => {
|
const isMultipleData = computed(() => {
|
||||||
return (
|
return (
|
||||||
Array.isArray(props.data) &&
|
Array.isArray(props.data) &&
|
||||||
props.data.length > 0 &&
|
props.data.length > 0 &&
|
||||||
typeof props.data[0] === 'object' &&
|
typeof props.data[0] === 'object' &&
|
||||||
'name' in props.data[0]
|
'name' in props.data[0]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取颜色配置
|
// 获取颜色配置
|
||||||
const getColor = (customColor?: string, index?: number) => {
|
const getColor = (customColor?: string, index?: number) => {
|
||||||
if (customColor) return customColor
|
if (customColor) return customColor
|
||||||
|
|
||||||
if (index !== undefined) {
|
if (index !== undefined) {
|
||||||
return props.colors![index % props.colors!.length]
|
return props.colors![index % props.colors!.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认渐变色
|
// 默认渐变色
|
||||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: getCssVar('--el-color-primary-light-4')
|
color: getCssVar('--el-color-primary-light-4')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: getCssVar('--el-color-primary')
|
color: getCssVar('--el-color-primary')
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建渐变色
|
// 创建渐变色
|
||||||
const createGradientColor = (color: string) => {
|
const createGradientColor = (color: string) => {
|
||||||
return new graphic.LinearGradient(0, 0, 0, 1, [
|
return new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: color
|
color: color
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: color
|
color: color
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取基础样式配置
|
// 获取基础样式配置
|
||||||
const getBaseItemStyle = (
|
const getBaseItemStyle = (
|
||||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||||
) => ({
|
) => ({
|
||||||
borderRadius: props.borderRadius,
|
borderRadius: props.borderRadius,
|
||||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建系列配置
|
// 创建系列配置
|
||||||
const createSeriesItem = (config: {
|
const createSeriesItem = (config: {
|
||||||
name?: string
|
name?: string
|
||||||
data: number[]
|
data: number[]
|
||||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||||
barWidth?: string | number
|
barWidth?: string | number
|
||||||
stack?: string
|
stack?: string
|
||||||
}) => {
|
}) => {
|
||||||
const animationConfig = getAnimationConfig()
|
const animationConfig = getAnimationConfig()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: config.name,
|
name: config.name,
|
||||||
data: config.data,
|
data: config.data,
|
||||||
type: 'bar' as const,
|
type: 'bar' as const,
|
||||||
stack: config.stack,
|
stack: config.stack,
|
||||||
itemStyle: getBaseItemStyle(config.color),
|
itemStyle: getBaseItemStyle(config.color),
|
||||||
barWidth: config.barWidth || props.barWidth,
|
barWidth: config.barWidth || props.barWidth,
|
||||||
...animationConfig
|
...animationConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const {
|
const {
|
||||||
chartRef,
|
chartRef,
|
||||||
getAxisLineStyle,
|
getAxisLineStyle,
|
||||||
getAxisLabelStyle,
|
getAxisLabelStyle,
|
||||||
getAxisTickStyle,
|
getAxisTickStyle,
|
||||||
getSplitLineStyle,
|
getSplitLineStyle,
|
||||||
getAnimationConfig,
|
getAnimationConfig,
|
||||||
getTooltipStyle,
|
getTooltipStyle,
|
||||||
getLegendStyle,
|
getLegendStyle,
|
||||||
getGridWithLegend
|
getGridWithLegend
|
||||||
} = useChartComponent({
|
} = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
// 检查单数据情况
|
// 检查单数据情况
|
||||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
return !singleData.length || singleData.every((val) => val === 0)
|
return !singleData.length || singleData.every((val) => val === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查多数据情况
|
// 检查多数据情况
|
||||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||||
const multiData = props.data as BarDataItem[]
|
const multiData = props.data as BarDataItem[]
|
||||||
return (
|
return (
|
||||||
!multiData.length ||
|
!multiData.length ||
|
||||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
multiData.every(
|
||||||
)
|
(item) => !item.data?.length || item.data.every((val) => val === 0)
|
||||||
}
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
const options: EChartsOption = {
|
const options: EChartsOption = {
|
||||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
grid: getGridWithLegend(
|
||||||
top: 15,
|
props.showLegend && isMultipleData.value,
|
||||||
right: 0,
|
props.legendPosition,
|
||||||
left: 0
|
{
|
||||||
}),
|
top: 15,
|
||||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
right: 0,
|
||||||
xAxis: {
|
left: 0
|
||||||
type: 'category',
|
}
|
||||||
data: props.xAxisData,
|
),
|
||||||
axisTick: getAxisTickStyle(),
|
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
xAxis: {
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
type: 'category',
|
||||||
},
|
data: props.xAxisData,
|
||||||
yAxis: {
|
axisTick: getAxisTickStyle(),
|
||||||
type: 'value',
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
},
|
||||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
yAxis: {
|
||||||
}
|
type: 'value',
|
||||||
}
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
|
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加图例配置
|
// 添加图例配置
|
||||||
if (props.showLegend && isMultipleData.value) {
|
if (props.showLegend && isMultipleData.value) {
|
||||||
options.legend = getLegendStyle(props.legendPosition)
|
options.legend = getLegendStyle(props.legendPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成系列数据
|
// 生成系列数据
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
const multiData = props.data as BarDataItem[]
|
const multiData = props.data as BarDataItem[]
|
||||||
options.series = multiData.map((item, index) => {
|
options.series = multiData.map((item, index) => {
|
||||||
const computedColor = getColor(props.colors[index], index)
|
const computedColor = getColor(props.colors[index], index)
|
||||||
|
|
||||||
return createSeriesItem({
|
return createSeriesItem({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
data: item.data,
|
data: item.data,
|
||||||
color: computedColor,
|
color: computedColor,
|
||||||
barWidth: item.barWidth,
|
barWidth: item.barWidth,
|
||||||
stack: props.stack ? item.stack || 'total' : undefined
|
stack: props.stack ? item.stack || 'total' : undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 单数据情况
|
// 单数据情况
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
const computedColor = getColor()
|
const computedColor = getColor()
|
||||||
|
|
||||||
options.series = [
|
options.series = [
|
||||||
createSeriesItem({
|
createSeriesItem({
|
||||||
data: singleData,
|
data: singleData,
|
||||||
color: computedColor
|
color: computedColor
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,195 +1,195 @@
|
|||||||
<!-- 双向堆叠柱状图 -->
|
<!-- 双向堆叠柱状图 -->
|
||||||
<template>
|
<template>
|
||||||
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
|
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
|
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
|
||||||
import type { BidirectionalBarChartProps } from '@/types/component/chart'
|
import type { BidirectionalBarChartProps } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtDualBarCompareChart' })
|
defineOptions({ name: 'ArtDualBarCompareChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
|
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
positiveData: () => [],
|
positiveData: () => [],
|
||||||
negativeData: () => [],
|
negativeData: () => [],
|
||||||
xAxisData: () => [],
|
xAxisData: () => [],
|
||||||
positiveName: '正向数据',
|
positiveName: '正向数据',
|
||||||
negativeName: '负向数据',
|
negativeName: '负向数据',
|
||||||
barWidth: 16,
|
barWidth: 16,
|
||||||
yAxisMin: -100,
|
yAxisMin: -100,
|
||||||
yAxisMax: 100,
|
yAxisMax: 100,
|
||||||
|
|
||||||
// 样式配置
|
// 样式配置
|
||||||
showDataLabel: false,
|
showDataLabel: false,
|
||||||
positiveBorderRadius: () => [10, 10, 0, 0],
|
positiveBorderRadius: () => [10, 10, 0, 0],
|
||||||
negativeBorderRadius: () => [0, 0, 10, 10],
|
negativeBorderRadius: () => [0, 0, 10, 10],
|
||||||
|
|
||||||
// 轴线显示配置
|
// 轴线显示配置
|
||||||
showAxisLabel: true,
|
showAxisLabel: true,
|
||||||
showAxisLine: false,
|
showAxisLine: false,
|
||||||
showSplitLine: false,
|
showSplitLine: false,
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'bottom'
|
legendPosition: 'bottom'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建系列配置的辅助函数
|
// 创建系列配置的辅助函数
|
||||||
const createSeriesConfig = (config: {
|
const createSeriesConfig = (config: {
|
||||||
name: string
|
name: string
|
||||||
data: number[]
|
data: number[]
|
||||||
borderRadius: number | number[]
|
borderRadius: number | number[]
|
||||||
labelPosition: 'top' | 'bottom'
|
labelPosition: 'top' | 'bottom'
|
||||||
colorIndex: number
|
colorIndex: number
|
||||||
formatter?: (params: unknown) => string
|
formatter?: (params: unknown) => string
|
||||||
}): BarSeriesOption => {
|
}): BarSeriesOption => {
|
||||||
const { fontColor } = useChartOps()
|
const { fontColor } = useChartOps()
|
||||||
const animationConfig = getAnimationConfig()
|
const animationConfig = getAnimationConfig()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: config.name,
|
name: config.name,
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
stack: 'total',
|
stack: 'total',
|
||||||
barWidth: props.barWidth,
|
barWidth: props.barWidth,
|
||||||
barGap: '-100%',
|
barGap: '-100%',
|
||||||
data: config.data,
|
data: config.data,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: config.borderRadius,
|
borderRadius: config.borderRadius,
|
||||||
color: props.colors[config.colorIndex]
|
color: props.colors[config.colorIndex]
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
show: props.showDataLabel,
|
show: props.showDataLabel,
|
||||||
position: config.labelPosition,
|
position: config.labelPosition,
|
||||||
formatter:
|
formatter:
|
||||||
config.formatter ||
|
config.formatter ||
|
||||||
((params: unknown) => String((params as Record<string, unknown>).value)),
|
((params: unknown) => String((params as Record<string, unknown>).value)),
|
||||||
color: fontColor,
|
color: fontColor,
|
||||||
fontSize: 12
|
fontSize: 12
|
||||||
},
|
},
|
||||||
...animationConfig
|
...animationConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用图表组件抽象
|
// 使用图表组件抽象
|
||||||
const {
|
const {
|
||||||
chartRef,
|
chartRef,
|
||||||
getAxisLineStyle,
|
getAxisLineStyle,
|
||||||
getAxisLabelStyle,
|
getAxisLabelStyle,
|
||||||
getAxisTickStyle,
|
getAxisTickStyle,
|
||||||
getSplitLineStyle,
|
getSplitLineStyle,
|
||||||
getAnimationConfig,
|
getAnimationConfig,
|
||||||
getTooltipStyle,
|
getTooltipStyle,
|
||||||
getLegendStyle,
|
getLegendStyle,
|
||||||
getGridWithLegend
|
getGridWithLegend
|
||||||
} = useChartComponent({
|
} = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
return (
|
return (
|
||||||
props.isEmpty ||
|
props.isEmpty ||
|
||||||
!props.positiveData.length ||
|
!props.positiveData.length ||
|
||||||
!props.negativeData.length ||
|
!props.negativeData.length ||
|
||||||
(props.positiveData.every((val) => val === 0) &&
|
(props.positiveData.every((val) => val === 0) &&
|
||||||
props.negativeData.every((val) => val === 0))
|
props.negativeData.every((val) => val === 0))
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
watchSources: [
|
watchSources: [
|
||||||
() => props.positiveData,
|
() => props.positiveData,
|
||||||
() => props.negativeData,
|
() => props.negativeData,
|
||||||
() => props.xAxisData,
|
() => props.xAxisData,
|
||||||
() => props.colors
|
() => props.colors
|
||||||
],
|
],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
// 处理负向数据,确保为负值
|
// 处理负向数据,确保为负值
|
||||||
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
|
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
|
||||||
|
|
||||||
// 优化的Grid配置
|
// 优化的Grid配置
|
||||||
const gridConfig = {
|
const gridConfig = {
|
||||||
top: props.showLegend ? 50 : 20,
|
top: props.showLegend ? 50 : 20,
|
||||||
right: 0,
|
right: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
bottom: 0, // 增加底部间距
|
bottom: 0, // 增加底部间距
|
||||||
containLabel: true
|
containLabel: true
|
||||||
}
|
}
|
||||||
|
|
||||||
const options: EChartsOption = {
|
const options: EChartsOption = {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
animation: true,
|
animation: true,
|
||||||
animationDuration: 1000,
|
animationDuration: 1000,
|
||||||
animationEasing: 'cubicOut',
|
animationEasing: 'cubicOut',
|
||||||
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
|
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
|
||||||
|
|
||||||
// 优化的提示框配置
|
// 优化的提示框配置
|
||||||
tooltip: props.showTooltip
|
tooltip: props.showTooltip
|
||||||
? {
|
? {
|
||||||
...getTooltipStyle(),
|
...getTooltipStyle(),
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'none' // 去除指示线
|
type: 'none' // 去除指示线
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
// 图例配置
|
// 图例配置
|
||||||
legend: props.showLegend
|
legend: props.showLegend
|
||||||
? {
|
? {
|
||||||
...getLegendStyle(props.legendPosition),
|
...getLegendStyle(props.legendPosition),
|
||||||
data: [props.negativeName, props.positiveName]
|
data: [props.negativeName, props.positiveName]
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
// X轴配置
|
// X轴配置
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: props.xAxisData,
|
data: props.xAxisData,
|
||||||
axisTick: getAxisTickStyle(),
|
axisTick: getAxisTickStyle(),
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
boundaryGap: true
|
boundaryGap: true
|
||||||
},
|
},
|
||||||
|
|
||||||
// Y轴配置
|
// Y轴配置
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
min: props.yAxisMin,
|
min: props.yAxisMin,
|
||||||
max: props.yAxisMax,
|
max: props.yAxisMax,
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 系列配置
|
// 系列配置
|
||||||
series: [
|
series: [
|
||||||
// 负向数据系列
|
// 负向数据系列
|
||||||
createSeriesConfig({
|
createSeriesConfig({
|
||||||
name: props.negativeName,
|
name: props.negativeName,
|
||||||
data: processedNegativeData,
|
data: processedNegativeData,
|
||||||
borderRadius: props.negativeBorderRadius,
|
borderRadius: props.negativeBorderRadius,
|
||||||
labelPosition: 'bottom',
|
labelPosition: 'bottom',
|
||||||
colorIndex: 1,
|
colorIndex: 1,
|
||||||
formatter: (params: unknown) =>
|
formatter: (params: unknown) =>
|
||||||
String(Math.abs((params as Record<string, unknown>).value as number))
|
String(Math.abs((params as Record<string, unknown>).value as number))
|
||||||
}),
|
}),
|
||||||
// 正向数据系列
|
// 正向数据系列
|
||||||
createSeriesConfig({
|
createSeriesConfig({
|
||||||
name: props.positiveName,
|
name: props.positiveName,
|
||||||
data: props.positiveData,
|
data: props.positiveData,
|
||||||
borderRadius: props.positiveBorderRadius,
|
borderRadius: props.positiveBorderRadius,
|
||||||
labelPosition: 'top',
|
labelPosition: 'top',
|
||||||
colorIndex: 0
|
colorIndex: 0
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,208 +1,214 @@
|
|||||||
<!-- 水平柱状图 -->
|
<!-- 水平柱状图 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
:style="{ height: props.height }"
|
:style="{ height: props.height }"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import { getCssVar } from '@/utils/ui'
|
import { getCssVar } from '@/utils/ui'
|
||||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||||
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtHBarChart' })
|
defineOptions({ name: 'ArtHBarChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<BarChartProps>(), {
|
const props = withDefaults(defineProps<BarChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||||
xAxisData: () => [],
|
xAxisData: () => [],
|
||||||
barWidth: '36%',
|
barWidth: '36%',
|
||||||
stack: false,
|
stack: false,
|
||||||
|
|
||||||
// 轴线显示配置
|
// 轴线显示配置
|
||||||
showAxisLabel: true,
|
showAxisLabel: true,
|
||||||
showAxisLine: true,
|
showAxisLine: true,
|
||||||
showSplitLine: true,
|
showSplitLine: true,
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'bottom'
|
legendPosition: 'bottom'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断是否为多数据
|
// 判断是否为多数据
|
||||||
const isMultipleData = computed(() => {
|
const isMultipleData = computed(() => {
|
||||||
return (
|
return (
|
||||||
Array.isArray(props.data) &&
|
Array.isArray(props.data) &&
|
||||||
props.data.length > 0 &&
|
props.data.length > 0 &&
|
||||||
typeof props.data[0] === 'object' &&
|
typeof props.data[0] === 'object' &&
|
||||||
'name' in props.data[0]
|
'name' in props.data[0]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取颜色配置
|
// 获取颜色配置
|
||||||
const getColor = (customColor?: string, index?: number) => {
|
const getColor = (customColor?: string, index?: number) => {
|
||||||
if (customColor) return customColor
|
if (customColor) return customColor
|
||||||
|
|
||||||
if (index !== undefined) {
|
if (index !== undefined) {
|
||||||
return props.colors![index % props.colors!.length]
|
return props.colors![index % props.colors!.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认渐变色
|
// 默认渐变色
|
||||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: getCssVar('--el-color-primary')
|
color: getCssVar('--el-color-primary')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: getCssVar('--el-color-primary-light-4')
|
color: getCssVar('--el-color-primary-light-4')
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建渐变色
|
// 创建渐变色
|
||||||
const createGradientColor = (color: string) => {
|
const createGradientColor = (color: string) => {
|
||||||
return new graphic.LinearGradient(0, 0, 1, 0, [
|
return new graphic.LinearGradient(0, 0, 1, 0, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: color
|
color: color
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: color
|
color: color
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取基础样式配置
|
// 获取基础样式配置
|
||||||
const getBaseItemStyle = (
|
const getBaseItemStyle = (
|
||||||
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
|
||||||
) => ({
|
) => ({
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
color: typeof color === 'string' ? createGradientColor(color) : color
|
color: typeof color === 'string' ? createGradientColor(color) : color
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建系列配置
|
// 创建系列配置
|
||||||
const createSeriesItem = (config: {
|
const createSeriesItem = (config: {
|
||||||
name?: string
|
name?: string
|
||||||
data: number[]
|
data: number[]
|
||||||
color?: string | InstanceType<typeof graphic.LinearGradient>
|
color?: string | InstanceType<typeof graphic.LinearGradient>
|
||||||
barWidth?: string | number
|
barWidth?: string | number
|
||||||
stack?: string
|
stack?: string
|
||||||
}) => {
|
}) => {
|
||||||
const animationConfig = getAnimationConfig()
|
const animationConfig = getAnimationConfig()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: config.name,
|
name: config.name,
|
||||||
data: config.data,
|
data: config.data,
|
||||||
type: 'bar' as const,
|
type: 'bar' as const,
|
||||||
stack: config.stack,
|
stack: config.stack,
|
||||||
itemStyle: getBaseItemStyle(config.color),
|
itemStyle: getBaseItemStyle(config.color),
|
||||||
barWidth: config.barWidth || props.barWidth,
|
barWidth: config.barWidth || props.barWidth,
|
||||||
...animationConfig
|
...animationConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const {
|
const {
|
||||||
chartRef,
|
chartRef,
|
||||||
getAxisLineStyle,
|
getAxisLineStyle,
|
||||||
getAxisLabelStyle,
|
getAxisLabelStyle,
|
||||||
getAxisTickStyle,
|
getAxisTickStyle,
|
||||||
getSplitLineStyle,
|
getSplitLineStyle,
|
||||||
getAnimationConfig,
|
getAnimationConfig,
|
||||||
getTooltipStyle,
|
getTooltipStyle,
|
||||||
getLegendStyle,
|
getLegendStyle,
|
||||||
getGridWithLegend
|
getGridWithLegend
|
||||||
} = useChartComponent({
|
} = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
// 检查单数据情况
|
// 检查单数据情况
|
||||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
return !singleData.length || singleData.every((val) => val === 0)
|
return !singleData.length || singleData.every((val) => val === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查多数据情况
|
// 检查多数据情况
|
||||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||||
const multiData = props.data as BarDataItem[]
|
const multiData = props.data as BarDataItem[]
|
||||||
return (
|
return (
|
||||||
!multiData.length ||
|
!multiData.length ||
|
||||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
multiData.every(
|
||||||
)
|
(item) => !item.data?.length || item.data.every((val) => val === 0)
|
||||||
}
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
const options: EChartsOption = {
|
const options: EChartsOption = {
|
||||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
grid: getGridWithLegend(
|
||||||
top: 15,
|
props.showLegend && isMultipleData.value,
|
||||||
right: 0,
|
props.legendPosition,
|
||||||
left: 0
|
{
|
||||||
}),
|
top: 15,
|
||||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
right: 0,
|
||||||
xAxis: {
|
left: 0
|
||||||
type: 'value',
|
}
|
||||||
axisTick: getAxisTickStyle(),
|
),
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
xAxis: {
|
||||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
type: 'value',
|
||||||
},
|
axisTick: getAxisTickStyle(),
|
||||||
yAxis: {
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
type: 'category',
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
data: props.xAxisData,
|
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||||
axisTick: getAxisTickStyle(),
|
},
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
yAxis: {
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine)
|
type: 'category',
|
||||||
}
|
data: props.xAxisData,
|
||||||
}
|
axisTick: getAxisTickStyle(),
|
||||||
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
|
axisLine: getAxisLineStyle(props.showAxisLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加图例配置
|
// 添加图例配置
|
||||||
if (props.showLegend && isMultipleData.value) {
|
if (props.showLegend && isMultipleData.value) {
|
||||||
options.legend = getLegendStyle(props.legendPosition)
|
options.legend = getLegendStyle(props.legendPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成系列数据
|
// 生成系列数据
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
const multiData = props.data as BarDataItem[]
|
const multiData = props.data as BarDataItem[]
|
||||||
options.series = multiData.map((item, index) => {
|
options.series = multiData.map((item, index) => {
|
||||||
const computedColor = getColor(props.colors[index], index)
|
const computedColor = getColor(props.colors[index], index)
|
||||||
|
|
||||||
return createSeriesItem({
|
return createSeriesItem({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
data: item.data,
|
data: item.data,
|
||||||
color: computedColor,
|
color: computedColor,
|
||||||
barWidth: item.barWidth,
|
barWidth: item.barWidth,
|
||||||
stack: props.stack ? item.stack || 'total' : undefined
|
stack: props.stack ? item.stack || 'total' : undefined
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 单数据情况
|
// 单数据情况
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
const computedColor = getColor()
|
const computedColor = getColor()
|
||||||
|
|
||||||
options.series = [
|
options.series = [
|
||||||
createSeriesItem({
|
createSeriesItem({
|
||||||
data: singleData,
|
data: singleData,
|
||||||
color: computedColor
|
color: computedColor
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,90 +1,91 @@
|
|||||||
<!-- k线图表 -->
|
<!-- k线图表 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
:style="{ height: props.height }"
|
:style="{ height: props.height }"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EChartsOption } from '@/plugins/echarts'
|
import type { EChartsOption } from '@/plugins/echarts'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import type { KLineChartProps } from '@/types/component/chart'
|
import type { KLineChartProps } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtKLineChart' })
|
defineOptions({ name: 'ArtKLineChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<KLineChartProps>(), {
|
const props = withDefaults(defineProps<KLineChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
data: () => [],
|
data: () => [],
|
||||||
showDataZoom: false,
|
showDataZoom: false,
|
||||||
dataZoomStart: 0,
|
dataZoomStart: 0,
|
||||||
dataZoomEnd: 100
|
dataZoomEnd: 100
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取实际使用的颜色
|
// 获取实际使用的颜色
|
||||||
const getActualColors = () => {
|
const getActualColors = () => {
|
||||||
const defaultUpColor = '#4C87F3'
|
const defaultUpColor = '#4C87F3'
|
||||||
const defaultDownColor = '#8BD8FC'
|
const defaultDownColor = '#8BD8FC'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
upColor: props.colors?.[0] || defaultUpColor,
|
upColor: props.colors?.[0] || defaultUpColor,
|
||||||
downColor: props.colors?.[1] || defaultDownColor
|
downColor: props.colors?.[1] || defaultDownColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const {
|
const {
|
||||||
chartRef,
|
chartRef,
|
||||||
getAxisLineStyle,
|
getAxisLineStyle,
|
||||||
getAxisLabelStyle,
|
getAxisLabelStyle,
|
||||||
getAxisTickStyle,
|
getAxisTickStyle,
|
||||||
getSplitLineStyle,
|
getSplitLineStyle,
|
||||||
getAnimationConfig,
|
getAnimationConfig,
|
||||||
getTooltipStyle
|
getTooltipStyle
|
||||||
} = useChartComponent({
|
} = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
return (
|
return (
|
||||||
!props.data?.length ||
|
!props.data?.length ||
|
||||||
props.data.every(
|
props.data.every(
|
||||||
(item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
|
(item) =>
|
||||||
)
|
item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
|
||||||
)
|
)
|
||||||
},
|
)
|
||||||
watchSources: [
|
},
|
||||||
() => props.data,
|
watchSources: [
|
||||||
() => props.colors,
|
() => props.data,
|
||||||
() => props.showDataZoom,
|
() => props.colors,
|
||||||
() => props.dataZoomStart,
|
() => props.showDataZoom,
|
||||||
() => props.dataZoomEnd
|
() => props.dataZoomStart,
|
||||||
],
|
() => props.dataZoomEnd
|
||||||
generateOptions: (): EChartsOption => {
|
],
|
||||||
const { upColor, downColor } = getActualColors()
|
generateOptions: (): EChartsOption => {
|
||||||
|
const { upColor, downColor } = getActualColors()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
top: 20,
|
top: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: props.showDataZoom ? 80 : 20,
|
bottom: props.showDataZoom ? 80 : 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
containLabel: true
|
containLabel: true
|
||||||
},
|
},
|
||||||
tooltip: getTooltipStyle('axis', {
|
tooltip: getTooltipStyle('axis', {
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'cross'
|
type: 'cross'
|
||||||
},
|
},
|
||||||
formatter: (params: Array<{ name: string; data: number[] }>) => {
|
formatter: (params: Array<{ name: string; data: number[] }>) => {
|
||||||
const param = params[0]
|
const param = params[0]
|
||||||
const data = param.data
|
const data = param.data
|
||||||
return `
|
return `
|
||||||
<div style="padding: 5px;">
|
<div style="padding: 5px;">
|
||||||
<div><strong>时间:</strong>${param.name}</div>
|
<div><strong>时间:</strong>${param.name}</div>
|
||||||
<div><strong>开盘:</strong>${data[0]}</div>
|
<div><strong>开盘:</strong>${data[0]}</div>
|
||||||
@@ -93,60 +94,65 @@
|
|||||||
<div><strong>最高:</strong>${data[3]}</div>
|
<div><strong>最高:</strong>${data[3]}</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'category',
|
||||||
data: props.data.map((item) => item.time),
|
data: props.data.map((item) => item.time),
|
||||||
axisTick: getAxisTickStyle(),
|
axisTick: getAxisTickStyle(),
|
||||||
axisLine: getAxisLineStyle(true),
|
axisLine: getAxisLineStyle(true),
|
||||||
axisLabel: getAxisLabelStyle(true)
|
axisLabel: getAxisLabelStyle(true)
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
scale: true,
|
scale: true,
|
||||||
axisLabel: getAxisLabelStyle(true),
|
axisLabel: getAxisLabelStyle(true),
|
||||||
axisLine: getAxisLineStyle(true),
|
axisLine: getAxisLineStyle(true),
|
||||||
splitLine: getSplitLineStyle(true)
|
splitLine: getSplitLineStyle(true)
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'candlestick',
|
type: 'candlestick',
|
||||||
data: props.data.map((item) => [item.open, item.close, item.low, item.high]),
|
data: props.data.map((item) => [
|
||||||
itemStyle: {
|
item.open,
|
||||||
color: upColor,
|
item.close,
|
||||||
color0: downColor,
|
item.low,
|
||||||
borderColor: upColor,
|
item.high
|
||||||
borderColor0: downColor,
|
]),
|
||||||
borderWidth: 1
|
itemStyle: {
|
||||||
},
|
color: upColor,
|
||||||
emphasis: {
|
color0: downColor,
|
||||||
itemStyle: {
|
borderColor: upColor,
|
||||||
borderWidth: 2,
|
borderColor0: downColor,
|
||||||
shadowBlur: 10,
|
borderWidth: 1
|
||||||
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
},
|
||||||
}
|
emphasis: {
|
||||||
},
|
itemStyle: {
|
||||||
...getAnimationConfig()
|
borderWidth: 2,
|
||||||
}
|
shadowBlur: 10,
|
||||||
],
|
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
||||||
dataZoom: props.showDataZoom
|
}
|
||||||
? [
|
},
|
||||||
{
|
...getAnimationConfig()
|
||||||
type: 'inside',
|
}
|
||||||
start: props.dataZoomStart,
|
],
|
||||||
end: props.dataZoomEnd
|
dataZoom: props.showDataZoom
|
||||||
},
|
? [
|
||||||
{
|
{
|
||||||
show: true,
|
type: 'inside',
|
||||||
type: 'slider',
|
start: props.dataZoomStart,
|
||||||
top: '90%',
|
end: props.dataZoomEnd
|
||||||
start: props.dataZoomStart,
|
},
|
||||||
end: props.dataZoomEnd
|
{
|
||||||
}
|
show: true,
|
||||||
]
|
type: 'slider',
|
||||||
: undefined
|
top: '90%',
|
||||||
}
|
start: props.dataZoomStart,
|
||||||
}
|
end: props.dataZoomEnd
|
||||||
})
|
}
|
||||||
|
]
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,371 +1,377 @@
|
|||||||
<!-- 折线图,支持多组数据,支持阶梯式动画效果 -->
|
<!-- 折线图,支持多组数据,支持阶梯式动画效果 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="relative w-[calc(100%+10px)]"
|
class="relative w-[calc(100%+10px)]"
|
||||||
:style="{ height: props.height }"
|
:style="{ height: props.height }"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
import { graphic, type EChartsOption } from '@/plugins/echarts'
|
||||||
import { getCssVar, hexToRgba } from '@/utils/ui'
|
import { getCssVar, hexToRgba } from '@/utils/ui'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
|
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtLineChart' })
|
defineOptions({ name: 'ArtLineChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<LineChartProps>(), {
|
const props = withDefaults(defineProps<LineChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
data: () => [0, 0, 0, 0, 0, 0, 0],
|
data: () => [0, 0, 0, 0, 0, 0, 0],
|
||||||
xAxisData: () => [],
|
xAxisData: () => [],
|
||||||
lineWidth: 2.5,
|
lineWidth: 2.5,
|
||||||
showAreaColor: false,
|
showAreaColor: false,
|
||||||
smooth: true,
|
smooth: true,
|
||||||
symbol: 'none',
|
symbol: 'none',
|
||||||
symbolSize: 6,
|
symbolSize: 6,
|
||||||
animationDelay: 200,
|
animationDelay: 200,
|
||||||
|
|
||||||
// 轴线显示配置
|
// 轴线显示配置
|
||||||
showAxisLabel: true,
|
showAxisLabel: true,
|
||||||
showAxisLine: true,
|
showAxisLine: true,
|
||||||
showSplitLine: true,
|
showSplitLine: true,
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'bottom'
|
legendPosition: 'bottom'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 动画状态管理
|
// 动画状态管理
|
||||||
const isAnimating = ref(false)
|
const isAnimating = ref(false)
|
||||||
const animationTimers = ref<number[]>([])
|
const animationTimers = ref<number[]>([])
|
||||||
const animatedData = ref<number[] | LineDataItem[]>([])
|
const animatedData = ref<number[] | LineDataItem[]>([])
|
||||||
|
|
||||||
// 清理所有定时器
|
// 清理所有定时器
|
||||||
const clearAnimationTimers = () => {
|
const clearAnimationTimers = () => {
|
||||||
animationTimers.value.forEach((timer) => clearTimeout(timer))
|
animationTimers.value.forEach((timer) => clearTimeout(timer))
|
||||||
animationTimers.value = []
|
animationTimers.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
|
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
|
||||||
const isMultipleData = computed(() => {
|
const isMultipleData = computed(() => {
|
||||||
return (
|
return (
|
||||||
Array.isArray(props.data) &&
|
Array.isArray(props.data) &&
|
||||||
props.data.length > 0 &&
|
props.data.length > 0 &&
|
||||||
typeof props.data[0] === 'object' &&
|
typeof props.data[0] === 'object' &&
|
||||||
'name' in props.data[0]
|
'name' in props.data[0]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 缓存计算的最大值,避免重复计算
|
// 缓存计算的最大值,避免重复计算
|
||||||
const maxValue = computed(() => {
|
const maxValue = computed(() => {
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
const multiData = props.data as LineDataItem[]
|
const multiData = props.data as LineDataItem[]
|
||||||
return multiData.reduce((max, item) => {
|
return multiData.reduce((max, item) => {
|
||||||
if (item.data?.length) {
|
if (item.data?.length) {
|
||||||
const itemMax = Math.max(...item.data)
|
const itemMax = Math.max(...item.data)
|
||||||
return Math.max(max, itemMax)
|
return Math.max(max, itemMax)
|
||||||
}
|
}
|
||||||
return max
|
return max
|
||||||
}, 0)
|
}, 0)
|
||||||
} else {
|
} else {
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
return singleData?.length ? Math.max(...singleData) : 0
|
return singleData?.length ? Math.max(...singleData) : 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 初始化动画数据(优化:减少条件判断)
|
// 初始化动画数据(优化:减少条件判断)
|
||||||
const initAnimationData = (): number[] | LineDataItem[] => {
|
const initAnimationData = (): number[] | LineDataItem[] => {
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
const multiData = props.data as LineDataItem[]
|
const multiData = props.data as LineDataItem[]
|
||||||
return multiData.map((item) => ({
|
return multiData.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
data: Array(item.data.length).fill(0)
|
data: Array(item.data.length).fill(0)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
return Array(singleData.length).fill(0)
|
return Array(singleData.length).fill(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 复制真实数据(优化:使用结构化克隆)
|
// 复制真实数据(优化:使用结构化克隆)
|
||||||
const copyRealData = (): number[] | LineDataItem[] => {
|
const copyRealData = (): number[] | LineDataItem[] => {
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
|
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
|
||||||
}
|
}
|
||||||
return [...(props.data as number[])]
|
return [...(props.data as number[])]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取颜色配置(优化:缓存主题色)
|
// 获取颜色配置(优化:缓存主题色)
|
||||||
const primaryColor = computed(() => getCssVar('--el-color-primary'))
|
const primaryColor = computed(() => getCssVar('--el-color-primary'))
|
||||||
|
|
||||||
const getColor = (customColor?: string, index?: number): string => {
|
const getColor = (customColor?: string, index?: number): string => {
|
||||||
if (customColor) return customColor
|
if (customColor) return customColor
|
||||||
if (index !== undefined) return props.colors![index % props.colors!.length]
|
if (index !== undefined) return props.colors![index % props.colors!.length]
|
||||||
return primaryColor.value
|
return primaryColor.value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成区域样式
|
// 生成区域样式
|
||||||
const generateAreaStyle = (item: LineDataItem, color: string) => {
|
const generateAreaStyle = (item: LineDataItem, color: string) => {
|
||||||
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
|
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
|
||||||
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
|
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
|
||||||
|
|
||||||
const areaConfig = item.areaStyle || {}
|
const areaConfig = item.areaStyle || {}
|
||||||
if (areaConfig.custom) return areaConfig.custom
|
if (areaConfig.custom) return areaConfig.custom
|
||||||
|
|
||||||
return {
|
return {
|
||||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
|
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
|
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成单数据区域样式
|
// 生成单数据区域样式
|
||||||
const generateSingleAreaStyle = () => {
|
const generateSingleAreaStyle = () => {
|
||||||
if (!props.showAreaColor) return undefined
|
if (!props.showAreaColor) return undefined
|
||||||
|
|
||||||
const color = getColor(props.colors[0])
|
const color = getColor(props.colors[0])
|
||||||
return {
|
return {
|
||||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
{
|
{
|
||||||
offset: 0,
|
offset: 0,
|
||||||
color: hexToRgba(color, 0.2).rgba
|
color: hexToRgba(color, 0.2).rgba
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
offset: 1,
|
offset: 1,
|
||||||
color: hexToRgba(color, 0.02).rgba
|
color: hexToRgba(color, 0.02).rgba
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建系列配置
|
// 创建系列配置
|
||||||
const createSeriesItem = (config: {
|
const createSeriesItem = (config: {
|
||||||
name?: string
|
name?: string
|
||||||
data: number[]
|
data: number[]
|
||||||
color?: string
|
color?: string
|
||||||
smooth?: boolean
|
smooth?: boolean
|
||||||
symbol?: string
|
symbol?: string
|
||||||
symbolSize?: number
|
symbolSize?: number
|
||||||
lineWidth?: number
|
lineWidth?: number
|
||||||
areaStyle?: any
|
areaStyle?: any
|
||||||
}) => {
|
}) => {
|
||||||
return {
|
return {
|
||||||
name: config.name,
|
name: config.name,
|
||||||
data: config.data,
|
data: config.data,
|
||||||
type: 'line' as const,
|
type: 'line' as const,
|
||||||
color: config.color,
|
color: config.color,
|
||||||
smooth: config.smooth ?? props.smooth,
|
smooth: config.smooth ?? props.smooth,
|
||||||
symbol: config.symbol ?? props.symbol,
|
symbol: config.symbol ?? props.symbol,
|
||||||
symbolSize: config.symbolSize ?? props.symbolSize,
|
symbolSize: config.symbolSize ?? props.symbolSize,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: config.lineWidth ?? props.lineWidth,
|
width: config.lineWidth ?? props.lineWidth,
|
||||||
color: config.color
|
color: config.color
|
||||||
},
|
},
|
||||||
areaStyle: config.areaStyle,
|
areaStyle: config.areaStyle,
|
||||||
emphasis: {
|
emphasis: {
|
||||||
focus: 'series' as const,
|
focus: 'series' as const,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: (config.lineWidth ?? props.lineWidth) + 1
|
width: (config.lineWidth ?? props.lineWidth) + 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成图表配置
|
// 生成图表配置
|
||||||
const generateChartOptions = (isInitial = false): EChartsOption => {
|
const generateChartOptions = (isInitial = false): EChartsOption => {
|
||||||
const options: EChartsOption = {
|
const options: EChartsOption = {
|
||||||
animation: true,
|
animation: true,
|
||||||
animationDuration: isInitial ? 0 : 1300,
|
animationDuration: isInitial ? 0 : 1300,
|
||||||
animationDurationUpdate: isInitial ? 0 : 1300,
|
animationDurationUpdate: isInitial ? 0 : 1300,
|
||||||
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
|
grid: getGridWithLegend(
|
||||||
top: 15,
|
props.showLegend && isMultipleData.value,
|
||||||
right: 15,
|
props.legendPosition,
|
||||||
left: 0
|
{
|
||||||
}),
|
top: 15,
|
||||||
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
right: 15,
|
||||||
xAxis: {
|
left: 0
|
||||||
type: 'category',
|
}
|
||||||
boundaryGap: false,
|
),
|
||||||
data: props.xAxisData,
|
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
|
||||||
axisTick: getAxisTickStyle(),
|
xAxis: {
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
type: 'category',
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
boundaryGap: false,
|
||||||
},
|
data: props.xAxisData,
|
||||||
yAxis: {
|
axisTick: getAxisTickStyle(),
|
||||||
type: 'value',
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
min: 0,
|
axisLabel: getAxisLabelStyle(props.showAxisLabel)
|
||||||
max: maxValue.value,
|
},
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
yAxis: {
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
type: 'value',
|
||||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
min: 0,
|
||||||
}
|
max: maxValue.value,
|
||||||
}
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
|
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加图例配置
|
// 添加图例配置
|
||||||
if (props.showLegend && isMultipleData.value) {
|
if (props.showLegend && isMultipleData.value) {
|
||||||
options.legend = getLegendStyle(props.legendPosition)
|
options.legend = getLegendStyle(props.legendPosition)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成系列数据
|
// 生成系列数据
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
const multiData = animatedData.value as LineDataItem[]
|
const multiData = animatedData.value as LineDataItem[]
|
||||||
options.series = multiData.map((item, index) => {
|
options.series = multiData.map((item, index) => {
|
||||||
const itemColor = getColor(props.colors[index], index)
|
const itemColor = getColor(props.colors[index], index)
|
||||||
const areaStyle = generateAreaStyle(item, itemColor)
|
const areaStyle = generateAreaStyle(item, itemColor)
|
||||||
|
|
||||||
return createSeriesItem({
|
return createSeriesItem({
|
||||||
name: item.name,
|
name: item.name,
|
||||||
data: item.data,
|
data: item.data,
|
||||||
color: itemColor,
|
color: itemColor,
|
||||||
smooth: item.smooth,
|
smooth: item.smooth,
|
||||||
symbol: item.symbol,
|
symbol: item.symbol,
|
||||||
lineWidth: item.lineWidth,
|
lineWidth: item.lineWidth,
|
||||||
areaStyle
|
areaStyle
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 单数据情况
|
// 单数据情况
|
||||||
const singleData = animatedData.value as number[]
|
const singleData = animatedData.value as number[]
|
||||||
const computedColor = getColor(props.colors[0])
|
const computedColor = getColor(props.colors[0])
|
||||||
const areaStyle = generateSingleAreaStyle()
|
const areaStyle = generateSingleAreaStyle()
|
||||||
|
|
||||||
options.series = [
|
options.series = [
|
||||||
createSeriesItem({
|
createSeriesItem({
|
||||||
data: singleData,
|
data: singleData,
|
||||||
color: computedColor,
|
color: computedColor,
|
||||||
areaStyle
|
areaStyle
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
return options
|
return options
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新图表
|
// 更新图表
|
||||||
const updateChartOptions = (options: EChartsOption) => {
|
const updateChartOptions = (options: EChartsOption) => {
|
||||||
initChart(options)
|
initChart(options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
|
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
|
||||||
const initChartWithAnimation = () => {
|
const initChartWithAnimation = () => {
|
||||||
clearAnimationTimers()
|
clearAnimationTimers()
|
||||||
isAnimating.value = true
|
isAnimating.value = true
|
||||||
|
|
||||||
// 初始化为0值数据
|
// 初始化为0值数据
|
||||||
animatedData.value = initAnimationData()
|
animatedData.value = initAnimationData()
|
||||||
updateChartOptions(generateChartOptions(true))
|
updateChartOptions(generateChartOptions(true))
|
||||||
|
|
||||||
if (isMultipleData.value) {
|
if (isMultipleData.value) {
|
||||||
// 多数据阶梯式动画
|
// 多数据阶梯式动画
|
||||||
const multiData = props.data as LineDataItem[]
|
const multiData = props.data as LineDataItem[]
|
||||||
const currentAnimatedData = animatedData.value as LineDataItem[]
|
const currentAnimatedData = animatedData.value as LineDataItem[]
|
||||||
|
|
||||||
multiData.forEach((item, index) => {
|
multiData.forEach((item, index) => {
|
||||||
const timer = window.setTimeout(
|
const timer = window.setTimeout(
|
||||||
() => {
|
() => {
|
||||||
currentAnimatedData[index] = { ...item, data: [...item.data] }
|
currentAnimatedData[index] = { ...item, data: [...item.data] }
|
||||||
animatedData.value = [...currentAnimatedData]
|
animatedData.value = [...currentAnimatedData]
|
||||||
updateChartOptions(generateChartOptions(false))
|
updateChartOptions(generateChartOptions(false))
|
||||||
},
|
},
|
||||||
index * props.animationDelay + 100
|
index * props.animationDelay + 100
|
||||||
)
|
)
|
||||||
|
|
||||||
animationTimers.value.push(timer)
|
animationTimers.value.push(timer)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 标记动画完成
|
// 标记动画完成
|
||||||
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
|
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
|
||||||
const finishTimer = window.setTimeout(() => {
|
const finishTimer = window.setTimeout(() => {
|
||||||
isAnimating.value = false
|
isAnimating.value = false
|
||||||
}, totalDelay)
|
}, totalDelay)
|
||||||
animationTimers.value.push(finishTimer)
|
animationTimers.value.push(finishTimer)
|
||||||
} else {
|
} else {
|
||||||
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
|
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
animatedData.value = copyRealData()
|
animatedData.value = copyRealData()
|
||||||
updateChartOptions(generateChartOptions(false))
|
updateChartOptions(generateChartOptions(false))
|
||||||
isAnimating.value = false
|
isAnimating.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空数据检查函数
|
// 空数据检查函数
|
||||||
const checkIsEmpty = () => {
|
const checkIsEmpty = () => {
|
||||||
// 检查单数据情况
|
// 检查单数据情况
|
||||||
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
|
||||||
const singleData = props.data as number[]
|
const singleData = props.data as number[]
|
||||||
return !singleData.length || singleData.every((val) => val === 0)
|
return !singleData.length || singleData.every((val) => val === 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查多数据情况
|
// 检查多数据情况
|
||||||
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
|
||||||
const multiData = props.data as LineDataItem[]
|
const multiData = props.data as LineDataItem[]
|
||||||
return (
|
return (
|
||||||
!multiData.length ||
|
!multiData.length ||
|
||||||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const {
|
const {
|
||||||
chartRef,
|
chartRef,
|
||||||
initChart,
|
initChart,
|
||||||
getAxisLineStyle,
|
getAxisLineStyle,
|
||||||
getAxisLabelStyle,
|
getAxisLabelStyle,
|
||||||
getAxisTickStyle,
|
getAxisTickStyle,
|
||||||
getSplitLineStyle,
|
getSplitLineStyle,
|
||||||
getTooltipStyle,
|
getTooltipStyle,
|
||||||
getLegendStyle,
|
getLegendStyle,
|
||||||
getGridWithLegend,
|
getGridWithLegend,
|
||||||
isEmpty
|
isEmpty
|
||||||
} = useChartComponent({
|
} = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: checkIsEmpty,
|
checkEmpty: checkIsEmpty,
|
||||||
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
|
||||||
onVisible: () => {
|
onVisible: () => {
|
||||||
// 当图表变为可见时,检查是否为空数据
|
// 当图表变为可见时,检查是否为空数据
|
||||||
if (!isEmpty.value) {
|
if (!isEmpty.value) {
|
||||||
initChartWithAnimation()
|
initChartWithAnimation()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
generateOptions: () => generateChartOptions(false)
|
generateOptions: () => generateChartOptions(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 图表渲染函数(优化:防止动画期间重复触发)
|
// 图表渲染函数(优化:防止动画期间重复触发)
|
||||||
const renderChart = () => {
|
const renderChart = () => {
|
||||||
if (!isAnimating.value && !isEmpty.value) {
|
if (!isAnimating.value && !isEmpty.value) {
|
||||||
initChartWithAnimation()
|
initChartWithAnimation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
|
||||||
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true })
|
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, {
|
||||||
|
deep: true
|
||||||
|
})
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
renderChart()
|
renderChart()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
clearAnimationTimers()
|
clearAnimationTimers()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,105 +1,108 @@
|
|||||||
<!-- 雷达图 -->
|
<!-- 雷达图 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
:style="{ height: props.height }"
|
:style="{ height: props.height }"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EChartsOption } from '@/plugins/echarts'
|
import type { EChartsOption } from '@/plugins/echarts'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import type { RadarChartProps } from '@/types/component/chart'
|
import type { RadarChartProps } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtRadarChart' })
|
defineOptions({ name: 'ArtRadarChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<RadarChartProps>(), {
|
const props = withDefaults(defineProps<RadarChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
indicator: () => [],
|
indicator: () => [],
|
||||||
data: () => [],
|
data: () => [],
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'bottom'
|
legendPosition: 'bottom'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
|
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
|
return (
|
||||||
},
|
!props.data?.length ||
|
||||||
watchSources: [() => props.data, () => props.indicator, () => props.colors],
|
props.data.every((item) => item.value.every((val) => val === 0))
|
||||||
generateOptions: (): EChartsOption => {
|
)
|
||||||
return {
|
},
|
||||||
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
|
watchSources: [() => props.data, () => props.indicator, () => props.colors],
|
||||||
radar: {
|
generateOptions: (): EChartsOption => {
|
||||||
indicator: props.indicator,
|
return {
|
||||||
center: ['50%', '50%'],
|
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
|
||||||
radius: '70%',
|
radar: {
|
||||||
axisName: {
|
indicator: props.indicator,
|
||||||
color: isDark.value ? '#ccc' : '#666',
|
center: ['50%', '50%'],
|
||||||
fontSize: 12
|
radius: '70%',
|
||||||
},
|
axisName: {
|
||||||
splitLine: {
|
color: isDark.value ? '#ccc' : '#666',
|
||||||
lineStyle: {
|
fontSize: 12
|
||||||
color: isDark.value ? '#444' : '#e6e6e6'
|
},
|
||||||
}
|
splitLine: {
|
||||||
},
|
lineStyle: {
|
||||||
axisLine: {
|
color: isDark.value ? '#444' : '#e6e6e6'
|
||||||
lineStyle: {
|
}
|
||||||
color: isDark.value ? '#444' : '#e6e6e6'
|
},
|
||||||
}
|
axisLine: {
|
||||||
},
|
lineStyle: {
|
||||||
splitArea: {
|
color: isDark.value ? '#444' : '#e6e6e6'
|
||||||
show: true,
|
}
|
||||||
areaStyle: {
|
},
|
||||||
color: isDark.value
|
splitArea: {
|
||||||
? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
|
show: true,
|
||||||
: ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)']
|
areaStyle: {
|
||||||
}
|
color: isDark.value
|
||||||
}
|
? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
|
||||||
},
|
: ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)']
|
||||||
series: [
|
}
|
||||||
{
|
}
|
||||||
type: 'radar',
|
},
|
||||||
data: props.data.map((item, index) => ({
|
series: [
|
||||||
name: item.name,
|
{
|
||||||
value: item.value,
|
type: 'radar',
|
||||||
symbolSize: 4,
|
data: props.data.map((item, index) => ({
|
||||||
lineStyle: {
|
name: item.name,
|
||||||
width: 2,
|
value: item.value,
|
||||||
color: props.colors[index % props.colors.length]
|
symbolSize: 4,
|
||||||
},
|
lineStyle: {
|
||||||
itemStyle: {
|
width: 2,
|
||||||
color: props.colors[index % props.colors.length]
|
color: props.colors[index % props.colors.length]
|
||||||
},
|
},
|
||||||
areaStyle: {
|
itemStyle: {
|
||||||
color: props.colors[index % props.colors.length],
|
color: props.colors[index % props.colors.length]
|
||||||
opacity: 0.1
|
},
|
||||||
},
|
areaStyle: {
|
||||||
emphasis: {
|
color: props.colors[index % props.colors.length],
|
||||||
areaStyle: {
|
opacity: 0.1
|
||||||
opacity: 0.25
|
},
|
||||||
},
|
emphasis: {
|
||||||
lineStyle: {
|
areaStyle: {
|
||||||
width: 3
|
opacity: 0.25
|
||||||
}
|
},
|
||||||
}
|
lineStyle: {
|
||||||
})),
|
width: 3
|
||||||
...getAnimationConfig(200, 1800)
|
}
|
||||||
}
|
}
|
||||||
]
|
})),
|
||||||
}
|
...getAnimationConfig(200, 1800)
|
||||||
}
|
}
|
||||||
})
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,133 +1,133 @@
|
|||||||
<!-- 环形图 -->
|
<!-- 环形图 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
:style="{ height: props.height }"
|
:style="{ height: props.height }"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EChartsOption } from '@/plugins/echarts'
|
import type { EChartsOption } from '@/plugins/echarts'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import type { RingChartProps } from '@/types/component/chart'
|
import type { RingChartProps } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtRingChart' })
|
defineOptions({ name: 'ArtRingChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<RingChartProps>(), {
|
const props = withDefaults(defineProps<RingChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
data: () => [],
|
data: () => [],
|
||||||
radius: () => ['50%', '80%'],
|
radius: () => ['50%', '80%'],
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
centerText: '',
|
centerText: '',
|
||||||
showLabel: false,
|
showLabel: false,
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'right'
|
legendPosition: 'right'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
|
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
|
||||||
useChartComponent({
|
useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
return !props.data?.length || props.data.every((item) => item.value === 0)
|
return !props.data?.length || props.data.every((item) => item.value === 0)
|
||||||
},
|
},
|
||||||
watchSources: [() => props.data, () => props.centerText],
|
watchSources: [() => props.data, () => props.centerText],
|
||||||
generateOptions: (): EChartsOption => {
|
generateOptions: (): EChartsOption => {
|
||||||
// 根据图例位置计算环形图中心位置
|
// 根据图例位置计算环形图中心位置
|
||||||
const getCenterPosition = (): [string, string] => {
|
const getCenterPosition = (): [string, string] => {
|
||||||
if (!props.showLegend) return ['50%', '50%']
|
if (!props.showLegend) return ['50%', '50%']
|
||||||
|
|
||||||
switch (props.legendPosition) {
|
switch (props.legendPosition) {
|
||||||
case 'left':
|
case 'left':
|
||||||
return ['60%', '50%']
|
return ['60%', '50%']
|
||||||
case 'right':
|
case 'right':
|
||||||
return ['40%', '50%']
|
return ['40%', '50%']
|
||||||
case 'top':
|
case 'top':
|
||||||
return ['50%', '60%']
|
return ['50%', '60%']
|
||||||
case 'bottom':
|
case 'bottom':
|
||||||
return ['50%', '40%']
|
return ['50%', '40%']
|
||||||
default:
|
default:
|
||||||
return ['50%', '50%']
|
return ['50%', '50%']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const option: EChartsOption = {
|
const option: EChartsOption = {
|
||||||
tooltip: props.showTooltip
|
tooltip: props.showTooltip
|
||||||
? getTooltipStyle('item', {
|
? getTooltipStyle('item', {
|
||||||
formatter: '{b}: {c} ({d}%)'
|
formatter: '{b}: {c} ({d}%)'
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
|
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '数据占比',
|
name: '数据占比',
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
radius: props.radius,
|
radius: props.radius,
|
||||||
center: getCenterPosition(),
|
center: getCenterPosition(),
|
||||||
avoidLabelOverlap: false,
|
avoidLabelOverlap: false,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
borderRadius: props.borderRadius,
|
borderRadius: props.borderRadius,
|
||||||
borderColor: isDark.value ? '#2c2c2c' : '#fff',
|
borderColor: isDark.value ? '#2c2c2c' : '#fff',
|
||||||
borderWidth: 0
|
borderWidth: 0
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
show: props.showLabel,
|
show: props.showLabel,
|
||||||
formatter: '{b}\n{d}%',
|
formatter: '{b}\n{d}%',
|
||||||
position: 'outside',
|
position: 'outside',
|
||||||
color: isDark.value ? '#ccc' : '#999',
|
color: isDark.value ? '#ccc' : '#999',
|
||||||
fontSize: 12
|
fontSize: 12
|
||||||
},
|
},
|
||||||
emphasis: {
|
emphasis: {
|
||||||
label: {
|
label: {
|
||||||
show: false,
|
show: false,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
labelLine: {
|
labelLine: {
|
||||||
show: props.showLabel,
|
show: props.showLabel,
|
||||||
length: 15,
|
length: 15,
|
||||||
length2: 25,
|
length2: 25,
|
||||||
smooth: true
|
smooth: true
|
||||||
},
|
},
|
||||||
data: props.data,
|
data: props.data,
|
||||||
color: props.colors,
|
color: props.colors,
|
||||||
...getAnimationConfig(),
|
...getAnimationConfig(),
|
||||||
animationType: 'expansion'
|
animationType: 'expansion'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加中心文字
|
// 添加中心文字
|
||||||
if (props.centerText) {
|
if (props.centerText) {
|
||||||
const centerPos = getCenterPosition()
|
const centerPos = getCenterPosition()
|
||||||
option.title = {
|
option.title = {
|
||||||
text: props.centerText,
|
text: props.centerText,
|
||||||
left: centerPos[0],
|
left: centerPos[0],
|
||||||
top: centerPos[1],
|
top: centerPos[1],
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
textVerticalAlign: 'middle',
|
textVerticalAlign: 'middle',
|
||||||
textStyle: {
|
textStyle: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: isDark.value ? '#999' : '#ADB0BC'
|
color: isDark.value ? '#999' : '#ADB0BC'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return option
|
return option
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,115 +1,122 @@
|
|||||||
<!-- 散点图 -->
|
<!-- 散点图 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="chartRef"
|
ref="chartRef"
|
||||||
class="relative w-full"
|
class="relative w-full"
|
||||||
:style="{ height: props.height }"
|
:style="{ height: props.height }"
|
||||||
v-loading="props.loading"
|
v-loading="props.loading"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { EChartsOption } from '@/plugins/echarts'
|
import type { EChartsOption } from '@/plugins/echarts'
|
||||||
import { getCssVar } from '@/utils/ui'
|
import { getCssVar } from '@/utils/ui'
|
||||||
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
|
||||||
import type { ScatterChartProps } from '@/types/component/chart'
|
import type { ScatterChartProps } from '@/types/component/chart'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtScatterChart' })
|
defineOptions({ name: 'ArtScatterChart' })
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ScatterChartProps>(), {
|
const props = withDefaults(defineProps<ScatterChartProps>(), {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
height: useChartOps().chartHeight,
|
height: useChartOps().chartHeight,
|
||||||
loading: false,
|
loading: false,
|
||||||
isEmpty: false,
|
isEmpty: false,
|
||||||
colors: () => useChartOps().colors,
|
colors: () => useChartOps().colors,
|
||||||
|
|
||||||
// 数据配置
|
// 数据配置
|
||||||
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
|
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
|
||||||
symbolSize: 14,
|
symbolSize: 14,
|
||||||
|
|
||||||
// 轴线显示配置
|
// 轴线显示配置
|
||||||
showAxisLabel: true,
|
showAxisLabel: true,
|
||||||
showAxisLine: true,
|
showAxisLine: true,
|
||||||
showSplitLine: true,
|
showSplitLine: true,
|
||||||
|
|
||||||
// 交互配置
|
// 交互配置
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
showLegend: false,
|
showLegend: false,
|
||||||
legendPosition: 'bottom'
|
legendPosition: 'bottom'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用新的图表组件抽象
|
// 使用新的图表组件抽象
|
||||||
const {
|
const {
|
||||||
chartRef,
|
chartRef,
|
||||||
isDark,
|
isDark,
|
||||||
getAxisLineStyle,
|
getAxisLineStyle,
|
||||||
getAxisLabelStyle,
|
getAxisLabelStyle,
|
||||||
getAxisTickStyle,
|
getAxisTickStyle,
|
||||||
getSplitLineStyle,
|
getSplitLineStyle,
|
||||||
getAnimationConfig,
|
getAnimationConfig,
|
||||||
getTooltipStyle
|
getTooltipStyle
|
||||||
} = useChartComponent({
|
} = useChartComponent({
|
||||||
props,
|
props,
|
||||||
checkEmpty: () => {
|
checkEmpty: () => {
|
||||||
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
|
return (
|
||||||
},
|
!props.data?.length ||
|
||||||
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
|
props.data.every((item) => item.value.every((val) => val === 0))
|
||||||
generateOptions: (): EChartsOption => {
|
)
|
||||||
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
|
},
|
||||||
|
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
|
||||||
|
generateOptions: (): EChartsOption => {
|
||||||
|
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
top: 20,
|
top: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
containLabel: true
|
containLabel: true
|
||||||
},
|
},
|
||||||
tooltip: props.showTooltip
|
tooltip: props.showTooltip
|
||||||
? getTooltipStyle('item', {
|
? getTooltipStyle('item', {
|
||||||
formatter: (params: { value: [number, number] }) => {
|
formatter: (params: { value: [number, number] }) => {
|
||||||
const [x, y] = params.value
|
const [x, y] = params.value
|
||||||
return `X: ${x}<br/>Y: ${y}`
|
return `X: ${x}<br/>Y: ${y}`
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
axisTick: getAxisTickStyle(),
|
axisTick: getAxisTickStyle(),
|
||||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||||
},
|
},
|
||||||
yAxis: {
|
yAxis: {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
axisLabel: getAxisLabelStyle(props.showAxisLabel),
|
||||||
axisLine: getAxisLineStyle(props.showAxisLine),
|
axisLine: getAxisLineStyle(props.showAxisLine),
|
||||||
axisTick: getAxisTickStyle(),
|
axisTick: getAxisTickStyle(),
|
||||||
splitLine: getSplitLineStyle(props.showSplitLine)
|
splitLine: getSplitLineStyle(props.showSplitLine)
|
||||||
},
|
},
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
data: props.data,
|
data: props.data,
|
||||||
symbolSize: props.symbolSize,
|
symbolSize: props.symbolSize,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: computedColor,
|
color: computedColor,
|
||||||
shadowBlur: 6,
|
shadowBlur: 6,
|
||||||
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
|
shadowColor: isDark.value
|
||||||
shadowOffsetY: 2
|
? 'rgba(255, 255, 255, 0.1)'
|
||||||
},
|
: 'rgba(0, 0, 0, 0.1)',
|
||||||
emphasis: {
|
shadowOffsetY: 2
|
||||||
itemStyle: {
|
},
|
||||||
shadowBlur: 12,
|
emphasis: {
|
||||||
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'
|
itemStyle: {
|
||||||
},
|
shadowBlur: 12,
|
||||||
scale: true
|
shadowColor: isDark.value
|
||||||
},
|
? 'rgba(255, 255, 255, 0.2)'
|
||||||
...getAnimationConfig()
|
: 'rgba(0, 0, 0, 0.2)'
|
||||||
}
|
},
|
||||||
]
|
scale: true
|
||||||
}
|
},
|
||||||
}
|
...getAnimationConfig()
|
||||||
})
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,71 +1,74 @@
|
|||||||
<!-- 更多按钮 -->
|
<!-- 更多按钮 -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ElDropdown v-if="hasAnyAuthItem">
|
<ElDropdown v-if="hasAnyAuthItem">
|
||||||
<ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" />
|
<ArtIconButton
|
||||||
<template #dropdown>
|
icon="ri:more-2-fill"
|
||||||
<ElDropdownMenu>
|
class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm"
|
||||||
<template v-for="item in list" :key="item.key">
|
/>
|
||||||
<ElDropdownItem
|
<template #dropdown>
|
||||||
v-if="!item.auth || hasAuth(item.auth)"
|
<ElDropdownMenu>
|
||||||
:disabled="item.disabled"
|
<template v-for="item in list" :key="item.key">
|
||||||
@click="handleClick(item)"
|
<ElDropdownItem
|
||||||
>
|
v-if="!item.auth || hasAuth(item.auth)"
|
||||||
<div class="flex-c gap-2" :style="{ color: item.color }">
|
:disabled="item.disabled"
|
||||||
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
|
@click="handleClick(item)"
|
||||||
<span>{{ item.label }}</span>
|
>
|
||||||
</div>
|
<div class="flex-c gap-2" :style="{ color: item.color }">
|
||||||
</ElDropdownItem>
|
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
|
||||||
</template>
|
<span>{{ item.label }}</span>
|
||||||
</ElDropdownMenu>
|
</div>
|
||||||
</template>
|
</ElDropdownItem>
|
||||||
</ElDropdown>
|
</template>
|
||||||
</div>
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useAuth } from '@/hooks/core/useAuth'
|
import { useAuth } from '@/hooks/core/useAuth'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtButtonMore' })
|
defineOptions({ name: 'ArtButtonMore' })
|
||||||
|
|
||||||
const { hasAuth } = useAuth()
|
const { hasAuth } = useAuth()
|
||||||
|
|
||||||
export interface ButtonMoreItem {
|
export interface ButtonMoreItem {
|
||||||
/** 按钮标识,可用于点击事件 */
|
/** 按钮标识,可用于点击事件 */
|
||||||
key: string | number
|
key: string | number
|
||||||
/** 按钮文本 */
|
/** 按钮文本 */
|
||||||
label: string
|
label: string
|
||||||
/** 是否禁用 */
|
/** 是否禁用 */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
/** 权限标识 */
|
/** 权限标识 */
|
||||||
auth?: string
|
auth?: string
|
||||||
/** 图标组件 */
|
/** 图标组件 */
|
||||||
icon?: string
|
icon?: string
|
||||||
/** 文本颜色 */
|
/** 文本颜色 */
|
||||||
color?: string
|
color?: string
|
||||||
/** 图标颜色(优先级高于 color) */
|
/** 图标颜色(优先级高于 color) */
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 下拉项列表 */
|
/** 下拉项列表 */
|
||||||
list: ButtonMoreItem[]
|
list: ButtonMoreItem[]
|
||||||
/** 整体权限控制 */
|
/** 整体权限控制 */
|
||||||
auth?: string
|
auth?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {})
|
const props = withDefaults(defineProps<Props>(), {})
|
||||||
|
|
||||||
// 检查是否有任何有权限的 item
|
// 检查是否有任何有权限的 item
|
||||||
const hasAnyAuthItem = computed(() => {
|
const hasAnyAuthItem = computed(() => {
|
||||||
return props.list.some((item) => !item.auth || hasAuth(item.auth))
|
return props.list.some((item) => !item.auth || hasAuth(item.auth))
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click', item: ButtonMoreItem): void
|
(e: 'click', item: ButtonMoreItem): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleClick = (item: ButtonMoreItem) => {
|
const handleClick = (item: ButtonMoreItem) => {
|
||||||
emit('click', item)
|
emit('click', item)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
<!-- 表格按钮 -->
|
<!-- 表格按钮 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
|
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
|
||||||
buttonClass
|
buttonClass
|
||||||
]"
|
]"
|
||||||
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
|
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
|
||||||
@click="handleClick"
|
@click="handleClick"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon :icon="iconContent" />
|
<ArtSvgIcon :icon="iconContent" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtButtonTable' })
|
defineOptions({ name: 'ArtButtonTable' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 按钮类型 */
|
/** 按钮类型 */
|
||||||
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
|
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
|
||||||
/** 按钮图标 */
|
/** 按钮图标 */
|
||||||
icon?: string
|
icon?: string
|
||||||
/** 按钮样式类 */
|
/** 按钮样式类 */
|
||||||
iconClass?: string
|
iconClass?: string
|
||||||
/** icon 颜色 */
|
/** icon 颜色 */
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
/** 按钮背景色 */
|
/** 按钮背景色 */
|
||||||
buttonBgColor?: string
|
buttonBgColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {})
|
const props = withDefaults(defineProps<Props>(), {})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'click'): void
|
(e: 'click'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 默认按钮配置
|
// 默认按钮配置
|
||||||
const defaultButtons = {
|
const defaultButtons = {
|
||||||
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
|
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
|
||||||
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
|
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
|
||||||
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
|
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
|
||||||
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
|
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
|
||||||
more: { icon: 'ri:more-2-fill', class: '' }
|
more: { icon: 'ri:more-2-fill', class: '' }
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// 获取图标内容
|
// 获取图标内容
|
||||||
const iconContent = computed(() => {
|
const iconContent = computed(() => {
|
||||||
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
|
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取按钮样式类
|
// 获取按钮样式类
|
||||||
const buttonClass = computed(() => {
|
const buttonClass = computed(() => {
|
||||||
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
|
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
emit('click')
|
emit('click')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,430 +1,431 @@
|
|||||||
<!-- 拖拽验证组件 -->
|
<!-- 拖拽验证组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="dragVerify"
|
ref="dragVerify"
|
||||||
class="drag_verify"
|
class="drag_verify"
|
||||||
:style="dragVerifyStyle"
|
:style="dragVerifyStyle"
|
||||||
@mousemove="dragMoving"
|
@mousemove="dragMoving"
|
||||||
@mouseup="dragFinish"
|
@mouseup="dragFinish"
|
||||||
@mouseleave="dragFinish"
|
@mouseleave="dragFinish"
|
||||||
@touchmove="dragMoving"
|
@touchmove="dragMoving"
|
||||||
@touchend="dragFinish"
|
@touchend="dragFinish"
|
||||||
>
|
>
|
||||||
<!-- 进度条 -->
|
<!-- 进度条 -->
|
||||||
<div
|
<div
|
||||||
class="dv_progress_bar"
|
class="dv_progress_bar"
|
||||||
:class="{ goFirst2: isOk }"
|
:class="{ goFirst2: isOk }"
|
||||||
ref="progressBar"
|
ref="progressBar"
|
||||||
:style="progressBarStyle"
|
:style="progressBarStyle"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提示文本 -->
|
<!-- 提示文本 -->
|
||||||
<div class="dv_text" :style="textStyle" ref="messageRef">
|
<div class="dv_text" :style="textStyle" ref="messageRef">
|
||||||
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
<slot name="textBefore" v-if="$slots.textBefore"></slot>
|
||||||
{{ message }}
|
{{ message }}
|
||||||
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
<slot name="textAfter" v-if="$slots.textAfter"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 滑块处理器 -->
|
<!-- 滑块处理器 -->
|
||||||
<div
|
<div
|
||||||
class="dv_handler dv_handler_bg"
|
class="dv_handler dv_handler_bg"
|
||||||
:class="{ goFirst: isOk }"
|
:class="{ goFirst: isOk }"
|
||||||
@mousedown="dragStart"
|
@mousedown="dragStart"
|
||||||
@touchstart="dragStart"
|
@touchstart="dragStart"
|
||||||
ref="handler"
|
ref="handler"
|
||||||
:style="handlerStyle"
|
:style="handlerStyle"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
|
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ name: 'ArtDragVerify' })
|
defineOptions({ name: 'ArtDragVerify' })
|
||||||
|
|
||||||
// 事件定义
|
// 事件定义
|
||||||
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
|
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
|
||||||
|
|
||||||
// 组件属性接口定义
|
// 组件属性接口定义
|
||||||
interface PropsType {
|
interface PropsType {
|
||||||
/** 是否通过验证 */
|
/** 是否通过验证 */
|
||||||
value: boolean
|
value: boolean
|
||||||
/** 组件宽度 */
|
/** 组件宽度 */
|
||||||
width?: number | string
|
width?: number | string
|
||||||
/** 组件高度 */
|
/** 组件高度 */
|
||||||
height?: number
|
height?: number
|
||||||
/** 默认提示文本 */
|
/** 默认提示文本 */
|
||||||
text?: string
|
text?: string
|
||||||
/** 成功提示文本 */
|
/** 成功提示文本 */
|
||||||
successText?: string
|
successText?: string
|
||||||
/** 背景色 */
|
/** 背景色 */
|
||||||
background?: string
|
background?: string
|
||||||
/** 进度条背景色 */
|
/** 进度条背景色 */
|
||||||
progressBarBg?: string
|
progressBarBg?: string
|
||||||
/** 完成状态背景色 */
|
/** 完成状态背景色 */
|
||||||
completedBg?: string
|
completedBg?: string
|
||||||
/** 是否圆角 */
|
/** 是否圆角 */
|
||||||
circle?: boolean
|
circle?: boolean
|
||||||
/** 圆角大小 */
|
/** 圆角大小 */
|
||||||
radius?: string
|
radius?: string
|
||||||
/** 滑块图标 */
|
/** 滑块图标 */
|
||||||
handlerIcon?: string
|
handlerIcon?: string
|
||||||
/** 成功图标 */
|
/** 成功图标 */
|
||||||
successIcon?: string
|
successIcon?: string
|
||||||
/** 滑块背景色 */
|
/** 滑块背景色 */
|
||||||
handlerBg?: string
|
handlerBg?: string
|
||||||
/** 文本大小 */
|
/** 文本大小 */
|
||||||
textSize?: string
|
textSize?: string
|
||||||
/** 文本颜色 */
|
/** 文本颜色 */
|
||||||
textColor?: string
|
textColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 属性默认值设置
|
// 属性默认值设置
|
||||||
const props = withDefaults(defineProps<PropsType>(), {
|
const props = withDefaults(defineProps<PropsType>(), {
|
||||||
value: false,
|
value: false,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 40,
|
height: 40,
|
||||||
text: '按住滑块拖动',
|
text: '按住滑块拖动',
|
||||||
successText: 'success',
|
successText: 'success',
|
||||||
background: '#eee',
|
background: '#eee',
|
||||||
progressBarBg: '#1385FF',
|
progressBarBg: '#1385FF',
|
||||||
completedBg: '#57D187',
|
completedBg: '#57D187',
|
||||||
circle: false,
|
circle: false,
|
||||||
radius: 'calc(var(--custom-radius) / 3 + 2px)',
|
radius: 'calc(var(--custom-radius) / 3 + 2px)',
|
||||||
handlerIcon: 'solar:double-alt-arrow-right-linear',
|
handlerIcon: 'solar:double-alt-arrow-right-linear',
|
||||||
successIcon: 'ri:check-fill',
|
successIcon: 'ri:check-fill',
|
||||||
handlerBg: '#fff',
|
handlerBg: '#fff',
|
||||||
textSize: '13px',
|
textSize: '13px',
|
||||||
textColor: '#333'
|
textColor: '#333'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件状态接口定义
|
// 组件状态接口定义
|
||||||
interface StateType {
|
interface StateType {
|
||||||
isMoving: boolean // 是否正在拖拽
|
isMoving: boolean // 是否正在拖拽
|
||||||
x: number // 拖拽起始位置
|
x: number // 拖拽起始位置
|
||||||
isOk: boolean // 是否验证成功
|
isOk: boolean // 是否验证成功
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式状态定义
|
// 响应式状态定义
|
||||||
const state = reactive(<StateType>{
|
const state = reactive(<StateType>{
|
||||||
isMoving: false,
|
isMoving: false,
|
||||||
x: 0,
|
x: 0,
|
||||||
isOk: false
|
isOk: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解构响应式状态
|
// 解构响应式状态
|
||||||
const { isOk } = toRefs(state)
|
const { isOk } = toRefs(state)
|
||||||
|
|
||||||
// DOM 元素引用
|
// DOM 元素引用
|
||||||
const dragVerify = ref()
|
const dragVerify = ref()
|
||||||
const messageRef = ref()
|
const messageRef = ref()
|
||||||
const handler = ref()
|
const handler = ref()
|
||||||
const progressBar = ref()
|
const progressBar = ref()
|
||||||
|
|
||||||
// 触摸事件变量 - 用于禁止页面滑动
|
// 触摸事件变量 - 用于禁止页面滑动
|
||||||
let startX: number, startY: number, moveX: number, moveY: number
|
let startX: number, startY: number, moveX: number, moveY: number
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触摸开始事件处理
|
* 触摸开始事件处理
|
||||||
* @param e 触摸事件对象
|
* @param e 触摸事件对象
|
||||||
*/
|
*/
|
||||||
const onTouchStart = (e: any) => {
|
const onTouchStart = (e: any) => {
|
||||||
startX = e.targetTouches[0].pageX
|
startX = e.targetTouches[0].pageX
|
||||||
startY = e.targetTouches[0].pageY
|
startY = e.targetTouches[0].pageY
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
|
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
|
||||||
* @param e 触摸事件对象
|
* @param e 触摸事件对象
|
||||||
*/
|
*/
|
||||||
const onTouchMove = (e: any) => {
|
const onTouchMove = (e: any) => {
|
||||||
moveX = e.targetTouches[0].pageX
|
moveX = e.targetTouches[0].pageX
|
||||||
moveY = e.targetTouches[0].pageY
|
moveY = e.targetTouches[0].pageY
|
||||||
|
|
||||||
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
|
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
|
||||||
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局事件监听器添加
|
// 全局事件监听器添加
|
||||||
document.addEventListener('touchstart', onTouchStart)
|
document.addEventListener('touchstart', onTouchStart)
|
||||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
|
|
||||||
// 获取数值形式的宽度
|
// 获取数值形式的宽度
|
||||||
const getNumericWidth = (): number => {
|
const getNumericWidth = (): number => {
|
||||||
if (typeof props.width === 'string') {
|
if (typeof props.width === 'string') {
|
||||||
// 如果是字符串,尝试从DOM元素获取实际宽度
|
// 如果是字符串,尝试从DOM元素获取实际宽度
|
||||||
return dragVerify.value?.offsetWidth || 260
|
return dragVerify.value?.offsetWidth || 260
|
||||||
}
|
}
|
||||||
return props.width
|
return props.width
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取样式字符串形式的宽度
|
// 获取样式字符串形式的宽度
|
||||||
const getStyleWidth = (): string => {
|
const getStyleWidth = (): string => {
|
||||||
if (typeof props.width === 'string') {
|
if (typeof props.width === 'string') {
|
||||||
return props.width
|
return props.width
|
||||||
}
|
}
|
||||||
return props.width + 'px'
|
return props.width + 'px'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载后的初始化
|
// 组件挂载后的初始化
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 设置 CSS 自定义属性
|
// 设置 CSS 自定义属性
|
||||||
dragVerify.value?.style.setProperty('--textColor', props.textColor)
|
dragVerify.value?.style.setProperty('--textColor', props.textColor)
|
||||||
|
|
||||||
// 等待DOM更新后设置宽度相关属性
|
// 等待DOM更新后设置宽度相关属性
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const numericWidth = getNumericWidth()
|
const numericWidth = getNumericWidth()
|
||||||
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
|
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
|
||||||
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
|
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 重复添加事件监听器(确保事件绑定)
|
// 重复添加事件监听器(确保事件绑定)
|
||||||
document.addEventListener('touchstart', onTouchStart)
|
document.addEventListener('touchstart', onTouchStart)
|
||||||
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
document.addEventListener('touchmove', onTouchMove, { passive: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载前清理事件监听器
|
// 组件卸载前清理事件监听器
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('touchstart', onTouchStart)
|
document.removeEventListener('touchstart', onTouchStart)
|
||||||
document.removeEventListener('touchmove', onTouchMove)
|
document.removeEventListener('touchmove', onTouchMove)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 滑块样式计算
|
// 滑块样式计算
|
||||||
const handlerStyle = {
|
const handlerStyle = {
|
||||||
left: '0',
|
left: '0',
|
||||||
width: props.height + 'px',
|
width: props.height + 'px',
|
||||||
height: props.height + 'px',
|
height: props.height + 'px',
|
||||||
background: props.handlerBg
|
background: props.handlerBg
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主容器样式计算
|
// 主容器样式计算
|
||||||
const dragVerifyStyle = computed(() => ({
|
const dragVerifyStyle = computed(() => ({
|
||||||
width: getStyleWidth(),
|
width: getStyleWidth(),
|
||||||
height: props.height + 'px',
|
height: props.height + 'px',
|
||||||
lineHeight: props.height + 'px',
|
lineHeight: props.height + 'px',
|
||||||
background: props.background,
|
background: props.background,
|
||||||
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
|
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 进度条样式计算
|
// 进度条样式计算
|
||||||
const progressBarStyle = {
|
const progressBarStyle = {
|
||||||
background: props.progressBarBg,
|
background: props.progressBarBg,
|
||||||
height: props.height + 'px',
|
height: props.height + 'px',
|
||||||
borderRadius: props.circle
|
borderRadius: props.circle
|
||||||
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
|
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
|
||||||
: props.radius
|
: props.radius
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文本样式计算
|
// 文本样式计算
|
||||||
const textStyle = computed(() => ({
|
const textStyle = computed(() => ({
|
||||||
fontSize: props.textSize
|
fontSize: props.textSize
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 显示消息计算属性
|
// 显示消息计算属性
|
||||||
const message = computed(() => {
|
const message = computed(() => {
|
||||||
return props.value ? props.successText : props.text
|
return props.value ? props.successText : props.text
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拖拽开始处理函数
|
* 拖拽开始处理函数
|
||||||
* @param e 鼠标或触摸事件对象
|
* @param e 鼠标或触摸事件对象
|
||||||
*/
|
*/
|
||||||
const dragStart = (e: any) => {
|
const dragStart = (e: any) => {
|
||||||
if (!props.value) {
|
if (!props.value) {
|
||||||
state.isMoving = true
|
state.isMoving = true
|
||||||
handler.value.style.transition = 'none'
|
handler.value.style.transition = 'none'
|
||||||
// 计算拖拽起始位置
|
// 计算拖拽起始位置
|
||||||
state.x =
|
state.x =
|
||||||
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10)
|
(e.pageX || e.touches[0].pageX) -
|
||||||
}
|
parseInt(handler.value.style.left.replace('px', ''), 10)
|
||||||
emit('handlerMove')
|
}
|
||||||
}
|
emit('handlerMove')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拖拽移动处理函数
|
* 拖拽移动处理函数
|
||||||
* @param e 鼠标或触摸事件对象
|
* @param e 鼠标或触摸事件对象
|
||||||
*/
|
*/
|
||||||
const dragMoving = (e: any) => {
|
const dragMoving = (e: any) => {
|
||||||
if (state.isMoving && !props.value) {
|
if (state.isMoving && !props.value) {
|
||||||
const numericWidth = getNumericWidth()
|
const numericWidth = getNumericWidth()
|
||||||
// 计算当前位置
|
// 计算当前位置
|
||||||
let _x = (e.pageX || e.touches[0].pageX) - state.x
|
let _x = (e.pageX || e.touches[0].pageX) - state.x
|
||||||
|
|
||||||
// 在有效范围内移动
|
// 在有效范围内移动
|
||||||
if (_x > 0 && _x <= numericWidth - props.height) {
|
if (_x > 0 && _x <= numericWidth - props.height) {
|
||||||
handler.value.style.left = _x + 'px'
|
handler.value.style.left = _x + 'px'
|
||||||
progressBar.value.style.width = _x + props.height / 2 + 'px'
|
progressBar.value.style.width = _x + props.height / 2 + 'px'
|
||||||
} else if (_x > numericWidth - props.height) {
|
} else if (_x > numericWidth - props.height) {
|
||||||
// 拖拽到末端,触发验证成功
|
// 拖拽到末端,触发验证成功
|
||||||
handler.value.style.left = numericWidth - props.height + 'px'
|
handler.value.style.left = numericWidth - props.height + 'px'
|
||||||
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
||||||
passVerify()
|
passVerify()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拖拽结束处理函数
|
* 拖拽结束处理函数
|
||||||
* @param e 鼠标或触摸事件对象
|
* @param e 鼠标或触摸事件对象
|
||||||
*/
|
*/
|
||||||
const dragFinish = (e: any) => {
|
const dragFinish = (e: any) => {
|
||||||
if (state.isMoving && !props.value) {
|
if (state.isMoving && !props.value) {
|
||||||
const numericWidth = getNumericWidth()
|
const numericWidth = getNumericWidth()
|
||||||
// 计算最终位置
|
// 计算最终位置
|
||||||
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
|
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
|
||||||
|
|
||||||
if (_x < numericWidth - props.height) {
|
if (_x < numericWidth - props.height) {
|
||||||
// 未拖拽到末端,重置位置
|
// 未拖拽到末端,重置位置
|
||||||
state.isOk = true
|
state.isOk = true
|
||||||
handler.value.style.left = '0'
|
handler.value.style.left = '0'
|
||||||
handler.value.style.transition = 'all 0.2s'
|
handler.value.style.transition = 'all 0.2s'
|
||||||
progressBar.value.style.width = '0'
|
progressBar.value.style.width = '0'
|
||||||
state.isOk = false
|
state.isOk = false
|
||||||
} else {
|
} else {
|
||||||
// 拖拽到末端,保持验证成功状态
|
// 拖拽到末端,保持验证成功状态
|
||||||
handler.value.style.transition = 'none'
|
handler.value.style.transition = 'none'
|
||||||
handler.value.style.left = numericWidth - props.height + 'px'
|
handler.value.style.left = numericWidth - props.height + 'px'
|
||||||
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
|
||||||
passVerify()
|
passVerify()
|
||||||
}
|
}
|
||||||
state.isMoving = false
|
state.isMoving = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证通过处理函数
|
* 验证通过处理函数
|
||||||
*/
|
*/
|
||||||
const passVerify = () => {
|
const passVerify = () => {
|
||||||
emit('update:value', true)
|
emit('update:value', true)
|
||||||
state.isMoving = false
|
state.isMoving = false
|
||||||
// 更新样式为成功状态
|
// 更新样式为成功状态
|
||||||
progressBar.value.style.background = props.completedBg
|
progressBar.value.style.background = props.completedBg
|
||||||
messageRef.value.style['-webkit-text-fill-color'] = 'unset'
|
messageRef.value.style['-webkit-text-fill-color'] = 'unset'
|
||||||
messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite'
|
messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite'
|
||||||
messageRef.value.style.color = '#fff'
|
messageRef.value.style.color = '#fff'
|
||||||
emit('passCallback')
|
emit('passCallback')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置验证状态函数
|
* 重置验证状态函数
|
||||||
*/
|
*/
|
||||||
const reset = () => {
|
const reset = () => {
|
||||||
// 重置滑块位置
|
// 重置滑块位置
|
||||||
handler.value.style.left = '0'
|
handler.value.style.left = '0'
|
||||||
progressBar.value.style.width = '0'
|
progressBar.value.style.width = '0'
|
||||||
progressBar.value.style.background = props.progressBarBg
|
progressBar.value.style.background = props.progressBarBg
|
||||||
// 重置文本样式
|
// 重置文本样式
|
||||||
messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
|
messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
|
||||||
messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite'
|
messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite'
|
||||||
messageRef.value.style.color = props.background
|
messageRef.value.style.color = props.background
|
||||||
// 重置状态
|
// 重置状态
|
||||||
emit('update:value', false)
|
emit('update:value', false)
|
||||||
state.isOk = false
|
state.isOk = false
|
||||||
state.isMoving = false
|
state.isMoving = false
|
||||||
state.x = 0
|
state.x = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露重置方法给父组件
|
// 暴露重置方法给父组件
|
||||||
defineExpose({
|
defineExpose({
|
||||||
reset
|
reset
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.drag_verify {
|
.drag_verify {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: 1px solid var(--default-border-dashed);
|
border: 1px solid var(--default-border-dashed);
|
||||||
|
|
||||||
.dv_handler {
|
.dv_handler {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: move;
|
cursor: move;
|
||||||
|
|
||||||
i {
|
i {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-icon-circle-check {
|
.el-icon-circle-check {
|
||||||
margin-top: 9px;
|
margin-top: 9px;
|
||||||
color: #6c6;
|
color: #6c6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dv_progress_bar {
|
.dv_progress_bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dv_text {
|
.dv_text {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to right,
|
to right,
|
||||||
var(--textColor) 0%,
|
var(--textColor) 0%,
|
||||||
var(--textColor) 40%,
|
var(--textColor) 40%,
|
||||||
#fff 50%,
|
#fff 50%,
|
||||||
var(--textColor) 60%,
|
var(--textColor) 60%,
|
||||||
var(--textColor) 100%
|
var(--textColor) 100%
|
||||||
);
|
);
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
|
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
text-size-adjust: none;
|
text-size-adjust: none;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
-webkit-text-fill-color: var(--textColor);
|
-webkit-text-fill-color: var(--textColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.goFirst {
|
.goFirst {
|
||||||
left: 0 !important;
|
left: 0 !important;
|
||||||
transition: left 0.5s;
|
transition: left 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.goFirst2 {
|
.goFirst2 {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
transition: width 0.5s;
|
transition: width 0.5s;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@keyframes slidetounlock {
|
@keyframes slidetounlock {
|
||||||
0% {
|
0% {
|
||||||
background-position: var(--pwidth) 0;
|
background-position: var(--pwidth) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
background-position: var(--width) 0;
|
background-position: var(--width) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slidetounlock2 {
|
@keyframes slidetounlock2 {
|
||||||
0% {
|
0% {
|
||||||
background-position: var(--pwidth) 0;
|
background-position: var(--pwidth) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
background-position: var(--pwidth) 0;
|
background-position: var(--pwidth) 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,389 +1,399 @@
|
|||||||
<!-- 导出 Excel 文件 -->
|
<!-- 导出 Excel 文件 -->
|
||||||
<template>
|
<template>
|
||||||
<ElButton
|
<ElButton
|
||||||
:type="type"
|
:type="type"
|
||||||
:size="size"
|
:size="size"
|
||||||
:loading="isExporting"
|
:loading="isExporting"
|
||||||
:disabled="disabled || !hasData"
|
:disabled="disabled || !hasData"
|
||||||
v-ripple
|
v-ripple
|
||||||
@click="handleExport"
|
@click="handleExport"
|
||||||
>
|
>
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<ElIcon class="is-loading">
|
<ElIcon class="is-loading">
|
||||||
<Loading />
|
<Loading />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
{{ loadingText }}
|
{{ loadingText }}
|
||||||
</template>
|
</template>
|
||||||
<slot>{{ buttonText }}</slot>
|
<slot>{{ buttonText }}</slot>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
import FileSaver from 'file-saver'
|
import FileSaver from 'file-saver'
|
||||||
import { ref, computed, nextTick } from 'vue'
|
import { ref, computed, nextTick } from 'vue'
|
||||||
import { Loading } from '@element-plus/icons-vue'
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
import type { ButtonType } from 'element-plus'
|
import type { ButtonType } from 'element-plus'
|
||||||
import { useThrottleFn } from '@vueuse/core'
|
import { useThrottleFn } from '@vueuse/core'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtExcelExport' })
|
defineOptions({ name: 'ArtExcelExport' })
|
||||||
|
|
||||||
/** 导出数据类型 */
|
/** 导出数据类型 */
|
||||||
type ExportValue = string | number | boolean | null | undefined | Date
|
type ExportValue = string | number | boolean | null | undefined | Date
|
||||||
|
|
||||||
interface ExportData {
|
interface ExportData {
|
||||||
[key: string]: ExportValue
|
[key: string]: ExportValue
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列配置 */
|
/** 列配置 */
|
||||||
interface ColumnConfig {
|
interface ColumnConfig {
|
||||||
/** 列标题 */
|
/** 列标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 列宽度 */
|
/** 列宽度 */
|
||||||
width?: number
|
width?: number
|
||||||
/** 数据格式化函数 */
|
/** 数据格式化函数 */
|
||||||
formatter?: (value: ExportValue, row: ExportData, index: number) => string
|
formatter?: (value: ExportValue, row: ExportData, index: number) => string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出配置选项 */
|
/** 导出配置选项 */
|
||||||
interface ExportOptions {
|
interface ExportOptions {
|
||||||
/** 数据源 */
|
/** 数据源 */
|
||||||
data: ExportData[]
|
data: ExportData[]
|
||||||
/** 文件名(不含扩展名) */
|
/** 文件名(不含扩展名) */
|
||||||
filename?: string
|
filename?: string
|
||||||
/** 工作表名称 */
|
/** 工作表名称 */
|
||||||
sheetName?: string
|
sheetName?: string
|
||||||
/** 按钮类型 */
|
/** 按钮类型 */
|
||||||
type?: ButtonType
|
type?: ButtonType
|
||||||
/** 按钮尺寸 */
|
/** 按钮尺寸 */
|
||||||
size?: 'large' | 'default' | 'small'
|
size?: 'large' | 'default' | 'small'
|
||||||
/** 是否禁用 */
|
/** 是否禁用 */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
/** 按钮文本 */
|
/** 按钮文本 */
|
||||||
buttonText?: string
|
buttonText?: string
|
||||||
/** 加载中文本 */
|
/** 加载中文本 */
|
||||||
loadingText?: string
|
loadingText?: string
|
||||||
/** 是否自动添加序号列 */
|
/** 是否自动添加序号列 */
|
||||||
autoIndex?: boolean
|
autoIndex?: boolean
|
||||||
/** 序号列标题 */
|
/** 序号列标题 */
|
||||||
indexColumnTitle?: string
|
indexColumnTitle?: string
|
||||||
/** 列配置映射 */
|
/** 列配置映射 */
|
||||||
columns?: Record<string, ColumnConfig>
|
columns?: Record<string, ColumnConfig>
|
||||||
/** 表头映射(简化版本,向后兼容) */
|
/** 表头映射(简化版本,向后兼容) */
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
/** 最大导出行数 */
|
/** 最大导出行数 */
|
||||||
maxRows?: number
|
maxRows?: number
|
||||||
/** 是否显示成功消息 */
|
/** 是否显示成功消息 */
|
||||||
showSuccessMessage?: boolean
|
showSuccessMessage?: boolean
|
||||||
/** 是否显示错误消息 */
|
/** 是否显示错误消息 */
|
||||||
showErrorMessage?: boolean
|
showErrorMessage?: boolean
|
||||||
/** 工作簿配置 */
|
/** 工作簿配置 */
|
||||||
workbookOptions?: {
|
workbookOptions?: {
|
||||||
/** 创建者 */
|
/** 创建者 */
|
||||||
creator?: string
|
creator?: string
|
||||||
/** 最后修改者 */
|
/** 最后修改者 */
|
||||||
lastModifiedBy?: string
|
lastModifiedBy?: string
|
||||||
/** 创建时间 */
|
/** 创建时间 */
|
||||||
created?: Date
|
created?: Date
|
||||||
/** 修改时间 */
|
/** 修改时间 */
|
||||||
modified?: Date
|
modified?: Date
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ExportOptions>(), {
|
const props = withDefaults(defineProps<ExportOptions>(), {
|
||||||
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
|
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
|
||||||
sheetName: 'Sheet1',
|
sheetName: 'Sheet1',
|
||||||
type: 'primary',
|
type: 'primary',
|
||||||
size: 'default',
|
size: 'default',
|
||||||
disabled: false,
|
disabled: false,
|
||||||
buttonText: '导出 Excel',
|
buttonText: '导出 Excel',
|
||||||
loadingText: '导出中...',
|
loadingText: '导出中...',
|
||||||
autoIndex: false,
|
autoIndex: false,
|
||||||
indexColumnTitle: '序号',
|
indexColumnTitle: '序号',
|
||||||
columns: () => ({}),
|
columns: () => ({}),
|
||||||
headers: () => ({}),
|
headers: () => ({}),
|
||||||
maxRows: 100000,
|
maxRows: 100000,
|
||||||
showSuccessMessage: true,
|
showSuccessMessage: true,
|
||||||
showErrorMessage: true,
|
showErrorMessage: true,
|
||||||
workbookOptions: () => ({})
|
workbookOptions: () => ({})
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'before-export': [data: ExportData[]]
|
'before-export': [data: ExportData[]]
|
||||||
'export-success': [filename: string, rowCount: number]
|
'export-success': [filename: string, rowCount: number]
|
||||||
'export-error': [error: ExportError]
|
'export-error': [error: ExportError]
|
||||||
'export-progress': [progress: number]
|
'export-progress': [progress: number]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/** 导出错误类型 */
|
/** 导出错误类型 */
|
||||||
class ExportError extends Error {
|
class ExportError extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public code: string,
|
public code: string,
|
||||||
public details?: any
|
public details?: any
|
||||||
) {
|
) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ExportError'
|
this.name = 'ExportError'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isExporting = ref(false)
|
const isExporting = ref(false)
|
||||||
|
|
||||||
/** 是否有数据可导出 */
|
/** 是否有数据可导出 */
|
||||||
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
|
||||||
|
|
||||||
/** 验证导出数据 */
|
/** 验证导出数据 */
|
||||||
const validateData = (data: ExportData[]): void => {
|
const validateData = (data: ExportData[]): void => {
|
||||||
if (!Array.isArray(data)) {
|
if (!Array.isArray(data)) {
|
||||||
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
|
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
throw new ExportError('没有可导出的数据', 'NO_DATA')
|
throw new ExportError('没有可导出的数据', 'NO_DATA')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length > props.maxRows) {
|
if (data.length > props.maxRows) {
|
||||||
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
|
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
|
||||||
currentRows: data.length,
|
currentRows: data.length,
|
||||||
maxRows: props.maxRows
|
maxRows: props.maxRows
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 格式化单元格值 */
|
/** 格式化单元格值 */
|
||||||
const formatCellValue = (
|
const formatCellValue = (
|
||||||
value: ExportValue,
|
value: ExportValue,
|
||||||
key: string,
|
key: string,
|
||||||
row: ExportData,
|
row: ExportData,
|
||||||
index: number
|
index: number
|
||||||
): string => {
|
): string => {
|
||||||
// 使用列配置的格式化函数
|
// 使用列配置的格式化函数
|
||||||
const column = props.columns[key]
|
const column = props.columns[key]
|
||||||
if (column?.formatter) {
|
if (column?.formatter) {
|
||||||
return column.formatter(value, row, index)
|
return column.formatter(value, row, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理特殊值
|
// 处理特殊值
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
return value.toLocaleDateString('zh-CN')
|
return value.toLocaleDateString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === 'boolean') {
|
if (typeof value === 'boolean') {
|
||||||
return value ? '是' : '否'
|
return value ? '是' : '否'
|
||||||
}
|
}
|
||||||
|
|
||||||
return String(value)
|
return String(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 处理数据 */
|
/** 处理数据 */
|
||||||
const processData = (data: ExportData[]): Record<string, string>[] => {
|
const processData = (data: ExportData[]): Record<string, string>[] => {
|
||||||
const processedData = data.map((item, index) => {
|
const processedData = data.map((item, index) => {
|
||||||
const processedItem: Record<string, string> = {}
|
const processedItem: Record<string, string> = {}
|
||||||
|
|
||||||
// 添加序号列
|
// 添加序号列
|
||||||
if (props.autoIndex) {
|
if (props.autoIndex) {
|
||||||
processedItem[props.indexColumnTitle] = String(index + 1)
|
processedItem[props.indexColumnTitle] = String(index + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理数据列
|
// 处理数据列
|
||||||
Object.entries(item).forEach(([key, value]) => {
|
Object.entries(item).forEach(([key, value]) => {
|
||||||
// 获取列标题
|
// 获取列标题
|
||||||
let columnTitle = key
|
let columnTitle = key
|
||||||
if (props.columns[key]?.title) {
|
if (props.columns[key]?.title) {
|
||||||
columnTitle = props.columns[key].title
|
columnTitle = props.columns[key].title
|
||||||
} else if (props.headers[key]) {
|
} else if (props.headers[key]) {
|
||||||
columnTitle = props.headers[key]
|
columnTitle = props.headers[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化值
|
// 格式化值
|
||||||
processedItem[columnTitle] = formatCellValue(value, key, item, index)
|
processedItem[columnTitle] = formatCellValue(value, key, item, index)
|
||||||
})
|
})
|
||||||
|
|
||||||
return processedItem
|
return processedItem
|
||||||
})
|
})
|
||||||
|
|
||||||
return processedData
|
return processedData
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 计算列宽度 */
|
/** 计算列宽度 */
|
||||||
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
|
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
|
||||||
if (data.length === 0) return []
|
if (data.length === 0) return []
|
||||||
|
|
||||||
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
|
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
|
||||||
const columns = Object.keys(data[0])
|
const columns = Object.keys(data[0])
|
||||||
|
|
||||||
return columns.map((column) => {
|
return columns.map((column) => {
|
||||||
// 使用配置的列宽度
|
// 使用配置的列宽度
|
||||||
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width
|
const configWidth = Object.values(props.columns).find(
|
||||||
|
(col) => col.title === column
|
||||||
|
)?.width
|
||||||
|
|
||||||
if (configWidth) {
|
if (configWidth) {
|
||||||
return { wch: configWidth }
|
return { wch: configWidth }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动计算列宽度
|
// 自动计算列宽度
|
||||||
const maxLength = Math.max(
|
const maxLength = Math.max(
|
||||||
column.length, // 标题长度
|
column.length, // 标题长度
|
||||||
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
|
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 限制最小和最大宽度
|
// 限制最小和最大宽度
|
||||||
const width = Math.min(Math.max(maxLength + 2, 8), 50)
|
const width = Math.min(Math.max(maxLength + 2, 8), 50)
|
||||||
return { wch: width }
|
return { wch: width }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 导出到 Excel */
|
/** 导出到 Excel */
|
||||||
const exportToExcel = async (
|
const exportToExcel = async (
|
||||||
data: ExportData[],
|
data: ExportData[],
|
||||||
filename: string,
|
filename: string,
|
||||||
sheetName: string
|
sheetName: string
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
emit('export-progress', 10)
|
emit('export-progress', 10)
|
||||||
|
|
||||||
// 处理数据
|
// 处理数据
|
||||||
const processedData = processData(data)
|
const processedData = processData(data)
|
||||||
emit('export-progress', 30)
|
emit('export-progress', 30)
|
||||||
|
|
||||||
// 创建工作簿
|
// 创建工作簿
|
||||||
const workbook = XLSX.utils.book_new()
|
const workbook = XLSX.utils.book_new()
|
||||||
|
|
||||||
// 设置工作簿属性
|
// 设置工作簿属性
|
||||||
if (props.workbookOptions) {
|
if (props.workbookOptions) {
|
||||||
workbook.Props = {
|
workbook.Props = {
|
||||||
Title: filename,
|
Title: filename,
|
||||||
Subject: '数据导出',
|
Subject: '数据导出',
|
||||||
Author: props.workbookOptions.creator || 'Art Design Pro',
|
Author: props.workbookOptions.creator || 'Art Design Pro',
|
||||||
Manager: props.workbookOptions.lastModifiedBy || '',
|
Manager: props.workbookOptions.lastModifiedBy || '',
|
||||||
Company: '系统导出',
|
Company: '系统导出',
|
||||||
Category: '数据',
|
Category: '数据',
|
||||||
Keywords: 'excel,export,data',
|
Keywords: 'excel,export,data',
|
||||||
Comments: '由系统自动生成',
|
Comments: '由系统自动生成',
|
||||||
CreatedDate: props.workbookOptions.created || new Date(),
|
CreatedDate: props.workbookOptions.created || new Date(),
|
||||||
ModifiedDate: props.workbookOptions.modified || new Date()
|
ModifiedDate: props.workbookOptions.modified || new Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emit('export-progress', 50)
|
emit('export-progress', 50)
|
||||||
|
|
||||||
// 创建工作表
|
// 创建工作表
|
||||||
const worksheet = XLSX.utils.json_to_sheet(processedData)
|
const worksheet = XLSX.utils.json_to_sheet(processedData)
|
||||||
|
|
||||||
// 设置列宽度
|
// 设置列宽度
|
||||||
worksheet['!cols'] = calculateColumnWidths(processedData)
|
worksheet['!cols'] = calculateColumnWidths(processedData)
|
||||||
|
|
||||||
emit('export-progress', 70)
|
emit('export-progress', 70)
|
||||||
|
|
||||||
// 添加工作表到工作簿
|
// 添加工作表到工作簿
|
||||||
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
|
||||||
|
|
||||||
emit('export-progress', 85)
|
emit('export-progress', 85)
|
||||||
|
|
||||||
// 生成 Excel 文件
|
// 生成 Excel 文件
|
||||||
const excelBuffer = XLSX.write(workbook, {
|
const excelBuffer = XLSX.write(workbook, {
|
||||||
bookType: 'xlsx',
|
bookType: 'xlsx',
|
||||||
type: 'array',
|
type: 'array',
|
||||||
compression: true
|
compression: true
|
||||||
})
|
})
|
||||||
|
|
||||||
// 创建 Blob 并下载
|
// 创建 Blob 并下载
|
||||||
const blob = new Blob([excelBuffer], {
|
const blob = new Blob([excelBuffer], {
|
||||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||||
})
|
})
|
||||||
|
|
||||||
emit('export-progress', 95)
|
emit('export-progress', 95)
|
||||||
|
|
||||||
// 使用时间戳确保文件名唯一
|
// 使用时间戳确保文件名唯一
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
const finalFilename = `${filename}_${timestamp}.xlsx`
|
const finalFilename = `${filename}_${timestamp}.xlsx`
|
||||||
|
|
||||||
FileSaver.saveAs(blob, finalFilename)
|
FileSaver.saveAs(blob, finalFilename)
|
||||||
|
|
||||||
emit('export-progress', 100)
|
emit('export-progress', 100)
|
||||||
|
|
||||||
// 等待下载开始
|
// 等待下载开始
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, 'EXPORT_FAILED', error)
|
throw new ExportError(
|
||||||
}
|
`Excel 导出失败: ${(error as Error).message}`,
|
||||||
}
|
'EXPORT_FAILED',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 处理导出 */
|
/** 处理导出 */
|
||||||
const handleExport = useThrottleFn(async () => {
|
const handleExport = useThrottleFn(async () => {
|
||||||
if (isExporting.value) return
|
if (isExporting.value) return
|
||||||
|
|
||||||
isExporting.value = true
|
isExporting.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 验证数据
|
// 验证数据
|
||||||
validateData(props.data)
|
validateData(props.data)
|
||||||
|
|
||||||
// 触发导出前事件
|
// 触发导出前事件
|
||||||
emit('before-export', props.data)
|
emit('before-export', props.data)
|
||||||
|
|
||||||
// 执行导出
|
// 执行导出
|
||||||
await exportToExcel(props.data, props.filename, props.sheetName)
|
await exportToExcel(props.data, props.filename, props.sheetName)
|
||||||
|
|
||||||
// 触发成功事件
|
// 触发成功事件
|
||||||
emit('export-success', props.filename, props.data.length)
|
emit('export-success', props.filename, props.data.length)
|
||||||
|
|
||||||
// 显示成功消息
|
// 显示成功消息
|
||||||
if (props.showSuccessMessage) {
|
if (props.showSuccessMessage) {
|
||||||
ElMessage.success({
|
ElMessage.success({
|
||||||
message: `成功导出 ${props.data.length} 条数据`,
|
message: `成功导出 ${props.data.length} 条数据`,
|
||||||
duration: 3000
|
duration: 3000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const exportError =
|
const exportError =
|
||||||
error instanceof ExportError
|
error instanceof ExportError
|
||||||
? error
|
? error
|
||||||
: new ExportError(`导出失败: ${(error as Error).message}`, 'UNKNOWN_ERROR', error)
|
: new ExportError(
|
||||||
|
`导出失败: ${(error as Error).message}`,
|
||||||
|
'UNKNOWN_ERROR',
|
||||||
|
error
|
||||||
|
)
|
||||||
|
|
||||||
// 触发错误事件
|
// 触发错误事件
|
||||||
emit('export-error', exportError)
|
emit('export-error', exportError)
|
||||||
|
|
||||||
// 显示错误消息
|
// 显示错误消息
|
||||||
if (props.showErrorMessage) {
|
if (props.showErrorMessage) {
|
||||||
ElMessage.error({
|
ElMessage.error({
|
||||||
message: exportError.message,
|
message: exportError.message,
|
||||||
duration: 5000
|
duration: 5000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error('Excel 导出错误:', exportError)
|
console.error('Excel 导出错误:', exportError)
|
||||||
} finally {
|
} finally {
|
||||||
isExporting.value = false
|
isExporting.value = false
|
||||||
emit('export-progress', 0)
|
emit('export-progress', 0)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
|
||||||
// 暴露方法供父组件调用
|
// 暴露方法供父组件调用
|
||||||
defineExpose({
|
defineExpose({
|
||||||
exportData: handleExport,
|
exportData: handleExport,
|
||||||
isExporting: readonly(isExporting),
|
isExporting: readonly(isExporting),
|
||||||
hasData
|
hasData
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.is-loading {
|
.is-loading {
|
||||||
animation: rotating 2s linear infinite;
|
animation: rotating 2s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rotating {
|
@keyframes rotating {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
<!-- 导入 Excel 文件 -->
|
<!-- 导入 Excel 文件 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="inline-block">
|
<div class="inline-block">
|
||||||
<ElUpload
|
<ElUpload
|
||||||
:auto-upload="false"
|
:auto-upload="false"
|
||||||
accept=".xlsx, .xls"
|
accept=".xlsx, .xls"
|
||||||
:show-file-list="false"
|
:show-file-list="false"
|
||||||
@change="handleFileChange"
|
@change="handleFileChange"
|
||||||
>
|
>
|
||||||
<ElButton type="primary" v-ripple>
|
<ElButton type="primary" v-ripple>
|
||||||
<slot>导入 Excel</slot>
|
<slot>导入 Excel</slot>
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</ElUpload>
|
</ElUpload>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as XLSX from 'xlsx'
|
import * as XLSX from 'xlsx'
|
||||||
import type { UploadFile } from 'element-plus'
|
import type { UploadFile } from 'element-plus'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtExcelImport' })
|
defineOptions({ name: 'ArtExcelImport' })
|
||||||
|
|
||||||
// Excel 导入工具函数
|
// Excel 导入工具函数
|
||||||
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
|
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
|
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
try {
|
try {
|
||||||
const data = e.target?.result
|
const data = e.target?.result
|
||||||
const workbook = XLSX.read(data, { type: 'array' })
|
const workbook = XLSX.read(data, { type: 'array' })
|
||||||
const firstSheetName = workbook.SheetNames[0]
|
const firstSheetName = workbook.SheetNames[0]
|
||||||
const worksheet = workbook.Sheets[firstSheetName]
|
const worksheet = workbook.Sheets[firstSheetName]
|
||||||
const results = XLSX.utils.sheet_to_json(worksheet)
|
const results = XLSX.utils.sheet_to_json(worksheet)
|
||||||
resolve(results as Array<Record<string, unknown>>)
|
resolve(results as Array<Record<string, unknown>>)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reject(error)
|
reject(error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reader.onerror = (error) => reject(error)
|
reader.onerror = (error) => reject(error)
|
||||||
reader.readAsArrayBuffer(file)
|
reader.readAsArrayBuffer(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义 emits
|
// 定义 emits
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'import-success': [data: Array<Record<string, unknown>>]
|
'import-success': [data: Array<Record<string, unknown>>]
|
||||||
'import-error': [error: Error]
|
'import-error': [error: Error]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 处理文件导入
|
// 处理文件导入
|
||||||
const handleFileChange = async (uploadFile: UploadFile) => {
|
const handleFileChange = async (uploadFile: UploadFile) => {
|
||||||
try {
|
try {
|
||||||
if (!uploadFile.raw) return
|
if (!uploadFile.raw) return
|
||||||
const results = await importExcel(uploadFile.raw)
|
const results = await importExcel(uploadFile.raw)
|
||||||
emit('import-success', results)
|
emit('import-success', results)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit('import-error', error as Error)
|
emit('import-error', error as Error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,310 +2,323 @@
|
|||||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||||
<template>
|
<template>
|
||||||
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
|
||||||
<ElForm
|
<ElForm
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="modelValue"
|
:model="modelValue"
|
||||||
:label-position="labelPosition"
|
:label-position="labelPosition"
|
||||||
v-bind="{ ...$attrs }"
|
v-bind="{ ...$attrs }"
|
||||||
>
|
>
|
||||||
<ElRow class="flex flex-wrap" :gutter="gutter">
|
<ElRow class="flex flex-wrap" :gutter="gutter">
|
||||||
<ElCol
|
<ElCol
|
||||||
v-for="item in visibleFormItems"
|
v-for="item in visibleFormItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
:xs="getColSpan(item.span, 'xs')"
|
:xs="getColSpan(item.span, 'xs')"
|
||||||
:sm="getColSpan(item.span, 'sm')"
|
:sm="getColSpan(item.span, 'sm')"
|
||||||
:md="getColSpan(item.span, 'md')"
|
:md="getColSpan(item.span, 'md')"
|
||||||
:lg="getColSpan(item.span, 'lg')"
|
:lg="getColSpan(item.span, 'lg')"
|
||||||
:xl="getColSpan(item.span, 'xl')"
|
:xl="getColSpan(item.span, 'xl')"
|
||||||
>
|
>
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:prop="item.key"
|
:prop="item.key"
|
||||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||||
>
|
>
|
||||||
<template #label v-if="item.label">
|
<template #label v-if="item.label">
|
||||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||||
<span v-else>{{ item.label }}</span>
|
<span v-else>{{ item.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||||
<component
|
<component
|
||||||
:is="getComponent(item)"
|
:is="getComponent(item)"
|
||||||
v-model="modelValue[item.key]"
|
v-model="modelValue[item.key]"
|
||||||
v-bind="getProps(item)"
|
v-bind="getProps(item)"
|
||||||
>
|
>
|
||||||
<!-- 下拉选择 -->
|
<!-- 下拉选择 -->
|
||||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="option in getProps(item).options"
|
v-for="option in getProps(item).options"
|
||||||
v-bind="option"
|
v-bind="option"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 复选框组 -->
|
<!-- 复选框组 -->
|
||||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
<template
|
||||||
<ElCheckbox
|
v-if="item.type === 'checkboxgroup' && getProps(item)?.options"
|
||||||
v-for="option in getProps(item).options"
|
>
|
||||||
v-bind="option"
|
<ElCheckbox
|
||||||
:key="option.value"
|
v-for="option in getProps(item).options"
|
||||||
/>
|
v-bind="option"
|
||||||
</template>
|
:key="option.value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 单选框组 -->
|
<!-- 单选框组 -->
|
||||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
<template
|
||||||
<ElRadio
|
v-if="item.type === 'radiogroup' && getProps(item)?.options"
|
||||||
v-for="option in getProps(item).options"
|
>
|
||||||
v-bind="option"
|
<ElRadio
|
||||||
:key="option.value"
|
v-for="option in getProps(item).options"
|
||||||
/>
|
v-bind="option"
|
||||||
</template>
|
:key="option.value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 动态插槽支持 -->
|
<!-- 动态插槽支持 -->
|
||||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
<template
|
||||||
<component :is="slotFn" />
|
v-for="(slotFn, slotName) in getSlots(item)"
|
||||||
</template>
|
:key="slotName"
|
||||||
</component>
|
#[slotName]
|
||||||
</slot>
|
>
|
||||||
</ElFormItem>
|
<component :is="slotFn" />
|
||||||
</ElCol>
|
</template>
|
||||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
</component>
|
||||||
<div
|
</slot>
|
||||||
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
</ElFormItem>
|
||||||
:style="actionButtonsStyle"
|
</ElCol>
|
||||||
>
|
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
|
||||||
<div class="flex gap-2 md:justify-center">
|
<div
|
||||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
|
||||||
{{ t('table.form.reset') }}
|
:style="actionButtonsStyle"
|
||||||
</ElButton>
|
>
|
||||||
<ElButton
|
<div class="flex gap-2 md:justify-center">
|
||||||
v-if="showSubmit"
|
<ElButton
|
||||||
type="primary"
|
v-if="showReset"
|
||||||
class="submit-button"
|
class="reset-button"
|
||||||
@click="handleSubmit"
|
@click="handleReset"
|
||||||
v-ripple
|
v-ripple
|
||||||
:disabled="disabledSubmit"
|
>
|
||||||
>
|
{{ t('table.form.reset') }}
|
||||||
{{ t('table.form.submit') }}
|
</ElButton>
|
||||||
</ElButton>
|
<ElButton
|
||||||
</div>
|
v-if="showSubmit"
|
||||||
</div>
|
type="primary"
|
||||||
</ElCol>
|
class="submit-button"
|
||||||
</ElRow>
|
@click="handleSubmit"
|
||||||
</ElForm>
|
v-ripple
|
||||||
</section>
|
:disabled="disabledSubmit"
|
||||||
|
>
|
||||||
|
{{ t('table.form.submit') }}
|
||||||
|
</ElButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</ElForm>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import {
|
import {
|
||||||
ElCascader,
|
ElCascader,
|
||||||
ElCheckbox,
|
ElCheckbox,
|
||||||
ElCheckboxGroup,
|
ElCheckboxGroup,
|
||||||
ElDatePicker,
|
ElDatePicker,
|
||||||
ElInput,
|
ElInput,
|
||||||
ElInputTag,
|
ElInputTag,
|
||||||
ElInputNumber,
|
ElInputNumber,
|
||||||
ElRadioGroup,
|
ElRadioGroup,
|
||||||
ElRate,
|
ElRate,
|
||||||
ElSelect,
|
ElSelect,
|
||||||
ElSlider,
|
ElSlider,
|
||||||
ElSwitch,
|
ElSwitch,
|
||||||
ElTimePicker,
|
ElTimePicker,
|
||||||
ElTimeSelect,
|
ElTimeSelect,
|
||||||
ElTreeSelect,
|
ElTreeSelect,
|
||||||
type FormInstance
|
type FormInstance
|
||||||
} from 'element-plus'
|
} from 'element-plus'
|
||||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtForm' })
|
defineOptions({ name: 'ArtForm' })
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
input: ElInput, // 输入框
|
input: ElInput, // 输入框
|
||||||
inputtag: ElInputTag, // 标签输入框
|
inputtag: ElInputTag, // 标签输入框
|
||||||
number: ElInputNumber, // 数字输入框
|
number: ElInputNumber, // 数字输入框
|
||||||
select: ElSelect, // 选择器
|
select: ElSelect, // 选择器
|
||||||
switch: ElSwitch, // 开关
|
switch: ElSwitch, // 开关
|
||||||
checkbox: ElCheckbox, // 复选框
|
checkbox: ElCheckbox, // 复选框
|
||||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||||
radiogroup: ElRadioGroup, // 单选框组
|
radiogroup: ElRadioGroup, // 单选框组
|
||||||
date: ElDatePicker, // 日期选择器
|
date: ElDatePicker, // 日期选择器
|
||||||
daterange: ElDatePicker, // 日期范围选择器
|
daterange: ElDatePicker, // 日期范围选择器
|
||||||
datetime: ElDatePicker, // 日期时间选择器
|
datetime: ElDatePicker, // 日期时间选择器
|
||||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||||
rate: ElRate, // 评分
|
rate: ElRate, // 评分
|
||||||
slider: ElSlider, // 滑块
|
slider: ElSlider, // 滑块
|
||||||
cascader: ElCascader, // 级联选择器
|
cascader: ElCascader, // 级联选择器
|
||||||
timepicker: ElTimePicker, // 时间选择器
|
timepicker: ElTimePicker, // 时间选择器
|
||||||
timeselect: ElTimeSelect, // 时间选择
|
timeselect: ElTimeSelect, // 时间选择
|
||||||
treeselect: ElTreeSelect // 树选择器
|
treeselect: ElTreeSelect // 树选择器
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isMobile = computed(() => width.value < 500)
|
const isMobile = computed(() => width.value < 500)
|
||||||
|
|
||||||
const formInstance = useTemplateRef<FormInstance>('formRef')
|
const formInstance = useTemplateRef<FormInstance>('formRef')
|
||||||
|
|
||||||
// 表单项配置
|
// 表单项配置
|
||||||
export interface FormItem {
|
export interface FormItem {
|
||||||
/** 表单项的唯一标识 */
|
/** 表单项的唯一标识 */
|
||||||
key: string
|
key: string
|
||||||
/** 表单项的标签文本或自定义渲染函数 */
|
/** 表单项的标签文本或自定义渲染函数 */
|
||||||
label: string | (() => VNode) | Component
|
label: string | (() => VNode) | Component
|
||||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||||
labelWidth?: string | number
|
labelWidth?: string | number
|
||||||
/** 表单项类型,支持预定义的组件类型 */
|
/** 表单项类型,支持预定义的组件类型 */
|
||||||
type?: keyof typeof componentMap | string
|
type?: keyof typeof componentMap | string
|
||||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||||
render?: (() => VNode) | Component
|
render?: (() => VNode) | Component
|
||||||
/** 是否隐藏该表单项 */
|
/** 是否隐藏该表单项 */
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||||
span?: number
|
span?: number
|
||||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||||
options?: Record<string, any>
|
options?: Record<string, any>
|
||||||
/** 传递给表单项组件的属性 */
|
/** 传递给表单项组件的属性 */
|
||||||
props?: Record<string, any>
|
props?: Record<string, any>
|
||||||
/** 表单项的插槽配置 */
|
/** 表单项的插槽配置 */
|
||||||
slots?: Record<string, (() => any) | undefined>
|
slots?: Record<string, (() => any) | undefined>
|
||||||
/** 表单项的占位符文本 */
|
/** 表单项的占位符文本 */
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单配置
|
// 表单配置
|
||||||
interface FormProps {
|
interface FormProps {
|
||||||
/** 表单数据 */
|
/** 表单数据 */
|
||||||
items: FormItem[]
|
items: FormItem[]
|
||||||
/** 每列的宽度(基于 24 格布局) */
|
/** 每列的宽度(基于 24 格布局) */
|
||||||
span?: number
|
span?: number
|
||||||
/** 表单控件间隙 */
|
/** 表单控件间隙 */
|
||||||
gutter?: number
|
gutter?: number
|
||||||
/** 表单域标签的位置 */
|
/** 表单域标签的位置 */
|
||||||
labelPosition?: 'left' | 'right' | 'top'
|
labelPosition?: 'left' | 'right' | 'top'
|
||||||
/** 文字宽度 */
|
/** 文字宽度 */
|
||||||
labelWidth?: string | number
|
labelWidth?: string | number
|
||||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||||
buttonLeftLimit?: number
|
buttonLeftLimit?: number
|
||||||
/** 是否显示重置按钮 */
|
/** 是否显示重置按钮 */
|
||||||
showReset?: boolean
|
showReset?: boolean
|
||||||
/** 是否显示提交按钮 */
|
/** 是否显示提交按钮 */
|
||||||
showSubmit?: boolean
|
showSubmit?: boolean
|
||||||
/** 是否禁用提交按钮 */
|
/** 是否禁用提交按钮 */
|
||||||
disabledSubmit?: boolean
|
disabledSubmit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<FormProps>(), {
|
const props = withDefaults(defineProps<FormProps>(), {
|
||||||
items: () => [],
|
items: () => [],
|
||||||
span: 6,
|
span: 6,
|
||||||
gutter: 12,
|
gutter: 12,
|
||||||
labelPosition: 'right',
|
labelPosition: 'right',
|
||||||
labelWidth: '70px',
|
labelWidth: '70px',
|
||||||
buttonLeftLimit: 2,
|
buttonLeftLimit: 2,
|
||||||
showReset: true,
|
showReset: true,
|
||||||
showSubmit: true,
|
showSubmit: true,
|
||||||
disabledSubmit: false
|
disabledSubmit: false
|
||||||
})
|
})
|
||||||
|
|
||||||
interface FormEmits {
|
interface FormEmits {
|
||||||
reset: []
|
reset: []
|
||||||
submit: []
|
submit: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<FormEmits>()
|
const emit = defineEmits<FormEmits>()
|
||||||
|
|
||||||
const modelValue = defineModel<Record<string, any>>({ default: {} })
|
const modelValue = defineModel<Record<string, any>>({ default: {} })
|
||||||
|
|
||||||
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
|
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
|
||||||
|
|
||||||
const getProps = (item: FormItem) => {
|
const getProps = (item: FormItem) => {
|
||||||
if (item.props) return item.props
|
if (item.props) return item.props
|
||||||
const props = { ...item }
|
const props = { ...item }
|
||||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
||||||
return props
|
return props
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取插槽
|
// 获取插槽
|
||||||
const getSlots = (item: FormItem) => {
|
const getSlots = (item: FormItem) => {
|
||||||
if (!item.slots) return {}
|
if (!item.slots) return {}
|
||||||
const validSlots: Record<string, () => any> = {}
|
const validSlots: Record<string, () => any> = {}
|
||||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||||
if (slotFn) {
|
if (slotFn) {
|
||||||
validSlots[key] = slotFn
|
validSlots[key] = slotFn
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return validSlots
|
return validSlots
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件
|
// 组件
|
||||||
const getComponent = (item: FormItem) => {
|
const getComponent = (item: FormItem) => {
|
||||||
// 优先使用 render 函数或组件渲染自定义组件
|
// 优先使用 render 函数或组件渲染自定义组件
|
||||||
if (item.render) {
|
if (item.render) {
|
||||||
return item.render
|
return item.render
|
||||||
}
|
}
|
||||||
// 使用 type 获取预定义组件
|
// 使用 type 获取预定义组件
|
||||||
const { type } = item
|
const { type } = item
|
||||||
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取列宽 span 值
|
* 获取列宽 span 值
|
||||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||||
*/
|
*/
|
||||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可见的表单项
|
* 可见的表单项
|
||||||
*/
|
*/
|
||||||
const visibleFormItems = computed(() => {
|
const visibleFormItems = computed(() => {
|
||||||
return props.items.filter((item) => !item.hidden)
|
return props.items.filter((item) => !item.hidden)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 操作按钮样式
|
* 操作按钮样式
|
||||||
*/
|
*/
|
||||||
const actionButtonsStyle = computed(() => ({
|
const actionButtonsStyle = computed(() => ({
|
||||||
'justify-content': isMobile.value
|
'justify-content': isMobile.value
|
||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||||
? 'flex-start'
|
? 'flex-start'
|
||||||
: 'flex-end'
|
: 'flex-end'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理重置事件
|
* 处理重置事件
|
||||||
*/
|
*/
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
// 重置表单字段(UI 层)
|
// 重置表单字段(UI 层)
|
||||||
formInstance.value?.resetFields()
|
formInstance.value?.resetFields()
|
||||||
|
|
||||||
// 清空所有表单项值(包含隐藏项)
|
// 清空所有表单项值(包含隐藏项)
|
||||||
Object.assign(
|
Object.assign(
|
||||||
modelValue.value,
|
modelValue.value,
|
||||||
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
||||||
)
|
)
|
||||||
|
|
||||||
// 触发 reset 事件
|
// 触发 reset 事件
|
||||||
emit('reset')
|
emit('reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理提交事件
|
* 处理提交事件
|
||||||
*/
|
*/
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
emit('submit')
|
emit('submit')
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
ref: formInstance,
|
ref: formInstance,
|
||||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||||
reset: handleReset
|
reset: handleReset
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解构 props 以便在模板中直接使用
|
// 解构 props 以便在模板中直接使用
|
||||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,436 +2,455 @@
|
|||||||
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
<!-- 支持常用表单组件、自定义组件、插槽、校验、隐藏表单项 -->
|
||||||
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
<!-- 写法同 ElementPlus 官方文档组件,把属性写在 props 里面就可以了 -->
|
||||||
<template>
|
<template>
|
||||||
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
|
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
|
||||||
<ElForm
|
<ElForm
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="modelValue"
|
:model="modelValue"
|
||||||
:label-position="labelPosition"
|
:label-position="labelPosition"
|
||||||
v-bind="{ ...$attrs }"
|
v-bind="{ ...$attrs }"
|
||||||
>
|
>
|
||||||
<ElRow :gutter="gutter">
|
<ElRow :gutter="gutter">
|
||||||
<ElCol
|
<ElCol
|
||||||
v-for="item in visibleFormItems"
|
v-for="item in visibleFormItems"
|
||||||
:key="item.key"
|
:key="item.key"
|
||||||
:xs="getColSpan(item.span, 'xs')"
|
:xs="getColSpan(item.span, 'xs')"
|
||||||
:sm="getColSpan(item.span, 'sm')"
|
:sm="getColSpan(item.span, 'sm')"
|
||||||
:md="getColSpan(item.span, 'md')"
|
:md="getColSpan(item.span, 'md')"
|
||||||
:lg="getColSpan(item.span, 'lg')"
|
:lg="getColSpan(item.span, 'lg')"
|
||||||
:xl="getColSpan(item.span, 'xl')"
|
:xl="getColSpan(item.span, 'xl')"
|
||||||
>
|
>
|
||||||
<ElFormItem
|
<ElFormItem
|
||||||
:prop="item.key"
|
:prop="item.key"
|
||||||
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
|
||||||
>
|
>
|
||||||
<template #label v-if="item.label">
|
<template #label v-if="item.label">
|
||||||
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
<component v-if="typeof item.label !== 'string'" :is="item.label" />
|
||||||
<span v-else>{{ item.label }}</span>
|
<span v-else>{{ item.label }}</span>
|
||||||
</template>
|
</template>
|
||||||
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
<slot :name="item.key" :item="item" :modelValue="modelValue">
|
||||||
<component
|
<component
|
||||||
:is="getComponent(item)"
|
:is="getComponent(item)"
|
||||||
v-model="modelValue[item.key]"
|
v-model="modelValue[item.key]"
|
||||||
v-bind="getProps(item)"
|
v-bind="getProps(item)"
|
||||||
>
|
>
|
||||||
<!-- 下拉选择 -->
|
<!-- 下拉选择 -->
|
||||||
<template v-if="item.type === 'select' && getProps(item)?.options">
|
<template v-if="item.type === 'select' && getProps(item)?.options">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="option in getProps(item).options"
|
v-for="option in getProps(item).options"
|
||||||
v-bind="option"
|
v-bind="option"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 复选框组 -->
|
<!-- 复选框组 -->
|
||||||
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
|
<template
|
||||||
<ElCheckbox
|
v-if="item.type === 'checkboxgroup' && getProps(item)?.options"
|
||||||
v-for="option in getProps(item).options"
|
>
|
||||||
v-bind="option"
|
<ElCheckbox
|
||||||
:key="option.value"
|
v-for="option in getProps(item).options"
|
||||||
/>
|
v-bind="option"
|
||||||
</template>
|
:key="option.value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 单选框组 -->
|
<!-- 单选框组 -->
|
||||||
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
|
<template
|
||||||
<ElRadio
|
v-if="item.type === 'radiogroup' && getProps(item)?.options"
|
||||||
v-for="option in getProps(item).options"
|
>
|
||||||
v-bind="option"
|
<ElRadio
|
||||||
:key="option.value"
|
v-for="option in getProps(item).options"
|
||||||
/>
|
v-bind="option"
|
||||||
</template>
|
:key="option.value"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 动态插槽支持 -->
|
<!-- 动态插槽支持 -->
|
||||||
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
|
<template
|
||||||
<component :is="slotFn" />
|
v-for="(slotFn, slotName) in getSlots(item)"
|
||||||
</template>
|
:key="slotName"
|
||||||
</component>
|
#[slotName]
|
||||||
</slot>
|
>
|
||||||
</ElFormItem>
|
<component :is="slotFn" />
|
||||||
</ElCol>
|
</template>
|
||||||
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
</component>
|
||||||
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
</slot>
|
||||||
<div class="form-buttons">
|
</ElFormItem>
|
||||||
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
|
</ElCol>
|
||||||
{{ t('table.searchBar.reset') }}
|
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
|
||||||
</ElButton>
|
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
|
||||||
<ElButton
|
<div class="form-buttons">
|
||||||
v-if="showSearch"
|
<ElButton
|
||||||
type="primary"
|
v-if="showReset"
|
||||||
class="search-button"
|
class="reset-button"
|
||||||
@click="handleSearch"
|
@click="handleReset"
|
||||||
v-ripple
|
v-ripple
|
||||||
:disabled="disabledSearch"
|
>
|
||||||
>
|
{{ t('table.searchBar.reset') }}
|
||||||
{{ t('table.searchBar.search') }}
|
</ElButton>
|
||||||
</ElButton>
|
<ElButton
|
||||||
</div>
|
v-if="showSearch"
|
||||||
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
|
type="primary"
|
||||||
<span>{{ expandToggleText }}</span>
|
class="search-button"
|
||||||
<div class="icon-wrapper">
|
@click="handleSearch"
|
||||||
<ElIcon>
|
v-ripple
|
||||||
<ArrowUpBold v-if="isExpanded" />
|
:disabled="disabledSearch"
|
||||||
<ArrowDownBold v-else />
|
>
|
||||||
</ElIcon>
|
{{ t('table.searchBar.search') }}
|
||||||
</div>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</ElCol>
|
v-if="shouldShowExpandToggle"
|
||||||
</ElRow>
|
class="filter-toggle"
|
||||||
</ElForm>
|
@click="toggleExpand"
|
||||||
</section>
|
>
|
||||||
|
<span>{{ expandToggleText }}</span>
|
||||||
|
<div class="icon-wrapper">
|
||||||
|
<ElIcon>
|
||||||
|
<ArrowUpBold v-if="isExpanded" />
|
||||||
|
<ArrowDownBold v-else />
|
||||||
|
</ElIcon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElCol>
|
||||||
|
</ElRow>
|
||||||
|
</ElForm>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
|
||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import {
|
import {
|
||||||
ElCascader,
|
ElCascader,
|
||||||
ElCheckbox,
|
ElCheckbox,
|
||||||
ElCheckboxGroup,
|
ElCheckboxGroup,
|
||||||
ElDatePicker,
|
ElDatePicker,
|
||||||
ElInput,
|
ElInput,
|
||||||
ElInputTag,
|
ElInputTag,
|
||||||
ElInputNumber,
|
ElInputNumber,
|
||||||
ElRadioGroup,
|
ElRadioGroup,
|
||||||
ElRate,
|
ElRate,
|
||||||
ElSelect,
|
ElSelect,
|
||||||
ElSlider,
|
ElSlider,
|
||||||
ElSwitch,
|
ElSwitch,
|
||||||
ElTimePicker,
|
ElTimePicker,
|
||||||
ElTimeSelect,
|
ElTimeSelect,
|
||||||
ElTreeSelect,
|
ElTreeSelect,
|
||||||
type FormInstance
|
type FormInstance
|
||||||
} from 'element-plus'
|
} from 'element-plus'
|
||||||
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtSearchBar' })
|
defineOptions({ name: 'ArtSearchBar' })
|
||||||
|
|
||||||
const componentMap = {
|
const componentMap = {
|
||||||
input: ElInput, // 输入框
|
input: ElInput, // 输入框
|
||||||
inputTag: ElInputTag, // 标签输入框
|
inputTag: ElInputTag, // 标签输入框
|
||||||
number: ElInputNumber, // 数字输入框
|
number: ElInputNumber, // 数字输入框
|
||||||
select: ElSelect, // 选择器
|
select: ElSelect, // 选择器
|
||||||
switch: ElSwitch, // 开关
|
switch: ElSwitch, // 开关
|
||||||
checkbox: ElCheckbox, // 复选框
|
checkbox: ElCheckbox, // 复选框
|
||||||
checkboxgroup: ElCheckboxGroup, // 复选框组
|
checkboxgroup: ElCheckboxGroup, // 复选框组
|
||||||
radiogroup: ElRadioGroup, // 单选框组
|
radiogroup: ElRadioGroup, // 单选框组
|
||||||
date: ElDatePicker, // 日期选择器
|
date: ElDatePicker, // 日期选择器
|
||||||
daterange: ElDatePicker, // 日期范围选择器
|
daterange: ElDatePicker, // 日期范围选择器
|
||||||
datetime: ElDatePicker, // 日期时间选择器
|
datetime: ElDatePicker, // 日期时间选择器
|
||||||
datetimerange: ElDatePicker, // 日期时间范围选择器
|
datetimerange: ElDatePicker, // 日期时间范围选择器
|
||||||
rate: ElRate, // 评分
|
rate: ElRate, // 评分
|
||||||
slider: ElSlider, // 滑块
|
slider: ElSlider, // 滑块
|
||||||
cascader: ElCascader, // 级联选择器
|
cascader: ElCascader, // 级联选择器
|
||||||
timepicker: ElTimePicker, // 时间选择器
|
timepicker: ElTimePicker, // 时间选择器
|
||||||
timeselect: ElTimeSelect, // 时间选择
|
timeselect: ElTimeSelect, // 时间选择
|
||||||
treeselect: ElTreeSelect // 树选择器
|
treeselect: ElTreeSelect // 树选择器
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isMobile = computed(() => width.value < 500)
|
const isMobile = computed(() => width.value < 500)
|
||||||
|
|
||||||
const formInstance = useTemplateRef<FormInstance>('formRef')
|
const formInstance = useTemplateRef<FormInstance>('formRef')
|
||||||
|
|
||||||
// 表单项配置
|
// 表单项配置
|
||||||
export interface SearchFormItem {
|
export interface SearchFormItem {
|
||||||
/** 表单项的唯一标识 */
|
/** 表单项的唯一标识 */
|
||||||
key: string
|
key: string
|
||||||
/** 表单项的标签文本或自定义渲染函数 */
|
/** 表单项的标签文本或自定义渲染函数 */
|
||||||
label: string | (() => VNode) | Component
|
label: string | (() => VNode) | Component
|
||||||
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
|
||||||
labelWidth?: string | number
|
labelWidth?: string | number
|
||||||
/** 表单项类型,支持预定义的组件类型 */
|
/** 表单项类型,支持预定义的组件类型 */
|
||||||
type?: keyof typeof componentMap | string
|
type?: keyof typeof componentMap | string
|
||||||
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type) */
|
||||||
render?: (() => VNode) | Component
|
render?: (() => VNode) | Component
|
||||||
/** 是否隐藏该表单项 */
|
/** 是否隐藏该表单项 */
|
||||||
hidden?: boolean
|
hidden?: boolean
|
||||||
/** 表单项占据的列宽,基于24格栅格系统 */
|
/** 表单项占据的列宽,基于24格栅格系统 */
|
||||||
span?: number
|
span?: number
|
||||||
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
|
||||||
options?: Record<string, any>
|
options?: Record<string, any>
|
||||||
/** 传递给表单项组件的属性 */
|
/** 传递给表单项组件的属性 */
|
||||||
props?: Record<string, any>
|
props?: Record<string, any>
|
||||||
/** 表单项的插槽配置 */
|
/** 表单项的插槽配置 */
|
||||||
slots?: Record<string, (() => any) | undefined>
|
slots?: Record<string, (() => any) | undefined>
|
||||||
/** 表单项的占位符文本 */
|
/** 表单项的占位符文本 */
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
/** 更多属性配置请参考 ElementPlus 官方文档 */
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表单配置
|
// 表单配置
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
/** 表单数据 */
|
/** 表单数据 */
|
||||||
items: SearchFormItem[]
|
items: SearchFormItem[]
|
||||||
/** 每列的宽度(基于 24 格布局) */
|
/** 每列的宽度(基于 24 格布局) */
|
||||||
span?: number
|
span?: number
|
||||||
/** 表单控件间隙 */
|
/** 表单控件间隙 */
|
||||||
gutter?: number
|
gutter?: number
|
||||||
/** 展开/收起 */
|
/** 展开/收起 */
|
||||||
isExpand?: boolean
|
isExpand?: boolean
|
||||||
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
|
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
|
||||||
defaultExpanded?: boolean
|
defaultExpanded?: boolean
|
||||||
/** 表单域标签的位置 */
|
/** 表单域标签的位置 */
|
||||||
labelPosition?: 'left' | 'right' | 'top'
|
labelPosition?: 'left' | 'right' | 'top'
|
||||||
/** 文字宽度 */
|
/** 文字宽度 */
|
||||||
labelWidth?: string | number
|
labelWidth?: string | number
|
||||||
/** 是否需要展示,收起 */
|
/** 是否需要展示,收起 */
|
||||||
showExpand?: boolean
|
showExpand?: boolean
|
||||||
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
/** 按钮靠左对齐限制(表单项小于等于该值时) */
|
||||||
buttonLeftLimit?: number
|
buttonLeftLimit?: number
|
||||||
/** 是否显示重置按钮 */
|
/** 是否显示重置按钮 */
|
||||||
showReset?: boolean
|
showReset?: boolean
|
||||||
/** 是否显示搜索按钮 */
|
/** 是否显示搜索按钮 */
|
||||||
showSearch?: boolean
|
showSearch?: boolean
|
||||||
/** 是否禁用搜索按钮 */
|
/** 是否禁用搜索按钮 */
|
||||||
disabledSearch?: boolean
|
disabledSearch?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<SearchBarProps>(), {
|
const props = withDefaults(defineProps<SearchBarProps>(), {
|
||||||
items: () => [],
|
items: () => [],
|
||||||
span: 6,
|
span: 6,
|
||||||
gutter: 12,
|
gutter: 12,
|
||||||
isExpand: false,
|
isExpand: false,
|
||||||
labelPosition: 'right',
|
labelPosition: 'right',
|
||||||
labelWidth: '70px',
|
labelWidth: '70px',
|
||||||
showExpand: true,
|
showExpand: true,
|
||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
buttonLeftLimit: 2,
|
buttonLeftLimit: 2,
|
||||||
showReset: true,
|
showReset: true,
|
||||||
showSearch: true,
|
showSearch: true,
|
||||||
disabledSearch: false
|
disabledSearch: false
|
||||||
})
|
})
|
||||||
|
|
||||||
interface SearchBarEmits {
|
interface SearchBarEmits {
|
||||||
reset: []
|
reset: []
|
||||||
search: []
|
search: []
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<SearchBarEmits>()
|
const emit = defineEmits<SearchBarEmits>()
|
||||||
|
|
||||||
const modelValue = defineModel<Record<string, any>>({ default: {} })
|
const modelValue = defineModel<Record<string, any>>({ default: {} })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否展开状态
|
* 是否展开状态
|
||||||
*/
|
*/
|
||||||
const isExpanded = ref(props.defaultExpanded)
|
const isExpanded = ref(props.defaultExpanded)
|
||||||
|
|
||||||
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
|
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
|
||||||
|
|
||||||
const getProps = (item: SearchFormItem) => {
|
const getProps = (item: SearchFormItem) => {
|
||||||
if (item.props) return item.props
|
if (item.props) return item.props
|
||||||
const props = { ...item }
|
const props = { ...item }
|
||||||
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
|
||||||
return props
|
return props
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取插槽
|
// 获取插槽
|
||||||
const getSlots = (item: SearchFormItem) => {
|
const getSlots = (item: SearchFormItem) => {
|
||||||
if (!item.slots) return {}
|
if (!item.slots) return {}
|
||||||
const validSlots: Record<string, () => any> = {}
|
const validSlots: Record<string, () => any> = {}
|
||||||
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
Object.entries(item.slots).forEach(([key, slotFn]) => {
|
||||||
if (slotFn) {
|
if (slotFn) {
|
||||||
validSlots[key] = slotFn
|
validSlots[key] = slotFn
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return validSlots
|
return validSlots
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取列宽 span 值
|
* 获取列宽 span 值
|
||||||
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
|
||||||
*/
|
*/
|
||||||
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
|
||||||
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件
|
// 组件
|
||||||
const getComponent = (item: SearchFormItem) => {
|
const getComponent = (item: SearchFormItem) => {
|
||||||
// 优先使用 render 函数或组件渲染自定义组件
|
// 优先使用 render 函数或组件渲染自定义组件
|
||||||
if (item.render) {
|
if (item.render) {
|
||||||
return item.render
|
return item.render
|
||||||
}
|
}
|
||||||
// 使用 type 获取预定义组件
|
// 使用 type 获取预定义组件
|
||||||
const { type } = item
|
const { type } = item
|
||||||
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
return componentMap[type as keyof typeof componentMap] || componentMap['input']
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 可见的表单项
|
* 可见的表单项
|
||||||
*/
|
*/
|
||||||
const visibleFormItems = computed(() => {
|
const visibleFormItems = computed(() => {
|
||||||
const filteredItems = props.items.filter((item) => !item.hidden)
|
const filteredItems = props.items.filter((item) => !item.hidden)
|
||||||
const shouldShowLess = !props.isExpand && !isExpanded.value
|
const shouldShowLess = !props.isExpand && !isExpanded.value
|
||||||
if (shouldShowLess) {
|
if (shouldShowLess) {
|
||||||
const maxItemsPerRow = Math.floor(24 / props.span) - 1
|
const maxItemsPerRow = Math.floor(24 / props.span) - 1
|
||||||
return filteredItems.slice(0, maxItemsPerRow)
|
return filteredItems.slice(0, maxItemsPerRow)
|
||||||
}
|
}
|
||||||
return filteredItems
|
return filteredItems
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否应该显示展开/收起按钮
|
* 是否应该显示展开/收起按钮
|
||||||
*/
|
*/
|
||||||
const shouldShowExpandToggle = computed(() => {
|
const shouldShowExpandToggle = computed(() => {
|
||||||
const filteredItems = props.items.filter((item) => !item.hidden)
|
const filteredItems = props.items.filter((item) => !item.hidden)
|
||||||
return (
|
return (
|
||||||
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
|
!props.isExpand &&
|
||||||
)
|
props.showExpand &&
|
||||||
})
|
filteredItems.length > Math.floor(24 / props.span) - 1
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 展开/收起按钮文本
|
* 展开/收起按钮文本
|
||||||
*/
|
*/
|
||||||
const expandToggleText = computed(() => {
|
const expandToggleText = computed(() => {
|
||||||
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
|
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 操作按钮样式
|
* 操作按钮样式
|
||||||
*/
|
*/
|
||||||
const actionButtonsStyle = computed(() => ({
|
const actionButtonsStyle = computed(() => ({
|
||||||
'justify-content': isMobile.value
|
'justify-content': isMobile.value
|
||||||
? 'flex-end'
|
? 'flex-end'
|
||||||
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
|
||||||
? 'flex-start'
|
? 'flex-start'
|
||||||
: 'flex-end'
|
: 'flex-end'
|
||||||
}))
|
}))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换展开/收起状态
|
* 切换展开/收起状态
|
||||||
*/
|
*/
|
||||||
const toggleExpand = () => {
|
const toggleExpand = () => {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理重置事件
|
* 处理重置事件
|
||||||
*/
|
*/
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
// 重置表单字段(UI 层)
|
// 重置表单字段(UI 层)
|
||||||
formInstance.value?.resetFields()
|
formInstance.value?.resetFields()
|
||||||
|
|
||||||
// 清空所有表单项值(包含隐藏项)
|
// 清空所有表单项值(包含隐藏项)
|
||||||
Object.assign(
|
Object.assign(
|
||||||
modelValue.value,
|
modelValue.value,
|
||||||
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
|
||||||
)
|
)
|
||||||
|
|
||||||
// 触发 reset 事件
|
// 触发 reset 事件
|
||||||
emit('reset')
|
emit('reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理搜索事件
|
* 处理搜索事件
|
||||||
*/
|
*/
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
emit('search')
|
emit('search')
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
ref: formInstance,
|
ref: formInstance,
|
||||||
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
validate: (...args: any[]) => formInstance.value?.validate(...args),
|
||||||
reset: handleReset
|
reset: handleReset
|
||||||
})
|
})
|
||||||
|
|
||||||
// 解构 props 以便在模板中直接使用
|
// 解构 props 以便在模板中直接使用
|
||||||
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.art-search-bar {
|
.art-search-bar {
|
||||||
padding: 15px 20px 0;
|
padding: 15px 20px 0;
|
||||||
|
|
||||||
.action-column {
|
.action-column {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
||||||
.action-buttons-wrapper {
|
.action-buttons-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-buttons {
|
.form-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toggle {
|
.filter-toggle {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
line-height: 32px;
|
line-height: 32px;
|
||||||
color: var(--theme-color);
|
color: var(--theme-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--ElColor-primary);
|
color: var(--ElColor-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-wrapper {
|
.icon-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: transform 0.2s ease;
|
transition: transform 0.2s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式优化
|
// 响应式优化
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.art-search-bar {
|
.art-search-bar {
|
||||||
padding: 16px 16px 0;
|
padding: 16px 16px 0;
|
||||||
|
|
||||||
.action-column {
|
.action-column {
|
||||||
.action-buttons-wrapper {
|
.action-buttons-wrapper {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|
||||||
.form-buttons {
|
.form-buttons {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-toggle {
|
.filter-toggle {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,219 +1,222 @@
|
|||||||
<!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
<!-- WangEditor 富文本编辑器 插件地址:https://www.wangeditor.com/ -->
|
||||||
<template>
|
<template>
|
||||||
<div class="editor-wrapper">
|
<div class="editor-wrapper">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
class="editor-toolbar"
|
class="editor-toolbar"
|
||||||
:editor="editorRef"
|
:editor="editorRef"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:defaultConfig="toolbarConfig"
|
:defaultConfig="toolbarConfig"
|
||||||
/>
|
/>
|
||||||
<Editor
|
<Editor
|
||||||
:style="{ height: height, overflowY: 'hidden' }"
|
:style="{ height: height, overflowY: 'hidden' }"
|
||||||
v-model="modelValue"
|
v-model="modelValue"
|
||||||
:mode="mode"
|
:mode="mode"
|
||||||
:defaultConfig="editorConfig"
|
:defaultConfig="editorConfig"
|
||||||
@onCreated="onCreateEditor"
|
@onCreated="onCreateEditor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import '@wangeditor/editor/dist/css/style.css'
|
import '@wangeditor/editor/dist/css/style.css'
|
||||||
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
|
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
|
||||||
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import EmojiText from '@/utils/ui/emojo'
|
import EmojiText from '@/utils/ui/emojo'
|
||||||
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
|
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtWangEditor' })
|
defineOptions({ name: 'ArtWangEditor' })
|
||||||
|
|
||||||
// Props 定义
|
// Props 定义
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 编辑器高度 */
|
/** 编辑器高度 */
|
||||||
height?: string
|
height?: string
|
||||||
/** 自定义工具栏配置 */
|
/** 自定义工具栏配置 */
|
||||||
toolbarKeys?: string[]
|
toolbarKeys?: string[]
|
||||||
/** 插入新工具到指定位置 */
|
/** 插入新工具到指定位置 */
|
||||||
insertKeys?: { index: number; keys: string[] }
|
insertKeys?: { index: number; keys: string[] }
|
||||||
/** 排除的工具栏项 */
|
/** 排除的工具栏项 */
|
||||||
excludeKeys?: string[]
|
excludeKeys?: string[]
|
||||||
/** 编辑器模式 */
|
/** 编辑器模式 */
|
||||||
mode?: 'default' | 'simple'
|
mode?: 'default' | 'simple'
|
||||||
/** 占位符文本 */
|
/** 占位符文本 */
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
/** 上传配置 */
|
/** 上传配置 */
|
||||||
uploadConfig?: {
|
uploadConfig?: {
|
||||||
maxFileSize?: number
|
maxFileSize?: number
|
||||||
maxNumberOfFiles?: number
|
maxNumberOfFiles?: number
|
||||||
server?: string
|
server?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: '500px',
|
height: '500px',
|
||||||
mode: 'default',
|
mode: 'default',
|
||||||
placeholder: '请输入内容...',
|
placeholder: '请输入内容...',
|
||||||
excludeKeys: () => ['fontFamily']
|
excludeKeys: () => ['fontFamily']
|
||||||
})
|
})
|
||||||
|
|
||||||
const modelValue = defineModel<string>({ required: true })
|
const modelValue = defineModel<string>({ required: true })
|
||||||
|
|
||||||
// 编辑器实例
|
// 编辑器实例
|
||||||
const editorRef = shallowRef<IDomEditor>()
|
const editorRef = shallowRef<IDomEditor>()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
// 常量配置
|
// 常量配置
|
||||||
const DEFAULT_UPLOAD_CONFIG = {
|
const DEFAULT_UPLOAD_CONFIG = {
|
||||||
maxFileSize: 3 * 1024 * 1024, // 3MB
|
maxFileSize: 3 * 1024 * 1024, // 3MB
|
||||||
maxNumberOfFiles: 10,
|
maxNumberOfFiles: 10,
|
||||||
fieldName: 'file',
|
fieldName: 'file',
|
||||||
allowedFileTypes: ['image/*']
|
allowedFileTypes: ['image/*']
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// 计算属性:上传服务器地址
|
// 计算属性:上传服务器地址
|
||||||
const uploadServer = computed(
|
const uploadServer = computed(
|
||||||
() =>
|
() =>
|
||||||
props.uploadConfig?.server || `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
|
props.uploadConfig?.server ||
|
||||||
)
|
`${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
|
||||||
|
)
|
||||||
|
|
||||||
// 合并上传配置
|
// 合并上传配置
|
||||||
const mergedUploadConfig = computed(() => ({
|
const mergedUploadConfig = computed(() => ({
|
||||||
...DEFAULT_UPLOAD_CONFIG,
|
...DEFAULT_UPLOAD_CONFIG,
|
||||||
...props.uploadConfig
|
...props.uploadConfig
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 工具栏配置
|
// 工具栏配置
|
||||||
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
|
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
|
||||||
const config: Partial<IToolbarConfig> = {}
|
const config: Partial<IToolbarConfig> = {}
|
||||||
|
|
||||||
// 完全自定义工具栏
|
// 完全自定义工具栏
|
||||||
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
|
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
|
||||||
config.toolbarKeys = props.toolbarKeys
|
config.toolbarKeys = props.toolbarKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
// 插入新工具
|
// 插入新工具
|
||||||
if (props.insertKeys) {
|
if (props.insertKeys) {
|
||||||
config.insertKeys = props.insertKeys
|
config.insertKeys = props.insertKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
// 排除工具
|
// 排除工具
|
||||||
if (props.excludeKeys && props.excludeKeys.length > 0) {
|
if (props.excludeKeys && props.excludeKeys.length > 0) {
|
||||||
config.excludeKeys = props.excludeKeys
|
config.excludeKeys = props.excludeKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
return config
|
return config
|
||||||
})
|
})
|
||||||
|
|
||||||
// 编辑器配置
|
// 编辑器配置
|
||||||
const editorConfig: Partial<IEditorConfig> = {
|
const editorConfig: Partial<IEditorConfig> = {
|
||||||
placeholder: props.placeholder,
|
placeholder: props.placeholder,
|
||||||
MENU_CONF: {
|
MENU_CONF: {
|
||||||
uploadImage: {
|
uploadImage: {
|
||||||
fieldName: mergedUploadConfig.value.fieldName,
|
fieldName: mergedUploadConfig.value.fieldName,
|
||||||
maxFileSize: mergedUploadConfig.value.maxFileSize,
|
maxFileSize: mergedUploadConfig.value.maxFileSize,
|
||||||
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
|
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
|
||||||
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
|
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
|
||||||
server: uploadServer.value,
|
server: uploadServer.value,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: userStore.accessToken
|
Authorization: userStore.accessToken
|
||||||
},
|
},
|
||||||
onSuccess() {
|
onSuccess() {
|
||||||
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
|
||||||
},
|
},
|
||||||
onError(file: File, err: any, res: any) {
|
onError(file: File, err: any, res: any) {
|
||||||
console.error('图片上传失败:', err, res)
|
console.error('图片上传失败:', err, res)
|
||||||
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编辑器创建回调
|
// 编辑器创建回调
|
||||||
const onCreateEditor = (editor: IDomEditor) => {
|
const onCreateEditor = (editor: IDomEditor) => {
|
||||||
editorRef.value = editor
|
editorRef.value = editor
|
||||||
|
|
||||||
// 监听全屏事件
|
// 监听全屏事件
|
||||||
editor.on('fullScreen', () => {
|
editor.on('fullScreen', () => {
|
||||||
console.log('编辑器进入全屏模式')
|
console.log('编辑器进入全屏模式')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 确保在编辑器创建后应用自定义图标
|
// 确保在编辑器创建后应用自定义图标
|
||||||
applyCustomIcons()
|
applyCustomIcons()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用自定义图标(带重试机制)
|
// 应用自定义图标(带重试机制)
|
||||||
const applyCustomIcons = () => {
|
const applyCustomIcons = () => {
|
||||||
let retryCount = 0
|
let retryCount = 0
|
||||||
const maxRetries = 10
|
const maxRetries = 10
|
||||||
const retryDelay = 100
|
const retryDelay = 100
|
||||||
|
|
||||||
const tryApplyIcons = () => {
|
const tryApplyIcons = () => {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
if (!editor) {
|
if (!editor) {
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
retryCount++
|
retryCount++
|
||||||
setTimeout(tryApplyIcons, retryDelay)
|
setTimeout(tryApplyIcons, retryDelay)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前编辑器的工具栏容器
|
// 获取当前编辑器的工具栏容器
|
||||||
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
|
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
|
||||||
if (!editorContainer) {
|
if (!editorContainer) {
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
retryCount++
|
retryCount++
|
||||||
setTimeout(tryApplyIcons, retryDelay)
|
setTimeout(tryApplyIcons, retryDelay)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolbar = editorContainer.querySelector('.w-e-toolbar')
|
const toolbar = editorContainer.querySelector('.w-e-toolbar')
|
||||||
const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]')
|
const toolbarButtons = editorContainer.querySelectorAll(
|
||||||
|
'.w-e-bar-item button[data-menu-key]'
|
||||||
|
)
|
||||||
|
|
||||||
if (toolbar && toolbarButtons.length > 0) {
|
if (toolbar && toolbarButtons.length > 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果工具栏还没渲染完成,继续重试
|
// 如果工具栏还没渲染完成,继续重试
|
||||||
if (retryCount < maxRetries) {
|
if (retryCount < maxRetries) {
|
||||||
retryCount++
|
retryCount++
|
||||||
setTimeout(tryApplyIcons, retryDelay)
|
setTimeout(tryApplyIcons, retryDelay)
|
||||||
} else {
|
} else {
|
||||||
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
|
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 requestAnimationFrame 确保在下一帧执行
|
// 使用 requestAnimationFrame 确保在下一帧执行
|
||||||
requestAnimationFrame(tryApplyIcons)
|
requestAnimationFrame(tryApplyIcons)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暴露编辑器实例和方法
|
// 暴露编辑器实例和方法
|
||||||
defineExpose({
|
defineExpose({
|
||||||
/** 获取编辑器实例 */
|
/** 获取编辑器实例 */
|
||||||
getEditor: () => editorRef.value,
|
getEditor: () => editorRef.value,
|
||||||
/** 设置编辑器内容 */
|
/** 设置编辑器内容 */
|
||||||
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
setHtml: (html: string) => editorRef.value?.setHtml(html),
|
||||||
/** 获取编辑器内容 */
|
/** 获取编辑器内容 */
|
||||||
getHtml: () => editorRef.value?.getHtml(),
|
getHtml: () => editorRef.value?.getHtml(),
|
||||||
/** 清空编辑器 */
|
/** 清空编辑器 */
|
||||||
clear: () => editorRef.value?.clear(),
|
clear: () => editorRef.value?.clear(),
|
||||||
/** 聚焦编辑器 */
|
/** 聚焦编辑器 */
|
||||||
focus: () => editorRef.value?.focus()
|
focus: () => editorRef.value?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 图标替换已在 onCreateEditor 中处理
|
// 图标替换已在 onCreateEditor 中处理
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
if (editor) {
|
if (editor) {
|
||||||
editor.destroy()
|
editor.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use './style';
|
@use './style';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,209 +2,209 @@ $box-radius: calc(var(--custom-radius) / 3 + 2px);
|
|||||||
|
|
||||||
// 全屏容器 z-index 调整
|
// 全屏容器 z-index 调整
|
||||||
.w-e-full-screen-container {
|
.w-e-full-screen-container {
|
||||||
z-index: 100 !important;
|
z-index: 100 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 编辑器容器 */
|
/* 编辑器容器 */
|
||||||
.editor-wrapper {
|
.editor-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border: 1px solid var(--art-gray-300);
|
border: 1px solid var(--art-gray-300);
|
||||||
border-radius: $box-radius !important;
|
border-radius: $box-radius !important;
|
||||||
|
|
||||||
.w-e-bar {
|
.w-e-bar {
|
||||||
border-radius: $box-radius $box-radius 0 0 !important;
|
border-radius: $box-radius $box-radius 0 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
i {
|
i {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具栏 */
|
/* 工具栏 */
|
||||||
.editor-toolbar {
|
.editor-toolbar {
|
||||||
border-bottom: 1px solid var(--default-border);
|
border-bottom: 1px solid var(--default-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下拉选择框配置 */
|
/* 下拉选择框配置 */
|
||||||
.w-e-select-list {
|
.w-e-select-list {
|
||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
padding: 5px 10px 10px;
|
padding: 5px 10px 10px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下拉选择框元素配置 */
|
/* 下拉选择框元素配置 */
|
||||||
.w-e-select-list ul li {
|
.w-e-select-list ul li {
|
||||||
margin-top: 5px;
|
margin-top: 5px;
|
||||||
font-size: 15px !important;
|
font-size: 15px !important;
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下拉选择框 正文文字大小调整 */
|
/* 下拉选择框 正文文字大小调整 */
|
||||||
.w-e-select-list ul li:last-of-type {
|
.w-e-select-list ul li:last-of-type {
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 下拉选择框 hover 样式调整 */
|
/* 下拉选择框 hover 样式调整 */
|
||||||
.w-e-select-list ul li:hover {
|
.w-e-select-list ul li:hover {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* 激活颜色 */
|
/* 激活颜色 */
|
||||||
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
--w-e-toolbar-active-bg-color: var(--art-gray-200);
|
||||||
|
|
||||||
/* toolbar 图标和文字颜色 */
|
/* toolbar 图标和文字颜色 */
|
||||||
--w-e-toolbar-color: #000;
|
--w-e-toolbar-color: #000;
|
||||||
|
|
||||||
/* 表格选中时候的边框颜色 */
|
/* 表格选中时候的边框颜色 */
|
||||||
--w-e-textarea-selected-border-color: #ddd;
|
--w-e-textarea-selected-border-color: #ddd;
|
||||||
|
|
||||||
/* 表格头背景颜色 */
|
/* 表格头背景颜色 */
|
||||||
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
--w-e-textarea-slight-bg-color: var(--art-gray-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具栏按钮样式 */
|
/* 工具栏按钮样式 */
|
||||||
.w-e-bar-item svg {
|
.w-e-bar-item svg {
|
||||||
fill: var(--art-gray-800);
|
fill: var(--art-gray-800);
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-e-bar-item button {
|
.w-e-bar-item button {
|
||||||
color: var(--art-gray-800);
|
color: var(--art-gray-800);
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具栏 hover 按钮背景颜色 */
|
/* 工具栏 hover 按钮背景颜色 */
|
||||||
.w-e-bar-item button:hover {
|
.w-e-bar-item button:hover {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具栏分割线 */
|
/* 工具栏分割线 */
|
||||||
.w-e-bar-divider {
|
.w-e-bar-divider {
|
||||||
height: 20px;
|
height: 20px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 工具栏菜单 */
|
/* 工具栏菜单 */
|
||||||
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
.w-e-bar-item-group .w-e-bar-item-menus-container {
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
|
|
||||||
.w-e-bar-item {
|
.w-e-bar-item {
|
||||||
button {
|
button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 5px;
|
margin: 0 5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 代码块 */
|
/* 代码块 */
|
||||||
.w-e-text-container [data-slate-editor] pre > code {
|
.w-e-text-container [data-slate-editor] pre > code {
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
background-color: var(--art-gray-50);
|
background-color: var(--art-gray-50);
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹出框 */
|
/* 弹出框 */
|
||||||
.w-e-drop-panel {
|
.w-e-drop-panel {
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #318ef4;
|
color: #318ef4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-e-text-container {
|
.w-e-text-container {
|
||||||
strong,
|
strong,
|
||||||
b {
|
b {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
i,
|
i,
|
||||||
em {
|
em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表格样式优化 */
|
/* 表格样式优化 */
|
||||||
.w-e-text-container [data-slate-editor] .table-container th {
|
.w-e-text-container [data-slate-editor] .table-container th {
|
||||||
border-right: none;
|
border-right: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
|
||||||
border-right: 1px solid #ccc !important;
|
border-right: 1px solid #ccc !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 引用 */
|
/* 引用 */
|
||||||
.w-e-text-container [data-slate-editor] blockquote {
|
.w-e-text-container [data-slate-editor] blockquote {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
border-left: 4px solid var(--art-gray-300);
|
border-left: 4px solid var(--art-gray-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 输入区域弹出 bar */
|
/* 输入区域弹出 bar */
|
||||||
.w-e-hover-bar {
|
.w-e-hover-bar {
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 超链接弹窗 */
|
/* 超链接弹窗 */
|
||||||
.w-e-modal {
|
.w-e-modal {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 图片样式调整 */
|
/* 图片样式调整 */
|
||||||
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
|
||||||
overflow: inherit;
|
overflow: inherit;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
transition: border 0.3s;
|
transition: border 0.3s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 1px solid #318ef4 !important;
|
border: 1px solid #318ef4 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-e-image-dragger {
|
.w-e-image-dragger {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
background-color: #318ef4;
|
background-color: #318ef4;
|
||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
border-radius: $box-radius;
|
border-radius: $box-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-top {
|
.left-top {
|
||||||
top: -6px;
|
top: -6px;
|
||||||
left: -6px;
|
left: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-top {
|
.right-top {
|
||||||
top: -6px;
|
top: -6px;
|
||||||
right: -6px;
|
right: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.left-bottom {
|
.left-bottom {
|
||||||
bottom: -6px;
|
bottom: -6px;
|
||||||
left: -6px;
|
left: -6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-bottom {
|
.right-bottom {
|
||||||
right: -6px;
|
right: -6px;
|
||||||
bottom: -6px;
|
bottom: -6px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +1,142 @@
|
|||||||
<!-- 面包屑导航 -->
|
<!-- 面包屑导航 -->
|
||||||
<template>
|
<template>
|
||||||
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
|
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
|
||||||
<ul class="flex-c h-full">
|
<ul class="flex-c h-full">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in breadcrumbItems"
|
v-for="(item, index) in breadcrumbItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
class="box-border flex-c h-7 text-sm leading-7"
|
class="box-border flex-c h-7 text-sm leading-7"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="
|
:class="
|
||||||
isClickable(item, index)
|
isClickable(item, index)
|
||||||
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
|
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
@click="handleBreadcrumbClick(item, index)"
|
@click="handleBreadcrumbClick(item, index)"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
|
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
|
||||||
>{{ formatMenuTitle(item.meta?.title as string) }}</span
|
>{{ formatMenuTitle(item.meta?.title as string) }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!isLastItem(index) && item.meta?.title"
|
v-if="!isLastItem(index) && item.meta?.title"
|
||||||
class="mx-1 text-sm not-italic text-g-500"
|
class="mx-1 text-sm not-italic text-g-500"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
/
|
/
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
|
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
|
||||||
import { formatMenuTitle } from '@/utils/router'
|
import { formatMenuTitle } from '@/utils/router'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtBreadcrumb' })
|
defineOptions({ name: 'ArtBreadcrumb' })
|
||||||
|
|
||||||
export interface BreadcrumbItem {
|
export interface BreadcrumbItem {
|
||||||
path: string
|
path: string
|
||||||
meta: RouteRecordRaw['meta']
|
meta: RouteRecordRaw['meta']
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
// 使用computed替代watch,提高性能
|
// 使用computed替代watch,提高性能
|
||||||
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
|
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
|
||||||
const { matched } = route
|
const { matched } = route
|
||||||
const matchedLength = matched.length
|
const matchedLength = matched.length
|
||||||
|
|
||||||
// 处理首页情况
|
// 处理首页情况
|
||||||
if (!matchedLength || isHomeRoute(matched[0])) {
|
if (!matchedLength || isHomeRoute(matched[0])) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理一级菜单和普通路由
|
// 处理一级菜单和普通路由
|
||||||
const firstRoute = matched[0]
|
const firstRoute = matched[0]
|
||||||
const isFirstLevel = firstRoute.meta?.isFirstLevel
|
const isFirstLevel = firstRoute.meta?.isFirstLevel
|
||||||
const lastIndex = matchedLength - 1
|
const lastIndex = matchedLength - 1
|
||||||
const currentRoute = matched[lastIndex]
|
const currentRoute = matched[lastIndex]
|
||||||
const currentRouteMeta = currentRoute.meta
|
const currentRouteMeta = currentRoute.meta
|
||||||
|
|
||||||
let items = isFirstLevel
|
let items = isFirstLevel
|
||||||
? [createBreadcrumbItem(currentRoute)]
|
? [createBreadcrumbItem(currentRoute)]
|
||||||
: matched.map(createBreadcrumbItem)
|
: matched.map(createBreadcrumbItem)
|
||||||
|
|
||||||
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
|
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside),则移除它
|
||||||
if (items.length > 1 && isWrapperContainer(items[0])) {
|
if (items.length > 1 && isWrapperContainer(items[0])) {
|
||||||
items = items.slice(1)
|
items = items.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
|
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
|
||||||
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
|
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
|
||||||
return [createBreadcrumbItem(currentRoute)]
|
return [createBreadcrumbItem(currentRoute)]
|
||||||
}
|
}
|
||||||
|
|
||||||
return items
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
// 辅助函数:判断是否为包裹容器路由
|
// 辅助函数:判断是否为包裹容器路由
|
||||||
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
|
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
|
||||||
item.path === '/outside' && !!item.meta?.isIframe
|
item.path === '/outside' && !!item.meta?.isIframe
|
||||||
|
|
||||||
// 辅助函数:创建面包屑项目
|
// 辅助函数:创建面包屑项目
|
||||||
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
|
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
|
||||||
path: route.path,
|
path: route.path,
|
||||||
meta: route.meta
|
meta: route.meta
|
||||||
})
|
})
|
||||||
|
|
||||||
// 辅助函数:判断是否为首页
|
// 辅助函数:判断是否为首页
|
||||||
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
|
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
|
||||||
|
|
||||||
// 辅助函数:判断是否为最后一项
|
// 辅助函数:判断是否为最后一项
|
||||||
const isLastItem = (index: number): boolean => {
|
const isLastItem = (index: number): boolean => {
|
||||||
const itemsLength = breadcrumbItems.value.length
|
const itemsLength = breadcrumbItems.value.length
|
||||||
return index === itemsLength - 1
|
return index === itemsLength - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数:判断是否可点击
|
// 辅助函数:判断是否可点击
|
||||||
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
|
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
|
||||||
item.path !== '/outside' && !isLastItem(index)
|
item.path !== '/outside' && !isLastItem(index)
|
||||||
|
|
||||||
// 辅助函数:查找路由的第一个有效子路由
|
// 辅助函数:查找路由的第一个有效子路由
|
||||||
const findFirstValidChild = (route: RouteRecordRaw) =>
|
const findFirstValidChild = (route: RouteRecordRaw) =>
|
||||||
route.children?.find((child) => !child.redirect && !child.meta?.isHide)
|
route.children?.find((child) => !child.redirect && !child.meta?.isHide)
|
||||||
|
|
||||||
// 辅助函数:构建完整路径
|
// 辅助函数:构建完整路径
|
||||||
const buildFullPath = (childPath: string): string => `/${childPath}`.replace('//', '/')
|
const buildFullPath = (childPath: string): string => `/${childPath}`.replace('//', '/')
|
||||||
|
|
||||||
// 处理面包屑点击事件
|
// 处理面包屑点击事件
|
||||||
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
|
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
|
||||||
// 如果是最后一项或外部链接,不处理
|
// 如果是最后一项或外部链接,不处理
|
||||||
if (isLastItem(index) || item.path === '/outside') {
|
if (isLastItem(index) || item.path === '/outside') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 缓存路由表查找结果
|
// 缓存路由表查找结果
|
||||||
const routes = router.getRoutes()
|
const routes = router.getRoutes()
|
||||||
const targetRoute = routes.find((route) => route.path === item.path)
|
const targetRoute = routes.find((route) => route.path === item.path)
|
||||||
|
|
||||||
if (!targetRoute?.children?.length) {
|
if (!targetRoute?.children?.length) {
|
||||||
await router.push(item.path)
|
await router.push(item.path)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const firstValidChild = findFirstValidChild(targetRoute)
|
const firstValidChild = findFirstValidChild(targetRoute)
|
||||||
if (firstValidChild) {
|
if (firstValidChild) {
|
||||||
await router.push(buildFullPath(firstValidChild.path))
|
await router.push(buildFullPath(firstValidChild.path))
|
||||||
} else {
|
} else {
|
||||||
await router.push(item.path)
|
await router.push(item.path)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('导航失败:', error)
|
console.error('导航失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,262 +1,279 @@
|
|||||||
<!-- 系统聊天窗口 -->
|
<!-- 系统聊天窗口 -->
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
|
<ElDrawer
|
||||||
<div class="mb-5 flex-cb">
|
v-model="isDrawerVisible"
|
||||||
<div>
|
:size="isMobile ? '100%' : '480px'"
|
||||||
<span class="text-base font-medium">Art Bot</span>
|
:with-header="false"
|
||||||
<div class="mt-1.5 flex-c gap-1">
|
>
|
||||||
<div
|
<div class="mb-5 flex-cb">
|
||||||
class="h-2 w-2 rounded-full"
|
<div>
|
||||||
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
|
<span class="text-base font-medium">Art Bot</span>
|
||||||
></div>
|
<div class="mt-1.5 flex-c gap-1">
|
||||||
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
|
<div
|
||||||
</div>
|
class="h-2 w-2 rounded-full"
|
||||||
</div>
|
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
|
||||||
<div>
|
></div>
|
||||||
<ElIcon class="c-p" :size="20" @click="closeChat">
|
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
|
||||||
<Close />
|
</div>
|
||||||
</ElIcon>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
</div>
|
<ElIcon class="c-p" :size="20" @click="closeChat">
|
||||||
<div class="flex h-[calc(100%-70px)] flex-col">
|
<Close />
|
||||||
<!-- 聊天消息区域 -->
|
</ElIcon>
|
||||||
<div
|
</div>
|
||||||
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
|
</div>
|
||||||
ref="messageContainer"
|
<div class="flex h-[calc(100%-70px)] flex-col">
|
||||||
>
|
<!-- 聊天消息区域 -->
|
||||||
<template v-for="(message, index) in messages" :key="index">
|
<div
|
||||||
<div
|
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
|
||||||
:class="[
|
ref="messageContainer"
|
||||||
'mb-7.5 flex w-full items-start gap-2',
|
>
|
||||||
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
<template v-for="(message, index) in messages" :key="index">
|
||||||
]"
|
<div
|
||||||
>
|
:class="[
|
||||||
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
|
'mb-7.5 flex w-full items-start gap-2',
|
||||||
<div
|
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
||||||
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
|
||||||
:class="[
|
<div
|
||||||
'mb-1 flex gap-2 text-xs',
|
:class="[
|
||||||
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
'flex max-w-[70%] flex-col',
|
||||||
]"
|
message.isMe ? 'items-end' : 'items-start'
|
||||||
>
|
]"
|
||||||
<span class="font-medium">{{ message.sender }}</span>
|
>
|
||||||
<span class="text-g-600">{{ message.time }}</span>
|
<div
|
||||||
</div>
|
:class="[
|
||||||
<div
|
'mb-1 flex gap-2 text-xs',
|
||||||
:class="[
|
message.isMe ? 'flex-row-reverse' : 'flex-row'
|
||||||
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
|
]"
|
||||||
message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50'
|
>
|
||||||
]"
|
<span class="font-medium">{{ message.sender }}</span>
|
||||||
>{{ message.content }}</div
|
<span class="text-g-600">{{ message.time }}</span>
|
||||||
>
|
</div>
|
||||||
</div>
|
<div
|
||||||
</div>
|
:class="[
|
||||||
</template>
|
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
|
||||||
</div>
|
message.isMe
|
||||||
|
? 'message-right bg-theme/15'
|
||||||
|
: 'message-left bg-g-300/50'
|
||||||
|
]"
|
||||||
|
>{{ message.content }}</div
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 聊天输入区域 -->
|
<!-- 聊天输入区域 -->
|
||||||
<div class="px-4 pt-4">
|
<div class="px-4 pt-4">
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model="messageText"
|
v-model="messageText"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:rows="3"
|
:rows="3"
|
||||||
placeholder="输入消息"
|
placeholder="输入消息"
|
||||||
resize="none"
|
resize="none"
|
||||||
@keyup.enter.prevent="sendMessage"
|
@keyup.enter.prevent="sendMessage"
|
||||||
>
|
>
|
||||||
<template #append>
|
<template #append>
|
||||||
<div class="flex gap-2 py-2">
|
<div class="flex gap-2 py-2">
|
||||||
<ElButton :icon="Paperclip" circle plain />
|
<ElButton :icon="Paperclip" circle plain />
|
||||||
<ElButton :icon="Picture" circle plain />
|
<ElButton :icon="Picture" circle plain />
|
||||||
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
|
<ElButton type="primary" @click="sendMessage" v-ripple
|
||||||
</div>
|
>发送</ElButton
|
||||||
</template>
|
>
|
||||||
</ElInput>
|
</div>
|
||||||
<div class="mt-3 flex-cb">
|
</template>
|
||||||
<div class="flex-c">
|
</ElInput>
|
||||||
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
|
<div class="mt-3 flex-cb">
|
||||||
<ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
|
<div class="flex-c">
|
||||||
</div>
|
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
|
||||||
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
|
<ArtSvgIcon
|
||||||
</div>
|
icon="ri:emotion-happy-line"
|
||||||
</div>
|
class="mr-5 c-p text-g-600 text-lg"
|
||||||
</div>
|
/>
|
||||||
</ElDrawer>
|
</div>
|
||||||
</div>
|
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20"
|
||||||
|
>发送</ElButton
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ElDrawer>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
|
import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
import meAvatar from '@/assets/images/avatar/avatar5.webp'
|
import meAvatar from '@/assets/images/avatar/avatar5.webp'
|
||||||
import aiAvatar from '@/assets/images/avatar/avatar10.webp'
|
import aiAvatar from '@/assets/images/avatar/avatar10.webp'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtChatWindow' })
|
defineOptions({ name: 'ArtChatWindow' })
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
interface ChatMessage {
|
interface ChatMessage {
|
||||||
id: number
|
id: number
|
||||||
sender: string
|
sender: string
|
||||||
content: string
|
content: string
|
||||||
time: string
|
time: string
|
||||||
isMe: boolean
|
isMe: boolean
|
||||||
avatar: string
|
avatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// 常量定义
|
// 常量定义
|
||||||
const MOBILE_BREAKPOINT = 640
|
const MOBILE_BREAKPOINT = 640
|
||||||
const SCROLL_DELAY = 100
|
const SCROLL_DELAY = 100
|
||||||
const BOT_NAME = 'Art Bot'
|
const BOT_NAME = 'Art Bot'
|
||||||
const USER_NAME = 'Ricky'
|
const USER_NAME = 'Ricky'
|
||||||
|
|
||||||
// 响应式布局
|
// 响应式布局
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
|
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
|
||||||
|
|
||||||
// 组件状态
|
// 组件状态
|
||||||
const isDrawerVisible = ref(false)
|
const isDrawerVisible = ref(false)
|
||||||
const isOnline = ref(true)
|
const isOnline = ref(true)
|
||||||
|
|
||||||
// 消息相关状态
|
// 消息相关状态
|
||||||
const messageText = ref('')
|
const messageText = ref('')
|
||||||
const messageId = ref(10)
|
const messageId = ref(10)
|
||||||
const messageContainer = ref<HTMLElement | null>(null)
|
const messageContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// 初始化聊天消息数据
|
// 初始化聊天消息数据
|
||||||
const initializeMessages = (): ChatMessage[] => [
|
const initializeMessages = (): ChatMessage[] => [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
sender: BOT_NAME,
|
sender: BOT_NAME,
|
||||||
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
|
content: '你好!我是你的AI助手,有什么我可以帮你的吗?',
|
||||||
time: '10:00',
|
time: '10:00',
|
||||||
isMe: false,
|
isMe: false,
|
||||||
avatar: aiAvatar
|
avatar: aiAvatar
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
sender: USER_NAME,
|
sender: USER_NAME,
|
||||||
content: '我想了解一下系统的使用方法。',
|
content: '我想了解一下系统的使用方法。',
|
||||||
time: '10:01',
|
time: '10:01',
|
||||||
isMe: true,
|
isMe: true,
|
||||||
avatar: meAvatar
|
avatar: meAvatar
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
sender: BOT_NAME,
|
sender: BOT_NAME,
|
||||||
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
|
content:
|
||||||
time: '10:02',
|
'好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
|
||||||
isMe: false,
|
time: '10:02',
|
||||||
avatar: aiAvatar
|
isMe: false,
|
||||||
},
|
avatar: aiAvatar
|
||||||
{
|
},
|
||||||
id: 4,
|
{
|
||||||
sender: USER_NAME,
|
id: 4,
|
||||||
content: '听起来很不错,能具体讲讲数据分析部分吗?',
|
sender: USER_NAME,
|
||||||
time: '10:05',
|
content: '听起来很不错,能具体讲讲数据分析部分吗?',
|
||||||
isMe: true,
|
time: '10:05',
|
||||||
avatar: meAvatar
|
isMe: true,
|
||||||
},
|
avatar: meAvatar
|
||||||
{
|
},
|
||||||
id: 5,
|
{
|
||||||
sender: BOT_NAME,
|
id: 5,
|
||||||
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
|
sender: BOT_NAME,
|
||||||
time: '10:06',
|
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
|
||||||
isMe: false,
|
time: '10:06',
|
||||||
avatar: aiAvatar
|
isMe: false,
|
||||||
},
|
avatar: aiAvatar
|
||||||
{
|
},
|
||||||
id: 6,
|
{
|
||||||
sender: USER_NAME,
|
id: 6,
|
||||||
content: '太好了,那我如何开始使用呢?',
|
sender: USER_NAME,
|
||||||
time: '10:08',
|
content: '太好了,那我如何开始使用呢?',
|
||||||
isMe: true,
|
time: '10:08',
|
||||||
avatar: meAvatar
|
isMe: true,
|
||||||
},
|
avatar: meAvatar
|
||||||
{
|
},
|
||||||
id: 7,
|
{
|
||||||
sender: BOT_NAME,
|
id: 7,
|
||||||
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
|
sender: BOT_NAME,
|
||||||
time: '10:09',
|
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
|
||||||
isMe: false,
|
time: '10:09',
|
||||||
avatar: aiAvatar
|
isMe: false,
|
||||||
},
|
avatar: aiAvatar
|
||||||
{
|
},
|
||||||
id: 8,
|
{
|
||||||
sender: USER_NAME,
|
id: 8,
|
||||||
content: '明白了,谢谢你的帮助!',
|
sender: USER_NAME,
|
||||||
time: '10:10',
|
content: '明白了,谢谢你的帮助!',
|
||||||
isMe: true,
|
time: '10:10',
|
||||||
avatar: meAvatar
|
isMe: true,
|
||||||
},
|
avatar: meAvatar
|
||||||
{
|
},
|
||||||
id: 9,
|
{
|
||||||
sender: BOT_NAME,
|
id: 9,
|
||||||
content: '不客气,有任何问题随时联系我。',
|
sender: BOT_NAME,
|
||||||
time: '10:11',
|
content: '不客气,有任何问题随时联系我。',
|
||||||
isMe: false,
|
time: '10:11',
|
||||||
avatar: aiAvatar
|
isMe: false,
|
||||||
}
|
avatar: aiAvatar
|
||||||
]
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>(initializeMessages())
|
const messages = ref<ChatMessage[]>(initializeMessages())
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
const formatCurrentTime = (): string => {
|
const formatCurrentTime = (): string => {
|
||||||
return new Date().toLocaleTimeString([], {
|
return new Date().toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit'
|
minute: '2-digit'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToBottom = (): void => {
|
const scrollToBottom = (): void => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (messageContainer.value) {
|
if (messageContainer.value) {
|
||||||
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
|
||||||
}
|
}
|
||||||
}, SCROLL_DELAY)
|
}, SCROLL_DELAY)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 消息处理方法
|
// 消息处理方法
|
||||||
const sendMessage = (): void => {
|
const sendMessage = (): void => {
|
||||||
const text = messageText.value.trim()
|
const text = messageText.value.trim()
|
||||||
if (!text) return
|
if (!text) return
|
||||||
|
|
||||||
const newMessage: ChatMessage = {
|
const newMessage: ChatMessage = {
|
||||||
id: messageId.value++,
|
id: messageId.value++,
|
||||||
sender: USER_NAME,
|
sender: USER_NAME,
|
||||||
content: text,
|
content: text,
|
||||||
time: formatCurrentTime(),
|
time: formatCurrentTime(),
|
||||||
isMe: true,
|
isMe: true,
|
||||||
avatar: meAvatar
|
avatar: meAvatar
|
||||||
}
|
}
|
||||||
|
|
||||||
messages.value.push(newMessage)
|
messages.value.push(newMessage)
|
||||||
messageText.value = ''
|
messageText.value = ''
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 聊天窗口控制方法
|
// 聊天窗口控制方法
|
||||||
const openChat = (): void => {
|
const openChat = (): void => {
|
||||||
isDrawerVisible.value = true
|
isDrawerVisible.value = true
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeChat = (): void => {
|
const closeChat = (): void => {
|
||||||
isDrawerVisible.value = false
|
isDrawerVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生命周期
|
// 生命周期
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
mittBus.on('openChat', openChat)
|
mittBus.on('openChat', openChat)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
mittBus.off('openChat', openChat)
|
mittBus.off('openChat', openChat)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,113 +1,117 @@
|
|||||||
<!-- 顶部快速入口面板 -->
|
<!-- 顶部快速入口面板 -->
|
||||||
<template>
|
<template>
|
||||||
<ElPopover
|
<ElPopover
|
||||||
ref="popoverRef"
|
ref="popoverRef"
|
||||||
:width="700"
|
:width="700"
|
||||||
:offset="0"
|
:offset="0"
|
||||||
:show-arrow="false"
|
:show-arrow="false"
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
popper-class="fast-enter-popover"
|
popper-class="fast-enter-popover"
|
||||||
:popper-style="{
|
:popper-style="{
|
||||||
border: '1px solid var(--default-border)',
|
border: '1px solid var(--default-border)',
|
||||||
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
|
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div class="flex-c gap-2">
|
<div class="flex-c gap-2">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="grid grid-cols-[2fr_0.8fr]">
|
<div class="grid grid-cols-[2fr_0.8fr]">
|
||||||
<div>
|
<div>
|
||||||
<div class="grid grid-cols-2 gap-1.5">
|
<div class="grid grid-cols-2 gap-1.5">
|
||||||
<!-- 应用列表 -->
|
<!-- 应用列表 -->
|
||||||
<div
|
<div
|
||||||
v-for="application in enabledApplications"
|
v-for="application in enabledApplications"
|
||||||
:key="application.name"
|
:key="application.name"
|
||||||
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent"
|
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent"
|
||||||
@click="handleApplicationClick(application)"
|
@click="handleApplicationClick(application)"
|
||||||
>
|
>
|
||||||
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30">
|
<div
|
||||||
<ArtSvgIcon
|
class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30"
|
||||||
class="text-xl"
|
>
|
||||||
:icon="application.icon"
|
<ArtSvgIcon
|
||||||
:style="{ color: application.iconColor }"
|
class="text-xl"
|
||||||
/>
|
:icon="application.icon"
|
||||||
</div>
|
:style="{ color: application.iconColor }"
|
||||||
<div>
|
/>
|
||||||
<h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3>
|
</div>
|
||||||
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
|
<div>
|
||||||
</div>
|
<h3 class="m-0 text-sm font-medium text-g-800">{{
|
||||||
</div>
|
application.name
|
||||||
</div>
|
}}</h3>
|
||||||
</div>
|
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="border-l-d pl-6 pt-2">
|
<div class="border-l-d pl-6 pt-2">
|
||||||
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
|
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="quickLink in enabledQuickLinks"
|
v-for="quickLink in enabledQuickLinks"
|
||||||
:key="quickLink.name"
|
:key="quickLink.name"
|
||||||
class="c-p py-2 hover:[&_span]:text-theme"
|
class="c-p py-2 hover:[&_span]:text-theme"
|
||||||
@click="handleQuickLinkClick(quickLink)"
|
@click="handleQuickLinkClick(quickLink)"
|
||||||
>
|
>
|
||||||
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
|
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ElPopover>
|
</ElPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useFastEnter } from '@/hooks/core/useFastEnter'
|
import { useFastEnter } from '@/hooks/core/useFastEnter'
|
||||||
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
|
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtFastEnter' })
|
defineOptions({ name: 'ArtFastEnter' })
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const popoverRef = ref()
|
const popoverRef = ref()
|
||||||
|
|
||||||
// 使用快速入口配置
|
// 使用快速入口配置
|
||||||
const { enabledApplications, enabledQuickLinks } = useFastEnter()
|
const { enabledApplications, enabledQuickLinks } = useFastEnter()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理导航跳转
|
* 处理导航跳转
|
||||||
* @param routeName 路由名称
|
* @param routeName 路由名称
|
||||||
* @param link 外部链接
|
* @param link 外部链接
|
||||||
*/
|
*/
|
||||||
const handleNavigate = (routeName?: string, link?: string): void => {
|
const handleNavigate = (routeName?: string, link?: string): void => {
|
||||||
const targetPath = routeName || link
|
const targetPath = routeName || link
|
||||||
|
|
||||||
if (!targetPath) {
|
if (!targetPath) {
|
||||||
console.warn('导航配置无效:缺少路由名称或链接')
|
console.warn('导航配置无效:缺少路由名称或链接')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetPath.startsWith('http')) {
|
if (targetPath.startsWith('http')) {
|
||||||
window.open(targetPath, '_blank')
|
window.open(targetPath, '_blank')
|
||||||
} else {
|
} else {
|
||||||
router.push({ name: targetPath })
|
router.push({ name: targetPath })
|
||||||
}
|
}
|
||||||
|
|
||||||
popoverRef.value?.hide()
|
popoverRef.value?.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理应用项点击
|
* 处理应用项点击
|
||||||
* @param application 应用配置对象
|
* @param application 应用配置对象
|
||||||
*/
|
*/
|
||||||
const handleApplicationClick = (application: FastEnterApplication): void => {
|
const handleApplicationClick = (application: FastEnterApplication): void => {
|
||||||
handleNavigate(application.routeName, application.link)
|
handleNavigate(application.routeName, application.link)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理快速链接点击
|
* 处理快速链接点击
|
||||||
* @param quickLink 快速链接配置对象
|
* @param quickLink 快速链接配置对象
|
||||||
*/
|
*/
|
||||||
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
|
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
|
||||||
handleNavigate(quickLink.routeName, quickLink.link)
|
handleNavigate(quickLink.routeName, quickLink.link)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,14 @@
|
|||||||
<!-- 全局组件 -->
|
<!-- 全局组件 -->
|
||||||
<template>
|
<template>
|
||||||
<component
|
<component
|
||||||
v-for="componentConfig in enabledComponents"
|
v-for="componentConfig in enabledComponents"
|
||||||
:key="componentConfig.key"
|
:key="componentConfig.key"
|
||||||
:is="componentConfig.component"
|
:is="componentConfig.component"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getEnabledGlobalComponents } from '@/config/modules/component'
|
import { getEnabledGlobalComponents } from '@/config/modules/component'
|
||||||
defineOptions({ name: 'ArtGlobalComponent' })
|
defineOptions({ name: 'ArtGlobalComponent' })
|
||||||
const enabledComponents = computed(() => getEnabledGlobalComponents())
|
const enabledComponents = computed(() => getEnabledGlobalComponents())
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,417 +1,430 @@
|
|||||||
<!-- 全局搜索组件 -->
|
<!-- 全局搜索组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-search">
|
<div class="layout-search">
|
||||||
<ElDialog
|
<ElDialog
|
||||||
v-model="showSearchDialog"
|
v-model="showSearchDialog"
|
||||||
width="600"
|
width="600"
|
||||||
:show-close="false"
|
:show-close="false"
|
||||||
:lock-scroll="false"
|
:lock-scroll="false"
|
||||||
modal-class="search-modal"
|
modal-class="search-modal"
|
||||||
@close="closeSearchDialog"
|
@close="closeSearchDialog"
|
||||||
>
|
>
|
||||||
<ElInput
|
<ElInput
|
||||||
v-model.trim="searchVal"
|
v-model.trim="searchVal"
|
||||||
:placeholder="$t('search.placeholder')"
|
:placeholder="$t('search.placeholder')"
|
||||||
@input="search"
|
@input="search"
|
||||||
@blur="searchBlur"
|
@blur="searchBlur"
|
||||||
ref="searchInput"
|
ref="searchInput"
|
||||||
:prefix-icon="Search"
|
:prefix-icon="Search"
|
||||||
class="h-12"
|
class="h-12"
|
||||||
>
|
>
|
||||||
<template #suffix>
|
<template #suffix>
|
||||||
<div
|
<div
|
||||||
class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500"
|
class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
|
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElInput>
|
</ElInput>
|
||||||
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
|
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
|
||||||
<div class="result w-full" v-show="searchResult.length">
|
<div class="result w-full" v-show="searchResult.length">
|
||||||
<div
|
<div
|
||||||
class="box !mt-0 c-p text-base leading-none"
|
class="box !mt-0 c-p text-base leading-none"
|
||||||
v-for="(item, index) in searchResult"
|
v-for="(item, index) in searchResult"
|
||||||
:key="index"
|
:key="index"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
|
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
|
||||||
:class="isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''"
|
:class="
|
||||||
@click="searchGoPage(item)"
|
isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''
|
||||||
@mouseenter="highlightOnHover(index)"
|
"
|
||||||
>
|
@click="searchGoPage(item)"
|
||||||
{{ formatMenuTitle(item.meta.title) }}
|
@mouseenter="highlightOnHover(index)"
|
||||||
<ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" />
|
>
|
||||||
</div>
|
{{ formatMenuTitle(item.meta.title) }}
|
||||||
</div>
|
<ArtSvgIcon
|
||||||
</div>
|
v-show="isHighlighted(index)"
|
||||||
|
icon="fluent:arrow-enter-left-20-filled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
|
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
|
||||||
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
|
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
|
||||||
<div class="mt-1.5 w-full">
|
<div class="mt-1.5 w-full">
|
||||||
<div
|
<div
|
||||||
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
|
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
|
||||||
v-for="(item, index) in historyResult"
|
v-for="(item, index) in historyResult"
|
||||||
:key="index"
|
:key="index"
|
||||||
:class="
|
:class="
|
||||||
historyHIndex === index
|
historyHIndex === index
|
||||||
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
|
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
|
||||||
: ''
|
: ''
|
||||||
"
|
"
|
||||||
@click="searchGoPage(item)"
|
@click="searchGoPage(item)"
|
||||||
@mouseenter="highlightOnHoverHistory(index)"
|
@mouseenter="highlightOnHoverHistory(index)"
|
||||||
>
|
>
|
||||||
{{ formatMenuTitle(item.meta.title) }}
|
{{ formatMenuTitle(item.meta.title) }}
|
||||||
<div
|
<div
|
||||||
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
|
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
|
||||||
@click.stop="deleteHistory(index)"
|
@click.stop="deleteHistory(index)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
|
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ElScrollbar>
|
</ElScrollbar>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
|
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
|
||||||
<div class="flex-cc">
|
<div class="flex-cc">
|
||||||
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
|
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
|
||||||
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.selectKeydown') }}</span>
|
<span class="mr-3.5 text-xs text-g-700">{{
|
||||||
</div>
|
$t('search.selectKeydown')
|
||||||
<div class="flex-c">
|
}}</span>
|
||||||
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
|
</div>
|
||||||
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
|
<div class="flex-c">
|
||||||
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.switchKeydown') }}</span>
|
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
|
||||||
</div>
|
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
|
||||||
<div class="flex-c">
|
<span class="mr-3.5 text-xs text-g-700">{{
|
||||||
<i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i>
|
$t('search.switchKeydown')
|
||||||
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.exitKeydown') }}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex-c">
|
||||||
</template>
|
<i class="keyboard !w-8 flex-cc"
|
||||||
</ElDialog>
|
><p class="text-[10px] font-medium">ESC</p></i
|
||||||
</div>
|
>
|
||||||
|
<span class="mr-3.5 text-xs text-g-700">{{
|
||||||
|
$t('search.exitKeydown')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { AppRouteRecord } from '@/types/router'
|
import { AppRouteRecord } from '@/types/router'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
import { useMenuStore } from '@/store/modules/menu'
|
import { useMenuStore } from '@/store/modules/menu'
|
||||||
import { formatMenuTitle } from '@/utils/router'
|
import { formatMenuTitle } from '@/utils/router'
|
||||||
import { type ScrollbarInstance } from 'element-plus'
|
import { type ScrollbarInstance } from 'element-plus'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtGlobalSearch' })
|
defineOptions({ name: 'ArtGlobalSearch' })
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { menuList } = storeToRefs(useMenuStore())
|
const { menuList } = storeToRefs(useMenuStore())
|
||||||
|
|
||||||
const showSearchDialog = ref(false)
|
const showSearchDialog = ref(false)
|
||||||
const searchVal = ref('')
|
const searchVal = ref('')
|
||||||
const searchResult = ref<AppRouteRecord[]>([])
|
const searchResult = ref<AppRouteRecord[]>([])
|
||||||
const historyMaxLength = 10
|
const historyMaxLength = 10
|
||||||
|
|
||||||
const { searchHistory: historyResult } = storeToRefs(userStore)
|
const { searchHistory: historyResult } = storeToRefs(userStore)
|
||||||
|
|
||||||
const searchInput = ref<HTMLInputElement | null>(null)
|
const searchInput = ref<HTMLInputElement | null>(null)
|
||||||
const highlightedIndex = ref(0)
|
const highlightedIndex = ref(0)
|
||||||
const historyHIndex = ref(0)
|
const historyHIndex = ref(0)
|
||||||
const searchResultScrollbar = ref<ScrollbarInstance>()
|
const searchResultScrollbar = ref<ScrollbarInstance>()
|
||||||
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
|
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
|
||||||
|
|
||||||
// 生命周期钩子
|
// 生命周期钩子
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mittBus.on('openSearchDialog', openSearchDialog)
|
mittBus.on('openSearchDialog', openSearchDialog)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 键盘快捷键处理
|
// 键盘快捷键处理
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
|
||||||
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
|
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
|
||||||
|
|
||||||
if (isCommandKey && event.key.toLowerCase() === 'k') {
|
if (isCommandKey && event.key.toLowerCase() === 'k') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
showSearchDialog.value = true
|
showSearchDialog.value = true
|
||||||
focusInput()
|
focusInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当搜索对话框打开时,处理方向键和回车键
|
// 当搜索对话框打开时,处理方向键和回车键
|
||||||
if (showSearchDialog.value) {
|
if (showSearchDialog.value) {
|
||||||
if (event.key === 'ArrowUp') {
|
if (event.key === 'ArrowUp') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
highlightPrevious()
|
highlightPrevious()
|
||||||
} else if (event.key === 'ArrowDown') {
|
} else if (event.key === 'ArrowDown') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
highlightNext()
|
highlightNext()
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
selectHighlighted()
|
selectHighlighted()
|
||||||
} else if (event.key === 'Escape') {
|
} else if (event.key === 'Escape') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
showSearchDialog.value = false
|
showSearchDialog.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
searchInput.value?.focus()
|
searchInput.value?.focus()
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索逻辑
|
// 搜索逻辑
|
||||||
const search = (val: string) => {
|
const search = (val: string) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
|
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
|
||||||
} else {
|
} else {
|
||||||
searchResult.value = []
|
searchResult.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
|
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
|
||||||
const lowerVal = val.toLowerCase()
|
const lowerVal = val.toLowerCase()
|
||||||
const result: AppRouteRecord[] = []
|
const result: AppRouteRecord[] = []
|
||||||
|
|
||||||
const flattenAndMatch = (item: AppRouteRecord) => {
|
const flattenAndMatch = (item: AppRouteRecord) => {
|
||||||
if (item.meta?.isHide) return
|
if (item.meta?.isHide) return
|
||||||
|
|
||||||
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase()
|
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase()
|
||||||
|
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
item.children.forEach(flattenAndMatch)
|
item.children.forEach(flattenAndMatch)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lowerItemTitle.includes(lowerVal) && item.path) {
|
if (lowerItemTitle.includes(lowerVal) && item.path) {
|
||||||
result.push({ ...item, children: undefined })
|
result.push({ ...item, children: undefined })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items.forEach(flattenAndMatch)
|
items.forEach(flattenAndMatch)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 高亮控制并实现滚动条跟随
|
// 高亮控制并实现滚动条跟随
|
||||||
const highlightPrevious = () => {
|
const highlightPrevious = () => {
|
||||||
isKeyboardNavigating.value = true
|
isKeyboardNavigating.value = true
|
||||||
if (searchVal.value) {
|
if (searchVal.value) {
|
||||||
highlightedIndex.value =
|
highlightedIndex.value =
|
||||||
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
|
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
|
||||||
scrollToHighlightedItem()
|
scrollToHighlightedItem()
|
||||||
} else {
|
} else {
|
||||||
historyHIndex.value =
|
historyHIndex.value =
|
||||||
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
|
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
|
||||||
scrollToHighlightedHistoryItem()
|
scrollToHighlightedHistoryItem()
|
||||||
}
|
}
|
||||||
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
|
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isKeyboardNavigating.value = false
|
isKeyboardNavigating.value = false
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightNext = () => {
|
const highlightNext = () => {
|
||||||
isKeyboardNavigating.value = true
|
isKeyboardNavigating.value = true
|
||||||
if (searchVal.value) {
|
if (searchVal.value) {
|
||||||
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
|
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
|
||||||
scrollToHighlightedItem()
|
scrollToHighlightedItem()
|
||||||
} else {
|
} else {
|
||||||
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
|
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
|
||||||
scrollToHighlightedHistoryItem()
|
scrollToHighlightedHistoryItem()
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
isKeyboardNavigating.value = false
|
isKeyboardNavigating.value = false
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToHighlightedItem = () => {
|
const scrollToHighlightedItem = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!searchResultScrollbar.value || !searchResult.value.length) return
|
if (!searchResultScrollbar.value || !searchResult.value.length) return
|
||||||
|
|
||||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||||
if (!scrollWrapper) return
|
if (!scrollWrapper) return
|
||||||
|
|
||||||
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
|
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
|
||||||
if (!highlightedElements[highlightedIndex.value]) return
|
if (!highlightedElements[highlightedIndex.value]) return
|
||||||
|
|
||||||
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
|
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
|
||||||
const itemHeight = highlightedElement.offsetHeight
|
const itemHeight = highlightedElement.offsetHeight
|
||||||
const scrollTop = scrollWrapper.scrollTop
|
const scrollTop = scrollWrapper.scrollTop
|
||||||
const containerHeight = scrollWrapper.clientHeight
|
const containerHeight = scrollWrapper.clientHeight
|
||||||
const itemTop = highlightedElement.offsetTop
|
const itemTop = highlightedElement.offsetTop
|
||||||
const itemBottom = itemTop + itemHeight
|
const itemBottom = itemTop + itemHeight
|
||||||
|
|
||||||
if (itemTop < scrollTop) {
|
if (itemTop < scrollTop) {
|
||||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||||
} else if (itemBottom > scrollTop + containerHeight) {
|
} else if (itemBottom > scrollTop + containerHeight) {
|
||||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToHighlightedHistoryItem = () => {
|
const scrollToHighlightedHistoryItem = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (!searchResultScrollbar.value || !historyResult.value.length) return
|
if (!searchResultScrollbar.value || !historyResult.value.length) return
|
||||||
|
|
||||||
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
const scrollWrapper = searchResultScrollbar.value.wrapRef
|
||||||
if (!scrollWrapper) return
|
if (!scrollWrapper) return
|
||||||
|
|
||||||
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
|
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
|
||||||
if (!historyItems[historyHIndex.value]) return
|
if (!historyItems[historyHIndex.value]) return
|
||||||
|
|
||||||
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
|
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
|
||||||
const itemHeight = highlightedElement.offsetHeight
|
const itemHeight = highlightedElement.offsetHeight
|
||||||
const scrollTop = scrollWrapper.scrollTop
|
const scrollTop = scrollWrapper.scrollTop
|
||||||
const containerHeight = scrollWrapper.clientHeight
|
const containerHeight = scrollWrapper.clientHeight
|
||||||
const itemTop = highlightedElement.offsetTop
|
const itemTop = highlightedElement.offsetTop
|
||||||
const itemBottom = itemTop + itemHeight
|
const itemBottom = itemTop + itemHeight
|
||||||
|
|
||||||
if (itemTop < scrollTop) {
|
if (itemTop < scrollTop) {
|
||||||
searchResultScrollbar.value.setScrollTop(itemTop)
|
searchResultScrollbar.value.setScrollTop(itemTop)
|
||||||
} else if (itemBottom > scrollTop + containerHeight) {
|
} else if (itemBottom > scrollTop + containerHeight) {
|
||||||
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectHighlighted = () => {
|
const selectHighlighted = () => {
|
||||||
if (searchVal.value && searchResult.value.length) {
|
if (searchVal.value && searchResult.value.length) {
|
||||||
searchGoPage(searchResult.value[highlightedIndex.value])
|
searchGoPage(searchResult.value[highlightedIndex.value])
|
||||||
} else if (!searchVal.value && historyResult.value.length) {
|
} else if (!searchVal.value && historyResult.value.length) {
|
||||||
searchGoPage(historyResult.value[historyHIndex.value])
|
searchGoPage(historyResult.value[historyHIndex.value])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isHighlighted = (index: number) => {
|
const isHighlighted = (index: number) => {
|
||||||
return highlightedIndex.value === index
|
return highlightedIndex.value === index
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchBlur = () => {
|
const searchBlur = () => {
|
||||||
highlightedIndex.value = 0
|
highlightedIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchGoPage = (item: AppRouteRecord) => {
|
const searchGoPage = (item: AppRouteRecord) => {
|
||||||
showSearchDialog.value = false
|
showSearchDialog.value = false
|
||||||
addHistory(item)
|
addHistory(item)
|
||||||
router.push(item.path)
|
router.push(item.path)
|
||||||
searchVal.value = ''
|
searchVal.value = ''
|
||||||
searchResult.value = []
|
searchResult.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 历史记录管理
|
// 历史记录管理
|
||||||
const updateHistory = () => {
|
const updateHistory = () => {
|
||||||
if (Array.isArray(historyResult.value)) {
|
if (Array.isArray(historyResult.value)) {
|
||||||
userStore.setSearchHistory(historyResult.value)
|
userStore.setSearchHistory(historyResult.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const addHistory = (item: AppRouteRecord) => {
|
const addHistory = (item: AppRouteRecord) => {
|
||||||
const hasItemIndex = historyResult.value.findIndex(
|
const hasItemIndex = historyResult.value.findIndex(
|
||||||
(historyItem: AppRouteRecord) => historyItem.path === item.path
|
(historyItem: AppRouteRecord) => historyItem.path === item.path
|
||||||
)
|
)
|
||||||
|
|
||||||
if (hasItemIndex !== -1) {
|
if (hasItemIndex !== -1) {
|
||||||
historyResult.value.splice(hasItemIndex, 1)
|
historyResult.value.splice(hasItemIndex, 1)
|
||||||
} else if (historyResult.value.length >= historyMaxLength) {
|
} else if (historyResult.value.length >= historyMaxLength) {
|
||||||
historyResult.value.pop()
|
historyResult.value.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanedItem = { ...item }
|
const cleanedItem = { ...item }
|
||||||
delete cleanedItem.children
|
delete cleanedItem.children
|
||||||
delete cleanedItem.meta.authList
|
delete cleanedItem.meta.authList
|
||||||
historyResult.value.unshift(cleanedItem)
|
historyResult.value.unshift(cleanedItem)
|
||||||
updateHistory()
|
updateHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteHistory = (index: number) => {
|
const deleteHistory = (index: number) => {
|
||||||
historyResult.value.splice(index, 1)
|
historyResult.value.splice(index, 1)
|
||||||
updateHistory()
|
updateHistory()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对话框控制
|
// 对话框控制
|
||||||
const openSearchDialog = () => {
|
const openSearchDialog = () => {
|
||||||
showSearchDialog.value = true
|
showSearchDialog.value = true
|
||||||
focusInput()
|
focusInput()
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeSearchDialog = () => {
|
const closeSearchDialog = () => {
|
||||||
searchVal.value = ''
|
searchVal.value = ''
|
||||||
searchResult.value = []
|
searchResult.value = []
|
||||||
highlightedIndex.value = 0
|
highlightedIndex.value = 0
|
||||||
historyHIndex.value = 0
|
historyHIndex.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
|
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
|
||||||
const highlightOnHover = (index: number) => {
|
const highlightOnHover = (index: number) => {
|
||||||
if (!isKeyboardNavigating.value && searchVal.value) {
|
if (!isKeyboardNavigating.value && searchVal.value) {
|
||||||
highlightedIndex.value = index
|
highlightedIndex.value = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlightOnHoverHistory = (index: number) => {
|
const highlightOnHoverHistory = (index: number) => {
|
||||||
if (!isKeyboardNavigating.value && !searchVal.value) {
|
if (!isKeyboardNavigating.value && !searchVal.value) {
|
||||||
historyHIndex.value = index
|
historyHIndex.value = index
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.layout-search {
|
.layout-search {
|
||||||
:deep(.search-modal) {
|
:deep(.search-modal) {
|
||||||
background-color: rgb(0 0 0 / 20%);
|
background-color: rgb(0 0 0 / 20%);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-dialog__body) {
|
:deep(.el-dialog__body) {
|
||||||
padding: 5px 0 0 !important;
|
padding: 5px 0 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-dialog__header) {
|
:deep(.el-dialog__header) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-input {
|
.el-input {
|
||||||
:deep(.el-input__wrapper) {
|
:deep(.el-input__wrapper) {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
border: 1px solid var(--default-border-dashed);
|
border: 1px solid var(--default-border-dashed);
|
||||||
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-input__inner) {
|
:deep(.el-input__inner) {
|
||||||
color: var(--art-gray-800) !important;
|
color: var(--art-gray-800) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .layout-search {
|
.dark .layout-search {
|
||||||
.el-input {
|
.el-input {
|
||||||
:deep(.el-input__wrapper) {
|
:deep(.el-input__wrapper) {
|
||||||
background-color: #333;
|
background-color: #333;
|
||||||
border: 1px solid #4c4d50;
|
border: 1px solid #4c4d50;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.search-modal) {
|
:deep(.search-modal) {
|
||||||
background-color: rgb(23 23 26 / 60%);
|
background-color: rgb(23 23 26 / 60%);
|
||||||
backdrop-filter: none;
|
backdrop-filter: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-dialog) {
|
:deep(.el-dialog) {
|
||||||
background-color: #252526;
|
background-color: #252526;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference '@styles/core/tailwind.css';
|
@reference '@styles/core/tailwind.css';
|
||||||
|
|
||||||
.keyboard {
|
.keyboard {
|
||||||
@apply mr-2
|
@apply mr-2
|
||||||
box-border
|
box-border
|
||||||
h-5
|
h-5
|
||||||
w-5.5
|
w-5.5
|
||||||
@@ -422,5 +435,5 @@
|
|||||||
text-g-500
|
text-g-500
|
||||||
shadow-[0_2px_0_var(--default-border-dashed)]
|
shadow-[0_2px_0_var(--default-border-dashed)]
|
||||||
last-of-type:mr-1.5;
|
last-of-type:mr-1.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,485 +1,509 @@
|
|||||||
<!-- 顶部栏 -->
|
<!-- 顶部栏 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="w-full bg-[var(--default-bg-color)]"
|
class="w-full bg-[var(--default-bg-color)]"
|
||||||
:class="[
|
:class="[
|
||||||
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
|
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="relative box-border flex-b h-15 leading-15 select-none"
|
class="relative box-border flex-b h-15 leading-15 select-none"
|
||||||
:class="[
|
:class="[
|
||||||
tabStyle === 'tab-card' || tabStyle === 'tab-google'
|
tabStyle === 'tab-card' || tabStyle === 'tab-google'
|
||||||
? 'border-b border-[var(--art-card-border)]'
|
? 'border-b border-[var(--art-card-border)]'
|
||||||
: ''
|
: ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex-c flex-1 min-w-0 leading-15" style="display: flex">
|
<div class="flex-c flex-1 min-w-0 leading-15" style="display: flex">
|
||||||
<!-- 系统信息 -->
|
<!-- 系统信息 -->
|
||||||
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu">
|
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu">
|
||||||
<ArtLogo class="pl-4.5" />
|
<ArtLogo class="pl-4.5" />
|
||||||
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ AppConfig.systemInfo.name }}</p>
|
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{
|
||||||
</div>
|
AppConfig.systemInfo.name
|
||||||
|
}}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ArtLogo
|
<ArtLogo
|
||||||
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
|
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
|
||||||
@click="toHome"
|
@click="toHome"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 菜单按钮 -->
|
<!-- 菜单按钮 -->
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
v-if="isLeftMenu && shouldShowMenuButton"
|
v-if="isLeftMenu && shouldShowMenuButton"
|
||||||
icon="ri:menu-2-fill"
|
icon="ri:menu-2-fill"
|
||||||
class="ml-3 max-sm:ml-[7px]"
|
class="ml-3 max-sm:ml-[7px]"
|
||||||
@click="visibleMenu"
|
@click="visibleMenu"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 刷新按钮 -->
|
<!-- 刷新按钮 -->
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
v-if="shouldShowRefreshButton"
|
v-if="shouldShowRefreshButton"
|
||||||
icon="ri:refresh-line"
|
icon="ri:refresh-line"
|
||||||
class="!ml-3 refresh-btn max-sm:!hidden"
|
class="!ml-3 refresh-btn max-sm:!hidden"
|
||||||
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
|
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
|
||||||
@click="reload"
|
@click="reload"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 快速入口 -->
|
<!-- 快速入口 -->
|
||||||
<ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
|
<ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
|
||||||
<ArtIconButton icon="ri:function-line" class="ml-3" />
|
<ArtIconButton icon="ri:function-line" class="ml-3" />
|
||||||
</ArtFastEnter>
|
</ArtFastEnter>
|
||||||
|
|
||||||
<!-- 面包屑 -->
|
<!-- 面包屑 -->
|
||||||
<ArtBreadcrumb
|
<ArtBreadcrumb
|
||||||
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)"
|
v-if="
|
||||||
/>
|
(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 顶部菜单 -->
|
<!-- 顶部菜单 -->
|
||||||
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
|
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
|
||||||
|
|
||||||
<!-- 混合菜单-顶部 -->
|
<!-- 混合菜单-顶部 -->
|
||||||
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
|
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-c gap-2.5">
|
<div class="flex-c gap-2.5">
|
||||||
<!-- 搜索 -->
|
<!-- 搜索 -->
|
||||||
<div
|
<div
|
||||||
v-if="shouldShowGlobalSearch"
|
v-if="shouldShowGlobalSearch"
|
||||||
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden"
|
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden"
|
||||||
@click="openSearchDialog"
|
@click="openSearchDialog"
|
||||||
>
|
>
|
||||||
<div class="flex-c">
|
<div class="flex-c">
|
||||||
<ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
|
<ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
|
||||||
<span class="ml-1 text-xs font-normal text-g-500">{{ $t('topBar.search.title') }}</span>
|
<span class="ml-1 text-xs font-normal text-g-500">{{
|
||||||
</div>
|
$t('topBar.search.title')
|
||||||
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
|
}}</span>
|
||||||
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
|
</div>
|
||||||
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
|
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
|
||||||
<span class="ml-0.5 text-xs">k</span>
|
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
|
||||||
</div>
|
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
|
||||||
</div>
|
<span class="ml-0.5 text-xs">k</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 全屏按钮 -->
|
<!-- 全屏按钮 -->
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
v-if="shouldShowFullscreen"
|
v-if="shouldShowFullscreen"
|
||||||
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
|
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
|
||||||
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
|
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
|
||||||
class="max-md:!hidden"
|
class="max-md:!hidden"
|
||||||
@click="toggleFullScreen"
|
@click="toggleFullScreen"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 国际化按钮 -->
|
<!-- 国际化按钮 -->
|
||||||
<ElDropdown
|
<ElDropdown
|
||||||
@command="changeLanguage"
|
@command="changeLanguage"
|
||||||
popper-class="langDropDownStyle"
|
popper-class="langDropDownStyle"
|
||||||
v-if="shouldShowLanguage"
|
v-if="shouldShowLanguage"
|
||||||
>
|
>
|
||||||
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
|
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item">
|
<div
|
||||||
<ElDropdownItem
|
v-for="item in languageOptions"
|
||||||
:command="item.value"
|
:key="item.value"
|
||||||
:class="{ 'is-selected': locale === item.value }"
|
class="lang-btn-item"
|
||||||
>
|
>
|
||||||
<span class="menu-txt">{{ item.label }}</span>
|
<ElDropdownItem
|
||||||
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
|
:command="item.value"
|
||||||
</ElDropdownItem>
|
:class="{ 'is-selected': locale === item.value }"
|
||||||
</div>
|
>
|
||||||
</ElDropdownMenu>
|
<span class="menu-txt">{{ item.label }}</span>
|
||||||
</template>
|
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
|
||||||
</ElDropdown>
|
</ElDropdownItem>
|
||||||
|
</div>
|
||||||
|
</ElDropdownMenu>
|
||||||
|
</template>
|
||||||
|
</ElDropdown>
|
||||||
|
|
||||||
<!-- 通知按钮 -->
|
<!-- 通知按钮 -->
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
v-if="shouldShowNotification"
|
v-if="shouldShowNotification"
|
||||||
icon="ri:notification-2-line"
|
icon="ri:notification-2-line"
|
||||||
class="notice-button relative"
|
class="notice-button relative"
|
||||||
@click="visibleNotice"
|
@click="visibleNotice"
|
||||||
>
|
>
|
||||||
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
|
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
|
||||||
</ArtIconButton>
|
</ArtIconButton>
|
||||||
|
|
||||||
<!-- 聊天按钮 -->
|
<!-- 聊天按钮 -->
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
v-if="shouldShowChat"
|
v-if="shouldShowChat"
|
||||||
icon="ri:message-3-line"
|
icon="ri:message-3-line"
|
||||||
class="chat-button relative"
|
class="chat-button relative"
|
||||||
@click="openChat"
|
@click="openChat"
|
||||||
>
|
>
|
||||||
<div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div>
|
<div
|
||||||
</ArtIconButton>
|
class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"
|
||||||
|
></div>
|
||||||
|
</ArtIconButton>
|
||||||
|
|
||||||
<!-- 设置按钮 -->
|
<!-- 设置按钮 -->
|
||||||
<div v-if="shouldShowSettings">
|
<div v-if="shouldShowSettings">
|
||||||
<ElPopover :visible="showSettingGuide" placement="bottom-start" :width="190" :offset="0">
|
<ElPopover
|
||||||
<template #reference>
|
:visible="showSettingGuide"
|
||||||
<div class="flex-cc">
|
placement="bottom-start"
|
||||||
<ArtIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" />
|
:width="190"
|
||||||
</div>
|
:offset="0"
|
||||||
</template>
|
>
|
||||||
<template #default>
|
<template #reference>
|
||||||
<p
|
<div class="flex-cc">
|
||||||
>{{ $t('topBar.guide.title')
|
<ArtIconButton
|
||||||
}}<span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.theme') }} </span
|
icon="ri:settings-line"
|
||||||
>、 <span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.menu') }} </span
|
class="setting-btn"
|
||||||
>{{ $t('topBar.guide.description') }}
|
@click="openSetting"
|
||||||
</p>
|
/>
|
||||||
</template>
|
</div>
|
||||||
</ElPopover>
|
</template>
|
||||||
</div>
|
<template #default>
|
||||||
|
<p
|
||||||
|
>{{ $t('topBar.guide.title')
|
||||||
|
}}<span :style="{ color: systemThemeColor }">
|
||||||
|
{{ $t('topBar.guide.theme') }} </span
|
||||||
|
>、
|
||||||
|
<span :style="{ color: systemThemeColor }">
|
||||||
|
{{ $t('topBar.guide.menu') }} </span
|
||||||
|
>{{ $t('topBar.guide.description') }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</ElPopover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 主题切换按钮 -->
|
<!-- 主题切换按钮 -->
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
v-if="shouldShowThemeToggle"
|
v-if="shouldShowThemeToggle"
|
||||||
@click="themeAnimation"
|
@click="themeAnimation"
|
||||||
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
|
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 用户头像、菜单 -->
|
<!-- 用户头像、菜单 -->
|
||||||
<ArtUserMenu />
|
<ArtUserMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签页 -->
|
<!-- 标签页 -->
|
||||||
<ArtWorkTab />
|
<ArtWorkTab />
|
||||||
|
|
||||||
<!-- 通知 -->
|
<!-- 通知 -->
|
||||||
<ArtNotification v-model:value="showNotice" ref="notice" />
|
<ArtNotification v-model:value="showNotice" ref="notice" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useFullscreen, useWindowSize } from '@vueuse/core'
|
import { useFullscreen, useWindowSize } from '@vueuse/core'
|
||||||
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
|
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useMenuStore } from '@/store/modules/menu'
|
import { useMenuStore } from '@/store/modules/menu'
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import { languageOptions } from '@/locales'
|
import { languageOptions } from '@/locales'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
import { themeAnimation } from '@/utils/ui/animation'
|
import { themeAnimation } from '@/utils/ui/animation'
|
||||||
import { useCommon } from '@/hooks/core/useCommon'
|
import { useCommon } from '@/hooks/core/useCommon'
|
||||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||||
import ArtUserMenu from './widget/ArtUserMenu.vue'
|
import ArtUserMenu from './widget/ArtUserMenu.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtHeaderBar' })
|
defineOptions({ name: 'ArtHeaderBar' })
|
||||||
|
|
||||||
// 检测操作系统类型
|
// 检测操作系统类型
|
||||||
const isWindows = navigator.userAgent.includes('Windows')
|
const isWindows = navigator.userAgent.includes('Windows')
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
|
|
||||||
// 顶部栏功能配置
|
// 顶部栏功能配置
|
||||||
const {
|
const {
|
||||||
shouldShowMenuButton,
|
shouldShowMenuButton,
|
||||||
shouldShowRefreshButton,
|
shouldShowRefreshButton,
|
||||||
shouldShowFastEnter,
|
shouldShowFastEnter,
|
||||||
shouldShowBreadcrumb,
|
shouldShowBreadcrumb,
|
||||||
shouldShowGlobalSearch,
|
shouldShowGlobalSearch,
|
||||||
shouldShowFullscreen,
|
shouldShowFullscreen,
|
||||||
shouldShowNotification,
|
shouldShowNotification,
|
||||||
shouldShowChat,
|
shouldShowChat,
|
||||||
shouldShowLanguage,
|
shouldShowLanguage,
|
||||||
shouldShowSettings,
|
shouldShowSettings,
|
||||||
shouldShowThemeToggle,
|
shouldShowThemeToggle,
|
||||||
fastEnterMinWidth: headerBarFastEnterMinWidth
|
fastEnterMinWidth: headerBarFastEnterMinWidth
|
||||||
} = useHeaderBar()
|
} = useHeaderBar()
|
||||||
|
|
||||||
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
|
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
|
||||||
storeToRefs(settingStore)
|
storeToRefs(settingStore)
|
||||||
|
|
||||||
const { language } = storeToRefs(userStore)
|
const { language } = storeToRefs(userStore)
|
||||||
const { menuList } = storeToRefs(menuStore)
|
const { menuList } = storeToRefs(menuStore)
|
||||||
|
|
||||||
const showNotice = ref(false)
|
const showNotice = ref(false)
|
||||||
const notice = ref(null)
|
const notice = ref(null)
|
||||||
|
|
||||||
// 菜单类型判断
|
// 菜单类型判断
|
||||||
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
|
||||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||||
|
|
||||||
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initLanguage()
|
initLanguage()
|
||||||
document.addEventListener('click', bodyCloseNotice)
|
document.addEventListener('click', bodyCloseNotice)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('click', bodyCloseNotice)
|
document.removeEventListener('click', bodyCloseNotice)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换全屏状态
|
* 切换全屏状态
|
||||||
*/
|
*/
|
||||||
const toggleFullScreen = (): void => {
|
const toggleFullScreen = (): void => {
|
||||||
toggleFullscreen()
|
toggleFullscreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换菜单显示/隐藏状态
|
* 切换菜单显示/隐藏状态
|
||||||
*/
|
*/
|
||||||
const visibleMenu = (): void => {
|
const visibleMenu = (): void => {
|
||||||
settingStore.setMenuOpen(!menuOpen.value)
|
settingStore.setMenuOpen(!menuOpen.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { homePath } = useCommon()
|
const { homePath } = useCommon()
|
||||||
const { refresh } = useCommon()
|
const { refresh } = useCommon()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到首页
|
* 跳转到首页
|
||||||
*/
|
*/
|
||||||
const toHome = (): void => {
|
const toHome = (): void => {
|
||||||
router.push(homePath.value)
|
router.push(homePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新页面
|
* 刷新页面
|
||||||
* @param {number} time - 延迟时间,默认为0毫秒
|
* @param {number} time - 延迟时间,默认为0毫秒
|
||||||
*/
|
*/
|
||||||
const reload = (time: number = 0): void => {
|
const reload = (time: number = 0): void => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refresh()
|
refresh()
|
||||||
}, time)
|
}, time)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化语言设置
|
* 初始化语言设置
|
||||||
*/
|
*/
|
||||||
const initLanguage = (): void => {
|
const initLanguage = (): void => {
|
||||||
locale.value = language.value
|
locale.value = language.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换系统语言
|
* 切换系统语言
|
||||||
* @param {LanguageEnum} lang - 目标语言类型
|
* @param {LanguageEnum} lang - 目标语言类型
|
||||||
*/
|
*/
|
||||||
const changeLanguage = (lang: LanguageEnum): void => {
|
const changeLanguage = (lang: LanguageEnum): void => {
|
||||||
if (locale.value === lang) return
|
if (locale.value === lang) return
|
||||||
locale.value = lang
|
locale.value = lang
|
||||||
userStore.setLanguage(lang)
|
userStore.setLanguage(lang)
|
||||||
reload(50)
|
reload(50)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开设置面板
|
* 打开设置面板
|
||||||
*/
|
*/
|
||||||
const openSetting = (): void => {
|
const openSetting = (): void => {
|
||||||
mittBus.emit('openSetting')
|
mittBus.emit('openSetting')
|
||||||
|
|
||||||
// 隐藏设置引导提示
|
// 隐藏设置引导提示
|
||||||
if (showSettingGuide.value) {
|
if (showSettingGuide.value) {
|
||||||
settingStore.hideSettingGuide()
|
settingStore.hideSettingGuide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开全局搜索对话框
|
* 打开全局搜索对话框
|
||||||
*/
|
*/
|
||||||
const openSearchDialog = (): void => {
|
const openSearchDialog = (): void => {
|
||||||
mittBus.emit('openSearchDialog')
|
mittBus.emit('openSearchDialog')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 点击页面其他区域关闭通知面板
|
* 点击页面其他区域关闭通知面板
|
||||||
* @param {Event} e - 点击事件对象
|
* @param {Event} e - 点击事件对象
|
||||||
*/
|
*/
|
||||||
const bodyCloseNotice = (e: any): void => {
|
const bodyCloseNotice = (e: any): void => {
|
||||||
if (!showNotice.value) return
|
if (!showNotice.value) return
|
||||||
|
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
|
|
||||||
// 检查是否点击了通知按钮或通知面板内部
|
// 检查是否点击了通知按钮或通知面板内部
|
||||||
const isNoticeButton = target.closest('.notice-button')
|
const isNoticeButton = target.closest('.notice-button')
|
||||||
const isNoticePanel = target.closest('.art-notification-panel')
|
const isNoticePanel = target.closest('.art-notification-panel')
|
||||||
|
|
||||||
if (!isNoticeButton && !isNoticePanel) {
|
if (!isNoticeButton && !isNoticePanel) {
|
||||||
showNotice.value = false
|
showNotice.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换通知面板显示状态
|
* 切换通知面板显示状态
|
||||||
*/
|
*/
|
||||||
const visibleNotice = (): void => {
|
const visibleNotice = (): void => {
|
||||||
showNotice.value = !showNotice.value
|
showNotice.value = !showNotice.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开聊天窗口
|
* 打开聊天窗口
|
||||||
*/
|
*/
|
||||||
const openChat = (): void => {
|
const openChat = (): void => {
|
||||||
mittBus.emit('openChat')
|
mittBus.emit('openChat')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
/* Custom animations */
|
/* Custom animations */
|
||||||
@keyframes rotate180 {
|
@keyframes rotate180 {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0);
|
transform: rotate(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0% {
|
0% {
|
||||||
transform: rotate(0);
|
transform: rotate(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
25% {
|
25% {
|
||||||
transform: rotate(-5deg);
|
transform: rotate(-5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: rotate(5deg);
|
transform: rotate(5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
75% {
|
75% {
|
||||||
transform: rotate(-5deg);
|
transform: rotate(-5deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(0);
|
transform: rotate(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes expand {
|
@keyframes expand {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shrink {
|
@keyframes shrink {
|
||||||
0% {
|
0% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes moveUp {
|
@keyframes moveUp {
|
||||||
0% {
|
0% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
transform: translateY(-3px);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes breathing {
|
@keyframes breathing {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
50% {
|
50% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
100% {
|
100% {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover animation classes */
|
/* Hover animation classes */
|
||||||
.refresh-btn:hover :deep(.art-svg-icon) {
|
.refresh-btn:hover :deep(.art-svg-icon) {
|
||||||
animation: rotate180 0.5s;
|
animation: rotate180 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.language-btn:hover :deep(.art-svg-icon) {
|
.language-btn:hover :deep(.art-svg-icon) {
|
||||||
animation: moveUp 0.4s;
|
animation: moveUp 0.4s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.setting-btn:hover :deep(.art-svg-icon) {
|
.setting-btn:hover :deep(.art-svg-icon) {
|
||||||
animation: rotate180 0.5s;
|
animation: rotate180 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.full-screen-btn:hover :deep(.art-svg-icon) {
|
.full-screen-btn:hover :deep(.art-svg-icon) {
|
||||||
animation: expand 0.6s forwards;
|
animation: expand 0.6s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
|
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
|
||||||
animation: shrink 0.6s forwards;
|
animation: shrink 0.6s forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-button:hover :deep(.art-svg-icon) {
|
.notice-button:hover :deep(.art-svg-icon) {
|
||||||
animation: shake 0.5s ease-in-out;
|
animation: shake 0.5s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-button:hover :deep(.art-svg-icon) {
|
.chat-button:hover :deep(.art-svg-icon) {
|
||||||
animation: shake 0.5s ease-in-out;
|
animation: shake 0.5s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Breathing animation for chat dot */
|
/* Breathing animation for chat dot */
|
||||||
.breathing-dot {
|
.breathing-dot {
|
||||||
animation: breathing 1.5s ease-in-out infinite;
|
animation: breathing 1.5s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* iPad breakpoint adjustments */
|
/* iPad breakpoint adjustments */
|
||||||
@media screen and (width <= 768px) {
|
@media screen and (width <= 768px) {
|
||||||
.logo2 {
|
.logo2 {
|
||||||
display: block !important;
|
display: block !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (width <= 640px) {
|
@media screen and (width <= 640px) {
|
||||||
.btn-box {
|
.btn-box {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,159 +1,161 @@
|
|||||||
<!-- 用户菜单 -->
|
<!-- 用户菜单 -->
|
||||||
<template>
|
<template>
|
||||||
<ElPopover
|
<ElPopover
|
||||||
ref="userMenuPopover"
|
ref="userMenuPopover"
|
||||||
placement="bottom-end"
|
placement="bottom-end"
|
||||||
:width="240"
|
:width="240"
|
||||||
:hide-after="0"
|
:hide-after="0"
|
||||||
:offset="10"
|
:offset="10"
|
||||||
trigger="hover"
|
trigger="hover"
|
||||||
:show-arrow="false"
|
:show-arrow="false"
|
||||||
popper-class="user-menu-popover"
|
popper-class="user-menu-popover"
|
||||||
popper-style="padding: 5px 16px;"
|
popper-style="padding: 5px 16px;"
|
||||||
>
|
>
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<img
|
<img
|
||||||
class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]"
|
class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]"
|
||||||
src="@imgs/user/avatar.webp"
|
src="@imgs/user/avatar.webp"
|
||||||
alt="avatar"
|
alt="avatar"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<template #default>
|
||||||
<div class="pt-3">
|
<div class="pt-3">
|
||||||
<div class="flex-c pb-1 px-0">
|
<div class="flex-c pb-1 px-0">
|
||||||
<img
|
<img
|
||||||
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
|
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
|
||||||
src="@imgs/user/avatar.webp"
|
src="@imgs/user/avatar.webp"
|
||||||
/>
|
/>
|
||||||
<div class="w-[calc(100%-60px)] h-full">
|
<div class="w-[calc(100%-60px)] h-full">
|
||||||
<span class="block text-sm font-medium text-g-800 truncate">{{
|
<span class="block text-sm font-medium text-g-800 truncate">{{
|
||||||
userInfo.userName
|
userInfo.userName
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="block mt-0.5 text-xs text-g-500 truncate">{{ userInfo.email }}</span>
|
<span class="block mt-0.5 text-xs text-g-500 truncate">{{
|
||||||
</div>
|
userInfo.email
|
||||||
</div>
|
}}</span>
|
||||||
<ul class="py-4 mt-3 border-t border-g-300/80">
|
</div>
|
||||||
<li class="btn-item" @click="goPage('/system/user-center')">
|
</div>
|
||||||
<ArtSvgIcon icon="ri:user-3-line" />
|
<ul class="py-4 mt-3 border-t border-g-300/80">
|
||||||
<span>{{ $t('topBar.user.userCenter') }}</span>
|
<li class="btn-item" @click="goPage('/system/user-center')">
|
||||||
</li>
|
<ArtSvgIcon icon="ri:user-3-line" />
|
||||||
<li class="btn-item" @click="toDocs()">
|
<span>{{ $t('topBar.user.userCenter') }}</span>
|
||||||
<ArtSvgIcon icon="ri:book-2-line" />
|
</li>
|
||||||
<span>{{ $t('topBar.user.docs') }}</span>
|
<li class="btn-item" @click="toDocs()">
|
||||||
</li>
|
<ArtSvgIcon icon="ri:book-2-line" />
|
||||||
<li class="btn-item" @click="toGithub()">
|
<span>{{ $t('topBar.user.docs') }}</span>
|
||||||
<ArtSvgIcon icon="ri:github-line" />
|
</li>
|
||||||
<span>{{ $t('topBar.user.github') }}</span>
|
<li class="btn-item" @click="toGithub()">
|
||||||
</li>
|
<ArtSvgIcon icon="ri:github-line" />
|
||||||
<li class="btn-item" @click="lockScreen()">
|
<span>{{ $t('topBar.user.github') }}</span>
|
||||||
<ArtSvgIcon icon="ri:lock-line" />
|
</li>
|
||||||
<span>{{ $t('topBar.user.lockScreen') }}</span>
|
<li class="btn-item" @click="lockScreen()">
|
||||||
</li>
|
<ArtSvgIcon icon="ri:lock-line" />
|
||||||
<div class="w-full h-px my-2 bg-g-300/80"></div>
|
<span>{{ $t('topBar.user.lockScreen') }}</span>
|
||||||
<div class="log-out c-p" @click="loginOut">
|
</li>
|
||||||
{{ $t('topBar.user.logout') }}
|
<div class="w-full h-px my-2 bg-g-300/80"></div>
|
||||||
</div>
|
<div class="log-out c-p" @click="loginOut">
|
||||||
</ul>
|
{{ $t('topBar.user.logout') }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</ul>
|
||||||
</ElPopover>
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElPopover>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
import { ElMessageBox } from 'element-plus'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { WEB_LINKS } from '@/utils/constants'
|
import { WEB_LINKS } from '@/utils/constants'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtUserMenu' })
|
defineOptions({ name: 'ArtUserMenu' })
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const { getUserInfo: userInfo } = storeToRefs(userStore)
|
const { getUserInfo: userInfo } = storeToRefs(userStore)
|
||||||
const userMenuPopover = ref()
|
const userMenuPopover = ref()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 页面跳转
|
* 页面跳转
|
||||||
* @param {string} path - 目标路径
|
* @param {string} path - 目标路径
|
||||||
*/
|
*/
|
||||||
const goPage = (path: string): void => {
|
const goPage = (path: string): void => {
|
||||||
router.push(path)
|
router.push(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开文档页面
|
* 打开文档页面
|
||||||
*/
|
*/
|
||||||
const toDocs = (): void => {
|
const toDocs = (): void => {
|
||||||
window.open(WEB_LINKS.DOCS)
|
window.open(WEB_LINKS.DOCS)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开 GitHub 页面
|
* 打开 GitHub 页面
|
||||||
*/
|
*/
|
||||||
const toGithub = (): void => {
|
const toGithub = (): void => {
|
||||||
window.open(WEB_LINKS.GITHUB)
|
window.open(WEB_LINKS.GITHUB)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打开锁屏功能
|
* 打开锁屏功能
|
||||||
*/
|
*/
|
||||||
const lockScreen = (): void => {
|
const lockScreen = (): void => {
|
||||||
mittBus.emit('openLockScreen')
|
mittBus.emit('openLockScreen')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户登出确认
|
* 用户登出确认
|
||||||
*/
|
*/
|
||||||
const loginOut = (): void => {
|
const loginOut = (): void => {
|
||||||
closeUserMenu()
|
closeUserMenu()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
|
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
|
||||||
confirmButtonText: t('common.confirm'),
|
confirmButtonText: t('common.confirm'),
|
||||||
cancelButtonText: t('common.cancel'),
|
cancelButtonText: t('common.cancel'),
|
||||||
customClass: 'login-out-dialog'
|
customClass: 'login-out-dialog'
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
userStore.logOut()
|
userStore.logOut()
|
||||||
})
|
})
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭用户菜单弹出层
|
* 关闭用户菜单弹出层
|
||||||
*/
|
*/
|
||||||
const closeUserMenu = (): void => {
|
const closeUserMenu = (): void => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
userMenuPopover.value.hide()
|
userMenuPopover.value.hide()
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference '@styles/core/tailwind.css';
|
@reference '@styles/core/tailwind.css';
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.btn-item {
|
.btn-item {
|
||||||
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
|
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
@apply text-sm;
|
@apply text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
@apply mr-2 text-base;
|
@apply mr-2 text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-out {
|
.log-out {
|
||||||
@apply py-1.5
|
@apply py-1.5
|
||||||
mt-5
|
mt-5
|
||||||
text-xs
|
text-xs
|
||||||
text-center
|
text-center
|
||||||
@@ -163,5 +165,5 @@
|
|||||||
transition-all
|
transition-all
|
||||||
duration-200
|
duration-200
|
||||||
hover:shadow-xl;
|
hover:shadow-xl;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,110 +1,110 @@
|
|||||||
<!-- 水平菜单 -->
|
<!-- 水平菜单 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-1 overflow-hidden">
|
<div class="flex-1 overflow-hidden">
|
||||||
<ElMenu
|
<ElMenu
|
||||||
:ellipsis="true"
|
:ellipsis="true"
|
||||||
mode="horizontal"
|
mode="horizontal"
|
||||||
:default-active="routerPath"
|
:default-active="routerPath"
|
||||||
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
|
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
|
||||||
:popper-offset="-6"
|
:popper-offset="-6"
|
||||||
background-color="transparent"
|
background-color="transparent"
|
||||||
:show-timeout="50"
|
:show-timeout="50"
|
||||||
:hide-timeout="50"
|
:hide-timeout="50"
|
||||||
popper-class="horizontal-menu-popper"
|
popper-class="horizontal-menu-popper"
|
||||||
class="w-full border-none"
|
class="w-full border-none"
|
||||||
>
|
>
|
||||||
<HorizontalSubmenu
|
<HorizontalSubmenu
|
||||||
v-for="item in filteredMenuItems"
|
v-for="item in filteredMenuItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:item="item"
|
:item="item"
|
||||||
:isMobile="false"
|
:isMobile="false"
|
||||||
:level="0"
|
:level="0"
|
||||||
/>
|
/>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AppRouteRecord } from '@/types/router'
|
import type { AppRouteRecord } from '@/types/router'
|
||||||
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
|
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtHorizontalMenu' })
|
defineOptions({ name: 'ArtHorizontalMenu' })
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { isDark } = storeToRefs(settingStore)
|
const { isDark } = storeToRefs(settingStore)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 菜单列表数据 */
|
/** 菜单列表数据 */
|
||||||
list: AppRouteRecord[]
|
list: AppRouteRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
list: () => []
|
list: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 过滤后的菜单项列表
|
* 过滤后的菜单项列表
|
||||||
* 只显示未隐藏的菜单项
|
* 只显示未隐藏的菜单项
|
||||||
*/
|
*/
|
||||||
const filteredMenuItems = computed(() => {
|
const filteredMenuItems = computed(() => {
|
||||||
return filterMenuItems(props.list)
|
return filterMenuItems(props.list)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 当前激活的路由路径
|
* 当前激活的路由路径
|
||||||
* 用于菜单高亮显示
|
* 用于菜单高亮显示
|
||||||
*/
|
*/
|
||||||
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 递归过滤菜单项,移除隐藏的菜单
|
* 递归过滤菜单项,移除隐藏的菜单
|
||||||
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
||||||
* @param items 菜单项数组
|
* @param items 菜单项数组
|
||||||
* @returns 过滤后的菜单项数组
|
* @returns 过滤后的菜单项数组
|
||||||
*/
|
*/
|
||||||
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||||
return items
|
return items
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
// 如果当前项被隐藏,直接过滤掉
|
// 如果当前项被隐藏,直接过滤掉
|
||||||
if (item.meta.isHide) {
|
if (item.meta.isHide) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有子菜单,递归过滤子菜单
|
// 如果有子菜单,递归过滤子菜单
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
const filteredChildren = filterMenuItems(item.children)
|
const filteredChildren = filterMenuItems(item.children)
|
||||||
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
||||||
return filteredChildren.length > 0
|
return filteredChildren.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 叶子节点且未被隐藏,保留
|
// 叶子节点且未被隐藏,保留
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
children: item.children ? filterMenuItems(item.children) : undefined
|
children: item.children ? filterMenuItems(item.children) : undefined
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Remove el-menu bottom border */
|
/* Remove el-menu bottom border */
|
||||||
:deep(.el-menu) {
|
:deep(.el-menu) {
|
||||||
border-bottom: none !important;
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove default styles for first-level menu items */
|
/* Remove default styles for first-level menu items */
|
||||||
:deep(.el-menu-item[tabindex='0']) {
|
:deep(.el-menu-item[tabindex='0']) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
border: none !important;
|
border: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove bottom border from submenu titles */
|
/* Remove bottom border from submenu titles */
|
||||||
:deep(.el-menu--horizontal .el-sub-menu__title) {
|
:deep(.el-menu--horizontal .el-sub-menu__title) {
|
||||||
padding: 0 30px 0 10px !important;
|
padding: 0 30px 0 10px !important;
|
||||||
border: 0 !important;
|
border: 0 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+79
-79
@@ -1,95 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
|
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
|
||||||
<template #title>
|
<template #title>
|
||||||
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
|
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
|
||||||
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||||
<div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" />
|
<div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" />
|
||||||
<div v-if="item.meta.showTextBadge" class="art-text-badge">
|
<div v-if="item.meta.showTextBadge" class="art-text-badge">
|
||||||
{{ item.meta.showTextBadge }}
|
{{ item.meta.showTextBadge }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 递归调用自身处理子菜单 -->
|
<!-- 递归调用自身处理子菜单 -->
|
||||||
<HorizontalSubmenu
|
<HorizontalSubmenu
|
||||||
v-for="child in filteredChildren"
|
v-for="child in filteredChildren"
|
||||||
:key="child.path"
|
:key="child.path"
|
||||||
:item="child"
|
:item="child"
|
||||||
:theme="theme"
|
:theme="theme"
|
||||||
:is-mobile="isMobile"
|
:is-mobile="isMobile"
|
||||||
:level="level + 1"
|
:level="level + 1"
|
||||||
@close="closeMenu"
|
@close="closeMenu"
|
||||||
/>
|
/>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
|
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
v-else-if="!item.meta.isHide"
|
v-else-if="!item.meta.isHide"
|
||||||
:index="item.path || item.meta.title"
|
:index="item.path || item.meta.title"
|
||||||
@click="goPage(item)"
|
@click="goPage(item)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
:icon="item.meta.icon"
|
:icon="item.meta.icon"
|
||||||
:color="theme?.iconColor"
|
:color="theme?.iconColor"
|
||||||
class="mr-1 text-lg"
|
class="mr-1 text-lg"
|
||||||
:style="{ color: theme.iconColor }"
|
:style="{ color: theme.iconColor }"
|
||||||
/>
|
/>
|
||||||
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
|
||||||
<div
|
<div
|
||||||
v-if="item.meta.showBadge"
|
v-if="item.meta.showBadge"
|
||||||
class="art-badge"
|
class="art-badge"
|
||||||
:style="{ right: level === 0 ? '10px' : '20px' }"
|
:style="{ right: level === 0 ? '10px' : '20px' }"
|
||||||
/>
|
/>
|
||||||
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
|
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
|
||||||
{{ item.meta.showTextBadge }}
|
{{ item.meta.showTextBadge }}
|
||||||
</div>
|
</div>
|
||||||
</ElMenuItem>
|
</ElMenuItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, type PropType } from 'vue'
|
import { computed, type PropType } from 'vue'
|
||||||
import { AppRouteRecord } from '@/types/router'
|
import { AppRouteRecord } from '@/types/router'
|
||||||
import { handleMenuJump } from '@/utils/navigation'
|
import { handleMenuJump } from '@/utils/navigation'
|
||||||
import { formatMenuTitle } from '@/utils/router'
|
import { formatMenuTitle } from '@/utils/router'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
item: {
|
item: {
|
||||||
type: Object as PropType<AppRouteRecord>,
|
type: Object as PropType<AppRouteRecord>,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
},
|
},
|
||||||
isMobile: Boolean,
|
isMobile: Boolean,
|
||||||
level: {
|
level: {
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0
|
default: 0
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close'])
|
const emit = defineEmits(['close'])
|
||||||
|
|
||||||
// 过滤后的子菜单项(不包含隐藏的)
|
// 过滤后的子菜单项(不包含隐藏的)
|
||||||
const filteredChildren = computed(() => {
|
const filteredChildren = computed(() => {
|
||||||
return props.item.children?.filter((child) => !child.meta.isHide) || []
|
return props.item.children?.filter((child) => !child.meta.isHide) || []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 计算当前项是否有可见的子菜单
|
// 计算当前项是否有可见的子菜单
|
||||||
const hasChildren = computed(() => {
|
const hasChildren = computed(() => {
|
||||||
return filteredChildren.value.length > 0
|
return filteredChildren.value.length > 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const goPage = (item: AppRouteRecord) => {
|
const goPage = (item: AppRouteRecord) => {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
handleMenuJump(item)
|
handleMenuJump(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeMenu = () => {
|
const closeMenu = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
|
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
|
||||||
right: 10px !important;
|
right: 10px !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,234 +1,237 @@
|
|||||||
<!-- 混合菜单 -->
|
<!-- 混合菜单 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="relative box-border flex-c w-full overflow-hidden">
|
<div class="relative box-border flex-c w-full overflow-hidden">
|
||||||
<!-- 左侧滚动按钮 -->
|
<!-- 左侧滚动按钮 -->
|
||||||
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
|
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
|
||||||
<ElIcon>
|
<ElIcon>
|
||||||
<ArrowLeft />
|
<ArrowLeft />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 滚动容器 -->
|
<!-- 滚动容器 -->
|
||||||
<ElScrollbar
|
<ElScrollbar
|
||||||
ref="scrollbarRef"
|
ref="scrollbarRef"
|
||||||
wrap-class="scrollbar-wrapper"
|
wrap-class="scrollbar-wrapper"
|
||||||
:horizontal="true"
|
:horizontal="true"
|
||||||
@scroll="handleScroll"
|
@scroll="handleScroll"
|
||||||
@wheel="handleWheel"
|
@wheel="handleWheel"
|
||||||
>
|
>
|
||||||
<div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap">
|
<div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap">
|
||||||
<template v-for="item in processedMenuList" :key="item.meta.title">
|
<template v-for="item in processedMenuList" :key="item.meta.title">
|
||||||
<div
|
<div
|
||||||
v-if="!item.meta.isHide"
|
v-if="!item.meta.isHide"
|
||||||
class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
|
class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
|
||||||
:class="{
|
:class="{
|
||||||
'menu-item-active text-theme': item.isActive
|
'menu-item-active text-theme': item.isActive
|
||||||
}"
|
}"
|
||||||
@click="handleMenuJump(item, true)"
|
@click="handleMenuJump(item, true)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
:icon="item.meta.icon"
|
:icon="item.meta.icon"
|
||||||
class="text-lg text-g-700 dark:text-g-800 mr-1"
|
class="text-lg text-g-700 dark:text-g-800 mr-1"
|
||||||
:class="item.isActive && '!text-theme'"
|
:class="item.isActive && '!text-theme'"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="text-md text-g-700 dark:text-g-800"
|
class="text-md text-g-700 dark:text-g-800"
|
||||||
:class="item.isActive && '!text-theme'"
|
:class="item.isActive && '!text-theme'"
|
||||||
>
|
>
|
||||||
{{ item.formattedTitle }}
|
{{ item.formattedTitle }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
|
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ElScrollbar>
|
</ElScrollbar>
|
||||||
|
|
||||||
<!-- 右侧滚动按钮 -->
|
<!-- 右侧滚动按钮 -->
|
||||||
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
|
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
|
||||||
<ElIcon>
|
<ElIcon>
|
||||||
<ArrowRight />
|
<ArrowRight />
|
||||||
</ElIcon>
|
</ElIcon>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||||
import { useThrottleFn } from '@vueuse/core'
|
import { useThrottleFn } from '@vueuse/core'
|
||||||
import { formatMenuTitle } from '@/utils/router'
|
import { formatMenuTitle } from '@/utils/router'
|
||||||
import { handleMenuJump } from '@/utils/navigation'
|
import { handleMenuJump } from '@/utils/navigation'
|
||||||
import type { AppRouteRecord } from '@/types/router'
|
import type { AppRouteRecord } from '@/types/router'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtMixedMenu' })
|
defineOptions({ name: 'ArtMixedMenu' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 菜单列表数据 */
|
/** 菜单列表数据 */
|
||||||
list: AppRouteRecord[]
|
list: AppRouteRecord[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessedMenuItem extends AppRouteRecord {
|
interface ProcessedMenuItem extends AppRouteRecord {
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
formattedTitle: string
|
formattedTitle: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrollDirection = 'left' | 'right'
|
type ScrollDirection = 'left' | 'right'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
list: () => []
|
list: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const scrollbarRef = ref<any>()
|
const scrollbarRef = ref<any>()
|
||||||
const showLeftArrow = ref(false)
|
const showLeftArrow = ref(false)
|
||||||
const showRightArrow = ref(false)
|
const showRightArrow = ref(false)
|
||||||
|
|
||||||
/** 滚动配置 */
|
/** 滚动配置 */
|
||||||
const SCROLL_CONFIG = {
|
const SCROLL_CONFIG = {
|
||||||
/** 点击按钮时的滚动距离 */
|
/** 点击按钮时的滚动距离 */
|
||||||
BUTTON_SCROLL_DISTANCE: 200,
|
BUTTON_SCROLL_DISTANCE: 200,
|
||||||
/** 鼠标滚轮快速滚动时的步长 */
|
/** 鼠标滚轮快速滚动时的步长 */
|
||||||
WHEEL_FAST_STEP: 35,
|
WHEEL_FAST_STEP: 35,
|
||||||
/** 鼠标滚轮慢速滚动时的步长 */
|
/** 鼠标滚轮慢速滚动时的步长 */
|
||||||
WHEEL_SLOW_STEP: 30,
|
WHEEL_SLOW_STEP: 30,
|
||||||
/** 区分快慢滚动的阈值 */
|
/** 区分快慢滚动的阈值 */
|
||||||
WHEEL_FAST_THRESHOLD: 100
|
WHEEL_FAST_THRESHOLD: 100
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前激活路径
|
* 获取当前激活路径
|
||||||
* 使用computed缓存,避免重复计算
|
* 使用computed缓存,避免重复计算
|
||||||
*/
|
*/
|
||||||
const currentActivePath = computed(() => {
|
const currentActivePath = computed(() => {
|
||||||
return String(route.meta.activePath || route.path)
|
return String(route.meta.activePath || route.path)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断菜单项是否为激活状态
|
* 判断菜单项是否为激活状态
|
||||||
* 递归检查子菜单中是否包含当前路径
|
* 递归检查子菜单中是否包含当前路径
|
||||||
* @param item 菜单项数据
|
* @param item 菜单项数据
|
||||||
* @returns 是否为激活状态
|
* @returns 是否为激活状态
|
||||||
*/
|
*/
|
||||||
const isMenuItemActive = (item: AppRouteRecord): boolean => {
|
const isMenuItemActive = (item: AppRouteRecord): boolean => {
|
||||||
const activePath = currentActivePath.value
|
const activePath = currentActivePath.value
|
||||||
|
|
||||||
// 如果有子菜单,递归检查子菜单
|
// 如果有子菜单,递归检查子菜单
|
||||||
if (item.children?.length) {
|
if (item.children?.length) {
|
||||||
return item.children.some((child) => {
|
return item.children.some((child) => {
|
||||||
if (child.children?.length) {
|
if (child.children?.length) {
|
||||||
return isMenuItemActive(child)
|
return isMenuItemActive(child)
|
||||||
}
|
}
|
||||||
return child.path === activePath
|
return child.path === activePath
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接比较路径
|
// 直接比较路径
|
||||||
return item.path === activePath
|
return item.path === activePath
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预处理菜单列表
|
* 预处理菜单列表
|
||||||
* 缓存每个菜单项的激活状态和格式化标题
|
* 缓存每个菜单项的激活状态和格式化标题
|
||||||
*/
|
*/
|
||||||
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
|
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
|
||||||
return props.list.map((item) => ({
|
return props.list.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
isActive: isMenuItemActive(item),
|
isActive: isMenuItemActive(item),
|
||||||
formattedTitle: formatMenuTitle(item.meta.title)
|
formattedTitle: formatMenuTitle(item.meta.title)
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理滚动事件的核心逻辑
|
* 处理滚动事件的核心逻辑
|
||||||
* 根据滚动位置显示/隐藏滚动按钮
|
* 根据滚动位置显示/隐藏滚动按钮
|
||||||
*/
|
*/
|
||||||
const handleScrollCore = (): void => {
|
const handleScrollCore = (): void => {
|
||||||
if (!scrollbarRef.value?.wrapRef) return
|
if (!scrollbarRef.value?.wrapRef) return
|
||||||
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef
|
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef
|
||||||
|
|
||||||
// 判断是否显示左侧滚动按钮
|
// 判断是否显示左侧滚动按钮
|
||||||
showLeftArrow.value = scrollLeft > 0
|
showLeftArrow.value = scrollLeft > 0
|
||||||
|
|
||||||
// 判断是否显示右侧滚动按钮
|
// 判断是否显示右侧滚动按钮
|
||||||
showRightArrow.value = scrollLeft + clientWidth < scrollWidth
|
showRightArrow.value = scrollLeft + clientWidth < scrollWidth
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节流后的滚动事件处理函数
|
* 节流后的滚动事件处理函数
|
||||||
* 调整节流间隔为16ms,约等于60fps
|
* 调整节流间隔为16ms,约等于60fps
|
||||||
*/
|
*/
|
||||||
const handleScroll = useThrottleFn(handleScrollCore, 16)
|
const handleScroll = useThrottleFn(handleScrollCore, 16)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 滚动菜单容器
|
* 滚动菜单容器
|
||||||
* @param direction 滚动方向,left 或 right
|
* @param direction 滚动方向,left 或 right
|
||||||
*/
|
*/
|
||||||
const scroll = (direction: ScrollDirection): void => {
|
const scroll = (direction: ScrollDirection): void => {
|
||||||
if (!scrollbarRef.value?.wrapRef) return
|
if (!scrollbarRef.value?.wrapRef) return
|
||||||
|
|
||||||
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
|
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
|
||||||
const targetScroll =
|
const targetScroll =
|
||||||
direction === 'left'
|
direction === 'left'
|
||||||
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
||||||
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
|
||||||
|
|
||||||
// 平滑滚动到目标位置
|
// 平滑滚动到目标位置
|
||||||
scrollbarRef.value.wrapRef.scrollTo({
|
scrollbarRef.value.wrapRef.scrollTo({
|
||||||
left: targetScroll,
|
left: targetScroll,
|
||||||
behavior: 'smooth'
|
behavior: 'smooth'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理鼠标滚轮事件
|
* 处理鼠标滚轮事件
|
||||||
* 优化滚轮响应性能
|
* 优化滚轮响应性能
|
||||||
* @param event 滚轮事件
|
* @param event 滚轮事件
|
||||||
*/
|
*/
|
||||||
const handleWheel = (event: WheelEvent): void => {
|
const handleWheel = (event: WheelEvent): void => {
|
||||||
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
|
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
|
||||||
// 直接处理滚动,提升响应性
|
// 直接处理滚动,提升响应性
|
||||||
if (!scrollbarRef.value?.wrapRef) return
|
if (!scrollbarRef.value?.wrapRef) return
|
||||||
|
|
||||||
const { wrapRef } = scrollbarRef.value
|
const { wrapRef } = scrollbarRef.value
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = wrapRef
|
const { scrollLeft, scrollWidth, clientWidth } = wrapRef
|
||||||
|
|
||||||
// 使用更小的滚动步长,让滚动更平滑
|
// 使用更小的滚动步长,让滚动更平滑
|
||||||
const scrollStep =
|
const scrollStep =
|
||||||
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
|
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
|
||||||
? SCROLL_CONFIG.WHEEL_FAST_STEP
|
? SCROLL_CONFIG.WHEEL_FAST_STEP
|
||||||
: SCROLL_CONFIG.WHEEL_SLOW_STEP
|
: SCROLL_CONFIG.WHEEL_SLOW_STEP
|
||||||
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
|
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
|
||||||
const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth))
|
const targetScroll = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth)
|
||||||
|
)
|
||||||
|
|
||||||
// 立即滚动,无动画
|
// 立即滚动,无动画
|
||||||
wrapRef.scrollLeft = targetScroll
|
wrapRef.scrollLeft = targetScroll
|
||||||
|
|
||||||
// 更新滚动按钮状态
|
// 更新滚动按钮状态
|
||||||
handleScrollCore()
|
handleScrollCore()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化滚动状态
|
* 初始化滚动状态
|
||||||
*/
|
*/
|
||||||
const initScrollState = (): void => {
|
const initScrollState = (): void => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
handleScrollCore()
|
handleScrollCore()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(initScrollState)
|
onMounted(initScrollState)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference '@styles/core/tailwind.css';
|
@reference '@styles/core/tailwind.css';
|
||||||
|
|
||||||
.button-arrow {
|
.button-arrow {
|
||||||
@apply absolute
|
@apply absolute
|
||||||
top-1/2
|
top-1/2
|
||||||
z-2
|
z-2
|
||||||
flex
|
flex
|
||||||
@@ -243,37 +246,37 @@
|
|||||||
-translate-y-1/2
|
-translate-y-1/2
|
||||||
hover:text-g-900
|
hover:text-g-900
|
||||||
hover:bg-g-200;
|
hover:bg-g-200;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
:deep(.el-scrollbar__bar.is-horizontal) {
|
:deep(.el-scrollbar__bar.is-horizontal) {
|
||||||
bottom: 5px;
|
bottom: 5px;
|
||||||
display: none;
|
display: none;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.scrollbar-wrapper) {
|
:deep(.scrollbar-wrapper) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin: 0 50px 0 30px;
|
margin: 0 50px 0 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item-active::after {
|
.menu-item-active::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
content: '';
|
content: '';
|
||||||
background-color: var(--theme-color);
|
background-color: var(--theme-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 1440px) {
|
@media (width <= 1440px) {
|
||||||
:deep(.scrollbar-wrapper) {
|
:deep(.scrollbar-wrapper) {
|
||||||
margin: 0 45px;
|
margin: 0 45px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,355 +1,362 @@
|
|||||||
<!-- 左侧菜单 或 双列菜单 -->
|
<!-- 左侧菜单 或 双列菜单 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="layout-sidebar"
|
class="layout-sidebar"
|
||||||
v-if="showLeftMenu || isDualMenu"
|
v-if="showLeftMenu || isDualMenu"
|
||||||
:class="{ 'no-border': menuList.length === 0 }"
|
:class="{ 'no-border': menuList.length === 0 }"
|
||||||
>
|
>
|
||||||
<!-- 双列菜单(左侧) -->
|
<!-- 双列菜单(左侧) -->
|
||||||
<div
|
<div
|
||||||
v-if="isDualMenu"
|
v-if="isDualMenu"
|
||||||
class="dual-menu-left"
|
class="dual-menu-left"
|
||||||
:style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }"
|
:style="{
|
||||||
>
|
width: dualMenuShowText ? '80px' : '64px',
|
||||||
<ArtLogo class="logo" @click="navigateToHome" />
|
background: getMenuTheme.background
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ArtLogo class="logo" @click="navigateToHome" />
|
||||||
|
|
||||||
<ElScrollbar style="height: calc(100% - 135px)">
|
<ElScrollbar style="height: calc(100% - 135px)">
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)">
|
<li
|
||||||
<ElTooltip
|
v-for="menu in firstLevelMenus"
|
||||||
class="box-item"
|
:key="menu.path"
|
||||||
effect="dark"
|
@click="handleMenuJump(menu, true)"
|
||||||
:content="$t(menu.meta.title)"
|
>
|
||||||
placement="right"
|
<ElTooltip
|
||||||
:offset="15"
|
class="box-item"
|
||||||
:hide-after="0"
|
effect="dark"
|
||||||
:disabled="dualMenuShowText"
|
:content="$t(menu.meta.title)"
|
||||||
>
|
placement="right"
|
||||||
<div
|
:offset="15"
|
||||||
:class="{
|
:hide-after="0"
|
||||||
'is-active': menu.meta.isFirstLevel
|
:disabled="dualMenuShowText"
|
||||||
? menu.path === route.path
|
>
|
||||||
: menu.path === firstLevelMenuPath
|
<div
|
||||||
}"
|
:class="{
|
||||||
:style="{
|
'is-active': menu.meta.isFirstLevel
|
||||||
height: dualMenuShowText ? '60px' : '46px'
|
? menu.path === route.path
|
||||||
}"
|
: menu.path === firstLevelMenuPath
|
||||||
>
|
}"
|
||||||
<ArtSvgIcon
|
:style="{
|
||||||
class="menu-icon text-g-700 dark:text-g-800"
|
height: dualMenuShowText ? '60px' : '46px'
|
||||||
:icon="menu.meta.icon"
|
}"
|
||||||
:style="{
|
>
|
||||||
marginBottom: dualMenuShowText ? '5px' : '0'
|
<ArtSvgIcon
|
||||||
}"
|
class="menu-icon text-g-700 dark:text-g-800"
|
||||||
/>
|
:icon="menu.meta.icon"
|
||||||
<span v-if="dualMenuShowText" class="text-md text-g-700">
|
:style="{
|
||||||
{{ $t(menu.meta.title) }}
|
marginBottom: dualMenuShowText ? '5px' : '0'
|
||||||
</span>
|
}"
|
||||||
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
|
/>
|
||||||
</div>
|
<span v-if="dualMenuShowText" class="text-md text-g-700">
|
||||||
</ElTooltip>
|
{{ $t(menu.meta.title) }}
|
||||||
</li>
|
</span>
|
||||||
</ul>
|
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
|
||||||
</ElScrollbar>
|
</div>
|
||||||
|
</ElTooltip>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</ElScrollbar>
|
||||||
|
|
||||||
<ArtIconButton
|
<ArtIconButton
|
||||||
class="switch-btn size-10"
|
class="switch-btn size-10"
|
||||||
icon="ri:arrow-left-right-fill"
|
icon="ri:arrow-left-right-fill"
|
||||||
@click="toggleDualMenuMode"
|
@click="toggleDualMenuMode"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 左侧菜单 || 双列菜单(右侧) -->
|
<!-- 左侧菜单 || 双列菜单(右侧) -->
|
||||||
<div
|
<div
|
||||||
v-show="menuList.length > 0"
|
v-show="menuList.length > 0"
|
||||||
class="menu-left"
|
class="menu-left"
|
||||||
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
|
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
|
||||||
:style="{ background: getMenuTheme.background }"
|
:style="{ background: getMenuTheme.background }"
|
||||||
>
|
>
|
||||||
<ElScrollbar :style="scrollbarStyle">
|
<ElScrollbar :style="scrollbarStyle">
|
||||||
<!-- Logo、系统名称 -->
|
<!-- Logo、系统名称 -->
|
||||||
<div
|
<div
|
||||||
class="header"
|
class="header"
|
||||||
@click="navigateToHome"
|
@click="navigateToHome"
|
||||||
:style="{
|
:style="{
|
||||||
background: getMenuTheme.background
|
background: getMenuTheme.background
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ArtLogo v-if="!isDualMenu" class="logo" />
|
<ArtLogo v-if="!isDualMenu" class="logo" />
|
||||||
|
|
||||||
<p
|
<p
|
||||||
:class="{ 'is-dual-menu-name': isDualMenu }"
|
:class="{ 'is-dual-menu-name': isDualMenu }"
|
||||||
:style="{
|
:style="{
|
||||||
color: getMenuTheme.systemNameColor,
|
color: getMenuTheme.systemNameColor,
|
||||||
opacity: !menuOpen ? 0 : 1
|
opacity: !menuOpen ? 0 : 1
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ AppConfig.systemInfo.name }}
|
{{ AppConfig.systemInfo.name }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ElMenu
|
<ElMenu
|
||||||
:class="'el-menu-' + getMenuTheme.theme"
|
:class="'el-menu-' + getMenuTheme.theme"
|
||||||
:collapse="!menuOpen"
|
:collapse="!menuOpen"
|
||||||
:default-active="routerPath"
|
:default-active="routerPath"
|
||||||
:text-color="getMenuTheme.textColor"
|
:text-color="getMenuTheme.textColor"
|
||||||
:unique-opened="uniqueOpened"
|
:unique-opened="uniqueOpened"
|
||||||
:background-color="getMenuTheme.background"
|
:background-color="getMenuTheme.background"
|
||||||
:default-openeds="defaultOpenedMenus"
|
:default-openeds="defaultOpenedMenus"
|
||||||
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
|
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
|
||||||
:show-timeout="50"
|
:show-timeout="50"
|
||||||
:hide-timeout="50"
|
:hide-timeout="50"
|
||||||
>
|
>
|
||||||
<SidebarSubmenu
|
<SidebarSubmenu
|
||||||
:list="menuList"
|
:list="menuList"
|
||||||
:isMobile="isMobileMode"
|
:isMobile="isMobileMode"
|
||||||
:theme="getMenuTheme"
|
:theme="getMenuTheme"
|
||||||
@close="handleMenuClose"
|
@close="handleMenuClose"
|
||||||
/>
|
/>
|
||||||
</ElMenu>
|
</ElMenu>
|
||||||
</ElScrollbar>
|
</ElScrollbar>
|
||||||
|
|
||||||
<!-- 双列菜单右侧折叠按钮 -->
|
<!-- 双列菜单右侧折叠按钮 -->
|
||||||
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
|
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
class="text-g-500/70"
|
class="text-g-500/70"
|
||||||
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
|
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="menu-model"
|
class="menu-model"
|
||||||
@click="toggleMenuVisibility"
|
@click="toggleMenuVisibility"
|
||||||
:style="{
|
:style="{
|
||||||
opacity: !menuOpen ? 0 : 1,
|
opacity: !menuOpen ? 0 : 1,
|
||||||
transform: showMobileModal ? 'scale(1)' : 'scale(0)'
|
transform: showMobileModal ? 'scale(1)' : 'scale(0)'
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
|
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
|
||||||
import { useMenuStore } from '@/store/modules/menu'
|
import { useMenuStore } from '@/store/modules/menu'
|
||||||
import { isIframe } from '@/utils/navigation'
|
import { isIframe } from '@/utils/navigation'
|
||||||
import { handleMenuJump } from '@/utils/navigation'
|
import { handleMenuJump } from '@/utils/navigation'
|
||||||
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
|
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
|
||||||
import { useCommon } from '@/hooks/core/useCommon'
|
import { useCommon } from '@/hooks/core/useCommon'
|
||||||
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
|
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtSidebarMenu' })
|
defineOptions({ name: 'ArtSidebarMenu' })
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 800
|
const MOBILE_BREAKPOINT = 800
|
||||||
const ANIMATION_DELAY = 350
|
const ANIMATION_DELAY = 350
|
||||||
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
|
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
|
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
|
||||||
storeToRefs(settingStore)
|
storeToRefs(settingStore)
|
||||||
|
|
||||||
// 组件内部状态
|
// 组件内部状态
|
||||||
const defaultOpenedMenus = ref<string[]>([])
|
const defaultOpenedMenus = ref<string[]>([])
|
||||||
const isMobileMode = ref(false)
|
const isMobileMode = ref(false)
|
||||||
const showMobileModal = ref(false)
|
const showMobileModal = ref(false)
|
||||||
|
|
||||||
// 使用 VueUse 的窗口尺寸监听
|
// 使用 VueUse 的窗口尺寸监听
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
|
|
||||||
// 菜单宽度相关
|
// 菜单宽度相关
|
||||||
const menuopenwidth = computed(() => getMenuOpenWidth.value)
|
const menuopenwidth = computed(() => getMenuOpenWidth.value)
|
||||||
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
|
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
|
||||||
|
|
||||||
// 菜单类型判断
|
// 菜单类型判断
|
||||||
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
|
||||||
const showLeftMenu = computed(
|
const showLeftMenu = computed(
|
||||||
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
|
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
|
||||||
)
|
)
|
||||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||||
|
|
||||||
// 移动端屏幕判断(使用 computed 避免重复计算)
|
// 移动端屏幕判断(使用 computed 避免重复计算)
|
||||||
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
|
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
|
||||||
|
|
||||||
// 路由相关
|
// 路由相关
|
||||||
const firstLevelMenuPath = computed(() => route.matched[0]?.path)
|
const firstLevelMenuPath = computed(() => route.matched[0]?.path)
|
||||||
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
const routerPath = computed(() => String(route.meta.activePath || route.path))
|
||||||
|
|
||||||
// 菜单数据
|
// 菜单数据
|
||||||
const firstLevelMenus = computed(() => {
|
const firstLevelMenus = computed(() => {
|
||||||
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
|
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
|
||||||
})
|
})
|
||||||
|
|
||||||
const menuList = computed(() => {
|
const menuList = computed(() => {
|
||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
const allMenus = menuStore.menuList
|
const allMenus = menuStore.menuList
|
||||||
|
|
||||||
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
|
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
|
||||||
if (!isTopLeftMenu.value && !isDualMenu.value) {
|
if (!isTopLeftMenu.value && !isDualMenu.value) {
|
||||||
return allMenus
|
return allMenus
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 iframe 路径
|
// 处理 iframe 路径
|
||||||
if (isIframe(route.path)) {
|
if (isIframe(route.path)) {
|
||||||
return findIframeMenuList(route.path, allMenus)
|
return findIframeMenuList(route.path, allMenus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理一级菜单
|
// 处理一级菜单
|
||||||
if (route.meta.isFirstLevel) {
|
if (route.meta.isFirstLevel) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回当前顶级路径对应的子菜单
|
// 返回当前顶级路径对应的子菜单
|
||||||
const currentTopPath = `/${route.path.split('/')[1]}`
|
const currentTopPath = `/${route.path.split('/')[1]}`
|
||||||
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
|
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
|
||||||
return currentMenu?.children ?? []
|
return currentMenu?.children ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
// 双列菜单收起时的滚动条样式
|
// 双列菜单收起时的滚动条样式
|
||||||
const scrollbarStyle = computed(() => {
|
const scrollbarStyle = computed(() => {
|
||||||
const isCollapsed = isDualMenu.value && !menuOpen.value
|
const isCollapsed = isDualMenu.value && !menuOpen.value
|
||||||
return {
|
return {
|
||||||
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
|
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
|
||||||
height: isCollapsed ? 'calc(100% + 50px)' : '100%',
|
height: isCollapsed ? 'calc(100% + 50px)' : '100%',
|
||||||
transition: 'transform 0.3s ease'
|
transition: 'transform 0.3s ease'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn)
|
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn)
|
||||||
*/
|
*/
|
||||||
const { start: delayHideMobileModal } = useTimeoutFn(
|
const { start: delayHideMobileModal } = useTimeoutFn(
|
||||||
() => {
|
() => {
|
||||||
showMobileModal.value = false
|
showMobileModal.value = false
|
||||||
},
|
},
|
||||||
ANIMATION_DELAY,
|
ANIMATION_DELAY,
|
||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查找 iframe 对应的二级菜单列表
|
* 查找 iframe 对应的二级菜单列表
|
||||||
*/
|
*/
|
||||||
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
|
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
|
||||||
// 递归查找包含当前路径的菜单项
|
// 递归查找包含当前路径的菜单项
|
||||||
const hasPath = (items: any[]): boolean => {
|
const hasPath = (items: any[]): boolean => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.path === currentPath) {
|
if (item.path === currentPath) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (item.children && hasPath(item.children)) {
|
if (item.children && hasPath(item.children)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 遍历一级菜单查找匹配的子菜单
|
// 遍历一级菜单查找匹配的子菜单
|
||||||
for (const menu of menuList) {
|
for (const menu of menuList) {
|
||||||
if (menu.children && hasPath(menu.children)) {
|
if (menu.children && hasPath(menu.children)) {
|
||||||
return menu.children
|
return menu.children
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
const { homePath } = useCommon()
|
const { homePath } = useCommon()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导航到首页
|
* 导航到首页
|
||||||
*/
|
*/
|
||||||
const navigateToHome = (): void => {
|
const navigateToHome = (): void => {
|
||||||
router.push(homePath.value)
|
router.push(homePath.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换菜单显示/隐藏
|
* 切换菜单显示/隐藏
|
||||||
*/
|
*/
|
||||||
const toggleMenuVisibility = (): void => {
|
const toggleMenuVisibility = (): void => {
|
||||||
settingStore.setMenuOpen(!menuOpen.value)
|
settingStore.setMenuOpen(!menuOpen.value)
|
||||||
|
|
||||||
// 移动端模态框控制逻辑
|
// 移动端模态框控制逻辑
|
||||||
if (isMobileScreen.value) {
|
if (isMobileScreen.value) {
|
||||||
if (!menuOpen.value) {
|
if (!menuOpen.value) {
|
||||||
// 菜单即将打开,立即显示模态框
|
// 菜单即将打开,立即显示模态框
|
||||||
showMobileModal.value = true
|
showMobileModal.value = true
|
||||||
} else {
|
} else {
|
||||||
// 菜单即将关闭,延迟隐藏模态框确保动画完成
|
// 菜单即将关闭,延迟隐藏模态框确保动画完成
|
||||||
delayHideMobileModal()
|
delayHideMobileModal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理菜单关闭(来自子组件)
|
* 处理菜单关闭(来自子组件)
|
||||||
*/
|
*/
|
||||||
const handleMenuClose = (): void => {
|
const handleMenuClose = (): void => {
|
||||||
if (isMobileScreen.value) {
|
if (isMobileScreen.value) {
|
||||||
settingStore.setMenuOpen(false)
|
settingStore.setMenuOpen(false)
|
||||||
delayHideMobileModal()
|
delayHideMobileModal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换双列菜单模式
|
* 切换双列菜单模式
|
||||||
*/
|
*/
|
||||||
const toggleDualMenuMode = (): void => {
|
const toggleDualMenuMode = (): void => {
|
||||||
settingStore.setDualMenuShowText(!dualMenuShowText.value)
|
settingStore.setDualMenuShowText(!dualMenuShowText.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听窗口尺寸变化,自动处理移动端菜单
|
* 监听窗口尺寸变化,自动处理移动端菜单
|
||||||
*/
|
*/
|
||||||
watch(width, (newWidth) => {
|
watch(width, (newWidth) => {
|
||||||
if (newWidth < MOBILE_BREAKPOINT) {
|
if (newWidth < MOBILE_BREAKPOINT) {
|
||||||
settingStore.setMenuOpen(false)
|
settingStore.setMenuOpen(false)
|
||||||
if (!menuOpen.value) {
|
if (!menuOpen.value) {
|
||||||
showMobileModal.value = false
|
showMobileModal.value = false
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
showMobileModal.value = false
|
showMobileModal.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听菜单开关状态变化
|
* 监听菜单开关状态变化
|
||||||
*/
|
*/
|
||||||
watch(menuOpen, (isMenuOpen: boolean) => {
|
watch(menuOpen, (isMenuOpen: boolean) => {
|
||||||
if (!isMobileScreen.value) {
|
if (!isMobileScreen.value) {
|
||||||
// 大屏幕设备上,模态框始终隐藏
|
// 大屏幕设备上,模态框始终隐藏
|
||||||
showMobileModal.value = false
|
showMobileModal.value = false
|
||||||
} else {
|
} else {
|
||||||
// 小屏幕设备上,根据菜单状态控制模态框
|
// 小屏幕设备上,根据菜单状态控制模态框
|
||||||
if (isMenuOpen) {
|
if (isMenuOpen) {
|
||||||
// 菜单打开时立即显示模态框
|
// 菜单打开时立即显示模态框
|
||||||
showMobileModal.value = true
|
showMobileModal.value = true
|
||||||
} else {
|
} else {
|
||||||
// 菜单关闭时延迟隐藏模态框,确保动画完成
|
// 菜单关闭时延迟隐藏模态框,确保动画完成
|
||||||
delayHideMobileModal()
|
delayHideMobileModal()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use './style';
|
@use './style';
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use './theme';
|
@use './theme';
|
||||||
|
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
// 展开的宽度
|
// 展开的宽度
|
||||||
.el-menu:not(.el-menu--collapse) {
|
.el-menu:not(.el-menu--collapse) {
|
||||||
width: v-bind(menuopenwidth);
|
width: v-bind(menuopenwidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 折叠后宽度
|
// 折叠后宽度
|
||||||
.el-menu--collapse {
|
.el-menu--collapse {
|
||||||
width: v-bind(menuclosewidth);
|
width: v-bind(menuclosewidth);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,253 +1,253 @@
|
|||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
border-right: 1px solid var(--art-card-border);
|
border-right: 1px solid var(--art-card-border);
|
||||||
|
|
||||||
&.no-border {
|
&.no-border {
|
||||||
border-right: none !important;
|
border-right: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义滚动条宽度
|
// 自定义滚动条宽度
|
||||||
:deep(.el-scrollbar__bar.is-vertical) {
|
:deep(.el-scrollbar__bar.is-vertical) {
|
||||||
width: 4px;
|
width: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-scrollbar__thumb) {
|
:deep(.el-scrollbar__thumb) {
|
||||||
right: -2px;
|
right: -2px;
|
||||||
background-color: #ccc;
|
background-color: #ccc;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-menu-left {
|
.dual-menu-left {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
border-right: 1px solid var(--art-card-border) !important;
|
border-right: 1px solid var(--art-card-border) !important;
|
||||||
transition: width 0.25s;
|
transition: width 0.25s;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
li {
|
li {
|
||||||
> div {
|
> div {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
line-clamp: 1;
|
line-clamp: 1;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
background: var(--el-color-primary-light-9);
|
background: var(--el-color-primary-light-9);
|
||||||
|
|
||||||
.art-svg-icon,
|
.art-svg-icon,
|
||||||
span {
|
span {
|
||||||
color: var(--theme-color) !important;
|
color: var(--theme-color) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.switch-btn {
|
.switch-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 15px;
|
bottom: 15px;
|
||||||
left: 0;
|
left: 0;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-left {
|
.menu-left {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
||||||
@media only screen and (width <= 640px) {
|
@media only screen and (width <= 640px) {
|
||||||
height: 100dvh;
|
height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.dual-menu-collapse-btn {
|
.dual-menu-collapse-btn {
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-menu-collapse-btn {
|
.dual-menu-collapse-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
right: -11px;
|
right: -11px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
width: 11px;
|
width: 11px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: var(--default-box-color);
|
background-color: var(--default-box-color);
|
||||||
border: 1px solid var(--art-card-border);
|
border: 1px solid var(--art-card-border);
|
||||||
border-radius: 0 15px 15px 0;
|
border-radius: 0 15px 15px 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
color: var(--art-gray-800) !important;
|
color: var(--art-gray-800) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: -4px;
|
left: -4px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 60px;
|
line-height: 60px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
margin-left: 22px;
|
margin-left: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 58px;
|
left: 58px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
|
||||||
&.is-dual-menu-name {
|
&.is-dual-menu-name {
|
||||||
left: 25px;
|
left: 25px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 60px);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
// 防止菜单内的滚动影响整个页面滚动
|
// 防止菜单内的滚动影响整个页面滚动
|
||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
-ms-scroll-chaining: contain;
|
-ms-scroll-chaining: contain;
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-model {
|
.menu-model {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (width <= 800px) {
|
@media only screen and (width <= 800px) {
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
height: 50px;
|
height: 50px;
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
height: calc(100vh - 60px);
|
height: calc(100vh - 60px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu--collapse {
|
.el-menu--collapse {
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 折叠状态下的header样式
|
// 折叠状态下的header样式
|
||||||
.menu-left-close .header {
|
.menu-left-close .header {
|
||||||
.logo {
|
.logo {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
left: 16px;
|
left: 16px;
|
||||||
font-size: 0;
|
font-size: 0;
|
||||||
opacity: 0 !important;
|
opacity: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-model {
|
.menu-model {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: rgba($color: #000, $alpha: 50%);
|
background: rgba($color: #000, $alpha: 50%);
|
||||||
transition: opacity 0.2s ease-in-out;
|
transition: opacity 0.2s ease-in-out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (width <= 640px) {
|
@media only screen and (width <= 640px) {
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
border-right: 0 !important;
|
border-right: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
border-right: 1px solid rgb(255 255 255 / 13%);
|
border-right: 1px solid rgb(255 255 255 / 13%);
|
||||||
|
|
||||||
:deep(.el-scrollbar__thumb) {
|
:deep(.el-scrollbar__thumb) {
|
||||||
background-color: #777;
|
background-color: #777;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dual-menu-left {
|
.dual-menu-left {
|
||||||
border-right: 1px solid rgb(255 255 255 / 9%) !important;
|
border-right: 1px solid rgb(255 255 255 / 9%) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,247 +12,247 @@ $popup-menu-radius: 6px;
|
|||||||
|
|
||||||
// 通用菜单项样式
|
// 通用菜单项样式
|
||||||
@mixin menu-item-base {
|
@mixin menu-item-base {
|
||||||
width: calc(100% - 16px);
|
width: calc(100% - 16px);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
margin-left: -7px;
|
margin-left: -7px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用 hover 样式
|
// 通用 hover 样式
|
||||||
@mixin menu-hover($bg-color) {
|
@mixin menu-hover($bg-color) {
|
||||||
.el-sub-menu__title:hover,
|
.el-sub-menu__title:hover,
|
||||||
.el-menu-item:not(.is-active):hover {
|
.el-menu-item:not(.is-active):hover {
|
||||||
background: $bg-color !important;
|
background: $bg-color !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用选中样式
|
// 通用选中样式
|
||||||
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
|
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
|
||||||
.el-menu-item.is-active {
|
.el-menu-item.is-active {
|
||||||
color: $color !important;
|
color: $color !important;
|
||||||
background-color: $bg-color;
|
background-color: $bg-color;
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
color: $icon-color !important;
|
color: $icon-color !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗菜单项样式
|
// 弹窗菜单项样式
|
||||||
@mixin popup-menu-item {
|
@mixin popup-menu-item {
|
||||||
height: $popup-menu-height;
|
height: $popup-menu-height;
|
||||||
margin-bottom: $popup-menu-margin;
|
margin-bottom: $popup-menu-margin;
|
||||||
border-radius: $popup-menu-radius;
|
border-radius: $popup-menu-radius;
|
||||||
|
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
margin-right: 5px;
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-of-type {
|
&:last-of-type {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑)
|
// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑)
|
||||||
@mixin theme-menu-base {
|
@mixin theme-menu-base {
|
||||||
.el-sub-menu__title,
|
.el-sub-menu__title,
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
@include menu-item-base;
|
@include menu-item-base;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 弹窗菜单通用样式
|
// 弹窗菜单通用样式
|
||||||
@mixin popup-menu-base($hover-bg, $active-color, $active-bg) {
|
@mixin popup-menu-base($hover-bg, $active-color, $active-bg) {
|
||||||
.el-menu--popup {
|
.el-menu--popup {
|
||||||
padding: $popup-menu-padding;
|
padding: $popup-menu-padding;
|
||||||
|
|
||||||
.el-sub-menu__title:hover,
|
.el-sub-menu__title:hover,
|
||||||
.el-menu-item:hover {
|
.el-menu-item:hover {
|
||||||
background-color: $hover-bg !important;
|
background-color: $hover-bg !important;
|
||||||
border-radius: $popup-menu-radius;
|
border-radius: $popup-menu-radius;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
@include popup-menu-item;
|
@include popup-menu-item;
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
color: $active-color !important;
|
color: $active-color !important;
|
||||||
background-color: $active-bg !important;
|
background-color: $active-bg !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-sub-menu {
|
.el-sub-menu {
|
||||||
@include popup-menu-item;
|
@include popup-menu-item;
|
||||||
|
|
||||||
height: $popup-menu-height !important;
|
height: $popup-menu-height !important;
|
||||||
|
|
||||||
.el-sub-menu__title {
|
.el-sub-menu__title {
|
||||||
height: $popup-menu-height !important;
|
height: $popup-menu-height !important;
|
||||||
border-radius: $popup-menu-radius;
|
border-radius: $popup-menu-radius;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
// ---------------------- Modify default style ----------------------
|
// ---------------------- Modify default style ----------------------
|
||||||
|
|
||||||
// 菜单折叠样式
|
// 菜单折叠样式
|
||||||
.menu-left-close {
|
.menu-left-close {
|
||||||
.header {
|
.header {
|
||||||
.logo {
|
.logo {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单图标
|
// 菜单图标
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
font-size: $menu-icon-size;
|
font-size: $menu-icon-size;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单高度
|
// 菜单高度
|
||||||
.el-sub-menu__title,
|
.el-sub-menu__title,
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
height: $menu-height !important;
|
height: $menu-height !important;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
line-height: $menu-height !important;
|
line-height: $menu-height !important;
|
||||||
|
|
||||||
span {
|
span {
|
||||||
font-size: $menu-font-size !important;
|
font-size: $menu-font-size !important;
|
||||||
|
|
||||||
@include ellipsis();
|
@include ellipsis();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右侧箭头
|
// 右侧箭头
|
||||||
.el-sub-menu__icon-arrow {
|
.el-sub-menu__icon-arrow {
|
||||||
width: 13px !important;
|
width: 13px !important;
|
||||||
font-size: 13px !important;
|
font-size: 13px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单折叠
|
// 菜单折叠
|
||||||
.el-menu--collapse {
|
.el-menu--collapse {
|
||||||
.el-sub-menu.is-active {
|
.el-sub-menu.is-active {
|
||||||
.el-sub-menu__title {
|
.el-sub-menu__title {
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
// 选中菜单图标颜色
|
// 选中菜单图标颜色
|
||||||
color: var(--theme-color) !important;
|
color: var(--theme-color) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------- Design theme menu ----------------------
|
// ---------------------- Design theme menu ----------------------
|
||||||
.el-menu-design {
|
.el-menu-design {
|
||||||
@include theme-menu-base;
|
@include theme-menu-base;
|
||||||
@include menu-active(var(--theme-color), var(--el-color-primary-light-9));
|
@include menu-active(var(--theme-color), var(--el-color-primary-light-9));
|
||||||
@include menu-hover($hover-bg-color);
|
@include menu-hover($hover-bg-color);
|
||||||
|
|
||||||
.el-sub-menu__icon-arrow {
|
.el-sub-menu__icon-arrow {
|
||||||
color: var(--art-gray-600);
|
color: var(--art-gray-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------- Dark theme menu ----------------------
|
// ---------------------- Dark theme menu ----------------------
|
||||||
.el-menu-dark {
|
.el-menu-dark {
|
||||||
@include theme-menu-base;
|
@include theme-menu-base;
|
||||||
@include menu-active(#fff, #27282d, #fff);
|
@include menu-active(#fff, #27282d, #fff);
|
||||||
@include menu-hover(#0f1015);
|
@include menu-hover(#0f1015);
|
||||||
|
|
||||||
.el-sub-menu__icon-arrow {
|
.el-sub-menu__icon-arrow {
|
||||||
color: var(--art-gray-400);
|
color: var(--art-gray-400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------- Light theme menu ----------------------
|
// ---------------------- Light theme menu ----------------------
|
||||||
.el-menu-light {
|
.el-menu-light {
|
||||||
.el-sub-menu__title,
|
.el-sub-menu__title,
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
.menu-icon {
|
.menu-icon {
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item.is-active {
|
.el-menu-item.is-active {
|
||||||
background-color: var(--el-color-primary-light-9);
|
background-color: var(--el-color-primary-light-9);
|
||||||
|
|
||||||
.art-svg-icon {
|
.art-svg-icon {
|
||||||
color: var(--theme-color) !important;
|
color: var(--theme-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
content: '';
|
content: '';
|
||||||
background: var(--theme-color);
|
background: var(--theme-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@include menu-hover($hover-bg-color);
|
@include menu-hover($hover-bg-color);
|
||||||
|
|
||||||
.el-sub-menu__icon-arrow {
|
.el-sub-menu__icon-arrow {
|
||||||
color: var(--art-gray-600);
|
color: var(--art-gray-600);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media only screen and (width <= 640px) {
|
@media only screen and (width <= 640px) {
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
.el-menu-design {
|
.el-menu-design {
|
||||||
> .el-sub-menu {
|
> .el-sub-menu {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-sub-menu {
|
.el-sub-menu {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单折叠 hover 弹窗样式(浅色主题)
|
// 菜单折叠 hover 弹窗样式(浅色主题)
|
||||||
.el-menu--vertical,
|
.el-menu--vertical,
|
||||||
.el-menu--popup-container {
|
.el-menu--popup-container {
|
||||||
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
|
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暗黑模式菜单样式
|
// 暗黑模式菜单样式
|
||||||
.dark {
|
.dark {
|
||||||
.el-menu--vertical,
|
.el-menu--vertical,
|
||||||
.el-menu--popup-container {
|
.el-menu--popup-container {
|
||||||
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
|
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
|
||||||
}
|
}
|
||||||
|
|
||||||
.layout-sidebar {
|
.layout-sidebar {
|
||||||
// 图标颜色、文字颜色
|
// 图标颜色、文字颜色
|
||||||
.menu-icon .art-svg-icon,
|
.menu-icon .art-svg-icon,
|
||||||
.menu-name {
|
.menu-name {
|
||||||
color: var(--art-gray-800) !important;
|
color: var(--art-gray-800) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选中的文字颜色跟图标颜色
|
// 选中的文字颜色跟图标颜色
|
||||||
.el-menu-item.is-active {
|
.el-menu-item.is-active {
|
||||||
span,
|
span,
|
||||||
.menu-icon .art-svg-icon {
|
.menu-icon .art-svg-icon {
|
||||||
color: var(--theme-color) !important;
|
color: var(--theme-color) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 右侧箭头颜色
|
// 右侧箭头颜色
|
||||||
.el-sub-menu__icon-arrow {
|
.el-sub-menu__icon-arrow {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-164
@@ -1,188 +1,191 @@
|
|||||||
<template>
|
<template>
|
||||||
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
|
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
|
||||||
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
|
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="menu-icon flex-cc">
|
<div class="menu-icon flex-cc">
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
:icon="item.meta.icon"
|
:icon="item.meta.icon"
|
||||||
:color="theme?.iconColor"
|
:color="theme?.iconColor"
|
||||||
:style="{ color: theme.iconColor }"
|
:style="{ color: theme.iconColor }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="menu-name">
|
<span class="menu-name">
|
||||||
{{ formatMenuTitle(item.meta.title) }}
|
{{ formatMenuTitle(item.meta.title) }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
|
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SidebarSubmenu
|
<SidebarSubmenu
|
||||||
:list="item.children"
|
:list="item.children"
|
||||||
:is-mobile="isMobile"
|
:is-mobile="isMobile"
|
||||||
:level="level + 1"
|
:level="level + 1"
|
||||||
:theme="theme"
|
:theme="theme"
|
||||||
@close="closeMenu"
|
@close="closeMenu"
|
||||||
/>
|
/>
|
||||||
</ElSubMenu>
|
</ElSubMenu>
|
||||||
|
|
||||||
<ElMenuItem
|
<ElMenuItem
|
||||||
v-else
|
v-else
|
||||||
:index="isExternalLink(item) ? undefined : item.path || item.meta.title"
|
:index="isExternalLink(item) ? undefined : item.path || item.meta.title"
|
||||||
:level-item="level + 1"
|
:level-item="level + 1"
|
||||||
@click="goPage(item)"
|
@click="goPage(item)"
|
||||||
>
|
>
|
||||||
<div class="menu-icon flex-cc">
|
<div class="menu-icon flex-cc">
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
:icon="item.meta.icon"
|
:icon="item.meta.icon"
|
||||||
:color="theme?.iconColor"
|
:color="theme?.iconColor"
|
||||||
:style="{ color: theme.iconColor }"
|
:style="{ color: theme.iconColor }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="item.meta.showBadge && level === 0 && !menuOpen"
|
v-show="item.meta.showBadge && level === 0 && !menuOpen"
|
||||||
class="art-badge"
|
class="art-badge"
|
||||||
style="right: 5px"
|
style="right: 5px"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="menu-name">
|
<span class="menu-name">
|
||||||
{{ formatMenuTitle(item.meta.title) }}
|
{{ formatMenuTitle(item.meta.title) }}
|
||||||
</span>
|
</span>
|
||||||
<div v-if="item.meta.showBadge" class="art-badge" />
|
<div v-if="item.meta.showBadge" class="art-badge" />
|
||||||
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge">
|
<div
|
||||||
{{ item.meta.showTextBadge }}
|
v-if="item.meta.showTextBadge && (level > 0 || menuOpen)"
|
||||||
</div>
|
class="art-text-badge"
|
||||||
</template>
|
>
|
||||||
</ElMenuItem>
|
{{ item.meta.showTextBadge }}
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElMenuItem>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { AppRouteRecord } from '@/types/router'
|
import type { AppRouteRecord } from '@/types/router'
|
||||||
import { formatMenuTitle } from '@/utils/router'
|
import { formatMenuTitle } from '@/utils/router'
|
||||||
import { handleMenuJump } from '@/utils/navigation'
|
import { handleMenuJump } from '@/utils/navigation'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
|
|
||||||
interface MenuTheme {
|
interface MenuTheme {
|
||||||
iconColor?: string
|
iconColor?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 菜单标题 */
|
/** 菜单标题 */
|
||||||
title?: string
|
title?: string
|
||||||
/** 菜单列表 */
|
/** 菜单列表 */
|
||||||
list?: AppRouteRecord[]
|
list?: AppRouteRecord[]
|
||||||
/** 主题配置 */
|
/** 主题配置 */
|
||||||
theme?: MenuTheme
|
theme?: MenuTheme
|
||||||
/** 是否为移动端模式 */
|
/** 是否为移动端模式 */
|
||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
/** 菜单层级 */
|
/** 菜单层级 */
|
||||||
level?: number
|
level?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
/** 关闭菜单事件 */
|
/** 关闭菜单事件 */
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
title: '',
|
title: '',
|
||||||
list: () => [],
|
list: () => [],
|
||||||
theme: () => ({}),
|
theme: () => ({}),
|
||||||
isMobile: false,
|
isMobile: false,
|
||||||
level: 0
|
level: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
const { menuOpen } = storeToRefs(settingStore)
|
const { menuOpen } = storeToRefs(settingStore)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 过滤后的菜单项列表
|
* 过滤后的菜单项列表
|
||||||
* 只显示未隐藏的菜单项
|
* 只显示未隐藏的菜单项
|
||||||
*/
|
*/
|
||||||
const filteredMenuItems = computed(() => filterRoutes(props.list))
|
const filteredMenuItems = computed(() => filterRoutes(props.list))
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 跳转到指定页面
|
* 跳转到指定页面
|
||||||
* @param item 菜单项数据
|
* @param item 菜单项数据
|
||||||
*/
|
*/
|
||||||
const goPage = (item: AppRouteRecord): void => {
|
const goPage = (item: AppRouteRecord): void => {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
handleMenuJump(item)
|
handleMenuJump(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭菜单
|
* 关闭菜单
|
||||||
* 触发父组件的关闭事件
|
* 触发父组件的关闭事件
|
||||||
*/
|
*/
|
||||||
const closeMenu = (): void => {
|
const closeMenu = (): void => {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 递归过滤菜单路由,移除隐藏的菜单项
|
* 递归过滤菜单路由,移除隐藏的菜单项
|
||||||
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
|
||||||
* @param items 菜单项数组
|
* @param items 菜单项数组
|
||||||
* @returns 过滤后的菜单项数组
|
* @returns 过滤后的菜单项数组
|
||||||
*/
|
*/
|
||||||
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
|
||||||
return items
|
return items
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
// 如果当前项被隐藏,直接过滤掉
|
// 如果当前项被隐藏,直接过滤掉
|
||||||
if (item.meta.isHide) {
|
if (item.meta.isHide) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有子菜单,递归过滤子菜单
|
// 如果有子菜单,递归过滤子菜单
|
||||||
if (item.children && item.children.length > 0) {
|
if (item.children && item.children.length > 0) {
|
||||||
const filteredChildren = filterRoutes(item.children)
|
const filteredChildren = filterRoutes(item.children)
|
||||||
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
|
||||||
return filteredChildren.length > 0
|
return filteredChildren.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 叶子节点且未被隐藏,保留
|
// 叶子节点且未被隐藏,保留
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
children: item.children ? filterRoutes(item.children) : undefined
|
children: item.children ? filterRoutes(item.children) : undefined
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断菜单项是否包含可见的子菜单
|
* 判断菜单项是否包含可见的子菜单
|
||||||
* @param item 菜单项数据
|
* @param item 菜单项数据
|
||||||
* @returns 是否包含可见的子菜单
|
* @returns 是否包含可见的子菜单
|
||||||
*/
|
*/
|
||||||
const hasChildren = (item: AppRouteRecord): boolean => {
|
const hasChildren = (item: AppRouteRecord): boolean => {
|
||||||
if (!item.children || item.children.length === 0) {
|
if (!item.children || item.children.length === 0) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// 递归检查是否有可见的子菜单
|
// 递归检查是否有可见的子菜单
|
||||||
const filteredChildren = filterRoutes(item.children)
|
const filteredChildren = filterRoutes(item.children)
|
||||||
return filteredChildren.length > 0
|
return filteredChildren.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为外部链接
|
* 判断是否为外部链接
|
||||||
* @param item 菜单项数据
|
* @param item 菜单项数据
|
||||||
* @returns 是否为外部链接
|
* @returns 是否为外部链接
|
||||||
*/
|
*/
|
||||||
const isExternalLink = (item: AppRouteRecord): boolean => {
|
const isExternalLink = (item: AppRouteRecord): boolean => {
|
||||||
return !!(item.meta.link && !item.meta.isIframe)
|
return !!(item.meta.link && !item.meta.isIframe)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成唯一的 key
|
* 生成唯一的 key
|
||||||
* 使用 path、title 和 index 组合确保唯一性
|
* 使用 path、title 和 index 组合确保唯一性
|
||||||
* @param item 菜单项数据
|
* @param item 菜单项数据
|
||||||
* @param index 索引
|
* @param index 索引
|
||||||
* @returns 唯一的 key
|
* @returns 唯一的 key
|
||||||
*/
|
*/
|
||||||
const getUniqueKey = (item: AppRouteRecord, index: number): string => {
|
const getUniqueKey = (item: AppRouteRecord, index: number): string => {
|
||||||
return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}`
|
return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}`
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,427 +1,432 @@
|
|||||||
<!-- 通知组件 -->
|
<!-- 通知组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="art-notification-panel art-card-sm !shadow-xl"
|
class="art-notification-panel art-card-sm !shadow-xl"
|
||||||
:style="{
|
:style="{
|
||||||
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
|
||||||
opacity: show ? 1 : 0
|
opacity: show ? 1 : 0
|
||||||
}"
|
}"
|
||||||
v-show="visible"
|
v-show="visible"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex-cb px-3.5 mt-3.5">
|
<div class="flex-cb px-3.5 mt-3.5">
|
||||||
<span class="text-base font-medium text-g-800">{{ $t('notice.title') }}</span>
|
<span class="text-base font-medium text-g-800">{{ $t('notice.title') }}</span>
|
||||||
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
|
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
|
||||||
{{ $t('notice.btnRead') }}
|
{{ $t('notice.btnRead') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
|
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in barList"
|
v-for="(item, index) in barList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
|
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
|
||||||
:class="{ 'bar-active': barActiveIndex === index }"
|
:class="{ 'bar-active': barActiveIndex === index }"
|
||||||
@click="changeBar(index)"
|
@click="changeBar(index)"
|
||||||
>
|
>
|
||||||
{{ item.name }} ({{ item.num }})
|
{{ item.name }} ({{ item.num }})
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="w-full h-[calc(100%-95px)]">
|
<div class="w-full h-[calc(100%-95px)]">
|
||||||
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
|
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
|
||||||
<!-- 通知 -->
|
<!-- 通知 -->
|
||||||
<ul v-show="barActiveIndex === 0">
|
<ul v-show="barActiveIndex === 0">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in noticeList"
|
v-for="(item, index) in noticeList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="size-9 leading-9 text-center rounded-lg flex-cc"
|
class="size-9 leading-9 text-center rounded-lg flex-cc"
|
||||||
:class="[getNoticeStyle(item.type).iconClass]"
|
:class="[getNoticeStyle(item.type).iconClass]"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon class="text-lg !bg-transparent" :icon="getNoticeStyle(item.type).icon" />
|
<ArtSvgIcon
|
||||||
</div>
|
class="text-lg !bg-transparent"
|
||||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
:icon="getNoticeStyle(item.type).icon"
|
||||||
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
|
/>
|
||||||
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
|
</div>
|
||||||
</div>
|
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||||
</li>
|
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{
|
||||||
</ul>
|
item.title
|
||||||
|
}}</h4>
|
||||||
|
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<!-- 消息 -->
|
<!-- 消息 -->
|
||||||
<ul v-show="barActiveIndex === 1">
|
<ul v-show="barActiveIndex === 1">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in msgList"
|
v-for="(item, index) in msgList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
|
||||||
>
|
>
|
||||||
<div class="w-9 h-9">
|
<div class="w-9 h-9">
|
||||||
<img :src="item.avatar" class="w-full h-full rounded-lg" />
|
<img :src="item.avatar" class="w-full h-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-[calc(100%-45px)] ml-3.5">
|
<div class="w-[calc(100%-45px)] ml-3.5">
|
||||||
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
|
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
|
||||||
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
|
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- 待办 -->
|
<!-- 待办 -->
|
||||||
<ul v-show="barActiveIndex === 2">
|
<ul v-show="barActiveIndex === 2">
|
||||||
<li
|
<li
|
||||||
v-for="(item, index) in pendingList"
|
v-for="(item, index) in pendingList"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="box-border px-5 py-3.5 last:border-b-0"
|
class="box-border px-5 py-3.5 last:border-b-0"
|
||||||
>
|
>
|
||||||
<h4>{{ item.title }}</h4>
|
<h4>{{ item.title }}</h4>
|
||||||
<p class="text-xs text-g-500">{{ item.time }}</p>
|
<p class="text-xs text-g-500">{{ item.time }}</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-show="currentTabIsEmpty"
|
v-show="currentTabIsEmpty"
|
||||||
class="relative top-25 h-full text-g-500 text-center !bg-transparent"
|
class="relative top-25 h-full text-g-500 text-center !bg-transparent"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" />
|
<ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" />
|
||||||
<p class="mt-3.5 text-xs !bg-transparent"
|
<p class="mt-3.5 text-xs !bg-transparent"
|
||||||
>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p
|
>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative box-border w-full px-3.5">
|
<div class="relative box-border w-full px-3.5">
|
||||||
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
|
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
|
||||||
{{ $t('notice.viewAll') }}
|
{{ $t('notice.viewAll') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-25"></div>
|
<div class="h-25"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
|
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
// 导入头像图片
|
// 导入头像图片
|
||||||
import avatar1 from '@/assets/images/avatar/avatar1.webp'
|
import avatar1 from '@/assets/images/avatar/avatar1.webp'
|
||||||
import avatar2 from '@/assets/images/avatar/avatar2.webp'
|
import avatar2 from '@/assets/images/avatar/avatar2.webp'
|
||||||
import avatar3 from '@/assets/images/avatar/avatar3.webp'
|
import avatar3 from '@/assets/images/avatar/avatar3.webp'
|
||||||
import avatar4 from '@/assets/images/avatar/avatar4.webp'
|
import avatar4 from '@/assets/images/avatar/avatar4.webp'
|
||||||
import avatar5 from '@/assets/images/avatar/avatar5.webp'
|
import avatar5 from '@/assets/images/avatar/avatar5.webp'
|
||||||
import avatar6 from '@/assets/images/avatar/avatar6.webp'
|
import avatar6 from '@/assets/images/avatar/avatar6.webp'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtNotification' })
|
defineOptions({ name: 'ArtNotification' })
|
||||||
|
|
||||||
interface NoticeItem {
|
interface NoticeItem {
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 时间 */
|
/** 时间 */
|
||||||
time: string
|
time: string
|
||||||
/** 类型 */
|
/** 类型 */
|
||||||
type: NoticeType
|
type: NoticeType
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MessageItem {
|
interface MessageItem {
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 时间 */
|
/** 时间 */
|
||||||
time: string
|
time: string
|
||||||
/** 头像 */
|
/** 头像 */
|
||||||
avatar: string
|
avatar: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PendingItem {
|
interface PendingItem {
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 时间 */
|
/** 时间 */
|
||||||
time: string
|
time: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BarItem {
|
interface BarItem {
|
||||||
/** 名称 */
|
/** 名称 */
|
||||||
name: ComputedRef<string>
|
name: ComputedRef<string>
|
||||||
/** 数量 */
|
/** 数量 */
|
||||||
num: number
|
num: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NoticeStyle {
|
interface NoticeStyle {
|
||||||
/** 图标 */
|
/** 图标 */
|
||||||
icon: string
|
icon: string
|
||||||
/** icon 样式 */
|
/** icon 样式 */
|
||||||
iconClass: string
|
iconClass: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type NoticeType = 'email' | 'message' | 'collection' | 'user' | 'notice'
|
type NoticeType = 'email' | 'message' | 'collection' | 'user' | 'notice'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
value: boolean
|
value: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:value': [value: boolean]
|
'update:value': [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const show = ref(false)
|
const show = ref(false)
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const barActiveIndex = ref(0)
|
const barActiveIndex = ref(0)
|
||||||
|
|
||||||
const useNotificationData = () => {
|
const useNotificationData = () => {
|
||||||
// 通知数据
|
// 通知数据
|
||||||
const noticeList = ref<NoticeItem[]>([
|
const noticeList = ref<NoticeItem[]>([
|
||||||
{
|
{
|
||||||
title: '新增国际化',
|
title: '新增国际化',
|
||||||
time: '2024-6-13 0:10',
|
time: '2024-6-13 0:10',
|
||||||
type: 'notice'
|
type: 'notice'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '冷月呆呆给你发了一条消息',
|
title: '冷月呆呆给你发了一条消息',
|
||||||
time: '2024-4-21 8:05',
|
time: '2024-4-21 8:05',
|
||||||
type: 'message'
|
type: 'message'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '小肥猪关注了你',
|
title: '小肥猪关注了你',
|
||||||
time: '2020-3-17 21:12',
|
time: '2020-3-17 21:12',
|
||||||
type: 'collection'
|
type: 'collection'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '新增使用文档',
|
title: '新增使用文档',
|
||||||
time: '2024-02-14 0:20',
|
time: '2024-02-14 0:20',
|
||||||
type: 'notice'
|
type: 'notice'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '小肥猪给你发了一封邮件',
|
title: '小肥猪给你发了一封邮件',
|
||||||
time: '2024-1-20 0:15',
|
time: '2024-1-20 0:15',
|
||||||
type: 'email'
|
type: 'email'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '菜单mock本地真实数据',
|
title: '菜单mock本地真实数据',
|
||||||
time: '2024-1-17 22:06',
|
time: '2024-1-17 22:06',
|
||||||
type: 'notice'
|
type: 'notice'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 消息数据
|
// 消息数据
|
||||||
const msgList = ref<MessageItem[]>([
|
const msgList = ref<MessageItem[]>([
|
||||||
{
|
{
|
||||||
title: '池不胖 关注了你',
|
title: '池不胖 关注了你',
|
||||||
time: '2021-2-26 23:50',
|
time: '2021-2-26 23:50',
|
||||||
avatar: avatar1
|
avatar: avatar1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '唐不苦 关注了你',
|
title: '唐不苦 关注了你',
|
||||||
time: '2021-2-21 8:05',
|
time: '2021-2-21 8:05',
|
||||||
avatar: avatar2
|
avatar: avatar2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '中小鱼 关注了你',
|
title: '中小鱼 关注了你',
|
||||||
time: '2020-1-17 21:12',
|
time: '2020-1-17 21:12',
|
||||||
avatar: avatar3
|
avatar: avatar3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '何小荷 关注了你',
|
title: '何小荷 关注了你',
|
||||||
time: '2021-01-14 0:20',
|
time: '2021-01-14 0:20',
|
||||||
avatar: avatar4
|
avatar: avatar4
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '誶誶淰 关注了你',
|
title: '誶誶淰 关注了你',
|
||||||
time: '2020-12-20 0:15',
|
time: '2020-12-20 0:15',
|
||||||
avatar: avatar5
|
avatar: avatar5
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '冷月呆呆 关注了你',
|
title: '冷月呆呆 关注了你',
|
||||||
time: '2020-12-17 22:06',
|
time: '2020-12-17 22:06',
|
||||||
avatar: avatar6
|
avatar: avatar6
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 待办数据
|
// 待办数据
|
||||||
const pendingList = ref<PendingItem[]>([])
|
const pendingList = ref<PendingItem[]>([])
|
||||||
|
|
||||||
// 标签栏数据
|
// 标签栏数据
|
||||||
const barList = computed<BarItem[]>(() => [
|
const barList = computed<BarItem[]>(() => [
|
||||||
{
|
{
|
||||||
name: computed(() => t('notice.bar[0]')),
|
name: computed(() => t('notice.bar[0]')),
|
||||||
num: noticeList.value.length
|
num: noticeList.value.length
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: computed(() => t('notice.bar[1]')),
|
name: computed(() => t('notice.bar[1]')),
|
||||||
num: msgList.value.length
|
num: msgList.value.length
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: computed(() => t('notice.bar[2]')),
|
name: computed(() => t('notice.bar[2]')),
|
||||||
num: pendingList.value.length
|
num: pendingList.value.length
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
noticeList,
|
noticeList,
|
||||||
msgList,
|
msgList,
|
||||||
pendingList,
|
pendingList,
|
||||||
barList
|
barList
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 样式管理
|
// 样式管理
|
||||||
const useNotificationStyles = () => {
|
const useNotificationStyles = () => {
|
||||||
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
|
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
|
||||||
email: {
|
email: {
|
||||||
icon: 'ri:mail-line',
|
icon: 'ri:mail-line',
|
||||||
iconClass: 'bg-warning/12 text-warning'
|
iconClass: 'bg-warning/12 text-warning'
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
icon: 'ri:volume-down-line',
|
icon: 'ri:volume-down-line',
|
||||||
iconClass: 'bg-success/12 text-success'
|
iconClass: 'bg-success/12 text-success'
|
||||||
},
|
},
|
||||||
collection: {
|
collection: {
|
||||||
icon: 'ri:heart-3-line',
|
icon: 'ri:heart-3-line',
|
||||||
iconClass: 'bg-danger/12 text-danger'
|
iconClass: 'bg-danger/12 text-danger'
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
icon: 'ri:volume-down-line',
|
icon: 'ri:volume-down-line',
|
||||||
iconClass: 'bg-info/12 text-info'
|
iconClass: 'bg-info/12 text-info'
|
||||||
},
|
},
|
||||||
notice: {
|
notice: {
|
||||||
icon: 'ri:notification-3-line',
|
icon: 'ri:notification-3-line',
|
||||||
iconClass: 'bg-theme/12 text-theme'
|
iconClass: 'bg-theme/12 text-theme'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
|
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
|
||||||
const defaultStyle: NoticeStyle = {
|
const defaultStyle: NoticeStyle = {
|
||||||
icon: 'ri:arrow-right-circle-line',
|
icon: 'ri:arrow-right-circle-line',
|
||||||
iconClass: 'bg-theme/12 text-theme'
|
iconClass: 'bg-theme/12 text-theme'
|
||||||
}
|
}
|
||||||
|
|
||||||
return noticeStyleMap[type] || defaultStyle
|
return noticeStyleMap[type] || defaultStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getNoticeStyle
|
getNoticeStyle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动画管理
|
// 动画管理
|
||||||
const useNotificationAnimation = () => {
|
const useNotificationAnimation = () => {
|
||||||
const showNotice = (open: boolean) => {
|
const showNotice = (open: boolean) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
show.value = true
|
show.value = true
|
||||||
}, 5)
|
}, 5)
|
||||||
} else {
|
} else {
|
||||||
show.value = false
|
show.value = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}, 350)
|
}, 350)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showNotice
|
showNotice
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标签页管理
|
// 标签页管理
|
||||||
const useTabManagement = (
|
const useTabManagement = (
|
||||||
noticeList: Ref<NoticeItem[]>,
|
noticeList: Ref<NoticeItem[]>,
|
||||||
msgList: Ref<MessageItem[]>,
|
msgList: Ref<MessageItem[]>,
|
||||||
pendingList: Ref<PendingItem[]>,
|
pendingList: Ref<PendingItem[]>,
|
||||||
businessHandlers: {
|
businessHandlers: {
|
||||||
handleNoticeAll: () => void
|
handleNoticeAll: () => void
|
||||||
handleMsgAll: () => void
|
handleMsgAll: () => void
|
||||||
handlePendingAll: () => void
|
handlePendingAll: () => void
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const changeBar = (index: number) => {
|
const changeBar = (index: number) => {
|
||||||
barActiveIndex.value = index
|
barActiveIndex.value = index
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查当前标签页是否为空
|
// 检查当前标签页是否为空
|
||||||
const currentTabIsEmpty = computed(() => {
|
const currentTabIsEmpty = computed(() => {
|
||||||
const tabDataMap = [noticeList.value, msgList.value, pendingList.value]
|
const tabDataMap = [noticeList.value, msgList.value, pendingList.value]
|
||||||
|
|
||||||
const currentData = tabDataMap[barActiveIndex.value]
|
const currentData = tabDataMap[barActiveIndex.value]
|
||||||
return currentData && currentData.length === 0
|
return currentData && currentData.length === 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleViewAll = () => {
|
const handleViewAll = () => {
|
||||||
// 查看全部处理器映射
|
// 查看全部处理器映射
|
||||||
const viewAllHandlers: Record<number, () => void> = {
|
const viewAllHandlers: Record<number, () => void> = {
|
||||||
0: businessHandlers.handleNoticeAll,
|
0: businessHandlers.handleNoticeAll,
|
||||||
1: businessHandlers.handleMsgAll,
|
1: businessHandlers.handleMsgAll,
|
||||||
2: businessHandlers.handlePendingAll
|
2: businessHandlers.handlePendingAll
|
||||||
}
|
}
|
||||||
|
|
||||||
const handler = viewAllHandlers[barActiveIndex.value]
|
const handler = viewAllHandlers[barActiveIndex.value]
|
||||||
handler?.()
|
handler?.()
|
||||||
|
|
||||||
// 关闭通知面板
|
// 关闭通知面板
|
||||||
emit('update:value', false)
|
emit('update:value', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
changeBar,
|
changeBar,
|
||||||
currentTabIsEmpty,
|
currentTabIsEmpty,
|
||||||
handleViewAll
|
handleViewAll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 业务逻辑处理
|
// 业务逻辑处理
|
||||||
const useBusinessLogic = () => {
|
const useBusinessLogic = () => {
|
||||||
const handleNoticeAll = () => {
|
const handleNoticeAll = () => {
|
||||||
// 处理查看全部通知
|
// 处理查看全部通知
|
||||||
console.log('查看全部通知')
|
console.log('查看全部通知')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMsgAll = () => {
|
const handleMsgAll = () => {
|
||||||
// 处理查看全部消息
|
// 处理查看全部消息
|
||||||
console.log('查看全部消息')
|
console.log('查看全部消息')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePendingAll = () => {
|
const handlePendingAll = () => {
|
||||||
// 处理查看全部待办
|
// 处理查看全部待办
|
||||||
console.log('查看全部待办')
|
console.log('查看全部待办')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleNoticeAll,
|
handleNoticeAll,
|
||||||
handleMsgAll,
|
handleMsgAll,
|
||||||
handlePendingAll
|
handlePendingAll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组合所有逻辑
|
// 组合所有逻辑
|
||||||
const { noticeList, msgList, pendingList, barList } = useNotificationData()
|
const { noticeList, msgList, pendingList, barList } = useNotificationData()
|
||||||
const { getNoticeStyle } = useNotificationStyles()
|
const { getNoticeStyle } = useNotificationStyles()
|
||||||
const { showNotice } = useNotificationAnimation()
|
const { showNotice } = useNotificationAnimation()
|
||||||
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic()
|
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic()
|
||||||
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
|
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
|
||||||
noticeList,
|
noticeList,
|
||||||
msgList,
|
msgList,
|
||||||
pendingList,
|
pendingList,
|
||||||
{ handleNoticeAll, handleMsgAll, handlePendingAll }
|
{ handleNoticeAll, handleMsgAll, handlePendingAll }
|
||||||
)
|
)
|
||||||
|
|
||||||
// 监听属性变化
|
// 监听属性变化
|
||||||
watch(
|
watch(
|
||||||
() => props.value,
|
() => props.value,
|
||||||
(newValue) => {
|
(newValue) => {
|
||||||
showNotice(newValue)
|
showNotice(newValue)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference '@styles/core/tailwind.css';
|
@reference '@styles/core/tailwind.css';
|
||||||
|
|
||||||
.art-notification-panel {
|
.art-notification-panel {
|
||||||
@apply absolute
|
@apply absolute
|
||||||
top-14.5
|
top-14.5
|
||||||
right-5
|
right-5
|
||||||
w-90
|
w-90
|
||||||
@@ -435,22 +440,22 @@
|
|||||||
max-[640px]:right-0
|
max-[640px]:right-0
|
||||||
max-[640px]:w-full
|
max-[640px]:w-full
|
||||||
max-[640px]:h-[80vh];
|
max-[640px]:h-[80vh];
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-active {
|
.bar-active {
|
||||||
color: var(--theme-color) !important;
|
color: var(--theme-color) !important;
|
||||||
border-bottom: 2px solid var(--theme-color);
|
border-bottom: 2px solid var(--theme-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
width: 5px !important;
|
width: 5px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||||
background-color: var(--default-box-color);
|
background-color: var(--default-box-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
background-color: #222 !important;
|
background-color: #222 !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,136 +1,136 @@
|
|||||||
<!-- 布局内容 -->
|
<!-- 布局内容 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle">
|
<div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle">
|
||||||
<div id="app-content-header">
|
<div id="app-content-header">
|
||||||
<!-- 节日滚动 -->
|
<!-- 节日滚动 -->
|
||||||
<ArtFestivalTextScroll v-if="!isFullPage" />
|
<ArtFestivalTextScroll v-if="!isFullPage" />
|
||||||
|
|
||||||
<!-- 路由信息调试 -->
|
<!-- 路由信息调试 -->
|
||||||
<div
|
<div
|
||||||
v-if="isOpenRouteInfo === 'true'"
|
v-if="isOpenRouteInfo === 'true'"
|
||||||
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md"
|
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md"
|
||||||
>
|
>
|
||||||
router meta:{{ route.meta }}
|
router meta:{{ route.meta }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
|
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
|
||||||
<!-- 缓存路由动画 -->
|
<!-- 缓存路由动画 -->
|
||||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||||
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
<KeepAlive :max="10" :exclude="keepAliveExclude">
|
||||||
<component
|
<component
|
||||||
class="art-page-view"
|
class="art-page-view"
|
||||||
:is="Component"
|
:is="Component"
|
||||||
:key="route.path"
|
:key="route.path"
|
||||||
v-if="route.meta.keepAlive"
|
v-if="route.meta.keepAlive"
|
||||||
/>
|
/>
|
||||||
</KeepAlive>
|
</KeepAlive>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<!-- 非缓存路由动画 -->
|
<!-- 非缓存路由动画 -->
|
||||||
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
|
||||||
<component
|
<component
|
||||||
class="art-page-view"
|
class="art-page-view"
|
||||||
:is="Component"
|
:is="Component"
|
||||||
:key="route.path"
|
:key="route.path"
|
||||||
v-if="!route.meta.keepAlive"
|
v-if="!route.meta.keepAlive"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
|
|
||||||
<!-- 全屏页面切换过渡遮罩(用于提升页面切换视觉体验) -->
|
<!-- 全屏页面切换过渡遮罩(用于提升页面切换视觉体验) -->
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<div
|
<div
|
||||||
v-show="showTransitionMask"
|
v-show="showTransitionMask"
|
||||||
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
|
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
|
||||||
/>
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CSSProperties } from 'vue'
|
import type { CSSProperties } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
|
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useWorktabStore } from '@/store/modules/worktab'
|
import { useWorktabStore } from '@/store/modules/worktab'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtPageContent' })
|
defineOptions({ name: 'ArtPageContent' })
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { containerMinHeight } = useAutoLayoutHeight()
|
const { containerMinHeight } = useAutoLayoutHeight()
|
||||||
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
|
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
|
||||||
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
const { keepAliveExclude } = storeToRefs(useWorktabStore())
|
||||||
|
|
||||||
const isRefresh = shallowRef(true)
|
const isRefresh = shallowRef(true)
|
||||||
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
|
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
|
||||||
const showTransitionMask = ref(false)
|
const showTransitionMask = ref(false)
|
||||||
|
|
||||||
// 标记是否是首次加载(浏览器刷新)
|
// 标记是否是首次加载(浏览器刷新)
|
||||||
const isFirstLoad = ref(true)
|
const isFirstLoad = ref(true)
|
||||||
|
|
||||||
// 检查当前路由是否需要使用无基础布局模式
|
// 检查当前路由是否需要使用无基础布局模式
|
||||||
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage))
|
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage))
|
||||||
const prevIsFullPage = ref(isFullPage.value)
|
const prevIsFullPage = ref(isFullPage.value)
|
||||||
|
|
||||||
// 切换动画名称:首次加载、从全屏返回时不使用动画
|
// 切换动画名称:首次加载、从全屏返回时不使用动画
|
||||||
const actualTransition = computed(() => {
|
const actualTransition = computed(() => {
|
||||||
if (isFirstLoad.value) return ''
|
if (isFirstLoad.value) return ''
|
||||||
if (prevIsFullPage.value && !isFullPage.value) return ''
|
if (prevIsFullPage.value && !isFullPage.value) return ''
|
||||||
return pageTransition.value
|
return pageTransition.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听全屏状态变化,显示过渡遮罩
|
// 监听全屏状态变化,显示过渡遮罩
|
||||||
watch(isFullPage, (val, oldVal) => {
|
watch(isFullPage, (val, oldVal) => {
|
||||||
if (val !== oldVal) {
|
if (val !== oldVal) {
|
||||||
showTransitionMask.value = true
|
showTransitionMask.value = true
|
||||||
// 延迟隐藏遮罩,给足时间让页面完成切换
|
// 延迟隐藏遮罩,给足时间让页面完成切换
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
showTransitionMask.value = false
|
showTransitionMask.value = false
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
prevIsFullPage.value = val
|
prevIsFullPage.value = val
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const containerStyle = computed(
|
const containerStyle = computed(
|
||||||
(): CSSProperties =>
|
(): CSSProperties =>
|
||||||
isFullPage.value
|
isFullPage.value
|
||||||
? {
|
? {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
zIndex: 2500,
|
zIndex: 2500,
|
||||||
background: 'var(--default-bg-color)'
|
background: 'var(--default-bg-color)'
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
maxWidth: containerWidth.value
|
maxWidth: containerWidth.value
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const contentStyle = computed(
|
const contentStyle = computed(
|
||||||
(): CSSProperties => ({
|
(): CSSProperties => ({
|
||||||
minHeight: containerMinHeight.value
|
minHeight: containerMinHeight.value
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
isRefresh.value = false
|
isRefresh.value = false
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isRefresh.value = true
|
isRefresh.value = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(refresh, reload, { flush: 'post' })
|
watch(refresh, reload, { flush: 'post' })
|
||||||
|
|
||||||
// 组件挂载后标记首次加载完成
|
// 组件挂载后标记首次加载完成
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 延迟一帧,确保首次渲染完成
|
// 延迟一帧,确保首次渲染完成
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isFirstLoad.value = false
|
isFirstLoad.value = false
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,519 +1,530 @@
|
|||||||
<!-- 锁屏 -->
|
<!-- 锁屏 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-lock-screen">
|
<div class="layout-lock-screen">
|
||||||
<!-- 开发者工具警告覆盖层 -->
|
<!-- 开发者工具警告覆盖层 -->
|
||||||
<div
|
<div
|
||||||
v-if="showDevToolsWarning"
|
v-if="showDevToolsWarning"
|
||||||
class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in"
|
class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in"
|
||||||
>
|
>
|
||||||
<div class="p-5 text-center select-none">
|
<div class="p-5 text-center select-none">
|
||||||
<div class="mb-7.5 text-5xl">🔒</div>
|
<div class="mb-7.5 text-5xl">🔒</div>
|
||||||
<h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1>
|
<h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1>
|
||||||
<p class="max-w-125 m-0 text-lg leading-relaxed text-white">
|
<p class="max-w-125 m-0 text-lg leading-relaxed text-white">
|
||||||
检测到开发者工具已打开<br />
|
检测到开发者工具已打开<br />
|
||||||
为了系统安全,请关闭开发者工具后继续使用
|
为了系统安全,请关闭开发者工具后继续使用
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
|
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 锁屏弹窗 -->
|
<!-- 锁屏弹窗 -->
|
||||||
<div v-if="!isLock">
|
<div v-if="!isLock">
|
||||||
<ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
|
<ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
|
||||||
<div class="flex-c flex-col">
|
<div class="flex-c flex-col">
|
||||||
<img class="w-16 h-16 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" />
|
<img
|
||||||
<div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div>
|
class="w-16 h-16 rounded-full"
|
||||||
<ElForm
|
src="@imgs/user/avatar.webp"
|
||||||
ref="formRef"
|
alt="用户头像"
|
||||||
:model="formData"
|
/>
|
||||||
:rules="rules"
|
<div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.userName }}</div>
|
||||||
class="w-[90%]"
|
<ElForm
|
||||||
@submit.prevent="handleLock"
|
ref="formRef"
|
||||||
>
|
:model="formData"
|
||||||
<ElFormItem prop="password">
|
:rules="rules"
|
||||||
<ElInput
|
class="w-[90%]"
|
||||||
v-model="formData.password"
|
@submit.prevent="handleLock"
|
||||||
type="password"
|
>
|
||||||
:placeholder="$t('lockScreen.lock.inputPlaceholder')"
|
<ElFormItem prop="password">
|
||||||
:show-password="true"
|
<ElInput
|
||||||
autocomplete="new-password"
|
v-model="formData.password"
|
||||||
ref="lockInputRef"
|
type="password"
|
||||||
class="w-full mt-9"
|
:placeholder="$t('lockScreen.lock.inputPlaceholder')"
|
||||||
@keyup.enter="handleLock"
|
:show-password="true"
|
||||||
>
|
autocomplete="new-password"
|
||||||
<template #suffix>
|
ref="lockInputRef"
|
||||||
<ElIcon class="c-p" @click="handleLock">
|
class="w-full mt-9"
|
||||||
<Lock />
|
@keyup.enter="handleLock"
|
||||||
</ElIcon>
|
>
|
||||||
</template>
|
<template #suffix>
|
||||||
</ElInput>
|
<ElIcon class="c-p" @click="handleLock">
|
||||||
</ElFormItem>
|
<Lock />
|
||||||
<ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple>
|
</ElIcon>
|
||||||
{{ $t('lockScreen.lock.btnText') }}
|
</template>
|
||||||
</ElButton>
|
</ElInput>
|
||||||
</ElForm>
|
</ElFormItem>
|
||||||
</div>
|
<ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple>
|
||||||
</ElDialog>
|
{{ $t('lockScreen.lock.btnText') }}
|
||||||
</div>
|
</ElButton>
|
||||||
|
</ElForm>
|
||||||
|
</div>
|
||||||
|
</ElDialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 解锁界面 -->
|
<!-- 解锁界面 -->
|
||||||
<div v-else class="unlock-content">
|
<div v-else class="unlock-content">
|
||||||
<div class="flex-c flex-col w-80">
|
<div class="flex-c flex-col w-80">
|
||||||
<img class="w-16 h-16 mt-5 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" />
|
<img
|
||||||
<div class="mt-3 mb-3.5 text-base font-medium">
|
class="w-16 h-16 mt-5 rounded-full"
|
||||||
{{ userInfo.userName }}
|
src="@imgs/user/avatar.webp"
|
||||||
</div>
|
alt="用户头像"
|
||||||
<ElForm
|
/>
|
||||||
ref="unlockFormRef"
|
<div class="mt-3 mb-3.5 text-base font-medium">
|
||||||
:model="unlockForm"
|
{{ userInfo.userName }}
|
||||||
:rules="rules"
|
</div>
|
||||||
class="w-full !px-2.5"
|
<ElForm
|
||||||
@submit.prevent="handleUnlock"
|
ref="unlockFormRef"
|
||||||
>
|
:model="unlockForm"
|
||||||
<ElFormItem prop="password">
|
:rules="rules"
|
||||||
<ElInput
|
class="w-full !px-2.5"
|
||||||
v-model="unlockForm.password"
|
@submit.prevent="handleUnlock"
|
||||||
type="password"
|
>
|
||||||
:placeholder="$t('lockScreen.unlock.inputPlaceholder')"
|
<ElFormItem prop="password">
|
||||||
:show-password="true"
|
<ElInput
|
||||||
autocomplete="new-password"
|
v-model="unlockForm.password"
|
||||||
ref="unlockInputRef"
|
type="password"
|
||||||
class="mt-5"
|
:placeholder="$t('lockScreen.unlock.inputPlaceholder')"
|
||||||
>
|
:show-password="true"
|
||||||
<template #suffix>
|
autocomplete="new-password"
|
||||||
<ElIcon class="c-p" @click="handleUnlock">
|
ref="unlockInputRef"
|
||||||
<Unlock />
|
class="mt-5"
|
||||||
</ElIcon>
|
>
|
||||||
</template>
|
<template #suffix>
|
||||||
</ElInput>
|
<ElIcon class="c-p" @click="handleUnlock">
|
||||||
</ElFormItem>
|
<Unlock />
|
||||||
|
</ElIcon>
|
||||||
|
</template>
|
||||||
|
</ElInput>
|
||||||
|
</ElFormItem>
|
||||||
|
|
||||||
<ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple>
|
<ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple>
|
||||||
{{ $t('lockScreen.unlock.btnText') }}
|
{{ $t('lockScreen.unlock.btnText') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<div class="w-full text-center">
|
<div class="w-full text-center">
|
||||||
<ElButton
|
<ElButton
|
||||||
text
|
text
|
||||||
class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent"
|
class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent"
|
||||||
@click="toLogin"
|
@click="toLogin"
|
||||||
>
|
>
|
||||||
{{ $t('lockScreen.unlock.backBtnText') }}
|
{{ $t('lockScreen.unlock.backBtnText') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</ElForm>
|
</ElForm>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Lock, Unlock } from '@element-plus/icons-vue'
|
import { Lock, Unlock } from '@element-plus/icons-vue'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import CryptoJS from 'crypto-js'
|
import CryptoJS from 'crypto-js'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { mittBus } from '@/utils/sys'
|
import { mittBus } from '@/utils/sys'
|
||||||
|
|
||||||
// 国际化
|
// 国际化
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 环境变量
|
// 环境变量
|
||||||
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
|
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
|
||||||
|
|
||||||
// Store
|
// Store
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const visible = ref<boolean>(false)
|
const visible = ref<boolean>(false)
|
||||||
const lockInputRef = ref<any>(null)
|
const lockInputRef = ref<any>(null)
|
||||||
const unlockInputRef = ref<any>(null)
|
const unlockInputRef = ref<any>(null)
|
||||||
const showDevToolsWarning = ref<boolean>(false)
|
const showDevToolsWarning = ref<boolean>(false)
|
||||||
|
|
||||||
// 表单相关
|
// 表单相关
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const unlockFormRef = ref<FormInstance>()
|
const unlockFormRef = ref<FormInstance>()
|
||||||
|
|
||||||
const formData = reactive({
|
const formData = reactive({
|
||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const unlockForm = reactive({
|
const unlockForm = reactive({
|
||||||
password: ''
|
password: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const rules = computed<FormRules>(() => ({
|
const rules = computed<FormRules>(() => ({
|
||||||
password: [
|
password: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: t('lockScreen.lock.inputPlaceholder'),
|
message: t('lockScreen.lock.inputPlaceholder'),
|
||||||
trigger: 'blur'
|
trigger: 'blur'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 检测是否为移动设备
|
// 检测是否为移动设备
|
||||||
const isMobile = () => {
|
const isMobile = () => {
|
||||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||||
navigator.userAgent
|
navigator.userAgent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加禁用控制台的函数
|
// 添加禁用控制台的函数
|
||||||
const disableDevTools = () => {
|
const disableDevTools = () => {
|
||||||
// 禁用右键菜单
|
// 禁用右键菜单
|
||||||
const handleContextMenu = (e: Event) => {
|
const handleContextMenu = (e: Event) => {
|
||||||
if (isLock.value) {
|
if (isLock.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('contextmenu', handleContextMenu, true)
|
document.addEventListener('contextmenu', handleContextMenu, true)
|
||||||
|
|
||||||
// 禁用开发者工具相关快捷键
|
// 禁用开发者工具相关快捷键
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (!isLock.value) return
|
if (!isLock.value) return
|
||||||
|
|
||||||
// 禁用 F12
|
// 禁用 F12
|
||||||
if (e.key === 'F12') {
|
if (e.key === 'F12') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
|
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
|
||||||
if (e.ctrlKey && e.shiftKey) {
|
if (e.ctrlKey && e.shiftKey) {
|
||||||
const key = e.key.toLowerCase()
|
const key = e.key.toLowerCase()
|
||||||
if (['i', 'j', 'c', 'k'].includes(key)) {
|
if (['i', 'j', 'c', 'k'].includes(key)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+U (查看源代码)
|
// 禁用 Ctrl+U (查看源代码)
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'u') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'u') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+S (保存页面)
|
// 禁用 Ctrl+S (保存页面)
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
if (e.ctrlKey && e.key.toLowerCase() === 's') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+A (全选)
|
// 禁用 Ctrl+A (全选)
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'a') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'a') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+P (打印)
|
// 禁用 Ctrl+P (打印)
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+F (查找)
|
// 禁用 Ctrl+F (查找)
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Alt+Tab (切换窗口)
|
// 禁用 Alt+Tab (切换窗口)
|
||||||
if (e.altKey && e.key === 'Tab') {
|
if (e.altKey && e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+Tab (切换标签页)
|
// 禁用 Ctrl+Tab (切换标签页)
|
||||||
if (e.ctrlKey && e.key === 'Tab') {
|
if (e.ctrlKey && e.key === 'Tab') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+W (关闭标签页)
|
// 禁用 Ctrl+W (关闭标签页)
|
||||||
if (e.ctrlKey && e.key.toLowerCase() === 'w') {
|
if (e.ctrlKey && e.key.toLowerCase() === 'w') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+R 和 F5 (刷新页面)
|
// 禁用 Ctrl+R 和 F5 (刷新页面)
|
||||||
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
|
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 禁用 Ctrl+Shift+R (强制刷新)
|
// 禁用 Ctrl+Shift+R (强制刷新)
|
||||||
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
|
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKeyDown, true)
|
document.addEventListener('keydown', handleKeyDown, true)
|
||||||
|
|
||||||
// 禁用选择文本
|
// 禁用选择文本
|
||||||
const handleSelectStart = (e: Event) => {
|
const handleSelectStart = (e: Event) => {
|
||||||
if (isLock.value) {
|
if (isLock.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('selectstart', handleSelectStart, true)
|
document.addEventListener('selectstart', handleSelectStart, true)
|
||||||
|
|
||||||
// 禁用拖拽
|
// 禁用拖拽
|
||||||
const handleDragStart = (e: Event) => {
|
const handleDragStart = (e: Event) => {
|
||||||
if (isLock.value) {
|
if (isLock.value) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('dragstart', handleDragStart, true)
|
document.addEventListener('dragstart', handleDragStart, true)
|
||||||
|
|
||||||
// 监听开发者工具打开状态(仅在桌面端启用)
|
// 监听开发者工具打开状态(仅在桌面端启用)
|
||||||
let devtools = { open: false }
|
let devtools = { open: false }
|
||||||
const threshold = 160
|
const threshold = 160
|
||||||
let devToolsInterval: ReturnType<typeof setInterval> | null = null
|
let devToolsInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const checkDevTools = () => {
|
const checkDevTools = () => {
|
||||||
if (!isLock.value || isMobile()) return
|
if (!isLock.value || isMobile()) return
|
||||||
|
|
||||||
const isDevToolsOpen =
|
const isDevToolsOpen =
|
||||||
window.outerHeight - window.innerHeight > threshold ||
|
window.outerHeight - window.innerHeight > threshold ||
|
||||||
window.outerWidth - window.innerWidth > threshold
|
window.outerWidth - window.innerWidth > threshold
|
||||||
|
|
||||||
if (isDevToolsOpen && !devtools.open) {
|
if (isDevToolsOpen && !devtools.open) {
|
||||||
devtools.open = true
|
devtools.open = true
|
||||||
showDevToolsWarning.value = true
|
showDevToolsWarning.value = true
|
||||||
} else if (!isDevToolsOpen && devtools.open) {
|
} else if (!isDevToolsOpen && devtools.open) {
|
||||||
devtools.open = false
|
devtools.open = false
|
||||||
showDevToolsWarning.value = false
|
showDevToolsWarning.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅在桌面端启用开发者工具检测
|
// 仅在桌面端启用开发者工具检测
|
||||||
if (!isMobile()) {
|
if (!isMobile()) {
|
||||||
devToolsInterval = setInterval(checkDevTools, 500)
|
devToolsInterval = setInterval(checkDevTools, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回清理函数
|
// 返回清理函数
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('contextmenu', handleContextMenu, true)
|
document.removeEventListener('contextmenu', handleContextMenu, true)
|
||||||
document.removeEventListener('keydown', handleKeyDown, true)
|
document.removeEventListener('keydown', handleKeyDown, true)
|
||||||
document.removeEventListener('selectstart', handleSelectStart, true)
|
document.removeEventListener('selectstart', handleSelectStart, true)
|
||||||
document.removeEventListener('dragstart', handleDragStart, true)
|
document.removeEventListener('dragstart', handleDragStart, true)
|
||||||
if (devToolsInterval) {
|
if (devToolsInterval) {
|
||||||
clearInterval(devToolsInterval)
|
clearInterval(devToolsInterval)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
|
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
|
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
|
||||||
CryptoJS.enc.Utf8
|
CryptoJS.enc.Utf8
|
||||||
)
|
)
|
||||||
return inputPassword === decryptedPassword
|
return inputPassword === decryptedPassword
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('密码解密失败:', error)
|
console.error('密码解密失败:', error)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 事件处理函数
|
// 事件处理函数
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (event.altKey && event.key.toLowerCase() === '¬') {
|
if (event.altKey && event.key.toLowerCase() === '¬') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDialogOpen = () => {
|
const handleDialogOpen = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
lockInputRef.value?.input?.focus()
|
lockInputRef.value?.input?.focus()
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleLock = async () => {
|
const handleLock = async () => {
|
||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
|
|
||||||
await formRef.value.validate((valid, fields) => {
|
await formRef.value.validate((valid, fields) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString()
|
const encryptedPassword = CryptoJS.AES.encrypt(
|
||||||
userStore.setLockStatus(true)
|
formData.password,
|
||||||
userStore.setLockPassword(encryptedPassword)
|
ENCRYPT_KEY
|
||||||
visible.value = false
|
).toString()
|
||||||
formData.password = ''
|
userStore.setLockStatus(true)
|
||||||
} else {
|
userStore.setLockPassword(encryptedPassword)
|
||||||
console.error('表单验证失败:', fields)
|
visible.value = false
|
||||||
}
|
formData.password = ''
|
||||||
})
|
} else {
|
||||||
}
|
console.error('表单验证失败:', fields)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleUnlock = async () => {
|
const handleUnlock = async () => {
|
||||||
if (!unlockFormRef.value) return
|
if (!unlockFormRef.value) return
|
||||||
|
|
||||||
await unlockFormRef.value.validate((valid, fields) => {
|
await unlockFormRef.value.validate((valid, fields) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
const isValid = verifyPassword(unlockForm.password, lockPassword.value)
|
const isValid = verifyPassword(unlockForm.password, lockPassword.value)
|
||||||
|
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
try {
|
try {
|
||||||
userStore.setLockStatus(false)
|
userStore.setLockStatus(false)
|
||||||
userStore.setLockPassword('')
|
userStore.setLockPassword('')
|
||||||
unlockForm.password = ''
|
unlockForm.password = ''
|
||||||
visible.value = false
|
visible.value = false
|
||||||
showDevToolsWarning.value = false
|
showDevToolsWarning.value = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新store失败:', error)
|
console.error('更新store失败:', error)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 触发抖动动画
|
// 触发抖动动画
|
||||||
const inputElement = unlockInputRef.value?.$el
|
const inputElement = unlockInputRef.value?.$el
|
||||||
if (inputElement) {
|
if (inputElement) {
|
||||||
inputElement.classList.add('shake-animation')
|
inputElement.classList.add('shake-animation')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
inputElement.classList.remove('shake-animation')
|
inputElement.classList.remove('shake-animation')
|
||||||
}, 300)
|
}, 300)
|
||||||
}
|
}
|
||||||
ElMessage.error(t('lockScreen.pwdError'))
|
ElMessage.error(t('lockScreen.pwdError'))
|
||||||
unlockForm.password = ''
|
unlockForm.password = ''
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error('表单验证失败:', fields)
|
console.error('表单验证失败:', fields)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const toLogin = () => {
|
const toLogin = () => {
|
||||||
userStore.logOut()
|
userStore.logOut()
|
||||||
}
|
}
|
||||||
|
|
||||||
const openLockScreen = () => {
|
const openLockScreen = () => {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听锁屏状态变化
|
// 监听锁屏状态变化
|
||||||
watch(isLock, (newValue) => {
|
watch(isLock, (newValue) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
unlockInputRef.value?.input?.focus()
|
unlockInputRef.value?.input?.focus()
|
||||||
}, 100)
|
}, 100)
|
||||||
} else {
|
} else {
|
||||||
document.body.style.overflow = 'auto'
|
document.body.style.overflow = 'auto'
|
||||||
showDevToolsWarning.value = false
|
showDevToolsWarning.value = false
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 存储清理函数
|
// 存储清理函数
|
||||||
let cleanupDevTools: (() => void) | null = null
|
let cleanupDevTools: (() => void) | null = null
|
||||||
|
|
||||||
// 生命周期钩子
|
// 生命周期钩子
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mittBus.on('openLockScreen', openLockScreen)
|
mittBus.on('openLockScreen', openLockScreen)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
if (isLock.value) {
|
if (isLock.value) {
|
||||||
visible.value = true
|
visible.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
unlockInputRef.value?.input?.focus()
|
unlockInputRef.value?.input?.focus()
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化禁用开发者工具功能
|
// 初始化禁用开发者工具功能
|
||||||
cleanupDevTools = disableDevTools()
|
cleanupDevTools = disableDevTools()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
document.body.style.overflow = 'auto'
|
document.body.style.overflow = 'auto'
|
||||||
// 清理禁用开发者工具的事件监听器
|
// 清理禁用开发者工具的事件监听器
|
||||||
if (cleanupDevTools) {
|
if (cleanupDevTools) {
|
||||||
cleanupDevTools()
|
cleanupDevTools()
|
||||||
cleanupDevTools = null
|
cleanupDevTools = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.layout-lock-screen :deep(.el-dialog) {
|
.layout-lock-screen :deep(.el-dialog) {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.unlock-content {
|
.unlock-content {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 2500;
|
z-index: 2500;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
background-image: url('@imgs/lock/bg_light.webp');
|
background-image: url('@imgs/lock/bg_light.webp');
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
transition: transform 0.3s ease-in-out;
|
transition: transform 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.unlock-content {
|
.unlock-content {
|
||||||
background-image: url('@imgs/lock/bg_dark.webp');
|
background-image: url('@imgs/lock/bg_dark.webp');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fade-in 0.3s ease-in-out;
|
animation: fade-in 0.3s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
10%,
|
10%,
|
||||||
30%,
|
30%,
|
||||||
50%,
|
50%,
|
||||||
70%,
|
70%,
|
||||||
90% {
|
90% {
|
||||||
transform: translateX(-10px);
|
transform: translateX(-10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
20%,
|
20%,
|
||||||
40%,
|
40%,
|
||||||
60%,
|
60%,
|
||||||
80% {
|
80% {
|
||||||
transform: translateX(10px);
|
transform: translateX(10px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.shake-animation {
|
.shake-animation {
|
||||||
animation: shake 0.5s ease-in-out;
|
animation: shake 0.5s ease-in-out;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,241 +8,241 @@ import { headerBarConfig } from '@/config/modules/headerBar'
|
|||||||
* 设置项配置选项管理
|
* 设置项配置选项管理
|
||||||
*/
|
*/
|
||||||
export function useSettingsConfig() {
|
export function useSettingsConfig() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 标签页风格选项
|
// 标签页风格选项
|
||||||
const tabStyleOptions = computed(() => [
|
const tabStyleOptions = computed(() => [
|
||||||
{
|
{
|
||||||
value: 'tab-default',
|
value: 'tab-default',
|
||||||
label: t('setting.tabStyle.default')
|
label: t('setting.tabStyle.default')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'tab-card',
|
value: 'tab-card',
|
||||||
label: t('setting.tabStyle.card')
|
label: t('setting.tabStyle.card')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'tab-google',
|
value: 'tab-google',
|
||||||
label: t('setting.tabStyle.google')
|
label: t('setting.tabStyle.google')
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 页面切换动画选项
|
// 页面切换动画选项
|
||||||
const pageTransitionOptions = computed(() => [
|
const pageTransitionOptions = computed(() => [
|
||||||
{
|
{
|
||||||
value: '',
|
value: '',
|
||||||
label: t('setting.transition.list.none')
|
label: t('setting.transition.list.none')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'fade',
|
value: 'fade',
|
||||||
label: t('setting.transition.list.fade')
|
label: t('setting.transition.list.fade')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'slide-left',
|
value: 'slide-left',
|
||||||
label: t('setting.transition.list.slideLeft')
|
label: t('setting.transition.list.slideLeft')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'slide-bottom',
|
value: 'slide-bottom',
|
||||||
label: t('setting.transition.list.slideBottom')
|
label: t('setting.transition.list.slideBottom')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'slide-top',
|
value: 'slide-top',
|
||||||
label: t('setting.transition.list.slideTop')
|
label: t('setting.transition.list.slideTop')
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 圆角大小选项
|
// 圆角大小选项
|
||||||
const customRadiusOptions = [
|
const customRadiusOptions = [
|
||||||
{ value: '0', label: '0' },
|
{ value: '0', label: '0' },
|
||||||
{ value: '0.25', label: '0.25' },
|
{ value: '0.25', label: '0.25' },
|
||||||
{ value: '0.5', label: '0.5' },
|
{ value: '0.5', label: '0.5' },
|
||||||
{ value: '0.75', label: '0.75' },
|
{ value: '0.75', label: '0.75' },
|
||||||
{ value: '1', label: '1' }
|
{ value: '1', label: '1' }
|
||||||
]
|
]
|
||||||
|
|
||||||
// 容器宽度选项
|
// 容器宽度选项
|
||||||
const containerWidthOptions = computed(() => [
|
const containerWidthOptions = computed(() => [
|
||||||
{
|
{
|
||||||
value: ContainerWidthEnum.FULL,
|
value: ContainerWidthEnum.FULL,
|
||||||
label: t('setting.container.list[0]'),
|
label: t('setting.container.list[0]'),
|
||||||
icon: 'icon-park-outline:auto-width'
|
icon: 'icon-park-outline:auto-width'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ContainerWidthEnum.BOXED,
|
value: ContainerWidthEnum.BOXED,
|
||||||
label: t('setting.container.list[1]'),
|
label: t('setting.container.list[1]'),
|
||||||
icon: 'ix:width'
|
icon: 'ix:width'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 盒子样式选项
|
// 盒子样式选项
|
||||||
const boxStyleOptions = computed(() => [
|
const boxStyleOptions = computed(() => [
|
||||||
{
|
{
|
||||||
value: 'border-mode',
|
value: 'border-mode',
|
||||||
label: t('setting.box.list[0]'),
|
label: t('setting.box.list[0]'),
|
||||||
type: 'border-mode' as const
|
type: 'border-mode' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'shadow-mode',
|
value: 'shadow-mode',
|
||||||
label: t('setting.box.list[1]'),
|
label: t('setting.box.list[1]'),
|
||||||
type: 'shadow-mode' as const
|
type: 'shadow-mode' as const
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// 从配置文件获取的选项
|
// 从配置文件获取的选项
|
||||||
const configOptions = {
|
const configOptions = {
|
||||||
// 主题色彩选项
|
// 主题色彩选项
|
||||||
mainColors: AppConfig.systemMainColor,
|
mainColors: AppConfig.systemMainColor,
|
||||||
|
|
||||||
// 主题风格选项
|
// 主题风格选项
|
||||||
themeList: AppConfig.settingThemeList,
|
themeList: AppConfig.settingThemeList,
|
||||||
|
|
||||||
// 菜单布局选项
|
// 菜单布局选项
|
||||||
menuLayoutList: AppConfig.menuLayoutList
|
menuLayoutList: AppConfig.menuLayoutList
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基础设置项配置
|
// 基础设置项配置
|
||||||
const basicSettingsConfig = computed(() => {
|
const basicSettingsConfig = computed(() => {
|
||||||
// 定义所有基础设置项
|
// 定义所有基础设置项
|
||||||
const allSettings = [
|
const allSettings = [
|
||||||
{
|
{
|
||||||
key: 'showWorkTab',
|
key: 'showWorkTab',
|
||||||
label: t('setting.basics.list.multiTab'),
|
label: t('setting.basics.list.multiTab'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'workTab',
|
handler: 'workTab',
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'uniqueOpened',
|
key: 'uniqueOpened',
|
||||||
label: t('setting.basics.list.accordion'),
|
label: t('setting.basics.list.accordion'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'uniqueOpened',
|
handler: 'uniqueOpened',
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'showMenuButton',
|
key: 'showMenuButton',
|
||||||
label: t('setting.basics.list.collapseSidebar'),
|
label: t('setting.basics.list.collapseSidebar'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'menuButton',
|
handler: 'menuButton',
|
||||||
headerBarKey: 'menuButton' as const
|
headerBarKey: 'menuButton' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'showFastEnter',
|
key: 'showFastEnter',
|
||||||
label: t('setting.basics.list.fastEnter'),
|
label: t('setting.basics.list.fastEnter'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'fastEnter',
|
handler: 'fastEnter',
|
||||||
headerBarKey: 'fastEnter' as const
|
headerBarKey: 'fastEnter' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'showRefreshButton',
|
key: 'showRefreshButton',
|
||||||
label: t('setting.basics.list.reloadPage'),
|
label: t('setting.basics.list.reloadPage'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'refreshButton',
|
handler: 'refreshButton',
|
||||||
headerBarKey: 'refreshButton' as const
|
headerBarKey: 'refreshButton' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'showCrumbs',
|
key: 'showCrumbs',
|
||||||
label: t('setting.basics.list.breadcrumb'),
|
label: t('setting.basics.list.breadcrumb'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'crumbs',
|
handler: 'crumbs',
|
||||||
mobileHide: true,
|
mobileHide: true,
|
||||||
headerBarKey: 'breadcrumb' as const
|
headerBarKey: 'breadcrumb' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'showLanguage',
|
key: 'showLanguage',
|
||||||
label: t('setting.basics.list.language'),
|
label: t('setting.basics.list.language'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'language',
|
handler: 'language',
|
||||||
headerBarKey: 'language' as const
|
headerBarKey: 'language' as const
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'showNprogress',
|
key: 'showNprogress',
|
||||||
label: t('setting.basics.list.progressBar'),
|
label: t('setting.basics.list.progressBar'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'nprogress',
|
handler: 'nprogress',
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'colorWeak',
|
key: 'colorWeak',
|
||||||
label: t('setting.basics.list.weakMode'),
|
label: t('setting.basics.list.weakMode'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'colorWeak',
|
handler: 'colorWeak',
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'watermarkVisible',
|
key: 'watermarkVisible',
|
||||||
label: t('setting.basics.list.watermark'),
|
label: t('setting.basics.list.watermark'),
|
||||||
type: 'switch' as const,
|
type: 'switch' as const,
|
||||||
handler: 'watermark',
|
handler: 'watermark',
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'menuOpenWidth',
|
key: 'menuOpenWidth',
|
||||||
label: t('setting.basics.list.menuWidth'),
|
label: t('setting.basics.list.menuWidth'),
|
||||||
type: 'input-number' as const,
|
type: 'input-number' as const,
|
||||||
handler: 'menuOpenWidth',
|
handler: 'menuOpenWidth',
|
||||||
min: 180,
|
min: 180,
|
||||||
max: 320,
|
max: 320,
|
||||||
step: 10,
|
step: 10,
|
||||||
style: { width: '120px' },
|
style: { width: '120px' },
|
||||||
controlsPosition: 'right' as const,
|
controlsPosition: 'right' as const,
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'tabStyle',
|
key: 'tabStyle',
|
||||||
label: t('setting.basics.list.tabStyle'),
|
label: t('setting.basics.list.tabStyle'),
|
||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
handler: 'tabStyle',
|
handler: 'tabStyle',
|
||||||
options: tabStyleOptions.value,
|
options: tabStyleOptions.value,
|
||||||
style: { width: '120px' },
|
style: { width: '120px' },
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'pageTransition',
|
key: 'pageTransition',
|
||||||
label: t('setting.basics.list.pageTransition'),
|
label: t('setting.basics.list.pageTransition'),
|
||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
handler: 'pageTransition',
|
handler: 'pageTransition',
|
||||||
options: pageTransitionOptions.value,
|
options: pageTransitionOptions.value,
|
||||||
style: { width: '120px' },
|
style: { width: '120px' },
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'customRadius',
|
key: 'customRadius',
|
||||||
label: t('setting.basics.list.borderRadius'),
|
label: t('setting.basics.list.borderRadius'),
|
||||||
type: 'select' as const,
|
type: 'select' as const,
|
||||||
handler: 'customRadius',
|
handler: 'customRadius',
|
||||||
options: customRadiusOptions,
|
options: customRadiusOptions,
|
||||||
style: { width: '120px' },
|
style: { width: '120px' },
|
||||||
headerBarKey: null // 不依赖headerBar配置
|
headerBarKey: null // 不依赖headerBar配置
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// 根据 headerBarConfig 过滤设置项
|
// 根据 headerBarConfig 过滤设置项
|
||||||
return (
|
return (
|
||||||
allSettings
|
allSettings
|
||||||
.filter((setting) => {
|
.filter((setting) => {
|
||||||
// 如果设置项不依赖headerBar配置,则始终显示
|
// 如果设置项不依赖headerBar配置,则始终显示
|
||||||
if (setting.headerBarKey === null) {
|
if (setting.headerBarKey === null) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果依赖headerBar配置,检查对应的功能是否启用
|
// 如果依赖headerBar配置,检查对应的功能是否启用
|
||||||
const headerBarFeature = headerBarConfig[setting.headerBarKey]
|
const headerBarFeature = headerBarConfig[setting.headerBarKey]
|
||||||
return headerBarFeature?.enabled !== false
|
return headerBarFeature?.enabled !== false
|
||||||
})
|
})
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
|
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 选项配置
|
// 选项配置
|
||||||
tabStyleOptions,
|
tabStyleOptions,
|
||||||
pageTransitionOptions,
|
pageTransitionOptions,
|
||||||
customRadiusOptions,
|
customRadiusOptions,
|
||||||
containerWidthOptions,
|
containerWidthOptions,
|
||||||
boxStyleOptions,
|
boxStyleOptions,
|
||||||
configOptions,
|
configOptions,
|
||||||
|
|
||||||
// 设置项配置
|
// 设置项配置
|
||||||
basicSettingsConfig
|
basicSettingsConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+133
-133
@@ -6,162 +6,162 @@ import type { ContainerWidthEnum } from '@/enums/appEnum'
|
|||||||
* 设置项通用处理逻辑
|
* 设置项通用处理逻辑
|
||||||
*/
|
*/
|
||||||
export function useSettingsHandlers() {
|
export function useSettingsHandlers() {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
// DOM 操作相关
|
// DOM 操作相关
|
||||||
const domOperations = {
|
const domOperations = {
|
||||||
// 设置HTML类名
|
// 设置HTML类名
|
||||||
setHtmlClass: (className: string, add: boolean) => {
|
setHtmlClass: (className: string, add: boolean) => {
|
||||||
const el = document.getElementsByTagName('html')[0]
|
const el = document.getElementsByTagName('html')[0]
|
||||||
if (add) {
|
if (add) {
|
||||||
el.classList.add(className)
|
el.classList.add(className)
|
||||||
} else {
|
} else {
|
||||||
el.classList.remove(className)
|
el.classList.remove(className)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置根元素属性
|
// 设置根元素属性
|
||||||
setRootAttribute: (attribute: string, value: string) => {
|
setRootAttribute: (attribute: string, value: string) => {
|
||||||
const el = document.documentElement
|
const el = document.documentElement
|
||||||
el.setAttribute(attribute, value)
|
el.setAttribute(attribute, value)
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置body类名
|
// 设置body类名
|
||||||
setBodyClass: (className: string, add: boolean) => {
|
setBodyClass: (className: string, add: boolean) => {
|
||||||
const el = document.getElementsByTagName('body')[0]
|
const el = document.getElementsByTagName('body')[0]
|
||||||
if (add) {
|
if (add) {
|
||||||
el.classList.add(className)
|
el.classList.add(className)
|
||||||
} else {
|
} else {
|
||||||
el.classList.remove(className)
|
el.classList.remove(className)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用切换处理器
|
// 通用切换处理器
|
||||||
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
|
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
|
||||||
return () => {
|
return () => {
|
||||||
storeMethod()
|
storeMethod()
|
||||||
callback?.()
|
callback?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通用值变更处理器
|
// 通用值变更处理器
|
||||||
const createValueHandler = <T>(
|
const createValueHandler = <T>(
|
||||||
storeMethod: (value: T) => void,
|
storeMethod: (value: T) => void,
|
||||||
callback?: (value: T) => void
|
callback?: (value: T) => void
|
||||||
) => {
|
) => {
|
||||||
return (value: T) => {
|
return (value: T) => {
|
||||||
if (value !== undefined && value !== null) {
|
if (value !== undefined && value !== null) {
|
||||||
storeMethod(value)
|
storeMethod(value)
|
||||||
callback?.(value)
|
callback?.(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基础设置处理器
|
// 基础设置处理器
|
||||||
const basicHandlers = {
|
const basicHandlers = {
|
||||||
// 工作台标签页
|
// 工作台标签页
|
||||||
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
|
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
|
||||||
|
|
||||||
// 菜单手风琴
|
// 菜单手风琴
|
||||||
uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()),
|
uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()),
|
||||||
|
|
||||||
// 显示菜单按钮
|
// 显示菜单按钮
|
||||||
menuButton: createToggleHandler(() => settingStore.setButton()),
|
menuButton: createToggleHandler(() => settingStore.setButton()),
|
||||||
|
|
||||||
// 显示快速入口
|
// 显示快速入口
|
||||||
fastEnter: createToggleHandler(() => settingStore.setFastEnter()),
|
fastEnter: createToggleHandler(() => settingStore.setFastEnter()),
|
||||||
|
|
||||||
// 显示刷新按钮
|
// 显示刷新按钮
|
||||||
refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()),
|
refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()),
|
||||||
|
|
||||||
// 显示面包屑
|
// 显示面包屑
|
||||||
crumbs: createToggleHandler(() => settingStore.setCrumbs()),
|
crumbs: createToggleHandler(() => settingStore.setCrumbs()),
|
||||||
|
|
||||||
// 显示语言切换
|
// 显示语言切换
|
||||||
language: createToggleHandler(() => settingStore.setLanguage()),
|
language: createToggleHandler(() => settingStore.setLanguage()),
|
||||||
|
|
||||||
// 显示进度条
|
// 显示进度条
|
||||||
nprogress: createToggleHandler(() => settingStore.setNprogress()),
|
nprogress: createToggleHandler(() => settingStore.setNprogress()),
|
||||||
|
|
||||||
// 色弱模式
|
// 色弱模式
|
||||||
colorWeak: createToggleHandler(
|
colorWeak: createToggleHandler(
|
||||||
() => settingStore.setColorWeak(),
|
() => settingStore.setColorWeak(),
|
||||||
() => {
|
() => {
|
||||||
domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
|
domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
// 水印显示
|
// 水印显示
|
||||||
watermark: createToggleHandler(() =>
|
watermark: createToggleHandler(() =>
|
||||||
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
|
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
|
||||||
),
|
),
|
||||||
|
|
||||||
// 菜单展开宽度
|
// 菜单展开宽度
|
||||||
menuOpenWidth: createValueHandler<number>((width: number) =>
|
menuOpenWidth: createValueHandler<number>((width: number) =>
|
||||||
settingStore.setMenuOpenWidth(width)
|
settingStore.setMenuOpenWidth(width)
|
||||||
),
|
),
|
||||||
|
|
||||||
// 标签页风格
|
// 标签页风格
|
||||||
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
|
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
|
||||||
|
|
||||||
// 页面切换动画
|
// 页面切换动画
|
||||||
pageTransition: createValueHandler<string>((transition: string) =>
|
pageTransition: createValueHandler<string>((transition: string) =>
|
||||||
settingStore.setPageTransition(transition)
|
settingStore.setPageTransition(transition)
|
||||||
),
|
),
|
||||||
|
|
||||||
// 圆角大小
|
// 圆角大小
|
||||||
customRadius: createValueHandler<string>((radius: string) =>
|
customRadius: createValueHandler<string>((radius: string) =>
|
||||||
settingStore.setCustomRadius(radius)
|
settingStore.setCustomRadius(radius)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 盒子样式处理器
|
// 盒子样式处理器
|
||||||
const boxStyleHandlers = {
|
const boxStyleHandlers = {
|
||||||
// 设置盒子模式
|
// 设置盒子模式
|
||||||
setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
|
setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
|
||||||
const { boxBorderMode } = storeToRefs(settingStore)
|
const { boxBorderMode } = storeToRefs(settingStore)
|
||||||
|
|
||||||
// 防止重复设置
|
// 防止重复设置
|
||||||
if (
|
if (
|
||||||
(type === 'shadow-mode' && boxBorderMode.value === false) ||
|
(type === 'shadow-mode' && boxBorderMode.value === false) ||
|
||||||
(type === 'border-mode' && boxBorderMode.value === true)
|
(type === 'border-mode' && boxBorderMode.value === true)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
domOperations.setRootAttribute('data-box-mode', type)
|
domOperations.setRootAttribute('data-box-mode', type)
|
||||||
settingStore.setBorderMode()
|
settingStore.setBorderMode()
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 颜色设置处理器
|
// 颜色设置处理器
|
||||||
const colorHandlers = {
|
const colorHandlers = {
|
||||||
// 选择主题色
|
// 选择主题色
|
||||||
selectColor: (theme: string) => {
|
selectColor: (theme: string) => {
|
||||||
settingStore.setElementTheme(theme)
|
settingStore.setElementTheme(theme)
|
||||||
settingStore.reload()
|
settingStore.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 容器设置处理器
|
// 容器设置处理器
|
||||||
const containerHandlers = {
|
const containerHandlers = {
|
||||||
// 设置容器宽度
|
// 设置容器宽度
|
||||||
setWidth: (type: ContainerWidthEnum) => {
|
setWidth: (type: ContainerWidthEnum) => {
|
||||||
settingStore.setContainerWidth(type)
|
settingStore.setContainerWidth(type)
|
||||||
settingStore.reload()
|
settingStore.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domOperations,
|
domOperations,
|
||||||
basicHandlers,
|
basicHandlers,
|
||||||
boxStyleHandlers,
|
boxStyleHandlers,
|
||||||
colorHandlers,
|
colorHandlers,
|
||||||
containerHandlers,
|
containerHandlers,
|
||||||
createToggleHandler,
|
createToggleHandler,
|
||||||
createValueHandler
|
createValueHandler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,194 +14,194 @@ import { useSettingsHandlers } from './useSettingsHandlers'
|
|||||||
* 设置面板核心逻辑管理
|
* 设置面板核心逻辑管理
|
||||||
*/
|
*/
|
||||||
export function useSettingsPanel() {
|
export function useSettingsPanel() {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
|
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
|
||||||
|
|
||||||
// Composables
|
// Composables
|
||||||
const { openFestival, cleanup } = useCeremony()
|
const { openFestival, cleanup } = useCeremony()
|
||||||
const { setSystemTheme, setSystemAutoTheme } = useTheme()
|
const { setSystemTheme, setSystemAutoTheme } = useTheme()
|
||||||
const { initColorWeak } = useSettingsState()
|
const { initColorWeak } = useSettingsState()
|
||||||
const { domOperations } = useSettingsHandlers()
|
const { domOperations } = useSettingsHandlers()
|
||||||
|
|
||||||
// 响应式状态
|
// 响应式状态
|
||||||
const showDrawer = ref(false)
|
const showDrawer = ref(false)
|
||||||
|
|
||||||
// 使用 VueUse breakpoints 优化性能
|
// 使用 VueUse breakpoints 优化性能
|
||||||
const breakpoints = useBreakpoints({ tablet: 1000 })
|
const breakpoints = useBreakpoints({ tablet: 1000 })
|
||||||
const isMobile = breakpoints.smaller('tablet')
|
const isMobile = breakpoints.smaller('tablet')
|
||||||
|
|
||||||
// 记录窗口宽度变化前的菜单类型
|
// 记录窗口宽度变化前的菜单类型
|
||||||
const beforeMenuType = ref<MenuTypeEnum>()
|
const beforeMenuType = ref<MenuTypeEnum>()
|
||||||
const hasChangedMenu = ref(false)
|
const hasChangedMenu = ref(false)
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
|
const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
|
||||||
|
|
||||||
// 主题相关处理
|
// 主题相关处理
|
||||||
const useThemeHandlers = () => {
|
const useThemeHandlers = () => {
|
||||||
// 初始化系统颜色
|
// 初始化系统颜色
|
||||||
const initSystemColor = () => {
|
const initSystemColor = () => {
|
||||||
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
|
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
|
||||||
settingStore.setElementTheme(AppConfig.systemMainColor[0])
|
settingStore.setElementTheme(AppConfig.systemMainColor[0])
|
||||||
settingStore.reload()
|
settingStore.reload()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化系统主题
|
// 初始化系统主题
|
||||||
const initSystemTheme = () => {
|
const initSystemTheme = () => {
|
||||||
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
|
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
|
||||||
setSystemAutoTheme()
|
setSystemAutoTheme()
|
||||||
} else {
|
} else {
|
||||||
setSystemTheme(systemThemeType.value)
|
setSystemTheme(systemThemeType.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 监听系统主题变化
|
||||||
const listenerSystemTheme = () => {
|
const listenerSystemTheme = () => {
|
||||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
mediaQuery.addEventListener('change', initSystemTheme)
|
mediaQuery.addEventListener('change', initSystemTheme)
|
||||||
return () => {
|
return () => {
|
||||||
mediaQuery.removeEventListener('change', initSystemTheme)
|
mediaQuery.removeEventListener('change', initSystemTheme)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initSystemColor,
|
initSystemColor,
|
||||||
initSystemTheme,
|
initSystemTheme,
|
||||||
listenerSystemTheme
|
listenerSystemTheme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 响应式布局处理
|
// 响应式布局处理
|
||||||
const useResponsiveLayout = () => {
|
const useResponsiveLayout = () => {
|
||||||
// 使用 watch 监听断点变化,性能更优
|
// 使用 watch 监听断点变化,性能更优
|
||||||
const stopWatch = watch(
|
const stopWatch = watch(
|
||||||
isMobile,
|
isMobile,
|
||||||
(mobile: boolean) => {
|
(mobile: boolean) => {
|
||||||
if (mobile) {
|
if (mobile) {
|
||||||
// 切换到移动端布局
|
// 切换到移动端布局
|
||||||
if (!hasChangedMenu.value) {
|
if (!hasChangedMenu.value) {
|
||||||
beforeMenuType.value = menuType.value
|
beforeMenuType.value = menuType.value
|
||||||
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
|
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
|
||||||
settingStore.setMenuOpen(false)
|
settingStore.setMenuOpen(false)
|
||||||
hasChangedMenu.value = true
|
hasChangedMenu.value = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 恢复桌面端布局
|
// 恢复桌面端布局
|
||||||
if (hasChangedMenu.value && beforeMenuType.value) {
|
if (hasChangedMenu.value && beforeMenuType.value) {
|
||||||
useSettingsState().switchMenuLayouts(beforeMenuType.value)
|
useSettingsState().switchMenuLayouts(beforeMenuType.value)
|
||||||
settingStore.setMenuOpen(true)
|
settingStore.setMenuOpen(true)
|
||||||
hasChangedMenu.value = false
|
hasChangedMenu.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
return { stopWatch }
|
return { stopWatch }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抽屉控制
|
// 抽屉控制
|
||||||
const useDrawerControl = () => {
|
const useDrawerControl = () => {
|
||||||
// 用于存储 setTimeout 的 ID,以便在需要时清除
|
// 用于存储 setTimeout 的 ID,以便在需要时清除
|
||||||
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null
|
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
// 打开抽屉
|
// 打开抽屉
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
// 清除可能存在的旧定时器
|
// 清除可能存在的旧定时器
|
||||||
if (themeChangeTimer) {
|
if (themeChangeTimer) {
|
||||||
clearTimeout(themeChangeTimer)
|
clearTimeout(themeChangeTimer)
|
||||||
}
|
}
|
||||||
// 延迟添加 theme-change class,避免抽屉打开动画受影响
|
// 延迟添加 theme-change class,避免抽屉打开动画受影响
|
||||||
themeChangeTimer = setTimeout(() => {
|
themeChangeTimer = setTimeout(() => {
|
||||||
domOperations.setBodyClass('theme-change', true)
|
domOperations.setBodyClass('theme-change', true)
|
||||||
themeChangeTimer = null
|
themeChangeTimer = null
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭抽屉
|
// 关闭抽屉
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
// 清除未执行的定时器,防止关闭后才添加 class
|
// 清除未执行的定时器,防止关闭后才添加 class
|
||||||
if (themeChangeTimer) {
|
if (themeChangeTimer) {
|
||||||
clearTimeout(themeChangeTimer)
|
clearTimeout(themeChangeTimer)
|
||||||
themeChangeTimer = null
|
themeChangeTimer = null
|
||||||
}
|
}
|
||||||
// 立即移除 theme-change class
|
// 立即移除 theme-change class
|
||||||
domOperations.setBodyClass('theme-change', false)
|
domOperations.setBodyClass('theme-change', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 打开设置
|
// 打开设置
|
||||||
const openSetting = () => {
|
const openSetting = () => {
|
||||||
showDrawer.value = true
|
showDrawer.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭设置
|
// 关闭设置
|
||||||
const closeDrawer = () => {
|
const closeDrawer = () => {
|
||||||
showDrawer.value = false
|
showDrawer.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleOpen,
|
handleOpen,
|
||||||
handleClose,
|
handleClose,
|
||||||
openSetting,
|
openSetting,
|
||||||
closeDrawer
|
closeDrawer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Props 变化监听
|
// Props 变化监听
|
||||||
const usePropsWatcher = (props: { open?: boolean }) => {
|
const usePropsWatcher = (props: { open?: boolean }) => {
|
||||||
watch(
|
watch(
|
||||||
() => props.open,
|
() => props.open,
|
||||||
(val: boolean | undefined) => {
|
(val: boolean | undefined) => {
|
||||||
if (val !== undefined) {
|
if (val !== undefined) {
|
||||||
showDrawer.value = val
|
showDrawer.value = val
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化设置
|
// 初始化设置
|
||||||
const useSettingsInitializer = () => {
|
const useSettingsInitializer = () => {
|
||||||
const themeHandlers = useThemeHandlers()
|
const themeHandlers = useThemeHandlers()
|
||||||
const { openSetting } = useDrawerControl()
|
const { openSetting } = useDrawerControl()
|
||||||
const { stopWatch } = useResponsiveLayout()
|
const { stopWatch } = useResponsiveLayout()
|
||||||
let themeCleanup: (() => void) | null = null
|
let themeCleanup: (() => void) | null = null
|
||||||
|
|
||||||
const initializeSettings = () => {
|
const initializeSettings = () => {
|
||||||
mittBus.on('openSetting', openSetting)
|
mittBus.on('openSetting', openSetting)
|
||||||
themeHandlers.initSystemColor()
|
themeHandlers.initSystemColor()
|
||||||
themeCleanup = themeHandlers.listenerSystemTheme()
|
themeCleanup = themeHandlers.listenerSystemTheme()
|
||||||
initColorWeak()
|
initColorWeak()
|
||||||
|
|
||||||
// 设置盒子模式
|
// 设置盒子模式
|
||||||
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
|
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
|
||||||
domOperations.setRootAttribute('data-box-mode', boxMode)
|
domOperations.setRootAttribute('data-box-mode', boxMode)
|
||||||
|
|
||||||
themeHandlers.initSystemTheme()
|
themeHandlers.initSystemTheme()
|
||||||
openFestival()
|
openFestival()
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanupSettings = () => {
|
const cleanupSettings = () => {
|
||||||
stopWatch()
|
stopWatch()
|
||||||
themeCleanup?.()
|
themeCleanup?.()
|
||||||
cleanup()
|
cleanup()
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
initializeSettings,
|
initializeSettings,
|
||||||
cleanupSettings
|
cleanupSettings
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
showDrawer,
|
showDrawer,
|
||||||
|
|
||||||
// 方法组合
|
// 方法组合
|
||||||
useThemeHandlers,
|
useThemeHandlers,
|
||||||
useResponsiveLayout,
|
useResponsiveLayout,
|
||||||
useDrawerControl,
|
useDrawerControl,
|
||||||
usePropsWatcher,
|
usePropsWatcher,
|
||||||
useSettingsInitializer
|
useSettingsInitializer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,33 +5,33 @@ import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
|
|||||||
* 设置状态管理
|
* 设置状态管理
|
||||||
*/
|
*/
|
||||||
export function useSettingsState() {
|
export function useSettingsState() {
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
|
|
||||||
// 色弱模式初始化
|
// 色弱模式初始化
|
||||||
const initColorWeak = () => {
|
const initColorWeak = () => {
|
||||||
if (settingStore.colorWeak) {
|
if (settingStore.colorWeak) {
|
||||||
const el = document.getElementsByTagName('html')[0]
|
const el = document.getElementsByTagName('html')[0]
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
el.classList.add('color-weak')
|
el.classList.add('color-weak')
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 菜单布局切换
|
// 菜单布局切换
|
||||||
const switchMenuLayouts = (type: MenuTypeEnum) => {
|
const switchMenuLayouts = (type: MenuTypeEnum) => {
|
||||||
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
|
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
|
||||||
settingStore.setMenuOpen(true)
|
settingStore.setMenuOpen(true)
|
||||||
}
|
}
|
||||||
settingStore.switchMenuLayouts(type)
|
settingStore.switchMenuLayouts(type)
|
||||||
if (type === MenuTypeEnum.DUAL_MENU) {
|
if (type === MenuTypeEnum.DUAL_MENU) {
|
||||||
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
|
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
|
||||||
settingStore.setMenuOpen(true)
|
settingStore.setMenuOpen(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 方法
|
// 方法
|
||||||
initColorWeak,
|
initColorWeak,
|
||||||
switchMenuLayouts
|
switchMenuLayouts
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,72 +1,72 @@
|
|||||||
<!-- 设置面板 -->
|
<!-- 设置面板 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="layout-settings">
|
<div class="layout-settings">
|
||||||
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
|
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
|
||||||
<!-- 头部关闭按钮 -->
|
<!-- 头部关闭按钮 -->
|
||||||
<SettingHeader @close="closeDrawer" />
|
<SettingHeader @close="closeDrawer" />
|
||||||
<!-- 主题风格 -->
|
<!-- 主题风格 -->
|
||||||
<ThemeSettings />
|
<ThemeSettings />
|
||||||
<!-- 菜单布局 -->
|
<!-- 菜单布局 -->
|
||||||
<MenuLayoutSettings />
|
<MenuLayoutSettings />
|
||||||
<!-- 菜单风格 -->
|
<!-- 菜单风格 -->
|
||||||
<MenuStyleSettings />
|
<MenuStyleSettings />
|
||||||
<!-- 系统主题色 -->
|
<!-- 系统主题色 -->
|
||||||
<ColorSettings />
|
<ColorSettings />
|
||||||
<!-- 盒子样式 -->
|
<!-- 盒子样式 -->
|
||||||
<BoxStyleSettings />
|
<BoxStyleSettings />
|
||||||
<!-- 容器宽度 -->
|
<!-- 容器宽度 -->
|
||||||
<ContainerSettings />
|
<ContainerSettings />
|
||||||
<!-- 基础配置 -->
|
<!-- 基础配置 -->
|
||||||
<BasicSettings />
|
<BasicSettings />
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<SettingActions />
|
<SettingActions />
|
||||||
</SettingDrawer>
|
</SettingDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useSettingsPanel } from './composables/useSettingsPanel'
|
import { useSettingsPanel } from './composables/useSettingsPanel'
|
||||||
|
|
||||||
import SettingDrawer from './widget/SettingDrawer.vue'
|
import SettingDrawer from './widget/SettingDrawer.vue'
|
||||||
import SettingHeader from './widget/SettingHeader.vue'
|
import SettingHeader from './widget/SettingHeader.vue'
|
||||||
import ThemeSettings from './widget/ThemeSettings.vue'
|
import ThemeSettings from './widget/ThemeSettings.vue'
|
||||||
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
|
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
|
||||||
import MenuStyleSettings from './widget/MenuStyleSettings.vue'
|
import MenuStyleSettings from './widget/MenuStyleSettings.vue'
|
||||||
import ColorSettings from './widget/ColorSettings.vue'
|
import ColorSettings from './widget/ColorSettings.vue'
|
||||||
import BoxStyleSettings from './widget/BoxStyleSettings.vue'
|
import BoxStyleSettings from './widget/BoxStyleSettings.vue'
|
||||||
import ContainerSettings from './widget/ContainerSettings.vue'
|
import ContainerSettings from './widget/ContainerSettings.vue'
|
||||||
import BasicSettings from './widget/BasicSettings.vue'
|
import BasicSettings from './widget/BasicSettings.vue'
|
||||||
import SettingActions from './widget/SettingActions.vue'
|
import SettingActions from './widget/SettingActions.vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtSettingsPanel' })
|
defineOptions({ name: 'ArtSettingsPanel' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 是否打开 */
|
/** 是否打开 */
|
||||||
open?: boolean
|
open?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
// 使用设置面板逻辑
|
// 使用设置面板逻辑
|
||||||
const settingsPanel = useSettingsPanel()
|
const settingsPanel = useSettingsPanel()
|
||||||
const { showDrawer } = settingsPanel
|
const { showDrawer } = settingsPanel
|
||||||
|
|
||||||
// 获取各种处理器
|
// 获取各种处理器
|
||||||
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
|
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
|
||||||
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
|
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
|
||||||
|
|
||||||
// 监听 props 变化
|
// 监听 props 变化
|
||||||
settingsPanel.usePropsWatcher(props)
|
settingsPanel.usePropsWatcher(props)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
initializeSettings()
|
initializeSettings()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
cleanupSettings()
|
cleanupSettings()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use './style';
|
@use './style';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,91 +2,91 @@
|
|||||||
|
|
||||||
// 设置抽屉模态框样式
|
// 设置抽屉模态框样式
|
||||||
.setting-modal {
|
.setting-modal {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
|
||||||
.el-drawer {
|
.el-drawer {
|
||||||
// 背景滤镜效果
|
// 背景滤镜效果
|
||||||
background: rgba($color: #fff, $alpha: 50%) !important;
|
background: rgba($color: #fff, $alpha: 50%) !important;
|
||||||
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
|
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
|
||||||
|
|
||||||
@include backdropBlur();
|
@include backdropBlur();
|
||||||
|
|
||||||
.setting-box-wrap {
|
.setting-box-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: calc(100% + 15px);
|
width: calc(100% + 15px);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: calc(33.333% - 15px);
|
width: calc(33.333% - 15px);
|
||||||
margin-right: 15px;
|
margin-right: 15px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
.box {
|
.box {
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: 2px solid var(--default-border);
|
border: 2px solid var(--default-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
|
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
|
||||||
transition: box-shadow 0.1s;
|
transition: box-shadow 0.1s;
|
||||||
|
|
||||||
&.mt-16 {
|
&.mt-16 {
|
||||||
margin-top: 16px;
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
border: 2px solid var(--theme-color);
|
border: 2px solid var(--theme-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 去除滚动条
|
// 去除滚动条
|
||||||
.el-drawer__body::-webkit-scrollbar {
|
.el-drawer__body::-webkit-scrollbar {
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.setting-modal {
|
.setting-modal {
|
||||||
.el-drawer {
|
.el-drawer {
|
||||||
background: rgba($color: #000, $alpha: 50%) !important;
|
background: rgba($color: #000, $alpha: 50%) !important;
|
||||||
|
|
||||||
.setting-item {
|
.setting-item {
|
||||||
.box {
|
.box {
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 去除火狐浏览器滚动条
|
// 去除火狐浏览器滚动条
|
||||||
:deep(.el-drawer__body) {
|
:deep(.el-drawer__body) {
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端隐藏
|
// 移动端隐藏
|
||||||
@media screen and (width <= 800px) {
|
@media screen and (width <= 800px) {
|
||||||
.mobile-hide {
|
.mobile-hide {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
|
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
|
||||||
<SettingItem
|
<SettingItem
|
||||||
v-for="config in basicSettingsConfig"
|
v-for="config in basicSettingsConfig"
|
||||||
:key="config.key"
|
:key="config.key"
|
||||||
:config="config"
|
:config="config"
|
||||||
:model-value="getSettingValue(config.key)"
|
:model-value="getSettingValue(config.key)"
|
||||||
@change="handleSettingChange(config.handler, $event)"
|
@change="handleSettingChange(config.handler, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import SettingItem from './SettingItem.vue'
|
import SettingItem from './SettingItem.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { basicSettingsConfig } = useSettingsConfig()
|
const { basicSettingsConfig } = useSettingsConfig()
|
||||||
const { basicHandlers } = useSettingsHandlers()
|
const { basicHandlers } = useSettingsHandlers()
|
||||||
|
|
||||||
// 获取store的响应式状态
|
// 获取store的响应式状态
|
||||||
const {
|
const {
|
||||||
uniqueOpened,
|
uniqueOpened,
|
||||||
showMenuButton,
|
showMenuButton,
|
||||||
showFastEnter,
|
showFastEnter,
|
||||||
showRefreshButton,
|
showRefreshButton,
|
||||||
showCrumbs,
|
showCrumbs,
|
||||||
showWorkTab,
|
showWorkTab,
|
||||||
showLanguage,
|
showLanguage,
|
||||||
showNprogress,
|
showNprogress,
|
||||||
colorWeak,
|
colorWeak,
|
||||||
watermarkVisible,
|
watermarkVisible,
|
||||||
menuOpenWidth,
|
menuOpenWidth,
|
||||||
tabStyle,
|
tabStyle,
|
||||||
pageTransition,
|
pageTransition,
|
||||||
customRadius
|
customRadius
|
||||||
} = storeToRefs(settingStore)
|
} = storeToRefs(settingStore)
|
||||||
|
|
||||||
// 创建设置值映射
|
// 创建设置值映射
|
||||||
const settingValueMap = {
|
const settingValueMap = {
|
||||||
uniqueOpened,
|
uniqueOpened,
|
||||||
showMenuButton,
|
showMenuButton,
|
||||||
showFastEnter,
|
showFastEnter,
|
||||||
showRefreshButton,
|
showRefreshButton,
|
||||||
showCrumbs,
|
showCrumbs,
|
||||||
showWorkTab,
|
showWorkTab,
|
||||||
showLanguage,
|
showLanguage,
|
||||||
showNprogress,
|
showNprogress,
|
||||||
colorWeak,
|
colorWeak,
|
||||||
watermarkVisible,
|
watermarkVisible,
|
||||||
menuOpenWidth,
|
menuOpenWidth,
|
||||||
tabStyle,
|
tabStyle,
|
||||||
pageTransition,
|
pageTransition,
|
||||||
customRadius
|
customRadius
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取设置值的方法
|
// 获取设置值的方法
|
||||||
const getSettingValue = (key: string) => {
|
const getSettingValue = (key: string) => {
|
||||||
const settingRef = settingValueMap[key as keyof typeof settingValueMap]
|
const settingRef = settingValueMap[key as keyof typeof settingValueMap]
|
||||||
return settingRef?.value ?? null
|
return settingRef?.value ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 统一的设置变更处理
|
// 统一的设置变更处理
|
||||||
const handleSettingChange = (handlerName: string, value: any) => {
|
const handleSettingChange = (handlerName: string, value: any) => {
|
||||||
const handler = (basicHandlers as any)[handlerName]
|
const handler = (basicHandlers as any)[handlerName]
|
||||||
if (typeof handler === 'function') {
|
if (typeof handler === 'function') {
|
||||||
handler(value)
|
handler(value)
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Handler "${handlerName}" not found in basicHandlers`)
|
console.warn(`Handler "${handlerName}" not found in basicHandlers`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,38 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle :title="$t('setting.box.title')" class="mt-10" />
|
<SectionTitle :title="$t('setting.box.title')" class="mt-10" />
|
||||||
<div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200">
|
<div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200">
|
||||||
<div
|
<div
|
||||||
v-for="option in boxStyleOptions"
|
v-for="option in boxStyleOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200"
|
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200"
|
||||||
:class="
|
:class="
|
||||||
isActive(option.type)
|
isActive(option.type)
|
||||||
? 'text-g-800 bg-[var(--default-box-color)] dark:!text-white dark:bg-g-300'
|
? 'text-g-800 bg-[var(--default-box-color)] dark:!text-white dark:bg-g-300'
|
||||||
: 'hover:text-g-800 hover:bg-black/[0.04] dark:hover:bg-black/20'
|
: 'hover:text-g-800 hover:bg-black/[0.04] dark:hover:bg-black/20'
|
||||||
"
|
"
|
||||||
@click="boxStyleHandlers.setBoxMode(option.type)"
|
@click="boxStyleHandlers.setBoxMode(option.type)"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { boxBorderMode } = storeToRefs(settingStore)
|
const { boxBorderMode } = storeToRefs(settingStore)
|
||||||
const { boxStyleOptions } = useSettingsConfig()
|
const { boxStyleOptions } = useSettingsConfig()
|
||||||
const { boxStyleHandlers } = useSettingsHandlers()
|
const { boxStyleHandlers } = useSettingsHandlers()
|
||||||
|
|
||||||
// 判断当前选项是否激活
|
// 判断当前选项是否激活
|
||||||
const isActive = (type: 'border-mode' | 'shadow-mode') => {
|
const isActive = (type: 'border-mode' | 'shadow-mode') => {
|
||||||
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
|
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle :title="$t('setting.color.title')" class="mt-10" />
|
<SectionTitle :title="$t('setting.color.title')" class="mt-10" />
|
||||||
<div class="-mr-4">
|
<div class="-mr-4">
|
||||||
<div class="flex flex-wrap">
|
<div class="flex flex-wrap">
|
||||||
<div
|
<div
|
||||||
v-for="color in configOptions.mainColors"
|
v-for="color in configOptions.mainColors"
|
||||||
:key="color"
|
:key="color"
|
||||||
class="flex items-center justify-center size-[23px] mr-4 mb-2.5 cursor-pointer rounded-full transition-all duration-200 hover:opacity-85"
|
class="flex items-center justify-center size-[23px] mr-4 mb-2.5 cursor-pointer rounded-full transition-all duration-200 hover:opacity-85"
|
||||||
:style="{ background: `${color} !important` }"
|
:style="{ background: `${color} !important` }"
|
||||||
@click="colorHandlers.selectColor(color)"
|
@click="colorHandlers.selectColor(color)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
icon="ri:check-fill"
|
icon="ri:check-fill"
|
||||||
class="text-base !text-white"
|
class="text-base !text-white"
|
||||||
v-show="color === systemThemeColor"
|
v-show="color === systemThemeColor"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { systemThemeColor } = storeToRefs(settingStore)
|
const { systemThemeColor } = storeToRefs(settingStore)
|
||||||
const { configOptions } = useSettingsConfig()
|
const { configOptions } = useSettingsConfig()
|
||||||
const { colorHandlers } = useSettingsHandlers()
|
const { colorHandlers } = useSettingsHandlers()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,33 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
|
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div
|
<div
|
||||||
v-for="option in containerWidthOptions"
|
v-for="option in containerWidthOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer !border-2 rounded-lg !text-g-800 last:mr-0"
|
class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer !border-2 rounded-lg !text-g-800 last:mr-0"
|
||||||
:class="{
|
:class="{
|
||||||
'border-theme [&_i]:!text-theme': containerWidth === option.value,
|
'border-theme [&_i]:!text-theme': containerWidth === option.value,
|
||||||
'border-full-d': containerWidth !== option.value
|
'border-full-d': containerWidth !== option.value
|
||||||
}"
|
}"
|
||||||
@click="containerHandlers.setWidth(option.value)"
|
@click="containerHandlers.setWidth(option.value)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" />
|
<ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" />
|
||||||
<span class="text-sm">{{ option.label }}</span>
|
<span class="text-sm">{{ option.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||||
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { containerWidth } = storeToRefs(settingStore)
|
const { containerWidth } = storeToRefs(settingStore)
|
||||||
const { containerWidthOptions } = useSettingsConfig()
|
const { containerWidthOptions } = useSettingsConfig()
|
||||||
const { containerHandlers } = useSettingsHandlers()
|
const { containerHandlers } = useSettingsHandlers()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="width > 1000">
|
<div v-if="width > 1000">
|
||||||
<SectionTitle :title="$t('setting.menuType.title')" />
|
<SectionTitle :title="$t('setting.menuType.title')" />
|
||||||
<div class="setting-box-wrap">
|
<div class="setting-box-wrap">
|
||||||
<div
|
<div
|
||||||
class="setting-item"
|
class="setting-item"
|
||||||
v-for="(item, index) in configOptions.menuLayoutList"
|
v-for="(item, index) in configOptions.menuLayoutList"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
@click="switchMenuLayouts(item.value)"
|
@click="switchMenuLayouts(item.value)"
|
||||||
>
|
>
|
||||||
<div class="box" :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }">
|
<div
|
||||||
<img :src="item.img" />
|
class="box"
|
||||||
</div>
|
:class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }"
|
||||||
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
|
>
|
||||||
</div>
|
<img :src="item.img" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||||
import { useSettingsState } from '../composables/useSettingsState'
|
import { useSettingsState } from '../composables/useSettingsState'
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { menuType } = storeToRefs(settingStore)
|
const { menuType } = storeToRefs(settingStore)
|
||||||
const { configOptions } = useSettingsConfig()
|
const { configOptions } = useSettingsConfig()
|
||||||
const { switchMenuLayouts } = useSettingsState()
|
const { switchMenuLayouts } = useSettingsState()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<SectionTitle :title="$t('setting.menu.title')" />
|
<SectionTitle :title="$t('setting.menu.title')" />
|
||||||
<div class="setting-box-wrap">
|
<div class="setting-box-wrap">
|
||||||
<div
|
<div
|
||||||
class="setting-item"
|
class="setting-item"
|
||||||
v-for="item in menuThemeList"
|
v-for="item in menuThemeList"
|
||||||
:key="item.theme"
|
:key="item.theme"
|
||||||
@click="switchMenuStyles(item.theme)"
|
@click="switchMenuStyles(item.theme)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="box"
|
class="box"
|
||||||
:class="{ 'is-active': item.theme === menuThemeType }"
|
:class="{ 'is-active': item.theme === menuThemeType }"
|
||||||
:style="{
|
:style="{
|
||||||
cursor: disabled ? 'no-drop' : 'pointer'
|
cursor: disabled ? 'no-drop' : 'pointer'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<img :src="item.img" />
|
<img :src="item.img" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
|
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
|
|
||||||
const menuThemeList = AppConfig.themeList
|
const menuThemeList = AppConfig.themeList
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
|
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
|
||||||
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
|
||||||
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
|
||||||
|
|
||||||
const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value)
|
const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value)
|
||||||
|
|
||||||
// 菜单样式切换
|
// 菜单样式切换
|
||||||
const switchMenuStyles = (theme: MenuThemeEnum) => {
|
const switchMenuStyles = (theme: MenuThemeEnum) => {
|
||||||
if (isDualMenu.value || isTopMenu.value || isDark.value) {
|
if (isDualMenu.value || isTopMenu.value || isDark.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
settingStore.switchMenuStyles(theme)
|
settingStore.switchMenuStyles(theme)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<p
|
<p
|
||||||
class="relative mt-7.5 mb-5.5 text-sm text-center text-g-800 before:absolute before:top-[10px] before:left-0 before:w-[50px] before:m-auto before:content-[''] before:border-b before:border-[var(--art-gray-300)] after:absolute after:top-[10px] after:right-0 after:w-[50px] after:m-auto after:content-[''] after:border-b after:border-g-300"
|
class="relative mt-7.5 mb-5.5 text-sm text-center text-g-800 before:absolute before:top-[10px] before:left-0 before:w-[50px] before:m-auto before:content-[''] before:border-b before:border-[var(--art-gray-300)] after:absolute after:top-[10px] after:right-0 after:w-[50px] after:m-auto after:content-[''] after:border-b after:border-g-300"
|
||||||
:style="style"
|
:style="style"
|
||||||
>
|
>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
style?: Record<string, any>
|
style?: Record<string, any>
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
defineProps<Props>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,235 +1,241 @@
|
|||||||
<!-- 设置操作按钮 -->
|
<!-- 设置操作按钮 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="mt-10 flex gap-8 border-t border-[var(--default-border)] bg-[var(--art-bg-color)] pt-5"
|
class="mt-10 flex gap-8 border-t border-[var(--default-border)] bg-[var(--art-bg-color)] pt-5"
|
||||||
>
|
>
|
||||||
<ElButton type="primary" class="flex-1 !h-8" @click="handleCopyConfig">
|
<ElButton type="primary" class="flex-1 !h-8" @click="handleCopyConfig">
|
||||||
{{ $t('setting.actions.copyConfig') }}
|
{{ $t('setting.actions.copyConfig') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
|
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
|
||||||
{{ $t('setting.actions.resetConfig') }}
|
{{ $t('setting.actions.resetConfig') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick } from 'vue'
|
import { nextTick } from 'vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
|
import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
|
||||||
import { useClipboard } from '@vueuse/core'
|
import { useClipboard } from '@vueuse/core'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { MenuThemeEnum } from '@/enums/appEnum'
|
import { MenuThemeEnum } from '@/enums/appEnum'
|
||||||
import { useTheme } from '@/hooks/core/useTheme'
|
import { useTheme } from '@/hooks/core/useTheme'
|
||||||
|
|
||||||
defineOptions({ name: 'SettingActions' })
|
defineOptions({ name: 'SettingActions' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { copy, copied } = useClipboard()
|
const { copy, copied } = useClipboard()
|
||||||
const { switchThemeStyles } = useTheme()
|
const { switchThemeStyles } = useTheme()
|
||||||
|
|
||||||
/** 枚举映射表 */
|
/** 枚举映射表 */
|
||||||
const ENUM_MAPS = {
|
const ENUM_MAPS = {
|
||||||
menuType: {
|
menuType: {
|
||||||
left: 'MenuTypeEnum.LEFT',
|
left: 'MenuTypeEnum.LEFT',
|
||||||
top: 'MenuTypeEnum.TOP',
|
top: 'MenuTypeEnum.TOP',
|
||||||
'top-left': 'MenuTypeEnum.TOP_LEFT',
|
'top-left': 'MenuTypeEnum.TOP_LEFT',
|
||||||
'dual-menu': 'MenuTypeEnum.DUAL_MENU'
|
'dual-menu': 'MenuTypeEnum.DUAL_MENU'
|
||||||
},
|
},
|
||||||
systemTheme: {
|
systemTheme: {
|
||||||
auto: 'SystemThemeEnum.AUTO',
|
auto: 'SystemThemeEnum.AUTO',
|
||||||
light: 'SystemThemeEnum.LIGHT',
|
light: 'SystemThemeEnum.LIGHT',
|
||||||
dark: 'SystemThemeEnum.DARK'
|
dark: 'SystemThemeEnum.DARK'
|
||||||
},
|
},
|
||||||
menuTheme: {
|
menuTheme: {
|
||||||
design: 'MenuThemeEnum.DESIGN',
|
design: 'MenuThemeEnum.DESIGN',
|
||||||
light: 'MenuThemeEnum.LIGHT',
|
light: 'MenuThemeEnum.LIGHT',
|
||||||
dark: 'MenuThemeEnum.DARK'
|
dark: 'MenuThemeEnum.DARK'
|
||||||
},
|
},
|
||||||
containerWidth: {
|
containerWidth: {
|
||||||
'100%': 'ContainerWidthEnum.FULL',
|
'100%': 'ContainerWidthEnum.FULL',
|
||||||
'1200px': 'ContainerWidthEnum.BOXED'
|
'1200px': 'ContainerWidthEnum.BOXED'
|
||||||
}
|
}
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
/** 配置项定义 */
|
/** 配置项定义 */
|
||||||
interface ConfigItem {
|
interface ConfigItem {
|
||||||
comment: string
|
comment: string
|
||||||
key: keyof typeof settingStore
|
key: keyof typeof settingStore
|
||||||
enumMap?: Record<string, string>
|
enumMap?: Record<string, string>
|
||||||
forceValue?: any
|
forceValue?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_ITEMS: ConfigItem[] = [
|
const CONFIG_ITEMS: ConfigItem[] = [
|
||||||
{ comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType },
|
{ comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType },
|
||||||
{ comment: '菜单展开宽度', key: 'menuOpenWidth' },
|
{ comment: '菜单展开宽度', key: 'menuOpenWidth' },
|
||||||
{ comment: '菜单是否展开', key: 'menuOpen' },
|
{ comment: '菜单是否展开', key: 'menuOpen' },
|
||||||
{ comment: '双菜单是否显示文本', key: 'dualMenuShowText' },
|
{ comment: '双菜单是否显示文本', key: 'dualMenuShowText' },
|
||||||
{ comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme },
|
{ comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme },
|
||||||
{ comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme },
|
{ comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme },
|
||||||
{ comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme },
|
{ comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme },
|
||||||
{ comment: '系统主题颜色', key: 'systemThemeColor' },
|
{ comment: '系统主题颜色', key: 'systemThemeColor' },
|
||||||
{ comment: '是否显示菜单按钮', key: 'showMenuButton' },
|
{ comment: '是否显示菜单按钮', key: 'showMenuButton' },
|
||||||
{ comment: '是否显示快速入口', key: 'showFastEnter' },
|
{ comment: '是否显示快速入口', key: 'showFastEnter' },
|
||||||
{ comment: '是否显示刷新按钮', key: 'showRefreshButton' },
|
{ comment: '是否显示刷新按钮', key: 'showRefreshButton' },
|
||||||
{ comment: '是否显示面包屑', key: 'showCrumbs' },
|
{ comment: '是否显示面包屑', key: 'showCrumbs' },
|
||||||
{ comment: '是否显示工作台标签', key: 'showWorkTab' },
|
{ comment: '是否显示工作台标签', key: 'showWorkTab' },
|
||||||
{ comment: '是否显示语言切换', key: 'showLanguage' },
|
{ comment: '是否显示语言切换', key: 'showLanguage' },
|
||||||
{ comment: '是否显示进度条', key: 'showNprogress' },
|
{ comment: '是否显示进度条', key: 'showNprogress' },
|
||||||
{ comment: '是否显示设置引导', key: 'showSettingGuide' },
|
{ comment: '是否显示设置引导', key: 'showSettingGuide' },
|
||||||
{ comment: '是否显示节日文本', key: 'showFestivalText' },
|
{ comment: '是否显示节日文本', key: 'showFestivalText' },
|
||||||
{ comment: '是否显示水印', key: 'watermarkVisible' },
|
{ comment: '是否显示水印', key: 'watermarkVisible' },
|
||||||
{ comment: '是否自动关闭', key: 'autoClose' },
|
{ comment: '是否自动关闭', key: 'autoClose' },
|
||||||
{ comment: '是否唯一展开', key: 'uniqueOpened' },
|
{ comment: '是否唯一展开', key: 'uniqueOpened' },
|
||||||
{ comment: '是否色弱模式', key: 'colorWeak' },
|
{ comment: '是否色弱模式', key: 'colorWeak' },
|
||||||
{ comment: '是否刷新', key: 'refresh' },
|
{ comment: '是否刷新', key: 'refresh' },
|
||||||
{ comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' },
|
{ comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' },
|
||||||
{ comment: '边框模式', key: 'boxBorderMode' },
|
{ comment: '边框模式', key: 'boxBorderMode' },
|
||||||
{ comment: '页面过渡效果', key: 'pageTransition' },
|
{ comment: '页面过渡效果', key: 'pageTransition' },
|
||||||
{ comment: '标签页样式', key: 'tabStyle' },
|
{ comment: '标签页样式', key: 'tabStyle' },
|
||||||
{ comment: '自定义圆角', key: 'customRadius' },
|
{ comment: '自定义圆角', key: 'customRadius' },
|
||||||
{ comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth },
|
{ comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth },
|
||||||
{ comment: '节日日期', key: 'festivalDate', forceValue: '' }
|
{ comment: '节日日期', key: 'festivalDate', forceValue: '' }
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将值转换为代码字符串
|
* 将值转换为代码字符串
|
||||||
*/
|
*/
|
||||||
const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
|
const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
|
||||||
if (value === null) return 'null'
|
if (value === null) return 'null'
|
||||||
if (value === undefined) return 'undefined'
|
if (value === undefined) return 'undefined'
|
||||||
|
|
||||||
// 优先查找枚举映射
|
// 优先查找枚举映射
|
||||||
if (enumMap && typeof value === 'string' && enumMap[value]) {
|
if (enumMap && typeof value === 'string' && enumMap[value]) {
|
||||||
return enumMap[value]
|
return enumMap[value]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 其他类型处理
|
// 其他类型处理
|
||||||
if (typeof value === 'string') return `'${value}'`
|
if (typeof value === 'string') return `'${value}'`
|
||||||
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
|
||||||
|
|
||||||
return JSON.stringify(value)
|
return JSON.stringify(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成配置代码
|
* 生成配置代码
|
||||||
*/
|
*/
|
||||||
const generateConfigCode = (): string => {
|
const generateConfigCode = (): string => {
|
||||||
const lines = ['export const SETTING_DEFAULT_CONFIG = {']
|
const lines = ['export const SETTING_DEFAULT_CONFIG = {']
|
||||||
|
|
||||||
CONFIG_ITEMS.forEach((item) => {
|
CONFIG_ITEMS.forEach((item) => {
|
||||||
lines.push(` /** ${item.comment} */`)
|
lines.push(` /** ${item.comment} */`)
|
||||||
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key]
|
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key]
|
||||||
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`)
|
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`)
|
||||||
})
|
})
|
||||||
|
|
||||||
lines.push('}')
|
lines.push('}')
|
||||||
return lines.join('\n')
|
return lines.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 复制配置到剪贴板
|
* 复制配置到剪贴板
|
||||||
*/
|
*/
|
||||||
const handleCopyConfig = async () => {
|
const handleCopyConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const configText = generateConfigCode()
|
const configText = generateConfigCode()
|
||||||
await copy(configText)
|
await copy(configText)
|
||||||
|
|
||||||
if (copied.value) {
|
if (copied.value) {
|
||||||
ElMessage.success({
|
ElMessage.success({
|
||||||
message: t('setting.actions.copySuccess'),
|
message: t('setting.actions.copySuccess'),
|
||||||
duration: 3000
|
duration: 3000
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('复制配置失败:', error)
|
console.error('复制配置失败:', error)
|
||||||
ElMessage.error(t('setting.actions.copyFailed'))
|
ElMessage.error(t('setting.actions.copyFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换布尔值配置(如果当前值与默认值不同)
|
* 切换布尔值配置(如果当前值与默认值不同)
|
||||||
*/
|
*/
|
||||||
const toggleIfDifferent = (
|
const toggleIfDifferent = (
|
||||||
currentValue: boolean,
|
currentValue: boolean,
|
||||||
defaultValue: boolean,
|
defaultValue: boolean,
|
||||||
toggleFn: () => void
|
toggleFn: () => void
|
||||||
) => {
|
) => {
|
||||||
if (currentValue !== defaultValue) {
|
if (currentValue !== defaultValue) {
|
||||||
toggleFn()
|
toggleFn()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 重置配置为默认值
|
* 重置配置为默认值
|
||||||
*/
|
*/
|
||||||
const handleResetConfig = async () => {
|
const handleResetConfig = async () => {
|
||||||
try {
|
try {
|
||||||
const config = SETTING_DEFAULT_CONFIG
|
const config = SETTING_DEFAULT_CONFIG
|
||||||
|
|
||||||
// 菜单相关
|
// 菜单相关
|
||||||
settingStore.switchMenuLayouts(config.menuType)
|
settingStore.switchMenuLayouts(config.menuType)
|
||||||
settingStore.setMenuOpenWidth(config.menuOpenWidth)
|
settingStore.setMenuOpenWidth(config.menuOpenWidth)
|
||||||
settingStore.setMenuOpen(config.menuOpen)
|
settingStore.setMenuOpen(config.menuOpen)
|
||||||
settingStore.setDualMenuShowText(config.dualMenuShowText)
|
settingStore.setDualMenuShowText(config.dualMenuShowText)
|
||||||
|
|
||||||
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
|
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
|
||||||
switchThemeStyles(config.systemThemeMode)
|
switchThemeStyles(config.systemThemeMode)
|
||||||
|
|
||||||
// 等待主题切换完成后,根据实际应用的主题设置菜单主题
|
// 等待主题切换完成后,根据实际应用的主题设置菜单主题
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType
|
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType
|
||||||
settingStore.switchMenuStyles(menuTheme)
|
settingStore.switchMenuStyles(menuTheme)
|
||||||
|
|
||||||
settingStore.setElementTheme(config.systemThemeColor)
|
settingStore.setElementTheme(config.systemThemeColor)
|
||||||
|
|
||||||
// 界面显示(切换类方法)
|
// 界面显示(切换类方法)
|
||||||
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
|
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
|
||||||
settingStore.setButton()
|
settingStore.setButton()
|
||||||
)
|
)
|
||||||
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
|
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
|
||||||
settingStore.setFastEnter()
|
settingStore.setFastEnter()
|
||||||
)
|
)
|
||||||
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
|
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
|
||||||
settingStore.setShowRefreshButton()
|
settingStore.setShowRefreshButton()
|
||||||
)
|
)
|
||||||
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () => settingStore.setCrumbs())
|
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () =>
|
||||||
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
|
settingStore.setCrumbs()
|
||||||
settingStore.setLanguage()
|
)
|
||||||
)
|
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
|
||||||
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
|
settingStore.setLanguage()
|
||||||
settingStore.setNprogress()
|
)
|
||||||
)
|
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
|
||||||
|
settingStore.setNprogress()
|
||||||
|
)
|
||||||
|
|
||||||
// 界面显示(直接设置类方法)
|
// 界面显示(直接设置类方法)
|
||||||
settingStore.setWorkTab(config.showWorkTab)
|
settingStore.setWorkTab(config.showWorkTab)
|
||||||
settingStore.setShowFestivalText(config.showFestivalText)
|
settingStore.setShowFestivalText(config.showFestivalText)
|
||||||
settingStore.setWatermarkVisible(config.watermarkVisible)
|
settingStore.setWatermarkVisible(config.watermarkVisible)
|
||||||
|
|
||||||
// 功能设置
|
// 功能设置
|
||||||
toggleIfDifferent(settingStore.autoClose, config.autoClose, () => settingStore.setAutoClose())
|
toggleIfDifferent(settingStore.autoClose, config.autoClose, () =>
|
||||||
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
|
settingStore.setAutoClose()
|
||||||
settingStore.setUniqueOpened()
|
)
|
||||||
)
|
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
|
||||||
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () => settingStore.setColorWeak())
|
settingStore.setUniqueOpened()
|
||||||
|
)
|
||||||
|
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () =>
|
||||||
|
settingStore.setColorWeak()
|
||||||
|
)
|
||||||
|
|
||||||
// 样式设置
|
// 样式设置
|
||||||
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
|
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
|
||||||
settingStore.setBorderMode()
|
settingStore.setBorderMode()
|
||||||
)
|
)
|
||||||
settingStore.setPageTransition(config.pageTransition)
|
settingStore.setPageTransition(config.pageTransition)
|
||||||
settingStore.setTabStyle(config.tabStyle)
|
settingStore.setTabStyle(config.tabStyle)
|
||||||
settingStore.setCustomRadius(config.customRadius)
|
settingStore.setCustomRadius(config.customRadius)
|
||||||
settingStore.setContainerWidth(config.containerWidth)
|
settingStore.setContainerWidth(config.containerWidth)
|
||||||
|
|
||||||
// 节日相关
|
// 节日相关
|
||||||
settingStore.setFestivalDate(config.festivalDate)
|
settingStore.setFestivalDate(config.festivalDate)
|
||||||
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded)
|
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded)
|
||||||
|
|
||||||
location.reload()
|
location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重置配置失败:', error)
|
console.error('重置配置失败:', error)
|
||||||
ElMessage.error(t('setting.actions.resetFailed'))
|
ElMessage.error(t('setting.actions.resetFailed'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,51 +1,51 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="setting-drawer">
|
<div class="setting-drawer">
|
||||||
<ElDrawer
|
<ElDrawer
|
||||||
size="300px"
|
size="300px"
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
:lock-scroll="true"
|
:lock-scroll="true"
|
||||||
:with-header="false"
|
:with-header="false"
|
||||||
:before-close="handleClose"
|
:before-close="handleClose"
|
||||||
:destroy-on-close="false"
|
:destroy-on-close="false"
|
||||||
modal-class="setting-modal"
|
modal-class="setting-modal"
|
||||||
@open="handleOpen"
|
@open="handleOpen"
|
||||||
@close="handleDrawerClose"
|
@close="handleDrawerClose"
|
||||||
>
|
>
|
||||||
<div class="drawer-con">
|
<div class="drawer-con">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</ElDrawer>
|
</ElDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'update:modelValue', value: boolean): void
|
(e: 'update:modelValue', value: boolean): void
|
||||||
(e: 'open'): void
|
(e: 'open'): void
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value: boolean) => emit('update:modelValue', value)
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
emit('open')
|
emit('open')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrawerClose = () => {
|
const handleDrawerClose = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
visible.value = false
|
visible.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<div
|
<div
|
||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
class="flex-cc c-p size-7.5 !transition-all duration-200 rounded hover:bg-g-300/80"
|
class="flex-cc c-p size-7.5 !transition-all duration-200 rounded hover:bg-g-300/80"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" />
|
<ArtSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,101 +1,105 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
|
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
|
||||||
<span class="text-sm">{{ config.label }}</span>
|
<span class="text-sm">{{ config.label }}</span>
|
||||||
|
|
||||||
<!-- 开关类型 -->
|
<!-- 开关类型 -->
|
||||||
<ElSwitch v-if="config.type === 'switch'" :model-value="modelValue" @change="handleChange" />
|
<ElSwitch
|
||||||
|
v-if="config.type === 'switch'"
|
||||||
|
:model-value="modelValue"
|
||||||
|
@change="handleChange"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 数字输入类型 -->
|
<!-- 数字输入类型 -->
|
||||||
<ElInputNumber
|
<ElInputNumber
|
||||||
v-else-if="config.type === 'input-number'"
|
v-else-if="config.type === 'input-number'"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:min="config.min"
|
:min="config.min"
|
||||||
:max="config.max"
|
:max="config.max"
|
||||||
:step="config.step"
|
:step="config.step"
|
||||||
:style="config.style"
|
:style="config.style"
|
||||||
:controls-position="config.controlsPosition"
|
:controls-position="config.controlsPosition"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 选择器类型 -->
|
<!-- 选择器类型 -->
|
||||||
<ElSelect
|
<ElSelect
|
||||||
v-else-if="config.type === 'select'"
|
v-else-if="config.type === 'select'"
|
||||||
:model-value="modelValue"
|
:model-value="modelValue"
|
||||||
:style="config.style"
|
:style="config.style"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
>
|
>
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="option in normalizedOptions"
|
v-for="option in normalizedOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:label="option.label"
|
:label="option.label"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ComputedRef } from 'vue'
|
import type { ComputedRef } from 'vue'
|
||||||
|
|
||||||
interface SettingItemConfig {
|
interface SettingItemConfig {
|
||||||
key: string
|
key: string
|
||||||
label: string
|
label: string
|
||||||
type: 'switch' | 'input-number' | 'select'
|
type: 'switch' | 'input-number' | 'select'
|
||||||
handler: string
|
handler: string
|
||||||
mobileHide?: boolean
|
mobileHide?: boolean
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
step?: number
|
step?: number
|
||||||
style?: Record<string, string>
|
style?: Record<string, string>
|
||||||
controlsPosition?: '' | 'right'
|
controlsPosition?: '' | 'right'
|
||||||
options?:
|
options?:
|
||||||
| Array<{ value: any; label: string }>
|
| Array<{ value: any; label: string }>
|
||||||
| ComputedRef<Array<{ value: any; label: string }>>
|
| ComputedRef<Array<{ value: any; label: string }>>
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: SettingItemConfig
|
config: SettingItemConfig
|
||||||
modelValue: any
|
modelValue: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'change', value: any): void
|
(e: 'change', value: any): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const emit = defineEmits<Emits>()
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
// 标准化选项,处理computed和普通数组
|
// 标准化选项,处理computed和普通数组
|
||||||
const normalizedOptions = computed(() => {
|
const normalizedOptions = computed(() => {
|
||||||
if (!props.config.options) return []
|
if (!props.config.options) return []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 如果是 ComputedRef,则返回其值
|
// 如果是 ComputedRef,则返回其值
|
||||||
if (typeof props.config.options === 'object' && 'value' in props.config.options) {
|
if (typeof props.config.options === 'object' && 'value' in props.config.options) {
|
||||||
return props.config.options.value || []
|
return props.config.options.value || []
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是普通数组,直接返回
|
// 如果是普通数组,直接返回
|
||||||
return Array.isArray(props.config.options) ? props.config.options : []
|
return Array.isArray(props.config.options) ? props.config.options : []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Error processing options for config:', props.config.key, error)
|
console.warn('Error processing options for config:', props.config.key, error)
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleChange = (value: any) => {
|
const handleChange = (value: any) => {
|
||||||
try {
|
try {
|
||||||
emit('change', value)
|
emit('change', value)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error handling change for config:', props.config.key, error)
|
console.error('Error handling change for config:', props.config.key, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@media screen and (width <= 768px) {
|
@media screen and (width <= 768px) {
|
||||||
.mobile-hide {
|
.mobile-hide {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<SectionTitle :title="$t('setting.theme.title')" />
|
<SectionTitle :title="$t('setting.theme.title')" />
|
||||||
<div class="setting-box-wrap">
|
<div class="setting-box-wrap">
|
||||||
<div
|
<div
|
||||||
class="setting-item"
|
class="setting-item"
|
||||||
v-for="(item, index) in configOptions.themeList"
|
v-for="(item, index) in configOptions.themeList"
|
||||||
:key="item.theme"
|
:key="item.theme"
|
||||||
@click="switchThemeStyles(item.theme)"
|
@click="switchThemeStyles(item.theme)"
|
||||||
>
|
>
|
||||||
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
|
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
|
||||||
<img :src="item.img" />
|
<img :src="item.img" />
|
||||||
</div>
|
</div>
|
||||||
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
|
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SectionTitle from './SectionTitle.vue'
|
import SectionTitle from './SectionTitle.vue'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
import { useSettingsConfig } from '../composables/useSettingsConfig'
|
||||||
import { useTheme } from '@/hooks/core/useTheme'
|
import { useTheme } from '@/hooks/core/useTheme'
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { systemThemeMode } = storeToRefs(settingStore)
|
const { systemThemeMode } = storeToRefs(settingStore)
|
||||||
const { configOptions } = useSettingsConfig()
|
const { configOptions } = useSettingsConfig()
|
||||||
const { switchThemeStyles } = useTheme()
|
const { switchThemeStyles } = useTheme()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,350 +1,350 @@
|
|||||||
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
|
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
|
||||||
<template>
|
<template>
|
||||||
<div class="cutter-container">
|
<div class="cutter-container">
|
||||||
<div class="cutter-component">
|
<div class="cutter-component">
|
||||||
<div class="title">{{ title }}</div>
|
<div class="title">{{ title }}</div>
|
||||||
<ImgCutter
|
<ImgCutter
|
||||||
ref="imgCutterModal"
|
ref="imgCutterModal"
|
||||||
@cutDown="cutDownImg"
|
@cutDown="cutDownImg"
|
||||||
@onPrintImg="cutterPrintImg"
|
@onPrintImg="cutterPrintImg"
|
||||||
@onImageLoadComplete="handleImageLoadComplete"
|
@onImageLoadComplete="handleImageLoadComplete"
|
||||||
@onImageLoadError="handleImageLoadError"
|
@onImageLoadError="handleImageLoadError"
|
||||||
@onClearAll="handleClearAll"
|
@onClearAll="handleClearAll"
|
||||||
v-bind="cutterProps"
|
v-bind="cutterProps"
|
||||||
class="img-cutter"
|
class="img-cutter"
|
||||||
>
|
>
|
||||||
<template #choose>
|
<template #choose>
|
||||||
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
|
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
|
||||||
</template>
|
</template>
|
||||||
<template #cancel>
|
<template #cancel>
|
||||||
<ElButton type="danger" plain v-ripple>清除</ElButton>
|
<ElButton type="danger" plain v-ripple>清除</ElButton>
|
||||||
</template>
|
</template>
|
||||||
<template #confirm>
|
<template #confirm>
|
||||||
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
|
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
|
||||||
<div></div>
|
<div></div>
|
||||||
</template>
|
</template>
|
||||||
</ImgCutter>
|
</ImgCutter>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showPreview" class="preview-container">
|
<div v-if="showPreview" class="preview-container">
|
||||||
<div class="title">{{ previewTitle }}</div>
|
<div class="title">{{ previewTitle }}</div>
|
||||||
<div
|
<div
|
||||||
class="preview-box"
|
class="preview-box"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${cutterProps.cutWidth}px`,
|
width: `${cutterProps.cutWidth}px`,
|
||||||
height: `${cutterProps.cutHeight}px`
|
height: `${cutterProps.cutHeight}px`
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
|
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
|
||||||
</div>
|
</div>
|
||||||
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
|
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
|
||||||
>下载图片</ElButton
|
>下载图片</ElButton
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ImgCutter from 'vue-img-cutter'
|
import ImgCutter from 'vue-img-cutter'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtCutterImg' })
|
defineOptions({ name: 'ArtCutterImg' })
|
||||||
|
|
||||||
interface CutterProps {
|
interface CutterProps {
|
||||||
// 基础配置
|
// 基础配置
|
||||||
/** 是否模态框 */
|
/** 是否模态框 */
|
||||||
isModal?: boolean
|
isModal?: boolean
|
||||||
/** 是否显示工具栏 */
|
/** 是否显示工具栏 */
|
||||||
tool?: boolean
|
tool?: boolean
|
||||||
/** 工具栏背景色 */
|
/** 工具栏背景色 */
|
||||||
toolBgc?: string
|
toolBgc?: string
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title?: string
|
title?: string
|
||||||
/** 预览标题 */
|
/** 预览标题 */
|
||||||
previewTitle?: string
|
previewTitle?: string
|
||||||
/** 是否显示预览 */
|
/** 是否显示预览 */
|
||||||
showPreview?: boolean
|
showPreview?: boolean
|
||||||
|
|
||||||
// 尺寸相关
|
// 尺寸相关
|
||||||
/** 容器宽度 */
|
/** 容器宽度 */
|
||||||
boxWidth?: number
|
boxWidth?: number
|
||||||
/** 容器高度 */
|
/** 容器高度 */
|
||||||
boxHeight?: number
|
boxHeight?: number
|
||||||
/** 裁剪宽度 */
|
/** 裁剪宽度 */
|
||||||
cutWidth?: number
|
cutWidth?: number
|
||||||
/** 裁剪高度 */
|
/** 裁剪高度 */
|
||||||
cutHeight?: number
|
cutHeight?: number
|
||||||
/** 是否允许大小调整 */
|
/** 是否允许大小调整 */
|
||||||
sizeChange?: boolean
|
sizeChange?: boolean
|
||||||
|
|
||||||
// 移动和缩放
|
// 移动和缩放
|
||||||
/** 是否允许移动 */
|
/** 是否允许移动 */
|
||||||
moveAble?: boolean
|
moveAble?: boolean
|
||||||
/** 是否允许图片移动 */
|
/** 是否允许图片移动 */
|
||||||
imgMove?: boolean
|
imgMove?: boolean
|
||||||
/** 是否允许缩放 */
|
/** 是否允许缩放 */
|
||||||
scaleAble?: boolean
|
scaleAble?: boolean
|
||||||
|
|
||||||
// 图片相关
|
// 图片相关
|
||||||
/** 是否显示原始图片 */
|
/** 是否显示原始图片 */
|
||||||
originalGraph?: boolean
|
originalGraph?: boolean
|
||||||
/** 是否允许跨域 */
|
/** 是否允许跨域 */
|
||||||
crossOrigin?: boolean
|
crossOrigin?: boolean
|
||||||
/** 文件类型 */
|
/** 文件类型 */
|
||||||
fileType?: 'png' | 'jpeg' | 'webp'
|
fileType?: 'png' | 'jpeg' | 'webp'
|
||||||
/** 质量 */
|
/** 质量 */
|
||||||
quality?: number
|
quality?: number
|
||||||
|
|
||||||
// 水印
|
// 水印
|
||||||
/** 水印文本 */
|
/** 水印文本 */
|
||||||
watermarkText?: string
|
watermarkText?: string
|
||||||
/** 水印字体大小 */
|
/** 水印字体大小 */
|
||||||
watermarkFontSize?: number
|
watermarkFontSize?: number
|
||||||
/** 水印颜色 */
|
/** 水印颜色 */
|
||||||
watermarkColor?: string
|
watermarkColor?: string
|
||||||
|
|
||||||
// 其他功能
|
// 其他功能
|
||||||
/** 是否保存裁剪位置 */
|
/** 是否保存裁剪位置 */
|
||||||
saveCutPosition?: boolean
|
saveCutPosition?: boolean
|
||||||
/** 是否预览模式 */
|
/** 是否预览模式 */
|
||||||
previewMode?: boolean
|
previewMode?: boolean
|
||||||
|
|
||||||
// 输入图片
|
// 输入图片
|
||||||
imgUrl?: string
|
imgUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CutterResult {
|
interface CutterResult {
|
||||||
fileName: string
|
fileName: string
|
||||||
file: File
|
file: File
|
||||||
blob: Blob
|
blob: Blob
|
||||||
dataURL: string
|
dataURL: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<CutterProps>(), {
|
const props = withDefaults(defineProps<CutterProps>(), {
|
||||||
// 基础配置默认值
|
// 基础配置默认值
|
||||||
isModal: false,
|
isModal: false,
|
||||||
tool: true,
|
tool: true,
|
||||||
toolBgc: '#fff',
|
toolBgc: '#fff',
|
||||||
title: '',
|
title: '',
|
||||||
previewTitle: '',
|
previewTitle: '',
|
||||||
showPreview: true,
|
showPreview: true,
|
||||||
|
|
||||||
// 尺寸相关默认值
|
// 尺寸相关默认值
|
||||||
boxWidth: 700,
|
boxWidth: 700,
|
||||||
boxHeight: 458,
|
boxHeight: 458,
|
||||||
cutWidth: 470,
|
cutWidth: 470,
|
||||||
cutHeight: 270,
|
cutHeight: 270,
|
||||||
sizeChange: true,
|
sizeChange: true,
|
||||||
|
|
||||||
// 移动和缩放默认值
|
// 移动和缩放默认值
|
||||||
moveAble: true,
|
moveAble: true,
|
||||||
imgMove: true,
|
imgMove: true,
|
||||||
scaleAble: true,
|
scaleAble: true,
|
||||||
|
|
||||||
// 图片相关默认值
|
// 图片相关默认值
|
||||||
originalGraph: true,
|
originalGraph: true,
|
||||||
crossOrigin: true,
|
crossOrigin: true,
|
||||||
fileType: 'png',
|
fileType: 'png',
|
||||||
quality: 0.9,
|
quality: 0.9,
|
||||||
|
|
||||||
// 水印默认值
|
// 水印默认值
|
||||||
watermarkText: '',
|
watermarkText: '',
|
||||||
watermarkFontSize: 20,
|
watermarkFontSize: 20,
|
||||||
watermarkColor: '#ffffff',
|
watermarkColor: '#ffffff',
|
||||||
|
|
||||||
// 其他功能默认值
|
// 其他功能默认值
|
||||||
saveCutPosition: true,
|
saveCutPosition: true,
|
||||||
previewMode: true
|
previewMode: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
|
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
|
||||||
|
|
||||||
const temImgPath = ref('')
|
const temImgPath = ref('')
|
||||||
const imgCutterModal = ref()
|
const imgCutterModal = ref()
|
||||||
|
|
||||||
// 计算属性:整合所有ImgCutter的props
|
// 计算属性:整合所有ImgCutter的props
|
||||||
const cutterProps = computed(() => ({
|
const cutterProps = computed(() => ({
|
||||||
...props,
|
...props,
|
||||||
WatermarkText: props.watermarkText,
|
WatermarkText: props.watermarkText,
|
||||||
WatermarkFontSize: props.watermarkFontSize,
|
WatermarkFontSize: props.watermarkFontSize,
|
||||||
WatermarkColor: props.watermarkColor
|
WatermarkColor: props.watermarkColor
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 图片预加载
|
// 图片预加载
|
||||||
function preloadImage(url: string): Promise<void> {
|
function preloadImage(url: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const img = new Image()
|
const img = new Image()
|
||||||
img.crossOrigin = 'anonymous'
|
img.crossOrigin = 'anonymous'
|
||||||
img.onload = () => resolve()
|
img.onload = () => resolve()
|
||||||
img.onerror = reject
|
img.onerror = reject
|
||||||
img.src = url
|
img.src = url
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化裁剪器
|
// 初始化裁剪器
|
||||||
async function initImgCutter() {
|
async function initImgCutter() {
|
||||||
if (props.imgUrl) {
|
if (props.imgUrl) {
|
||||||
try {
|
try {
|
||||||
await preloadImage(props.imgUrl)
|
await preloadImage(props.imgUrl)
|
||||||
imgCutterModal.value?.handleOpen({
|
imgCutterModal.value?.handleOpen({
|
||||||
name: '封面图片',
|
name: '封面图片',
|
||||||
src: props.imgUrl
|
src: props.imgUrl
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
emit('error', error)
|
emit('error', error)
|
||||||
console.error('图片加载失败:', error)
|
console.error('图片加载失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生命周期钩子
|
// 生命周期钩子
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.imgUrl) {
|
if (props.imgUrl) {
|
||||||
temImgPath.value = props.imgUrl
|
temImgPath.value = props.imgUrl
|
||||||
initImgCutter()
|
initImgCutter()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听图片URL变化
|
// 监听图片URL变化
|
||||||
watch(
|
watch(
|
||||||
() => props.imgUrl,
|
() => props.imgUrl,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
temImgPath.value = newVal
|
temImgPath.value = newVal
|
||||||
initImgCutter()
|
initImgCutter()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 实时预览
|
// 实时预览
|
||||||
function cutterPrintImg(result: { dataURL: string }) {
|
function cutterPrintImg(result: { dataURL: string }) {
|
||||||
temImgPath.value = result.dataURL
|
temImgPath.value = result.dataURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// 裁剪完成
|
// 裁剪完成
|
||||||
function cutDownImg(result: CutterResult) {
|
function cutDownImg(result: CutterResult) {
|
||||||
emit('update:imgUrl', result.dataURL)
|
emit('update:imgUrl', result.dataURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片加载完成
|
// 图片加载完成
|
||||||
function handleImageLoadComplete(result: any) {
|
function handleImageLoadComplete(result: any) {
|
||||||
emit('imageLoadComplete', result)
|
emit('imageLoadComplete', result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 图片加载失败
|
// 图片加载失败
|
||||||
function handleImageLoadError(error: any) {
|
function handleImageLoadError(error: any) {
|
||||||
emit('error', error)
|
emit('error', error)
|
||||||
emit('imageLoadError', error)
|
emit('imageLoadError', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除所有
|
// 清除所有
|
||||||
function handleClearAll() {
|
function handleClearAll() {
|
||||||
temImgPath.value = ''
|
temImgPath.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 下载图片
|
// 下载图片
|
||||||
function downloadImg() {
|
function downloadImg() {
|
||||||
console.log('下载图片')
|
console.log('下载图片')
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = temImgPath.value
|
a.href = temImgPath.value
|
||||||
a.download = 'image.png'
|
a.download = 'image.png'
|
||||||
a.click()
|
a.click()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.cutter-container {
|
.cutter-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-flow: row wrap;
|
flex-flow: row wrap;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cutter-component {
|
.cutter-component {
|
||||||
margin-right: 30px;
|
margin-right: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preview-container {
|
.preview-container {
|
||||||
.preview-box {
|
.preview-box {
|
||||||
background-color: var(--art-active-color) !important;
|
background-color: var(--art-active-color) !important;
|
||||||
|
|
||||||
.preview-img {
|
.preview-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-btn {
|
.download-btn {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 20px auto;
|
margin: 20px auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.toolBoxControl) {
|
:deep(.toolBoxControl) {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dockMain) {
|
:deep(.dockMain) {
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: -40px;
|
bottom: -40px;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.copyright) {
|
:deep(.copyright) {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.i-dialog-footer) {
|
:deep(.i-dialog-footer) {
|
||||||
margin-top: 60px !important;
|
margin-top: 60px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dockBtn) {
|
:deep(.dockBtn) {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 26px;
|
line-height: 26px;
|
||||||
color: var(--el-color-primary) !important;
|
color: var(--el-color-primary) !important;
|
||||||
background-color: var(--el-color-primary-light-9) !important;
|
background-color: var(--el-color-primary-light-9) !important;
|
||||||
border: 1px solid var(--el-color-primary-light-4) !important;
|
border: 1px solid var(--el-color-primary-light-4) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dockBtnScrollBar) {
|
:deep(.dockBtnScrollBar) {
|
||||||
margin: 0 10px 0 6px;
|
margin: 0 10px 0 6px;
|
||||||
background-color: var(--el-color-primary-light-1);
|
background-color: var(--el-color-primary-light-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.scrollBarControl) {
|
:deep(.scrollBarControl) {
|
||||||
border-color: var(--el-color-primary);
|
border-color: var(--el-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.closeIcon) {
|
:deep(.closeIcon) {
|
||||||
line-height: 15px !important;
|
line-height: 15px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
.cutter-container {
|
.cutter-container {
|
||||||
:deep(.toolBox) {
|
:deep(.toolBox) {
|
||||||
border: transparent;
|
border: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.dialogMain) {
|
:deep(.dialogMain) {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.i-dialog-footer) {
|
:deep(.i-dialog-footer) {
|
||||||
.btn {
|
.btn {
|
||||||
background-color: var(--el-color-primary) !important;
|
background-color: var(--el-color-primary) !important;
|
||||||
border: transparent;
|
border: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,111 +1,111 @@
|
|||||||
<!-- 视频播放器组件:https://h5player.bytedance.com/-->
|
<!-- 视频播放器组件:https://h5player.bytedance.com/-->
|
||||||
<template>
|
<template>
|
||||||
<div :id="playerId" />
|
<div :id="playerId" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Player from 'xgplayer'
|
import Player from 'xgplayer'
|
||||||
import 'xgplayer/dist/index.min.css'
|
import 'xgplayer/dist/index.min.css'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtVideoPlayer' })
|
defineOptions({ name: 'ArtVideoPlayer' })
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 播放器容器 ID */
|
/** 播放器容器 ID */
|
||||||
playerId: string
|
playerId: string
|
||||||
/** 视频源URL */
|
/** 视频源URL */
|
||||||
videoUrl: string
|
videoUrl: string
|
||||||
/** 视频封面图URL */
|
/** 视频封面图URL */
|
||||||
posterUrl: string
|
posterUrl: string
|
||||||
/** 是否自动播放 */
|
/** 是否自动播放 */
|
||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
/** 音量大小(0-1) */
|
/** 音量大小(0-1) */
|
||||||
volume?: number
|
volume?: number
|
||||||
/** 可选的播放速率 */
|
/** 可选的播放速率 */
|
||||||
playbackRates?: number[]
|
playbackRates?: number[]
|
||||||
/** 是否循环播放 */
|
/** 是否循环播放 */
|
||||||
loop?: boolean
|
loop?: boolean
|
||||||
/** 是否静音 */
|
/** 是否静音 */
|
||||||
muted?: boolean
|
muted?: boolean
|
||||||
commonStyle?: VideoPlayerStyle
|
commonStyle?: VideoPlayerStyle
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
playerId: '',
|
playerId: '',
|
||||||
videoUrl: '',
|
videoUrl: '',
|
||||||
posterUrl: '',
|
posterUrl: '',
|
||||||
autoplay: false,
|
autoplay: false,
|
||||||
volume: 1,
|
volume: 1,
|
||||||
loop: false,
|
loop: false,
|
||||||
muted: false
|
muted: false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置属性默认值
|
// 设置属性默认值
|
||||||
|
|
||||||
// 播放器实例引用
|
// 播放器实例引用
|
||||||
const playerInstance = ref<Player | null>(null)
|
const playerInstance = ref<Player | null>(null)
|
||||||
|
|
||||||
// 播放器样式接口定义
|
// 播放器样式接口定义
|
||||||
interface VideoPlayerStyle {
|
interface VideoPlayerStyle {
|
||||||
progressColor?: string // 进度条背景色
|
progressColor?: string // 进度条背景色
|
||||||
playedColor?: string // 已播放部分颜色
|
playedColor?: string // 已播放部分颜色
|
||||||
cachedColor?: string // 缓存部分颜色
|
cachedColor?: string // 缓存部分颜色
|
||||||
sliderBtnStyle?: Record<string, string> // 滑块按钮样式
|
sliderBtnStyle?: Record<string, string> // 滑块按钮样式
|
||||||
volumeColor?: string // 音量控制器颜色
|
volumeColor?: string // 音量控制器颜色
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认样式配置
|
// 默认样式配置
|
||||||
const defaultStyle: VideoPlayerStyle = {
|
const defaultStyle: VideoPlayerStyle = {
|
||||||
progressColor: 'rgba(255, 255, 255, 0.3)',
|
progressColor: 'rgba(255, 255, 255, 0.3)',
|
||||||
playedColor: '#00AEED',
|
playedColor: '#00AEED',
|
||||||
cachedColor: 'rgba(255, 255, 255, 0.6)',
|
cachedColor: 'rgba(255, 255, 255, 0.6)',
|
||||||
sliderBtnStyle: {
|
sliderBtnStyle: {
|
||||||
width: '10px',
|
width: '10px',
|
||||||
height: '10px',
|
height: '10px',
|
||||||
backgroundColor: '#00AEED'
|
backgroundColor: '#00AEED'
|
||||||
},
|
},
|
||||||
volumeColor: '#00AEED'
|
volumeColor: '#00AEED'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件挂载时初始化播放器
|
// 组件挂载时初始化播放器
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
playerInstance.value = new Player({
|
playerInstance.value = new Player({
|
||||||
id: props.playerId,
|
id: props.playerId,
|
||||||
lang: 'zh', // 设置界面语言为中文
|
lang: 'zh', // 设置界面语言为中文
|
||||||
volume: props.volume,
|
volume: props.volume,
|
||||||
autoplay: props.autoplay,
|
autoplay: props.autoplay,
|
||||||
screenShot: true, // 启用截图功能
|
screenShot: true, // 启用截图功能
|
||||||
url: props.videoUrl,
|
url: props.videoUrl,
|
||||||
poster: props.posterUrl,
|
poster: props.posterUrl,
|
||||||
fluid: true, // 启用流式布局,自适应容器大小
|
fluid: true, // 启用流式布局,自适应容器大小
|
||||||
playbackRate: props.playbackRates,
|
playbackRate: props.playbackRates,
|
||||||
loop: props.loop,
|
loop: props.loop,
|
||||||
muted: props.muted,
|
muted: props.muted,
|
||||||
commonStyle: {
|
commonStyle: {
|
||||||
...defaultStyle,
|
...defaultStyle,
|
||||||
...props.commonStyle
|
...props.commonStyle
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 播放事件监听器
|
// 播放事件监听器
|
||||||
playerInstance.value.on('play', () => {
|
playerInstance.value.on('play', () => {
|
||||||
console.log('Video is playing')
|
console.log('Video is playing')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暂停事件监听器
|
// 暂停事件监听器
|
||||||
playerInstance.value.on('pause', () => {
|
playerInstance.value.on('pause', () => {
|
||||||
console.log('Video is paused')
|
console.log('Video is paused')
|
||||||
})
|
})
|
||||||
|
|
||||||
// 错误事件监听器
|
// 错误事件监听器
|
||||||
playerInstance.value.on('error', (error) => {
|
playerInstance.value.on('error', (error) => {
|
||||||
console.error('Error occurred:', error)
|
console.error('Error occurred:', error)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// 组件卸载前清理播放器实例
|
// 组件卸载前清理播放器实例
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (playerInstance.value) {
|
if (playerInstance.value) {
|
||||||
playerInstance.value.destroy()
|
playerInstance.value.destroy()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,415 +1,418 @@
|
|||||||
<!-- 右键菜单 -->
|
<!-- 右键菜单 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="menu-right">
|
<div class="menu-right">
|
||||||
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
|
||||||
<div
|
<div
|
||||||
v-show="visible"
|
v-show="visible"
|
||||||
:style="menuStyle"
|
:style="menuStyle"
|
||||||
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
|
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
|
||||||
>
|
>
|
||||||
<ul class="menu-list m-0 list-none" :style="menuListStyle">
|
<ul class="menu-list m-0 list-none" :style="menuListStyle">
|
||||||
<template v-for="item in menuItems" :key="item.key">
|
<template v-for="item in menuItems" :key="item.key">
|
||||||
<!-- 普通菜单项 -->
|
<!-- 普通菜单项 -->
|
||||||
<li
|
<li
|
||||||
v-if="!item.children"
|
v-if="!item.children"
|
||||||
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
||||||
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
|
||||||
:style="menuItemStyle"
|
:style="menuItemStyle"
|
||||||
@click="handleMenuClick(item)"
|
@click="handleMenuClick(item)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
v-if="item.icon"
|
v-if="item.icon"
|
||||||
class="mr-2 shrink-0 text-base text-g-800"
|
class="mr-2 shrink-0 text-base text-g-800"
|
||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||||
>{{ item.label }}</span
|
>{{ item.label }}</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<!-- 子菜单 -->
|
<!-- 子菜单 -->
|
||||||
<li
|
<li
|
||||||
v-else
|
v-else
|
||||||
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
||||||
:style="menuItemStyle"
|
:style="menuItemStyle"
|
||||||
>
|
>
|
||||||
<div class="submenu-title flex-c w-full">
|
<div class="submenu-title flex-c w-full">
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
v-if="item.icon"
|
v-if="item.icon"
|
||||||
class="mr-2 shrink-0 text-base text-g-800"
|
class="mr-2 shrink-0 text-base text-g-800"
|
||||||
:icon="item.icon"
|
:icon="item.icon"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||||
>{{ item.label }}</span
|
>{{ item.label }}</span
|
||||||
>
|
>
|
||||||
<ArtSvgIcon
|
<ArtSvgIcon
|
||||||
icon="ri:arrow-right-s-line"
|
icon="ri:arrow-right-s-line"
|
||||||
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
|
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ul
|
<ul
|
||||||
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
|
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
|
||||||
:style="submenuListStyle"
|
:style="submenuListStyle"
|
||||||
>
|
>
|
||||||
<li
|
<li
|
||||||
v-for="child in item.children"
|
v-for="child in item.children"
|
||||||
:key="child.key"
|
:key="child.key"
|
||||||
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
|
||||||
:class="{ 'is-disabled': child.disabled, 'has-line': child.showLine }"
|
:class="{
|
||||||
:style="menuItemStyle"
|
'is-disabled': child.disabled,
|
||||||
@click="handleMenuClick(child)"
|
'has-line': child.showLine
|
||||||
>
|
}"
|
||||||
<ArtSvgIcon
|
:style="menuItemStyle"
|
||||||
v-if="child.icon"
|
@click="handleMenuClick(child)"
|
||||||
class="r-2 shrink-0 text-base text-g-800 mr-1"
|
>
|
||||||
:icon="child.icon"
|
<ArtSvgIcon
|
||||||
/>
|
v-if="child.icon"
|
||||||
<span
|
class="r-2 shrink-0 text-base text-g-800 mr-1"
|
||||||
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
:icon="child.icon"
|
||||||
>{{ child.label }}</span
|
/>
|
||||||
>
|
<span
|
||||||
</li>
|
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
|
||||||
</ul>
|
>{{ child.label }}</span
|
||||||
</li>
|
>
|
||||||
</template>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</li>
|
||||||
</Transition>
|
</template>
|
||||||
</div>
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CSSProperties } from 'vue'
|
import type { CSSProperties } from 'vue'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtMenuRight' })
|
defineOptions({ name: 'ArtMenuRight' })
|
||||||
|
|
||||||
export interface MenuItemType {
|
export interface MenuItemType {
|
||||||
/** 菜单项唯一标识 */
|
/** 菜单项唯一标识 */
|
||||||
key: string
|
key: string
|
||||||
/** 菜单项标签 */
|
/** 菜单项标签 */
|
||||||
label: string
|
label: string
|
||||||
/** 菜单项图标 */
|
/** 菜单项图标 */
|
||||||
icon?: string
|
icon?: string
|
||||||
/** 菜单项是否禁用 */
|
/** 菜单项是否禁用 */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
/** 菜单项是否显示分割线 */
|
/** 菜单项是否显示分割线 */
|
||||||
showLine?: boolean
|
showLine?: boolean
|
||||||
/** 子菜单 */
|
/** 子菜单 */
|
||||||
children?: MenuItemType[]
|
children?: MenuItemType[]
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
menuItems: MenuItemType[]
|
menuItems: MenuItemType[]
|
||||||
/** 菜单宽度 */
|
/** 菜单宽度 */
|
||||||
menuWidth?: number
|
menuWidth?: number
|
||||||
/** 子菜单宽度 */
|
/** 子菜单宽度 */
|
||||||
submenuWidth?: number
|
submenuWidth?: number
|
||||||
/** 菜单项高度 */
|
/** 菜单项高度 */
|
||||||
itemHeight?: number
|
itemHeight?: number
|
||||||
/** 边界距离 */
|
/** 边界距离 */
|
||||||
boundaryDistance?: number
|
boundaryDistance?: number
|
||||||
/** 菜单内边距 */
|
/** 菜单内边距 */
|
||||||
menuPadding?: number
|
menuPadding?: number
|
||||||
/** 菜单项水平内边距 */
|
/** 菜单项水平内边距 */
|
||||||
itemPaddingX?: number
|
itemPaddingX?: number
|
||||||
/** 菜单圆角 */
|
/** 菜单圆角 */
|
||||||
borderRadius?: number
|
borderRadius?: number
|
||||||
/** 动画持续时间 */
|
/** 动画持续时间 */
|
||||||
animationDuration?: number
|
animationDuration?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
menuWidth: 120,
|
menuWidth: 120,
|
||||||
submenuWidth: 150,
|
submenuWidth: 150,
|
||||||
itemHeight: 32,
|
itemHeight: 32,
|
||||||
boundaryDistance: 10,
|
boundaryDistance: 10,
|
||||||
menuPadding: 5,
|
menuPadding: 5,
|
||||||
itemPaddingX: 6,
|
itemPaddingX: 6,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
animationDuration: 100
|
animationDuration: 100
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'select', item: MenuItemType): void
|
(e: 'select', item: MenuItemType): void
|
||||||
(e: 'show'): void
|
(e: 'show'): void
|
||||||
(e: 'hide'): void
|
(e: 'hide'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const visible = ref(false)
|
const visible = ref(false)
|
||||||
const position = ref({ x: 0, y: 0 })
|
const position = ref({ x: 0, y: 0 })
|
||||||
|
|
||||||
// 用于清理定时器和事件监听器
|
// 用于清理定时器和事件监听器
|
||||||
let showTimer: number | null = null
|
let showTimer: number | null = null
|
||||||
let eventListenersAdded = false
|
let eventListenersAdded = false
|
||||||
|
|
||||||
// 计算菜单样式
|
// 计算菜单样式
|
||||||
const menuStyle = computed(
|
const menuStyle = computed(
|
||||||
(): CSSProperties => ({
|
(): CSSProperties => ({
|
||||||
position: 'fixed' as const,
|
position: 'fixed' as const,
|
||||||
left: `${position.value.x}px`,
|
left: `${position.value.x}px`,
|
||||||
top: `${position.value.y}px`,
|
top: `${position.value.y}px`,
|
||||||
zIndex: 2000,
|
zIndex: 2000,
|
||||||
width: `${props.menuWidth}px`
|
width: `${props.menuWidth}px`
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算菜单列表样式
|
// 计算菜单列表样式
|
||||||
const menuListStyle = computed(
|
const menuListStyle = computed(
|
||||||
(): CSSProperties => ({
|
(): CSSProperties => ({
|
||||||
padding: `${props.menuPadding}px`
|
padding: `${props.menuPadding}px`
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算菜单项样式
|
// 计算菜单项样式
|
||||||
const menuItemStyle = computed(
|
const menuItemStyle = computed(
|
||||||
(): CSSProperties => ({
|
(): CSSProperties => ({
|
||||||
height: `${props.itemHeight}px`,
|
height: `${props.itemHeight}px`,
|
||||||
padding: `0 ${props.itemPaddingX}px`,
|
padding: `0 ${props.itemPaddingX}px`,
|
||||||
borderRadius: '4px'
|
borderRadius: '4px'
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算子菜单列表样式
|
// 计算子菜单列表样式
|
||||||
const submenuListStyle = computed(
|
const submenuListStyle = computed(
|
||||||
(): CSSProperties => ({
|
(): CSSProperties => ({
|
||||||
minWidth: `${props.submenuWidth}px`,
|
minWidth: `${props.submenuWidth}px`,
|
||||||
padding: `${props.menuPadding}px 0`,
|
padding: `${props.menuPadding}px 0`,
|
||||||
borderRadius: `${props.borderRadius}px`
|
borderRadius: `${props.borderRadius}px`
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
// 计算菜单高度(用于边界检测)
|
// 计算菜单高度(用于边界检测)
|
||||||
const calculateMenuHeight = (): number => {
|
const calculateMenuHeight = (): number => {
|
||||||
let totalHeight = props.menuPadding * 2 // 上下内边距
|
let totalHeight = props.menuPadding * 2 // 上下内边距
|
||||||
|
|
||||||
props.menuItems.forEach((item) => {
|
props.menuItems.forEach((item) => {
|
||||||
totalHeight += props.itemHeight
|
totalHeight += props.itemHeight
|
||||||
if (item.showLine) {
|
if (item.showLine) {
|
||||||
totalHeight += 10 // 分割线额外高度
|
totalHeight += 10 // 分割线额外高度
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return totalHeight
|
return totalHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化的位置计算函数
|
// 优化的位置计算函数
|
||||||
const calculatePosition = (e: MouseEvent) => {
|
const calculatePosition = (e: MouseEvent) => {
|
||||||
const screenWidth = window.innerWidth
|
const screenWidth = window.innerWidth
|
||||||
const screenHeight = window.innerHeight
|
const screenHeight = window.innerHeight
|
||||||
const menuHeight = calculateMenuHeight()
|
const menuHeight = calculateMenuHeight()
|
||||||
|
|
||||||
let x = e.clientX
|
let x = e.clientX
|
||||||
let y = e.clientY
|
let y = e.clientY
|
||||||
|
|
||||||
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
|
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
|
||||||
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
|
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
|
||||||
x = Math.max(props.boundaryDistance, x - props.menuWidth)
|
x = Math.max(props.boundaryDistance, x - props.menuWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
|
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
|
||||||
if (y + menuHeight > screenHeight - props.boundaryDistance) {
|
if (y + menuHeight > screenHeight - props.boundaryDistance) {
|
||||||
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
|
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保不会超出边界
|
// 确保不会超出边界
|
||||||
x = Math.max(
|
x = Math.max(
|
||||||
props.boundaryDistance,
|
props.boundaryDistance,
|
||||||
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
|
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
|
||||||
)
|
)
|
||||||
y = Math.max(
|
y = Math.max(
|
||||||
props.boundaryDistance,
|
props.boundaryDistance,
|
||||||
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
|
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
|
||||||
)
|
)
|
||||||
|
|
||||||
return { x, y }
|
return { x, y }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加事件监听器
|
// 添加事件监听器
|
||||||
const addEventListeners = () => {
|
const addEventListeners = () => {
|
||||||
if (eventListenersAdded) return
|
if (eventListenersAdded) return
|
||||||
|
|
||||||
document.addEventListener('click', handleDocumentClick)
|
document.addEventListener('click', handleDocumentClick)
|
||||||
document.addEventListener('contextmenu', handleDocumentContextmenu)
|
document.addEventListener('contextmenu', handleDocumentContextmenu)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
eventListenersAdded = true
|
eventListenersAdded = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除事件监听器
|
// 移除事件监听器
|
||||||
const removeEventListeners = () => {
|
const removeEventListeners = () => {
|
||||||
if (!eventListenersAdded) return
|
if (!eventListenersAdded) return
|
||||||
|
|
||||||
document.removeEventListener('click', handleDocumentClick)
|
document.removeEventListener('click', handleDocumentClick)
|
||||||
document.removeEventListener('contextmenu', handleDocumentContextmenu)
|
document.removeEventListener('contextmenu', handleDocumentContextmenu)
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
eventListenersAdded = false
|
eventListenersAdded = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理文档点击事件
|
// 处理文档点击事件
|
||||||
const handleDocumentClick = (e: Event) => {
|
const handleDocumentClick = (e: Event) => {
|
||||||
// 检查点击是否在菜单内部
|
// 检查点击是否在菜单内部
|
||||||
const target = e.target as Element
|
const target = e.target as Element
|
||||||
const menuElement = document.querySelector('.context-menu')
|
const menuElement = document.querySelector('.context-menu')
|
||||||
if (menuElement && menuElement.contains(target)) {
|
if (menuElement && menuElement.contains(target)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理文档右键事件
|
// 处理文档右键事件
|
||||||
const handleDocumentContextmenu = () => {
|
const handleDocumentContextmenu = () => {
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理键盘事件
|
// 处理键盘事件
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const show = (e: MouseEvent) => {
|
const show = (e: MouseEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|
||||||
// 清理之前的定时器
|
// 清理之前的定时器
|
||||||
if (showTimer) {
|
if (showTimer) {
|
||||||
window.clearTimeout(showTimer)
|
window.clearTimeout(showTimer)
|
||||||
showTimer = null
|
showTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算位置
|
// 计算位置
|
||||||
position.value = calculatePosition(e)
|
position.value = calculatePosition(e)
|
||||||
visible.value = true
|
visible.value = true
|
||||||
|
|
||||||
emit('show')
|
emit('show')
|
||||||
|
|
||||||
// 延迟添加事件监听器,避免立即触发关闭
|
// 延迟添加事件监听器,避免立即触发关闭
|
||||||
showTimer = window.setTimeout(() => {
|
showTimer = window.setTimeout(() => {
|
||||||
if (visible.value) {
|
if (visible.value) {
|
||||||
addEventListeners()
|
addEventListeners()
|
||||||
}
|
}
|
||||||
showTimer = null
|
showTimer = null
|
||||||
}, 50) // 减少延迟时间,提升响应性
|
}, 50) // 减少延迟时间,提升响应性
|
||||||
}
|
}
|
||||||
|
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
if (!visible.value) return
|
if (!visible.value) return
|
||||||
|
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('hide')
|
emit('hide')
|
||||||
|
|
||||||
// 清理定时器
|
// 清理定时器
|
||||||
if (showTimer) {
|
if (showTimer) {
|
||||||
window.clearTimeout(showTimer)
|
window.clearTimeout(showTimer)
|
||||||
showTimer = null
|
showTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除事件监听器
|
// 移除事件监听器
|
||||||
removeEventListeners()
|
removeEventListeners()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMenuClick = (item: MenuItemType) => {
|
const handleMenuClick = (item: MenuItemType) => {
|
||||||
if (item.disabled) return
|
if (item.disabled) return
|
||||||
emit('select', item)
|
emit('select', item)
|
||||||
hide()
|
hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动画钩子函数
|
// 动画钩子函数
|
||||||
const onBeforeEnter = (el: Element) => {
|
const onBeforeEnter = (el: Element) => {
|
||||||
const element = el as HTMLElement
|
const element = el as HTMLElement
|
||||||
element.style.transformOrigin = 'top left'
|
element.style.transformOrigin = 'top left'
|
||||||
}
|
}
|
||||||
|
|
||||||
const onAfterLeave = () => {
|
const onAfterLeave = () => {
|
||||||
// 确保清理所有资源
|
// 确保清理所有资源
|
||||||
removeEventListeners()
|
removeEventListeners()
|
||||||
if (showTimer) {
|
if (showTimer) {
|
||||||
window.clearTimeout(showTimer)
|
window.clearTimeout(showTimer)
|
||||||
showTimer = null
|
showTimer = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 组件卸载时清理资源
|
// 组件卸载时清理资源
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
removeEventListeners()
|
removeEventListeners()
|
||||||
if (showTimer) {
|
if (showTimer) {
|
||||||
window.clearTimeout(showTimer)
|
window.clearTimeout(showTimer)
|
||||||
showTimer = null
|
showTimer = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 导出方法供父组件调用
|
// 导出方法供父组件调用
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show,
|
show,
|
||||||
hide,
|
hide,
|
||||||
visible: computed(() => visible.value)
|
visible: computed(() => visible.value)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.menu-right {
|
.menu-right {
|
||||||
--menu-width: v-bind('props.menuWidth + "px"');
|
--menu-width: v-bind('props.menuWidth + "px"');
|
||||||
--border-radius: v-bind('props.borderRadius + "px"');
|
--border-radius: v-bind('props.borderRadius + "px"');
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.has-line {
|
.menu-item.has-line {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.has-line::after {
|
.menu-item.has-line::after {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: -5px;
|
bottom: -5px;
|
||||||
left: 0;
|
left: 0;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
content: '';
|
content: '';
|
||||||
background-color: var(--art-gray-300);
|
background-color: var(--art-gray-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.is-disabled {
|
.menu-item.is-disabled {
|
||||||
color: var(--el-text-color-disabled);
|
color: var(--el-text-color-disabled);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.is-disabled:hover {
|
.menu-item.is-disabled:hover {
|
||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.is-disabled i:not(.submenu-arrow),
|
.menu-item.is-disabled i:not(.submenu-arrow),
|
||||||
.menu-item.is-disabled :deep(.art-svg-icon) {
|
.menu-item.is-disabled :deep(.art-svg-icon) {
|
||||||
color: var(--el-text-color-disabled) !important;
|
color: var(--el-text-color-disabled) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.is-disabled .menu-label {
|
.menu-item.is-disabled .menu-label {
|
||||||
color: var(--el-text-color-disabled) !important;
|
color: var(--el-text-color-disabled) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.submenu:hover .submenu-list {
|
.menu-item.submenu:hover .submenu-list {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item.submenu:hover .submenu-title .submenu-arrow {
|
.menu-item.submenu:hover .submenu-title .submenu-arrow {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动画样式 */
|
/* 动画样式 */
|
||||||
.context-menu-enter-active,
|
.context-menu-enter-active,
|
||||||
.context-menu-leave-active {
|
.context-menu-leave-active {
|
||||||
transition: all v-bind('props.animationDuration + "ms"') ease-out;
|
transition: all v-bind('props.animationDuration + "ms"') ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-enter-from,
|
.context-menu-enter-from,
|
||||||
.context-menu-leave-to {
|
.context-menu-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: scale(0.9);
|
transform: scale(0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-enter-to,
|
.context-menu-enter-to,
|
||||||
.context-menu-leave-from {
|
.context-menu-leave-from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,64 +1,64 @@
|
|||||||
<!-- 水印组件 -->
|
<!-- 水印组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="watermarkVisible"
|
v-if="watermarkVisible"
|
||||||
class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
|
class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
|
||||||
:style="{ zIndex: zIndex }"
|
:style="{ zIndex: zIndex }"
|
||||||
>
|
>
|
||||||
<ElWatermark
|
<ElWatermark
|
||||||
:content="content"
|
:content="content"
|
||||||
:font="{ fontSize: fontSize, color: fontColor }"
|
:font="{ fontSize: fontSize, color: fontColor }"
|
||||||
:rotate="rotate"
|
:rotate="rotate"
|
||||||
:gap="[gapX, gapY]"
|
:gap="[gapX, gapY]"
|
||||||
:offset="[offsetX, offsetY]"
|
:offset="[offsetX, offsetY]"
|
||||||
>
|
>
|
||||||
<div style="height: 100vh"></div>
|
<div style="height: 100vh"></div>
|
||||||
</ElWatermark>
|
</ElWatermark>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtWatermark' })
|
defineOptions({ name: 'ArtWatermark' })
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { watermarkVisible } = storeToRefs(settingStore)
|
const { watermarkVisible } = storeToRefs(settingStore)
|
||||||
|
|
||||||
interface WatermarkProps {
|
interface WatermarkProps {
|
||||||
/** 水印内容 */
|
/** 水印内容 */
|
||||||
content?: string
|
content?: string
|
||||||
/** 水印是否可见 */
|
/** 水印是否可见 */
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
/** 水印字体大小 */
|
/** 水印字体大小 */
|
||||||
fontSize?: number
|
fontSize?: number
|
||||||
/** 水印字体颜色 */
|
/** 水印字体颜色 */
|
||||||
fontColor?: string
|
fontColor?: string
|
||||||
/** 水印旋转角度 */
|
/** 水印旋转角度 */
|
||||||
rotate?: number
|
rotate?: number
|
||||||
/** 水印间距X */
|
/** 水印间距X */
|
||||||
gapX?: number
|
gapX?: number
|
||||||
/** 水印间距Y */
|
/** 水印间距Y */
|
||||||
gapY?: number
|
gapY?: number
|
||||||
/** 水印偏移X */
|
/** 水印偏移X */
|
||||||
offsetX?: number
|
offsetX?: number
|
||||||
/** 水印偏移Y */
|
/** 水印偏移Y */
|
||||||
offsetY?: number
|
offsetY?: number
|
||||||
/** 水印层级 */
|
/** 水印层级 */
|
||||||
zIndex?: number
|
zIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<WatermarkProps>(), {
|
withDefaults(defineProps<WatermarkProps>(), {
|
||||||
content: AppConfig.systemInfo.name,
|
content: AppConfig.systemInfo.name,
|
||||||
visible: false,
|
visible: false,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontColor: 'rgba(128, 128, 128, 0.2)',
|
fontColor: 'rgba(128, 128, 128, 0.2)',
|
||||||
rotate: -22,
|
rotate: -22,
|
||||||
gapX: 100,
|
gapX: 100,
|
||||||
gapY: 100,
|
gapY: 100,
|
||||||
offsetX: 50,
|
offsetX: 50,
|
||||||
offsetY: 50,
|
offsetY: 50,
|
||||||
zIndex: 3100
|
zIndex: 3100
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,328 +1,339 @@
|
|||||||
<!-- 表格头部,包含表格大小、刷新、全屏、列设置、其他设置 -->
|
<!-- 表格头部,包含表格大小、刷新、全屏、列设置、其他设置 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="flex-cb max-md:!block" id="art-table-header">
|
<div class="flex-cb max-md:!block" id="art-table-header">
|
||||||
<div class="flex-wrap">
|
<div class="flex-wrap">
|
||||||
<slot name="left"></slot>
|
<slot name="left"></slot>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
|
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
|
||||||
<div
|
<div
|
||||||
v-if="showSearchBar != null"
|
v-if="showSearchBar != null"
|
||||||
class="button"
|
class="button"
|
||||||
@click="search"
|
@click="search"
|
||||||
:class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
|
:class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="ri:search-line" :class="showSearchBar ? 'text-white' : 'text-g-700'" />
|
<ArtSvgIcon
|
||||||
</div>
|
icon="ri:search-line"
|
||||||
<div
|
:class="showSearchBar ? 'text-white' : 'text-g-700'"
|
||||||
v-if="shouldShow('refresh')"
|
/>
|
||||||
class="button"
|
</div>
|
||||||
@click="refresh"
|
<div
|
||||||
:class="{ loading: loading && isManualRefresh }"
|
v-if="shouldShow('refresh')"
|
||||||
>
|
class="button"
|
||||||
<ArtSvgIcon
|
@click="refresh"
|
||||||
icon="ri:refresh-line"
|
:class="{ loading: loading && isManualRefresh }"
|
||||||
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
|
>
|
||||||
/>
|
<ArtSvgIcon
|
||||||
</div>
|
icon="ri:refresh-line"
|
||||||
|
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
|
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
|
||||||
<div class="button">
|
<div class="button">
|
||||||
<ArtSvgIcon icon="ri:arrow-up-down-fill" />
|
<ArtSvgIcon icon="ri:arrow-up-down-fill" />
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<ElDropdownMenu>
|
<ElDropdownMenu>
|
||||||
<div
|
<div
|
||||||
v-for="item in tableSizeOptions"
|
v-for="item in tableSizeOptions"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
|
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
|
||||||
>
|
>
|
||||||
<ElDropdownItem
|
<ElDropdownItem
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
:command="item.value"
|
:command="item.value"
|
||||||
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
|
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
|
||||||
>
|
>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</ElDropdownItem>
|
</ElDropdownItem>
|
||||||
</div>
|
</div>
|
||||||
</ElDropdownMenu>
|
</ElDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</ElDropdown>
|
</ElDropdown>
|
||||||
|
|
||||||
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
|
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
|
||||||
<ArtSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
|
<ArtSvgIcon
|
||||||
</div>
|
:icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 列设置 -->
|
<!-- 列设置 -->
|
||||||
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
|
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<div class="button">
|
<div class="button">
|
||||||
<ArtSvgIcon icon="ri:align-right" />
|
<ArtSvgIcon icon="ri:align-right" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div>
|
<div>
|
||||||
<ElScrollbar max-height="380px">
|
<ElScrollbar max-height="380px">
|
||||||
<VueDraggable
|
<VueDraggable
|
||||||
v-model="columns"
|
v-model="columns"
|
||||||
:disabled="false"
|
:disabled="false"
|
||||||
filter=".fixed-column"
|
filter=".fixed-column"
|
||||||
:prevent-on-filter="false"
|
:prevent-on-filter="false"
|
||||||
@move="checkColumnMove"
|
@move="checkColumnMove"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in columns"
|
v-for="item in columns"
|
||||||
:key="item.prop || item.type"
|
:key="item.prop || item.type"
|
||||||
class="column-option flex-c"
|
class="column-option flex-c"
|
||||||
:class="{ 'fixed-column': item.fixed }"
|
:class="{ 'fixed-column': item.fixed }"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
|
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
|
||||||
:class="item.fixed ? 'cursor-default text-g-300' : 'cursor-move'"
|
:class="
|
||||||
>
|
item.fixed ? 'cursor-default text-g-300' : 'cursor-move'
|
||||||
<ArtSvgIcon
|
"
|
||||||
:icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'"
|
>
|
||||||
class="text-base"
|
<ArtSvgIcon
|
||||||
/>
|
:icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'"
|
||||||
</div>
|
class="text-base"
|
||||||
<ElCheckbox
|
/>
|
||||||
:model-value="getColumnVisibility(item)"
|
</div>
|
||||||
@update:model-value="(val) => updateColumnVisibility(item, val)"
|
<ElCheckbox
|
||||||
:disabled="item.disabled"
|
:model-value="getColumnVisibility(item)"
|
||||||
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
|
@update:model-value="(val) => updateColumnVisibility(item, val)"
|
||||||
>{{
|
:disabled="item.disabled"
|
||||||
item.label || (item.type === 'selection' ? t('table.selection') : '')
|
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
|
||||||
}}</ElCheckbox
|
>{{
|
||||||
>
|
item.label ||
|
||||||
</div>
|
(item.type === 'selection' ? t('table.selection') : '')
|
||||||
</VueDraggable>
|
}}</ElCheckbox
|
||||||
</ElScrollbar>
|
>
|
||||||
</div>
|
</div>
|
||||||
</ElPopover>
|
</VueDraggable>
|
||||||
<!-- 其他设置 -->
|
</ElScrollbar>
|
||||||
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
|
</div>
|
||||||
<template #reference>
|
</ElPopover>
|
||||||
<div class="button">
|
<!-- 其他设置 -->
|
||||||
<ArtSvgIcon icon="ri:settings-line" />
|
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
|
||||||
</div>
|
<template #reference>
|
||||||
</template>
|
<div class="button">
|
||||||
<div>
|
<ArtSvgIcon icon="ri:settings-line" />
|
||||||
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
|
</div>
|
||||||
t('table.zebra')
|
</template>
|
||||||
}}</ElCheckbox>
|
<div>
|
||||||
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
|
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
|
||||||
t('table.border')
|
t('table.zebra')
|
||||||
}}</ElCheckbox>
|
}}</ElCheckbox>
|
||||||
<ElCheckbox v-if="showHeaderBackground" v-model="isHeaderBackground" :value="true">{{
|
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
|
||||||
t('table.headerBackground')
|
t('table.border')
|
||||||
}}</ElCheckbox>
|
}}</ElCheckbox>
|
||||||
</div>
|
<ElCheckbox
|
||||||
</ElPopover>
|
v-if="showHeaderBackground"
|
||||||
<slot name="right"></slot>
|
v-model="isHeaderBackground"
|
||||||
</div>
|
:value="true"
|
||||||
</div>
|
>{{ t('table.headerBackground') }}</ElCheckbox
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</ElPopover>
|
||||||
|
<slot name="right"></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { TableSizeEnum } from '@/enums/formEnum'
|
import { TableSizeEnum } from '@/enums/formEnum'
|
||||||
import { useTableStore } from '@/store/modules/table'
|
import { useTableStore } from '@/store/modules/table'
|
||||||
import { VueDraggable } from 'vue-draggable-plus'
|
import { VueDraggable } from 'vue-draggable-plus'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import type { ColumnOption } from '@/types/component'
|
import type { ColumnOption } from '@/types/component'
|
||||||
import { ElScrollbar } from 'element-plus'
|
import { ElScrollbar } from 'element-plus'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtTableHeader' })
|
defineOptions({ name: 'ArtTableHeader' })
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** 斑马纹 */
|
/** 斑马纹 */
|
||||||
showZebra?: boolean
|
showZebra?: boolean
|
||||||
/** 边框 */
|
/** 边框 */
|
||||||
showBorder?: boolean
|
showBorder?: boolean
|
||||||
/** 表头背景 */
|
/** 表头背景 */
|
||||||
showHeaderBackground?: boolean
|
showHeaderBackground?: boolean
|
||||||
/** 全屏 class */
|
/** 全屏 class */
|
||||||
fullClass?: string
|
fullClass?: string
|
||||||
/** 组件布局,子组件名用逗号分隔 */
|
/** 组件布局,子组件名用逗号分隔 */
|
||||||
layout?: string
|
layout?: string
|
||||||
/** 加载中 */
|
/** 加载中 */
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
/** 搜索栏显示状态 */
|
/** 搜索栏显示状态 */
|
||||||
showSearchBar?: boolean
|
showSearchBar?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
showZebra: true,
|
showZebra: true,
|
||||||
showBorder: true,
|
showBorder: true,
|
||||||
showHeaderBackground: true,
|
showHeaderBackground: true,
|
||||||
fullClass: 'art-page-view',
|
fullClass: 'art-page-view',
|
||||||
layout: 'search,refresh,size,fullscreen,columns,settings',
|
layout: 'search,refresh,size,fullscreen,columns,settings',
|
||||||
showSearchBar: undefined
|
showSearchBar: undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const columns = defineModel<ColumnOption[]>('columns', {
|
const columns = defineModel<ColumnOption[]>('columns', {
|
||||||
required: false,
|
required: false,
|
||||||
default: () => []
|
default: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'refresh'): void
|
(e: 'refresh'): void
|
||||||
(e: 'search'): void
|
(e: 'search'): void
|
||||||
(e: 'update:showSearchBar', value: boolean): void
|
(e: 'update:showSearchBar', value: boolean): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取列的显示状态
|
* 获取列的显示状态
|
||||||
* 优先使用 visible 字段,如果不存在则使用 checked 字段
|
* 优先使用 visible 字段,如果不存在则使用 checked 字段
|
||||||
*/
|
*/
|
||||||
const getColumnVisibility = (col: ColumnOption): boolean => {
|
const getColumnVisibility = (col: ColumnOption): boolean => {
|
||||||
if (col.visible !== undefined) {
|
if (col.visible !== undefined) {
|
||||||
return col.visible
|
return col.visible
|
||||||
}
|
}
|
||||||
return col.checked ?? true
|
return col.checked ?? true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新列的显示状态
|
* 更新列的显示状态
|
||||||
* 同时更新 checked 和 visible 字段以保持兼容性
|
* 同时更新 checked 和 visible 字段以保持兼容性
|
||||||
*/
|
*/
|
||||||
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
|
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
|
||||||
const boolValue = !!value
|
const boolValue = !!value
|
||||||
col.checked = boolValue
|
col.checked = boolValue
|
||||||
col.visible = boolValue
|
col.visible = boolValue
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表格大小选项配置 */
|
/** 表格大小选项配置 */
|
||||||
const tableSizeOptions = [
|
const tableSizeOptions = [
|
||||||
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
|
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
|
||||||
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
|
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
|
||||||
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
|
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
|
||||||
]
|
]
|
||||||
|
|
||||||
const tableStore = useTableStore()
|
const tableStore = useTableStore()
|
||||||
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
|
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
|
||||||
|
|
||||||
/** 解析 layout 属性,转换为数组 */
|
/** 解析 layout 属性,转换为数组 */
|
||||||
const layoutItems = computed(() => {
|
const layoutItems = computed(() => {
|
||||||
return props.layout.split(',').map((item) => item.trim())
|
return props.layout.split(',').map((item) => item.trim())
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查组件是否应该显示
|
* 检查组件是否应该显示
|
||||||
* @param componentName 组件名称
|
* @param componentName 组件名称
|
||||||
* @returns 是否显示
|
* @returns 是否显示
|
||||||
*/
|
*/
|
||||||
const shouldShow = (componentName: string) => {
|
const shouldShow = (componentName: string) => {
|
||||||
return layoutItems.value.includes(componentName)
|
return layoutItems.value.includes(componentName)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拖拽移动事件处理 - 防止固定列位置改变
|
* 拖拽移动事件处理 - 防止固定列位置改变
|
||||||
* @param evt move事件对象
|
* @param evt move事件对象
|
||||||
* @returns 是否允许移动
|
* @returns 是否允许移动
|
||||||
*/
|
*/
|
||||||
const checkColumnMove = (event: any) => {
|
const checkColumnMove = (event: any) => {
|
||||||
// 拖拽进入的目标 DOM 元素
|
// 拖拽进入的目标 DOM 元素
|
||||||
const toElement = event.related as HTMLElement
|
const toElement = event.related as HTMLElement
|
||||||
// 如果目标位置是 fixed 列,则不允许移动
|
// 如果目标位置是 fixed 列,则不允许移动
|
||||||
if (toElement && toElement.classList.contains('fixed-column')) {
|
if (toElement && toElement.classList.contains('fixed-column')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 搜索事件处理 */
|
/** 搜索事件处理 */
|
||||||
const search = () => {
|
const search = () => {
|
||||||
// 切换搜索栏显示状态
|
// 切换搜索栏显示状态
|
||||||
emit('update:showSearchBar', !props.showSearchBar)
|
emit('update:showSearchBar', !props.showSearchBar)
|
||||||
emit('search')
|
emit('search')
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新事件处理 */
|
/** 刷新事件处理 */
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
isManualRefresh.value = true
|
isManualRefresh.value = true
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 表格大小变化处理
|
* 表格大小变化处理
|
||||||
* @param command 表格大小枚举值
|
* @param command 表格大小枚举值
|
||||||
*/
|
*/
|
||||||
const handleTableSizeChange = (command: TableSizeEnum) => {
|
const handleTableSizeChange = (command: TableSizeEnum) => {
|
||||||
useTableStore().setTableSize(command)
|
useTableStore().setTableSize(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否手动点击刷新 */
|
/** 是否手动点击刷新 */
|
||||||
const isManualRefresh = ref(false)
|
const isManualRefresh = ref(false)
|
||||||
|
|
||||||
/** 加载中 */
|
/** 加载中 */
|
||||||
const isFullScreen = ref(false)
|
const isFullScreen = ref(false)
|
||||||
|
|
||||||
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
|
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
|
||||||
const originalOverflow = ref('')
|
const originalOverflow = ref('')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 切换全屏状态
|
* 切换全屏状态
|
||||||
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
|
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
|
||||||
*/
|
*/
|
||||||
const toggleFullScreen = () => {
|
const toggleFullScreen = () => {
|
||||||
const el = document.querySelector(`.${props.fullClass}`)
|
const el = document.querySelector(`.${props.fullClass}`)
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
||||||
isFullScreen.value = !isFullScreen.value
|
isFullScreen.value = !isFullScreen.value
|
||||||
|
|
||||||
if (isFullScreen.value) {
|
if (isFullScreen.value) {
|
||||||
// 进入全屏:保存原始样式并隐藏滚动条
|
// 进入全屏:保存原始样式并隐藏滚动条
|
||||||
originalOverflow.value = document.body.style.overflow
|
originalOverflow.value = document.body.style.overflow
|
||||||
document.body.style.overflow = 'hidden'
|
document.body.style.overflow = 'hidden'
|
||||||
el.classList.add('el-full-screen')
|
el.classList.add('el-full-screen')
|
||||||
tableStore.setIsFullScreen(true)
|
tableStore.setIsFullScreen(true)
|
||||||
} else {
|
} else {
|
||||||
// 退出全屏:恢复原始样式
|
// 退出全屏:恢复原始样式
|
||||||
document.body.style.overflow = originalOverflow.value
|
document.body.style.overflow = originalOverflow.value
|
||||||
el.classList.remove('el-full-screen')
|
el.classList.remove('el-full-screen')
|
||||||
tableStore.setIsFullScreen(false)
|
tableStore.setIsFullScreen(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ESC键退出全屏的事件处理器
|
* ESC键退出全屏的事件处理器
|
||||||
* 需要保存引用以便在组件卸载时正确移除监听器
|
* 需要保存引用以便在组件卸载时正确移除监听器
|
||||||
*/
|
*/
|
||||||
const handleEscapeKey = (e: KeyboardEvent) => {
|
const handleEscapeKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape' && isFullScreen.value) {
|
if (e.key === 'Escape' && isFullScreen.value) {
|
||||||
toggleFullScreen()
|
toggleFullScreen()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组件挂载时注册全局事件监听器 */
|
/** 组件挂载时注册全局事件监听器 */
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', handleEscapeKey)
|
document.addEventListener('keydown', handleEscapeKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 组件卸载时清理资源 */
|
/** 组件卸载时清理资源 */
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
// 移除事件监听器
|
// 移除事件监听器
|
||||||
document.removeEventListener('keydown', handleEscapeKey)
|
document.removeEventListener('keydown', handleEscapeKey)
|
||||||
|
|
||||||
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
|
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
|
||||||
if (isFullScreen.value) {
|
if (isFullScreen.value) {
|
||||||
document.body.style.overflow = originalOverflow.value
|
document.body.style.overflow = originalOverflow.value
|
||||||
const el = document.querySelector(`.${props.fullClass}`)
|
const el = document.querySelector(`.${props.fullClass}`)
|
||||||
if (el) {
|
if (el) {
|
||||||
el.classList.remove('el-full-screen')
|
el.classList.remove('el-full-screen')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@reference '@styles/core/tailwind.css';
|
@reference '@styles/core/tailwind.css';
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
@apply ml-2
|
@apply ml-2
|
||||||
size-8
|
size-8
|
||||||
flex
|
flex
|
||||||
items-center
|
items-center
|
||||||
@@ -335,5 +346,5 @@
|
|||||||
hover:bg-g-300
|
hover:bg-g-300
|
||||||
md:ml-0
|
md:ml-0
|
||||||
md:mr-2.5;
|
md:mr-2.5;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,340 +3,341 @@
|
|||||||
<!-- 扩展功能:分页组件、渲染自定义列、loading、表格全局边框、斑马纹、表格尺寸、表头背景配置 -->
|
<!-- 扩展功能:分页组件、渲染自定义列、loading、表格全局边框、斑马纹、表格尺寸、表头背景配置 -->
|
||||||
<!-- 获取 ref:默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
|
<!-- 获取 ref:默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
|
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
|
||||||
<ElTable
|
<ElTable
|
||||||
ref="elTableRef"
|
ref="elTableRef"
|
||||||
v-loading="!!loading"
|
v-loading="!!loading"
|
||||||
v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }"
|
v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }"
|
||||||
>
|
>
|
||||||
<template v-for="col in columns" :key="col.prop || col.type">
|
<template v-for="col in columns" :key="col.prop || col.type">
|
||||||
<!-- 渲染全局序号列 -->
|
<!-- 渲染全局序号列 -->
|
||||||
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
|
||||||
<template #default="{ $index }">
|
<template #default="{ $index }">
|
||||||
<span>{{ getGlobalIndex($index) }}</span>
|
<span>{{ getGlobalIndex($index) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
<!-- 渲染展开行 -->
|
<!-- 渲染展开行 -->
|
||||||
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<component :is="col.formatter ? col.formatter(row) : null" />
|
<component :is="col.formatter ? col.formatter(row) : null" />
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
<!-- 渲染普通列 -->
|
<!-- 渲染普通列 -->
|
||||||
<ElTableColumn v-else v-bind="cleanColumnProps(col)">
|
<ElTableColumn v-else v-bind="cleanColumnProps(col)">
|
||||||
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
|
||||||
<slot
|
<slot
|
||||||
:name="col.headerSlotName || `${col.prop}-header`"
|
:name="col.headerSlotName || `${col.prop}-header`"
|
||||||
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
|
||||||
>
|
>
|
||||||
{{ col.label }}
|
{{ col.label }}
|
||||||
</slot>
|
</slot>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="col.useSlot && col.prop" #default="slotScope">
|
<template v-if="col.useSlot && col.prop" #default="slotScope">
|
||||||
<slot
|
<slot
|
||||||
:name="col.slotName || col.prop"
|
:name="col.slotName || col.prop"
|
||||||
v-bind="{
|
v-bind="{
|
||||||
...slotScope,
|
...slotScope,
|
||||||
prop: col.prop,
|
prop: col.prop,
|
||||||
value: col.prop ? slotScope.row[col.prop] : undefined
|
value: col.prop ? slotScope.row[col.prop] : undefined
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="$slots.default" #default><slot /></template>
|
<template v-if="$slots.default" #default><slot /></template>
|
||||||
|
|
||||||
<template #empty>
|
<template #empty>
|
||||||
<div v-if="loading"></div>
|
<div v-if="loading"></div>
|
||||||
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
<ElEmpty v-else :description="emptyText" :image-size="120" />
|
||||||
</template>
|
</template>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="pagination custom-pagination"
|
class="pagination custom-pagination"
|
||||||
v-if="showPagination"
|
v-if="showPagination"
|
||||||
:class="mergedPaginationOptions?.align"
|
:class="mergedPaginationOptions?.align"
|
||||||
ref="paginationRef"
|
ref="paginationRef"
|
||||||
>
|
>
|
||||||
<ElPagination
|
<ElPagination
|
||||||
v-bind="mergedPaginationOptions"
|
v-bind="mergedPaginationOptions"
|
||||||
:total="pagination?.total"
|
:total="pagination?.total"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
:page-size="pagination?.size"
|
:page-size="pagination?.size"
|
||||||
:current-page="pagination?.current"
|
:current-page="pagination?.current"
|
||||||
@size-change="handleSizeChange"
|
@size-change="handleSizeChange"
|
||||||
@current-change="handleCurrentChange"
|
@current-change="handleCurrentChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, nextTick, watchEffect } from 'vue'
|
import { ref, computed, nextTick, watchEffect } from 'vue'
|
||||||
import type { ElTable, TableProps } from 'element-plus'
|
import type { ElTable, TableProps } from 'element-plus'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { ColumnOption } from '@/types'
|
import { ColumnOption } from '@/types'
|
||||||
import { useTableStore } from '@/store/modules/table'
|
import { useTableStore } from '@/store/modules/table'
|
||||||
import { useCommon } from '@/hooks/core/useCommon'
|
import { useCommon } from '@/hooks/core/useCommon'
|
||||||
import { useTableHeight } from '@/hooks/core/useTableHeight'
|
import { useTableHeight } from '@/hooks/core/useTableHeight'
|
||||||
import { useResizeObserver, useWindowSize } from '@vueuse/core'
|
import { useResizeObserver, useWindowSize } from '@vueuse/core'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtTable' })
|
defineOptions({ name: 'ArtTable' })
|
||||||
|
|
||||||
const { width } = useWindowSize()
|
const { width } = useWindowSize()
|
||||||
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null)
|
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null)
|
||||||
const paginationRef = ref<HTMLElement>()
|
const paginationRef = ref<HTMLElement>()
|
||||||
const tableHeaderRef = ref<HTMLElement>()
|
const tableHeaderRef = ref<HTMLElement>()
|
||||||
const tableStore = useTableStore()
|
const tableStore = useTableStore()
|
||||||
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } = storeToRefs(tableStore)
|
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } =
|
||||||
|
storeToRefs(tableStore)
|
||||||
|
|
||||||
/** 分页配置接口 */
|
/** 分页配置接口 */
|
||||||
interface PaginationConfig {
|
interface PaginationConfig {
|
||||||
/** 当前页码 */
|
/** 当前页码 */
|
||||||
current: number
|
current: number
|
||||||
/** 每页显示条目个数 */
|
/** 每页显示条目个数 */
|
||||||
size: number
|
size: number
|
||||||
/** 总条目数 */
|
/** 总条目数 */
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分页器配置选项接口 */
|
/** 分页器配置选项接口 */
|
||||||
interface PaginationOptions {
|
interface PaginationOptions {
|
||||||
/** 每页显示个数选择器的选项列表 */
|
/** 每页显示个数选择器的选项列表 */
|
||||||
pageSizes?: number[]
|
pageSizes?: number[]
|
||||||
/** 分页器的对齐方式 */
|
/** 分页器的对齐方式 */
|
||||||
align?: 'left' | 'center' | 'right'
|
align?: 'left' | 'center' | 'right'
|
||||||
/** 分页器的布局 */
|
/** 分页器的布局 */
|
||||||
layout?: string
|
layout?: string
|
||||||
/** 是否显示分页器背景 */
|
/** 是否显示分页器背景 */
|
||||||
background?: boolean
|
background?: boolean
|
||||||
/** 只有一页时是否隐藏分页器 */
|
/** 只有一页时是否隐藏分页器 */
|
||||||
hideOnSinglePage?: boolean
|
hideOnSinglePage?: boolean
|
||||||
/** 分页器的大小 */
|
/** 分页器的大小 */
|
||||||
size?: 'small' | 'default' | 'large'
|
size?: 'small' | 'default' | 'large'
|
||||||
/** 分页器的页码数量 */
|
/** 分页器的页码数量 */
|
||||||
pagerCount?: number
|
pagerCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ArtTable 组件的 Props 接口 */
|
/** ArtTable 组件的 Props 接口 */
|
||||||
interface ArtTableProps extends TableProps<Record<string, any>> {
|
interface ArtTableProps extends TableProps<Record<string, any>> {
|
||||||
/** 加载状态 */
|
/** 加载状态 */
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
/** 列渲染配置 */
|
/** 列渲染配置 */
|
||||||
columns?: ColumnOption[]
|
columns?: ColumnOption[]
|
||||||
/** 分页状态 */
|
/** 分页状态 */
|
||||||
pagination?: PaginationConfig
|
pagination?: PaginationConfig
|
||||||
/** 分页配置 */
|
/** 分页配置 */
|
||||||
paginationOptions?: PaginationOptions
|
paginationOptions?: PaginationOptions
|
||||||
/** 空数据表格高度 */
|
/** 空数据表格高度 */
|
||||||
emptyHeight?: string
|
emptyHeight?: string
|
||||||
/** 空数据时显示的文本 */
|
/** 空数据时显示的文本 */
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
/** 是否开启 ArtTableHeader,解决表格高度自适应问题 */
|
/** 是否开启 ArtTableHeader,解决表格高度自适应问题 */
|
||||||
showTableHeader?: boolean
|
showTableHeader?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<ArtTableProps>(), {
|
const props = withDefaults(defineProps<ArtTableProps>(), {
|
||||||
columns: () => [],
|
columns: () => [],
|
||||||
fit: true,
|
fit: true,
|
||||||
showHeader: true,
|
showHeader: true,
|
||||||
stripe: undefined,
|
stripe: undefined,
|
||||||
border: undefined,
|
border: undefined,
|
||||||
size: undefined,
|
size: undefined,
|
||||||
emptyHeight: '100%',
|
emptyHeight: '100%',
|
||||||
emptyText: '暂无数据',
|
emptyText: '暂无数据',
|
||||||
showTableHeader: true
|
showTableHeader: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const LAYOUT = {
|
const LAYOUT = {
|
||||||
MOBILE: 'prev, pager, next, sizes, jumper, total',
|
MOBILE: 'prev, pager, next, sizes, jumper, total',
|
||||||
IPAD: 'prev, pager, next, jumper, total',
|
IPAD: 'prev, pager, next, jumper, total',
|
||||||
DESKTOP: 'total, prev, pager, next, sizes, jumper'
|
DESKTOP: 'total, prev, pager, next, sizes, jumper'
|
||||||
}
|
}
|
||||||
|
|
||||||
const layout = computed(() => {
|
const layout = computed(() => {
|
||||||
if (width.value < 768) {
|
if (width.value < 768) {
|
||||||
return LAYOUT.MOBILE
|
return LAYOUT.MOBILE
|
||||||
} else if (width.value < 1024) {
|
} else if (width.value < 1024) {
|
||||||
return LAYOUT.IPAD
|
return LAYOUT.IPAD
|
||||||
} else {
|
} else {
|
||||||
return LAYOUT.DESKTOP
|
return LAYOUT.DESKTOP
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 默认分页常量
|
// 默认分页常量
|
||||||
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
|
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
|
||||||
pageSizes: [10, 20, 30, 50, 100],
|
pageSizes: [10, 20, 30, 50, 100],
|
||||||
align: 'center',
|
align: 'center',
|
||||||
background: true,
|
background: true,
|
||||||
layout: layout.value,
|
layout: layout.value,
|
||||||
hideOnSinglePage: false,
|
hideOnSinglePage: false,
|
||||||
size: 'default',
|
size: 'default',
|
||||||
pagerCount: width.value > 1200 ? 7 : 5
|
pagerCount: width.value > 1200 ? 7 : 5
|
||||||
}
|
}
|
||||||
|
|
||||||
// 合并分页配置
|
// 合并分页配置
|
||||||
const mergedPaginationOptions = computed(() => ({
|
const mergedPaginationOptions = computed(() => ({
|
||||||
...DEFAULT_PAGINATION_OPTIONS,
|
...DEFAULT_PAGINATION_OPTIONS,
|
||||||
...props.paginationOptions
|
...props.paginationOptions
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 边框 (优先级:props > store)
|
// 边框 (优先级:props > store)
|
||||||
const border = computed(() => props.border ?? isBorder.value)
|
const border = computed(() => props.border ?? isBorder.value)
|
||||||
// 斑马纹
|
// 斑马纹
|
||||||
const stripe = computed(() => props.stripe ?? isZebra.value)
|
const stripe = computed(() => props.stripe ?? isZebra.value)
|
||||||
// 表格尺寸
|
// 表格尺寸
|
||||||
const size = computed(() => props.size ?? tableSize.value)
|
const size = computed(() => props.size ?? tableSize.value)
|
||||||
// 数据是否为空
|
// 数据是否为空
|
||||||
const isEmpty = computed(() => props.data?.length === 0)
|
const isEmpty = computed(() => props.data?.length === 0)
|
||||||
|
|
||||||
const paginationHeight = ref(0)
|
const paginationHeight = ref(0)
|
||||||
const tableHeaderHeight = ref(0)
|
const tableHeaderHeight = ref(0)
|
||||||
|
|
||||||
// 使用 useResizeObserver 监听分页器高度变化
|
// 使用 useResizeObserver 监听分页器高度变化
|
||||||
useResizeObserver(paginationRef, (entries) => {
|
useResizeObserver(paginationRef, (entries) => {
|
||||||
const entry = entries[0]
|
const entry = entries[0]
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
paginationHeight.value = entry.contentRect.height
|
paginationHeight.value = entry.contentRect.height
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 使用 useResizeObserver 监听表格头部高度变化
|
// 使用 useResizeObserver 监听表格头部高度变化
|
||||||
useResizeObserver(tableHeaderRef, (entries) => {
|
useResizeObserver(tableHeaderRef, (entries) => {
|
||||||
const entry = entries[0]
|
const entry = entries[0]
|
||||||
if (entry) {
|
if (entry) {
|
||||||
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
tableHeaderHeight.value = entry.contentRect.height
|
tableHeaderHeight.value = entry.contentRect.height
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
|
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
|
||||||
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
|
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
|
||||||
|
|
||||||
// 使用表格高度计算 Hook
|
// 使用表格高度计算 Hook
|
||||||
const { containerHeight } = useTableHeight({
|
const { containerHeight } = useTableHeight({
|
||||||
showTableHeader: computed(() => props.showTableHeader),
|
showTableHeader: computed(() => props.showTableHeader),
|
||||||
paginationHeight,
|
paginationHeight,
|
||||||
tableHeaderHeight,
|
tableHeaderHeight,
|
||||||
paginationSpacing: PAGINATION_SPACING
|
paginationSpacing: PAGINATION_SPACING
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表格高度逻辑
|
// 表格高度逻辑
|
||||||
const height = computed(() => {
|
const height = computed(() => {
|
||||||
// 全屏模式下占满全屏
|
// 全屏模式下占满全屏
|
||||||
if (isFullScreen.value) return '100%'
|
if (isFullScreen.value) return '100%'
|
||||||
// 空数据且非加载状态时固定高度
|
// 空数据且非加载状态时固定高度
|
||||||
if (isEmpty.value && !props.loading) return props.emptyHeight
|
if (isEmpty.value && !props.loading) return props.emptyHeight
|
||||||
// 使用传入的高度
|
// 使用传入的高度
|
||||||
if (props.height) return props.height
|
if (props.height) return props.height
|
||||||
// 默认占满容器高度
|
// 默认占满容器高度
|
||||||
return '100%'
|
return '100%'
|
||||||
})
|
})
|
||||||
|
|
||||||
// 表头背景颜色样式
|
// 表头背景颜色样式
|
||||||
const headerCellStyle = computed(() => ({
|
const headerCellStyle = computed(() => ({
|
||||||
background: isHeaderBackground.value
|
background: isHeaderBackground.value
|
||||||
? 'var(--el-fill-color-lighter)'
|
? 'var(--el-fill-color-lighter)'
|
||||||
: 'var(--default-box-color)',
|
: 'var(--default-box-color)',
|
||||||
...(props.headerCellStyle || {}) // 合并用户传入的样式
|
...(props.headerCellStyle || {}) // 合并用户传入的样式
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 是否显示分页器
|
// 是否显示分页器
|
||||||
const showPagination = computed(() => props.pagination && !isEmpty.value)
|
const showPagination = computed(() => props.pagination && !isEmpty.value)
|
||||||
|
|
||||||
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
|
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
|
||||||
const cleanColumnProps = (col: ColumnOption) => {
|
const cleanColumnProps = (col: ColumnOption) => {
|
||||||
const columnProps = { ...col }
|
const columnProps = { ...col }
|
||||||
// 删除自定义的插槽控制属性
|
// 删除自定义的插槽控制属性
|
||||||
delete columnProps.useHeaderSlot
|
delete columnProps.useHeaderSlot
|
||||||
delete columnProps.headerSlotName
|
delete columnProps.headerSlotName
|
||||||
delete columnProps.useSlot
|
delete columnProps.useSlot
|
||||||
delete columnProps.slotName
|
delete columnProps.slotName
|
||||||
return columnProps
|
return columnProps
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页大小变化
|
// 分页大小变化
|
||||||
const handleSizeChange = (val: number) => {
|
const handleSizeChange = (val: number) => {
|
||||||
emit('pagination:size-change', val)
|
emit('pagination:size-change', val)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页当前页变化
|
// 分页当前页变化
|
||||||
const handleCurrentChange = (val: number) => {
|
const handleCurrentChange = (val: number) => {
|
||||||
emit('pagination:current-change', val)
|
emit('pagination:current-change', val)
|
||||||
scrollToTop() // 页码改变后滚动到表格顶部
|
scrollToTop() // 页码改变后滚动到表格顶部
|
||||||
}
|
}
|
||||||
|
|
||||||
const { scrollToTop: scrollPageToTop } = useCommon()
|
const { scrollToTop: scrollPageToTop } = useCommon()
|
||||||
|
|
||||||
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
|
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部
|
elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部
|
||||||
scrollPageToTop() // 调用公共 composable 滚动页面到顶部
|
scrollPageToTop() // 调用公共 composable 滚动页面到顶部
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 全局序号
|
// 全局序号
|
||||||
const getGlobalIndex = (index: number) => {
|
const getGlobalIndex = (index: number) => {
|
||||||
if (!props.pagination) return index + 1
|
if (!props.pagination) return index + 1
|
||||||
const { current, size } = props.pagination
|
const { current, size } = props.pagination
|
||||||
return (current - 1) * size + index + 1
|
return (current - 1) * size + index + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'pagination:size-change', val: number): void
|
(e: 'pagination:size-change', val: number): void
|
||||||
(e: 'pagination:current-change', val: number): void
|
(e: 'pagination:current-change', val: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 查找并绑定表格头部元素 - 使用 VueUse 优化
|
// 查找并绑定表格头部元素 - 使用 VueUse 优化
|
||||||
const findTableHeader = () => {
|
const findTableHeader = () => {
|
||||||
if (!props.showTableHeader) {
|
if (!props.showTableHeader) {
|
||||||
tableHeaderRef.value = undefined
|
tableHeaderRef.value = undefined
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const tableHeader = document.getElementById('art-table-header')
|
const tableHeader = document.getElementById('art-table-header')
|
||||||
if (tableHeader) {
|
if (tableHeader) {
|
||||||
tableHeaderRef.value = tableHeader
|
tableHeaderRef.value = tableHeader
|
||||||
} else {
|
} else {
|
||||||
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0
|
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 0
|
||||||
tableHeaderRef.value = undefined
|
tableHeaderRef.value = undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(
|
watchEffect(
|
||||||
() => {
|
() => {
|
||||||
// 访问响应式数据以建立依赖追踪
|
// 访问响应式数据以建立依赖追踪
|
||||||
void props.data?.length // 追踪数据变化
|
void props.data?.length // 追踪数据变化
|
||||||
const shouldShow = props.showTableHeader
|
const shouldShow = props.showTableHeader
|
||||||
|
|
||||||
// 只有在需要显示表格头部时才查找
|
// 只有在需要显示表格头部时才查找
|
||||||
if (shouldShow) {
|
if (shouldShow) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
findTableHeader()
|
findTableHeader()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
// 不显示时清空引用
|
// 不显示时清空引用
|
||||||
tableHeaderRef.value = undefined
|
tableHeaderRef.value = undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ flush: 'post' }
|
{ flush: 'post' }
|
||||||
)
|
)
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
scrollToTop,
|
scrollToTop,
|
||||||
elTableRef
|
elTableRef
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@use './style';
|
@use './style';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,99 +1,99 @@
|
|||||||
.art-table {
|
.art-table {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
.el-table {
|
.el-table {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.el-loading-mask) {
|
:deep(.el-loading-mask) {
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
background-color: var(--default-box-color) !important;
|
background-color: var(--default-box-color) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading 过渡动画 - 消失时淡出
|
// Loading 过渡动画 - 消失时淡出
|
||||||
.loading-fade-leave-active {
|
.loading-fade-leave-active {
|
||||||
transition: opacity 0.3s ease-out;
|
transition: opacity 0.3s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-fade-leave-to {
|
.loading-fade-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 空状态垂直居中
|
// 空状态垂直居中
|
||||||
&.is-empty {
|
&.is-empty {
|
||||||
:deep(.el-scrollbar__wrap) {
|
:deep(.el-scrollbar__wrap) {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination {
|
.pagination {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: 13px;
|
margin-top: 13px;
|
||||||
|
|
||||||
:deep(.el-select) {
|
:deep(.el-select) {
|
||||||
width: 102px !important;
|
width: 102px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 分页对齐方式
|
// 分页对齐方式
|
||||||
&.left {
|
&.left {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.center {
|
&.center {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.right {
|
&.right {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义分页组件样式
|
// 自定义分页组件样式
|
||||||
&.custom-pagination {
|
&.custom-pagination {
|
||||||
:deep(.el-pagination) {
|
:deep(.el-pagination) {
|
||||||
.btn-prev,
|
.btn-prev,
|
||||||
.btn-next {
|
.btn-next {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--art-gray-300);
|
border: 1px solid var(--art-gray-300);
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
&:hover:not(.is-disabled) {
|
&:hover:not(.is-disabled) {
|
||||||
color: var(--theme-color);
|
color: var(--theme-color);
|
||||||
border-color: var(--theme-color);
|
border-color: var(--theme-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-weight: 400 !important;
|
font-weight: 400 !important;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border: 1px solid var(--art-gray-300);
|
border: 1px solid var(--art-gray-300);
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
&.is-active {
|
&.is-active {
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background-color: var(--theme-color);
|
background-color: var(--theme-color);
|
||||||
border: 1px solid var(--theme-color);
|
border: 1px solid var(--theme-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(.is-disabled) {
|
&:hover:not(.is-disabled) {
|
||||||
border-color: var(--theme-color);
|
border-color: var(--theme-color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动端分页
|
// 移动端分页
|
||||||
@media (width <= 640px) {
|
@media (width <= 640px) {
|
||||||
:deep(.el-pagination) {
|
:deep(.el-pagination) {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 15px 0;
|
gap: 15px 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,310 +1,319 @@
|
|||||||
<!-- 数字滚动 -->
|
<!-- 数字滚动 -->
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
class="text-g-900 tabular-nums"
|
class="text-g-900 tabular-nums"
|
||||||
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
|
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
|
||||||
>
|
>
|
||||||
{{ formattedValue }}
|
{{ formattedValue }}
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue'
|
import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue'
|
||||||
import { useTransition, TransitionPresets } from '@vueuse/core'
|
import { useTransition, TransitionPresets } from '@vueuse/core'
|
||||||
|
|
||||||
// 类型定义
|
// 类型定义
|
||||||
interface CountToProps {
|
interface CountToProps {
|
||||||
/** 目标值 */
|
/** 目标值 */
|
||||||
target: number
|
target: number
|
||||||
/** 动画持续时间(毫秒) */
|
/** 动画持续时间(毫秒) */
|
||||||
duration?: number
|
duration?: number
|
||||||
/** 是否自动开始 */
|
/** 是否自动开始 */
|
||||||
autoStart?: boolean
|
autoStart?: boolean
|
||||||
/** 小数位数 */
|
/** 小数位数 */
|
||||||
decimals?: number
|
decimals?: number
|
||||||
/** 小数点符号 */
|
/** 小数点符号 */
|
||||||
decimal?: string
|
decimal?: string
|
||||||
/** 千分位分隔符 */
|
/** 千分位分隔符 */
|
||||||
separator?: string
|
separator?: string
|
||||||
/** 前缀 */
|
/** 前缀 */
|
||||||
prefix?: string
|
prefix?: string
|
||||||
/** 后缀 */
|
/** 后缀 */
|
||||||
suffix?: string
|
suffix?: string
|
||||||
/** 缓动函数 */
|
/** 缓动函数 */
|
||||||
easing?: keyof typeof TransitionPresets
|
easing?: keyof typeof TransitionPresets
|
||||||
/** 是否禁用动画 */
|
/** 是否禁用动画 */
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountToEmits {
|
interface CountToEmits {
|
||||||
started: [value: number]
|
started: [value: number]
|
||||||
finished: [value: number]
|
finished: [value: number]
|
||||||
paused: [value: number]
|
paused: [value: number]
|
||||||
reset: []
|
reset: []
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CountToExpose {
|
interface CountToExpose {
|
||||||
start: (target?: number) => void
|
start: (target?: number) => void
|
||||||
pause: () => void
|
pause: () => void
|
||||||
reset: (newTarget?: number) => void
|
reset: (newTarget?: number) => void
|
||||||
stop: () => void
|
stop: () => void
|
||||||
setTarget: (target: number) => void
|
setTarget: (target: number) => void
|
||||||
readonly isRunning: boolean
|
readonly isRunning: boolean
|
||||||
readonly isPaused: boolean
|
readonly isPaused: boolean
|
||||||
readonly currentValue: number
|
readonly currentValue: number
|
||||||
readonly targetValue: number
|
readonly targetValue: number
|
||||||
readonly progress: number
|
readonly progress: number
|
||||||
}
|
}
|
||||||
|
|
||||||
// 常量定义
|
// 常量定义
|
||||||
const EPSILON = Number.EPSILON
|
const EPSILON = Number.EPSILON
|
||||||
const MIN_DURATION = 100
|
const MIN_DURATION = 100
|
||||||
const MAX_DURATION = 60000
|
const MAX_DURATION = 60000
|
||||||
const MAX_DECIMALS = 10
|
const MAX_DECIMALS = 10
|
||||||
const DEFAULT_EASING = 'easeOutExpo'
|
const DEFAULT_EASING = 'easeOutExpo'
|
||||||
const DEFAULT_DURATION = 2000
|
const DEFAULT_DURATION = 2000
|
||||||
|
|
||||||
const props = withDefaults(defineProps<CountToProps>(), {
|
const props = withDefaults(defineProps<CountToProps>(), {
|
||||||
target: 0,
|
target: 0,
|
||||||
duration: DEFAULT_DURATION,
|
duration: DEFAULT_DURATION,
|
||||||
autoStart: true,
|
autoStart: true,
|
||||||
decimals: 0,
|
decimals: 0,
|
||||||
decimal: '.',
|
decimal: '.',
|
||||||
separator: '',
|
separator: '',
|
||||||
prefix: '',
|
prefix: '',
|
||||||
suffix: '',
|
suffix: '',
|
||||||
easing: DEFAULT_EASING,
|
easing: DEFAULT_EASING,
|
||||||
disabled: false
|
disabled: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<CountToEmits>()
|
const emit = defineEmits<CountToEmits>()
|
||||||
|
|
||||||
// 工具函数
|
// 工具函数
|
||||||
const validateNumber = (value: number, name: string, defaultValue: number): number => {
|
const validateNumber = (value: number, name: string, defaultValue: number): number => {
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
console.warn(`[CountTo] Invalid ${name} value:`, value)
|
console.warn(`[CountTo] Invalid ${name} value:`, value)
|
||||||
return defaultValue
|
return defaultValue
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamp = (value: number, min: number, max: number): number => {
|
const clamp = (value: number, min: number, max: number): number => {
|
||||||
return Math.max(min, Math.min(value, max))
|
return Math.max(min, Math.min(value, max))
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatNumber = (
|
const formatNumber = (
|
||||||
value: number,
|
value: number,
|
||||||
decimals: number,
|
decimals: number,
|
||||||
decimal: string,
|
decimal: string,
|
||||||
separator: string
|
separator: string
|
||||||
): string => {
|
): string => {
|
||||||
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString()
|
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString()
|
||||||
|
|
||||||
// 处理小数点符号
|
// 处理小数点符号
|
||||||
if (decimal !== '.' && result.includes('.')) {
|
if (decimal !== '.' && result.includes('.')) {
|
||||||
result = result.replace('.', decimal)
|
result = result.replace('.', decimal)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理千分位分隔符
|
// 处理千分位分隔符
|
||||||
if (separator) {
|
if (separator) {
|
||||||
const parts = result.split(decimal)
|
const parts = result.split(decimal)
|
||||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
|
||||||
result = parts.join(decimal)
|
result = parts.join(decimal)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 安全计算值
|
// 安全计算值
|
||||||
const safeTarget = computed(() => validateNumber(props.target, 'target', 0))
|
const safeTarget = computed(() => validateNumber(props.target, 'target', 0))
|
||||||
const safeDuration = computed(() =>
|
const safeDuration = computed(() =>
|
||||||
clamp(validateNumber(props.duration, 'duration', DEFAULT_DURATION), MIN_DURATION, MAX_DURATION)
|
clamp(
|
||||||
)
|
validateNumber(props.duration, 'duration', DEFAULT_DURATION),
|
||||||
const safeDecimals = computed(() =>
|
MIN_DURATION,
|
||||||
clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS)
|
MAX_DURATION
|
||||||
)
|
)
|
||||||
const safeEasing = computed(() => {
|
)
|
||||||
const easing = props.easing
|
const safeDecimals = computed(() =>
|
||||||
if (!(easing in TransitionPresets)) {
|
clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS)
|
||||||
console.warn('[CountTo] Invalid easing value:', easing)
|
)
|
||||||
return DEFAULT_EASING
|
const safeEasing = computed(() => {
|
||||||
}
|
const easing = props.easing
|
||||||
return easing
|
if (!(easing in TransitionPresets)) {
|
||||||
})
|
console.warn('[CountTo] Invalid easing value:', easing)
|
||||||
|
return DEFAULT_EASING
|
||||||
|
}
|
||||||
|
return easing
|
||||||
|
})
|
||||||
|
|
||||||
// 状态管理
|
// 状态管理
|
||||||
const currentValue = shallowRef(0)
|
const currentValue = shallowRef(0)
|
||||||
const targetValue = shallowRef(safeTarget.value)
|
const targetValue = shallowRef(safeTarget.value)
|
||||||
const isRunning = shallowRef(false)
|
const isRunning = shallowRef(false)
|
||||||
const isPaused = shallowRef(false)
|
const isPaused = shallowRef(false)
|
||||||
const pausedValue = shallowRef(0)
|
const pausedValue = shallowRef(0)
|
||||||
|
|
||||||
// 动画控制
|
// 动画控制
|
||||||
const transitionValue = useTransition(currentValue, {
|
const transitionValue = useTransition(currentValue, {
|
||||||
duration: safeDuration,
|
duration: safeDuration,
|
||||||
transition: computed(() => TransitionPresets[safeEasing.value]),
|
transition: computed(() => TransitionPresets[safeEasing.value]),
|
||||||
onStarted: () => {
|
onStarted: () => {
|
||||||
isRunning.value = true
|
isRunning.value = true
|
||||||
isPaused.value = false
|
isPaused.value = false
|
||||||
emit('started', targetValue.value)
|
emit('started', targetValue.value)
|
||||||
},
|
},
|
||||||
onFinished: () => {
|
onFinished: () => {
|
||||||
isRunning.value = false
|
isRunning.value = false
|
||||||
isPaused.value = false
|
isPaused.value = false
|
||||||
emit('finished', targetValue.value)
|
emit('finished', targetValue.value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 格式化显示值
|
// 格式化显示值
|
||||||
const formattedValue = computed(() => {
|
const formattedValue = computed(() => {
|
||||||
const value = isPaused.value ? pausedValue.value : transitionValue.value
|
const value = isPaused.value ? pausedValue.value : transitionValue.value
|
||||||
|
|
||||||
if (!Number.isFinite(value)) {
|
if (!Number.isFinite(value)) {
|
||||||
return `${props.prefix}0${props.suffix}`
|
return `${props.prefix}0${props.suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedNumber = formatNumber(value, safeDecimals.value, props.decimal, props.separator)
|
const formattedNumber = formatNumber(
|
||||||
return `${props.prefix}${formattedNumber}${props.suffix}`
|
value,
|
||||||
})
|
safeDecimals.value,
|
||||||
|
props.decimal,
|
||||||
|
props.separator
|
||||||
|
)
|
||||||
|
return `${props.prefix}${formattedNumber}${props.suffix}`
|
||||||
|
})
|
||||||
|
|
||||||
// 私有方法
|
// 私有方法
|
||||||
const shouldSkipAnimation = (target: number): boolean => {
|
const shouldSkipAnimation = (target: number): boolean => {
|
||||||
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
||||||
return Math.abs(current - target) < EPSILON
|
return Math.abs(current - target) < EPSILON
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPauseState = (): void => {
|
const resetPauseState = (): void => {
|
||||||
isPaused.value = false
|
isPaused.value = false
|
||||||
pausedValue.value = 0
|
pausedValue.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公共方法
|
// 公共方法
|
||||||
const start = (target?: number): void => {
|
const start = (target?: number): void => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
console.warn('[CountTo] Animation is disabled')
|
console.warn('[CountTo] Animation is disabled')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalTarget = target !== undefined ? target : targetValue.value
|
const finalTarget = target !== undefined ? target : targetValue.value
|
||||||
|
|
||||||
if (!Number.isFinite(finalTarget)) {
|
if (!Number.isFinite(finalTarget)) {
|
||||||
console.warn('[CountTo] Invalid target value for start:', finalTarget)
|
console.warn('[CountTo] Invalid target value for start:', finalTarget)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetValue.value = finalTarget
|
targetValue.value = finalTarget
|
||||||
|
|
||||||
if (shouldSkipAnimation(finalTarget)) {
|
if (shouldSkipAnimation(finalTarget)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从暂停值开始(如果存在)
|
// 从暂停值开始(如果存在)
|
||||||
if (isPaused.value) {
|
if (isPaused.value) {
|
||||||
currentValue.value = pausedValue.value
|
currentValue.value = pausedValue.value
|
||||||
resetPauseState()
|
resetPauseState()
|
||||||
}
|
}
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
currentValue.value = finalTarget
|
currentValue.value = finalTarget
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const pause = (): void => {
|
const pause = (): void => {
|
||||||
if (!isRunning.value || isPaused.value) {
|
if (!isRunning.value || isPaused.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isPaused.value = true
|
isPaused.value = true
|
||||||
pausedValue.value = transitionValue.value
|
pausedValue.value = transitionValue.value
|
||||||
currentValue.value = pausedValue.value
|
currentValue.value = pausedValue.value
|
||||||
|
|
||||||
emit('paused', pausedValue.value)
|
emit('paused', pausedValue.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const reset = (newTarget = 0): void => {
|
const reset = (newTarget = 0): void => {
|
||||||
const target = validateNumber(newTarget, 'reset target', 0)
|
const target = validateNumber(newTarget, 'reset target', 0)
|
||||||
|
|
||||||
currentValue.value = target
|
currentValue.value = target
|
||||||
targetValue.value = target
|
targetValue.value = target
|
||||||
resetPauseState()
|
resetPauseState()
|
||||||
|
|
||||||
emit('reset')
|
emit('reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
const setTarget = (target: number): void => {
|
const setTarget = (target: number): void => {
|
||||||
if (!Number.isFinite(target)) {
|
if (!Number.isFinite(target)) {
|
||||||
console.warn('[CountTo] Invalid target value for setTarget:', target)
|
console.warn('[CountTo] Invalid target value for setTarget:', target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
targetValue.value = target
|
targetValue.value = target
|
||||||
|
|
||||||
if ((isRunning.value || props.autoStart) && !props.disabled) {
|
if ((isRunning.value || props.autoStart) && !props.disabled) {
|
||||||
start(target)
|
start(target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const stop = (): void => {
|
const stop = (): void => {
|
||||||
if (isRunning.value || isPaused.value) {
|
if (isRunning.value || isPaused.value) {
|
||||||
currentValue.value = 0
|
currentValue.value = 0
|
||||||
resetPauseState()
|
resetPauseState()
|
||||||
emit('paused', 0)
|
emit('paused', 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听器
|
// 监听器
|
||||||
watch(
|
watch(
|
||||||
safeTarget,
|
safeTarget,
|
||||||
(newTarget) => {
|
(newTarget) => {
|
||||||
if (props.autoStart && !props.disabled) {
|
if (props.autoStart && !props.disabled) {
|
||||||
start(newTarget)
|
start(newTarget)
|
||||||
} else {
|
} else {
|
||||||
targetValue.value = newTarget
|
targetValue.value = newTarget
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ immediate: props.autoStart && !props.disabled }
|
{ immediate: props.autoStart && !props.disabled }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.disabled,
|
() => props.disabled,
|
||||||
(disabled) => {
|
(disabled) => {
|
||||||
if (disabled && isRunning.value) {
|
if (disabled && isRunning.value) {
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 清理
|
// 清理
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (isRunning.value) {
|
if (isRunning.value) {
|
||||||
stop()
|
stop()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 暴露 API
|
// 暴露 API
|
||||||
defineExpose<CountToExpose>({
|
defineExpose<CountToExpose>({
|
||||||
start,
|
start,
|
||||||
pause,
|
pause,
|
||||||
reset,
|
reset,
|
||||||
stop,
|
stop,
|
||||||
setTarget,
|
setTarget,
|
||||||
get isRunning() {
|
get isRunning() {
|
||||||
return isRunning.value
|
return isRunning.value
|
||||||
},
|
},
|
||||||
get isPaused() {
|
get isPaused() {
|
||||||
return isPaused.value
|
return isPaused.value
|
||||||
},
|
},
|
||||||
get currentValue() {
|
get currentValue() {
|
||||||
return isPaused.value ? pausedValue.value : transitionValue.value
|
return isPaused.value ? pausedValue.value : transitionValue.value
|
||||||
},
|
},
|
||||||
get targetValue() {
|
get targetValue() {
|
||||||
return targetValue.value
|
return targetValue.value
|
||||||
},
|
},
|
||||||
get progress() {
|
get progress() {
|
||||||
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
const current = isPaused.value ? pausedValue.value : transitionValue.value
|
||||||
const target = targetValue.value
|
const target = targetValue.value
|
||||||
if (target === 0) return current === 0 ? 1 : 0
|
if (target === 0) return current === 0 ? 1 : 0
|
||||||
return Math.abs(current / target)
|
return Math.abs(current / target)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
<!-- 节日文本滚动 -->
|
<!-- 节日文本滚动 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="overflow-hidden transition-[height] duration-600 ease-in-out"
|
class="overflow-hidden transition-[height] duration-600 ease-in-out"
|
||||||
:style="{
|
:style="{
|
||||||
height: showFestivalText ? '48px' : '0'
|
height: showFestivalText ? '48px' : '0'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<ArtTextScroll
|
<ArtTextScroll
|
||||||
v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
|
v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
|
||||||
:text="currentFestivalData?.scrollText || ''"
|
:text="currentFestivalData?.scrollText || ''"
|
||||||
style="margin-bottom: 12px"
|
style="margin-bottom: 12px"
|
||||||
showClose
|
showClose
|
||||||
@close="handleClose"
|
@close="handleClose"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useCeremony } from '@/hooks/core/useCeremony'
|
import { useCeremony } from '@/hooks/core/useCeremony'
|
||||||
|
|
||||||
defineOptions({ name: 'ArtFestivalTextScroll' })
|
defineOptions({ name: 'ArtFestivalTextScroll' })
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { showFestivalText } = storeToRefs(settingStore)
|
const { showFestivalText } = storeToRefs(settingStore)
|
||||||
const { currentFestivalData } = useCeremony()
|
const { currentFestivalData } = useCeremony()
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
settingStore.setShowFestivalText(false)
|
settingStore.setShowFestivalText(false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,285 +1,285 @@
|
|||||||
<!-- 文字滚动 -->
|
<!-- 文字滚动 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="containerRef"
|
ref="containerRef"
|
||||||
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
|
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
|
||||||
:class="themeClasses"
|
:class="themeClasses"
|
||||||
:style="containerStyle"
|
:style="containerStyle"
|
||||||
>
|
>
|
||||||
<div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }">
|
<div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }">
|
||||||
<ArtSvgIcon icon="ri:volume-down-line" class="text-lg" />
|
<ArtSvgIcon icon="ri:volume-down-line" class="text-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="contentRef"
|
ref="contentRef"
|
||||||
class="whitespace-nowrap inline-block transition-opacity duration-600 [&_a]:text-danger [&_a:hover]:underline [&_a:hover]:text-danger/80 px-9"
|
class="whitespace-nowrap inline-block transition-opacity duration-600 [&_a]:text-danger [&_a:hover]:underline [&_a:hover]:text-danger/80 px-9"
|
||||||
:class="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
|
:class="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
|
||||||
:style="contentStyle"
|
:style="contentStyle"
|
||||||
@click="handleContentClick"
|
@click="handleContentClick"
|
||||||
>
|
>
|
||||||
<!-- 原始内容 -->
|
<!-- 原始内容 -->
|
||||||
<span ref="textRef" class="inline-block">
|
<span ref="textRef" class="inline-block">
|
||||||
<slot>
|
<slot>
|
||||||
<span v-html="text"></span>
|
<span v-html="text"></span>
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
<!-- 克隆内容用于无缝循环 -->
|
<!-- 克隆内容用于无缝循环 -->
|
||||||
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
|
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
|
||||||
<slot>
|
<slot>
|
||||||
<span v-html="text"></span>
|
<span v-html="text"></span>
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="showClose"
|
v-if="showClose"
|
||||||
class="flex-cc absolute right-0 h-full w-9 c-p"
|
class="flex-cc absolute right-0 h-full w-9 c-p"
|
||||||
:style="{ backgroundColor: bgColor }"
|
:style="{ backgroundColor: bgColor }"
|
||||||
@click="handleClose"
|
@click="handleClose"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon icon="ri:close-fill" class="text-lg" />
|
<ArtSvgIcon icon="ri:close-fill" class="text-lg" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
useElementSize,
|
useElementSize,
|
||||||
useRafFn,
|
useRafFn,
|
||||||
useElementHover,
|
useElementHover,
|
||||||
useDebounceFn,
|
useDebounceFn,
|
||||||
useTimeoutFn
|
useTimeoutFn
|
||||||
} from '@vueuse/core'
|
} from '@vueuse/core'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
|
|
||||||
type ThemeType =
|
type ThemeType =
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'primary'
|
| 'primary'
|
||||||
| 'secondary'
|
| 'secondary'
|
||||||
| 'error'
|
| 'error'
|
||||||
| 'info'
|
| 'info'
|
||||||
| 'success'
|
| 'success'
|
||||||
| 'warning'
|
| 'warning'
|
||||||
| 'danger'
|
| 'danger'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 文本滚动组件属性接口
|
* 文本滚动组件属性接口
|
||||||
*/
|
*/
|
||||||
export interface TextScrollProps {
|
export interface TextScrollProps {
|
||||||
/** 滚动文本内容 */
|
/** 滚动文本内容 */
|
||||||
text?: string
|
text?: string
|
||||||
/** 主题类型 */
|
/** 主题类型 */
|
||||||
type?: ThemeType
|
type?: ThemeType
|
||||||
/** 滚动方向 */
|
/** 滚动方向 */
|
||||||
direction?: 'left' | 'right' | 'up' | 'down'
|
direction?: 'left' | 'right' | 'up' | 'down'
|
||||||
/** 滚动速度,单位:像素/秒 */
|
/** 滚动速度,单位:像素/秒 */
|
||||||
speed?: number
|
speed?: number
|
||||||
/** 容器宽度 */
|
/** 容器宽度 */
|
||||||
width?: string
|
width?: string
|
||||||
/** 容器高度 */
|
/** 容器高度 */
|
||||||
height?: string
|
height?: string
|
||||||
/** 鼠标悬停时是否暂停滚动 */
|
/** 鼠标悬停时是否暂停滚动 */
|
||||||
pauseOnHover?: boolean
|
pauseOnHover?: boolean
|
||||||
/** 是否显示关闭按钮 */
|
/** 是否显示关闭按钮 */
|
||||||
showClose?: boolean
|
showClose?: boolean
|
||||||
/** 始终滚动(即使文字未溢出) */
|
/** 始终滚动(即使文字未溢出) */
|
||||||
alwaysScroll?: boolean
|
alwaysScroll?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<TextScrollProps>(), {
|
const props = withDefaults(defineProps<TextScrollProps>(), {
|
||||||
text: '',
|
text: '',
|
||||||
direction: 'left',
|
direction: 'left',
|
||||||
speed: 80,
|
speed: 80,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '36px',
|
height: '36px',
|
||||||
pauseOnHover: true,
|
pauseOnHover: true,
|
||||||
type: 'theme',
|
type: 'theme',
|
||||||
showClose: false,
|
showClose: false,
|
||||||
alwaysScroll: true
|
alwaysScroll: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const { isDark } = storeToRefs(settingStore)
|
const { isDark } = storeToRefs(settingStore)
|
||||||
|
|
||||||
const containerRef = ref<HTMLElement>()
|
const containerRef = ref<HTMLElement>()
|
||||||
const contentRef = ref<HTMLElement>()
|
const contentRef = ref<HTMLElement>()
|
||||||
const textRef = ref<HTMLElement>()
|
const textRef = ref<HTMLElement>()
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
|
|
||||||
const currentPosition = ref(0)
|
const currentPosition = ref(0)
|
||||||
const textSize = ref(0)
|
const textSize = ref(0)
|
||||||
const containerSize = ref(0)
|
const containerSize = ref(0)
|
||||||
const shouldClone = ref(false)
|
const shouldClone = ref(false)
|
||||||
|
|
||||||
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
|
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
|
||||||
const isReverse = computed(() => props.direction === 'right' || props.direction === 'down')
|
const isReverse = computed(() => props.direction === 'right' || props.direction === 'down')
|
||||||
|
|
||||||
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
|
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
|
||||||
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef)
|
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef)
|
||||||
|
|
||||||
// 使用 VueUse 的 useElementHover 检测鼠标悬停
|
// 使用 VueUse 的 useElementHover 检测鼠标悬停
|
||||||
const isHovered = useElementHover(containerRef)
|
const isHovered = useElementHover(containerRef)
|
||||||
|
|
||||||
// 计算是否应该暂停动画
|
// 计算是否应该暂停动画
|
||||||
const isPaused = computed(() => {
|
const isPaused = computed(() => {
|
||||||
// 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动
|
// 如果未启用 alwaysScroll,且文字未超出容器,则暂停滚动
|
||||||
if (!props.alwaysScroll && textSize.value <= containerSize.value) {
|
if (!props.alwaysScroll && textSize.value <= containerSize.value) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return props.pauseOnHover && isHovered.value
|
return props.pauseOnHover && isHovered.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// 主题样式映射
|
// 主题样式映射
|
||||||
const themeClasses = computed(() => {
|
const themeClasses = computed(() => {
|
||||||
const themeMap: Record<ThemeType, string> = {
|
const themeMap: Record<ThemeType, string> = {
|
||||||
theme: 'text-theme/90 !border-theme/50',
|
theme: 'text-theme/90 !border-theme/50',
|
||||||
primary: 'text-primary/90 !border-primary/50',
|
primary: 'text-primary/90 !border-primary/50',
|
||||||
secondary: 'text-secondary/90 !border-secondary/50',
|
secondary: 'text-secondary/90 !border-secondary/50',
|
||||||
error: 'text-error/90 !border-error/50',
|
error: 'text-error/90 !border-error/50',
|
||||||
info: 'text-info/90 !border-info/50',
|
info: 'text-info/90 !border-info/50',
|
||||||
success: 'text-success/90 !border-success/50',
|
success: 'text-success/90 !border-success/50',
|
||||||
warning: 'text-warning/90 !border-warning/50',
|
warning: 'text-warning/90 !border-warning/50',
|
||||||
danger: 'text-danger/90 !border-danger/50'
|
danger: 'text-danger/90 !border-danger/50'
|
||||||
}
|
}
|
||||||
return themeMap[props.type] || themeMap.theme
|
return themeMap[props.type] || themeMap.theme
|
||||||
})
|
})
|
||||||
|
|
||||||
// 背景色
|
// 背景色
|
||||||
const bgColor = computed(
|
const bgColor = computed(
|
||||||
() =>
|
() =>
|
||||||
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))`
|
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))`
|
||||||
)
|
)
|
||||||
|
|
||||||
const containerStyle = computed(() => ({
|
const containerStyle = computed(() => ({
|
||||||
width: props.width,
|
width: props.width,
|
||||||
height: props.height,
|
height: props.height,
|
||||||
backgroundColor: bgColor.value
|
backgroundColor: bgColor.value
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const contentClass = computed(() => {
|
const contentClass = computed(() => {
|
||||||
if (!isHorizontal.value) {
|
if (!isHorizontal.value) {
|
||||||
return 'flex flex-col'
|
return 'flex flex-col'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentStyle = computed(() => {
|
const contentStyle = computed(() => {
|
||||||
const transform = isHorizontal.value
|
const transform = isHorizontal.value
|
||||||
? `translateX(${currentPosition.value}px)`
|
? `translateX(${currentPosition.value}px)`
|
||||||
: `translateY(${currentPosition.value}px)`
|
: `translateY(${currentPosition.value}px)`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
transform,
|
transform,
|
||||||
willChange: 'transform'
|
willChange: 'transform'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 克隆元素的间距
|
// 克隆元素的间距
|
||||||
const cloneSpacing = computed(() => {
|
const cloneSpacing = computed(() => {
|
||||||
const spacing = '2em'
|
const spacing = '2em'
|
||||||
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing }
|
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing }
|
||||||
})
|
})
|
||||||
|
|
||||||
const measureSizes = () => {
|
const measureSizes = () => {
|
||||||
if (!containerRef.value || !textRef.value) return
|
if (!containerRef.value || !textRef.value) return
|
||||||
|
|
||||||
const text = textRef.value
|
const text = textRef.value
|
||||||
|
|
||||||
if (isHorizontal.value) {
|
if (isHorizontal.value) {
|
||||||
containerSize.value = containerWidth.value
|
containerSize.value = containerWidth.value
|
||||||
textSize.value = text.offsetWidth
|
textSize.value = text.offsetWidth
|
||||||
} else {
|
} else {
|
||||||
containerSize.value = containerHeight.value
|
containerSize.value = containerHeight.value
|
||||||
textSize.value = text.offsetHeight
|
textSize.value = text.offsetHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
const isOverflow = textSize.value > containerSize.value
|
const isOverflow = textSize.value > containerSize.value
|
||||||
shouldClone.value = isOverflow
|
shouldClone.value = isOverflow
|
||||||
|
|
||||||
// 居中显示
|
// 居中显示
|
||||||
currentPosition.value = (containerSize.value - textSize.value) / 2
|
currentPosition.value = (containerSize.value - textSize.value) / 2
|
||||||
|
|
||||||
// 测量完成后才显示内容
|
// 测量完成后才显示内容
|
||||||
if (!isReady.value) {
|
if (!isReady.value) {
|
||||||
isReady.value = true
|
isReady.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用 VueUse 的 useDebounceFn 防抖测量
|
// 使用 VueUse 的 useDebounceFn 防抖测量
|
||||||
const debouncedMeasure = useDebounceFn(measureSizes, 150)
|
const debouncedMeasure = useDebounceFn(measureSizes, 150)
|
||||||
|
|
||||||
let lastTimestamp = 0
|
let lastTimestamp = 0
|
||||||
|
|
||||||
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
|
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
|
||||||
const { pause, resume } = useRafFn(
|
const { pause, resume } = useRafFn(
|
||||||
({ timestamp }) => {
|
({ timestamp }) => {
|
||||||
if (!lastTimestamp) lastTimestamp = timestamp
|
if (!lastTimestamp) lastTimestamp = timestamp
|
||||||
|
|
||||||
if (!isPaused.value) {
|
if (!isPaused.value) {
|
||||||
const delta = (timestamp - lastTimestamp) / 1000
|
const delta = (timestamp - lastTimestamp) / 1000
|
||||||
const distance = props.speed * delta
|
const distance = props.speed * delta
|
||||||
const spacing = textSize.value * 0.1
|
const spacing = textSize.value * 0.1
|
||||||
|
|
||||||
currentPosition.value += isReverse.value ? distance : -distance
|
currentPosition.value += isReverse.value ? distance : -distance
|
||||||
|
|
||||||
// 循环边界检测
|
// 循环边界检测
|
||||||
if (isReverse.value) {
|
if (isReverse.value) {
|
||||||
if (currentPosition.value > containerSize.value) {
|
if (currentPosition.value > containerSize.value) {
|
||||||
currentPosition.value = -(textSize.value + spacing)
|
currentPosition.value = -(textSize.value + spacing)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (currentPosition.value < -(textSize.value + spacing)) {
|
if (currentPosition.value < -(textSize.value + spacing)) {
|
||||||
currentPosition.value = containerSize.value
|
currentPosition.value = containerSize.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastTimestamp = timestamp
|
lastTimestamp = timestamp
|
||||||
},
|
},
|
||||||
{ immediate: false }
|
{ immediate: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleContentClick = (e: MouseEvent) => {
|
const handleContentClick = (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement
|
||||||
if (target.tagName === 'A') {
|
if (target.tagName === 'A') {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听容器尺寸变化
|
// 监听容器尺寸变化
|
||||||
watch([containerWidth, containerHeight], () => {
|
watch([containerWidth, containerHeight], () => {
|
||||||
debouncedMeasure()
|
debouncedMeasure()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听属性变化
|
// 监听属性变化
|
||||||
watch(
|
watch(
|
||||||
() => [props.direction, props.speed, props.text],
|
() => [props.direction, props.speed, props.text],
|
||||||
() => {
|
() => {
|
||||||
measureSizes()
|
measureSizes()
|
||||||
lastTimestamp = 0
|
lastTimestamp = 0
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout
|
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout
|
||||||
const { start: startMeasure } = useTimeoutFn(() => {
|
const { start: startMeasure } = useTimeoutFn(() => {
|
||||||
measureSizes()
|
measureSizes()
|
||||||
// 测量完成后立即开始动画
|
// 测量完成后立即开始动画
|
||||||
resume()
|
resume()
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startMeasure()
|
startMeasure()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
pause()
|
pause()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,100 +1,100 @@
|
|||||||
<!-- 一个让 SVG 图片跟随主题的组件,只对特定 svg 图片生效,不建议开发者使用 -->
|
<!-- 一个让 SVG 图片跟随主题的组件,只对特定 svg 图片生效,不建议开发者使用 -->
|
||||||
<!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 -->
|
<!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="theme-svg" :style="sizeStyle">
|
<div class="theme-svg" :style="sizeStyle">
|
||||||
<div v-if="src" class="svg-container" v-html="svgContent"></div>
|
<div v-if="src" class="svg-container" v-html="svgContent"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watchEffect } from 'vue'
|
import { ref, computed, watchEffect } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
size?: string | number
|
size?: string | number
|
||||||
themeColor?: string
|
themeColor?: string
|
||||||
src?: string
|
src?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
size: 500,
|
size: 500,
|
||||||
themeColor: 'var(--el-color-primary)'
|
themeColor: 'var(--el-color-primary)'
|
||||||
})
|
})
|
||||||
|
|
||||||
const svgContent = ref('')
|
const svgContent = ref('')
|
||||||
|
|
||||||
// 计算样式
|
// 计算样式
|
||||||
const sizeStyle = computed(() => {
|
const sizeStyle = computed(() => {
|
||||||
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
|
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
|
||||||
return {
|
return {
|
||||||
width: sizeValue,
|
width: sizeValue,
|
||||||
height: sizeValue
|
height: sizeValue
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 颜色映射配置
|
// 颜色映射配置
|
||||||
const COLOR_MAPPINGS = {
|
const COLOR_MAPPINGS = {
|
||||||
'#C7DEFF': 'var(--el-color-primary-light-6)',
|
'#C7DEFF': 'var(--el-color-primary-light-6)',
|
||||||
'#071F4D': 'var(--el-color-primary-dark-2)',
|
'#071F4D': 'var(--el-color-primary-dark-2)',
|
||||||
'#00E4E5': 'var(--el-color-primary-light-1)',
|
'#00E4E5': 'var(--el-color-primary-light-1)',
|
||||||
'#006EFF': 'var(--el-color-primary)',
|
'#006EFF': 'var(--el-color-primary)',
|
||||||
'#fff': 'var(--default-box-color)',
|
'#fff': 'var(--default-box-color)',
|
||||||
'#ffffff': 'var(--default-box-color)',
|
'#ffffff': 'var(--default-box-color)',
|
||||||
'#DEEBFC': 'var(--el-color-primary-light-7)'
|
'#DEEBFC': 'var(--el-color-primary-light-7)'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
// 将主题色应用到 SVG 内容
|
// 将主题色应用到 SVG 内容
|
||||||
const applyThemeToSvg = (content: string): string => {
|
const applyThemeToSvg = (content: string): string => {
|
||||||
return Object.entries(COLOR_MAPPINGS).reduce(
|
return Object.entries(COLOR_MAPPINGS).reduce(
|
||||||
(processedContent, [originalColor, themeColor]) => {
|
(processedContent, [originalColor, themeColor]) => {
|
||||||
const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi')
|
const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi')
|
||||||
const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi')
|
const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi')
|
||||||
|
|
||||||
return processedContent
|
return processedContent
|
||||||
.replace(fillRegex, `fill="${themeColor}"`)
|
.replace(fillRegex, `fill="${themeColor}"`)
|
||||||
.replace(strokeRegex, `stroke="${themeColor}"`)
|
.replace(strokeRegex, `stroke="${themeColor}"`)
|
||||||
},
|
},
|
||||||
content
|
content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载 SVG 文件内容
|
// 加载 SVG 文件内容
|
||||||
const loadSvgContent = async () => {
|
const loadSvgContent = async () => {
|
||||||
if (!props.src) {
|
if (!props.src) {
|
||||||
svgContent.value = ''
|
svgContent.value = ''
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(props.src)
|
const response = await fetch(props.src)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = await response.text()
|
const content = await response.text()
|
||||||
svgContent.value = applyThemeToSvg(content)
|
svgContent.value = applyThemeToSvg(content)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load SVG:', error)
|
console.error('Failed to load SVG:', error)
|
||||||
svgContent.value = ''
|
svgContent.value = ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
loadSvgContent()
|
loadSvgContent()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.theme-svg {
|
.theme-svg {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|
||||||
.svg-container {
|
.svg-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
:deep(svg) {
|
:deep(svg) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
|
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
|
||||||
<div class="flex-cc max-md:!block max-md:text-center">
|
<div class="flex-cc max-md:!block max-md:text-center">
|
||||||
<ThemeSvg :src="data.imgUrl" size="100%" class="!w-100" />
|
<ThemeSvg :src="data.imgUrl" size="100%" class="!w-100" />
|
||||||
<div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center">
|
<div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center">
|
||||||
<p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p>
|
<p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p>
|
||||||
<ElButton type="primary" size="large" @click="backHome" v-ripple class="mt-5">{{
|
<ElButton type="primary" size="large" @click="backHome" v-ripple class="mt-5">{{
|
||||||
data.btnText
|
data.btnText
|
||||||
}}</ElButton>
|
}}</ElButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useCommon } from '@/hooks/core/useCommon'
|
import { useCommon } from '@/hooks/core/useCommon'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
interface ExceptionData {
|
interface ExceptionData {
|
||||||
/** 标题 */
|
/** 标题 */
|
||||||
title: string
|
title: string
|
||||||
/** 描述 */
|
/** 描述 */
|
||||||
desc: string
|
desc: string
|
||||||
/** 按钮文本 */
|
/** 按钮文本 */
|
||||||
btnText: string
|
btnText: string
|
||||||
/** 图片地址 */
|
/** 图片地址 */
|
||||||
imgUrl: string
|
imgUrl: string
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
data: ExceptionData
|
data: ExceptionData
|
||||||
}>(),
|
}>(),
|
||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
|
|
||||||
const { homePath } = useCommon()
|
const { homePath } = useCommon()
|
||||||
|
|
||||||
const backHome = () => {
|
const backHome = () => {
|
||||||
router.push(homePath.value)
|
router.push(homePath.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,149 +1,161 @@
|
|||||||
<!-- 授权页右上角组件 -->
|
<!-- 授权页右上角组件 -->
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
|
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
|
||||||
>
|
>
|
||||||
<div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
|
<div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
|
||||||
<ArtLogo class="icon" size="46" />
|
<ArtLogo class="icon" size="46" />
|
||||||
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
|
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
|
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
|
||||||
<div class="color-picker-expandable relative flex-c max-sm:!hidden">
|
<div class="color-picker-expandable relative flex-c max-sm:!hidden">
|
||||||
<div
|
<div
|
||||||
class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
|
class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(color, index) in mainColors"
|
v-for="(color, index) in mainColors"
|
||||||
:key="color"
|
:key="color"
|
||||||
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
|
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
|
||||||
:class="{ active: color === systemThemeColor }"
|
:class="{ active: color === systemThemeColor }"
|
||||||
:style="{ background: color, '--index': index }"
|
:style="{ background: color, '--index': index }"
|
||||||
@click="changeThemeColor(color)"
|
@click="changeThemeColor(color)"
|
||||||
>
|
>
|
||||||
<ArtSvgIcon v-if="color === systemThemeColor" icon="ri:check-fill" class="text-white" />
|
<ArtSvgIcon
|
||||||
</div>
|
v-if="color === systemThemeColor"
|
||||||
</div>
|
icon="ri:check-fill"
|
||||||
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
|
class="text-white"
|
||||||
<ArtSvgIcon
|
/>
|
||||||
icon="ri:palette-line"
|
</div>
|
||||||
class="text-xl text-g-800 transition-colors duration-300"
|
</div>
|
||||||
/>
|
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
|
||||||
</div>
|
<ArtSvgIcon
|
||||||
</div>
|
icon="ri:palette-line"
|
||||||
<ElDropdown
|
class="text-xl text-g-800 transition-colors duration-300"
|
||||||
v-if="shouldShowLanguage"
|
/>
|
||||||
@command="changeLanguage"
|
</div>
|
||||||
popper-class="langDropDownStyle"
|
</div>
|
||||||
>
|
<ElDropdown
|
||||||
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
|
v-if="shouldShowLanguage"
|
||||||
<ArtSvgIcon
|
@command="changeLanguage"
|
||||||
icon="ri:translate-2"
|
popper-class="langDropDownStyle"
|
||||||
class="text-[19px] text-g-800 transition-colors duration-300"
|
>
|
||||||
/>
|
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
|
||||||
</div>
|
<ArtSvgIcon
|
||||||
<template #dropdown>
|
icon="ri:translate-2"
|
||||||
<ElDropdownMenu>
|
class="text-[19px] text-g-800 transition-colors duration-300"
|
||||||
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
|
/>
|
||||||
<ElDropdownItem
|
</div>
|
||||||
:command="lang.value"
|
<template #dropdown>
|
||||||
:class="{ 'is-selected': locale === lang.value }"
|
<ElDropdownMenu>
|
||||||
>
|
<div
|
||||||
<span class="menu-txt">{{ lang.label }}</span>
|
v-for="lang in languageOptions"
|
||||||
<ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
|
:key="lang.value"
|
||||||
</ElDropdownItem>
|
class="lang-btn-item"
|
||||||
</div>
|
>
|
||||||
</ElDropdownMenu>
|
<ElDropdownItem
|
||||||
</template>
|
:command="lang.value"
|
||||||
</ElDropdown>
|
:class="{ 'is-selected': locale === lang.value }"
|
||||||
<div
|
>
|
||||||
v-if="shouldShowThemeToggle"
|
<span class="menu-txt">{{ lang.label }}</span>
|
||||||
class="btn theme-btn h-8 w-8 c-p flex-cc tad-300"
|
<ArtSvgIcon
|
||||||
@click="themeAnimation"
|
icon="ri:check-fill"
|
||||||
>
|
class="text-base"
|
||||||
<ArtSvgIcon
|
v-if="locale === lang.value"
|
||||||
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
|
/>
|
||||||
class="text-xl text-g-800 transition-colors duration-300"
|
</ElDropdownItem>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</ElDropdownMenu>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</ElDropdown>
|
||||||
|
<div
|
||||||
|
v-if="shouldShowThemeToggle"
|
||||||
|
class="btn theme-btn h-8 w-8 c-p flex-cc tad-300"
|
||||||
|
@click="themeAnimation"
|
||||||
|
>
|
||||||
|
<ArtSvgIcon
|
||||||
|
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
|
||||||
|
class="text-xl text-g-800 transition-colors duration-300"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useSettingStore } from '@/store/modules/setting'
|
import { useSettingStore } from '@/store/modules/setting'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
|
||||||
import { themeAnimation } from '@/utils/ui/animation'
|
import { themeAnimation } from '@/utils/ui/animation'
|
||||||
import { languageOptions } from '@/locales'
|
import { languageOptions } from '@/locales'
|
||||||
import { LanguageEnum } from '@/enums/appEnum'
|
import { LanguageEnum } from '@/enums/appEnum'
|
||||||
import AppConfig from '@/config'
|
import AppConfig from '@/config'
|
||||||
|
|
||||||
defineOptions({ name: 'AuthTopBar' })
|
defineOptions({ name: 'AuthTopBar' })
|
||||||
|
|
||||||
const settingStore = useSettingStore()
|
const settingStore = useSettingStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { isDark, systemThemeColor } = storeToRefs(settingStore)
|
const { isDark, systemThemeColor } = storeToRefs(settingStore)
|
||||||
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
|
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const mainColors = AppConfig.systemMainColor
|
const mainColors = AppConfig.systemMainColor
|
||||||
const color = systemThemeColor // css v-bind 使用
|
const color = systemThemeColor // css v-bind 使用
|
||||||
|
|
||||||
const changeLanguage = (lang: LanguageEnum) => {
|
const changeLanguage = (lang: LanguageEnum) => {
|
||||||
if (locale.value === lang) return
|
if (locale.value === lang) return
|
||||||
locale.value = lang
|
locale.value = lang
|
||||||
userStore.setLanguage(lang)
|
userStore.setLanguage(lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeThemeColor = (color: string) => {
|
const changeThemeColor = (color: string) => {
|
||||||
if (systemThemeColor.value === color) return
|
if (systemThemeColor.value === color) return
|
||||||
settingStore.setElementTheme(color)
|
settingStore.setElementTheme(color)
|
||||||
settingStore.reload()
|
settingStore.reload()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.color-dots {
|
.color-dots {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-shadow: 0 2px 12px var(--art-gray-300);
|
box-shadow: 0 2px 12px var(--art-gray-300);
|
||||||
transition:
|
transition:
|
||||||
opacity 0.3s ease,
|
opacity 0.3s ease,
|
||||||
transform 0.3s ease;
|
transform 0.3s ease;
|
||||||
transform: translateX(10px);
|
transform: translateX(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-dot {
|
.color-dot {
|
||||||
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
|
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
transition-delay: calc(var(--index) * 0.05s);
|
transition-delay: calc(var(--index) * 0.05s);
|
||||||
transform: translateX(20px) scale(0.8);
|
transform: translateX(20px) scale(0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-dot:hover {
|
.color-dot:hover {
|
||||||
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
|
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
|
||||||
transform: translateX(0) scale(1.1);
|
transform: translateX(0) scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-expandable:hover .color-dots {
|
.color-picker-expandable:hover .color-dots {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-expandable:hover .color-dot {
|
.color-picker-expandable:hover .color-dot {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(0) scale(1);
|
transform: translateX(0) scale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .color-dots {
|
.dark .color-dots {
|
||||||
background-color: var(--art-gray-200);
|
background-color: var(--art-gray-200);
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
|
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
|
||||||
color: v-bind(color);
|
color: v-bind(color);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user