Compare commits
3 Commits
1cc427cbb0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d8144bf87 | |||
| 2f5ee49594 | |||
| a0afedf5f3 |
+1
-2
@@ -1,3 +1,2 @@
|
|||||||
/node_modules/*
|
/node_modules/*
|
||||||
/dist/*
|
/dist/*
|
||||||
/src/main.ts
|
|
||||||
+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.js"></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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-26
@@ -1,34 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
</ElConfigProvider>
|
</ElConfigProvider>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { useUserStore } from './store/modules/user'
|
import { useUserStore } from './store/modules/user'
|
||||||
import zh from 'element-plus/es/locale/lang/zh-cn'
|
import zh from 'element-plus/es/locale/lang/zh-cn'
|
||||||
import en from 'element-plus/es/locale/lang/en'
|
import en from 'element-plus/es/locale/lang/en'
|
||||||
import { systemUpgrade } from './utils/sys'
|
import { systemUpgrade } from './utils/sys'
|
||||||
import { toggleTransition } from './utils/ui/animation'
|
import { toggleTransition } from './utils/ui/animation'
|
||||||
import { checkStorageCompatibility } from './utils/storage'
|
import { checkStorageCompatibility } from './utils/storage'
|
||||||
import { initializeTheme } from './hooks/core/useTheme'
|
import { initializeTheme } from './hooks/core/useTheme'
|
||||||
|
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const { language } = storeToRefs(userStore)
|
const { language } = storeToRefs(userStore)
|
||||||
|
|
||||||
const locales = {
|
const locales = {
|
||||||
zh: zh,
|
zh: zh,
|
||||||
en: en
|
en: en
|
||||||
}
|
}
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
toggleTransition(true)
|
toggleTransition(true)
|
||||||
initializeTheme()
|
initializeTheme()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkStorageCompatibility()
|
checkStorageCompatibility()
|
||||||
toggleTransition(false)
|
toggleTransition(false)
|
||||||
systemUpgrade()
|
systemUpgrade()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录
|
||||||
|
* @param params 登录参数
|
||||||
|
* @returns 登录响应
|
||||||
|
*/
|
||||||
|
export function fetchLogin(params) {
|
||||||
|
return request.post({
|
||||||
|
url: '/api/auth/login',
|
||||||
|
params
|
||||||
|
// showSuccessMessage: true // 显示成功消息
|
||||||
|
// showErrorMessage: false // 不显示错误消息
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户信息
|
||||||
|
* @returns 用户信息
|
||||||
|
*/
|
||||||
|
export function fetchGetUserInfo() {
|
||||||
|
return request.get({
|
||||||
|
url: '/api/user/info'
|
||||||
|
// 自定义请求头
|
||||||
|
// headers: {
|
||||||
|
// 'X-Custom-Header': 'your-custom-value'
|
||||||
|
// }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import request from '@/utils/http'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录
|
|
||||||
* @param params 登录参数
|
|
||||||
* @returns 登录响应
|
|
||||||
*/
|
|
||||||
export function fetchLogin(params: Api.Auth.LoginParams) {
|
|
||||||
return request.post<Api.Auth.LoginResponse>({
|
|
||||||
url: '/api/auth/login',
|
|
||||||
params
|
|
||||||
// showSuccessMessage: true // 显示成功消息
|
|
||||||
// showErrorMessage: false // 不显示错误消息
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户信息
|
|
||||||
* @returns 用户信息
|
|
||||||
*/
|
|
||||||
export function fetchGetUserInfo() {
|
|
||||||
return request.get<Api.Auth.UserInfo>({
|
|
||||||
url: '/api/user/info'
|
|
||||||
// 自定义请求头
|
|
||||||
// headers: {
|
|
||||||
// 'X-Custom-Header': 'your-custom-value'
|
|
||||||
// }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import request from '@/utils/http'
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
export function fetchGetUserList(params) {
|
||||||
|
return request.get({
|
||||||
|
url: '/api/user/list',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取角色列表
|
||||||
|
export function fetchGetRoleList(params) {
|
||||||
|
return request.get({
|
||||||
|
url: '/api/role/list',
|
||||||
|
params
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取菜单列表
|
||||||
|
export function fetchGetMenuList() {
|
||||||
|
return request.get({
|
||||||
|
url: '/api/v3/system/menus/simple'
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import request from '@/utils/http'
|
|
||||||
import { AppRouteRecord } from '@/types/router'
|
|
||||||
|
|
||||||
// 获取用户列表
|
|
||||||
export function fetchGetUserList(params: Api.SystemManage.UserSearchParams) {
|
|
||||||
return request.get<Api.SystemManage.UserList>({
|
|
||||||
url: '/api/user/list',
|
|
||||||
params
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取角色列表
|
|
||||||
export function fetchGetRoleList(params: Api.SystemManage.RoleSearchParams) {
|
|
||||||
return request.get<Api.SystemManage.RoleList>({
|
|
||||||
url: '/api/role/list',
|
|
||||||
params
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取菜单列表
|
|
||||||
export function fetchGetMenuList() {
|
|
||||||
return request.get<AppRouteRecord[]>({
|
|
||||||
url: '/api/v3/system/menus/simple'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+188
-188
@@ -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>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user