first commit

This commit is contained in:
2026-01-10 10:04:08 +08:00
commit 1cc427cbb0
291 changed files with 51036 additions and 0 deletions

22
.env Normal file
View File

@@ -0,0 +1,22 @@
# 【通用】环境变量
# 版本号
VITE_VERSION = 3.0.1
# 端口号
VITE_PORT = 3006
# 应用部署基础路径(如部署在子目录 /admin则设置为 /admin/
VITE_BASE_URL = /
# 权限模式【 frontend 前端模式 / backend 后端模式 】
VITE_ACCESS_MODE = frontend
# 跨域请求时是否携带 Cookie开启前需确保后端支持
VITE_WITH_CREDENTIALS = false
# 是否打开路由信息
VITE_OPEN_ROUTE_INFO = false
# 锁屏加密密钥
VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro

13
.env.development Normal file
View File

@@ -0,0 +1,13 @@
# 【开发】环境变量
# 应用部署基础路径(如部署在子目录 /admin则设置为 /admin/
VITE_BASE_URL = /
# API 请求基础路径(开发环境设置为 / 使用代理,生产环境设置为完整后端地址)
VITE_API_URL = /
# 代理目标地址(开发环境通过 Vite 代理转发请求到此地址,解决跨域问题)
VITE_API_PROXY_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default
# Delete console
VITE_DROP_CONSOLE = false

10
.env.production Normal file
View File

@@ -0,0 +1,10 @@
# 【生产】环境变量
# 应用部署基础路径(如部署在子目录 /admin则设置为 /admin/
VITE_BASE_URL = /
# API 地址前缀
VITE_API_URL = https://m1.apifoxmock.com/m1/6400575-6097373-default
# Delete console
VITE_DROP_CONSOLE = true

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
*.html linguist-detectable=false
*.vue linguist-detectable=true

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.cursorrules
# Auto-generated files
src/types/import/auto-imports.d.ts
src/types/import/components.d.ts
.auto-import.json

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
/node_modules/*
/dist/*
/src/main.ts

20
.prettierrc Normal file
View File

@@ -0,0 +1,20 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"vueIndentScriptAndStyle": true,
"singleQuote": true,
"quoteProps": "as-needed",
"bracketSpacing": true,
"trailingComma": "none",
"bracketSameLine": false,
"jsxSingleQuote": false,
"arrowParens": "always",
"insertPragma": false,
"requirePragma": false,
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "auto",
"rangeStart": 0
}

9
.stylelintignore Normal file
View File

@@ -0,0 +1,9 @@
dist
node_modules
public
.husky
.vscode
src/components/Layout/MenuLeft/index.vue
src/assets
stats.html

82
.stylelintrc.cjs Normal file
View File

@@ -0,0 +1,82 @@
module.exports = {
// 继承推荐规范配置
extends: [
'stylelint-config-standard',
'stylelint-config-recommended-scss',
'stylelint-config-recommended-vue/scss',
'stylelint-config-html/vue',
'stylelint-config-recess-order'
],
// 指定不同文件对应的解析器
overrides: [
{
files: ['**/*.{vue,html}'],
customSyntax: 'postcss-html'
},
{
files: ['**/*.{css,scss}'],
customSyntax: 'postcss-scss'
}
],
// 自定义规则
rules: {
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
'selector-class-pattern': null, // 选择器类名命名规则
'custom-property-pattern': null, // 自定义属性命名规则
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
'no-descending-specificity': null, // 允许无降序特异性
'no-empty-source': null, // 允许空样式
'property-no-vendor-prefix': null, // 允许属性前缀
// 允许 global 、export 、deep伪类
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global', 'export', 'deep']
}
],
// 允许未知属性
'property-no-unknown': [
true,
{
ignoreProperties: []
}
],
// 允许未知规则
'at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'apply',
'use',
'mixin',
'include',
'extend',
'each',
'if',
'else',
'for',
'while',
'reference'
]
}
],
'scss/at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'apply',
'use',
'mixin',
'include',
'extend',
'each',
'if',
'else',
'for',
'while',
'reference'
]
}
]
}
}

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 SuperManTT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

83
eslint.config.mjs Normal file
View File

@@ -0,0 +1,83 @@
// 从 URL 和路径模块中导入必要的功能
import fs from 'fs'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
// 从 ESLint 插件中导入推荐配置
import pluginJs from '@eslint/js'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
// 使用 import.meta.url 获取当前模块的路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象
const autoImportConfig = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8')
)
export default [
// 指定文件匹配规则
{
files: ['**/*.{js,mjs,cjs,ts,vue}']
},
// 指定全局变量和环境
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
// 扩展配置
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/essential'],
// 自定义规则
{
// 针对所有 JavaScript、TypeScript 和 Vue 文件应用以下配置
files: ['**/*.{js,mjs,cjs,ts,vue}'],
languageOptions: {
globals: {
// 合并从 autoImportConfig 中读取的全局变量配置
...autoImportConfig.globals,
// TypeScript 全局命名空间
Api: 'readonly'
}
},
rules: {
quotes: ['error', 'single'], // 使用单引号
semi: ['error', 'never'], // 语句末尾不加分号
'no-var': 'error', // 要求使用 let 或 const 而不是 var
'@typescript-eslint/no-explicit-any': 'off', // 禁用 any 检查
'vue/multi-word-component-names': 'off', // 禁用对 Vue 组件名称的多词要求检查
'no-multiple-empty-lines': ['warn', { max: 1 }], // 不允许多个空行
'no-unexpected-multiline': 'error' // 禁止空余的多行
}
},
// vue 规则
{
files: ['**/*.vue'],
languageOptions: {
parserOptions: { parser: tseslint.parser }
}
},
// 忽略文件
{
ignores: [
'node_modules',
'dist',
'public',
'.vscode/**',
'src/assets/**',
'src/utils/console.ts'
]
},
// prettier 配置
eslintPluginPrettierRecommended
]

47
index.html Normal file
View File

@@ -0,0 +1,47 @@
<!doctype html>
<html>
<head>
<title>Art Design Pro</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Art Design Pro - A modern admin dashboard template built with Vue 3, TypeScript, and Element Plus."
/>
<link rel="shortcut icon" type="image/x-icon" href="src/assets/images/favicon.ico" />
<style>
/* 防止页面刷新时白屏的初始样式 */
html {
background-color: #fafbfc;
}
html.dark {
background-color: #070707;
}
</style>
<script>
// 初始化 html class 主题属性
;(function () {
try {
if (typeof Storage === 'undefined' || !window.localStorage) {
return
}
const themeType = localStorage.getItem('sys-theme')
if (themeType === 'dark') {
document.documentElement.classList.add('dark')
}
} catch (e) {
console.warn('Failed to apply initial theme:', e)
}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

118
package.json Normal file
View File

@@ -0,0 +1,118 @@
{
"name": "art-design-pro",
"version": "0.0.0",
"type": "module",
"engines": {
"node": ">=20.19.0",
"pnpm": ">=8.8.0"
},
"scripts": {
"dev": "vite --open",
"build": "vue-tsc --noEmit && vite build",
"serve": "vite preview",
"lint": "eslint",
"fix": "eslint --fix",
"lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,css,less,scss,vue,html,md}\"",
"lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix",
"lint:lint-staged": "lint-staged",
"prepare": "husky",
"commit": "git-cz",
"clean:dev": "tsx scripts/clean-dev.ts"
},
"config": {
"commitizen": {
"path": "node_modules/cz-git"
}
},
"lint-staged": {
"*.{js,ts,mjs,mts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{cjs,json,jsonc}": [
"prettier --write"
],
"*.vue": [
"eslint --fix",
"stylelint --fix --allow-empty-input",
"prettier --write"
],
"*.{html,htm}": [
"prettier --write"
],
"*.{scss,css,less}": [
"stylelint --fix --allow-empty-input",
"prettier --write"
],
"*.{md,mdx}": [
"prettier --write"
],
"*.{yaml,yml}": [
"prettier --write"
]
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@iconify/vue": "^5.0.0",
"@tailwindcss/vite": "^4.1.14",
"@vue/reactivity": "^3.5.21",
"@vueuse/core": "^13.9.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "next",
"axios": "^1.12.2",
"crypto-js": "^4.2.0",
"echarts": "^6.0.0",
"element-plus": "^2.11.2",
"file-saver": "^2.0.5",
"highlight.js": "^11.10.0",
"mitt": "^3.0.1",
"nprogress": "^0.2.0",
"ohash": "^2.0.11",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
"qrcode.vue": "^3.6.0",
"tailwindcss": "^4.1.14",
"vue": "^3.5.21",
"vue-draggable-plus": "^0.6.0",
"vue-i18n": "^9.14.0",
"vue-router": "^4.5.1",
"xgplayer": "^3.0.20",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/node": "^24.0.5",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/compiler-sfc": "^3.0.5",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-vue": "^9.27.0",
"globals": "^15.9.0",
"lint-staged": "^15.5.2",
"prettier": "^3.5.3",
"rollup-plugin-visualizer": "^5.12.0",
"sass": "^1.81.0",
"stylelint": "^16.20.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-recess-order": "^4.6.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^36.0.1",
"terser": "^5.36.0",
"tsx": "^4.20.3",
"typescript": "~5.6.3",
"typescript-eslint": "^8.9.0",
"unplugin-auto-import": "^20.2.0",
"unplugin-element-plus": "^0.10.0",
"unplugin-vue-components": "^29.1.0",
"vite": "^7.1.5",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-vue-devtools": "^7.7.6",
"vue-demi": "^0.14.9",
"vue-img-cutter": "^3.0.5",
"vue-tsc": "~2.1.6"
}
}

10109
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

34
src/App.vue Normal file
View File

@@ -0,0 +1,34 @@
<template>
<ElConfigProvider size="default" :locale="locales[language]" :z-index="3000">
<RouterView></RouterView>
</ElConfigProvider>
</template>
<script setup lang="ts">
import { useUserStore } from './store/modules/user'
import zh from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { systemUpgrade } from './utils/sys'
import { toggleTransition } from './utils/ui/animation'
import { checkStorageCompatibility } from './utils/storage'
import { initializeTheme } from './hooks/core/useTheme'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
const locales = {
zh: zh,
en: en
}
onBeforeMount(() => {
toggleTransition(true)
initializeTheme()
})
onMounted(() => {
checkStorageCompatibility()
toggleTransition(false)
systemUpgrade()
})
</script>

29
src/api/auth.ts Normal file
View File

@@ -0,0 +1,29 @@
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'
// }
})
}

25
src/api/system-manage.ts Normal file
View File

@@ -0,0 +1,25 @@
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'
})
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="94" y="34" width="212" height="233"><path d="M306 34H94v233h212V34Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M234.427 155.64h38.36V69.6h-38.36v86.04ZM113.326 155.64h121.1V69.6h-121.1v86.04Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.126 155.354h104.2v-72.95h-104.2v72.95ZM236.369 71.05s0 3.3 1.65 5.05c2.33 2.52 7.38-.2 7.38-.2s-1.75 5.15-1.55 10.19c.29 8.24 6.99 9.51 10 4.75 4.56 4.85 8.94-.29 9.52-2.62 4.27 4.76 9.32-.87 9.32-.87v-6.3l-23.99-12.13-12.33 2.13Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M234.429 155.641h-121.1l-15.93 32.11h121.1l15.93-32.11Z" fill="#fff"/><path d="M234.427 69.6h38.46v86.04M113.326 146.52V69.6h121.1M234.429 155.641l-15.93 32.11h-121.1l15.93-32.11h111.39" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M226.37 159.715H116.82l-12.04 23.86H215l11.37-23.86Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="m288.807 187.751-15.92-32.11h-38.46l16.02 32.11h38.36Z" fill="#fff"/><path d="m238.607 163.981 11.84 23.77h38.36l-15.92-32.11h-38.46" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M207.336 223.734c-3.69-13.77-15.44-23.86-29.33-23.86h-8.65s-27.09 14.94-27.09 33.27c0 18.34 25.44 33.18 25.44 33.18h10.4c13.79-.1 25.44-10.19 29.13-23.87 1.75-12.51 0-18.62.1-18.72Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M243.459 240.421c3.98 0 7.28-3.3 7.28-7.27 0-3.98-3.3-7.28-7.28-7.28h-31.08c-3.98 0-7.28 3.3-7.28 7.28 0 3.97 3.3 7.27 7.28 7.27h31.08Z" fill="#C7DEFF"/><path d="M210.342 223.737c-4.08-13.87-16.9-23.96-32.05-23.96H168.972s-29.62 14.94-29.62 33.37 27.87 33.37 27.87 33.37h11.27c15.05-.1 27.77-10.19 31.75-23.96" stroke="#071F4D"/><path d="M212.379 240.421c-3.98 0-7.28-3.3-7.28-7.27m0 0c0-3.98 3.3-7.28 7.28-7.28" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" fill="#006EFF"/><path d="M168.781 199.777c-18.45 0-33.41 14.94-33.41 33.37s14.96 33.37 33.41 33.37c18.45 0 33.4-14.94 33.4-33.37s-14.95-33.37-33.4-33.37Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M168.775 209.38c-13.14 0-23.79 10.64-23.79 23.77 0 13.12 10.65 23.76 23.79 23.76 13.14 0 23.8-10.64 23.8-23.76 0-13.13-10.66-23.77-23.8-23.77Z" fill="#00E4E5"/><path d="M162.174 223.736a17.48 17.48 0 0 1 14.76-8.05M159.455 231.982c.1-1.36.29-2.62.68-3.88" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M173.535 209.87c-1.55-.3-3.11-.49-4.76-.49-13.11 0-23.79 10.67-23.79 23.77 0 13.09 10.68 23.76 23.79 23.76 1.65 0 3.21-.19 4.76-.48-10.88-2.23-19.03-11.84-19.03-23.28 0-11.45 8.15-21.05 19.03-23.28Z" fill="#071F4D"/><path d="M219.957 225.774h23.6c4.08 0 7.38 3.3 7.38 7.37m0 0c0 4.08-3.3 7.37-7.38 7.37h-20.1M212.091 225.774h3.3" stroke="#071F4D"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" fill="#fff"/><path d="m248.894 34.485-.19 18.24c0 4.07-.39 5.23-2.14 6.79-8.15 6.88-10.97 9.02-9.22 12.9 1.45 3.2 6.79 2.23 9.61-1.55-.39 4.56-5.24 15.32-.58 18.04 4.37 2.52 6.89-3.49 6.89-3.49s.49 3.49 4.47 3.49c3.69 0 5.24-4.75 5.24-4.75s2.14 3.49 6.22 1.35c3.11-1.55 5.44-7.08 5.44-26.67v-24.35" stroke="#071F4D"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6l2.04-9.6Z" fill="#fff"/><path d="M255.307 75.71s-.39 5.43-2.04 9.6" stroke="#071F4D"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63l2.04-8.63Z" fill="#fff"/><path d="M264.921 75.323s-.68 5.24-2.04 8.63M147.801 34.485v34.92M121.775 34.485v34.92M102.546 204.724v13.97M102.546 222.379v.87M102.546 197.934v3.49M115.268 206.955v26.29M115.268 239.451v5.34M244.43 197.643v11.93M244.43 213.939v3.49M270.359 201.232v33.76M115.369 47.774h-13.6M94.486 47.774h3.4M241.516 47.774h-84.1M280.168 47.774h25.35" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m282.497 183.575-12.04-23.86h-27.29l11.36 23.86h27.97Z" fill="#00E4E5"/><path d="M234.427 134.88V69.6M234.427 140.412v7.66" stroke="#071F4D"/><path d="M220.831 228.684h16.99M240.934 228.684h2.43" stroke="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="m223.842 187.462 21.46-.2-10.97-20.66-10.49 20.86Z" fill="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,5 @@
<svg
viewBox="0 0 400 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="47" y="38" width="307" height="224"><path d="M353.3 38H47.5v223.8h305.8V38Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M299.2 200.6H61.6v5.1h240.3l-2.7-5.1Z" fill="#C7DEFF"/><path d="m308.9 185.8-6.5 20H183.7M332.3 127.6h10.6l-5 16.7-14.8-.1-7.2 21.1M328.8 127.4l13.6-39.6M307.6 166 337 84.7H180.6l-9.8 26.9h-10.5M296.6 196l4.3-11.8M157.2 149.2l6.4-17.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-34.8 95.8h136.4l34.7-95.8ZM169.9 166.2l5-13.6-5 13.6Z" fill="#fff"/><path d="m169.9 166.2 5-13.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.8 93.1H188.5l-4 11.7h135.8l4.5-11.7Z" fill="#006EFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M102.6 159.5h38.3l2.7 36.6h-38.4c-10.1 0-20.9-8.2-20.9-18.3 0-10.1 8.2-18.3 18.3-18.3Z" fill="#DEEBFC"/><path fill-rule="evenodd" clip-rule="evenodd" d="M84.3 174.102c2.5 3.4 10 5 17.9 2.8 16.6-6.5 23.8-3.9 23.8-3.9s.5-3.4 1.3-5c-5.8-3-15.4.3-26.1 3.1-10.7 2.8-15.8-2.5-15.8-2.5-.4 0-1.1 2.8-1.1 5.5Z" fill="#fff"/><path d="M96.5 194.2c-7.2-3.3-12.2-10.5-12.2-19m0 0c0-11.5 9.3-20.8 20.8-20.8h29.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8l14.5 19.8Zm-14.5-19.8c0-11.5 9.3-20.8 20.8-20.8l-20.8 20.8Zm20.8-20.8c11.5 0 20.8 9.3 20.8 20.8l-20.8-20.8Zm20.8 20.8c0 8.4-5 15.6-12.1 18.9l12.1-18.9Z" fill="#fff"/><path d="M140.3 195.1c-8.4-2.7-14.5-10.6-14.5-19.8m0 0c0-11.5 9.3-20.8 20.8-20.8m0 0c11.5 0 20.8 9.3 20.8 20.8m0 0c0 8.4-5 15.6-12.1 18.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.5 177.2c0-7.7-6.3-14-14-14s-14 6.3-14 14c0 5.8 3.5 10.8 8.6 12.9.1 0 5.8 1.6 10.7 0 5.3-1.7 8.7-7.1 8.7-12.9Z" fill="#00E4E5"/><path d="M140.5 190.1c-5.8-2.4-9.9-8.2-9.9-14.9 0-8.9 7.2-16.1 16.1-16.1 8.9 0 16.1 7.2 16.1 16.1 0 6.8-4.2 12.5-10.1 14.9M88.4 170.604c2.9 1.3 7.7 2.6 13.6.3 14.7-5.7 22.3-4.3 24.6-3.5M84.5 174.599s5.9 6.5 19 1.7c9.2-3.4 15.3-3.9 18.8-3.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M340.6 112.3h-55.2l-2.7 6.2H338l2.6-6.2Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M236.8 117.9c-16.13 0-29.2 13.07-29.2 29.2s13.07 29.2 29.2 29.2 29.2-13.07 29.2-29.2-13.07-29.2-29.2-29.2Z" fill="#00E4E5"/><path d="M265 123.3c13.1 13.1 13.1 34.4 0 47.6M306 205.9h19.2M61.7 205.9h32.9M181.2 196.2h115.2M47.5 205.9h10v-9.7h73.8" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M146.7 179.2c-2.49 0-4.5 2.01-4.5 4.5s2.01 4.5 4.5 4.5 4.5-2.01 4.5-4.5-2.01-4.5-4.5-4.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M169.5 196.2c3.9 0 7.1 3.2 7.1 7.1 0 3.9-3.2 7.1-7.1 7.1H144c-2.1 0-3.9 1.7-3.9 3.9v1c0 2.1 1.7 3.9 3.9 3.9h48c5.1 0 9.2 4.1 9.2 9.2s-4.1 9.3-9.2 9.2h-33.8c-2.3 0-4.1 1.8-4.1 4.1s1.8 4.1 4.1 4.1h4.2c4.4 0 8 3.6 8 8s-3.6 8-8 8H111c-3.7 0-6.8-3-6.8-6.8 0-3.7 3-6.8 6.8-6.8h.3c2.3 0 4.1-1.8 4.1-4.1s-1.8-4.1-4.1-4.1H79c-4.5 0-8.1-3.6-8.1-8.1s3.6-8.1 8.1-8.1h37.7c2.1 0 3.9-1.7 3.9-3.9 0-2.1-1.7-3.9-3.9-3.9h-7.9c-4.4 0-7.9-3.5-7.9-7.9s3.5-7.9 7.9-7.9h30.4c2.2 0 3.9-1.8 3.9-3.9V187c0-1.9 1.6-3.5 3.5-3.5s3.5 1.6 3.5 3.5v5.3c0 2.2 1.8 3.9 3.9 3.9h15.5Z" fill="#006EFF"/><path d="m227.8 138.5 18.7 18.7M227.8 157.2l18.7-18.7" stroke="#fff" stroke-width="6"/><path fill-rule="evenodd" clip-rule="evenodd" d="M194.8 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8ZM202.9 96.9c-.99 0-1.8.81-1.8 1.8s.81 1.8 1.8 1.8 1.8-.81 1.8-1.8-.81-1.8-1.8-1.8Z" fill="#fff"/><path d="m291.7 184.3-1.6 4.6h-121M298.1 166.7l22.5-61.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m193 134.1 2.2-5.1h-19.4l-2.3 5.1H193ZM313.2 123.5l2.2-5.1h-24.5l-2.3 5.1h24.6Z" fill="#DEEBFC"/><path d="m164.5 159.2 19.8-54.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M199.6 119.8h-53.2l-4.4 9.3h53.2l4.4-9.3Z" fill="#00E4E5"/><path d="M151.3 129.1H142l4.4-9.3h16.9" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M353.3 169.4h-67.4l-4.8 12.2h67.3l4.9-12.2Z" fill="#006EFF"/><path d="M332.4 169.4h20.9l-4.9 12.2h-39.7M242.7 235.5v-4.8c0-3.8 3.1-7 7-7h20.2c3.8 0 7 3.1 7 7" stroke="#071F4D"/><path d="M261.1 235.5v-4.8c0-3.8 3.1-7 7-7h13.7c3.8 0 7 3.1 7 7v4.8M242.6 230.7h13.7M235.2 237.7h63.3M224 237.7h6.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M324.1 141.3H335l3.3-10.7h-10.2l-4 10.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M288.3 230.4c0-3.6-2.9-6.5-6.5-6.5h-14.2c-3.6 0-6.5 2.9-6.5 6.5v5.3h27.2v-5.3Z" fill="#071F4D"/><path d="M80.4 228.5H83M87.7 228.5h19.2M146.3 195.8v2c0 3.6-2.9 6.6-6.6 6.6H138M133.4 204.3h1.5M154 249.9h9.4" stroke="#DEEBFC"/><path d="m299.4 141.9 5.1-13.9" stroke="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1 @@
<svg viewBox="0 0 400 300" fill="none" xmlns="http://www.w3.org/2000/svg"><mask id="a" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="44" y="42" width="312" height="217"><path d="M355.3 42H44v216.9h311.3V42Z" fill="#fff"/></mask><g mask="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M288.2 248.4h25.1v-30h-25.1v30Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M304.498 238.199c-1.5-3.9-5.9-15.4-4-21.6-2.9.8-3.3.1-5-.1-1.7-.1 0 10.7 2.2 16.4 1.7 4.5 2.1 11.1 2.1 13.6h5.4c.2-1.9.3-5.5-.7-8.3Z" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6" fill="#fff"/><path d="M311.5 214.7v-1.6c0-.7-.6-1.3-1.3-1.3h-22.8c-.7 0-1.3.6-1.3 1.3v1.6M290.2 214.7h21.4c1 0 1.8.8 1.8 1.8v29" stroke="#071F4D" stroke-width="1.096"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" fill="#fff"/><path d="M284.3 245.6v-29c0-1 .8-1.8 1.8-1.8h1.6" stroke="#071F4D" stroke-width="1.096"/><path d="M295.402 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3M300.502 216.5c-.9 4.2-.4 9.7 2.8 17.5 2.4 5.9 1.9 10.2 1.8 12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m331 258.4-.3-5.2H88.5l-1.2 5.2H331Z" fill="#C7DEFF"/><path d="M252.9 248.7H331M216.6 258.4H331M47.1 139.3l-2.6 1.5 42.7 117.6h129.2v-6.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" fill="#fff"/><path d="m247.2 248.6-40.4-111.3H50.5l40.3 111.3h156.4Z" stroke="#071F4D"/><path d="m203.2 153.2 32.2 88.7H97.8l-32.3-88.7" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M72.2 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4ZM79.3 146.9c-.77 0-1.4.63-1.4 1.4 0 .77.63 1.4 1.4 1.4.77 0 1.4-.63 1.4-1.4 0-.77-.63-1.4-1.4-1.4Z" fill="#fff"/><path fill-rule="evenodd" clip-rule="evenodd" d="M263.5 171.2h80.3v-63.7h-80.3v63.7Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M290 143.9h-45.6l12.5 51.3H290v-51.3Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M286 117.4h-29.3v77.8h92.9v-67.6l-55.9.6-7.7-10.8Z" fill="#00E4E5"/><path d="m332.6 127.6-38.9.6-7.7-10.8h-11.7M308.9 195.2h45.9M250.3 195.2h28.5M287.3 195.2h12.3" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M130.5 211.4H186v-44h-55.5v44Z" fill="#C7DEFF"/><path fill-rule="evenodd" clip-rule="evenodd" d="M148.7 192.5h-31.6l8.7 35.5h22.9v-35.5Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M145.9 174.2h-20.2V228h64.1v-46.7l-38.6.4-5.3-7.5Z" fill="#006EFF"/><path d="m179 181.3-27.8.4-5.3-7.5h-7.7M176.2 201.7h19.2M163.2 210.7H195M172.1 228h-54.2M184.8 228h8.1M174.9 228h5.4" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="m293.2 155.7-6.4 6.3 15.3 15.3 22.7-22.6-6.4-6.4-16.3 16.3-8.9-8.9Z" fill="#fff"/><path d="M57.2 258.4h283.6M345.9 258.4h8.1M55.4 258.4h220.5M160.1 118.8l-1.2 2.7M156.7 127c-.3.8-.7 1.8-1.1 2.8M222 68.5c-1 .2-1.9.5-2.9.8M214.1 70.7c-5.8 1.9-11.3 4.4-16.5 7.4M195.4 79.5c-.9.5-1.7 1.1-2.5 1.6M314.2 98.5c-.6-.8-1.3-1.5-2-2.3M308.9 92.8c-4-4-8.3-7.6-13-10.8M293.9 80.7c-.8-.5-1.7-1.1-2.5-1.6" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.296 71.203c-3.6-1.5-18.5-2.9-21.8-1.9-1 5.8 4.9 13.5 4.9 13.5s6-9.9 16.9-11.6Z" fill="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M251.3 42.704c-6.5 6.7-7.8 13-8.8 19.3 24.4-1.1 36.3 13 42.8 20 3.2-9.1 7.8-23 7.2-29-7.1-6.4-20-11.7-41.2-10.3Z" fill="#C7DEFF"/><path d="M230 69.3c36.2-3.8 52 21.1 52 21.1s11.4-28.2 10.5-37.4c-7.3-6.5-23.3-12-45.6-10.1-9 6.3-15.6 18.7-16.9 26.4Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M161.604 70.7c-6 8.4-9.9 21.9-8.8 33.8 8.4 5.3 32.3 10.5 43.6 11.5 6.1-7.9 15.9-26 15.9-26s-32-4.8-50.7-19.3Z" fill="#C7DEFF"/><path d="M193.103 119.5c4.8-2.7 19.2-29.5 19.2-29.5s-35.8-5.4-53.7-21.8c-9.3 6.1-16.4 24.3-15 40.1 10.6 6.7 45.8 13.3 49.5 11.2Z" stroke="#071F4D"/><path fill-rule="evenodd" clip-rule="evenodd" d="M189.5 111.6c-3 5.2-5.7 7.2-9.8 6.6 12.2 2.6 13.5 1.2 15.6-1.1 2.2-2.4 4.2-6.6 4.2-6.6s-3.1 2.5-10 1.1Z" fill="#071F4D"/><path d="M331 251.8v6.6M77 165.4l-2.7-6.7h7.8M222.8 228.9l2.8 6.6h-7.9" stroke="#071F4D"/></g></svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,292 @@
// 全局样式
// 顶部进度条颜色
#nprogress .bar {
z-index: 2400;
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
}
#nprogress .peg {
box-shadow:
0 0 10px var(--theme-color),
0 0 5px var(--theme-color) !important;
}
#nprogress .spinner-icon {
border-top-color: var(--theme-color) !important;
border-left-color: var(--theme-color) !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
* {
cursor: default !important;
}
}
// 背景滤镜
*,
::before,
::after {
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
// 色弱模式
.color-weak {
filter: invert(80%);
-webkit-filter: invert(80%);
}
#noop {
display: none;
}
// 语言切换选中样式
.langDropDownStyle {
// 选中项背景颜色
.is-selected {
background-color: var(--art-el-active-color) !important;
}
// 语言切换按钮菜单样式优化
.lang-btn-item {
.el-dropdown-menu__item {
padding-left: 13px !important;
padding-right: 6px !important;
margin-bottom: 3px !important;
}
&:last-child {
.el-dropdown-menu__item {
margin-bottom: 0 !important;
}
}
.menu-txt {
min-width: 60px;
display: block;
}
i {
font-size: 10px;
margin-left: 10px;
}
}
}
// 盒子默认边框
.page-content {
border: 1px solid var(--art-card-border) !important;
}
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
background: var(--default-box-color);
border: 1px solid #{$border-color} !important;
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
box-shadow: #{$shadow} !important;
--el-card-border-color: var(--default-border) !important;
}
.art-card,
.art-card-sm,
.art-card-xs {
border: 1px solid var(--art-card-border);
}
// 盒子边框
[data-box-mode='border-mode'] {
.page-content,
.art-table-card {
border: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(var(--art-card-border), none, 4px);
}
.art-card-sm {
@include art-card-base(var(--art-card-border), none, 0px);
}
.art-card-xs {
@include art-card-base(var(--art-card-border), none, -4px);
}
}
// 盒子阴影
[data-box-mode='shadow-mode'] {
.page-content,
.art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid var(--art-gray-200) !important;
}
.layout-sidebar {
border-right: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
4px
);
}
.art-card-sm {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
2px
);
}
.art-card-xs {
@include art-card-base(
var(--art-gray-200),
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
-4px
);
}
}
// 元素全屏
.el-full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw !important;
height: 100% !important;
z-index: 2300;
margin-top: 0;
padding: 15px;
box-sizing: border-box;
background-color: var(--default-box-color);
display: flex;
flex-direction: column;
}
// 表格卡片
.art-table-card {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 12px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body {
height: 100%;
overflow: hidden;
}
}
// 容器全高
.art-full-height {
height: var(--art-full-height);
display: flex;
flex-direction: column;
@media (max-width: 640px) {
height: auto;
}
}
// 徽章样式
.art-badge {
position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: 6px;
height: 6px;
margin: auto;
background: #ff3860;
border-radius: 50%;
animation: breathe 1.5s ease-in-out infinite;
&.art-badge-horizontal {
right: 0;
}
&.art-badge-mixed {
right: 0;
}
&.art-badge-dual {
right: 5px;
top: 5px;
bottom: auto;
}
}
// 文字徽章样式
.art-text-badge {
position: absolute;
top: 0;
right: 12px;
bottom: 0;
min-width: 20px;
height: 18px;
line-height: 17px;
padding: 0 5px;
margin: auto;
font-size: 10px;
color: #fff;
text-align: center;
background: #fd4e4e;
border-radius: 4px;
}
@keyframes breathe {
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
// 修复老机型 loading 定位问题
.art-loading-fix {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.art-loading-fix .el-loading-spinner {
position: static !important;
top: auto !important;
left: auto !important;
transform: none !important;
}
// 去除移动端点击背景色
@media screen and (max-width: 1180px) {
* {
-webkit-tap-highlight-color: transparent;
}
}

View File

@@ -0,0 +1,93 @@
/*
* 深色主题
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
*/
$font-color: rgba(#ffffff, 0.85);
/* 覆盖element-plus默认深色背景色 */
html.dark {
// element-plus
--el-bg-color: var(--default-box-color);
--el-text-color-regular: #{$font-color};
// 富文本编辑器
// 工具栏背景颜色
--w-e-toolbar-bg-color: #18191c;
// 输入区域背景颜色
--w-e-textarea-bg-color: #090909;
// 工具栏文字颜色
--w-e-toolbar-color: var(--art-gray-600);
// 选中菜单颜色
--w-e-toolbar-active-bg-color: #25262b;
// 弹窗边框颜色
--w-e-toolbar-border-color: var(--default-border-dashed);
// 分割线颜色
--w-e-textarea-border-color: var(--default-border-dashed);
// 链接输入框边框颜色
--w-e-modal-button-border-color: var(--default-border-dashed);
// 表格头颜色
--w-e-textarea-slight-bg-color: #090909;
// 按钮背景颜色
--w-e-modal-button-bg-color: #090909;
// hover toolbar 背景颜色
--w-e-toolbar-active-color: var(--art-gray-800);
}
.dark {
.page-content .article-list .item .left .outer > div {
border-right-color: var(--dark-border-color) !important;
}
// 富文本编辑器
.editor-wrapper {
*:not(pre code *) {
color: inherit !important;
}
}
// 分隔线
.w-e-bar-divider {
background-color: var(--art-gray-300) !important;
}
.w-e-select-list,
.w-e-drop-panel,
.w-e-bar-item-group .w-e-bar-item-menus-container,
.w-e-text-container [data-slate-editor] pre > code {
border: 1px solid var(--default-border) !important;
}
// 下拉选择框
.w-e-select-list {
background-color: var(--default-box-color) !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover,
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: #090909 !important;
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
background-color: #25262b !important;
text-shadow: none !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
border-left: 4px solid var(--default-border-dashed) !important;
background-color: var(--art-color);
}
.editor-wrapper {
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--default-border-dashed) !important;
}
.w-e-modal {
background-color: var(--art-color);
}
}
}

View File

@@ -0,0 +1,2 @@
// 导入暗黑主题
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;

View File

@@ -0,0 +1,34 @@
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
// 自定义Element 亮色主题
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'white': #ffffff,
'black': #000000,
'success': (
'base': #13deb9
),
'warning': (
'base': #ffae1f
),
'danger': (
'base': #ff4d4f
),
'error': (
'base': #fa896b
)
),
$button: (
'hover-bg-color': var(--el-color-primary-light-9),
'hover-border-color': var(--el-color-primary),
'border-color': var(--el-color-primary),
'text-color': var(--el-color-primary)
),
$messagebox: (
'border-radius': '12px'
),
$popover: (
'padding': '14px',
'border-radius': '10px'
)
);

View File

@@ -0,0 +1,519 @@
// 优化 Element Plus 组件库默认样式
:root {
// 系统主色
--main-color: var(--el-color-primary);
--el-color-white: white !important;
--el-color-black: white !important;
// 输入框边框颜色
// --el-border-color: #E4E4E7 !important; // DCDFE6
// 按钮粗度
--el-font-weight-primary: 400 !important;
--el-component-custom-height: 36px !important;
--el-component-size: var(--el-component-custom-height) !important;
// 边框、按钮圆角...
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--theme-color);
}
}
// 优化 el-form-item 标签高度
.el-form-item__label {
height: var(--el-component-custom-height) !important;
line-height: var(--el-component-custom-height) !important;
}
// 日期选择器
.el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
}
// el-card 背景色跟系统背景色保持一致
html.dark .el-card {
--el-card-bg-color: var(--default-box-color) !important;
}
// 修改 el-pagination 大小
.el-pagination--default {
& {
--el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important;
}
@media (max-width: 1180px) {
& {
--el-pagination-button-width: 28px !important;
}
}
.el-select--default .el-select__wrapper {
min-height: var(--el-pagination-button-width) !important;
}
.el-pagination__jump .el-input {
height: var(--el-pagination-button-width) !important;
}
}
.el-pager li {
padding: 0 10px !important;
// border: 1px solid red !important;
}
// 优化菜单折叠展开动画(提升动画流畅度)
.el-menu.el-menu--inline {
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
// 优化菜单 item hover 动画(提升鼠标跟手感)
.el-sub-menu__title,
.el-menu-item {
transition: background-color 0s !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
// 修改 el-button 高度
.el-button--default {
height: var(--el-component-custom-height) !important;
}
// circle 按钮宽度优化
.el-button--default.is-circle {
width: var(--el-component-custom-height) !important;
}
// 修改 el-select 高度
.el-select--default {
.el-select__wrapper {
min-height: var(--el-component-custom-height) !important;
}
}
// 修改 el-checkbox-button 高度
.el-checkbox-button--default .el-checkbox-button__inner,
// 修改 el-radio-button 高度
.el-radio-button--default .el-radio-button__inner {
padding: 10px 15px !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
.el-pagination.is-background .btn-next,
.el-pagination.is-background .btn-prev,
.el-pagination.is-background .el-pager li {
border-radius: 6px;
}
.el-popover {
min-width: 80px;
border-radius: var(--el-border-radius-small) !important;
}
.el-dialog {
border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
overflow: hidden;
}
.el-dialog__header {
.el-dialog__title {
font-size: 16px;
}
}
.el-dialog__body {
padding: 25px 0 !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
}
.el-dialog.el-dialog-border {
.el-dialog__body {
// 上边框
&::before,
// 下边框
&::after {
content: '';
position: absolute;
left: -16px;
width: calc(100% + 32px);
height: 1px;
background-color: var(--art-gray-300);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
}
// el-message 样式优化
.el-message {
background-color: var(--default-box-color) !important;
border: 0 !important;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
p {
font-size: 13px;
}
}
// 修改 el-dropdown 样式
.el-dropdown-menu {
padding: 6px !important;
border-radius: 10px !important;
border: none !important;
.el-dropdown-menu__item {
padding: 6px 16px !important;
border-radius: 6px !important;
&:hover:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-el-active-color) !important;
}
&:focus:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-gray-200) !important;
}
}
}
// 隐藏 select、dropdown 的三角
.el-select__popper,
.el-dropdown__popper {
margin-top: -6px !important;
.el-popper__arrow {
display: none;
}
}
.el-dropdown-selfdefine:focus {
outline: none !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
.el-message-box,
.el-dialog {
width: calc(100% - 24px) !important;
}
.el-date-picker.has-sidebar.has-time {
width: calc(100% - 24px);
left: 12px !important;
}
.el-picker-panel *[slot='sidebar'],
.el-picker-panel__sidebar {
display: none;
}
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
.el-picker-panel__sidebar + .el-picker-panel__body {
margin-left: 0;
}
}
// 修改el-button样式
.el-button {
&.el-button--text {
background-color: transparent !important;
padding: 0 !important;
span {
margin-left: 0 !important;
}
}
}
// 修改el-tag样式
.el-tag {
font-weight: 500;
transition: all 0s !important;
&.el-tag--default {
height: 26px !important;
}
}
.el-checkbox-group {
&.el-table-filter__checkbox-group label.el-checkbox {
height: 17px !important;
.el-checkbox__label {
font-weight: 400 !important;
}
}
}
.el-radio--default {
// 优化单选按钮大小
.el-radio__input {
.el-radio__inner {
width: 16px;
height: 16px;
&::after {
width: 6px;
height: 6px;
}
}
}
}
.el-checkbox {
.el-checkbox__inner {
border-radius: 2px !important;
}
}
// 优化复选框样式
.el-checkbox--default {
.el-checkbox__inner {
width: 16px !important;
height: 16px !important;
border-radius: 4px !important;
&::before {
content: '';
height: 4px !important;
top: 5px !important;
background-color: #fff !important;
transform: scale(0.6) !important;
}
}
.is-checked {
.el-checkbox__inner {
&::after {
width: 3px;
height: 8px;
margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0;
border-top: 0;
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
transform-origin: center;
}
}
}
}
.el-notification .el-notification__icon {
font-size: 22px !important;
}
// 修改 el-message-box 样式
.el-message-box__headerbtn .el-message-box__close,
.el-dialog__headerbtn .el-dialog__close {
top: 7px;
right: 7px;
width: 30px;
height: 30px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background-color: var(--art-hover-color) !important;
color: var(--art-gray-900) !important;
}
}
.el-message-box {
padding: 25px 20px !important;
}
.el-message-box__title {
font-weight: 500 !important;
}
.el-table__column-filter-trigger i {
color: var(--theme-color) !important;
margin: -3px 0 0 2px;
}
// 去除 el-dropdown 鼠标放上去出现的边框
.el-tooltip__trigger:focus-visible {
outline: unset;
}
// ipad 表单右侧按钮优化
@media screen and (max-width: 1180px) {
.el-table-fixed-column--right {
padding-right: 0 !important;
}
}
.login-out-dialog {
padding: 30px 20px !important;
border-radius: 10px !important;
}
// 修改 dialog 动画
.dialog-fade-enter-active {
.el-dialog:not(.is-draggable) {
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
// 修复 el-dialog 动画后宽度不自适应问题
.el-select__selected-item {
display: inline-block;
}
}
}
.dialog-fade-leave-active {
animation: fade-out 0.2s linear;
.el-dialog:not(.is-draggable) {
animation: dialog-close 0.5s;
}
}
@keyframes dialog-open {
0% {
opacity: 0;
transform: scale(0.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.2);
}
}
// 遮罩层动画
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
// 修改 el-select 样式
.el-select__popper:not(.el-tree-select__popper) {
.el-select-dropdown__list {
padding: 5px !important;
.el-select-dropdown__item {
height: 34px !important;
line-height: 34px !important;
border-radius: 6px !important;
&.is-selected {
color: var(--art-gray-900) !important;
font-weight: 400 !important;
background-color: var(--art-el-active-color) !important;
margin-bottom: 4px !important;
}
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.el-select-dropdown__item:hover ~ .is-selected,
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
background-color: transparent !important;
}
}
}
// 修改 el-tree-select 样式
.el-tree-select__popper {
.el-select-dropdown__list {
padding: 5px !important;
.el-tree-node {
.el-tree-node__content {
height: 36px !important;
border-radius: 6px !important;
&:hover {
background-color: var(--art-gray-200) !important;
}
}
}
}
}
// 实现水波纹在文字下面效果
.el-button > span {
position: relative;
z-index: 10;
}
// 优化颜色选择器圆角
.el-color-picker__color {
border-radius: 2px !important;
}
// 优化日期时间选择器底部圆角
.el-picker-panel {
.el-picker-panel__footer {
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
}
}
// 优化树型菜单样式
.el-tree-node__content {
border-radius: 4px;
margin-bottom: 4px;
padding: 1px 0;
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.dark {
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: var(--art-gray-300) !important;
}
}
// 隐藏折叠菜单弹窗 hover 出现的边框
.menu-left-popper:focus-within,
.horizontal-menu-popper:focus-within {
box-shadow: none !important;
outline: none !important;
}
// 数字输入组件右侧按钮高度跟随自定义组件高度
.el-input-number--default.is-controls-right {
.el-input-number__decrease,
.el-input-number__increase {
height: calc((var(--el-component-size) / 2)) !important;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,157 @@
// sass 混合宏(函数)
/**
* 溢出省略号
* @param {Number} 行数
*/
@mixin ellipsis($rowCount: 1) {
@if $rowCount <=1 {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} @else {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: $rowCount;
-webkit-box-orient: vertical;
}
}
/**
* 控制用户能否选中文本
* @param {String} 类型
*/
@mixin userSelect($value: none) {
user-select: $value;
-moz-user-select: $value;
-ms-user-select: $value;
-webkit-user-select: $value;
}
// 绝对定位居中
@mixin absoluteCenter() {
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
margin: auto;
}
/**
* css3动画
*
*/
@mixin animation(
$from: (
width: 0px
),
$to: (
width: 100px
),
$name: mymove,
$animate: mymove 2s 1 linear infinite
) {
-webkit-animation: $animate;
-o-animation: $animate;
animation: $animate;
@keyframes #{$name} {
from {
@each $key, $value in $from {
#{$key}: #{$value};
}
}
to {
@each $key, $value in $to {
#{$key}: #{$value};
}
}
}
@-webkit-keyframes #{$name} {
from {
@each $key, $value in $from {
$key: $value;
}
}
to {
@each $key, $value in $to {
$key: $value;
}
}
}
}
// 圆形盒子
@mixin circle($size: 11px, $bg: #fff) {
border-radius: 50%;
width: $size;
height: $size;
line-height: $size;
text-align: center;
background: $bg;
}
// placeholder
@mixin placeholder($color: #bbb) {
// Firefox
&::-moz-placeholder {
color: $color;
opacity: 1;
}
// Internet Explorer 10+
&:-ms-input-placeholder {
color: $color;
}
// Safari and Chrome
&::-webkit-input-placeholder {
color: $color;
}
&:placeholder-shown {
text-overflow: ellipsis;
}
}
//背景透明文字不透明。兼容IE8
@mixin betterTransparentize($color, $alpha) {
$c: rgba($color, $alpha);
$ie_c: ie_hex_str($c);
background: rgba($color, 1);
background: $c;
background: transparent \9;
zoom: 1;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c});
-ms-filter: 'progid:DXImageTransform.Microsoft.gradient(startColorstr=#{$ie_c}, endColorstr=#{$ie_c})';
}
//添加浏览器前缀
@mixin browserPrefix($propertyName, $value) {
@each $prefix in -webkit-, -moz-, -ms-, -o-, '' {
#{$prefix}#{$propertyName}: $value;
}
}
// 边框
@mixin border($color: red) {
border: 1px solid $color;
}
// 背景滤镜
@mixin backdropBlur() {
--tw-backdrop-blur: blur(30px);
-webkit-backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness)
var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate)
var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate)
var(--tw-backdrop-sepia);
backdrop-filter: var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast)
var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert)
var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);
}

View File

@@ -0,0 +1,41 @@
@charset "UTF-8";
/*滚动条*/
/*滚动条整体部分,必须要设置*/
::-webkit-scrollbar {
width: 8px !important;
height: 0 !important;
}
/*滚动条的轨道*/
::-webkit-scrollbar-track {
background-color: var(--art-gray-200);
}
/*滚动条的滑块按钮*/
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: #cccccc !important;
transition: all 0.2s;
-webkit-transition: all 0.2s;
}
::-webkit-scrollbar-thumb:hover {
background-color: #b0abab !important;
}
/*滚动条的上下两端的按钮*/
::-webkit-scrollbar-button {
height: 0px;
width: 0;
}
.dark {
::-webkit-scrollbar-track {
background-color: var(--default-bg-color);
}
::-webkit-scrollbar-thumb {
background-color: var(--art-gray-300) !important;
}
}

View File

@@ -0,0 +1,104 @@
@use 'sass:map';
// === 变量区域 ===
$transition: (
// 动画持续时间
duration: 0.25s,
// 滑动动画的移动距离
distance: 15px,
// 默认缓动函数
easing: cubic-bezier(0.25, 0.1, 0.25, 1),
// 淡入淡出专用的缓动函数
fade-easing: cubic-bezier(0.4, 0, 0.6, 1)
);
// 抽取配置值函数,提高可复用性
@function transition-config($key) {
@return map.get($transition, $key);
}
// 变量简写
$duration: transition-config('duration');
$distance: transition-config('distance');
$easing: transition-config('easing');
$fade-easing: transition-config('fade-easing');
// === 动画类 ===
// 淡入淡出动画
.fade {
&-enter-active,
&-leave-active {
transition: opacity $duration $fade-easing;
will-change: opacity;
}
&-enter-from,
&-leave-to {
opacity: 0;
}
&-enter-to,
&-leave-from {
opacity: 1;
}
}
// 滑动动画通用样式
@mixin slide-transition($direction) {
$distance-x: 0;
$distance-y: 0;
@if $direction == 'left' {
$distance-x: -$distance;
} @else if $direction == 'right' {
$distance-x: $distance;
} @else if $direction == 'top' {
$distance-y: -$distance;
} @else if $direction == 'bottom' {
$distance-y: $distance;
}
&-enter-active {
transition:
opacity $duration $easing,
transform $duration $easing;
will-change: opacity, transform;
}
&-leave-active {
transition:
opacity calc($duration * 0.7) $easing,
transform calc($duration * 0.7) $easing;
will-change: opacity, transform;
}
&-enter-from {
opacity: 0;
transform: translate3d($distance-x, $distance-y, 0);
}
&-enter-to {
opacity: 1;
transform: translate3d(0, 0, 0);
}
&-leave-to {
opacity: 0;
transform: translate3d(-$distance-x, -$distance-y, 0);
}
}
// 滑动动画方向类
.slide-left {
@include slide-transition('left');
}
.slide-right {
@include slide-transition('right');
}
.slide-top {
@include slide-transition('top');
}
.slide-bottom {
@include slide-transition('bottom');
}

View File

@@ -0,0 +1,208 @@
@import 'tailwindcss';
@custom-variant dark (&:where(.dark, .dark *));
/* ==================== Light Mode Variables ==================== */
:root {
/* Base Colors */
--art-color: #ffffff;
--theme-color: var(--main-color);
/* Theme Colors - OKLCH Format */
--art-primary: oklch(0.7 0.23 260);
--art-secondary: oklch(0.72 0.19 231.6);
--art-error: oklch(0.73 0.15 25.3);
--art-info: oklch(0.58 0.03 254.1);
--art-success: oklch(0.78 0.17 166.1);
--art-warning: oklch(0.78 0.14 75.5);
--art-danger: oklch(0.68 0.22 25.3);
/* Gray Scale - Light Mode */
--art-gray-100: #f9fafb;
--art-gray-200: #f2f4f5;
--art-gray-300: #e6eaeb;
--art-gray-400: #dbdfe1;
--art-gray-500: #949eb7;
--art-gray-600: #7987a1;
--art-gray-700: #4d5875;
--art-gray-800: #383853;
--art-gray-900: #323251;
/* Border Colors */
--art-card-border: rgba(0, 0, 0, 0.08);
--default-border: #e2e8ee;
--default-border-dashed: #dbdfe9;
/* Background Colors */
--default-bg-color: #fafbfc;
--default-box-color: #ffffff;
/* Hover Color */
--art-hover-color: #edeff0;
/* Active Color */
--art-active-color: #f2f4f5;
/* Element Component Active Color */
--art-el-active-color: #f2f4f5;
}
/* ==================== Dark Mode Variables ==================== */
.dark {
/* Base Colors */
--art-color: #000000;
/* Gray Scale - Dark Mode */
--art-gray-100: #110f0f;
--art-gray-200: #17171c;
--art-gray-300: #393946;
--art-gray-400: #505062;
--art-gray-500: #73738c;
--art-gray-600: #8f8fa3;
--art-gray-700: #ababba;
--art-gray-800: #c7c7d1;
--art-gray-900: #e3e3e8;
/* Border Colors */
--art-card-border: rgba(255, 255, 255, 0.08);
--default-border: rgba(255, 255, 255, 0.1);
--default-border-dashed: #363843;
/* Background Colors */
--default-bg-color: #070707;
--default-box-color: #161618;
/* Hover Color */
--art-hover-color: #252530;
/* Active Color */
--art-active-color: #202226;
/* Element Component Active Color */
--art-el-active-color: #2e2e38;
}
/* ==================== Tailwind Theme Configuration ==================== */
@theme {
/* Box Color (Light: white / Dark: black) */
--color-box: var(--default-box-color);
/* System Theme Color */
--color-theme: var(--theme-color);
/* Hover Color */
--color-hover-color: var(--art-hover-color);
/* Active Color */
--color-active-color: var(--art-active-color);
/* Active Color */
--color-el-active-color: var(--art-active-color);
/* ElementPlus Theme Colors */
--color-primary: var(--art-primary);
--color-secondary: var(--art-secondary);
--color-error: var(--art-error);
--color-info: var(--art-info);
--color-success: var(--art-success);
--color-warning: var(--art-warning);
--color-danger: var(--art-danger);
/* Gray Scale Colors (Auto-adapts to dark mode) */
--color-g-100: var(--art-gray-100);
--color-g-200: var(--art-gray-200);
--color-g-300: var(--art-gray-300);
--color-g-400: var(--art-gray-400);
--color-g-500: var(--art-gray-500);
--color-g-600: var(--art-gray-600);
--color-g-700: var(--art-gray-700);
--color-g-800: var(--art-gray-800);
--color-g-900: var(--art-gray-900);
}
/* ==================== Custom Border Radius Utilities ==================== */
@utility rounded-custom-xs {
border-radius: calc(var(--custom-radius) / 2);
}
@utility rounded-custom-sm {
border-radius: calc(var(--custom-radius) / 2 + 2px);
}
/* ==================== Custom Utility Classes ==================== */
@layer utilities {
/* Flexbox Layout Utilities */
.flex-c {
@apply flex items-center;
}
.flex-b {
@apply flex justify-between;
}
.flex-cc {
@apply flex items-center justify-center;
}
.flex-cb {
@apply flex items-center justify-between;
}
/* Transition Utilities */
.tad-200 {
@apply transition-all duration-200;
}
.tad-300 {
@apply transition-all duration-300;
}
/* Border Utilities */
.border-full-d {
@apply border border-[var(--default-border)];
}
.border-b-d {
@apply border-b border-[var(--default-border)];
}
.border-t-d {
@apply border-t border-[var(--default-border)];
}
.border-l-d {
@apply border-l border-[var(--default-border)];
}
.border-r-d {
@apply border-r border-[var(--default-border)];
}
/* Cursor Utilities */
.c-p {
@apply cursor-pointer;
}
}
/* ==================== Custom Component Classes ==================== */
@layer components {
/* Art Card Header Component */
.art-card-header {
@apply flex justify-between pr-6 pb-1;
.title {
h4 {
@apply text-lg font-medium text-g-900;
}
p {
@apply mt-1 text-sm text-g-600;
span {
@apply ml-2 font-medium;
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
// 定义基础变量
$bg-animation-color-light: #000;
$bg-animation-color-dark: #fff;
$bg-animation-duration: 0.5s;
html {
--bg-animation-color: $bg-animation-color-light;
&.dark {
--bg-animation-color: $bg-animation-color-dark;
}
// View transition styles
&::view-transition-old(*) {
animation: none;
}
&::view-transition-new(*) {
animation: clip $bg-animation-duration ease-in both;
}
&::view-transition-old(root) {
z-index: 1;
}
&::view-transition-new(root) {
z-index: 9999;
}
&.dark {
&::view-transition-old(*) {
animation: clip $bg-animation-duration ease-in reverse both;
}
&::view-transition-new(*) {
animation: none;
}
&::view-transition-old(root) {
z-index: 9999;
}
&::view-transition-new(root) {
z-index: 1;
}
}
}
// 定义动画
@keyframes clip {
from {
clip-path: circle(0% at var(--x) var(--y));
}
to {
clip-path: circle(var(--r) at var(--x) var(--y));
}
}
// body 相关样式
body {
background-color: var(--bg-animation-color);
}

View File

@@ -0,0 +1,11 @@
// 主题切换过渡优化,优化除视觉上的不适感
.theme-change {
* {
transition: 0s !important;
}
.el-switch__core,
.el-switch__action {
transition: all 0.3s !important;
}
}

View File

@@ -0,0 +1,98 @@
.hljs {
display: block;
overflow-x: auto;
padding: 0.5em;
color: #a6accd;
}
.hljs-string,
.hljs-section,
.hljs-selector-class,
.hljs-template-variable,
.hljs-deletion {
color: #aed07e !important;
}
.hljs-comment,
.hljs-quote {
color: #6f747d;
}
.hljs-doctag,
.hljs-keyword,
.hljs-formula {
color: #c792ea;
}
.hljs-section,
.hljs-name,
.hljs-selector-tag,
.hljs-deletion,
.hljs-subst {
color: #c86068;
}
.hljs-literal {
color: #56b6c2;
}
.hljs-string,
.hljs-regexp,
.hljs-addition,
.hljs-attribute,
.hljs-meta-string {
color: #abb2bf;
}
.hljs-attribute {
color: #c792ea;
}
.hljs-function {
color: #c792ea;
}
.hljs-type {
color: #f07178;
}
.hljs-title {
color: #82aaff !important;
}
.hljs-built_in,
.hljs-class {
color: #82aaff;
}
// 括号
.hljs-params {
color: #a6accd;
}
.hljs-attr,
.hljs-variable,
.hljs-template-variable,
.hljs-selector-class,
.hljs-selector-attr,
.hljs-selector-pseudo,
.hljs-number {
color: #de7e61;
}
.hljs-symbol,
.hljs-bullet,
.hljs-link,
.hljs-meta,
.hljs-selector-id {
color: #61aeee;
}
.hljs-strong {
font-weight: bold;
}
.hljs-link {
text-decoration: underline;
}

View File

@@ -0,0 +1,23 @@
// 重置默认样式
@use './core/reset.scss';
// 应用全局样式
@use './core/app.scss';
// Element Plus 样式优化
@use './core/el-ui.scss';
// Element Plus 暗黑主题
@use './core/el-dark.scss';
// 暗黑主题样式优化
@use './core/dark.scss';
// 路由切换动画
@use './core/router-transition';
// 主题切换过渡优化
@use './core/theme-change.scss';
// 主题切换圆形扩散动画
@use './core/theme-animation.scss';

32
src/assets/svg/loading.ts Normal file
View File

@@ -0,0 +1,32 @@
// 自定义四点旋转SVG
export const fourDotsSpinnerSvg = `
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<style>
.spinner {
transform-origin: 20px 20px;
animation: rotate 1.6s linear infinite;
}
.dot {
fill: var(--theme-color);
animation: fade 1.6s infinite;
}
.dot:nth-child(1) { animation-delay: 0s; }
.dot:nth-child(2) { animation-delay: 0.5s; }
.dot:nth-child(3) { animation-delay: 1s; }
.dot:nth-child(4) { animation-delay: 1.5s; }
@keyframes rotate {
100% { transform: rotate(360deg); }
}
@keyframes fade {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
</style>
<g class="spinner">
<circle class="dot" cx="20" cy="8" r="4"/>
<circle class="dot" cx="32" cy="20" r="4"/>
<circle class="dot" cx="20" cy="32" r="4"/>
<circle class="dot" cx="8" cy="20" r="4"/>
</g>
</svg>
`

View File

@@ -0,0 +1,343 @@
<!-- 基础横幅组件 -->
<template>
<div
class="art-card basic-banner"
:class="[{ 'has-decoration': decoration }, boxStyle]"
:style="{ height }"
@click="emit('click')"
>
<!-- 流星效果 -->
<div v-if="meteorConfig?.enabled && isDark" class="basic-banner__meteors">
<span
v-for="(meteor, index) in meteors"
:key="index"
class="meteor"
:style="{
top: '-60px',
left: `${meteor.x}%`,
animationDuration: `${meteor.speed}s`,
animationDelay: `${meteor.delay}s`
}"
></span>
</div>
<div class="basic-banner__content">
<!-- title slot -->
<slot name="title">
<p v-if="title" class="basic-banner__title" :style="{ color: titleColor }">{{ title }}</p>
</slot>
<!-- subtitle slot -->
<slot name="subtitle">
<p v-if="subtitle" class="basic-banner__subtitle" :style="{ color: subtitleColor }">{{
subtitle
}}</p>
</slot>
<!-- button slot -->
<slot name="button">
<div
v-if="buttonConfig?.show"
class="basic-banner__button"
:style="{
backgroundColor: buttonColor,
color: buttonTextColor,
borderRadius: buttonRadius
}"
@click.stop="emit('buttonClick')"
>
{{ buttonConfig?.text }}
</div>
</slot>
<!-- default slot -->
<slot></slot>
<!-- background image -->
<img
v-if="imageConfig.src"
class="basic-banner__background-image"
:src="imageConfig.src"
:style="{ width: imageConfig.width, bottom: imageConfig.bottom, right: imageConfig.right }"
loading="lazy"
alt="背景图片"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
defineOptions({ name: 'ArtBasicBanner' })
// 流星对象接口定义
interface Meteor {
/** 流星的水平位置(百分比) */
x: number
/** 流星划过的速度 */
speed: number
/** 流星出现的延迟时间 */
delay: number
}
// 按钮配置接口定义
interface ButtonConfig {
/** 是否启用按钮 */
show: boolean
/** 按钮文本 */
text: string
/** 按钮背景色 */
color?: string
/** 按钮文字颜色 */
textColor?: string
/** 按钮圆角大小 */
radius?: string
}
// 流星效果配置接口定义
interface MeteorConfig {
/** 是否启用流星效果 */
enabled: boolean
/** 流星数量 */
count?: number
}
// 背景图片配置接口定义
interface ImageConfig {
/** 图片源地址 */
src: string
/** 图片宽度 */
width?: string
/** 距底部距离 */
bottom?: string
/** 距右侧距离 */
right?: string // 距右侧距离
}
// 组件属性接口定义
interface Props {
/** 横幅高度 */
height?: string
/** 标题文本 */
title?: string
/** 副标题文本 */
subtitle?: string
/** 盒子样式 */
boxStyle?: string
/** 是否显示装饰效果 */
decoration?: boolean
/** 按钮配置 */
buttonConfig?: ButtonConfig
/** 流星配置 */
meteorConfig?: MeteorConfig
/** 图片配置 */
imageConfig?: ImageConfig
/** 标题颜色 */
titleColor?: string
/** 副标题颜色 */
subtitleColor?: string
}
// 组件属性默认值设置
const props = withDefaults(defineProps<Props>(), {
height: '11rem',
titleColor: 'white',
subtitleColor: 'white',
boxStyle: '!bg-theme/60',
decoration: true,
buttonConfig: () => ({
show: true,
text: '查看',
color: '#fff',
textColor: '#333',
radius: '6px'
}),
meteorConfig: () => ({ enabled: false, count: 10 }),
imageConfig: () => ({ src: '', width: '12rem', bottom: '-3rem', right: '0' })
})
// 定义组件事件
const emit = defineEmits<{
(e: 'click'): void // 整体点击事件
(e: 'buttonClick'): void // 按钮点击事件
}>()
// 计算按钮样式属性
const buttonColor = computed(() => props.buttonConfig?.color ?? '#fff')
const buttonTextColor = computed(() => props.buttonConfig?.textColor ?? '#333')
const buttonRadius = computed(() => props.buttonConfig?.radius ?? '6px')
// 流星数据初始化
const meteors = ref<Meteor[]>([])
onMounted(() => {
if (props.meteorConfig?.enabled) {
meteors.value = generateMeteors(props.meteorConfig?.count ?? 10)
}
})
/**
* 生成流星数据数组
* @param count 流星数量
* @returns 流星数据数组
*/
function generateMeteors(count: number): Meteor[] {
// 计算每个流星的区域宽度
const segmentWidth = 100 / count
return Array.from({ length: count }, (_, index) => {
// 计算流星起始位置
const segmentStart = index * segmentWidth
// 在区域内随机生成x坐标
const x = segmentStart + Math.random() * segmentWidth
// 随机决定流星速度快慢
const isSlow = Math.random() > 0.5
return {
x,
speed: isSlow ? 5 + Math.random() * 3 : 2 + Math.random() * 2,
delay: Math.random() * 5
}
})
}
</script>
<style lang="scss" scoped>
.basic-banner {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
padding: 0 2rem;
overflow: hidden;
color: white;
border-radius: calc(var(--custom-radius) + 2px) !important;
&__content {
position: relative;
z-index: 1;
}
&__title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
font-weight: 600;
}
&__subtitle {
position: relative;
z-index: 10;
margin: 0 0 1.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
&__button {
box-sizing: border-box;
display: inline-block;
min-width: 80px;
height: var(--el-component-custom-height);
padding: 0 12px;
font-size: 14px;
line-height: var(--el-component-custom-height);
text-align: center;
cursor: pointer;
user-select: none;
transition: all 0.3s;
&:hover {
opacity: 0.8;
}
}
&__background-image {
position: absolute;
right: 0;
bottom: -3rem;
z-index: 0;
width: 12rem;
}
&.has-decoration::after {
position: absolute;
right: -10%;
bottom: -20%;
width: 60%;
height: 140%;
content: '';
background: rgb(255 255 255 / 10%);
border-radius: 30%;
transform: rotate(-20deg);
}
&__meteors {
position: absolute;
top: 0;
left: 0;
z-index: 0;
width: 100%;
height: 100%;
pointer-events: none;
.meteor {
position: absolute;
width: 2px;
height: 60px;
background: linear-gradient(
to top,
rgb(255 255 255 / 40%),
rgb(255 255 255 / 10%),
transparent
);
opacity: 0;
transform-origin: top left;
animation-name: meteor-fall;
animation-timing-function: linear;
animation-iteration-count: infinite;
&::before {
position: absolute;
right: 0;
bottom: 0;
width: 2px;
height: 2px;
content: '';
background: rgb(255 255 255 / 50%);
}
}
}
}
@keyframes meteor-fall {
0% {
opacity: 1;
transform: translate(0, -60px) rotate(-45deg);
}
100% {
opacity: 0;
transform: translate(400px, 340px) rotate(-45deg);
}
}
@media (width <= 640px) {
.basic-banner {
box-sizing: border-box;
justify-content: flex-start;
padding: 16px;
&__title {
font-size: 1.4rem;
}
&__background-image {
display: none;
}
&.has-decoration::after {
display: none;
}
}
}
</style>

View File

@@ -0,0 +1,114 @@
<!-- 卡片横幅组件 -->
<template>
<div class="art-card-sm flex-c flex-col pb-6" :style="{ height: height }">
<div class="flex-c flex-col gap-4 text-center">
<div class="w-45">
<img :src="image" :alt="title" class="w-full h-full object-contain" />
</div>
<div class="box-border px-4">
<p class="mb-2 text-lg font-semibold text-g-800">{{ title }}</p>
<p class="m-0 text-sm text-g-600">{{ description }}</p>
</div>
<div class="flex-c gap-3">
<div
v-if="cancelButton?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md border border-g-300"
:style="{
backgroundColor: cancelButton?.color,
color: cancelButton?.textColor
}"
@click="handleCancel"
>
{{ cancelButton?.text }}
</div>
<div
v-if="button?.show"
class="inline-block h-9 px-3 text-sm/9 c-p select-none rounded-md"
:style="{ backgroundColor: button?.color, color: button?.textColor }"
@click="handleClick"
>
{{ button?.text }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
// 导入默认图标
import defaultIcon from '@imgs/3d/icon1.webp'
defineOptions({ name: 'ArtCardBanner' })
// 定义卡片横幅组件的属性接口
interface CardBannerProps {
/** 高度 */
height?: string
/** 图片路径 */
image?: string
/** 标题文本 */
title: string
/** 描述文本 */
description: string
/** 主按钮配置 */
button?: {
/** 是否显示 */
show?: boolean
/** 按钮文本 */
text?: string
/** 背景颜色 */
color?: string
/** 文字颜色 */
textColor?: string
}
/** 取消按钮配置 */
cancelButton?: {
/** 是否显示 */
show?: boolean
/** 按钮文本 */
text?: string
/** 背景颜色 */
color?: string
/** 文字颜色 */
textColor?: string
}
}
// 定义组件属性默认值
withDefaults(defineProps<CardBannerProps>(), {
height: '24rem',
image: defaultIcon,
title: '',
description: '',
// 主按钮默认配置
button: () => ({
show: true,
text: '查看详情',
color: 'var(--theme-color)',
textColor: '#fff'
}),
// 取消按钮默认配置
cancelButton: () => ({
show: false,
text: '取消',
color: '#f5f5f5',
textColor: '#666'
})
})
// 定义组件事件
const emit = defineEmits<{
(e: 'click'): void // 主按钮点击事件
(e: 'cancel'): void // 取消按钮点击事件
}>()
// 主按钮点击处理函数
const handleClick = () => {
emit('click')
}
// 取消按钮点击处理函数
const handleCancel = () => {
emit('cancel')
}
</script>

View File

@@ -0,0 +1,40 @@
<!-- 返回顶部按钮 -->
<template>
<Transition
enter-active-class="tad-300 ease-out"
leave-active-class="tad-200 ease-in"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-2"
>
<div
v-show="showButton"
class="fixed right-10 bottom-15 size-9.5 flex-cc c-p border border-g-300 rounded-md tad-300 hover:bg-g-200"
@click="scrollToTop"
>
<ArtSvgIcon icon="ri:arrow-up-wide-line" class="text-g-500 text-lg" />
</div>
</Transition>
</template>
<script setup lang="ts">
import { useCommon } from '@/hooks/core/useCommon'
defineOptions({ name: 'ArtBackToTop' })
const { scrollToTop } = useCommon()
const showButton = ref(false)
const scrollThreshold = 300
onMounted(() => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
const { y } = useScroll(scrollContainer)
watch(y, (newY: number) => {
showButton.value = newY > scrollThreshold
})
}
})
</script>

View File

@@ -0,0 +1,21 @@
<!-- 系统logo -->
<template>
<div class="flex-cc">
<img :style="logoStyle" src="@imgs/common/logo.webp" alt="logo" class="w-full h-full" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtLogo' })
interface Props {
/** logo 大小 */
size?: number | string
}
const props = withDefaults(defineProps<Props>(), {
size: 36
})
const logoStyle = computed(() => ({ width: `${props.size}px` }))
</script>

View File

@@ -0,0 +1,24 @@
<!-- 图标组件 -->
<template>
<Icon v-if="icon" :icon="icon" v-bind="bindAttrs" class="art-svg-icon inline" />
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineOptions({ name: 'ArtSvgIcon', inheritAttrs: false })
interface Props {
/** Iconify icon name */
icon?: string
}
defineProps<Props>()
const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || ''
}))
</script>

View File

@@ -0,0 +1,103 @@
<!-- 柱状图卡片 -->
<template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-5 flex-b items-start px-5 pt-5">
<div>
<p class="m-0 text-2xl font-medium leading-tight text-g-900">
{{ value }}
</p>
<p class="mt-1 text-sm text-g-600">{{ label }}</p>
</div>
<div
class="text-sm font-medium text-danger"
:class="[percentage > 0 ? 'text-success' : '', isMiniChart ? 'absolute bottom-5' : '']"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-600">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="absolute bottom-0 left-0 right-0 mx-auto"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { type EChartsOption } from '@/plugins/echarts'
defineOptions({ name: 'ArtBarChartCard' })
interface Props {
/** 数值 */
value: number
/** 标签 */
label: string
/** 百分比 +(绿色)-(红色) */
percentage: number
/** 日期 */
date?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 图表数据 */
chartData: number[]
/** 柱状图宽度 */
barWidth?: string
/** 是否为迷你图表 */
isMiniChart?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 11,
barWidth: '26%'
})
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
},
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.barWidth],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor
return {
grid: {
top: 0,
right: 0,
bottom: 15,
left: 0
},
xAxis: {
type: 'category',
show: false
},
yAxis: {
type: 'value',
show: false
},
series: [
{
data: props.chartData,
type: 'bar',
barWidth: props.barWidth,
itemStyle: {
color: computedColor,
borderRadius: 2
}
}
]
}
}
})
</script>

View File

@@ -0,0 +1,74 @@
<!-- 数据列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<div v-for="(item, index) in list" :key="index" class="flex-c py-3">
<div v-if="item.icon" class="flex-cc mr-3 size-10 rounded-lg" :class="item.class">
<ArtSvgIcon :icon="item.icon" class="text-xl" />
</div>
<div class="flex-1">
<div class="mb-1 text-sm">{{ item.title }}</div>
<div class="text-xs text-g-500">{{ item.status }}</div>
</div>
<div class="ml-3 text-xs text-g-500">{{ item.time }}</div>
</div>
</ElScrollbar>
<ElButton
class="mt-[25px] w-full text-center"
v-if="showMoreButton"
v-ripple
@click="handleMore"
>查看更多</ElButton
>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtDataListCard' })
interface Props {
/** 数据列表 */
list: Activity[]
/** 标题 */
title: string
/** 副标题 */
subtitle?: string
/** 最大显示数量 */
maxCount?: number
/** 是否显示更多按钮 */
showMoreButton?: boolean
}
interface Activity {
/** 标题 */
title: string
/** 状态 */
status: string
/** 时间 */
time: string
/** 样式类名 */
class: string
/** 图标 */
icon: string
}
const ITEM_HEIGHT = 66
const DEFAULT_MAX_COUNT = 5
const props = withDefaults(defineProps<Props>(), {
maxCount: DEFAULT_MAX_COUNT
})
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
const emit = defineEmits<{
/** 点击更多按钮事件 */
(e: 'more'): void
}>()
const handleMore = () => emit('more')
</script>

View File

@@ -0,0 +1,124 @@
<!-- 环型图卡片 -->
<template>
<div class="art-card overflow-hidden" :style="{ height: `${height}rem` }">
<div class="flex box-border h-full p-5 pr-2">
<div class="flex w-full items-start gap-5">
<div class="flex-b h-full flex-1 flex-col">
<p class="m-0 text-xl font-medium leading-tight text-g-900">
{{ title }}
</p>
<div>
<p class="m-0 mt-2.5 text-xl font-medium leading-tight text-g-900">
{{ formatNumber(value) }}
</p>
<div
class="mt-1.5 text-xs font-medium"
:class="percentage > 0 ? 'text-success' : 'text-danger'"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
<span v-if="percentageLabel">{{ percentageLabel }}</span>
</div>
</div>
<div class="mt-2 flex gap-4 text-xs text-g-600">
<div v-if="currentValue" class="flex-cc">
<div class="size-2 bg-theme/100 rounded mr-2"></div>
{{ currentValue }}
</div>
<div v-if="previousValue" class="flex-cc">
<div class="size-2 bg-g-400 rounded mr-2"></div>
{{ previousValue }}
</div>
</div>
</div>
<div class="flex-c h-full max-w-40 flex-1">
<div ref="chartRef" class="h-30 w-full"></div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { type EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
defineOptions({ name: 'ArtDonutChartCard' })
interface Props {
/** 数值 */
value: number
/** 标题 */
title: string
/** 百分比 */
percentage: number
/** 百分比标签 */
percentageLabel?: string
/** 当前年份 */
currentValue?: string
/** 去年年份 */
previousValue?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 半径 */
radius?: [string, string]
/** 数据 */
data: [number, number]
}
const props = withDefaults(defineProps<Props>(), {
height: 9,
radius: () => ['70%', '90%'],
data: () => [0, 0]
})
const formatNumber = (num: number) => {
return num.toLocaleString()
}
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: props.data.every((val) => val === 0)
},
checkEmpty: () => props.data.every((val) => val === 0),
watchSources: [
() => props.data,
() => props.color,
() => props.radius,
() => props.currentValue,
() => props.previousValue
],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor
return {
series: [
{
type: 'pie',
radius: props.radius,
avoidLabelOverlap: false,
label: {
show: false
},
data: [
{
value: props.data[0],
name: props.currentValue,
itemStyle: { color: computedColor }
},
{
value: props.data[1],
name: props.previousValue,
itemStyle: { color: '#e6e8f7' }
}
]
}
]
}
}
})
</script>

View File

@@ -0,0 +1,89 @@
<!-- 图片卡片 -->
<template>
<div class="w-full c-p" @click="handleClick">
<div class="art-card overflow-hidden">
<div class="relative w-full aspect-[16/10] overflow-hidden">
<ElImage
:src="props.imageUrl"
fit="cover"
loading="lazy"
class="w-full h-full transition-transform duration-300 ease-in-out hover:scale-105"
>
<template #placeholder>
<div class="flex-cc w-full h-full bg-[#f5f7fa]">
<ElIcon><Picture /></ElIcon>
</div>
</template>
</ElImage>
<div
class="absolute right-3.5 bottom-3.5 py-1 px-2 text-xs bg-g-200 rounded"
v-if="props.readTime"
>
{{ props.readTime }} 阅读
</div>
</div>
<div class="p-4">
<div
class="inline-block py-0.5 px-2 mb-2 text-xs bg-g-300/70 rounded"
v-if="props.category"
>
{{ props.category }}
</div>
<p class="m-0 mb-3 text-base font-medium">{{ props.title }}</p>
<div class="flex-c gap-4 text-xs text-g-600">
<span class="flex-c gap-1" v-if="props.views">
<ElIcon class="text-base"><View /></ElIcon>
{{ props.views }}
</span>
<span class="flex-c gap-1" v-if="props.comments">
<ElIcon class="text-base"><ChatLineRound /></ElIcon>
{{ props.comments }}
</span>
<span>{{ props.date }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Picture, View, ChatLineRound } from '@element-plus/icons-vue'
defineOptions({ name: 'ArtImageCard' })
interface Props {
/** 图片地址 */
imageUrl: string
/** 标题 */
title: string
/** 分类 */
category?: string
/** 阅读时间 */
readTime?: string
/** 浏览量 */
views?: number
/** 评论数 */
comments?: number
/** 日期 */
date?: string
}
const props = withDefaults(defineProps<Props>(), {
imageUrl: '',
title: '',
category: '',
readTime: '',
views: 0,
comments: 0,
date: ''
})
const emit = defineEmits<{
(e: 'click', card: Props): void
}>()
const handleClick = () => {
emit('click', props)
}
</script>

View File

@@ -0,0 +1,126 @@
<!-- 折线图卡片 -->
<template>
<div class="art-card relative overflow-hidden" :style="{ height: `${height}rem` }">
<div class="mb-2.5 flex-b items-start p-5">
<div>
<p class="text-2xl font-medium leading-none">
{{ value }}
</p>
<p class="mt-1 text-sm text-g-500">{{ label }}</p>
</div>
<div
class="text-sm font-medium"
:class="[
percentage > 0 ? 'text-success' : 'text-danger',
isMiniChart ? 'absolute bottom-5' : ''
]"
>
{{ percentage > 0 ? '+' : '' }}{{ percentage }}%
</div>
<div v-if="date" class="absolute bottom-5 right-5 text-xs text-g-500">
{{ date }}
</div>
</div>
<div
ref="chartRef"
class="absolute bottom-0 left-0 right-0 box-border w-full"
:class="isMiniChart ? '!absolute !top-5 !right-5 !bottom-auto !left-auto !h-15 !w-4/10' : ''"
:style="{ height: isMiniChart ? '60px' : `calc(${height}rem - 5rem)` }"
></div>
</div>
</template>
<script setup lang="ts">
import { graphic, type EChartsOption } from '@/plugins/echarts'
import { getCssVar, hexToRgba } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
defineOptions({ name: 'ArtLineChartCard' })
interface Props {
/** 数值 */
value: number
/** 标签 */
label: string
/** 百分比 */
percentage: number
/** 日期 */
date?: string
/** 高度 */
height?: number
/** 颜色 */
color?: string
/** 是否显示区域颜色 */
showAreaColor?: boolean
/** 图表数据 */
chartData: number[]
/** 是否为迷你图表 */
isMiniChart?: boolean
}
const props = withDefaults(defineProps<Props>(), {
height: 11
})
// 使用新的图表组件抽象
const { chartRef } = useChartComponent({
props: {
height: `${props.height}rem`,
loading: false,
isEmpty: !props.chartData?.length || props.chartData.every((val) => val === 0)
},
checkEmpty: () => !props.chartData?.length || props.chartData.every((val) => val === 0),
watchSources: [() => props.chartData, () => props.color, () => props.showAreaColor],
generateOptions: (): EChartsOption => {
const computedColor = props.color || useChartOps().themeColor
return {
grid: {
top: 0,
right: 0,
bottom: 0,
left: 0
},
xAxis: {
type: 'category',
show: false,
boundaryGap: false
},
yAxis: {
type: 'value',
show: false
},
series: [
{
data: props.chartData,
type: 'line',
smooth: true,
showSymbol: false,
lineStyle: {
width: 3,
color: computedColor
},
areaStyle: props.showAreaColor
? {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: props.color
? hexToRgba(props.color, 0.2).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.2).rgba
},
{
offset: 1,
color: props.color
? hexToRgba(props.color, 0.01).rgba
: hexToRgba(getCssVar('--el-color-primary'), 0.01).rgba
}
])
}
: undefined
}
]
}
}
})
</script>

View File

@@ -0,0 +1,86 @@
<!-- 进度条卡片 -->
<template>
<div class="art-card h-32 flex flex-col justify-center px-5">
<div class="mb-3.5 flex-c" :style="{ justifyContent: icon ? 'space-between' : 'flex-start' }">
<div v-if="icon" class="size-11 flex-cc bg-g-300 text-xl rounded-lg" :class="iconStyle">
<ArtSvgIcon :icon="icon" class="text-2xl"></ArtSvgIcon>
</div>
<div>
<ArtCountTo
class="mb-1 block text-2xl font-semibold"
:target="percentage"
:duration="2000"
suffix="%"
:style="{ textAlign: icon ? 'right' : 'left' }"
/>
<p class="text-sm text-g-500">{{ title }}</p>
</div>
</div>
<ElProgress
:percentage="currentPercentage"
:stroke-width="strokeWidth"
:show-text="false"
:color="color"
class="[&_.el-progress-bar__outer]:bg-[rgb(240_240_240)]"
/>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtProgressCard' })
interface Props {
/** 进度百分比 */
percentage: number
/** 标题 */
title: string
/** 颜色 */
color?: string
/** 图标 */
icon?: string
/** 图标样式 */
iconStyle?: string
/** 进度条宽度 */
strokeWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
strokeWidth: 5,
color: '#67C23A'
})
const animationDuration = 500
const currentPercentage = ref(0)
const animateProgress = () => {
const startTime = Date.now()
const startValue = currentPercentage.value
const endValue = props.percentage
const animate = () => {
const currentTime = Date.now()
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / animationDuration, 1)
currentPercentage.value = startValue + (endValue - startValue) * progress
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}
onMounted(() => {
animateProgress()
})
// 当 percentage 属性变化时重新执行动画
watch(
() => props.percentage,
() => {
animateProgress()
}
)
</script>

View File

@@ -0,0 +1,67 @@
<!-- 统计卡片 -->
<template>
<div
class="art-card h-32 flex-c px-5 transition-transform duration-200 hover:-translate-y-0.5"
:class="boxStyle"
>
<div v-if="icon" class="mr-4 size-11 flex-cc rounded-lg text-xl text-white" :class="iconStyle">
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
</div>
<div class="flex-1">
<p class="m-0 text-lg font-medium" :style="{ color: textColor }" v-if="title">
{{ title }}
</p>
<ArtCountTo
class="m-0 text-2xl font-medium"
v-if="count !== undefined"
:target="count"
:duration="2000"
:decimals="decimals"
:separator="separator"
/>
<p
class="mt-1 text-sm text-g-500 opacity-90"
:style="{ color: textColor }"
v-if="description"
>{{ description }}</p
>
</div>
<div v-if="showArrow">
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-xl text-g-500" />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtStatsCard' })
interface StatsCardProps {
/** 盒子样式 */
boxStyle?: string
/** 图标 */
icon?: string
/** 图标样式 */
iconStyle?: string
/** 标题 */
title?: string
/** 数值 */
count?: number
/** 小数位 */
decimals?: number
/** 分隔符 */
separator?: string
/** 描述 */
description: string
/** 文本颜色 */
textColor?: string
/** 是否显示箭头 */
showArrow?: boolean
}
withDefaults(defineProps<StatsCardProps>(), {
iconSize: 30,
iconBgRadius: 50,
decimals: 0,
separator: ','
})
</script>

View File

@@ -0,0 +1,69 @@
<!-- 时间轴列表卡片 -->
<template>
<div class="art-card p-5">
<div class="pb-3.5">
<p class="text-lg font-medium">{{ title }}</p>
<p class="text-sm text-g-600">{{ subtitle }}</p>
</div>
<ElScrollbar :style="{ height: maxHeight }">
<ElTimeline class="!pl-0.5">
<ElTimelineItem
v-for="item in list"
:key="item.time"
:timestamp="item.time"
:placement="TIMELINE_PLACEMENT"
:color="item.status"
:center="true"
>
<div class="flex-c gap-3">
<div class="flex-c gap-2">
<span class="text-sm">{{ item.content }}</span>
<span v-if="item.code" class="text-sm text-theme"> #{{ item.code }} </span>
</div>
</div>
</ElTimelineItem>
</ElTimeline>
</ElScrollbar>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtTimelineListCard' })
// 常量配置
const ITEM_HEIGHT = 65
const TIMELINE_PLACEMENT = 'top'
const DEFAULT_MAX_COUNT = 5
interface TimelineItem {
/** 时间 */
time: string
/** 状态颜色 */
status: string
/** 内容 */
content: string
/** 代码标识 */
code?: string
}
interface Props {
/** 时间轴列表数据 */
list: TimelineItem[]
/** 标题 */
title: string
/** 副标题 */
subtitle?: string
/** 最大显示数量 */
maxCount?: number
}
// Props 定义和验证
const props = withDefaults(defineProps<Props>(), {
title: '',
subtitle: '',
maxCount: DEFAULT_MAX_COUNT
})
// 计算最大高度
const maxHeight = computed(() => `${ITEM_HEIGHT * props.maxCount}px`)
</script>

View File

@@ -0,0 +1,203 @@
<!-- 柱状图 -->
<template>
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { getCssVar } from '@/utils/ui'
import { graphic, type EChartsOption } from '@/plugins/echarts'
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
defineOptions({ name: 'ArtBarChart' })
const props = withDefaults(defineProps<BarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
borderRadius: 4,
// 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
barWidth: '40%',
stack: false,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'bottom'
})
// 判断是否为多数据
const isMultipleData = computed(() => {
return (
Array.isArray(props.data) &&
props.data.length > 0 &&
typeof props.data[0] === 'object' &&
'name' in props.data[0]
)
})
// 获取颜色配置
const getColor = (customColor?: string, index?: number) => {
if (customColor) return customColor
if (index !== undefined) {
return props.colors![index % props.colors!.length]
}
// 默认渐变色
return new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: getCssVar('--el-color-primary-light-4')
},
{
offset: 1,
color: getCssVar('--el-color-primary')
}
])
}
// 创建渐变色
const createGradientColor = (color: string) => {
return new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: color
},
{
offset: 1,
color: color
}
])
}
// 获取基础样式配置
const getBaseItemStyle = (
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
) => ({
borderRadius: props.borderRadius,
color: typeof color === 'string' ? createGradientColor(color) : color
})
// 创建系列配置
const createSeriesItem = (config: {
name?: string
data: number[]
color?: string | InstanceType<typeof graphic.LinearGradient>
barWidth?: string | number
stack?: string
}) => {
const animationConfig = getAnimationConfig()
return {
name: config.name,
data: config.data,
type: 'bar' as const,
stack: config.stack,
itemStyle: getBaseItemStyle(config.color),
barWidth: config.barWidth || props.barWidth,
...animationConfig
}
}
// 使用新的图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
getGridWithLegend
} = useChartComponent({
props,
checkEmpty: () => {
// 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
const singleData = props.data as number[]
return !singleData.length || singleData.every((val) => val === 0)
}
// 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
const multiData = props.data as BarDataItem[]
return (
!multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
)
}
return true
},
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
generateOptions: (): EChartsOption => {
const options: EChartsOption = {
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
top: 15,
right: 0,
left: 0
}),
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
xAxis: {
type: 'category',
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel)
},
yAxis: {
type: 'value',
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine)
}
}
// 添加图例配置
if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition)
}
// 生成系列数据
if (isMultipleData.value) {
const multiData = props.data as BarDataItem[]
options.series = multiData.map((item, index) => {
const computedColor = getColor(props.colors[index], index)
return createSeriesItem({
name: item.name,
data: item.data,
color: computedColor,
barWidth: item.barWidth,
stack: props.stack ? item.stack || 'total' : undefined
})
})
} else {
// 单数据情况
const singleData = props.data as number[]
const computedColor = getColor()
options.series = [
createSeriesItem({
data: singleData,
color: computedColor
})
]
}
return options
}
})
</script>

View File

@@ -0,0 +1,195 @@
<!-- 双向堆叠柱状图 -->
<template>
<div ref="chartRef" :style="{ height: props.height }" v-loading="props.loading"> </div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { EChartsOption, BarSeriesOption } from '@/plugins/echarts'
import type { BidirectionalBarChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtDualBarCompareChart' })
const props = withDefaults(defineProps<BidirectionalBarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
positiveData: () => [],
negativeData: () => [],
xAxisData: () => [],
positiveName: '正向数据',
negativeName: '负向数据',
barWidth: 16,
yAxisMin: -100,
yAxisMax: 100,
// 样式配置
showDataLabel: false,
positiveBorderRadius: () => [10, 10, 0, 0],
negativeBorderRadius: () => [0, 0, 10, 10],
// 轴线显示配置
showAxisLabel: true,
showAxisLine: false,
showSplitLine: false,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'bottom'
})
// 创建系列配置的辅助函数
const createSeriesConfig = (config: {
name: string
data: number[]
borderRadius: number | number[]
labelPosition: 'top' | 'bottom'
colorIndex: number
formatter?: (params: unknown) => string
}): BarSeriesOption => {
const { fontColor } = useChartOps()
const animationConfig = getAnimationConfig()
return {
name: config.name,
type: 'bar',
stack: 'total',
barWidth: props.barWidth,
barGap: '-100%',
data: config.data,
itemStyle: {
borderRadius: config.borderRadius,
color: props.colors[config.colorIndex]
},
label: {
show: props.showDataLabel,
position: config.labelPosition,
formatter:
config.formatter ||
((params: unknown) => String((params as Record<string, unknown>).value)),
color: fontColor,
fontSize: 12
},
...animationConfig
}
}
// 使用图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
getGridWithLegend
} = useChartComponent({
props,
checkEmpty: () => {
return (
props.isEmpty ||
!props.positiveData.length ||
!props.negativeData.length ||
(props.positiveData.every((val) => val === 0) &&
props.negativeData.every((val) => val === 0))
)
},
watchSources: [
() => props.positiveData,
() => props.negativeData,
() => props.xAxisData,
() => props.colors
],
generateOptions: (): EChartsOption => {
// 处理负向数据,确保为负值
const processedNegativeData = props.negativeData.map((val) => (val > 0 ? -val : val))
// 优化的Grid配置
const gridConfig = {
top: props.showLegend ? 50 : 20,
right: 0,
left: 0,
bottom: 0, // 增加底部间距
containLabel: true
}
const options: EChartsOption = {
backgroundColor: 'transparent',
animation: true,
animationDuration: 1000,
animationEasing: 'cubicOut',
grid: getGridWithLegend(props.showLegend, props.legendPosition, gridConfig),
// 优化的提示框配置
tooltip: props.showTooltip
? {
...getTooltipStyle(),
trigger: 'axis',
axisPointer: {
type: 'none' // 去除指示线
}
}
: undefined,
// 图例配置
legend: props.showLegend
? {
...getLegendStyle(props.legendPosition),
data: [props.negativeName, props.positiveName]
}
: undefined,
// X轴配置
xAxis: {
type: 'category',
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
boundaryGap: true
},
// Y轴配置
yAxis: {
type: 'value',
min: props.yAxisMin,
max: props.yAxisMax,
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine)
},
// 系列配置
series: [
// 负向数据系列
createSeriesConfig({
name: props.negativeName,
data: processedNegativeData,
borderRadius: props.negativeBorderRadius,
labelPosition: 'bottom',
colorIndex: 1,
formatter: (params: unknown) =>
String(Math.abs((params as Record<string, unknown>).value as number))
}),
// 正向数据系列
createSeriesConfig({
name: props.positiveName,
data: props.positiveData,
borderRadius: props.positiveBorderRadius,
labelPosition: 'top',
colorIndex: 0
})
]
}
return options
}
})
</script>

View File

@@ -0,0 +1,208 @@
<!-- 水平柱状图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import { getCssVar } from '@/utils/ui'
import { graphic, type EChartsOption } from '@/plugins/echarts'
import type { BarChartProps, BarDataItem } from '@/types/component/chart'
defineOptions({ name: 'ArtHBarChart' })
const props = withDefaults(defineProps<BarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
barWidth: '36%',
stack: false,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'bottom'
})
// 判断是否为多数据
const isMultipleData = computed(() => {
return (
Array.isArray(props.data) &&
props.data.length > 0 &&
typeof props.data[0] === 'object' &&
'name' in props.data[0]
)
})
// 获取颜色配置
const getColor = (customColor?: string, index?: number) => {
if (customColor) return customColor
if (index !== undefined) {
return props.colors![index % props.colors!.length]
}
// 默认渐变色
return new graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: getCssVar('--el-color-primary')
},
{
offset: 1,
color: getCssVar('--el-color-primary-light-4')
}
])
}
// 创建渐变色
const createGradientColor = (color: string) => {
return new graphic.LinearGradient(0, 0, 1, 0, [
{
offset: 0,
color: color
},
{
offset: 1,
color: color
}
])
}
// 获取基础样式配置
const getBaseItemStyle = (
color: string | InstanceType<typeof graphic.LinearGradient> | undefined
) => ({
borderRadius: 4,
color: typeof color === 'string' ? createGradientColor(color) : color
})
// 创建系列配置
const createSeriesItem = (config: {
name?: string
data: number[]
color?: string | InstanceType<typeof graphic.LinearGradient>
barWidth?: string | number
stack?: string
}) => {
const animationConfig = getAnimationConfig()
return {
name: config.name,
data: config.data,
type: 'bar' as const,
stack: config.stack,
itemStyle: getBaseItemStyle(config.color),
barWidth: config.barWidth || props.barWidth,
...animationConfig
}
}
// 使用新的图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
getGridWithLegend
} = useChartComponent({
props,
checkEmpty: () => {
// 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
const singleData = props.data as number[]
return !singleData.length || singleData.every((val) => val === 0)
}
// 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
const multiData = props.data as BarDataItem[]
return (
!multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
)
}
return true
},
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
generateOptions: (): EChartsOption => {
const options: EChartsOption = {
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
top: 15,
right: 0,
left: 0
}),
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
xAxis: {
type: 'value',
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
splitLine: getSplitLineStyle(props.showSplitLine)
},
yAxis: {
type: 'category',
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine)
}
}
// 添加图例配置
if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition)
}
// 生成系列数据
if (isMultipleData.value) {
const multiData = props.data as BarDataItem[]
options.series = multiData.map((item, index) => {
const computedColor = getColor(props.colors[index], index)
return createSeriesItem({
name: item.name,
data: item.data,
color: computedColor,
barWidth: item.barWidth,
stack: props.stack ? item.stack || 'total' : undefined
})
})
} else {
// 单数据情况
const singleData = props.data as number[]
const computedColor = getColor()
options.series = [
createSeriesItem({
data: singleData,
color: computedColor
})
]
}
return options
}
})
</script>

View File

@@ -0,0 +1,152 @@
<!-- k线图表 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { KLineChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtKLineChart' })
const props = withDefaults(defineProps<KLineChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [],
showDataZoom: false,
dataZoomStart: 0,
dataZoomEnd: 100
})
// 获取实际使用的颜色
const getActualColors = () => {
const defaultUpColor = '#4C87F3'
const defaultDownColor = '#8BD8FC'
return {
upColor: props.colors?.[0] || defaultUpColor,
downColor: props.colors?.[1] || defaultDownColor
}
}
// 使用新的图表组件抽象
const {
chartRef,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle
} = useChartComponent({
props,
checkEmpty: () => {
return (
!props.data?.length ||
props.data.every(
(item) => item.open === 0 && item.close === 0 && item.high === 0 && item.low === 0
)
)
},
watchSources: [
() => props.data,
() => props.colors,
() => props.showDataZoom,
() => props.dataZoomStart,
() => props.dataZoomEnd
],
generateOptions: (): EChartsOption => {
const { upColor, downColor } = getActualColors()
return {
grid: {
top: 20,
right: 20,
bottom: props.showDataZoom ? 80 : 20,
left: 20,
containLabel: true
},
tooltip: getTooltipStyle('axis', {
axisPointer: {
type: 'cross'
},
formatter: (params: Array<{ name: string; data: number[] }>) => {
const param = params[0]
const data = param.data
return `
<div style="padding: 5px;">
<div><strong>时间:</strong>${param.name}</div>
<div><strong>开盘:</strong>${data[0]}</div>
<div><strong>收盘:</strong>${data[1]}</div>
<div><strong>最低:</strong>${data[2]}</div>
<div><strong>最高:</strong>${data[3]}</div>
</div>
`
}
}),
xAxis: {
type: 'category',
data: props.data.map((item) => item.time),
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(true),
axisLabel: getAxisLabelStyle(true)
},
yAxis: {
type: 'value',
scale: true,
axisLabel: getAxisLabelStyle(true),
axisLine: getAxisLineStyle(true),
splitLine: getSplitLineStyle(true)
},
series: [
{
type: 'candlestick',
data: props.data.map((item) => [item.open, item.close, item.low, item.high]),
itemStyle: {
color: upColor,
color0: downColor,
borderColor: upColor,
borderColor0: downColor,
borderWidth: 1
},
emphasis: {
itemStyle: {
borderWidth: 2,
shadowBlur: 10,
shadowColor: 'rgba(0, 0, 0, 0.3)'
}
},
...getAnimationConfig()
}
],
dataZoom: props.showDataZoom
? [
{
type: 'inside',
start: props.dataZoomStart,
end: props.dataZoomEnd
},
{
show: true,
type: 'slider',
top: '90%',
start: props.dataZoomStart,
end: props.dataZoomEnd
}
]
: undefined
}
}
})
</script>

View File

@@ -0,0 +1,371 @@
<!-- 折线图支持多组数据支持阶梯式动画效果 -->
<template>
<div
ref="chartRef"
class="relative w-[calc(100%+10px)]"
:style="{ height: props.height }"
v-loading="props.loading"
>
</div>
</template>
<script setup lang="ts">
import { graphic, type EChartsOption } from '@/plugins/echarts'
import { getCssVar, hexToRgba } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { LineChartProps, LineDataItem } from '@/types/component/chart'
defineOptions({ name: 'ArtLineChart' })
const props = withDefaults(defineProps<LineChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [0, 0, 0, 0, 0, 0, 0],
xAxisData: () => [],
lineWidth: 2.5,
showAreaColor: false,
smooth: true,
symbol: 'none',
symbolSize: 6,
animationDelay: 200,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'bottom'
})
// 动画状态管理
const isAnimating = ref(false)
const animationTimers = ref<number[]>([])
const animatedData = ref<number[] | LineDataItem[]>([])
// 清理所有定时器
const clearAnimationTimers = () => {
animationTimers.value.forEach((timer) => clearTimeout(timer))
animationTimers.value = []
}
// 判断是否为多数据(使用 VueUse 的 computedEager 优化)
const isMultipleData = computed(() => {
return (
Array.isArray(props.data) &&
props.data.length > 0 &&
typeof props.data[0] === 'object' &&
'name' in props.data[0]
)
})
// 缓存计算的最大值,避免重复计算
const maxValue = computed(() => {
if (isMultipleData.value) {
const multiData = props.data as LineDataItem[]
return multiData.reduce((max, item) => {
if (item.data?.length) {
const itemMax = Math.max(...item.data)
return Math.max(max, itemMax)
}
return max
}, 0)
} else {
const singleData = props.data as number[]
return singleData?.length ? Math.max(...singleData) : 0
}
})
// 初始化动画数据(优化:减少条件判断)
const initAnimationData = (): number[] | LineDataItem[] => {
if (isMultipleData.value) {
const multiData = props.data as LineDataItem[]
return multiData.map((item) => ({
...item,
data: Array(item.data.length).fill(0)
}))
}
const singleData = props.data as number[]
return Array(singleData.length).fill(0)
}
// 复制真实数据(优化:使用结构化克隆)
const copyRealData = (): number[] | LineDataItem[] => {
if (isMultipleData.value) {
return (props.data as LineDataItem[]).map((item) => ({ ...item, data: [...item.data] }))
}
return [...(props.data as number[])]
}
// 获取颜色配置(优化:缓存主题色)
const primaryColor = computed(() => getCssVar('--el-color-primary'))
const getColor = (customColor?: string, index?: number): string => {
if (customColor) return customColor
if (index !== undefined) return props.colors![index % props.colors!.length]
return primaryColor.value
}
// 生成区域样式
const generateAreaStyle = (item: LineDataItem, color: string) => {
// 如果有 areaStyle 配置,或者显式开启了区域颜色,则显示区域样式
if (!item.areaStyle && !item.showAreaColor && !props.showAreaColor) return undefined
const areaConfig = item.areaStyle || {}
if (areaConfig.custom) return areaConfig.custom
return {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRgba(color, areaConfig.startOpacity || 0.2).rgba
},
{
offset: 1,
color: hexToRgba(color, areaConfig.endOpacity || 0.02).rgba
}
])
}
}
// 生成单数据区域样式
const generateSingleAreaStyle = () => {
if (!props.showAreaColor) return undefined
const color = getColor(props.colors[0])
return {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: hexToRgba(color, 0.2).rgba
},
{
offset: 1,
color: hexToRgba(color, 0.02).rgba
}
])
}
}
// 创建系列配置
const createSeriesItem = (config: {
name?: string
data: number[]
color?: string
smooth?: boolean
symbol?: string
symbolSize?: number
lineWidth?: number
areaStyle?: any
}) => {
return {
name: config.name,
data: config.data,
type: 'line' as const,
color: config.color,
smooth: config.smooth ?? props.smooth,
symbol: config.symbol ?? props.symbol,
symbolSize: config.symbolSize ?? props.symbolSize,
lineStyle: {
width: config.lineWidth ?? props.lineWidth,
color: config.color
},
areaStyle: config.areaStyle,
emphasis: {
focus: 'series' as const,
lineStyle: {
width: (config.lineWidth ?? props.lineWidth) + 1
}
}
}
}
// 生成图表配置
const generateChartOptions = (isInitial = false): EChartsOption => {
const options: EChartsOption = {
animation: true,
animationDuration: isInitial ? 0 : 1300,
animationDurationUpdate: isInitial ? 0 : 1300,
grid: getGridWithLegend(props.showLegend && isMultipleData.value, props.legendPosition, {
top: 15,
right: 15,
left: 0
}),
tooltip: props.showTooltip ? getTooltipStyle() : undefined,
xAxis: {
type: 'category',
boundaryGap: false,
data: props.xAxisData,
axisTick: getAxisTickStyle(),
axisLine: getAxisLineStyle(props.showAxisLine),
axisLabel: getAxisLabelStyle(props.showAxisLabel)
},
yAxis: {
type: 'value',
min: 0,
max: maxValue.value,
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
splitLine: getSplitLineStyle(props.showSplitLine)
}
}
// 添加图例配置
if (props.showLegend && isMultipleData.value) {
options.legend = getLegendStyle(props.legendPosition)
}
// 生成系列数据
if (isMultipleData.value) {
const multiData = animatedData.value as LineDataItem[]
options.series = multiData.map((item, index) => {
const itemColor = getColor(props.colors[index], index)
const areaStyle = generateAreaStyle(item, itemColor)
return createSeriesItem({
name: item.name,
data: item.data,
color: itemColor,
smooth: item.smooth,
symbol: item.symbol,
lineWidth: item.lineWidth,
areaStyle
})
})
} else {
// 单数据情况
const singleData = animatedData.value as number[]
const computedColor = getColor(props.colors[0])
const areaStyle = generateSingleAreaStyle()
options.series = [
createSeriesItem({
data: singleData,
color: computedColor,
areaStyle
})
]
}
return options
}
// 更新图表
const updateChartOptions = (options: EChartsOption) => {
initChart(options)
}
// 初始化动画函数(优化:统一定时器管理,减少内存泄漏风险)
const initChartWithAnimation = () => {
clearAnimationTimers()
isAnimating.value = true
// 初始化为0值数据
animatedData.value = initAnimationData()
updateChartOptions(generateChartOptions(true))
if (isMultipleData.value) {
// 多数据阶梯式动画
const multiData = props.data as LineDataItem[]
const currentAnimatedData = animatedData.value as LineDataItem[]
multiData.forEach((item, index) => {
const timer = window.setTimeout(
() => {
currentAnimatedData[index] = { ...item, data: [...item.data] }
animatedData.value = [...currentAnimatedData]
updateChartOptions(generateChartOptions(false))
},
index * props.animationDelay + 100
)
animationTimers.value.push(timer)
})
// 标记动画完成
const totalDelay = (multiData.length - 1) * props.animationDelay + 1500
const finishTimer = window.setTimeout(() => {
isAnimating.value = false
}, totalDelay)
animationTimers.value.push(finishTimer)
} else {
// 单数据简单动画 - 使用 nextTick 确保初始状态已渲染
nextTick(() => {
animatedData.value = copyRealData()
updateChartOptions(generateChartOptions(false))
isAnimating.value = false
})
}
}
// 空数据检查函数
const checkIsEmpty = () => {
// 检查单数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'number') {
const singleData = props.data as number[]
return !singleData.length || singleData.every((val) => val === 0)
}
// 检查多数据情况
if (Array.isArray(props.data) && typeof props.data[0] === 'object') {
const multiData = props.data as LineDataItem[]
return (
!multiData.length ||
multiData.every((item) => !item.data?.length || item.data.every((val) => val === 0))
)
}
return true
}
// 使用新的图表组件抽象
const {
chartRef,
initChart,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getTooltipStyle,
getLegendStyle,
getGridWithLegend,
isEmpty
} = useChartComponent({
props,
checkEmpty: checkIsEmpty,
watchSources: [() => props.data, () => props.xAxisData, () => props.colors],
onVisible: () => {
// 当图表变为可见时,检查是否为空数据
if (!isEmpty.value) {
initChartWithAnimation()
}
},
generateOptions: () => generateChartOptions(false)
})
// 图表渲染函数(优化:防止动画期间重复触发)
const renderChart = () => {
if (!isAnimating.value && !isEmpty.value) {
initChartWithAnimation()
}
}
// 使用 VueUse 的 watchDebounced 优化数据监听(避免频繁更新)
watch([() => props.data, () => props.xAxisData, () => props.colors], renderChart, { deep: true })
// 生命周期
onMounted(() => {
renderChart()
})
onBeforeUnmount(() => {
clearAnimationTimers()
})
</script>

View File

@@ -0,0 +1,105 @@
<!-- 雷达图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
></div>
</template>
<script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { RadarChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtRadarChart' })
const props = withDefaults(defineProps<RadarChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
indicator: () => [],
data: () => [],
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'bottom'
})
// 使用新的图表组件抽象
const { chartRef, isDark, getAnimationConfig, getTooltipStyle } = useChartComponent({
props,
checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
},
watchSources: [() => props.data, () => props.indicator, () => props.colors],
generateOptions: (): EChartsOption => {
return {
tooltip: props.showTooltip ? getTooltipStyle('item') : undefined,
radar: {
indicator: props.indicator,
center: ['50%', '50%'],
radius: '70%',
axisName: {
color: isDark.value ? '#ccc' : '#666',
fontSize: 12
},
splitLine: {
lineStyle: {
color: isDark.value ? '#444' : '#e6e6e6'
}
},
axisLine: {
lineStyle: {
color: isDark.value ? '#444' : '#e6e6e6'
}
},
splitArea: {
show: true,
areaStyle: {
color: isDark.value
? ['rgba(255, 255, 255, 0.02)', 'rgba(255, 255, 255, 0.05)']
: ['rgba(0, 0, 0, 0.02)', 'rgba(0, 0, 0, 0.05)']
}
}
},
series: [
{
type: 'radar',
data: props.data.map((item, index) => ({
name: item.name,
value: item.value,
symbolSize: 4,
lineStyle: {
width: 2,
color: props.colors[index % props.colors.length]
},
itemStyle: {
color: props.colors[index % props.colors.length]
},
areaStyle: {
color: props.colors[index % props.colors.length],
opacity: 0.1
},
emphasis: {
areaStyle: {
opacity: 0.25
},
lineStyle: {
width: 3
}
}
})),
...getAnimationConfig(200, 1800)
}
]
}
}
})
</script>

View File

@@ -0,0 +1,133 @@
<!-- 环形图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
>
</div>
</template>
<script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { RingChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtRingChart' })
const props = withDefaults(defineProps<RingChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [],
radius: () => ['50%', '80%'],
borderRadius: 10,
centerText: '',
showLabel: false,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'right'
})
// 使用新的图表组件抽象
const { chartRef, isDark, getAnimationConfig, getTooltipStyle, getLegendStyle } =
useChartComponent({
props,
checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value === 0)
},
watchSources: [() => props.data, () => props.centerText],
generateOptions: (): EChartsOption => {
// 根据图例位置计算环形图中心位置
const getCenterPosition = (): [string, string] => {
if (!props.showLegend) return ['50%', '50%']
switch (props.legendPosition) {
case 'left':
return ['60%', '50%']
case 'right':
return ['40%', '50%']
case 'top':
return ['50%', '60%']
case 'bottom':
return ['50%', '40%']
default:
return ['50%', '50%']
}
}
const option: EChartsOption = {
tooltip: props.showTooltip
? getTooltipStyle('item', {
formatter: '{b}: {c} ({d}%)'
})
: undefined,
legend: props.showLegend ? getLegendStyle(props.legendPosition) : undefined,
series: [
{
name: '数据占比',
type: 'pie',
radius: props.radius,
center: getCenterPosition(),
avoidLabelOverlap: false,
itemStyle: {
borderRadius: props.borderRadius,
borderColor: isDark.value ? '#2c2c2c' : '#fff',
borderWidth: 0
},
label: {
show: props.showLabel,
formatter: '{b}\n{d}%',
position: 'outside',
color: isDark.value ? '#ccc' : '#999',
fontSize: 12
},
emphasis: {
label: {
show: false,
fontSize: 14,
fontWeight: 'bold'
}
},
labelLine: {
show: props.showLabel,
length: 15,
length2: 25,
smooth: true
},
data: props.data,
color: props.colors,
...getAnimationConfig(),
animationType: 'expansion'
}
]
}
// 添加中心文字
if (props.centerText) {
const centerPos = getCenterPosition()
option.title = {
text: props.centerText,
left: centerPos[0],
top: centerPos[1],
textAlign: 'center',
textVerticalAlign: 'middle',
textStyle: {
fontSize: 18,
fontWeight: 500,
color: isDark.value ? '#999' : '#ADB0BC'
}
}
}
return option
}
})
</script>

View File

@@ -0,0 +1,115 @@
<!-- 散点图 -->
<template>
<div
ref="chartRef"
class="relative w-full"
:style="{ height: props.height }"
v-loading="props.loading"
>
</div>
</template>
<script setup lang="ts">
import type { EChartsOption } from '@/plugins/echarts'
import { getCssVar } from '@/utils/ui'
import { useChartOps, useChartComponent } from '@/hooks/core/useChart'
import type { ScatterChartProps } from '@/types/component/chart'
defineOptions({ name: 'ArtScatterChart' })
const props = withDefaults(defineProps<ScatterChartProps>(), {
// 基础配置
height: useChartOps().chartHeight,
loading: false,
isEmpty: false,
colors: () => useChartOps().colors,
// 数据配置
data: () => [{ value: [0, 0] }, { value: [0, 0] }],
symbolSize: 14,
// 轴线显示配置
showAxisLabel: true,
showAxisLine: true,
showSplitLine: true,
// 交互配置
showTooltip: true,
showLegend: false,
legendPosition: 'bottom'
})
// 使用新的图表组件抽象
const {
chartRef,
isDark,
getAxisLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getSplitLineStyle,
getAnimationConfig,
getTooltipStyle
} = useChartComponent({
props,
checkEmpty: () => {
return !props.data?.length || props.data.every((item) => item.value.every((val) => val === 0))
},
watchSources: [() => props.data, () => props.colors, () => props.symbolSize],
generateOptions: (): EChartsOption => {
const computedColor = props.colors[0] || getCssVar('--el-color-primary')
return {
grid: {
top: 20,
right: 20,
bottom: 20,
left: 20,
containLabel: true
},
tooltip: props.showTooltip
? getTooltipStyle('item', {
formatter: (params: { value: [number, number] }) => {
const [x, y] = params.value
return `X: ${x}<br/>Y: ${y}`
}
})
: undefined,
xAxis: {
type: 'value',
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
axisTick: getAxisTickStyle(),
splitLine: getSplitLineStyle(props.showSplitLine)
},
yAxis: {
type: 'value',
axisLabel: getAxisLabelStyle(props.showAxisLabel),
axisLine: getAxisLineStyle(props.showAxisLine),
axisTick: getAxisTickStyle(),
splitLine: getSplitLineStyle(props.showSplitLine)
},
series: [
{
type: 'scatter',
data: props.data,
symbolSize: props.symbolSize,
itemStyle: {
color: computedColor,
shadowBlur: 6,
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)',
shadowOffsetY: 2
},
emphasis: {
itemStyle: {
shadowBlur: 12,
shadowColor: isDark.value ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'
},
scale: true
},
...getAnimationConfig()
}
]
}
}
})
</script>

View File

@@ -0,0 +1,71 @@
<!-- 更多按钮 -->
<template>
<div>
<ElDropdown v-if="hasAnyAuthItem">
<ArtIconButton icon="ri:more-2-fill" class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm" />
<template #dropdown>
<ElDropdownMenu>
<template v-for="item in list" :key="item.key">
<ElDropdownItem
v-if="!item.auth || hasAuth(item.auth)"
:disabled="item.disabled"
@click="handleClick(item)"
>
<div class="flex-c gap-2" :style="{ color: item.color }">
<ArtSvgIcon v-if="item.icon" :icon="item.icon" />
<span>{{ item.label }}</span>
</div>
</ElDropdownItem>
</template>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
<script setup lang="ts">
import { useAuth } from '@/hooks/core/useAuth'
defineOptions({ name: 'ArtButtonMore' })
const { hasAuth } = useAuth()
export interface ButtonMoreItem {
/** 按钮标识,可用于点击事件 */
key: string | number
/** 按钮文本 */
label: string
/** 是否禁用 */
disabled?: boolean
/** 权限标识 */
auth?: string
/** 图标组件 */
icon?: string
/** 文本颜色 */
color?: string
/** 图标颜色(优先级高于 color */
iconColor?: string
}
interface Props {
/** 下拉项列表 */
list: ButtonMoreItem[]
/** 整体权限控制 */
auth?: string
}
const props = withDefaults(defineProps<Props>(), {})
// 检查是否有任何有权限的 item
const hasAnyAuthItem = computed(() => {
return props.list.some((item) => !item.auth || hasAuth(item.auth))
})
const emit = defineEmits<{
(e: 'click', item: ButtonMoreItem): void
}>()
const handleClick = (item: ButtonMoreItem) => {
emit('click', item)
}
</script>

View File

@@ -0,0 +1,59 @@
<!-- 表格按钮 -->
<template>
<div
:class="[
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 mr-2.5 text-sm c-p rounded-md align-middle',
buttonClass
]"
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
@click="handleClick"
>
<ArtSvgIcon :icon="iconContent" />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtButtonTable' })
interface Props {
/** 按钮类型 */
type?: 'add' | 'edit' | 'delete' | 'more' | 'view'
/** 按钮图标 */
icon?: string
/** 按钮样式类 */
iconClass?: string
/** icon 颜色 */
iconColor?: string
/** 按钮背景色 */
buttonBgColor?: string
}
const props = withDefaults(defineProps<Props>(), {})
const emit = defineEmits<{
(e: 'click'): void
}>()
// 默认按钮配置
const defaultButtons = {
add: { icon: 'ri:add-fill', class: 'bg-theme/12 text-theme' },
edit: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
delete: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
view: { icon: 'ri:eye-line', class: 'bg-info/12 text-info' },
more: { icon: 'ri:more-2-fill', class: '' }
} as const
// 获取图标内容
const iconContent = computed(() => {
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
})
// 获取按钮样式类
const buttonClass = computed(() => {
return props.iconClass || (props.type ? defaultButtons[props.type]?.class : '') || ''
})
const handleClick = () => {
emit('click')
}
</script>

View File

@@ -0,0 +1,430 @@
<!-- 拖拽验证组件 -->
<template>
<div
ref="dragVerify"
class="drag_verify"
:style="dragVerifyStyle"
@mousemove="dragMoving"
@mouseup="dragFinish"
@mouseleave="dragFinish"
@touchmove="dragMoving"
@touchend="dragFinish"
>
<!-- 进度条 -->
<div
class="dv_progress_bar"
:class="{ goFirst2: isOk }"
ref="progressBar"
:style="progressBarStyle"
>
</div>
<!-- 提示文本 -->
<div class="dv_text" :style="textStyle" ref="messageRef">
<slot name="textBefore" v-if="$slots.textBefore"></slot>
{{ message }}
<slot name="textAfter" v-if="$slots.textAfter"></slot>
</div>
<!-- 滑块处理器 -->
<div
class="dv_handler dv_handler_bg"
:class="{ goFirst: isOk }"
@mousedown="dragStart"
@touchstart="dragStart"
ref="handler"
:style="handlerStyle"
>
<ArtSvgIcon :icon="value ? successIcon : handlerIcon" class="text-g-600"></ArtSvgIcon>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtDragVerify' })
// 事件定义
const emit = defineEmits(['handlerMove', 'update:value', 'passCallback'])
// 组件属性接口定义
interface PropsType {
/** 是否通过验证 */
value: boolean
/** 组件宽度 */
width?: number | string
/** 组件高度 */
height?: number
/** 默认提示文本 */
text?: string
/** 成功提示文本 */
successText?: string
/** 背景色 */
background?: string
/** 进度条背景色 */
progressBarBg?: string
/** 完成状态背景色 */
completedBg?: string
/** 是否圆角 */
circle?: boolean
/** 圆角大小 */
radius?: string
/** 滑块图标 */
handlerIcon?: string
/** 成功图标 */
successIcon?: string
/** 滑块背景色 */
handlerBg?: string
/** 文本大小 */
textSize?: string
/** 文本颜色 */
textColor?: string
}
// 属性默认值设置
const props = withDefaults(defineProps<PropsType>(), {
value: false,
width: '100%',
height: 40,
text: '按住滑块拖动',
successText: 'success',
background: '#eee',
progressBarBg: '#1385FF',
completedBg: '#57D187',
circle: false,
radius: 'calc(var(--custom-radius) / 3 + 2px)',
handlerIcon: 'solar:double-alt-arrow-right-linear',
successIcon: 'ri:check-fill',
handlerBg: '#fff',
textSize: '13px',
textColor: '#333'
})
// 组件状态接口定义
interface StateType {
isMoving: boolean // 是否正在拖拽
x: number // 拖拽起始位置
isOk: boolean // 是否验证成功
}
// 响应式状态定义
const state = reactive(<StateType>{
isMoving: false,
x: 0,
isOk: false
})
// 解构响应式状态
const { isOk } = toRefs(state)
// DOM 元素引用
const dragVerify = ref()
const messageRef = ref()
const handler = ref()
const progressBar = ref()
// 触摸事件变量 - 用于禁止页面滑动
let startX: number, startY: number, moveX: number, moveY: number
/**
* 触摸开始事件处理
* @param e 触摸事件对象
*/
const onTouchStart = (e: any) => {
startX = e.targetTouches[0].pageX
startY = e.targetTouches[0].pageY
}
/**
* 触摸移动事件处理 - 判断是否为横向滑动,如果是则阻止默认行为
* @param e 触摸事件对象
*/
const onTouchMove = (e: any) => {
moveX = e.targetTouches[0].pageX
moveY = e.targetTouches[0].pageY
// 如果横向移动距离大于纵向移动距离,阻止默认行为(防止页面滑动)
if (Math.abs(moveX - startX) > Math.abs(moveY - startY)) {
e.preventDefault()
}
}
// 全局事件监听器添加
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchmove', onTouchMove, { passive: false })
// 获取数值形式的宽度
const getNumericWidth = (): number => {
if (typeof props.width === 'string') {
// 如果是字符串尝试从DOM元素获取实际宽度
return dragVerify.value?.offsetWidth || 260
}
return props.width
}
// 获取样式字符串形式的宽度
const getStyleWidth = (): string => {
if (typeof props.width === 'string') {
return props.width
}
return props.width + 'px'
}
// 组件挂载后的初始化
onMounted(() => {
// 设置 CSS 自定义属性
dragVerify.value?.style.setProperty('--textColor', props.textColor)
// 等待DOM更新后设置宽度相关属性
nextTick(() => {
const numericWidth = getNumericWidth()
dragVerify.value?.style.setProperty('--width', Math.floor(numericWidth / 2) + 'px')
dragVerify.value?.style.setProperty('--pwidth', -Math.floor(numericWidth / 2) + 'px')
})
// 重复添加事件监听器(确保事件绑定)
document.addEventListener('touchstart', onTouchStart)
document.addEventListener('touchmove', onTouchMove, { passive: false })
})
// 组件卸载前清理事件监听器
onBeforeUnmount(() => {
document.removeEventListener('touchstart', onTouchStart)
document.removeEventListener('touchmove', onTouchMove)
})
// 滑块样式计算
const handlerStyle = {
left: '0',
width: props.height + 'px',
height: props.height + 'px',
background: props.handlerBg
}
// 主容器样式计算
const dragVerifyStyle = computed(() => ({
width: getStyleWidth(),
height: props.height + 'px',
lineHeight: props.height + 'px',
background: props.background,
borderRadius: props.circle ? props.height / 2 + 'px' : props.radius
}))
// 进度条样式计算
const progressBarStyle = {
background: props.progressBarBg,
height: props.height + 'px',
borderRadius: props.circle
? props.height / 2 + 'px 0 0 ' + props.height / 2 + 'px'
: props.radius
}
// 文本样式计算
const textStyle = computed(() => ({
fontSize: props.textSize
}))
// 显示消息计算属性
const message = computed(() => {
return props.value ? props.successText : props.text
})
/**
* 拖拽开始处理函数
* @param e 鼠标或触摸事件对象
*/
const dragStart = (e: any) => {
if (!props.value) {
state.isMoving = true
handler.value.style.transition = 'none'
// 计算拖拽起始位置
state.x =
(e.pageX || e.touches[0].pageX) - parseInt(handler.value.style.left.replace('px', ''), 10)
}
emit('handlerMove')
}
/**
* 拖拽移动处理函数
* @param e 鼠标或触摸事件对象
*/
const dragMoving = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth()
// 计算当前位置
let _x = (e.pageX || e.touches[0].pageX) - state.x
// 在有效范围内移动
if (_x > 0 && _x <= numericWidth - props.height) {
handler.value.style.left = _x + 'px'
progressBar.value.style.width = _x + props.height / 2 + 'px'
} else if (_x > numericWidth - props.height) {
// 拖拽到末端,触发验证成功
handler.value.style.left = numericWidth - props.height + 'px'
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
passVerify()
}
}
}
/**
* 拖拽结束处理函数
* @param e 鼠标或触摸事件对象
*/
const dragFinish = (e: any) => {
if (state.isMoving && !props.value) {
const numericWidth = getNumericWidth()
// 计算最终位置
let _x = (e.pageX || e.changedTouches[0].pageX) - state.x
if (_x < numericWidth - props.height) {
// 未拖拽到末端,重置位置
state.isOk = true
handler.value.style.left = '0'
handler.value.style.transition = 'all 0.2s'
progressBar.value.style.width = '0'
state.isOk = false
} else {
// 拖拽到末端,保持验证成功状态
handler.value.style.transition = 'none'
handler.value.style.left = numericWidth - props.height + 'px'
progressBar.value.style.width = numericWidth - props.height / 2 + 'px'
passVerify()
}
state.isMoving = false
}
}
/**
* 验证通过处理函数
*/
const passVerify = () => {
emit('update:value', true)
state.isMoving = false
// 更新样式为成功状态
progressBar.value.style.background = props.completedBg
messageRef.value.style['-webkit-text-fill-color'] = 'unset'
messageRef.value.style.animation = 'slidetounlock2 2s cubic-bezier(0, 0.2, 1, 1) infinite'
messageRef.value.style.color = '#fff'
emit('passCallback')
}
/**
* 重置验证状态函数
*/
const reset = () => {
// 重置滑块位置
handler.value.style.left = '0'
progressBar.value.style.width = '0'
progressBar.value.style.background = props.progressBarBg
// 重置文本样式
messageRef.value.style['-webkit-text-fill-color'] = 'transparent'
messageRef.value.style.animation = 'slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite'
messageRef.value.style.color = props.background
// 重置状态
emit('update:value', false)
state.isOk = false
state.isMoving = false
state.x = 0
}
// 暴露重置方法给父组件
defineExpose({
reset
})
</script>
<style lang="scss" scoped>
.drag_verify {
position: relative;
box-sizing: border-box;
overflow: hidden;
text-align: center;
border: 1px solid var(--default-border-dashed);
.dv_handler {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
cursor: move;
i {
padding-left: 0;
font-size: 14px;
color: #999;
}
.el-icon-circle-check {
margin-top: 9px;
color: #6c6;
}
}
.dv_progress_bar {
position: absolute;
width: 0;
height: 34px;
}
.dv_text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: transparent;
user-select: none;
background: linear-gradient(
to right,
var(--textColor) 0%,
var(--textColor) 40%,
#fff 50%,
var(--textColor) 60%,
var(--textColor) 100%
);
-webkit-background-clip: text;
background-clip: text;
animation: slidetounlock 2s cubic-bezier(0, 0.2, 1, 1) infinite;
-webkit-text-fill-color: transparent;
text-size-adjust: none;
* {
-webkit-text-fill-color: var(--textColor);
}
}
}
.goFirst {
left: 0 !important;
transition: left 0.5s;
}
.goFirst2 {
width: 0 !important;
transition: width 0.5s;
}
</style>
<style lang="scss">
@keyframes slidetounlock {
0% {
background-position: var(--pwidth) 0;
}
100% {
background-position: var(--width) 0;
}
}
@keyframes slidetounlock2 {
0% {
background-position: var(--pwidth) 0;
}
100% {
background-position: var(--pwidth) 0;
}
}
</style>

View File

@@ -0,0 +1,389 @@
<!-- 导出 Excel 文件 -->
<template>
<ElButton
:type="type"
:size="size"
:loading="isExporting"
:disabled="disabled || !hasData"
v-ripple
@click="handleExport"
>
<template #loading>
<ElIcon class="is-loading">
<Loading />
</ElIcon>
{{ loadingText }}
</template>
<slot>{{ buttonText }}</slot>
</ElButton>
</template>
<script setup lang="ts">
import * as XLSX from 'xlsx'
import FileSaver from 'file-saver'
import { ref, computed, nextTick } from 'vue'
import { Loading } from '@element-plus/icons-vue'
import type { ButtonType } from 'element-plus'
import { useThrottleFn } from '@vueuse/core'
defineOptions({ name: 'ArtExcelExport' })
/** 导出数据类型 */
type ExportValue = string | number | boolean | null | undefined | Date
interface ExportData {
[key: string]: ExportValue
}
/** 列配置 */
interface ColumnConfig {
/** 列标题 */
title: string
/** 列宽度 */
width?: number
/** 数据格式化函数 */
formatter?: (value: ExportValue, row: ExportData, index: number) => string
}
/** 导出配置选项 */
interface ExportOptions {
/** 数据源 */
data: ExportData[]
/** 文件名(不含扩展名) */
filename?: string
/** 工作表名称 */
sheetName?: string
/** 按钮类型 */
type?: ButtonType
/** 按钮尺寸 */
size?: 'large' | 'default' | 'small'
/** 是否禁用 */
disabled?: boolean
/** 按钮文本 */
buttonText?: string
/** 加载中文本 */
loadingText?: string
/** 是否自动添加序号列 */
autoIndex?: boolean
/** 序号列标题 */
indexColumnTitle?: string
/** 列配置映射 */
columns?: Record<string, ColumnConfig>
/** 表头映射(简化版本,向后兼容) */
headers?: Record<string, string>
/** 最大导出行数 */
maxRows?: number
/** 是否显示成功消息 */
showSuccessMessage?: boolean
/** 是否显示错误消息 */
showErrorMessage?: boolean
/** 工作簿配置 */
workbookOptions?: {
/** 创建者 */
creator?: string
/** 最后修改者 */
lastModifiedBy?: string
/** 创建时间 */
created?: Date
/** 修改时间 */
modified?: Date
}
}
const props = withDefaults(defineProps<ExportOptions>(), {
filename: () => `export_${new Date().toISOString().slice(0, 10)}`,
sheetName: 'Sheet1',
type: 'primary',
size: 'default',
disabled: false,
buttonText: '导出 Excel',
loadingText: '导出中...',
autoIndex: false,
indexColumnTitle: '序号',
columns: () => ({}),
headers: () => ({}),
maxRows: 100000,
showSuccessMessage: true,
showErrorMessage: true,
workbookOptions: () => ({})
})
const emit = defineEmits<{
'before-export': [data: ExportData[]]
'export-success': [filename: string, rowCount: number]
'export-error': [error: ExportError]
'export-progress': [progress: number]
}>()
/** 导出错误类型 */
class ExportError extends Error {
constructor(
message: string,
public code: string,
public details?: any
) {
super(message)
this.name = 'ExportError'
}
}
const isExporting = ref(false)
/** 是否有数据可导出 */
const hasData = computed(() => Array.isArray(props.data) && props.data.length > 0)
/** 验证导出数据 */
const validateData = (data: ExportData[]): void => {
if (!Array.isArray(data)) {
throw new ExportError('数据必须是数组格式', 'INVALID_DATA_TYPE')
}
if (data.length === 0) {
throw new ExportError('没有可导出的数据', 'NO_DATA')
}
if (data.length > props.maxRows) {
throw new ExportError(`数据行数超过限制(${props.maxRows}行)`, 'EXCEED_MAX_ROWS', {
currentRows: data.length,
maxRows: props.maxRows
})
}
}
/** 格式化单元格值 */
const formatCellValue = (
value: ExportValue,
key: string,
row: ExportData,
index: number
): string => {
// 使用列配置的格式化函数
const column = props.columns[key]
if (column?.formatter) {
return column.formatter(value, row, index)
}
// 处理特殊值
if (value === null || value === undefined) {
return ''
}
if (value instanceof Date) {
return value.toLocaleDateString('zh-CN')
}
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
return String(value)
}
/** 处理数据 */
const processData = (data: ExportData[]): Record<string, string>[] => {
const processedData = data.map((item, index) => {
const processedItem: Record<string, string> = {}
// 添加序号列
if (props.autoIndex) {
processedItem[props.indexColumnTitle] = String(index + 1)
}
// 处理数据列
Object.entries(item).forEach(([key, value]) => {
// 获取列标题
let columnTitle = key
if (props.columns[key]?.title) {
columnTitle = props.columns[key].title
} else if (props.headers[key]) {
columnTitle = props.headers[key]
}
// 格式化值
processedItem[columnTitle] = formatCellValue(value, key, item, index)
})
return processedItem
})
return processedData
}
/** 计算列宽度 */
const calculateColumnWidths = (data: Record<string, string>[]): XLSX.ColInfo[] => {
if (data.length === 0) return []
const sampleSize = Math.min(data.length, 100) // 只取前100行计算列宽
const columns = Object.keys(data[0])
return columns.map((column) => {
// 使用配置的列宽度
const configWidth = Object.values(props.columns).find((col) => col.title === column)?.width
if (configWidth) {
return { wch: configWidth }
}
// 自动计算列宽度
const maxLength = Math.max(
column.length, // 标题长度
...data.slice(0, sampleSize).map((row) => String(row[column] || '').length)
)
// 限制最小和最大宽度
const width = Math.min(Math.max(maxLength + 2, 8), 50)
return { wch: width }
})
}
/** 导出到 Excel */
const exportToExcel = async (
data: ExportData[],
filename: string,
sheetName: string
): Promise<void> => {
try {
emit('export-progress', 10)
// 处理数据
const processedData = processData(data)
emit('export-progress', 30)
// 创建工作簿
const workbook = XLSX.utils.book_new()
// 设置工作簿属性
if (props.workbookOptions) {
workbook.Props = {
Title: filename,
Subject: '数据导出',
Author: props.workbookOptions.creator || 'Art Design Pro',
Manager: props.workbookOptions.lastModifiedBy || '',
Company: '系统导出',
Category: '数据',
Keywords: 'excel,export,data',
Comments: '由系统自动生成',
CreatedDate: props.workbookOptions.created || new Date(),
ModifiedDate: props.workbookOptions.modified || new Date()
}
}
emit('export-progress', 50)
// 创建工作表
const worksheet = XLSX.utils.json_to_sheet(processedData)
// 设置列宽度
worksheet['!cols'] = calculateColumnWidths(processedData)
emit('export-progress', 70)
// 添加工作表到工作簿
XLSX.utils.book_append_sheet(workbook, worksheet, sheetName)
emit('export-progress', 85)
// 生成 Excel 文件
const excelBuffer = XLSX.write(workbook, {
bookType: 'xlsx',
type: 'array',
compression: true
})
// 创建 Blob 并下载
const blob = new Blob([excelBuffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
})
emit('export-progress', 95)
// 使用时间戳确保文件名唯一
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
const finalFilename = `${filename}_${timestamp}.xlsx`
FileSaver.saveAs(blob, finalFilename)
emit('export-progress', 100)
// 等待下载开始
await nextTick()
return Promise.resolve()
} catch (error) {
throw new ExportError(`Excel 导出失败: ${(error as Error).message}`, 'EXPORT_FAILED', error)
}
}
/** 处理导出 */
const handleExport = useThrottleFn(async () => {
if (isExporting.value) return
isExporting.value = true
try {
// 验证数据
validateData(props.data)
// 触发导出前事件
emit('before-export', props.data)
// 执行导出
await exportToExcel(props.data, props.filename, props.sheetName)
// 触发成功事件
emit('export-success', props.filename, props.data.length)
// 显示成功消息
if (props.showSuccessMessage) {
ElMessage.success({
message: `成功导出 ${props.data.length} 条数据`,
duration: 3000
})
}
} catch (error) {
const exportError =
error instanceof ExportError
? error
: new ExportError(`导出失败: ${(error as Error).message}`, 'UNKNOWN_ERROR', error)
// 触发错误事件
emit('export-error', exportError)
// 显示错误消息
if (props.showErrorMessage) {
ElMessage.error({
message: exportError.message,
duration: 5000
})
}
console.error('Excel 导出错误:', exportError)
} finally {
isExporting.value = false
emit('export-progress', 0)
}
}, 1000)
// 暴露方法供父组件调用
defineExpose({
exportData: handleExport,
isExporting: readonly(isExporting),
hasData
})
</script>
<style scoped>
.is-loading {
animation: rotating 2s linear infinite;
}
@keyframes rotating {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,62 @@
<!-- 导入 Excel 文件 -->
<template>
<div class="inline-block">
<ElUpload
:auto-upload="false"
accept=".xlsx, .xls"
:show-file-list="false"
@change="handleFileChange"
>
<ElButton type="primary" v-ripple>
<slot>导入 Excel</slot>
</ElButton>
</ElUpload>
</div>
</template>
<script setup lang="ts">
import * as XLSX from 'xlsx'
import type { UploadFile } from 'element-plus'
defineOptions({ name: 'ArtExcelImport' })
// Excel 导入工具函数
async function importExcel(file: File): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = e.target?.result
const workbook = XLSX.read(data, { type: 'array' })
const firstSheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[firstSheetName]
const results = XLSX.utils.sheet_to_json(worksheet)
resolve(results as Array<Record<string, unknown>>)
} catch (error) {
reject(error)
}
}
reader.onerror = (error) => reject(error)
reader.readAsArrayBuffer(file)
})
}
// 定义 emits
const emit = defineEmits<{
'import-success': [data: Array<Record<string, unknown>>]
'import-error': [error: Error]
}>()
// 处理文件导入
const handleFileChange = async (uploadFile: UploadFile) => {
try {
if (!uploadFile.raw) return
const results = await importExcel(uploadFile.raw)
emit('import-success', results)
} catch (error) {
emit('import-error', error as Error)
}
}
</script>

View File

@@ -0,0 +1,311 @@
<!-- 表单组件 -->
<!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 -->
<template>
<section class="px-4 pb-0 pt-4 md:px-4 md:pt-4">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow class="flex flex-wrap" :gutter="gutter">
<ElCol
v-for="item in visibleFormItems"
:key="item.key"
:xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')"
>
<ElFormItem
:prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
:is="getComponent(item)"
v-model="modelValue[item.key]"
v-bind="getProps(item)"
>
<!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
<ElCheckbox
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
<ElRadio
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
<component :is="slotFn" />
</template>
</component>
</slot>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="max-w-full flex-1">
<div
class="mb-3 flex-c flex-wrap justify-end md:flex-row md:items-stretch md:gap-2"
:style="actionButtonsStyle"
>
<div class="flex gap-2 md:justify-center">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
{{ t('table.form.reset') }}
</ElButton>
<ElButton
v-if="showSubmit"
type="primary"
class="submit-button"
@click="handleSubmit"
v-ripple
:disabled="disabledSubmit"
>
{{ t('table.form.submit') }}
</ElButton>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import type { Component } from 'vue'
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
type FormInstance
} from 'element-plus'
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
defineOptions({ name: 'ArtForm' })
const componentMap = {
input: ElInput, // 输入框
inputtag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器
switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect // 树选择器
}
const { width } = useWindowSize()
const { t } = useI18n()
const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单项配置
export interface FormItem {
/** 表单项的唯一标识 */
key: string
/** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number
/** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type */
render?: (() => VNode) | Component
/** 是否隐藏该表单项 */
hidden?: boolean
/** 表单项占据的列宽基于24格栅格系统 */
span?: number
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any>
/** 传递给表单项组件的属性 */
props?: Record<string, any>
/** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined>
/** 表单项的占位符文本 */
placeholder?: string
/** 更多属性配置请参考 ElementPlus 官方文档 */
}
// 表单配置
interface FormProps {
/** 表单数据 */
items: FormItem[]
/** 每列的宽度(基于 24 格布局) */
span?: number
/** 表单控件间隙 */
gutter?: number
/** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 文字宽度 */
labelWidth?: string | number
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number
/** 是否显示重置按钮 */
showReset?: boolean
/** 是否显示提交按钮 */
showSubmit?: boolean
/** 是否禁用提交按钮 */
disabledSubmit?: boolean
}
const props = withDefaults(defineProps<FormProps>(), {
items: () => [],
span: 6,
gutter: 12,
labelPosition: 'right',
labelWidth: '70px',
buttonLeftLimit: 2,
showReset: true,
showSubmit: true,
disabledSubmit: false
})
interface FormEmits {
reset: []
submit: []
}
const emit = defineEmits<FormEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} })
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
const getProps = (item: FormItem) => {
if (item.props) return item.props
const props = { ...item }
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
return props
}
// 获取插槽
const getSlots = (item: FormItem) => {
if (!item.slots) return {}
const validSlots: Record<string, () => any> = {}
Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) {
validSlots[key] = slotFn
}
})
return validSlots
}
// 组件
const getComponent = (item: FormItem) => {
// 优先使用 render 函数或组件渲染自定义组件
if (item.render) {
return item.render
}
// 使用 type 获取预定义组件
const { type } = item
return componentMap[type as keyof typeof componentMap] || componentMap['input']
}
/**
* 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
}
/**
* 可见的表单项
*/
const visibleFormItems = computed(() => {
return props.items.filter((item) => !item.hidden)
})
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value
? 'flex-end'
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? 'flex-start'
: 'flex-end'
}))
/**
* 处理重置事件
*/
const handleReset = () => {
// 重置表单字段UI 层)
formInstance.value?.resetFields()
// 清空所有表单项值(包含隐藏项)
Object.assign(
modelValue.value,
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
)
// 触发 reset 事件
emit('reset')
}
/**
* 处理提交事件
*/
const handleSubmit = () => {
emit('submit')
}
defineExpose({
ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset
})
// 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
</script>

View File

@@ -0,0 +1,437 @@
<!-- 表格搜索组件 -->
<!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 -->
<template>
<section class="art-search-bar art-card-xs" :class="{ 'is-expanded': isExpanded }">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow :gutter="gutter">
<ElCol
v-for="item in visibleFormItems"
:key="item.key"
:xs="getColSpan(item.span, 'xs')"
:sm="getColSpan(item.span, 'sm')"
:md="getColSpan(item.span, 'md')"
:lg="getColSpan(item.span, 'lg')"
:xl="getColSpan(item.span, 'xl')"
>
<ElFormItem
:prop="item.key"
:label-width="item.label ? item.labelWidth || labelWidth : undefined"
>
<template #label v-if="item.label">
<component v-if="typeof item.label !== 'string'" :is="item.label" />
<span v-else>{{ item.label }}</span>
</template>
<slot :name="item.key" :item="item" :modelValue="modelValue">
<component
:is="getComponent(item)"
v-model="modelValue[item.key]"
v-bind="getProps(item)"
>
<!-- 下拉选择 -->
<template v-if="item.type === 'select' && getProps(item)?.options">
<ElOption
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 复选框组 -->
<template v-if="item.type === 'checkboxgroup' && getProps(item)?.options">
<ElCheckbox
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 单选框组 -->
<template v-if="item.type === 'radiogroup' && getProps(item)?.options">
<ElRadio
v-for="option in getProps(item).options"
v-bind="option"
:key="option.value"
/>
</template>
<!-- 动态插槽支持 -->
<template v-for="(slotFn, slotName) in getSlots(item)" :key="slotName" #[slotName]>
<component :is="slotFn" />
</template>
</component>
</slot>
</ElFormItem>
</ElCol>
<ElCol :xs="24" :sm="24" :md="span" :lg="span" :xl="span" class="action-column">
<div class="action-buttons-wrapper" :style="actionButtonsStyle">
<div class="form-buttons">
<ElButton v-if="showReset" class="reset-button" @click="handleReset" v-ripple>
{{ t('table.searchBar.reset') }}
</ElButton>
<ElButton
v-if="showSearch"
type="primary"
class="search-button"
@click="handleSearch"
v-ripple
:disabled="disabledSearch"
>
{{ t('table.searchBar.search') }}
</ElButton>
</div>
<div v-if="shouldShowExpandToggle" class="filter-toggle" @click="toggleExpand">
<span>{{ expandToggleText }}</span>
<div class="icon-wrapper">
<ElIcon>
<ArrowUpBold v-if="isExpanded" />
<ArrowDownBold v-else />
</ElIcon>
</div>
</div>
</div>
</ElCol>
</ElRow>
</ElForm>
</section>
</template>
<script setup lang="ts">
import { ArrowUpBold, ArrowDownBold } from '@element-plus/icons-vue'
import { useWindowSize } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import type { Component } from 'vue'
import {
ElCascader,
ElCheckbox,
ElCheckboxGroup,
ElDatePicker,
ElInput,
ElInputTag,
ElInputNumber,
ElRadioGroup,
ElRate,
ElSelect,
ElSlider,
ElSwitch,
ElTimePicker,
ElTimeSelect,
ElTreeSelect,
type FormInstance
} from 'element-plus'
import { calculateResponsiveSpan, type ResponsiveBreakpoint } from '@/utils/form/responsive'
defineOptions({ name: 'ArtSearchBar' })
const componentMap = {
input: ElInput, // 输入框
inputTag: ElInputTag, // 标签输入框
number: ElInputNumber, // 数字输入框
select: ElSelect, // 选择器
switch: ElSwitch, // 开关
checkbox: ElCheckbox, // 复选框
checkboxgroup: ElCheckboxGroup, // 复选框组
radiogroup: ElRadioGroup, // 单选框组
date: ElDatePicker, // 日期选择器
daterange: ElDatePicker, // 日期范围选择器
datetime: ElDatePicker, // 日期时间选择器
datetimerange: ElDatePicker, // 日期时间范围选择器
rate: ElRate, // 评分
slider: ElSlider, // 滑块
cascader: ElCascader, // 级联选择器
timepicker: ElTimePicker, // 时间选择器
timeselect: ElTimeSelect, // 时间选择
treeselect: ElTreeSelect // 树选择器
}
const { width } = useWindowSize()
const { t } = useI18n()
const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单项配置
export interface SearchFormItem {
/** 表单项的唯一标识 */
key: string
/** 表单项的标签文本或自定义渲染函数 */
label: string | (() => VNode) | Component
/** 表单项标签的宽度,会覆盖 Form 的 labelWidth */
labelWidth?: string | number
/** 表单项类型,支持预定义的组件类型 */
type?: keyof typeof componentMap | string
/** 自定义渲染函数或组件,用于渲染自定义组件(优先级高于 type */
render?: (() => VNode) | Component
/** 是否隐藏该表单项 */
hidden?: boolean
/** 表单项占据的列宽基于24格栅格系统 */
span?: number
/** 选项数据,用于 select、checkbox-group、radio-group 等 */
options?: Record<string, any>
/** 传递给表单项组件的属性 */
props?: Record<string, any>
/** 表单项的插槽配置 */
slots?: Record<string, (() => any) | undefined>
/** 表单项的占位符文本 */
placeholder?: string
/** 更多属性配置请参考 ElementPlus 官方文档 */
}
// 表单配置
interface SearchBarProps {
/** 表单数据 */
items: SearchFormItem[]
/** 每列的宽度(基于 24 格布局) */
span?: number
/** 表单控件间隙 */
gutter?: number
/** 展开/收起 */
isExpand?: boolean
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
defaultExpanded?: boolean
/** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 文字宽度 */
labelWidth?: string | number
/** 是否需要展示,收起 */
showExpand?: boolean
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number
/** 是否显示重置按钮 */
showReset?: boolean
/** 是否显示搜索按钮 */
showSearch?: boolean
/** 是否禁用搜索按钮 */
disabledSearch?: boolean
}
const props = withDefaults(defineProps<SearchBarProps>(), {
items: () => [],
span: 6,
gutter: 12,
isExpand: false,
labelPosition: 'right',
labelWidth: '70px',
showExpand: true,
defaultExpanded: false,
buttonLeftLimit: 2,
showReset: true,
showSearch: true,
disabledSearch: false
})
interface SearchBarEmits {
reset: []
search: []
}
const emit = defineEmits<SearchBarEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} })
/**
* 是否展开状态
*/
const isExpanded = ref(props.defaultExpanded)
const rootProps = ['label', 'labelWidth', 'key', 'type', 'hidden', 'span', 'slots']
const getProps = (item: SearchFormItem) => {
if (item.props) return item.props
const props = { ...item }
rootProps.forEach((key) => delete (props as Record<string, any>)[key])
return props
}
// 获取插槽
const getSlots = (item: SearchFormItem) => {
if (!item.slots) return {}
const validSlots: Record<string, () => any> = {}
Object.entries(item.slots).forEach(([key, slotFn]) => {
if (slotFn) {
validSlots[key] = slotFn
}
})
return validSlots
}
/**
* 获取列宽 span 值
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*/
const getColSpan = (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, span.value, breakpoint)
}
// 组件
const getComponent = (item: SearchFormItem) => {
// 优先使用 render 函数或组件渲染自定义组件
if (item.render) {
return item.render
}
// 使用 type 获取预定义组件
const { type } = item
return componentMap[type as keyof typeof componentMap] || componentMap['input']
}
/**
* 可见的表单项
*/
const visibleFormItems = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden)
const shouldShowLess = !props.isExpand && !isExpanded.value
if (shouldShowLess) {
const maxItemsPerRow = Math.floor(24 / props.span) - 1
return filteredItems.slice(0, maxItemsPerRow)
}
return filteredItems
})
/**
* 是否应该显示展开/收起按钮
*/
const shouldShowExpandToggle = computed(() => {
const filteredItems = props.items.filter((item) => !item.hidden)
return (
!props.isExpand && props.showExpand && filteredItems.length > Math.floor(24 / props.span) - 1
)
})
/**
* 展开/收起按钮文本
*/
const expandToggleText = computed(() => {
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
})
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value
? 'flex-end'
: props.items.filter((item) => !item.hidden).length <= props.buttonLeftLimit
? 'flex-start'
: 'flex-end'
}))
/**
* 切换展开/收起状态
*/
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
}
/**
* 处理重置事件
*/
const handleReset = () => {
// 重置表单字段UI 层)
formInstance.value?.resetFields()
// 清空所有表单项值(包含隐藏项)
Object.assign(
modelValue.value,
Object.fromEntries(props.items.map(({ key }) => [key, undefined]))
)
// 触发 reset 事件
emit('reset')
}
/**
* 处理搜索事件
*/
const handleSearch = () => {
emit('search')
}
defineExpose({
ref: formInstance,
validate: (...args: any[]) => formInstance.value?.validate(...args),
reset: handleReset
})
// 解构 props 以便在模板中直接使用
const { span, gutter, labelPosition, labelWidth } = toRefs(props)
</script>
<style lang="scss" scoped>
.art-search-bar {
padding: 15px 20px 0;
.action-column {
flex: 1;
max-width: 100%;
.action-buttons-wrapper {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
margin-bottom: 12px;
}
.form-buttons {
display: flex;
gap: 8px;
}
.filter-toggle {
display: flex;
align-items: center;
margin-left: 10px;
line-height: 32px;
color: var(--theme-color);
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: var(--ElColor-primary);
}
span {
font-size: 14px;
user-select: none;
}
.icon-wrapper {
display: flex;
align-items: center;
margin-left: 4px;
font-size: 14px;
transition: transform 0.2s ease;
}
}
}
}
// 响应式优化
@media (width <= 768px) {
.art-search-bar {
padding: 16px 16px 0;
.action-column {
.action-buttons-wrapper {
flex-direction: column;
gap: 8px;
align-items: stretch;
.form-buttons {
justify-content: center;
}
.filter-toggle {
justify-content: center;
margin-left: 0;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,219 @@
<!-- WangEditor 富文本编辑器 插件地址https://www.wangeditor.com/ -->
<template>
<div class="editor-wrapper">
<Toolbar
class="editor-toolbar"
:editor="editorRef"
:mode="mode"
:defaultConfig="toolbarConfig"
/>
<Editor
:style="{ height: height, overflowY: 'hidden' }"
v-model="modelValue"
:mode="mode"
:defaultConfig="editorConfig"
@onCreated="onCreateEditor"
/>
</div>
</template>
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, onMounted, shallowRef, computed } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import { useUserStore } from '@/store/modules/user'
import EmojiText from '@/utils/ui/emojo'
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
defineOptions({ name: 'ArtWangEditor' })
// Props 定义
interface Props {
/** 编辑器高度 */
height?: string
/** 自定义工具栏配置 */
toolbarKeys?: string[]
/** 插入新工具到指定位置 */
insertKeys?: { index: number; keys: string[] }
/** 排除的工具栏项 */
excludeKeys?: string[]
/** 编辑器模式 */
mode?: 'default' | 'simple'
/** 占位符文本 */
placeholder?: string
/** 上传配置 */
uploadConfig?: {
maxFileSize?: number
maxNumberOfFiles?: number
server?: string
}
}
const props = withDefaults(defineProps<Props>(), {
height: '500px',
mode: 'default',
placeholder: '请输入内容...',
excludeKeys: () => ['fontFamily']
})
const modelValue = defineModel<string>({ required: true })
// 编辑器实例
const editorRef = shallowRef<IDomEditor>()
const userStore = useUserStore()
// 常量配置
const DEFAULT_UPLOAD_CONFIG = {
maxFileSize: 3 * 1024 * 1024, // 3MB
maxNumberOfFiles: 10,
fieldName: 'file',
allowedFileTypes: ['image/*']
} as const
// 计算属性:上传服务器地址
const uploadServer = computed(
() =>
props.uploadConfig?.server || `${import.meta.env.VITE_API_URL}/api/common/upload/wangeditor`
)
// 合并上传配置
const mergedUploadConfig = computed(() => ({
...DEFAULT_UPLOAD_CONFIG,
...props.uploadConfig
}))
// 工具栏配置
const toolbarConfig = computed((): Partial<IToolbarConfig> => {
const config: Partial<IToolbarConfig> = {}
// 完全自定义工具栏
if (props.toolbarKeys && props.toolbarKeys.length > 0) {
config.toolbarKeys = props.toolbarKeys
}
// 插入新工具
if (props.insertKeys) {
config.insertKeys = props.insertKeys
}
// 排除工具
if (props.excludeKeys && props.excludeKeys.length > 0) {
config.excludeKeys = props.excludeKeys
}
return config
})
// 编辑器配置
const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder,
MENU_CONF: {
uploadImage: {
fieldName: mergedUploadConfig.value.fieldName,
maxFileSize: mergedUploadConfig.value.maxFileSize,
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes,
server: uploadServer.value,
headers: {
Authorization: userStore.accessToken
},
onSuccess() {
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
},
onError(file: File, err: any, res: any) {
console.error('图片上传失败:', err, res)
ElMessage.error(`图片上传失败 ${EmojiText[500]}`)
}
}
}
}
// 编辑器创建回调
const onCreateEditor = (editor: IDomEditor) => {
editorRef.value = editor
// 监听全屏事件
editor.on('fullScreen', () => {
console.log('编辑器进入全屏模式')
})
// 确保在编辑器创建后应用自定义图标
applyCustomIcons()
}
// 应用自定义图标(带重试机制)
const applyCustomIcons = () => {
let retryCount = 0
const maxRetries = 10
const retryDelay = 100
const tryApplyIcons = () => {
const editor = editorRef.value
if (!editor) {
if (retryCount < maxRetries) {
retryCount++
setTimeout(tryApplyIcons, retryDelay)
}
return
}
// 获取当前编辑器的工具栏容器
const editorContainer = editor.getEditableContainer().closest('.editor-wrapper')
if (!editorContainer) {
if (retryCount < maxRetries) {
retryCount++
setTimeout(tryApplyIcons, retryDelay)
}
return
}
const toolbar = editorContainer.querySelector('.w-e-toolbar')
const toolbarButtons = editorContainer.querySelectorAll('.w-e-bar-item button[data-menu-key]')
if (toolbar && toolbarButtons.length > 0) {
return
}
// 如果工具栏还没渲染完成,继续重试
if (retryCount < maxRetries) {
retryCount++
setTimeout(tryApplyIcons, retryDelay)
} else {
console.warn('工具栏渲染超时,无法应用自定义图标 - 编辑器实例:', editor.id)
}
}
// 使用 requestAnimationFrame 确保在下一帧执行
requestAnimationFrame(tryApplyIcons)
}
// 暴露编辑器实例和方法
defineExpose({
/** 获取编辑器实例 */
getEditor: () => editorRef.value,
/** 设置编辑器内容 */
setHtml: (html: string) => editorRef.value?.setHtml(html),
/** 获取编辑器内容 */
getHtml: () => editorRef.value?.getHtml(),
/** 清空编辑器 */
clear: () => editorRef.value?.clear(),
/** 聚焦编辑器 */
focus: () => editorRef.value?.focus()
})
// 生命周期
onMounted(() => {
// 图标替换已在 onCreateEditor 中处理
})
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) {
editor.destroy()
}
})
</script>
<style lang="scss">
@use './style';
</style>

View File

@@ -0,0 +1,210 @@
$box-radius: calc(var(--custom-radius) / 3 + 2px);
// 全屏容器 z-index 调整
.w-e-full-screen-container {
z-index: 100 !important;
}
/* 编辑器容器 */
.editor-wrapper {
width: 100%;
height: 100%;
border: 1px solid var(--art-gray-300);
border-radius: $box-radius !important;
.w-e-bar {
border-radius: $box-radius $box-radius 0 0 !important;
}
.menu-item {
display: flex;
flex-direction: row;
align-items: center;
i {
margin-right: 5px;
}
}
/* 工具栏 */
.editor-toolbar {
border-bottom: 1px solid var(--default-border);
}
/* 下拉选择框配置 */
.w-e-select-list {
min-width: 140px;
padding: 5px 10px 10px;
border: none;
border-radius: $box-radius;
}
/* 下拉选择框元素配置 */
.w-e-select-list ul li {
margin-top: 5px;
font-size: 15px !important;
border-radius: $box-radius;
}
/* 下拉选择框 正文文字大小调整 */
.w-e-select-list ul li:last-of-type {
font-size: 16px !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover {
background-color: var(--art-gray-200);
}
:root {
/* 激活颜色 */
--w-e-toolbar-active-bg-color: var(--art-gray-200);
/* toolbar 图标和文字颜色 */
--w-e-toolbar-color: #000;
/* 表格选中时候的边框颜色 */
--w-e-textarea-selected-border-color: #ddd;
/* 表格头背景颜色 */
--w-e-textarea-slight-bg-color: var(--art-gray-200);
}
/* 工具栏按钮样式 */
.w-e-bar-item svg {
fill: var(--art-gray-800);
}
.w-e-bar-item button {
color: var(--art-gray-800);
border-radius: $box-radius;
}
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: var(--art-gray-200);
}
/* 工具栏分割线 */
.w-e-bar-divider {
height: 20px;
margin-top: 10px;
background-color: #ccc;
}
/* 工具栏菜单 */
.w-e-bar-item-group .w-e-bar-item-menus-container {
min-width: 120px;
padding: 10px 0;
border: none;
border-radius: $box-radius;
.w-e-bar-item {
button {
width: 100%;
margin: 0 5px;
}
}
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
padding: 0.6rem 1rem;
background-color: var(--art-gray-50);
border-radius: $box-radius;
}
/* 弹出框 */
.w-e-drop-panel {
border: 0;
border-radius: $box-radius;
}
a {
color: #318ef4;
}
.w-e-text-container {
strong,
b {
font-weight: 500;
}
i,
em {
font-style: italic;
}
}
/* 表格样式优化 */
.w-e-text-container [data-slate-editor] .table-container th {
border-right: none;
}
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid #ccc !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
background-color: var(--art-gray-200);
border-left: 4px solid var(--art-gray-300);
}
/* 输入区域弹出 bar */
.w-e-hover-bar {
border-radius: $box-radius;
}
/* 超链接弹窗 */
.w-e-modal {
border: none;
border-radius: $box-radius;
}
/* 图片样式调整 */
.w-e-text-container [data-slate-editor] .w-e-selected-image-container {
overflow: inherit;
&:hover {
border: 0;
}
img {
border: 1px solid transparent;
transition: border 0.3s;
&:hover {
border: 1px solid #318ef4 !important;
}
}
.w-e-image-dragger {
width: 12px;
height: 12px;
background-color: #318ef4;
border: 2px solid #fff;
border-radius: $box-radius;
}
.left-top {
top: -6px;
left: -6px;
}
.right-top {
top: -6px;
right: -6px;
}
.left-bottom {
bottom: -6px;
left: -6px;
}
.right-bottom {
right: -6px;
bottom: -6px;
}
}
}

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