初始化

This commit is contained in:
2026-03-03 09:53:54 +08:00
commit 3f349a35a4
437 changed files with 65639 additions and 0 deletions

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 saithink
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.

99
README.md Normal file
View File

@@ -0,0 +1,99 @@
<p align="center">
<img src="https://saithink.top/images/logo.png" width="120" />
</p>
<p align="center">
<img src="https://svg.hamm.cn/badge.svg?key=License&value=MIT" />
<img src="https://svg.hamm.cn/badge.svg?key=Version&value=6.x" />
</p>
<div style="padding:18px;max-width: 1024px;margin:0 auto;">
<h1>SaiAdmin 6.x</h1>
## 项目简介
SaiAdmin 是一个基于 [Webman](https://www.workerman.net/webman) 的高性能后台管理系统插件。它提供了完整的权限管理、系统配置、代码生成等功能,帮助开发者快速构建企业级应用。
---
## ✨ 核心特性
- **🚀 高性能** - 基于 Webman 常驻内存框架,性能优异
- **🔐 完整权限系统** - RBAC 权限模型,支持用户、角色、部门、岗位管理
- **📝 代码生成器** - 一键生成 CRUD 代码,提升开发效率
- **⚡ 双 ORM 支持** - 同时支持 ThinkORM 和 Eloquent ORM
- **🔧 插件化架构** - 支持插件扩展,便于功能模块化
- **📊 系统监控** - 内置服务器监控、缓存管理功能
- **📋 日志系统** - 完整的登录日志和操作日志记录
## 🛠️ 功能模块
### 系统管理
| 模块 | 说明 |
| ---------- | -------------------------------- |
| 用户管理 | 用户增删改查、密码管理、缓存清理 |
| 角色管理 | 角色 CRUD、菜单权限分配 |
| 部门管理 | 组织架构管理、树形结构 |
| 岗位管理 | 岗位信息维护、Excel 模板导入导出 |
| 菜单管理 | 菜单配置、按钮权限 |
| 字典管理 | 字典类型与字典数据维护 |
| 附件管理 | 文件上传、分类管理、资源移动 |
| 系统配置 | 分组配置、邮件设置、动态参数 |
| 日志管理 | 登录日志、操作日志查询与清理 |
| 服务监控 | 服务器状态、缓存信息、一键清理 |
| 数据表维护 | 数据表结构、表优化、碎片整理 |
### 开发工具
| 模块 | 说明 |
| -------- | ---------------------------- |
| 代码生成 | 根据数据表自动生成 CRUD 代码 |
| 定时任务 | Crontab 任务管理、执行日志 |
<h1>学习</h1>
<ul>
<li>
<a href="https://saithink.top" target="_blank">主页 / Home page</a>
</li>
<li>
<a href="https://saithink.top/documents/v6/" target="_blank">文档 / Document</a>
</li>
</ul>
<h1>演示地址</h1>
<p>演示地址: <a href="http://v6.saithink.top" target="_blank">http://v6.saithink.top</a></p>
<p>演示账号admin</p>
<p>演示密码123456</p>
<h1>共同交流</h1>
<table>
<tbody>
<tr>
<td align="center" valign="middle">
<img src="https://saithink.top/images/me.png" class="no-zoom" width="180px">
<p>saiadmin交流群(添加我微信备注"saiadmin")</p>
</td>
</tr>
</tbody>
</table>
<h1>支持项目</h1>
如果您正在使用这个项目并感觉良好,或者是想支持我继续开发,您可以通过如下`任意`方式支持我:
谢谢! ❤️
| 微信 | 支付宝 |
| :------------------------------------------------------------------------------: | :------------------------------------------------------------------------------: |
| <img src="https://saithink.top/images/wechat.png" alt="Wechat QRcode" width=180> | <img src="https://saithink.top/images/alipay.png" alt="Alipay QRcode" width=180> |
<div style="clear: both">
<h1>LICENSE</h1>
This project is open-sourced software licensed under the MIT.
</div>
</div>

22
saiadmin-artd/.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 = backend
# 跨域请求时是否携带 Cookie开启前需确保后端支持
VITE_WITH_CREDENTIALS = false
# 是否打开路由信息
VITE_OPEN_ROUTE_INFO = false
# 锁屏加密密钥
VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro

View File

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

View File

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

2
saiadmin-artd/.gitattributes vendored Normal file
View File

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

11
saiadmin-artd/.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

View File

@@ -0,0 +1 @@
pnpm dlx commitlint --edit $1

View File

@@ -0,0 +1 @@
pnpm run lint:lint-staged

View File

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

20
saiadmin-artd/.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
}

View File

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

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'
]
}
]
}
}

10
saiadmin-artd/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"recommendations": [
"vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"lokalise.i18n-ally",
"bradlc.vscode-tailwindcss"
]
}

18
saiadmin-artd/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"volar.inlayHints.eventArgumentInInlineHandlers": true,
"css.lint.unknownAtRules": "ignore",
"i18n-ally.localesPaths": ["src/locales/langs", "src/locales"],
"i18n-ally.enabledParsers": ["json"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.displayLanguage": "zh",
"i18n-ally.enabledFrameworks": ["vue", "react"],
"i18n-ally.keystyle": "nested",
"i18n-ally.sortKeys": true,
"i18n-ally.namespace": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"prettier.requireConfig": true,
"[vue]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

21
saiadmin-artd/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.

31
saiadmin-artd/SAIADMIN.md Normal file
View File

@@ -0,0 +1,31 @@
## 前端开发
[module] 代表一个应用模块
[business] 代表一个功能模块分组
[table] 代表一个具体的功能
### 前端开发规范
```
src/views/plugin/[module]/
├── api
│ ├── [business]/ # 功能模块接口分组
| │ ├── [table].ts # 具体功能接口
├── [business]/ # 功能模块分组
│ ├── [table]/ # 具体功能目录
│ │ ├── index.vue # 功能主页面(列表页)
│ │ └── modules/ # 子组件目录
│ │ ├── table-search.vue # 搜索表单组件
│ │ └── edit-dialog.vue # 编辑弹窗组件
```
### 组件说明
| 组件文件 | 说明 |
|---------|------|
| `table.ts` | 具体功能接口
| `index.vue` | 功能主页面,包含列表展示、操作按钮等 |
| `modules/table-search.vue` | 搜索表单组件,用于筛选列表数据 |
| `modules/edit-dialog.vue` | 编辑弹窗组件,用于新增/编辑数据 |

View File

@@ -0,0 +1,97 @@
/**
* commitlint 配置文件
* 文档
* https://commitlint.js.org/#/reference-rules
* https://cz-git.qbb.sh/zh/guide/
*/
module.exports = {
// 继承的规则
extends: ['@commitlint/config-conventional'],
// 自定义规则
rules: {
// 提交类型枚举git提交type必须是以下类型
'type-enum': [
2,
'always',
[
'feat', // 新增功能
'fix', // 修复缺陷
'docs', // 文档变更
'style', // 代码格式(不影响功能,例如空格、分号等格式修正)
'refactor', // 代码重构(不包括 bug 修复、功能新增)
'perf', // 性能优化
'test', // 添加疏漏测试或已有测试改动
'build', // 构建流程、外部依赖变更(如升级 npm 包、修改 webpack 配置等)
'ci', // 修改 CI 配置、脚本
'revert', // 回滚 commit
'chore', // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
'wip' // 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)
]
],
'subject-case': [0] // subject大小写不做校验
},
prompt: {
messages: {
type: '选择你要提交的类型 :',
scope: '选择一个提交范围(可选):',
customScope: '请输入自定义的提交范围 :',
subject: '填写简短精炼的变更描述 :\n',
body: '填写更加详细的变更描述(可选)。使用 "|" 换行 :\n',
breaking: '列举非兼容性重大的变更(可选)。使用 "|" 换行 :\n',
footerPrefixesSelect: '选择关联issue前缀可选:',
customFooterPrefix: '输入自定义issue前缀 :',
footer: '列举关联issue (可选) 例如: #31, #I3244 :\n',
generatingByAI: '正在通过 AI 生成你的提交简短描述...',
generatedSelectByAI: '选择一个 AI 生成的简短描述:',
confirmCommit: '是否提交或修改commit ?'
},
// prettier-ignore
types: [
{ value: "feat", name: "feat: 新增功能" },
{ value: "fix", name: "fix: 修复缺陷" },
{ value: "docs", name: "docs: 文档变更" },
{ value: "style", name: "style: 代码格式(不影响功能,例如空格、分号等格式修正)" },
{ value: "refactor", name: "refactor: 代码重构(不包括 bug 修复、功能新增)" },
{ value: "perf", name: "perf: 性能优化" },
{ value: "test", name: "test: 添加疏漏测试或已有测试改动" },
{ value: "build", name: "build: 构建流程、外部依赖变更(如升级 npm 包、修改 vite 配置等)" },
{ value: "ci", name: "ci: 修改 CI 配置、脚本" },
{ value: "revert", name: "revert: 回滚 commit" },
{ value: "chore", name: "chore: 对构建过程或辅助工具和库的更改(不影响源文件、测试用例)" },
],
useEmoji: true,
emojiAlign: 'center',
useAI: false,
aiNumber: 1,
themeColorCode: '',
scopes: [],
allowCustomScopes: true,
allowEmptyScopes: true,
customScopesAlign: 'bottom',
customScopesAlias: 'custom',
emptyScopesAlias: 'empty',
upperCaseSubject: false,
markBreakingChangeMode: false,
allowBreakingChanges: ['feat', 'fix'],
breaklineNumber: 100,
breaklineChar: '|',
skipQuestions: ['breaking', 'footerPrefix', 'footer'], // 跳过的步骤
issuePrefixes: [{ value: 'closed', name: 'closed: ISSUES has been processed' }],
customIssuePrefixAlign: 'top',
emptyIssuePrefixAlias: 'skip',
customIssuePrefixAlias: 'custom',
allowCustomIssuePrefix: true,
allowEmptyIssuePrefix: true,
confirmColorize: true,
maxHeaderLength: Infinity,
maxSubjectLength: Infinity,
minSubjectLength: 0,
scopeOverrides: undefined,
defaultBody: '',
defaultIssues: '',
defaultScope: '',
defaultSubject: ''
}
}

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
saiadmin-artd/index.html Normal file
View File

@@ -0,0 +1,47 @@
<!doctype html>
<html>
<head>
<title>SaiAdmin</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="SaiAdmin - A modern admin dashboard system built with Vue 3, Element Plus, and Webman."
/>
<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>

126
saiadmin-artd/package.json Normal file
View File

@@ -0,0 +1,126 @@
{
"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",
"md-editor-v3": "^6.3.1",
"nprogress": "^0.2.0",
"ohash": "^2.0.11",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
"qrcode.vue": "^3.6.0",
"spark-md5": "^3.0.2",
"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": {
"@commitlint/cli": "^19.4.1",
"@commitlint/config-conventional": "^19.4.1",
"@eslint/js": "^9.9.1",
"@types/node": "^24.0.5",
"@types/spark-md5": "^3.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",
"commitizen": "^4.3.0",
"cz-git": "^1.11.1",
"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",
"husky": "^9.1.5",
"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"
}
}

7553
saiadmin-artd/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,7553 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@element-plus/icons-vue':
specifier: ^2.3.2
version: 2.3.2(vue@3.5.22(typescript@5.6.3))
'@iconify/vue':
specifier: ^5.0.0
version: 5.0.0(vue@3.5.22(typescript@5.6.3))
'@tailwindcss/vite':
specifier: ^4.1.14
version: 4.1.14(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
'@vue/reactivity':
specifier: ^3.5.21
version: 3.5.22
'@vueuse/core':
specifier: ^13.9.0
version: 13.9.0(vue@3.5.22(typescript@5.6.3))
'@wangeditor/editor':
specifier: ^5.1.23
version: 5.1.23
'@wangeditor/editor-for-vue':
specifier: next
version: 5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.22(typescript@5.6.3))
axios:
specifier: ^1.12.2
version: 1.12.2
crypto-js:
specifier: ^4.2.0
version: 4.2.0
echarts:
specifier: ^6.0.0
version: 6.0.0
element-plus:
specifier: ^2.11.2
version: 2.11.4(vue@3.5.22(typescript@5.6.3))
file-saver:
specifier: ^2.0.5
version: 2.0.5
highlight.js:
specifier: ^11.10.0
version: 11.11.1
mitt:
specifier: ^3.0.1
version: 3.0.1
nprogress:
specifier: ^0.2.0
version: 0.2.0
ohash:
specifier: ^2.0.11
version: 2.0.11
pinia:
specifier: ^3.0.3
version: 3.0.3(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))
pinia-plugin-persistedstate:
specifier: ^4.3.0
version: 4.5.0(pinia@3.0.3(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)))
qrcode.vue:
specifier: ^3.6.0
version: 3.6.0(vue@3.5.22(typescript@5.6.3))
spark-md5:
specifier: ^3.0.2
version: 3.0.2
tailwindcss:
specifier: ^4.1.14
version: 4.1.14
vue:
specifier: ^3.5.21
version: 3.5.22(typescript@5.6.3)
vue-draggable-plus:
specifier: ^0.6.0
version: 0.6.0(@types/sortablejs@1.15.8)
vue-i18n:
specifier: ^9.14.0
version: 9.14.5(vue@3.5.22(typescript@5.6.3))
vue-router:
specifier: ^4.5.1
version: 4.5.1(vue@3.5.22(typescript@5.6.3))
xgplayer:
specifier: ^3.0.20
version: 3.0.23(core-js@3.45.1)
xlsx:
specifier: ^0.18.5
version: 0.18.5
devDependencies:
'@commitlint/cli':
specifier: ^19.4.1
version: 19.8.1(@types/node@24.8.1)(typescript@5.6.3)
'@commitlint/config-conventional':
specifier: ^19.4.1
version: 19.8.1
'@eslint/js':
specifier: ^9.9.1
version: 9.36.0
'@types/node':
specifier: ^24.0.5
version: 24.8.1
'@types/spark-md5':
specifier: ^3.0.5
version: 3.0.5
'@typescript-eslint/eslint-plugin':
specifier: ^8.3.0
version: 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3))(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@typescript-eslint/parser':
specifier: ^8.3.0
version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@vitejs/plugin-vue':
specifier: ^6.0.1
version: 6.0.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))
'@vue/compiler-sfc':
specifier: ^3.0.5
version: 3.5.22
commitizen:
specifier: ^4.3.0
version: 4.3.1(@types/node@24.8.1)(typescript@5.6.3)
cz-git:
specifier: ^1.11.1
version: 1.12.0
eslint:
specifier: ^9.9.1
version: 9.36.0(jiti@2.6.0)
eslint-config-prettier:
specifier: ^9.1.0
version: 9.1.2(eslint@9.36.0(jiti@2.6.0))
eslint-plugin-prettier:
specifier: ^5.2.1
version: 5.5.4(eslint-config-prettier@9.1.2(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2)
eslint-plugin-vue:
specifier: ^9.27.0
version: 9.33.0(eslint@9.36.0(jiti@2.6.0))
globals:
specifier: ^15.9.0
version: 15.15.0
husky:
specifier: ^9.1.5
version: 9.1.7
lint-staged:
specifier: ^15.5.2
version: 15.5.2
prettier:
specifier: ^3.5.3
version: 3.6.2
rollup-plugin-visualizer:
specifier: ^5.12.0
version: 5.14.0(rollup@4.52.3)
sass:
specifier: ^1.81.0
version: 1.93.2
stylelint:
specifier: ^16.20.0
version: 16.24.0(typescript@5.6.3)
stylelint-config-html:
specifier: ^1.1.0
version: 1.1.0(postcss-html@1.8.0)(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-recess-order:
specifier: ^4.6.0
version: 4.6.0(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-recommended-scss:
specifier: ^14.1.0
version: 14.1.0(postcss@8.5.6)(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-recommended-vue:
specifier: ^1.5.0
version: 1.6.1(postcss-html@1.8.0)(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-standard:
specifier: ^36.0.1
version: 36.0.1(stylelint@16.24.0(typescript@5.6.3))
terser:
specifier: ^5.36.0
version: 5.44.0
tsx:
specifier: ^4.20.3
version: 4.20.6
typescript:
specifier: ~5.6.3
version: 5.6.3
typescript-eslint:
specifier: ^8.9.0
version: 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
unplugin-auto-import:
specifier: ^20.2.0
version: 20.2.0(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3)))
unplugin-element-plus:
specifier: ^0.10.0
version: 0.10.0
unplugin-vue-components:
specifier: ^29.1.0
version: 29.1.0(@babel/parser@7.28.4)(vue@3.5.22(typescript@5.6.3))
vite:
specifier: ^7.1.5
version: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
vite-plugin-compression:
specifier: ^0.5.1
version: 0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
vite-plugin-vue-devtools:
specifier: ^7.7.6
version: 7.7.7(rollup@4.52.3)(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))
vue-demi:
specifier: ^0.14.9
version: 0.14.10(vue@3.5.22(typescript@5.6.3))
vue-img-cutter:
specifier: ^3.0.5
version: 3.0.7(typescript@5.6.3)
vue-tsc:
specifier: ~2.1.6
version: 2.1.10(typescript@5.6.3)
packages:
'@antfu/utils@0.7.10':
resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
'@babel/compat-data@7.28.4':
resolution: {integrity: sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==}
engines: {node: '>=6.9.0'}
'@babel/core@7.28.4':
resolution: {integrity: sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.28.3':
resolution: {integrity: sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==}
engines: {node: '>=6.9.0'}
'@babel/helper-annotate-as-pure@7.27.3':
resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==}
engines: {node: '>=6.9.0'}
'@babel/helper-compilation-targets@7.27.2':
resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==}
engines: {node: '>=6.9.0'}
'@babel/helper-create-class-features-plugin@7.28.3':
resolution: {integrity: sha512-V9f6ZFIYSLNEbuGA/92uOvYsGCJNsuA8ESZ4ldc09bWk/j8H8TKiPw8Mk1eG6olpnO0ALHJmYfZvF4MEE4gajg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-globals@7.28.0':
resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==}
engines: {node: '>=6.9.0'}
'@babel/helper-member-expression-to-functions@7.27.1':
resolution: {integrity: sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-imports@7.27.1':
resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==}
engines: {node: '>=6.9.0'}
'@babel/helper-module-transforms@7.28.3':
resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-optimise-call-expression@7.27.1':
resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==}
engines: {node: '>=6.9.0'}
'@babel/helper-plugin-utils@7.27.1':
resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==}
engines: {node: '>=6.9.0'}
'@babel/helper-replace-supers@7.27.1':
resolution: {integrity: sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==}
engines: {node: '>=6.9.0'}
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.27.1':
resolution: {integrity: sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-option@7.27.1':
resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==}
engines: {node: '>=6.9.0'}
'@babel/helpers@7.28.4':
resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.28.4':
resolution: {integrity: sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/plugin-proposal-decorators@7.28.0':
resolution: {integrity: sha512-zOiZqvANjWDUaUS9xMxbMcK/Zccztbe/6ikvUXaG9nsPH3w6qh5UaPGAnirI/WhIbZ8m3OHU0ReyPrknG+ZKeg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-decorators@7.27.1':
resolution: {integrity: sha512-YMq8Z87Lhl8EGkmb0MwYkt36QnxC+fzCgrl66ereamPlYToRpIk5nUjKUY3QKLWq8mwUB1BgbeXcTJhZOCDg5A==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-import-attributes@7.27.1':
resolution: {integrity: sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-import-meta@7.10.4':
resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-jsx@7.27.1':
resolution: {integrity: sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-syntax-typescript@7.27.1':
resolution: {integrity: sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-typescript@7.28.0':
resolution: {integrity: sha512-4AEiDEBPIZvLQaWlc9liCavE0xRM0dNca41WtBeM3jgFptfUOSG9z0uteLhq6+3rq+WB6jIvUwKDTpXEHPJ2Vg==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.28.4':
resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==}
engines: {node: '>=6.9.0'}
'@babel/template@7.27.2':
resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.28.4':
resolution: {integrity: sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==}
engines: {node: '>=6.9.0'}
'@babel/types@7.28.4':
resolution: {integrity: sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==}
engines: {node: '>=6.9.0'}
'@cacheable/memoize@2.0.2':
resolution: {integrity: sha512-wPrr7FUiq3Qt4yQyda2/NcOLTJCFcQSU3Am2adP+WLy+sz93/fKTokVTHmtz+rjp4PD7ee0AEOeRVNN6IvIfsg==}
'@cacheable/memory@2.0.2':
resolution: {integrity: sha512-sJTITLfeCI1rg7P3ssaGmQryq235EGT8dXGcx6oZwX5NRnKq9IE6lddlllcOl+oXW+yaeTRddCjo0xrfU6ZySA==}
'@cacheable/utils@2.0.2':
resolution: {integrity: sha512-JTFM3raFhVv8LH95T7YnZbf2YoE9wEtkPPStuRF9a6ExZ103hFvs+QyCuYJ6r0hA9wRtbzgZtwUCoDWxssZd4Q==}
'@commitlint/cli@19.8.1':
resolution: {integrity: sha512-LXUdNIkspyxrlV6VDHWBmCZRtkEVRpBKxi2Gtw3J54cGWhLCTouVD/Q6ZSaSvd2YaDObWK8mDjrz3TIKtaQMAA==}
engines: {node: '>=v18'}
hasBin: true
'@commitlint/config-conventional@19.8.1':
resolution: {integrity: sha512-/AZHJL6F6B/G959CsMAzrPKKZjeEiAVifRyEwXxcT6qtqbPwGw+iQxmNS+Bu+i09OCtdNRW6pNpBvgPrtMr9EQ==}
engines: {node: '>=v18'}
'@commitlint/config-validator@19.8.1':
resolution: {integrity: sha512-0jvJ4u+eqGPBIzzSdqKNX1rvdbSU1lPNYlfQQRIFnBgLy26BtC0cFnr7c/AyuzExMxWsMOte6MkTi9I3SQ3iGQ==}
engines: {node: '>=v18'}
'@commitlint/config-validator@20.0.0':
resolution: {integrity: sha512-BeyLMaRIJDdroJuYM2EGhDMGwVBMZna9UiIqV9hxj+J551Ctc6yoGuGSmghOy/qPhBSuhA6oMtbEiTmxECafsg==}
engines: {node: '>=v18'}
'@commitlint/ensure@19.8.1':
resolution: {integrity: sha512-mXDnlJdvDzSObafjYrOSvZBwkD01cqB4gbnnFuVyNpGUM5ijwU/r/6uqUmBXAAOKRfyEjpkGVZxaDsCVnHAgyw==}
engines: {node: '>=v18'}
'@commitlint/execute-rule@19.8.1':
resolution: {integrity: sha512-YfJyIqIKWI64Mgvn/sE7FXvVMQER/Cd+s3hZke6cI1xgNT/f6ZAz5heND0QtffH+KbcqAwXDEE1/5niYayYaQA==}
engines: {node: '>=v18'}
'@commitlint/execute-rule@20.0.0':
resolution: {integrity: sha512-xyCoOShoPuPL44gVa+5EdZsBVao/pNzpQhkzq3RdtlFdKZtjWcLlUFQHSWBuhk5utKYykeJPSz2i8ABHQA+ZZw==}
engines: {node: '>=v18'}
'@commitlint/format@19.8.1':
resolution: {integrity: sha512-kSJj34Rp10ItP+Eh9oCItiuN/HwGQMXBnIRk69jdOwEW9llW9FlyqcWYbHPSGofmjsqeoxa38UaEA5tsbm2JWw==}
engines: {node: '>=v18'}
'@commitlint/is-ignored@19.8.1':
resolution: {integrity: sha512-AceOhEhekBUQ5dzrVhDDsbMaY5LqtN8s1mqSnT2Kz1ERvVZkNihrs3Sfk1Je/rxRNbXYFzKZSHaPsEJJDJV8dg==}
engines: {node: '>=v18'}
'@commitlint/lint@19.8.1':
resolution: {integrity: sha512-52PFbsl+1EvMuokZXLRlOsdcLHf10isTPlWwoY1FQIidTsTvjKXVXYb7AvtpWkDzRO2ZsqIgPK7bI98x8LRUEw==}
engines: {node: '>=v18'}
'@commitlint/load@19.8.1':
resolution: {integrity: sha512-9V99EKG3u7z+FEoe4ikgq7YGRCSukAcvmKQuTtUyiYPnOd9a2/H9Ak1J9nJA1HChRQp9OA/sIKPugGS+FK/k1A==}
engines: {node: '>=v18'}
'@commitlint/load@20.0.0':
resolution: {integrity: sha512-WiNKO9fDPlLY90Rruw2HqHKcghrmj5+kMDJ4GcTlX1weL8K07Q6b27C179DxnsrjGCRAKVwFKyzxV4x+xDY28Q==}
engines: {node: '>=v18'}
'@commitlint/message@19.8.1':
resolution: {integrity: sha512-+PMLQvjRXiU+Ae0Wc+p99EoGEutzSXFVwQfa3jRNUZLNW5odZAyseb92OSBTKCu+9gGZiJASt76Cj3dLTtcTdg==}
engines: {node: '>=v18'}
'@commitlint/parse@19.8.1':
resolution: {integrity: sha512-mmAHYcMBmAgJDKWdkjIGq50X4yB0pSGpxyOODwYmoexxxiUCy5JJT99t1+PEMK7KtsCtzuWYIAXYAiKR+k+/Jw==}
engines: {node: '>=v18'}
'@commitlint/read@19.8.1':
resolution: {integrity: sha512-03Jbjb1MqluaVXKHKRuGhcKWtSgh3Jizqy2lJCRbRrnWpcM06MYm8th59Xcns8EqBYvo0Xqb+2DoZFlga97uXQ==}
engines: {node: '>=v18'}
'@commitlint/resolve-extends@19.8.1':
resolution: {integrity: sha512-GM0mAhFk49I+T/5UCYns5ayGStkTt4XFFrjjf0L4S26xoMTSkdCf9ZRO8en1kuopC4isDFuEm7ZOm/WRVeElVg==}
engines: {node: '>=v18'}
'@commitlint/resolve-extends@20.0.0':
resolution: {integrity: sha512-BA4vva1hY8y0/Hl80YDhe9TJZpRFMsUYzVxvwTLPTEBotbGx/gS49JlVvtF1tOCKODQp7pS7CbxCpiceBgp3Dg==}
engines: {node: '>=v18'}
'@commitlint/rules@19.8.1':
resolution: {integrity: sha512-Hnlhd9DyvGiGwjfjfToMi1dsnw1EXKGJNLTcsuGORHz6SS9swRgkBsou33MQ2n51/boIDrbsg4tIBbRpEWK2kw==}
engines: {node: '>=v18'}
'@commitlint/to-lines@19.8.1':
resolution: {integrity: sha512-98Mm5inzbWTKuZQr2aW4SReY6WUukdWXuZhrqf1QdKPZBCCsXuG87c+iP0bwtD6DBnmVVQjgp4whoHRVixyPBg==}
engines: {node: '>=v18'}
'@commitlint/top-level@19.8.1':
resolution: {integrity: sha512-Ph8IN1IOHPSDhURCSXBz44+CIu+60duFwRsg6HqaISFHQHbmBtxVw4ZrFNIYUzEP7WwrNPxa2/5qJ//NK1FGcw==}
engines: {node: '>=v18'}
'@commitlint/types@19.8.1':
resolution: {integrity: sha512-/yCrWGCoA1SVKOks25EGadP9Pnj0oAIHGpl2wH2M2Y46dPM2ueb8wyCVOD7O3WCTkaJ0IkKvzhl1JY7+uCT2Dw==}
engines: {node: '>=v18'}
'@commitlint/types@20.0.0':
resolution: {integrity: sha512-bVUNBqG6aznYcYjTjnc3+Cat/iBgbgpflxbIBTnsHTX0YVpnmINPEkSRWymT2Q8aSH3Y7aKnEbunilkYe8TybA==}
engines: {node: '>=v18'}
'@csstools/css-parser-algorithms@3.0.5':
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-tokenizer@3.0.4':
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@csstools/media-query-list-parser@4.0.3':
resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==}
engines: {node: '>=18'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/selector-specificity@5.0.0':
resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==}
engines: {node: '>=18'}
peerDependencies:
postcss-selector-parser: ^7.0.0
'@ctrl/tinycolor@3.6.1':
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'}
'@dual-bundle/import-meta-resolve@4.2.1':
resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==}
'@element-plus/icons-vue@2.3.2':
resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==}
peerDependencies:
vue: ^3.2.0
'@esbuild/aix-ppc64@0.25.10':
resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.10':
resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.10':
resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.10':
resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.10':
resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.10':
resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.10':
resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.10':
resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.10':
resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.10':
resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.10':
resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.10':
resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.10':
resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.10':
resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.10':
resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.10':
resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.10':
resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.10':
resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.10':
resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.10':
resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.10':
resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openharmony-arm64@0.25.10':
resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.10':
resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.10':
resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.10':
resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.10':
resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@eslint-community/eslint-utils@4.9.0':
resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
'@eslint-community/regexpp@4.12.1':
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
'@eslint/config-array@0.21.0':
resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/config-helpers@0.3.1':
resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/core@0.15.2':
resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/eslintrc@3.3.1':
resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/js@9.36.0':
resolution: {integrity: sha512-uhCbYtYynH30iZErszX78U+nR3pJU3RHGQ57NXy5QupD4SBVwDeU8TNBy+MjMngc1UyIW9noKqsRqfjQTBU2dw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@eslint/plugin-kit@0.3.5':
resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
'@humanfs/node@0.16.7':
resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==}
engines: {node: '>=18.18.0'}
'@humanwhocodes/module-importer@1.0.1':
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
engines: {node: '>=12.22'}
'@humanwhocodes/retry@0.4.3':
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@iconify/types@2.0.0':
resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
'@iconify/vue@5.0.0':
resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
peerDependencies:
vue: '>=3'
'@intlify/core-base@9.14.5':
resolution: {integrity: sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==}
engines: {node: '>= 16'}
'@intlify/message-compiler@9.14.5':
resolution: {integrity: sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==}
engines: {node: '>= 16'}
'@intlify/shared@9.14.5':
resolution: {integrity: sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==}
engines: {node: '>= 16'}
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
'@jridgewell/remapping@2.3.5':
resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
'@jridgewell/source-map@0.3.11':
resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@keyv/bigmap@1.0.2':
resolution: {integrity: sha512-KR03xkEZlAZNF4IxXgVXb+uNIVNvwdh8UwI0cnc7WI6a+aQcDp8GL80qVfeB4E5NpsKJzou5jU0r6yLSSbMOtA==}
engines: {node: '>= 18'}
'@keyv/serialize@1.1.1':
resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
'@nodelib/fs.stat@2.0.5':
resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==}
engines: {node: '>= 8'}
'@nodelib/fs.walk@1.2.8':
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'}
'@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [android]
'@parcel/watcher-darwin-arm64@2.5.1':
resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [darwin]
'@parcel/watcher-darwin-x64@2.5.1':
resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [darwin]
'@parcel/watcher-freebsd-x64@2.5.1':
resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [freebsd]
'@parcel/watcher-linux-arm-glibc@2.5.1':
resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [win32]
'@parcel/watcher-win32-ia32@2.5.1':
resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==}
engines: {node: '>= 10.0.0'}
cpu: [ia32]
os: [win32]
'@parcel/watcher-win32-x64@2.5.1':
resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [win32]
'@parcel/watcher@2.5.1':
resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==}
engines: {node: '>= 10.0.0'}
'@pkgr/core@0.2.9':
resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==}
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
'@rolldown/pluginutils@1.0.0-beta.29':
resolution: {integrity: sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==}
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
peerDependenciesMeta:
rollup:
optional: true
'@rollup/rollup-android-arm-eabi@4.52.3':
resolution: {integrity: sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.52.3':
resolution: {integrity: sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.52.3':
resolution: {integrity: sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.52.3':
resolution: {integrity: sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.52.3':
resolution: {integrity: sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.52.3':
resolution: {integrity: sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.52.3':
resolution: {integrity: sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm-musleabihf@4.52.3':
resolution: {integrity: sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA==}
cpu: [arm]
os: [linux]
'@rollup/rollup-linux-arm64-gnu@4.52.3':
resolution: {integrity: sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-arm64-musl@4.52.3':
resolution: {integrity: sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw==}
cpu: [arm64]
os: [linux]
'@rollup/rollup-linux-loong64-gnu@4.52.3':
resolution: {integrity: sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg==}
cpu: [loong64]
os: [linux]
'@rollup/rollup-linux-ppc64-gnu@4.52.3':
resolution: {integrity: sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw==}
cpu: [ppc64]
os: [linux]
'@rollup/rollup-linux-riscv64-gnu@4.52.3':
resolution: {integrity: sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-riscv64-musl@4.52.3':
resolution: {integrity: sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg==}
cpu: [riscv64]
os: [linux]
'@rollup/rollup-linux-s390x-gnu@4.52.3':
resolution: {integrity: sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg==}
cpu: [s390x]
os: [linux]
'@rollup/rollup-linux-x64-gnu@4.52.3':
resolution: {integrity: sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA==}
cpu: [x64]
os: [linux]
'@rollup/rollup-linux-x64-musl@4.52.3':
resolution: {integrity: sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw==}
cpu: [x64]
os: [linux]
'@rollup/rollup-openharmony-arm64@4.52.3':
resolution: {integrity: sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.52.3':
resolution: {integrity: sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.52.3':
resolution: {integrity: sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.52.3':
resolution: {integrity: sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.52.3':
resolution: {integrity: sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA==}
cpu: [x64]
os: [win32]
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@sindresorhus/merge-streams@4.0.0':
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
'@sxzz/popperjs-es@2.11.7':
resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==}
'@tailwindcss/node@4.1.14':
resolution: {integrity: sha512-hpz+8vFk3Ic2xssIA3e01R6jkmsAhvkQdXlEbRTk6S10xDAtiQiM3FyvZVGsucefq764euO/b8WUW9ysLdThHw==}
'@tailwindcss/oxide-android-arm64@4.1.14':
resolution: {integrity: sha512-a94ifZrGwMvbdeAxWoSuGcIl6/DOP5cdxagid7xJv6bwFp3oebp7y2ImYsnZBMTwjn5Ev5xESvS3FFYUGgPODQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.14':
resolution: {integrity: sha512-HkFP/CqfSh09xCnrPJA7jud7hij5ahKyWomrC3oiO2U9i0UjP17o9pJbxUN0IJ471GTQQmzwhp0DEcpbp4MZTA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.14':
resolution: {integrity: sha512-eVNaWmCgdLf5iv6Qd3s7JI5SEFBFRtfm6W0mphJYXgvnDEAZ5sZzqmI06bK6xo0IErDHdTA5/t7d4eTfWbWOFw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.14':
resolution: {integrity: sha512-QWLoRXNikEuqtNb0dhQN6wsSVVjX6dmUFzuuiL09ZeXju25dsei2uIPl71y2Ic6QbNBsB4scwBoFnlBfabHkEw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14':
resolution: {integrity: sha512-VB4gjQni9+F0VCASU+L8zSIyjrLLsy03sjcR3bM0V2g4SNamo0FakZFKyUQ96ZVwGK4CaJsc9zd/obQy74o0Fw==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.14':
resolution: {integrity: sha512-qaEy0dIZ6d9vyLnmeg24yzA8XuEAD9WjpM5nIM1sUgQ/Zv7cVkharPDQcmm/t/TvXoKo/0knI3me3AGfdx6w1w==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
resolution: {integrity: sha512-ISZjT44s59O8xKsPEIesiIydMG/sCXoMBCqsphDm/WcbnuWLxxb+GcvSIIA5NjUw6F8Tex7s5/LM2yDy8RqYBQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
resolution: {integrity: sha512-02c6JhLPJj10L2caH4U0zF8Hji4dOeahmuMl23stk0MU1wfd1OraE7rOloidSF8W5JTHkFdVo/O7uRUJJnUAJg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
resolution: {integrity: sha512-TNGeLiN1XS66kQhxHG/7wMeQDOoL0S33x9BgmydbrWAb9Qw0KYdd8o1ifx4HOGDWhVmJ+Ul+JQ7lyknQFilO3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.14':
resolution: {integrity: sha512-uZYAsaW/jS/IYkd6EWPJKW/NlPNSkWkBlaeVBi/WsFQNP05/bzkebUL8FH1pdsqx4f2fH/bWFcUABOM9nfiJkQ==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.14':
resolution: {integrity: sha512-Az0RnnkcvRqsuoLH2Z4n3JfAef0wElgzHD5Aky/e+0tBUxUhIeIqFBTMNQvmMRSP15fWwmvjBxZ3Q8RhsDnxAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.14':
resolution: {integrity: sha512-ttblVGHgf68kEE4om1n/n44I0yGPkCPbLsqzjvybhpwa6mKKtgFfAzy6btc3HRmuW7nHe0OOrSeNP9sQmmH9XA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.14':
resolution: {integrity: sha512-23yx+VUbBwCg2x5XWdB8+1lkPajzLmALEfMb51zZUBYaYVPDQvBSD/WYDqiVyBIo2BZFa3yw1Rpy3G2Jp+K0dw==}
engines: {node: '>= 10'}
'@tailwindcss/vite@4.1.14':
resolution: {integrity: sha512-BoFUoU0XqgCUS1UXWhmDJroKKhNXeDzD7/XwabjkDIAbMnc4ULn5e2FuEuBbhZ6ENZoSYzKlzvZ44Yr6EUDUSA==}
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@transloadit/prettier-bytes@0.0.7':
resolution: {integrity: sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==}
'@types/conventional-commits-parser@5.0.1':
resolution: {integrity: sha512-7uz5EHdzz2TqoMfV7ee61Egf5y6NkcO4FB/1iCCQnbeiI1F3xzv3vK5dBCXUCLQgGYS+mUeigK1iKQzvED+QnQ==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/event-emitter@0.3.5':
resolution: {integrity: sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.20':
resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==}
'@types/node@24.8.1':
resolution: {integrity: sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==}
'@types/sortablejs@1.15.8':
resolution: {integrity: sha512-b79830lW+RZfwaztgs1aVPgbasJ8e7AXtZYHTELNXZPsERt4ymJdjV4OccDbHQAvHrCcFpbF78jkm0R6h/pZVg==}
'@types/spark-md5@3.0.5':
resolution: {integrity: sha512-lWf05dnD42DLVKQJZrDHtWFidcLrHuip01CtnC2/S6AMhX4t9ZlEUj4iuRlAnts0PQk7KESOqKxeGE/b6sIPGg==}
'@types/web-bluetooth@0.0.16':
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@typescript-eslint/eslint-plugin@8.44.1':
resolution: {integrity: sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
'@typescript-eslint/parser': ^8.44.1
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/parser@8.44.1':
resolution: {integrity: sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/project-service@8.44.1':
resolution: {integrity: sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/scope-manager@8.44.1':
resolution: {integrity: sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/tsconfig-utils@8.44.1':
resolution: {integrity: sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/type-utils@8.44.1':
resolution: {integrity: sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/types@8.44.1':
resolution: {integrity: sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/typescript-estree@8.44.1':
resolution: {integrity: sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/utils@8.44.1':
resolution: {integrity: sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
'@typescript-eslint/visitor-keys@8.44.1':
resolution: {integrity: sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@uppy/companion-client@2.2.2':
resolution: {integrity: sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==}
'@uppy/core@2.3.4':
resolution: {integrity: sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==}
'@uppy/store-default@2.1.1':
resolution: {integrity: sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==}
'@uppy/utils@4.1.3':
resolution: {integrity: sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==}
'@uppy/xhr-upload@2.1.3':
resolution: {integrity: sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==}
peerDependencies:
'@uppy/core': ^2.3.3
'@vitejs/plugin-vue@6.0.1':
resolution: {integrity: sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0 || ^7.0.0
vue: ^3.2.25
'@volar/language-core@2.4.23':
resolution: {integrity: sha512-hEEd5ET/oSmBC6pi1j6NaNYRWoAiDhINbT8rmwtINugR39loROSlufGdYMF9TaKGfz+ViGs1Idi3mAhnuPcoGQ==}
'@volar/source-map@2.4.23':
resolution: {integrity: sha512-Z1Uc8IB57Lm6k7q6KIDu/p+JWtf3xsXJqAX/5r18hYOTpJyBn0KXUR8oTJ4WFYOcDzWC9n3IflGgHowx6U6z9Q==}
'@volar/typescript@2.4.23':
resolution: {integrity: sha512-lAB5zJghWxVPqfcStmAP1ZqQacMpe90UrP5RJ3arDyrhy4aCUQqmxPPLB2PWDKugvylmO41ljK7vZ+t6INMTag==}
'@vue/babel-helper-vue-transform-on@1.5.0':
resolution: {integrity: sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==}
'@vue/babel-plugin-jsx@1.5.0':
resolution: {integrity: sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==}
peerDependencies:
'@babel/core': ^7.0.0-0
peerDependenciesMeta:
'@babel/core':
optional: true
'@vue/babel-plugin-resolve-type@1.5.0':
resolution: {integrity: sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==}
peerDependencies:
'@babel/core': ^7.0.0-0
'@vue/compiler-core@3.5.22':
resolution: {integrity: sha512-jQ0pFPmZwTEiRNSb+i9Ow/I/cHv2tXYqsnHKKyCQ08irI2kdF5qmYedmF8si8mA7zepUFmJ2hqzS8CQmNOWOkQ==}
'@vue/compiler-dom@3.5.22':
resolution: {integrity: sha512-W8RknzUM1BLkypvdz10OVsGxnMAuSIZs9Wdx1vzA3mL5fNMN15rhrSCLiTm6blWeACwUwizzPVqGJgOGBEN/hA==}
'@vue/compiler-sfc@3.5.22':
resolution: {integrity: sha512-tbTR1zKGce4Lj+JLzFXDq36K4vcSZbJ1RBu8FxcDv1IGRz//Dh2EBqksyGVypz3kXpshIfWKGOCcqpSbyGWRJQ==}
'@vue/compiler-ssr@3.5.22':
resolution: {integrity: sha512-GdgyLvg4R+7T8Nk2Mlighx7XGxq/fJf9jaVofc3IL0EPesTE86cP/8DD1lT3h1JeZr2ySBvyqKQJgbS54IX1Ww==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.7':
resolution: {integrity: sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==}
'@vue/devtools-core@7.7.7':
resolution: {integrity: sha512-9z9TLbfC+AjAi1PQyWX+OErjIaJmdFlbDHcD+cAMYKY6Bh5VlsAtCeGyRMrXwIlMEQPukvnWt3gZBLwTAIMKzQ==}
peerDependencies:
vue: ^3.0.0
'@vue/devtools-kit@7.7.7':
resolution: {integrity: sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==}
'@vue/devtools-shared@7.7.7':
resolution: {integrity: sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==}
'@vue/language-core@2.1.10':
resolution: {integrity: sha512-DAI289d0K3AB5TUG3xDp9OuQ71CnrujQwJrQnfuZDwo6eGNf0UoRlPuaVNO+Zrn65PC3j0oB2i7mNmVPggeGeQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.22':
resolution: {integrity: sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==}
'@vue/runtime-core@3.5.22':
resolution: {integrity: sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==}
'@vue/runtime-dom@3.5.22':
resolution: {integrity: sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==}
'@vue/server-renderer@3.5.22':
resolution: {integrity: sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==}
peerDependencies:
vue: 3.5.22
'@vue/shared@3.5.22':
resolution: {integrity: sha512-F4yc6palwq3TT0u+FYf0Ns4Tfl9GRFURDN2gWG7L1ecIaS/4fCIuFOjMTnCyjsu/OK6vaDKLCrGAa+KvvH+h4w==}
'@vueuse/core@13.9.0':
resolution: {integrity: sha512-ts3regBQyURfCE2BcytLqzm8+MmLlo5Ln/KLoxDVcsZ2gzIwVNnQpQOL/UKV8alUqjSZOlpFZcRNsLRqj+OzyA==}
peerDependencies:
vue: ^3.5.0
'@vueuse/core@9.13.0':
resolution: {integrity: sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==}
'@vueuse/metadata@13.9.0':
resolution: {integrity: sha512-1AFRvuiGphfF7yWixZa0KwjYH8ulyjDCC0aFgrGRz8+P4kvDFSdXLVfTk5xAN9wEuD1J6z4/myMoYbnHoX07zg==}
'@vueuse/metadata@9.13.0':
resolution: {integrity: sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==}
'@vueuse/shared@13.9.0':
resolution: {integrity: sha512-e89uuTLMh0U5cZ9iDpEI2senqPGfbPRTHM/0AaQkcxnpqjkZqDYP8rpfm7edOz8s+pOCOROEy1PIveSW8+fL5g==}
peerDependencies:
vue: ^3.5.0
'@vueuse/shared@9.13.0':
resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
'@wangeditor/basic-modules@1.1.7':
resolution: {integrity: sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.throttle: ^4.1.1
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/code-highlight@1.0.3':
resolution: {integrity: sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/core@1.1.19':
resolution: {integrity: sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==}
peerDependencies:
'@uppy/core': ^2.1.1
'@uppy/xhr-upload': ^2.0.3
dom7: ^3.0.0
is-hotkey: ^0.2.0
lodash.camelcase: ^4.3.0
lodash.clonedeep: ^4.5.0
lodash.debounce: ^4.0.8
lodash.foreach: ^4.5.0
lodash.isequal: ^4.5.0
lodash.throttle: ^4.1.1
lodash.toarray: ^4.4.0
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/editor-for-vue@5.1.12':
resolution: {integrity: sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==}
peerDependencies:
'@wangeditor/editor': '>=5.1.0'
vue: ^3.0.5
'@wangeditor/editor@5.1.23':
resolution: {integrity: sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==}
'@wangeditor/list-module@1.0.5':
resolution: {integrity: sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/table-module@1.1.4':
resolution: {integrity: sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==}
peerDependencies:
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.isequal: ^4.5.0
lodash.throttle: ^4.1.1
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/upload-image-module@1.0.2':
resolution: {integrity: sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==}
peerDependencies:
'@uppy/core': ^2.0.3
'@uppy/xhr-upload': ^2.0.3
'@wangeditor/basic-modules': 1.x
'@wangeditor/core': 1.x
dom7: ^3.0.0
lodash.foreach: ^4.5.0
slate: ^0.72.0
snabbdom: ^3.1.0
'@wangeditor/video-module@1.1.4':
resolution: {integrity: sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==}
peerDependencies:
'@uppy/core': ^2.1.4
'@uppy/xhr-upload': ^2.0.7
'@wangeditor/core': 1.x
dom7: ^3.0.0
nanoid: ^3.2.0
slate: ^0.72.0
snabbdom: ^3.1.0
JSONStream@1.3.5:
resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==}
hasBin: true
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
acorn: ^6.0.0 || ^7.0.0 || ^8.0.0
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
adler-32@1.3.1:
resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
engines: {node: '>=0.8'}
ajv@6.12.6:
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
ajv@8.17.1:
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
alien-signals@0.2.2:
resolution: {integrity: sha512-cZIRkbERILsBOXTQmMrxc9hgpxglstn69zm+F1ARf4aPAzdAFYd6sBq87ErO0Fj3DV94tglcyHG5kQz9nDC/8A==}
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
ansi-escapes@7.1.1:
resolution: {integrity: sha512-Zhl0ErHcSRUaVfGUeUdDuLgpkEo8KIFjB4Y9uAc46ScOpdDiU1Dbyplh7qWJeJ/ZHpbyMSM26+X3BySgnIz40Q==}
engines: {node: '>=18'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@3.2.1:
resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==}
engines: {node: '>=4'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
array-ify@1.0.0:
resolution: {integrity: sha512-c5AMf34bKdvPhQ7tBGhqkgKNUzMr4WUs+WDtC2ZUGOUncbxKMTvqxYctiseW3+L4bA8ec+GcZ6/A/FW4m8ukng==}
array-union@2.1.0:
resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
engines: {node: '>=8'}
astral-regex@2.0.0:
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
engines: {node: '>=8'}
async-validator@4.2.5:
resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
engines: {node: '>= 4.0.0'}
axios@1.12.2:
resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
balanced-match@2.0.0:
resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
baseline-browser-mapping@2.8.8:
resolution: {integrity: sha512-be0PUaPsQX/gPWWgFsdD+GFzaoig5PXaUC1xLkQiYdDnANU8sMnHoQd8JhbJQuvTWrWLyeFN9Imb5Qtfvr4RrQ==}
hasBin: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
birpc@2.6.1:
resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
browserslist@4.26.2:
resolution: {integrity: sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
cacheable@2.0.2:
resolution: {integrity: sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==}
cachedir@2.3.0:
resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==}
engines: {node: '>=6'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
callsites@3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001745:
resolution: {integrity: sha512-ywt6i8FzvdgrrrGbr1jZVObnVv6adj+0if2/omv9cmR2oiZs30zL4DIyaptKcbOrBdOIc74QTMoJvSE2QHh5UQ==}
cfb@1.2.2:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'}
chalk@2.4.2:
resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==}
engines: {node: '>=4'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
chardet@0.7.0:
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
cli-spinners@2.9.2:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
cli-truncate@4.0.0:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
cli-width@3.0.0:
resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==}
engines: {node: '>= 10'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
clone@1.0.4:
resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
engines: {node: '>=0.8'}
codepage@1.15.0:
resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==}
engines: {node: '>=0.8'}
color-convert@1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
colord@2.9.3:
resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==}
colorette@2.0.20:
resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
commander@13.1.0:
resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
engines: {node: '>=18'}
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commitizen@4.3.1:
resolution: {integrity: sha512-gwAPAVTy/j5YcOOebcCRIijn+mSjWJC+IYKivTu6aG8Ei/scoXgfsMRnuAk6b0GRste2J4NGxVdMN3ZpfNaVaw==}
engines: {node: '>= 12'}
hasBin: true
compare-func@2.0.0:
resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==}
compute-scroll-into-view@1.0.20:
resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.2:
resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==}
conventional-changelog-angular@7.0.0:
resolution: {integrity: sha512-ROjNchA9LgfNMTTFSIWPzebCwOGFdgkEq45EnvvrmSLvCtAw0HSmrCs7/ty+wAeYUZyNay0YMUNYFTRL72PkBQ==}
engines: {node: '>=16'}
conventional-changelog-conventionalcommits@7.0.2:
resolution: {integrity: sha512-NKXYmMR/Hr1DevQegFB4MwfM5Vv0m4UIxKZTTYuD98lpTknaZlSRrDOG4X7wIXpGkfsYxZTghUN+Qq+T0YQI7w==}
engines: {node: '>=16'}
conventional-commit-types@3.0.0:
resolution: {integrity: sha512-SmmCYnOniSsAa9GqWOeLqc179lfr5TRu5b4QFDkbsrJ5TZjPJx85wtOr3zn+1dbeNiXDKGPbZ72IKbPhLXh/Lg==}
conventional-commits-parser@5.0.0:
resolution: {integrity: sha512-ZPMl0ZJbw74iS9LuX9YIAiW8pfM5p3yh2o/NbXHbkFuZzY5jvdi5jFycEOkmBW5H5I7nA+D6f3UcsCLP2vvSEA==}
engines: {node: '>=16'}
hasBin: true
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
copy-anything@3.0.5:
resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==}
engines: {node: '>=12.13'}
core-js@3.45.1:
resolution: {integrity: sha512-L4NPsJlCfZsPeXukyzHFlg/i7IIVwHSItR0wg0FLNqYClJ4MQYTYLbC7EkjKYRLZF2iof2MUgN0EGy7MdQFChg==}
cosmiconfig-typescript-loader@6.1.0:
resolution: {integrity: sha512-tJ1w35ZRUiM5FeTzT7DtYWAFFv37ZLqSRkGi2oeCK1gPhvaWjkAtfXvLmvE1pRfxxp9aQo6ba/Pvg1dKj05D4g==}
engines: {node: '>=v18'}
peerDependencies:
'@types/node': '*'
cosmiconfig: '>=9'
typescript: '>=5'
cosmiconfig@9.0.0:
resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==}
engines: {node: '>=14'}
peerDependencies:
typescript: '>=4.9.5'
peerDependenciesMeta:
typescript:
optional: true
crc-32@1.2.2:
resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==}
engines: {node: '>=0.8'}
hasBin: true
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
css-functions-list@3.2.3:
resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==}
engines: {node: '>=12 || >=16'}
css-tree@3.1.0:
resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
cz-conventional-changelog@3.3.0:
resolution: {integrity: sha512-U466fIzU5U22eES5lTNiNbZ+d8dfcHcssH4o7QsdWaCcRs/feIPCxKYSWkYBNs5mny7MvEfwpTLWjvbm94hecw==}
engines: {node: '>= 10'}
cz-git@1.12.0:
resolution: {integrity: sha512-LaZ+8whPPUOo6Y0Zy4nIbf6JOleV3ejp41sT6N4RPKiKKA+ICWf4ueeIlxIO8b6JtdlDxRzHH/EcRji07nDxcg==}
engines: {node: '>=v12.20.0'}
d@1.0.2:
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
engines: {node: '>=0.12'}
danmu.js@1.1.13:
resolution: {integrity: sha512-knFd0/cB2HA4FFWiA7eB2suc5vCvoHdqio33FyyCSfP7C+1A+zQcTvnvwfxaZhrxsGj4qaQI2I8XiTqedRaVmg==}
dargs@8.1.0:
resolution: {integrity: sha512-wAV9QHOsNbwnWdNW2FYvE1P56wtgSbM+3SZcdGiWQILwVjACCXDCI3Ai8QlCjMDB8YK5zySiXZYBiwGmNY3lnw==}
engines: {node: '>=12'}
dayjs@1.11.18:
resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
dedent@0.7.0:
resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
deep-pick-omit@1.2.1:
resolution: {integrity: sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw==}
default-browser-id@5.0.0:
resolution: {integrity: sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==}
engines: {node: '>=18'}
default-browser@5.2.1:
resolution: {integrity: sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==}
engines: {node: '>=18'}
defaults@1.0.4:
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
define-lazy-prop@2.0.0:
resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==}
engines: {node: '>=8'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
delegate@3.2.0:
resolution: {integrity: sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-file@1.0.0:
resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==}
engines: {node: '>=0.10.0'}
detect-indent@6.1.0:
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
engines: {node: '>=8'}
detect-libc@1.0.3:
resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==}
engines: {node: '>=0.10'}
hasBin: true
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dir-glob@3.0.1:
resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
engines: {node: '>=8'}
dom-serializer@2.0.0:
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
dom7@3.0.0:
resolution: {integrity: sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==}
domelementtype@2.3.0:
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
domhandler@5.0.3:
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
engines: {node: '>= 4'}
domutils@3.2.2:
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
dot-prop@5.3.0:
resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==}
engines: {node: '>=8'}
downloadjs@1.4.7:
resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
echarts@6.0.0:
resolution: {integrity: sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==}
electron-to-chromium@1.5.227:
resolution: {integrity: sha512-ITxuoPfJu3lsNWUi2lBM2PaBPYgH3uqmxut5vmBxgYvyI4AlJ6P3Cai1O76mOrkJCBzq0IxWg/NtqOrpu/0gKA==}
element-plus@2.11.4:
resolution: {integrity: sha512-sLq+Ypd0cIVilv8wGGMEGvzRVBBsRpJjnAS5PsI/1JU1COZXqzH3N1UYMUc/HCdvdjf6dfrBy80Sj7KcACsT7w==}
peerDependencies:
vue: ^3.2.0
emoji-regex@10.5.0:
resolution: {integrity: sha512-lb49vf1Xzfx080OKA0o6l8DQQpV+6Vg95zyCJX9VB/BqKYlhG7N4wgROUUHRA+ZPUefLnteQOad7z1kT2bV7bg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
enhanced-resolve@5.18.3:
resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==}
engines: {node: '>=10.13.0'}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
environment@1.1.0:
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
engines: {node: '>=18'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
error-stack-parser-es@0.1.5:
resolution: {integrity: sha512-xHku1X40RO+fO8yJ8Wh2f2rZWVjqyhb1zgq1yZ8aZRQkv6OOKhKWRUaht3eSCUbAOBaKIgM+ykwFLE+QUxgGeg==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-module-lexer@1.7.0:
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
es5-ext@0.10.64:
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
engines: {node: '>=0.10'}
es6-iterator@2.0.3:
resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
es6-symbol@3.1.4:
resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
engines: {node: '>=0.12'}
esbuild@0.25.10:
resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==}
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
escape-string-regexp@1.0.5:
resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
engines: {node: '>=0.8.0'}
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
eslint-config-prettier@9.1.2:
resolution: {integrity: sha512-iI1f+D2ViGn+uvv5HuHVUamg8ll4tN+JRHGc6IJi4TP9Kl976C57fzPXgseXNs8v0iA8aSJpHsTWjDb9QJamGQ==}
hasBin: true
peerDependencies:
eslint: '>=7.0.0'
eslint-plugin-prettier@5.5.4:
resolution: {integrity: sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
'@types/eslint': '>=8.0.0'
eslint: '>=8.0.0'
eslint-config-prettier: '>= 7.0.0 <10.0.0 || >=10.1.0'
prettier: '>=3.0.0'
peerDependenciesMeta:
'@types/eslint':
optional: true
eslint-config-prettier:
optional: true
eslint-plugin-vue@9.33.0:
resolution: {integrity: sha512-174lJKuNsuDIlLpjeXc5E2Tss8P44uIimAfGD0b90k0NoirJqpG7stLuU9Vp/9ioTOrQdWVREc4mRd1BD+CvGw==}
engines: {node: ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0
eslint-scope@7.2.2:
resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-scope@8.4.0:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint-visitor-keys@3.4.3:
resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
eslint-visitor-keys@4.2.1:
resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
eslint@9.36.0:
resolution: {integrity: sha512-hB4FIzXovouYzwzECDcUkJ4OcfOEkXTv2zRY6B9bkwjx/cprAq0uvm1nl7zvQ0/TsUk0zQiN4uPfJpB9m+rPMQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
hasBin: true
peerDependencies:
jiti: '*'
peerDependenciesMeta:
jiti:
optional: true
esniff@2.0.1:
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
engines: {node: '>=0.10'}
espree@10.4.0:
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
espree@9.6.1:
resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
esquery@1.6.0:
resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==}
engines: {node: '>=0.10'}
esrecurse@4.3.0:
resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==}
engines: {node: '>=4.0'}
estraverse@5.3.0:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-emitter@0.3.5:
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
execa@8.0.1:
resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==}
engines: {node: '>=16.17'}
execa@9.6.0:
resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==}
engines: {node: ^18.19.0 || >=20.5.0}
expand-tilde@2.0.2:
resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==}
engines: {node: '>=0.10.0'}
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
external-editor@3.1.0:
resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==}
engines: {node: '>=4'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-diff@1.3.0:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-uri@3.1.0:
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
fastest-levenshtein@1.0.16:
resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==}
engines: {node: '>= 4.9.1'}
fastq@1.19.1:
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
figures@6.1.0:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
file-entry-cache@10.1.4:
resolution: {integrity: sha512-5XRUFc0WTtUbjfGzEwXc42tiGxQHBmtbUG1h9L2apu4SulCGN3Hqm//9D6FAolf8MYNL7f/YlJl9vy08pj5JuA==}
file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
file-saver@2.0.5:
resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
find-node-modules@2.1.3:
resolution: {integrity: sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==}
find-root@1.1.0:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
find-up@7.0.0:
resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==}
engines: {node: '>=18'}
findup-sync@4.0.0:
resolution: {integrity: sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==}
engines: {node: '>= 8'}
flat-cache@4.0.1:
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
engines: {node: '>=16'}
flat-cache@6.1.14:
resolution: {integrity: sha512-ExZSCSV9e7v/Zt7RzCbX57lY2dnPdxzU/h3UE6WJ6NtEMfwBd8jmi1n4otDEUfz+T/R+zxrFDpICFdjhD3H/zw==}
flatted@3.3.3:
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
follow-redirects@1.15.11:
resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
frac@1.1.2:
resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==}
engines: {node: '>=0.8'}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
fs-extra@11.3.2:
resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==}
engines: {node: '>=14.14'}
fs-extra@9.1.0:
resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==}
engines: {node: '>=10'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gensync@1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.4.0:
resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==}
engines: {node: '>=18'}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
get-stream@8.0.1:
resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==}
engines: {node: '>=16'}
get-stream@9.0.1:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
get-tsconfig@4.10.1:
resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==}
git-raw-commits@4.0.0:
resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==}
engines: {node: '>=16'}
hasBin: true
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
glob-parent@6.0.2:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
global-directory@4.0.1:
resolution: {integrity: sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q==}
engines: {node: '>=18'}
global-modules@1.0.0:
resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==}
engines: {node: '>=0.10.0'}
global-modules@2.0.0:
resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==}
engines: {node: '>=6'}
global-prefix@1.0.2:
resolution: {integrity: sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==}
engines: {node: '>=0.10.0'}
global-prefix@3.0.0:
resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==}
engines: {node: '>=6'}
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
engines: {node: '>=8'}
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
globals@15.15.0:
resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==}
engines: {node: '>=18'}
globby@11.1.0:
resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
engines: {node: '>=10'}
globjoin@0.1.4:
resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
homedir-polyfill@1.0.3:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
engines: {node: '>=0.10.0'}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
hookified@1.12.1:
resolution: {integrity: sha512-xnKGl+iMIlhrZmGHB729MqlmPoWBznctSQTYCpFKqNsCgimJQmithcW0xSQMMFzYnV2iKUh25alswn6epgxS0Q==}
html-tags@3.3.1:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
html-void-elements@2.0.1:
resolution: {integrity: sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==}
htmlparser2@8.0.2:
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
human-signals@5.0.0:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
human-signals@8.0.1:
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
engines: {node: '>=18.18.0'}
husky@9.1.7:
resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==}
engines: {node: '>=18'}
hasBin: true
i18next@20.6.1:
resolution: {integrity: sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
ignore@7.0.5:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@9.0.21:
resolution: {integrity: sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==}
immutable@5.1.3:
resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-meta-resolve@4.2.0:
resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
ini@4.1.1:
resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
inquirer@8.2.5:
resolution: {integrity: sha512-QAgPDQMEgrDssk1XiwwHoOGYF9BAbUcc1+j+FhEvaOt8/cKRqyLn0U5qA6F74fGhTMGxf92pOvPBeh29jQJDTQ==}
engines: {node: '>=12.0.0'}
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-docker@2.2.1:
resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==}
engines: {node: '>=8'}
hasBin: true
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-fullwidth-code-point@4.0.0:
resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==}
engines: {node: '>=12'}
is-fullwidth-code-point@5.1.0:
resolution: {integrity: sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==}
engines: {node: '>=18'}
is-glob@4.0.3:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hotkey@0.2.0:
resolution: {integrity: sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-interactive@1.0.0:
resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==}
engines: {node: '>=8'}
is-number@7.0.0:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-obj@2.0.0:
resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==}
engines: {node: '>=8'}
is-plain-obj@4.1.0:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-stream@3.0.0:
resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
is-stream@4.0.1:
resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==}
engines: {node: '>=18'}
is-text-path@2.0.0:
resolution: {integrity: sha512-+oDTluR6WEjdXEJMnC2z6A4FRwFoYuvShVVEGsS7ewc0UTi2QtAKMDJuL4BDEVt+5T7MjFo12RP8ghOM75oKJw==}
engines: {node: '>=8'}
is-unicode-supported@0.1.0:
resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==}
engines: {node: '>=10'}
is-unicode-supported@2.1.0:
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
engines: {node: '>=18'}
is-url@1.2.4:
resolution: {integrity: sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==}
is-utf8@0.2.1:
resolution: {integrity: sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==}
is-what@4.1.16:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
is-windows@1.0.2:
resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==}
engines: {node: '>=0.10.0'}
is-wsl@2.2.0:
resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==}
engines: {node: '>=8'}
is-wsl@3.1.0:
resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
jiti@2.6.0:
resolution: {integrity: sha512-VXe6RjJkBPj0ohtqaO8vSWP3ZhAKo66fKrFNCll4BTcwljPLz03pCbaNKfzGP5MbrCYcbJ7v0nOYYwUzTEIdXQ==}
hasBin: true
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-tokens@9.0.1:
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
js-yaml@4.1.0:
resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
hasBin: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-stable-stringify-without-jsonify@1.0.1:
resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==}
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
hasBin: true
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
jsonparse@1.3.1:
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
engines: {'0': node >= 0.2.0}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
keyv@5.5.3:
resolution: {integrity: sha512-h0Un1ieD+HUrzBH6dJXhod3ifSghk5Hw/2Y4/KHBziPlZecrFyE9YOTPU6eOs0V9pYl8gOs86fkr/KN8lUX39A==}
kind-of@6.0.3:
resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
engines: {node: '>=0.10.0'}
known-css-properties@0.36.0:
resolution: {integrity: sha512-A+9jP+IUmuQsNdsLdcg6Yt7voiMF/D4K83ew0OpJtpu+l34ef7LaohWV0Rc6KNvzw6ZDizkqfyB5JznZnzuKQA==}
known-css-properties@0.37.0:
resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==}
kolorist@1.8.0:
resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
lightningcss-darwin-arm64@1.30.1:
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.1:
resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.1:
resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.1:
resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.1:
resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.30.1:
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.30.1:
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.30.1:
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.30.1:
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.1:
resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.1:
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
engines: {node: '>= 12.0.0'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
lint-staged@15.5.2:
resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==}
engines: {node: '>=18.12.0'}
hasBin: true
listr2@8.3.3:
resolution: {integrity: sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==}
engines: {node: '>=18.0.0'}
local-pkg@1.1.2:
resolution: {integrity: sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==}
engines: {node: '>=14'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
locate-path@7.2.0:
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash-unified@1.0.3:
resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==}
peerDependencies:
'@types/lodash-es': '*'
lodash: '*'
lodash-es: '*'
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.foreach@4.5.0:
resolution: {integrity: sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==}
lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.kebabcase@4.1.1:
resolution: {integrity: sha512-N8XRTIMMqqDgSy4VLKPnJ/+hpGZN+PHQiJnSenYqPaVV/NCqEogTnAdZLQiGKhxX+JCs8waWq2t1XHWKOmlY8g==}
lodash.map@4.6.0:
resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
lodash.mergewith@4.6.2:
resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==}
lodash.snakecase@4.1.1:
resolution: {integrity: sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==}
lodash.startcase@4.4.0:
resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==}
lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
lodash.toarray@4.4.0:
resolution: {integrity: sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==}
lodash.truncate@4.4.2:
resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==}
lodash.uniq@4.5.0:
resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==}
lodash.upperfirst@4.3.1:
resolution: {integrity: sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
log-symbols@4.1.0:
resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==}
engines: {node: '>=10'}
log-update@6.1.0:
resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==}
engines: {node: '>=18'}
longest@2.0.1:
resolution: {integrity: sha512-Ajzxb8CM6WAnFjgiloPsI3bF+WCxcvhdIG3KNA2KN962+tdBsHcuQ4k4qX/EcS/2CRkcc0iAkR956Nib6aXU/Q==}
engines: {node: '>=0.10.0'}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
magic-string@0.30.19:
resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
mathml-tag-names@2.1.3:
resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==}
mdn-data@2.12.2:
resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==}
mdn-data@2.24.0:
resolution: {integrity: sha512-i97fklrJl03tL1tdRVw0ZfLLvuDsdb6wxL+TrJ+PKkCbLrp2PCu2+OYdCKychIUm19nSM/35S6qz7pJpnXttoA==}
memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
meow@12.1.1:
resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==}
engines: {node: '>=16.10'}
meow@13.2.0:
resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==}
engines: {node: '>=18'}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
merge@2.1.1:
resolution: {integrity: sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-match@1.0.2:
resolution: {integrity: sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mimic-fn@2.1.0:
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
engines: {node: '>=6'}
mimic-fn@4.0.0:
resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==}
engines: {node: '>=12'}
mimic-function@5.0.1:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.7:
resolution: {integrity: sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
minipass@7.1.2:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
minizlib@3.1.0:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mitt@3.0.1:
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
mute-stream@0.0.8:
resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==}
namespace-emitter@2.0.1:
resolution: {integrity: sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.6:
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
engines: {node: ^18 || >=20}
hasBin: true
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-tick@1.1.0:
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-releases@2.0.21:
resolution: {integrity: sha512-5b0pgg78U3hwXkCM8Z9b2FJdPZlr9Psr9V2gQPESdGHqbntyFJKFW4r5TeWGFzafGY3hzs1JC62VEQMbl1JFkw==}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-wheel-es@1.2.0:
resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==}
npm-run-path@5.3.0:
resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
onetime@5.1.2:
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
engines: {node: '>=6'}
onetime@6.0.0:
resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==}
engines: {node: '>=12'}
onetime@7.0.0:
resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==}
engines: {node: '>=18'}
open@10.2.0:
resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==}
engines: {node: '>=18'}
open@8.4.2:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
ora@5.4.1:
resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==}
engines: {node: '>=10'}
os-tmpdir@1.0.2:
resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==}
engines: {node: '>=0.10.0'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-limit@4.0.0:
resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-locate@6.0.0:
resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
parse-ms@4.0.0:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
parse-passwd@1.0.0:
resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==}
engines: {node: '>=0.10.0'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-exists@5.0.0:
resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
path-is-absolute@1.0.1:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
path-type@4.0.0:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@2.3.1:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
pidtree@0.6.0:
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
engines: {node: '>=0.10'}
hasBin: true
pinia-plugin-persistedstate@4.5.0:
resolution: {integrity: sha512-QTkP1xJVyCdr2I2p3AKUZM84/e+IS+HktRxKGAIuDzkyaKKV48mQcYkJFVVDuvTxlI5j6X3oZObpqoVB8JnWpw==}
peerDependencies:
'@nuxt/kit': '>=3.0.0'
'@pinia/nuxt': '>=0.10.0'
pinia: '>=3.0.0'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@pinia/nuxt':
optional: true
pinia:
optional: true
pinia@3.0.3:
resolution: {integrity: sha512-ttXO/InUULUXkMHpTdp9Fj4hLpD/2AoJdmAbAeW2yu1iy1k+pkFekQXw5VpC0/5p51IOR/jDaDRfRWRnMMsGOA==}
peerDependencies:
typescript: '>=4.4.4'
vue: ^2.7.0 || ^3.5.11
peerDependenciesMeta:
typescript:
optional: true
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
postcss-html@1.8.0:
resolution: {integrity: sha512-5mMeb1TgLWoRKxZ0Xh9RZDfwUUIqRrcxO2uXO+Ezl1N5lqpCiSU5Gk6+1kZediBfBHFtPCdopr2UZ2SgUsKcgQ==}
engines: {node: ^12 || >=14}
postcss-media-query-parser@0.2.3:
resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==}
postcss-resolve-nested-selector@0.1.6:
resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==}
postcss-safe-parser@6.0.0:
resolution: {integrity: sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.3.3
postcss-safe-parser@7.0.1:
resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==}
engines: {node: '>=18.0'}
peerDependencies:
postcss: ^8.4.31
postcss-scss@4.0.9:
resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.4.29
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
postcss-selector-parser@7.1.0:
resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==}
engines: {node: '>=4'}
postcss-sorting@8.0.2:
resolution: {integrity: sha512-M9dkSrmU00t/jK7rF6BZSZauA5MAaBW4i5EnJXspMwt4iqTh/L9j6fgMnbElEOfyRyfLfVbIHj/R52zHzAPe1Q==}
peerDependencies:
postcss: ^8.4.20
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact@10.27.2:
resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier-linter-helpers@1.0.0:
resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==}
engines: {node: '>=6.0.0'}
prettier@3.6.2:
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
engines: {node: '>=14'}
hasBin: true
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode.vue@3.6.0:
resolution: {integrity: sha512-vQcl2fyHYHMjDO1GguCldJxepq2izQjBkDEEu9NENgfVKP6mv/e2SU62WbqYHGwTgWXLhxZ1NCD1dAZKHQq1fg==}
peerDependencies:
vue: ^3.0.0
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
resolve-dir@1.0.1:
resolution: {integrity: sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==}
engines: {node: '>=0.10.0'}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
resolve-from@5.0.0:
resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==}
engines: {node: '>=8'}
resolve-pkg-maps@1.0.0:
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
restore-cursor@3.1.0:
resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==}
engines: {node: '>=8'}
restore-cursor@5.1.0:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rollup-plugin-visualizer@5.14.0:
resolution: {integrity: sha512-VlDXneTDaKsHIw8yzJAFWtrzguoJ/LnQ+lMpoVfYJ3jJF4Ihe5oYLAqLklIK/35lgUY+1yEzCkHyZ1j4A5w5fA==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
rolldown: 1.x
rollup: 2.x || 3.x || 4.x
peerDependenciesMeta:
rolldown:
optional: true
rollup:
optional: true
rollup@4.52.3:
resolution: {integrity: sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-async@2.4.1:
resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==}
engines: {node: '>=0.12.0'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
sass@1.93.2:
resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==}
engines: {node: '>=14.0.0'}
hasBin: true
scroll-into-view-if-needed@2.2.31:
resolution: {integrity: sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==}
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
semver@6.3.1:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
slate-history@0.66.0:
resolution: {integrity: sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==}
peerDependencies:
slate: '>=0.65.3'
slate@0.72.8:
resolution: {integrity: sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==}
slice-ansi@4.0.0:
resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==}
engines: {node: '>=10'}
slice-ansi@5.0.0:
resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==}
engines: {node: '>=12'}
slice-ansi@7.1.2:
resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==}
engines: {node: '>=18'}
snabbdom@3.6.2:
resolution: {integrity: sha512-ig5qOnCDbugFntKi6c7Xlib8bA6xiJVk8O+WdFrV3wxbMqeHO0hXFQC4nAhPVWfZfi8255lcZkNhtIBINCc4+Q==}
engines: {node: '>=12.17.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
source-map-support@0.5.21:
resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==}
source-map@0.6.1:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
source-map@0.7.6:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
speakingurl@14.0.1:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssf@0.11.2:
resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==}
engines: {node: '>=0.8'}
ssr-window@3.0.0:
resolution: {integrity: sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==}
string-argv@0.3.2:
resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==}
engines: {node: '>=0.6.19'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
strip-bom@4.0.0:
resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==}
engines: {node: '>=8'}
strip-final-newline@3.0.0:
resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==}
engines: {node: '>=12'}
strip-final-newline@4.0.0:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-json-comments@3.1.1:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-literal@3.1.0:
resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
stylelint-config-html@1.1.0:
resolution: {integrity: sha512-IZv4IVESjKLumUGi+HWeb7skgO6/g4VMuAYrJdlqQFndgbj6WJAXPhaysvBiXefX79upBdQVumgYcdd17gCpjQ==}
engines: {node: ^12 || >=14}
peerDependencies:
postcss-html: ^1.0.0
stylelint: '>=14.0.0'
stylelint-config-recess-order@4.6.0:
resolution: {integrity: sha512-V76fhv3YtcNXh/hyAuAdSzi5FmcrG54Mp2AThJ3D/PTMTSYzUPd7GIhP6z9mTqnRhmkk6YTfcu/JWB8h+Yrcaw==}
peerDependencies:
stylelint: '>=15'
stylelint-config-recommended-scss@14.1.0:
resolution: {integrity: sha512-bhaMhh1u5dQqSsf6ri2GVWWQW5iUjBYgcHkh7SgDDn92ijoItC/cfO/W+fpXshgTQWhwFkP1rVcewcv4jaftRg==}
engines: {node: '>=18.12.0'}
peerDependencies:
postcss: ^8.3.3
stylelint: ^16.6.1
peerDependenciesMeta:
postcss:
optional: true
stylelint-config-recommended-vue@1.6.1:
resolution: {integrity: sha512-lLW7hTIMBiTfjenGuDq2kyHA6fBWd/+Df7MO4/AWOxiFeXP9clbpKgg27kHfwA3H7UNMGC7aeP3mNlZB5LMmEQ==}
engines: {node: ^12 || >=14}
peerDependencies:
postcss-html: ^1.0.0
stylelint: '>=14.0.0'
stylelint-config-recommended@14.0.1:
resolution: {integrity: sha512-bLvc1WOz/14aPImu/cufKAZYfXs/A/owZfSMZ4N+16WGXLoX5lOir53M6odBxvhgmgdxCVnNySJmZKx73T93cg==}
engines: {node: '>=18.12.0'}
peerDependencies:
stylelint: ^16.1.0
stylelint-config-recommended@17.0.0:
resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==}
engines: {node: '>=18.12.0'}
peerDependencies:
stylelint: ^16.23.0
stylelint-config-standard@36.0.1:
resolution: {integrity: sha512-8aX8mTzJ6cuO8mmD5yon61CWuIM4UD8Q5aBcWKGSf6kg+EC3uhB+iOywpTK4ca6ZL7B49en8yanOFtUW0qNzyw==}
engines: {node: '>=18.12.0'}
peerDependencies:
stylelint: ^16.1.0
stylelint-order@6.0.4:
resolution: {integrity: sha512-0UuKo4+s1hgQ/uAxlYU4h0o0HS4NiQDud0NAUNI0aa8FJdmYHA5ZZTFHiV5FpmE3071e9pZx5j0QpVJW5zOCUA==}
peerDependencies:
stylelint: ^14.0.0 || ^15.0.0 || ^16.0.1
stylelint-scss@6.12.1:
resolution: {integrity: sha512-UJUfBFIvXfly8WKIgmqfmkGKPilKB4L5j38JfsDd+OCg2GBdU0vGUV08Uw82tsRZzd4TbsUURVVNGeOhJVF7pA==}
engines: {node: '>=18.12.0'}
peerDependencies:
stylelint: ^16.0.2
stylelint@16.24.0:
resolution: {integrity: sha512-7ksgz3zJaSbTUGr/ujMXvLVKdDhLbGl3R/3arNudH7z88+XZZGNLMTepsY28WlnvEFcuOmUe7fg40Q3lfhOfSQ==}
engines: {node: '>=18.12.0'}
hasBin: true
superjson@2.2.2:
resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==}
engines: {node: '>=16'}
supports-color@5.5.0:
resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==}
engines: {node: '>=4'}
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
supports-hyperlinks@3.2.0:
resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==}
engines: {node: '>=14.18'}
svg-tags@1.0.0:
resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==}
synckit@0.11.11:
resolution: {integrity: sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==}
engines: {node: ^14.18.0 || >=16.0.0}
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
tailwindcss@4.1.14:
resolution: {integrity: sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==}
tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar@7.5.1:
resolution: {integrity: sha512-nlGpxf+hv0v7GkWBK2V9spgactGOp0qvfWRxUMjqHyzrt3SgwE48DIv/FhqPHJYLHpgW1opq3nERbz5Anq7n1g==}
engines: {node: '>=18'}
terser@5.44.0:
resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==}
engines: {node: '>=10'}
hasBin: true
text-extensions@2.4.0:
resolution: {integrity: sha512-te/NtwBwfiNRLf9Ijqx3T0nlqZiQ2XrrtBvu+cLL8ZRrGkO0NHTug8MYFKyoSrv/sHTaSKfilUkizV6XhxMJ3g==}
engines: {node: '>=8'}
through@2.3.8:
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinyexec@1.0.1:
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tmp@0.0.33:
resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==}
engines: {node: '>=0.6.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
peerDependencies:
typescript: '>=4.8.4'
tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tsx@4.20.6:
resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==}
engines: {node: '>=18.0.0'}
hasBin: true
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
type-fest@0.20.2:
resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
engines: {node: '>=10'}
type-fest@0.21.3:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
type@2.7.3:
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
typescript-eslint@8.44.1:
resolution: {integrity: sha512-0ws8uWGrUVTjEeN2OM4K1pLKHK/4NiNP/vz6ns+LjT/6sqpaYzIVFajZb1fj/IDwpsrrHb3Jy0Qm5u9CPcKaeg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
typescript: '>=4.8.4 <6.0.0'
typescript@5.6.3:
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
engines: {node: '>=14.17'}
hasBin: true
ufo@1.6.1:
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
undici-types@7.14.0:
resolution: {integrity: sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==}
unicorn-magic@0.1.0:
resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==}
engines: {node: '>=18'}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unimport@5.4.0:
resolution: {integrity: sha512-g/OLFZR2mEfqbC6NC9b2225eCJGvufxq34mj6kM3OmI5gdSL0qyqtnv+9qmsGpAmnzSl6x0IWZj4W+8j2hLkMA==}
engines: {node: '>=18.12.0'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unplugin-auto-import@20.2.0:
resolution: {integrity: sha512-vfBI/SvD9hJqYNinipVOAj5n8dS8DJXFlCKFR5iLDp2SaQwsfdnfLXgZ+34Kd3YY3YEY9omk8XQg0bwos3Q8ug==}
engines: {node: '>=14'}
peerDependencies:
'@nuxt/kit': ^4.0.0
'@vueuse/core': '*'
peerDependenciesMeta:
'@nuxt/kit':
optional: true
'@vueuse/core':
optional: true
unplugin-element-plus@0.10.0:
resolution: {integrity: sha512-oRSW0x6U58xBOWKy8TcoVZNA8ElIpfp3TUJRLQI6ey/E9PpjHl9/deeTAZNt8D57Li4OA4pCJtM6p2cb4Ff4ZA==}
engines: {node: '>=18.12.0'}
unplugin-utils@0.2.5:
resolution: {integrity: sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==}
engines: {node: '>=18.12.0'}
unplugin-utils@0.3.0:
resolution: {integrity: sha512-JLoggz+PvLVMJo+jZt97hdIIIZ2yTzGgft9e9q8iMrC4ewufl62ekeW7mixBghonn2gVb/ICjyvlmOCUBnJLQg==}
engines: {node: '>=20.19.0'}
unplugin-vue-components@29.1.0:
resolution: {integrity: sha512-z/9ACPXth199s9aCTCdKZAhe5QGOpvzJYP+Hkd0GN1/PpAmsu+W3UlRY3BJAewPqQxh5xi56+Og6mfiCV1Jzpg==}
engines: {node: '>=14'}
peerDependencies:
'@babel/parser': ^7.15.8
'@nuxt/kit': ^3.2.2 || ^4.0.0
vue: 2 || 3
peerDependenciesMeta:
'@babel/parser':
optional: true
'@nuxt/kit':
optional: true
unplugin@2.3.10:
resolution: {integrity: sha512-6NCPkv1ClwH+/BGE9QeoTIl09nuiAt0gS28nn1PvYXsGKRwM2TCbFA2QiilmehPDTXIe684k4rZI1yl3A1PCUw==}
engines: {node: '>=18.12.0'}
update-browserslist-db@1.1.3:
resolution: {integrity: sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==}
hasBin: true
peerDependencies:
browserslist: '>= 4.21.0'
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
vite-hot-client@2.1.0:
resolution: {integrity: sha512-7SpgZmU7R+dDnSmvXE1mfDtnHLHQSisdySVR7lO8ceAXvM0otZeuQQ6C8LrS5d/aYyP/QZ0hI0L+dIPrm4YlFQ==}
peerDependencies:
vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
vite-plugin-compression@0.5.1:
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
peerDependencies:
vite: '>=2.0.0'
vite-plugin-inspect@0.8.9:
resolution: {integrity: sha512-22/8qn+LYonzibb1VeFZmISdVao5kC22jmEKm24vfFE8siEn47EpVcCLYMv6iKOYMJfjSvSJfueOwcFCkUnV3A==}
engines: {node: '>=14'}
peerDependencies:
'@nuxt/kit': '*'
vite: ^3.1.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.1
peerDependenciesMeta:
'@nuxt/kit':
optional: true
vite-plugin-vue-devtools@7.7.7:
resolution: {integrity: sha512-d0fIh3wRcgSlr4Vz7bAk4va1MkdqhQgj9ANE/rBhsAjOnRfTLs2ocjFMvSUOsv6SRRXU9G+VM7yMgqDb6yI4iQ==}
engines: {node: '>=v14.21.3'}
peerDependencies:
vite: ^3.1.0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
vite-plugin-vue-inspector@5.3.2:
resolution: {integrity: sha512-YvEKooQcSiBTAs0DoYLfefNja9bLgkFM7NI2b07bE2SruuvX0MEa9cMaxjKVMkeCp5Nz9FRIdcN1rOdFVBeL6Q==}
peerDependencies:
vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0
vite@7.1.7:
resolution: {integrity: sha512-VbA8ScMvAISJNJVbRDTJdCwqQoAareR/wutevKanhR2/1EkoXVZVkkORaYm/tNVCjP/UDTKtcw3bAkwOUdedmA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
jiti: '>=1.21.0'
less: ^4.0.0
lightningcss: ^1.21.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vscode-uri@3.1.0:
resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
hasBin: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-draggable-plus@0.6.0:
resolution: {integrity: sha512-G5TSfHrt9tX9EjdG49InoFJbt2NYk0h3kgjgKxkFWr3ulIUays0oFObr5KZ8qzD4+QnhtALiRwIqY6qul4egqw==}
peerDependencies:
'@types/sortablejs': ^1.15.0
'@vue/composition-api': '*'
peerDependenciesMeta:
'@vue/composition-api':
optional: true
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
peerDependencies:
eslint: '>=6.0.0'
vue-i18n@9.14.5:
resolution: {integrity: sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==}
engines: {node: '>= 16'}
peerDependencies:
vue: ^3.0.0
vue-img-cutter@3.0.7:
resolution: {integrity: sha512-fNw3kimawg9XVXDZCw2bI74NI+Jq+H42wjymatZVVSY46wuBty6LbQsu4GeVfo/yzpS9AHY0tzckpYzX3D2fmA==}
vue-router@4.5.1:
resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==}
peerDependencies:
vue: ^3.2.0
vue-tsc@2.1.10:
resolution: {integrity: sha512-RBNSfaaRHcN5uqVqJSZh++Gy/YUzryuv9u1aFWhsammDJXNtUiJMNoJ747lZcQ68wUQFx6E73y4FY3D8E7FGMA==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.22:
resolution: {integrity: sha512-toaZjQ3a/G/mYaLSbV+QsQhIdMo9x5rrqIpYRObsJ6T/J+RyCSFwN2LHNVH9v8uIcljDNa3QzPVdv3Y6b9hAJQ==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
webpack-virtual-modules@0.6.2:
resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
which@1.3.1:
resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==}
hasBin: true
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wildcard@1.1.2:
resolution: {integrity: sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==}
wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
word@0.3.0:
resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==}
engines: {node: '>=0.8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
write-file-atomic@5.0.1:
resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
wsl-utils@0.1.0:
resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==}
engines: {node: '>=18'}
xgplayer-subtitles@3.0.23:
resolution: {integrity: sha512-deGdV75giVzfTTdG9XATmji39NHwKTpEelWt2rRx/RyXGgU2bQFp0Ft7yWaK2Uu8A/WVrP5fpxEAj4MstREMkQ==}
peerDependencies:
core-js: '>=3.12.1'
xgplayer@3.0.23:
resolution: {integrity: sha512-Bn3zQfMMAZimlVG9EeIDybMcklc+6FH8Sv47KpTq4K6ofCzyhPG/KenxailDedlHmxjb5B2o+240TpJtMQ3oJA==}
peerDependencies:
core-js: '>=3.12.1'
xlsx@0.18.5:
resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==}
engines: {node: '>=0.8'}
hasBin: true
xml-name-validator@4.0.0:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@5.0.0:
resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==}
engines: {node: '>=18'}
yaml@2.8.1:
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
yocto-queue@1.2.1:
resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==}
engines: {node: '>=12.20'}
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
zrender@6.0.0:
resolution: {integrity: sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==}
snapshots:
'@antfu/utils@0.7.10': {}
'@babel/code-frame@7.27.1':
dependencies:
'@babel/helper-validator-identifier': 7.27.1
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/compat-data@7.28.4': {}
'@babel/core@7.28.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.3
'@babel/helper-compilation-targets': 7.27.2
'@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.4)
'@babel/helpers': 7.28.4
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3
gensync: 1.0.0-beta.2
json5: 2.2.3
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/generator@7.28.3':
dependencies:
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
'@babel/helper-annotate-as-pure@7.27.3':
dependencies:
'@babel/types': 7.28.4
'@babel/helper-compilation-targets@7.27.2':
dependencies:
'@babel/compat-data': 7.28.4
'@babel/helper-validator-option': 7.27.1
browserslist: 4.26.2
lru-cache: 5.1.1
semver: 6.3.1
'@babel/helper-create-class-features-plugin@7.28.3(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-member-expression-to-functions': 7.27.1
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.27.1(@babel/core@7.28.4)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/traverse': 7.28.4
semver: 6.3.1
transitivePeerDependencies:
- supports-color
'@babel/helper-globals@7.28.0': {}
'@babel/helper-member-expression-to-functions@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-module-transforms@7.28.3(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-optimise-call-expression@7.27.1':
dependencies:
'@babel/types': 7.28.4
'@babel/helper-plugin-utils@7.27.1': {}
'@babel/helper-replace-supers@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-member-expression-to-functions': 7.27.1
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/traverse': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
transitivePeerDependencies:
- supports-color
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.27.1': {}
'@babel/helper-validator-option@7.27.1': {}
'@babel/helpers@7.28.4':
dependencies:
'@babel/template': 7.27.2
'@babel/types': 7.28.4
'@babel/parser@7.28.4':
dependencies:
'@babel/types': 7.28.4
'@babel/plugin-proposal-decorators@7.28.0(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4)
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-decorators': 7.27.1(@babel/core@7.28.4)
transitivePeerDependencies:
- supports-color
'@babel/plugin-syntax-decorators@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-import-attributes@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-transform-typescript@7.28.0(@babel/core@7.28.4)':
dependencies:
'@babel/core': 7.28.4
'@babel/helper-annotate-as-pure': 7.27.3
'@babel/helper-create-class-features-plugin': 7.28.3(@babel/core@7.28.4)
'@babel/helper-plugin-utils': 7.27.1
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.4)
transitivePeerDependencies:
- supports-color
'@babel/runtime@7.28.4': {}
'@babel/template@7.27.2':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/parser': 7.28.4
'@babel/types': 7.28.4
'@babel/traverse@7.28.4':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/generator': 7.28.3
'@babel/helper-globals': 7.28.0
'@babel/parser': 7.28.4
'@babel/template': 7.27.2
'@babel/types': 7.28.4
debug: 4.4.3
transitivePeerDependencies:
- supports-color
'@babel/types@7.28.4':
dependencies:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.27.1
'@cacheable/memoize@2.0.2':
dependencies:
'@cacheable/utils': 2.0.2
'@cacheable/memory@2.0.2':
dependencies:
'@cacheable/memoize': 2.0.2
'@cacheable/utils': 2.0.2
'@keyv/bigmap': 1.0.2
hookified: 1.12.1
keyv: 5.5.3
'@cacheable/utils@2.0.2': {}
'@commitlint/cli@19.8.1(@types/node@24.8.1)(typescript@5.6.3)':
dependencies:
'@commitlint/format': 19.8.1
'@commitlint/lint': 19.8.1
'@commitlint/load': 19.8.1(@types/node@24.8.1)(typescript@5.6.3)
'@commitlint/read': 19.8.1
'@commitlint/types': 19.8.1
tinyexec: 1.0.1
yargs: 17.7.2
transitivePeerDependencies:
- '@types/node'
- typescript
'@commitlint/config-conventional@19.8.1':
dependencies:
'@commitlint/types': 19.8.1
conventional-changelog-conventionalcommits: 7.0.2
'@commitlint/config-validator@19.8.1':
dependencies:
'@commitlint/types': 19.8.1
ajv: 8.17.1
'@commitlint/config-validator@20.0.0':
dependencies:
'@commitlint/types': 20.0.0
ajv: 8.17.1
optional: true
'@commitlint/ensure@19.8.1':
dependencies:
'@commitlint/types': 19.8.1
lodash.camelcase: 4.3.0
lodash.kebabcase: 4.1.1
lodash.snakecase: 4.1.1
lodash.startcase: 4.4.0
lodash.upperfirst: 4.3.1
'@commitlint/execute-rule@19.8.1': {}
'@commitlint/execute-rule@20.0.0':
optional: true
'@commitlint/format@19.8.1':
dependencies:
'@commitlint/types': 19.8.1
chalk: 5.6.2
'@commitlint/is-ignored@19.8.1':
dependencies:
'@commitlint/types': 19.8.1
semver: 7.7.2
'@commitlint/lint@19.8.1':
dependencies:
'@commitlint/is-ignored': 19.8.1
'@commitlint/parse': 19.8.1
'@commitlint/rules': 19.8.1
'@commitlint/types': 19.8.1
'@commitlint/load@19.8.1(@types/node@24.8.1)(typescript@5.6.3)':
dependencies:
'@commitlint/config-validator': 19.8.1
'@commitlint/execute-rule': 19.8.1
'@commitlint/resolve-extends': 19.8.1
'@commitlint/types': 19.8.1
chalk: 5.6.2
cosmiconfig: 9.0.0(typescript@5.6.3)
cosmiconfig-typescript-loader: 6.1.0(@types/node@24.8.1)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
transitivePeerDependencies:
- '@types/node'
- typescript
'@commitlint/load@20.0.0(@types/node@24.8.1)(typescript@5.6.3)':
dependencies:
'@commitlint/config-validator': 20.0.0
'@commitlint/execute-rule': 20.0.0
'@commitlint/resolve-extends': 20.0.0
'@commitlint/types': 20.0.0
chalk: 5.6.2
cosmiconfig: 9.0.0(typescript@5.6.3)
cosmiconfig-typescript-loader: 6.1.0(@types/node@24.8.1)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3)
lodash.isplainobject: 4.0.6
lodash.merge: 4.6.2
lodash.uniq: 4.5.0
transitivePeerDependencies:
- '@types/node'
- typescript
optional: true
'@commitlint/message@19.8.1': {}
'@commitlint/parse@19.8.1':
dependencies:
'@commitlint/types': 19.8.1
conventional-changelog-angular: 7.0.0
conventional-commits-parser: 5.0.0
'@commitlint/read@19.8.1':
dependencies:
'@commitlint/top-level': 19.8.1
'@commitlint/types': 19.8.1
git-raw-commits: 4.0.0
minimist: 1.2.8
tinyexec: 1.0.1
'@commitlint/resolve-extends@19.8.1':
dependencies:
'@commitlint/config-validator': 19.8.1
'@commitlint/types': 19.8.1
global-directory: 4.0.1
import-meta-resolve: 4.2.0
lodash.mergewith: 4.6.2
resolve-from: 5.0.0
'@commitlint/resolve-extends@20.0.0':
dependencies:
'@commitlint/config-validator': 20.0.0
'@commitlint/types': 20.0.0
global-directory: 4.0.1
import-meta-resolve: 4.2.0
lodash.mergewith: 4.6.2
resolve-from: 5.0.0
optional: true
'@commitlint/rules@19.8.1':
dependencies:
'@commitlint/ensure': 19.8.1
'@commitlint/message': 19.8.1
'@commitlint/to-lines': 19.8.1
'@commitlint/types': 19.8.1
'@commitlint/to-lines@19.8.1': {}
'@commitlint/top-level@19.8.1':
dependencies:
find-up: 7.0.0
'@commitlint/types@19.8.1':
dependencies:
'@types/conventional-commits-parser': 5.0.1
chalk: 5.6.2
'@commitlint/types@20.0.0':
dependencies:
'@types/conventional-commits-parser': 5.0.1
chalk: 5.6.2
optional: true
'@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-tokenizer@3.0.4': {}
'@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.0)':
dependencies:
postcss-selector-parser: 7.1.0
'@ctrl/tinycolor@3.6.1': {}
'@dual-bundle/import-meta-resolve@4.2.1': {}
'@element-plus/icons-vue@2.3.2(vue@3.5.22(typescript@5.6.3))':
dependencies:
vue: 3.5.22(typescript@5.6.3)
'@esbuild/aix-ppc64@0.25.10':
optional: true
'@esbuild/android-arm64@0.25.10':
optional: true
'@esbuild/android-arm@0.25.10':
optional: true
'@esbuild/android-x64@0.25.10':
optional: true
'@esbuild/darwin-arm64@0.25.10':
optional: true
'@esbuild/darwin-x64@0.25.10':
optional: true
'@esbuild/freebsd-arm64@0.25.10':
optional: true
'@esbuild/freebsd-x64@0.25.10':
optional: true
'@esbuild/linux-arm64@0.25.10':
optional: true
'@esbuild/linux-arm@0.25.10':
optional: true
'@esbuild/linux-ia32@0.25.10':
optional: true
'@esbuild/linux-loong64@0.25.10':
optional: true
'@esbuild/linux-mips64el@0.25.10':
optional: true
'@esbuild/linux-ppc64@0.25.10':
optional: true
'@esbuild/linux-riscv64@0.25.10':
optional: true
'@esbuild/linux-s390x@0.25.10':
optional: true
'@esbuild/linux-x64@0.25.10':
optional: true
'@esbuild/netbsd-arm64@0.25.10':
optional: true
'@esbuild/netbsd-x64@0.25.10':
optional: true
'@esbuild/openbsd-arm64@0.25.10':
optional: true
'@esbuild/openbsd-x64@0.25.10':
optional: true
'@esbuild/openharmony-arm64@0.25.10':
optional: true
'@esbuild/sunos-x64@0.25.10':
optional: true
'@esbuild/win32-arm64@0.25.10':
optional: true
'@esbuild/win32-ia32@0.25.10':
optional: true
'@esbuild/win32-x64@0.25.10':
optional: true
'@eslint-community/eslint-utils@4.9.0(eslint@9.36.0(jiti@2.6.0))':
dependencies:
eslint: 9.36.0(jiti@2.6.0)
eslint-visitor-keys: 3.4.3
'@eslint-community/regexpp@4.12.1': {}
'@eslint/config-array@0.21.0':
dependencies:
'@eslint/object-schema': 2.1.6
debug: 4.4.3
minimatch: 3.1.2
transitivePeerDependencies:
- supports-color
'@eslint/config-helpers@0.3.1': {}
'@eslint/core@0.15.2':
dependencies:
'@types/json-schema': 7.0.15
'@eslint/eslintrc@3.3.1':
dependencies:
ajv: 6.12.6
debug: 4.4.3
espree: 10.4.0
globals: 14.0.0
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.0
minimatch: 3.1.2
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
'@eslint/js@9.36.0': {}
'@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.3.5':
dependencies:
'@eslint/core': 0.15.2
levn: 0.4.1
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/utils@0.2.10': {}
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
dependencies:
'@humanfs/core': 0.19.1
'@humanwhocodes/retry': 0.4.3
'@humanwhocodes/module-importer@1.0.1': {}
'@humanwhocodes/retry@0.4.3': {}
'@iconify/types@2.0.0': {}
'@iconify/vue@5.0.0(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@iconify/types': 2.0.0
vue: 3.5.22(typescript@5.6.3)
'@intlify/core-base@9.14.5':
dependencies:
'@intlify/message-compiler': 9.14.5
'@intlify/shared': 9.14.5
'@intlify/message-compiler@9.14.5':
dependencies:
'@intlify/shared': 9.14.5
source-map-js: 1.2.1
'@intlify/shared@9.14.5': {}
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.2
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/remapping@2.3.5':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/source-map@0.3.11':
dependencies:
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@keyv/bigmap@1.0.2':
dependencies:
hookified: 1.12.1
'@keyv/serialize@1.1.1': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
run-parallel: 1.2.0
'@nodelib/fs.stat@2.0.5': {}
'@nodelib/fs.walk@1.2.8':
dependencies:
'@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1
'@parcel/watcher-android-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-arm64@2.5.1':
optional: true
'@parcel/watcher-darwin-x64@2.5.1':
optional: true
'@parcel/watcher-freebsd-x64@2.5.1':
optional: true
'@parcel/watcher-linux-arm-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm-musl@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-arm64-musl@2.5.1':
optional: true
'@parcel/watcher-linux-x64-glibc@2.5.1':
optional: true
'@parcel/watcher-linux-x64-musl@2.5.1':
optional: true
'@parcel/watcher-win32-arm64@2.5.1':
optional: true
'@parcel/watcher-win32-ia32@2.5.1':
optional: true
'@parcel/watcher-win32-x64@2.5.1':
optional: true
'@parcel/watcher@2.5.1':
dependencies:
detect-libc: 1.0.3
is-glob: 4.0.3
micromatch: 4.0.8
node-addon-api: 7.1.1
optionalDependencies:
'@parcel/watcher-android-arm64': 2.5.1
'@parcel/watcher-darwin-arm64': 2.5.1
'@parcel/watcher-darwin-x64': 2.5.1
'@parcel/watcher-freebsd-x64': 2.5.1
'@parcel/watcher-linux-arm-glibc': 2.5.1
'@parcel/watcher-linux-arm-musl': 2.5.1
'@parcel/watcher-linux-arm64-glibc': 2.5.1
'@parcel/watcher-linux-arm64-musl': 2.5.1
'@parcel/watcher-linux-x64-glibc': 2.5.1
'@parcel/watcher-linux-x64-musl': 2.5.1
'@parcel/watcher-win32-arm64': 2.5.1
'@parcel/watcher-win32-ia32': 2.5.1
'@parcel/watcher-win32-x64': 2.5.1
optional: true
'@pkgr/core@0.2.9': {}
'@polka/url@1.0.0-next.29': {}
'@rolldown/pluginutils@1.0.0-beta.29': {}
'@rollup/pluginutils@5.3.0(rollup@4.52.3)':
dependencies:
'@types/estree': 1.0.8
estree-walker: 2.0.2
picomatch: 4.0.3
optionalDependencies:
rollup: 4.52.3
'@rollup/rollup-android-arm-eabi@4.52.3':
optional: true
'@rollup/rollup-android-arm64@4.52.3':
optional: true
'@rollup/rollup-darwin-arm64@4.52.3':
optional: true
'@rollup/rollup-darwin-x64@4.52.3':
optional: true
'@rollup/rollup-freebsd-arm64@4.52.3':
optional: true
'@rollup/rollup-freebsd-x64@4.52.3':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.52.3':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.52.3':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.52.3':
optional: true
'@rollup/rollup-linux-arm64-musl@4.52.3':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.52.3':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.52.3':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.52.3':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.52.3':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.52.3':
optional: true
'@rollup/rollup-linux-x64-gnu@4.52.3':
optional: true
'@rollup/rollup-linux-x64-musl@4.52.3':
optional: true
'@rollup/rollup-openharmony-arm64@4.52.3':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.52.3':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.52.3':
optional: true
'@rollup/rollup-win32-x64-gnu@4.52.3':
optional: true
'@rollup/rollup-win32-x64-msvc@4.52.3':
optional: true
'@sec-ant/readable-stream@0.4.1': {}
'@sindresorhus/merge-streams@4.0.0': {}
'@sxzz/popperjs-es@2.11.7': {}
'@tailwindcss/node@4.1.14':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.3
jiti: 2.6.0
lightningcss: 1.30.1
magic-string: 0.30.19
source-map-js: 1.2.1
tailwindcss: 4.1.14
'@tailwindcss/oxide-android-arm64@4.1.14':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.14':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.14':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.14':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.14':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.14':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.14':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.14':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.14':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.14':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.14':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.14':
optional: true
'@tailwindcss/oxide@4.1.14':
dependencies:
detect-libc: 2.1.2
tar: 7.5.1
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.14
'@tailwindcss/oxide-darwin-arm64': 4.1.14
'@tailwindcss/oxide-darwin-x64': 4.1.14
'@tailwindcss/oxide-freebsd-x64': 4.1.14
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.14
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.14
'@tailwindcss/oxide-linux-arm64-musl': 4.1.14
'@tailwindcss/oxide-linux-x64-gnu': 4.1.14
'@tailwindcss/oxide-linux-x64-musl': 4.1.14
'@tailwindcss/oxide-wasm32-wasi': 4.1.14
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.14
'@tailwindcss/oxide-win32-x64-msvc': 4.1.14
'@tailwindcss/vite@4.1.14(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))':
dependencies:
'@tailwindcss/node': 4.1.14
'@tailwindcss/oxide': 4.1.14
tailwindcss: 4.1.14
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
'@transloadit/prettier-bytes@0.0.7': {}
'@types/conventional-commits-parser@5.0.1':
dependencies:
'@types/node': 24.8.1
'@types/estree@1.0.8': {}
'@types/event-emitter@0.3.5': {}
'@types/json-schema@7.0.15': {}
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.20
'@types/lodash@4.17.20': {}
'@types/node@24.8.1':
dependencies:
undici-types: 7.14.0
'@types/sortablejs@1.15.8': {}
'@types/spark-md5@3.0.5': {}
'@types/web-bluetooth@0.0.16': {}
'@types/web-bluetooth@0.0.21': {}
'@typescript-eslint/eslint-plugin@8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3))(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)':
dependencies:
'@eslint-community/regexpp': 4.12.1
'@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@typescript-eslint/scope-manager': 8.44.1
'@typescript-eslint/type-utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.44.1
eslint: 9.36.0(jiti@2.6.0)
graphemer: 1.4.0
ignore: 7.0.5
natural-compare: 1.4.0
ts-api-utils: 2.1.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/scope-manager': 8.44.1
'@typescript-eslint/types': 8.44.1
'@typescript-eslint/typescript-estree': 8.44.1(typescript@5.6.3)
'@typescript-eslint/visitor-keys': 8.44.1
debug: 4.4.3
eslint: 9.36.0(jiti@2.6.0)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/project-service@8.44.1(typescript@5.6.3)':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.6.3)
'@typescript-eslint/types': 8.44.1
debug: 4.4.3
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/scope-manager@8.44.1':
dependencies:
'@typescript-eslint/types': 8.44.1
'@typescript-eslint/visitor-keys': 8.44.1
'@typescript-eslint/tsconfig-utils@8.44.1(typescript@5.6.3)':
dependencies:
typescript: 5.6.3
'@typescript-eslint/type-utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)':
dependencies:
'@typescript-eslint/types': 8.44.1
'@typescript-eslint/typescript-estree': 8.44.1(typescript@5.6.3)
'@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
debug: 4.4.3
eslint: 9.36.0(jiti@2.6.0)
ts-api-utils: 2.1.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/types@8.44.1': {}
'@typescript-eslint/typescript-estree@8.44.1(typescript@5.6.3)':
dependencies:
'@typescript-eslint/project-service': 8.44.1(typescript@5.6.3)
'@typescript-eslint/tsconfig-utils': 8.44.1(typescript@5.6.3)
'@typescript-eslint/types': 8.44.1
'@typescript-eslint/visitor-keys': 8.44.1
debug: 4.4.3
fast-glob: 3.3.3
is-glob: 4.0.3
minimatch: 9.0.5
semver: 7.7.2
ts-api-utils: 2.1.0(typescript@5.6.3)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/utils@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)':
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0))
'@typescript-eslint/scope-manager': 8.44.1
'@typescript-eslint/types': 8.44.1
'@typescript-eslint/typescript-estree': 8.44.1(typescript@5.6.3)
eslint: 9.36.0(jiti@2.6.0)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/visitor-keys@8.44.1':
dependencies:
'@typescript-eslint/types': 8.44.1
eslint-visitor-keys: 4.2.1
'@uppy/companion-client@2.2.2':
dependencies:
'@uppy/utils': 4.1.3
namespace-emitter: 2.0.1
'@uppy/core@2.3.4':
dependencies:
'@transloadit/prettier-bytes': 0.0.7
'@uppy/store-default': 2.1.1
'@uppy/utils': 4.1.3
lodash.throttle: 4.1.1
mime-match: 1.0.2
namespace-emitter: 2.0.1
nanoid: 3.3.11
preact: 10.27.2
'@uppy/store-default@2.1.1': {}
'@uppy/utils@4.1.3':
dependencies:
lodash.throttle: 4.1.1
'@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4)':
dependencies:
'@uppy/companion-client': 2.2.2
'@uppy/core': 2.3.4
'@uppy/utils': 4.1.3
nanoid: 3.3.11
'@vitejs/plugin-vue@6.0.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.29
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
vue: 3.5.22(typescript@5.6.3)
'@volar/language-core@2.4.23':
dependencies:
'@volar/source-map': 2.4.23
'@volar/source-map@2.4.23': {}
'@volar/typescript@2.4.23':
dependencies:
'@volar/language-core': 2.4.23
path-browserify: 1.0.1
vscode-uri: 3.1.0
'@vue/babel-helper-vue-transform-on@1.5.0': {}
'@vue/babel-plugin-jsx@1.5.0(@babel/core@7.28.4)':
dependencies:
'@babel/helper-module-imports': 7.27.1
'@babel/helper-plugin-utils': 7.27.1
'@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.4)
'@babel/template': 7.27.2
'@babel/traverse': 7.28.4
'@babel/types': 7.28.4
'@vue/babel-helper-vue-transform-on': 1.5.0
'@vue/babel-plugin-resolve-type': 1.5.0(@babel/core@7.28.4)
'@vue/shared': 3.5.22
optionalDependencies:
'@babel/core': 7.28.4
transitivePeerDependencies:
- supports-color
'@vue/babel-plugin-resolve-type@1.5.0(@babel/core@7.28.4)':
dependencies:
'@babel/code-frame': 7.27.1
'@babel/core': 7.28.4
'@babel/helper-module-imports': 7.27.1
'@babel/helper-plugin-utils': 7.27.1
'@babel/parser': 7.28.4
'@vue/compiler-sfc': 3.5.22
transitivePeerDependencies:
- supports-color
'@vue/compiler-core@3.5.22':
dependencies:
'@babel/parser': 7.28.4
'@vue/shared': 3.5.22
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.22':
dependencies:
'@vue/compiler-core': 3.5.22
'@vue/shared': 3.5.22
'@vue/compiler-sfc@3.5.22':
dependencies:
'@babel/parser': 7.28.4
'@vue/compiler-core': 3.5.22
'@vue/compiler-dom': 3.5.22
'@vue/compiler-ssr': 3.5.22
'@vue/shared': 3.5.22
estree-walker: 2.0.2
magic-string: 0.30.19
postcss: 8.5.6
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.22':
dependencies:
'@vue/compiler-dom': 3.5.22
'@vue/shared': 3.5.22
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.7':
dependencies:
'@vue/devtools-kit': 7.7.7
'@vue/devtools-core@7.7.7(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@vue/devtools-kit': 7.7.7
'@vue/devtools-shared': 7.7.7
mitt: 3.0.1
nanoid: 5.1.6
pathe: 2.0.3
vite-hot-client: 2.1.0(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
vue: 3.5.22(typescript@5.6.3)
transitivePeerDependencies:
- vite
'@vue/devtools-kit@7.7.7':
dependencies:
'@vue/devtools-shared': 7.7.7
birpc: 2.6.1
hookable: 5.5.3
mitt: 3.0.1
perfect-debounce: 1.0.0
speakingurl: 14.0.1
superjson: 2.2.2
'@vue/devtools-shared@7.7.7':
dependencies:
rfdc: 1.4.1
'@vue/language-core@2.1.10(typescript@5.6.3)':
dependencies:
'@volar/language-core': 2.4.23
'@vue/compiler-dom': 3.5.22
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.22
alien-signals: 0.2.2
minimatch: 9.0.5
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.6.3
'@vue/reactivity@3.5.22':
dependencies:
'@vue/shared': 3.5.22
'@vue/runtime-core@3.5.22':
dependencies:
'@vue/reactivity': 3.5.22
'@vue/shared': 3.5.22
'@vue/runtime-dom@3.5.22':
dependencies:
'@vue/reactivity': 3.5.22
'@vue/runtime-core': 3.5.22
'@vue/shared': 3.5.22
csstype: 3.1.3
'@vue/server-renderer@3.5.22(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@vue/compiler-ssr': 3.5.22
'@vue/shared': 3.5.22
vue: 3.5.22(typescript@5.6.3)
'@vue/shared@3.5.22': {}
'@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 13.9.0
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.6.3))
vue: 3.5.22(typescript@5.6.3)
'@vueuse/core@9.13.0(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.13.0
'@vueuse/shared': 9.13.0(vue@3.5.22(typescript@5.6.3))
vue-demi: 0.14.10(vue@3.5.22(typescript@5.6.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@vueuse/metadata@13.9.0': {}
'@vueuse/metadata@9.13.0': {}
'@vueuse/shared@13.9.0(vue@3.5.22(typescript@5.6.3))':
dependencies:
vue: 3.5.22(typescript@5.6.3)
'@vueuse/shared@9.13.0(vue@3.5.22(typescript@5.6.3))':
dependencies:
vue-demi: 0.14.10(vue@3.5.22(typescript@5.6.3))
transitivePeerDependencies:
- '@vue/composition-api'
- vue
'@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
is-url: 1.2.4
lodash.throttle: 4.1.1
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.2
'@wangeditor/code-highlight@1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
prismjs: 1.30.0
slate: 0.72.8
snabbdom: 3.6.2
'@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@types/event-emitter': 0.3.5
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
dom7: 3.0.0
event-emitter: 0.3.5
html-void-elements: 2.0.1
i18next: 20.6.1
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.foreach: 4.5.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 3.3.11
scroll-into-view-if-needed: 2.2.31
slate: 0.72.8
slate-history: 0.66.0(slate@0.72.8)
snabbdom: 3.6.2
'@wangeditor/editor-for-vue@5.1.12(@wangeditor/editor@5.1.23)(vue@3.5.22(typescript@5.6.3))':
dependencies:
'@wangeditor/editor': 5.1.23
vue: 3.5.22(typescript@5.6.3)
'@wangeditor/editor@5.1.23':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/code-highlight': 1.0.3(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/list-module': 1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/table-module': 1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/upload-image-module': 1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/video-module': 1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
is-hotkey: 0.2.0
lodash.camelcase: 4.3.0
lodash.clonedeep: 4.5.0
lodash.debounce: 4.0.8
lodash.foreach: 4.5.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
lodash.toarray: 4.4.0
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.2
'@wangeditor/list-module@1.0.5(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
slate: 0.72.8
snabbdom: 3.6.2
'@wangeditor/table-module@1.1.4(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
lodash.isequal: 4.5.0
lodash.throttle: 4.1.1
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.2
'@wangeditor/upload-image-module@1.0.2(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/basic-modules@1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.foreach@4.5.0)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/basic-modules': 1.1.7(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(lodash.throttle@4.1.1)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
lodash.foreach: 4.5.0
slate: 0.72.8
snabbdom: 3.6.2
'@wangeditor/video-module@1.1.4(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(@wangeditor/core@1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2))(dom7@3.0.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)':
dependencies:
'@uppy/core': 2.3.4
'@uppy/xhr-upload': 2.1.3(@uppy/core@2.3.4)
'@wangeditor/core': 1.1.19(@uppy/core@2.3.4)(@uppy/xhr-upload@2.1.3(@uppy/core@2.3.4))(dom7@3.0.0)(is-hotkey@0.2.0)(lodash.camelcase@4.3.0)(lodash.clonedeep@4.5.0)(lodash.debounce@4.0.8)(lodash.foreach@4.5.0)(lodash.isequal@4.5.0)(lodash.throttle@4.1.1)(lodash.toarray@4.4.0)(nanoid@3.3.11)(slate@0.72.8)(snabbdom@3.6.2)
dom7: 3.0.0
nanoid: 3.3.11
slate: 0.72.8
snabbdom: 3.6.2
JSONStream@1.3.5:
dependencies:
jsonparse: 1.3.1
through: 2.3.8
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
acorn@8.15.0: {}
adler-32@1.3.1: {}
ajv@6.12.6:
dependencies:
fast-deep-equal: 3.1.3
fast-json-stable-stringify: 2.1.0
json-schema-traverse: 0.4.1
uri-js: 4.4.1
ajv@8.17.1:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.0
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
alien-signals@0.2.2: {}
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
ansi-escapes@7.1.1:
dependencies:
environment: 1.1.0
ansi-regex@5.0.1: {}
ansi-regex@6.2.2: {}
ansi-styles@3.2.1:
dependencies:
color-convert: 1.9.3
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
ansi-styles@6.2.3: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.1
argparse@2.0.1: {}
array-ify@1.0.0: {}
array-union@2.1.0: {}
astral-regex@2.0.0: {}
async-validator@4.2.5: {}
asynckit@0.4.0: {}
at-least-node@1.0.0: {}
axios@1.12.2:
dependencies:
follow-redirects: 1.15.11
form-data: 4.0.4
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {}
balanced-match@2.0.0: {}
base64-js@1.5.1: {}
baseline-browser-mapping@2.8.8: {}
binary-extensions@2.3.0: {}
birpc@2.6.1: {}
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
boolbase@1.0.0: {}
brace-expansion@1.1.12:
dependencies:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
braces@3.0.3:
dependencies:
fill-range: 7.1.1
browserslist@4.26.2:
dependencies:
baseline-browser-mapping: 2.8.8
caniuse-lite: 1.0.30001745
electron-to-chromium: 1.5.227
node-releases: 2.0.21
update-browserslist-db: 1.1.3(browserslist@4.26.2)
buffer-from@1.1.2: {}
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
cacheable@2.0.2:
dependencies:
'@cacheable/memoize': 2.0.2
'@cacheable/memory': 2.0.2
'@cacheable/utils': 2.0.2
hookified: 1.12.1
keyv: 5.5.3
cachedir@2.3.0: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
callsites@3.1.0: {}
caniuse-lite@1.0.30001745: {}
cfb@1.2.2:
dependencies:
adler-32: 1.3.1
crc-32: 1.2.2
chalk@2.4.2:
dependencies:
ansi-styles: 3.2.1
escape-string-regexp: 1.0.5
supports-color: 5.5.0
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
supports-color: 7.2.0
chalk@5.6.2: {}
chardet@0.7.0: {}
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
chownr@3.0.0: {}
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
cli-spinners@2.9.2: {}
cli-truncate@4.0.0:
dependencies:
slice-ansi: 5.0.0
string-width: 7.2.0
cli-width@3.0.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
clone@1.0.4: {}
codepage@1.15.0: {}
color-convert@1.9.3:
dependencies:
color-name: 1.1.3
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.3: {}
color-name@1.1.4: {}
colord@2.9.3: {}
colorette@2.0.20: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
commander@13.1.0: {}
commander@2.20.3: {}
commitizen@4.3.1(@types/node@24.8.1)(typescript@5.6.3):
dependencies:
cachedir: 2.3.0
cz-conventional-changelog: 3.3.0(@types/node@24.8.1)(typescript@5.6.3)
dedent: 0.7.0
detect-indent: 6.1.0
find-node-modules: 2.1.3
find-root: 1.1.0
fs-extra: 9.1.0
glob: 7.2.3
inquirer: 8.2.5
is-utf8: 0.2.1
lodash: 4.17.21
minimist: 1.2.7
strip-bom: 4.0.0
strip-json-comments: 3.1.1
transitivePeerDependencies:
- '@types/node'
- typescript
compare-func@2.0.0:
dependencies:
array-ify: 1.0.0
dot-prop: 5.3.0
compute-scroll-into-view@1.0.20: {}
concat-map@0.0.1: {}
confbox@0.1.8: {}
confbox@0.2.2: {}
conventional-changelog-angular@7.0.0:
dependencies:
compare-func: 2.0.0
conventional-changelog-conventionalcommits@7.0.2:
dependencies:
compare-func: 2.0.0
conventional-commit-types@3.0.0: {}
conventional-commits-parser@5.0.0:
dependencies:
JSONStream: 1.3.5
is-text-path: 2.0.0
meow: 12.1.1
split2: 4.2.0
convert-source-map@2.0.0: {}
copy-anything@3.0.5:
dependencies:
is-what: 4.1.16
core-js@3.45.1: {}
cosmiconfig-typescript-loader@6.1.0(@types/node@24.8.1)(cosmiconfig@9.0.0(typescript@5.6.3))(typescript@5.6.3):
dependencies:
'@types/node': 24.8.1
cosmiconfig: 9.0.0(typescript@5.6.3)
jiti: 2.6.0
typescript: 5.6.3
cosmiconfig@9.0.0(typescript@5.6.3):
dependencies:
env-paths: 2.2.1
import-fresh: 3.3.1
js-yaml: 4.1.0
parse-json: 5.2.0
optionalDependencies:
typescript: 5.6.3
crc-32@1.2.2: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
crypto-js@4.2.0: {}
css-functions-list@3.2.3: {}
css-tree@3.1.0:
dependencies:
mdn-data: 2.12.2
source-map-js: 1.2.1
cssesc@3.0.0: {}
csstype@3.1.3: {}
cz-conventional-changelog@3.3.0(@types/node@24.8.1)(typescript@5.6.3):
dependencies:
chalk: 2.4.2
commitizen: 4.3.1(@types/node@24.8.1)(typescript@5.6.3)
conventional-commit-types: 3.0.0
lodash.map: 4.6.0
longest: 2.0.1
word-wrap: 1.2.5
optionalDependencies:
'@commitlint/load': 20.0.0(@types/node@24.8.1)(typescript@5.6.3)
transitivePeerDependencies:
- '@types/node'
- typescript
cz-git@1.12.0: {}
d@1.0.2:
dependencies:
es5-ext: 0.10.64
type: 2.7.3
danmu.js@1.1.13:
dependencies:
event-emitter: 0.3.5
dargs@8.1.0: {}
dayjs@1.11.18: {}
de-indent@1.0.2: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
dedent@0.7.0: {}
deep-is@0.1.4: {}
deep-pick-omit@1.2.1: {}
default-browser-id@5.0.0: {}
default-browser@5.2.1:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.0
defaults@1.0.4:
dependencies:
clone: 1.0.4
define-lazy-prop@2.0.0: {}
define-lazy-prop@3.0.0: {}
defu@6.1.4: {}
delayed-stream@1.0.0: {}
delegate@3.2.0: {}
destr@2.0.5: {}
detect-file@1.0.0: {}
detect-indent@6.1.0: {}
detect-libc@1.0.3:
optional: true
detect-libc@2.1.2: {}
dir-glob@3.0.1:
dependencies:
path-type: 4.0.0
dom-serializer@2.0.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
entities: 4.5.0
dom7@3.0.0:
dependencies:
ssr-window: 3.0.0
domelementtype@2.3.0: {}
domhandler@5.0.3:
dependencies:
domelementtype: 2.3.0
domutils@3.2.2:
dependencies:
dom-serializer: 2.0.0
domelementtype: 2.3.0
domhandler: 5.0.3
dot-prop@5.3.0:
dependencies:
is-obj: 2.0.0
downloadjs@1.4.7: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
echarts@6.0.0:
dependencies:
tslib: 2.3.0
zrender: 6.0.0
electron-to-chromium@1.5.227: {}
element-plus@2.11.4(vue@3.5.22(typescript@5.6.3)):
dependencies:
'@ctrl/tinycolor': 3.6.1
'@element-plus/icons-vue': 2.3.2(vue@3.5.22(typescript@5.6.3))
'@floating-ui/dom': 1.7.4
'@popperjs/core': '@sxzz/popperjs-es@2.11.7'
'@types/lodash': 4.17.20
'@types/lodash-es': 4.17.12
'@vueuse/core': 9.13.0(vue@3.5.22(typescript@5.6.3))
async-validator: 4.2.5
dayjs: 1.11.18
escape-html: 1.0.3
lodash: 4.17.21
lodash-es: 4.17.21
lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21)
memoize-one: 6.0.0
normalize-wheel-es: 1.2.0
vue: 3.5.22(typescript@5.6.3)
transitivePeerDependencies:
- '@vue/composition-api'
emoji-regex@10.5.0: {}
emoji-regex@8.0.0: {}
enhanced-resolve@5.18.3:
dependencies:
graceful-fs: 4.2.11
tapable: 2.3.0
entities@4.5.0: {}
env-paths@2.2.1: {}
environment@1.1.0: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
error-stack-parser-es@0.1.5: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-module-lexer@1.7.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
es5-ext@0.10.64:
dependencies:
es6-iterator: 2.0.3
es6-symbol: 3.1.4
esniff: 2.0.1
next-tick: 1.1.0
es6-iterator@2.0.3:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
es6-symbol: 3.1.4
es6-symbol@3.1.4:
dependencies:
d: 1.0.2
ext: 1.7.0
esbuild@0.25.10:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.10
'@esbuild/android-arm': 0.25.10
'@esbuild/android-arm64': 0.25.10
'@esbuild/android-x64': 0.25.10
'@esbuild/darwin-arm64': 0.25.10
'@esbuild/darwin-x64': 0.25.10
'@esbuild/freebsd-arm64': 0.25.10
'@esbuild/freebsd-x64': 0.25.10
'@esbuild/linux-arm': 0.25.10
'@esbuild/linux-arm64': 0.25.10
'@esbuild/linux-ia32': 0.25.10
'@esbuild/linux-loong64': 0.25.10
'@esbuild/linux-mips64el': 0.25.10
'@esbuild/linux-ppc64': 0.25.10
'@esbuild/linux-riscv64': 0.25.10
'@esbuild/linux-s390x': 0.25.10
'@esbuild/linux-x64': 0.25.10
'@esbuild/netbsd-arm64': 0.25.10
'@esbuild/netbsd-x64': 0.25.10
'@esbuild/openbsd-arm64': 0.25.10
'@esbuild/openbsd-x64': 0.25.10
'@esbuild/openharmony-arm64': 0.25.10
'@esbuild/sunos-x64': 0.25.10
'@esbuild/win32-arm64': 0.25.10
'@esbuild/win32-ia32': 0.25.10
'@esbuild/win32-x64': 0.25.10
escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@1.0.5: {}
escape-string-regexp@4.0.0: {}
escape-string-regexp@5.0.0: {}
eslint-config-prettier@9.1.2(eslint@9.36.0(jiti@2.6.0)):
dependencies:
eslint: 9.36.0(jiti@2.6.0)
eslint-plugin-prettier@5.5.4(eslint-config-prettier@9.1.2(eslint@9.36.0(jiti@2.6.0)))(eslint@9.36.0(jiti@2.6.0))(prettier@3.6.2):
dependencies:
eslint: 9.36.0(jiti@2.6.0)
prettier: 3.6.2
prettier-linter-helpers: 1.0.0
synckit: 0.11.11
optionalDependencies:
eslint-config-prettier: 9.1.2(eslint@9.36.0(jiti@2.6.0))
eslint-plugin-vue@9.33.0(eslint@9.36.0(jiti@2.6.0)):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0))
eslint: 9.36.0(jiti@2.6.0)
globals: 13.24.0
natural-compare: 1.4.0
nth-check: 2.1.1
postcss-selector-parser: 6.1.2
semver: 7.7.2
vue-eslint-parser: 9.4.3(eslint@9.36.0(jiti@2.6.0))
xml-name-validator: 4.0.0
transitivePeerDependencies:
- supports-color
eslint-scope@7.2.2:
dependencies:
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-scope@8.4.0:
dependencies:
esrecurse: 4.3.0
estraverse: 5.3.0
eslint-visitor-keys@3.4.3: {}
eslint-visitor-keys@4.2.1: {}
eslint@9.36.0(jiti@2.6.0):
dependencies:
'@eslint-community/eslint-utils': 4.9.0(eslint@9.36.0(jiti@2.6.0))
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.21.0
'@eslint/config-helpers': 0.3.1
'@eslint/core': 0.15.2
'@eslint/eslintrc': 3.3.1
'@eslint/js': 9.36.0
'@eslint/plugin-kit': 0.3.5
'@humanfs/node': 0.16.7
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.3
'@types/estree': 1.0.8
'@types/json-schema': 7.0.15
ajv: 6.12.6
chalk: 4.1.2
cross-spawn: 7.0.6
debug: 4.4.3
escape-string-regexp: 4.0.0
eslint-scope: 8.4.0
eslint-visitor-keys: 4.2.1
espree: 10.4.0
esquery: 1.6.0
esutils: 2.0.3
fast-deep-equal: 3.1.3
file-entry-cache: 8.0.0
find-up: 5.0.0
glob-parent: 6.0.2
ignore: 5.3.2
imurmurhash: 0.1.4
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
minimatch: 3.1.2
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
jiti: 2.6.0
transitivePeerDependencies:
- supports-color
esniff@2.0.1:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
event-emitter: 0.3.5
type: 2.7.3
espree@10.4.0:
dependencies:
acorn: 8.15.0
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 4.2.1
espree@9.6.1:
dependencies:
acorn: 8.15.0
acorn-jsx: 5.3.2(acorn@8.15.0)
eslint-visitor-keys: 3.4.3
esquery@1.6.0:
dependencies:
estraverse: 5.3.0
esrecurse@4.3.0:
dependencies:
estraverse: 5.3.0
estraverse@5.3.0: {}
estree-walker@2.0.2: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {}
event-emitter@0.3.5:
dependencies:
d: 1.0.2
es5-ext: 0.10.64
eventemitter3@4.0.7: {}
eventemitter3@5.0.1: {}
execa@8.0.1:
dependencies:
cross-spawn: 7.0.6
get-stream: 8.0.1
human-signals: 5.0.0
is-stream: 3.0.0
merge-stream: 2.0.0
npm-run-path: 5.3.0
onetime: 6.0.0
signal-exit: 4.1.0
strip-final-newline: 3.0.0
execa@9.6.0:
dependencies:
'@sindresorhus/merge-streams': 4.0.0
cross-spawn: 7.0.6
figures: 6.1.0
get-stream: 9.0.1
human-signals: 8.0.1
is-plain-obj: 4.1.0
is-stream: 4.0.1
npm-run-path: 6.0.0
pretty-ms: 9.3.0
signal-exit: 4.1.0
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expand-tilde@2.0.2:
dependencies:
homedir-polyfill: 1.0.3
exsolve@1.0.7: {}
ext@1.7.0:
dependencies:
type: 2.7.3
external-editor@3.1.0:
dependencies:
chardet: 0.7.0
iconv-lite: 0.4.24
tmp: 0.0.33
fast-deep-equal@3.1.3: {}
fast-diff@1.3.0: {}
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.8
fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {}
fast-uri@3.1.0: {}
fastest-levenshtein@1.0.16: {}
fastq@1.19.1:
dependencies:
reusify: 1.1.0
fdir@6.5.0(picomatch@4.0.3):
optionalDependencies:
picomatch: 4.0.3
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
figures@6.1.0:
dependencies:
is-unicode-supported: 2.1.0
file-entry-cache@10.1.4:
dependencies:
flat-cache: 6.1.14
file-entry-cache@8.0.0:
dependencies:
flat-cache: 4.0.1
file-saver@2.0.5: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
find-node-modules@2.1.3:
dependencies:
findup-sync: 4.0.0
merge: 2.1.1
find-root@1.1.0: {}
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
path-exists: 4.0.0
find-up@7.0.0:
dependencies:
locate-path: 7.2.0
path-exists: 5.0.0
unicorn-magic: 0.1.0
findup-sync@4.0.0:
dependencies:
detect-file: 1.0.0
is-glob: 4.0.3
micromatch: 4.0.8
resolve-dir: 1.0.1
flat-cache@4.0.1:
dependencies:
flatted: 3.3.3
keyv: 4.5.4
flat-cache@6.1.14:
dependencies:
cacheable: 2.0.2
flatted: 3.3.3
hookified: 1.12.1
flatted@3.3.3: {}
follow-redirects@1.15.11: {}
form-data@4.0.4:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
frac@1.1.2: {}
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@11.3.2:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@9.1.0:
dependencies:
at-least-node: 1.0.0
graceful-fs: 4.2.11
jsonfile: 6.2.0
universalify: 2.0.1
fs.realpath@1.0.0: {}
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-east-asian-width@1.4.0: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.1
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
get-stream@8.0.1: {}
get-stream@9.0.1:
dependencies:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
get-tsconfig@4.10.1:
dependencies:
resolve-pkg-maps: 1.0.0
git-raw-commits@4.0.0:
dependencies:
dargs: 8.1.0
meow: 12.1.1
split2: 4.2.0
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
glob-parent@6.0.2:
dependencies:
is-glob: 4.0.3
glob@7.2.3:
dependencies:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
once: 1.4.0
path-is-absolute: 1.0.1
global-directory@4.0.1:
dependencies:
ini: 4.1.1
global-modules@1.0.0:
dependencies:
global-prefix: 1.0.2
is-windows: 1.0.2
resolve-dir: 1.0.1
global-modules@2.0.0:
dependencies:
global-prefix: 3.0.0
global-prefix@1.0.2:
dependencies:
expand-tilde: 2.0.2
homedir-polyfill: 1.0.3
ini: 1.3.8
is-windows: 1.0.2
which: 1.3.1
global-prefix@3.0.0:
dependencies:
ini: 1.3.8
kind-of: 6.0.3
which: 1.3.1
globals@13.24.0:
dependencies:
type-fest: 0.20.2
globals@14.0.0: {}
globals@15.15.0: {}
globby@11.1.0:
dependencies:
array-union: 2.1.0
dir-glob: 3.0.1
fast-glob: 3.3.3
ignore: 5.3.2
merge2: 1.4.1
slash: 3.0.0
globjoin@0.1.4: {}
gopd@1.2.0: {}
graceful-fs@4.2.11: {}
graphemer@1.4.0: {}
has-flag@3.0.0: {}
has-flag@4.0.0: {}
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
he@1.2.0: {}
highlight.js@11.11.1: {}
homedir-polyfill@1.0.3:
dependencies:
parse-passwd: 1.0.0
hookable@5.5.3: {}
hookified@1.12.1: {}
html-tags@3.3.1: {}
html-void-elements@2.0.1: {}
htmlparser2@8.0.2:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 4.5.0
human-signals@5.0.0: {}
human-signals@8.0.1: {}
husky@9.1.7: {}
i18next@20.6.1:
dependencies:
'@babel/runtime': 7.28.4
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
immer@9.0.21: {}
immutable@5.1.3: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
resolve-from: 4.0.0
import-meta-resolve@4.2.0: {}
imurmurhash@0.1.4: {}
inflight@1.0.6:
dependencies:
once: 1.4.0
wrappy: 1.0.2
inherits@2.0.4: {}
ini@1.3.8: {}
ini@4.1.1: {}
inquirer@8.2.5:
dependencies:
ansi-escapes: 4.3.2
chalk: 4.1.2
cli-cursor: 3.1.0
cli-width: 3.0.0
external-editor: 3.1.0
figures: 3.2.0
lodash: 4.17.21
mute-stream: 0.0.8
ora: 5.4.1
run-async: 2.4.1
rxjs: 7.8.2
string-width: 4.2.3
strip-ansi: 6.0.1
through: 2.3.8
wrap-ansi: 7.0.0
is-arrayish@0.2.1: {}
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-docker@2.2.1: {}
is-docker@3.0.0: {}
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
is-fullwidth-code-point@4.0.0: {}
is-fullwidth-code-point@5.1.0:
dependencies:
get-east-asian-width: 1.4.0
is-glob@4.0.3:
dependencies:
is-extglob: 2.1.1
is-hotkey@0.2.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-interactive@1.0.0: {}
is-number@7.0.0: {}
is-obj@2.0.0: {}
is-plain-obj@4.1.0: {}
is-plain-object@5.0.0: {}
is-stream@3.0.0: {}
is-stream@4.0.1: {}
is-text-path@2.0.0:
dependencies:
text-extensions: 2.4.0
is-unicode-supported@0.1.0: {}
is-unicode-supported@2.1.0: {}
is-url@1.2.4: {}
is-utf8@0.2.1: {}
is-what@4.1.16: {}
is-windows@1.0.2: {}
is-wsl@2.2.0:
dependencies:
is-docker: 2.2.1
is-wsl@3.1.0:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
jiti@2.6.0: {}
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
js-yaml@4.1.0:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
json-parse-even-better-errors@2.3.1: {}
json-schema-traverse@0.4.1: {}
json-schema-traverse@1.0.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
json5@2.2.3: {}
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
jsonparse@1.3.1: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
keyv@5.5.3:
dependencies:
'@keyv/serialize': 1.1.1
kind-of@6.0.3: {}
known-css-properties@0.36.0: {}
known-css-properties@0.37.0: {}
kolorist@1.8.0: {}
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
type-check: 0.4.0
lightningcss-darwin-arm64@1.30.1:
optional: true
lightningcss-darwin-x64@1.30.1:
optional: true
lightningcss-freebsd-x64@1.30.1:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.1:
optional: true
lightningcss-linux-arm64-gnu@1.30.1:
optional: true
lightningcss-linux-arm64-musl@1.30.1:
optional: true
lightningcss-linux-x64-gnu@1.30.1:
optional: true
lightningcss-linux-x64-musl@1.30.1:
optional: true
lightningcss-win32-arm64-msvc@1.30.1:
optional: true
lightningcss-win32-x64-msvc@1.30.1:
optional: true
lightningcss@1.30.1:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-darwin-arm64: 1.30.1
lightningcss-darwin-x64: 1.30.1
lightningcss-freebsd-x64: 1.30.1
lightningcss-linux-arm-gnueabihf: 1.30.1
lightningcss-linux-arm64-gnu: 1.30.1
lightningcss-linux-arm64-musl: 1.30.1
lightningcss-linux-x64-gnu: 1.30.1
lightningcss-linux-x64-musl: 1.30.1
lightningcss-win32-arm64-msvc: 1.30.1
lightningcss-win32-x64-msvc: 1.30.1
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
lint-staged@15.5.2:
dependencies:
chalk: 5.6.2
commander: 13.1.0
debug: 4.4.3
execa: 8.0.1
lilconfig: 3.1.3
listr2: 8.3.3
micromatch: 4.0.8
pidtree: 0.6.0
string-argv: 0.3.2
yaml: 2.8.1
transitivePeerDependencies:
- supports-color
listr2@8.3.3:
dependencies:
cli-truncate: 4.0.0
colorette: 2.0.20
eventemitter3: 5.0.1
log-update: 6.1.0
rfdc: 1.4.1
wrap-ansi: 9.0.2
local-pkg@1.1.2:
dependencies:
mlly: 1.8.0
pkg-types: 2.3.0
quansync: 0.2.11
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
locate-path@7.2.0:
dependencies:
p-locate: 6.0.0
lodash-es@4.17.21: {}
lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.17.21)(lodash@4.17.21):
dependencies:
'@types/lodash-es': 4.17.12
lodash: 4.17.21
lodash-es: 4.17.21
lodash.camelcase@4.3.0: {}
lodash.clonedeep@4.5.0: {}
lodash.debounce@4.0.8: {}
lodash.foreach@4.5.0: {}
lodash.isequal@4.5.0: {}
lodash.isplainobject@4.0.6: {}
lodash.kebabcase@4.1.1: {}
lodash.map@4.6.0: {}
lodash.merge@4.6.2: {}
lodash.mergewith@4.6.2: {}
lodash.snakecase@4.1.1: {}
lodash.startcase@4.4.0: {}
lodash.throttle@4.1.1: {}
lodash.toarray@4.4.0: {}
lodash.truncate@4.4.2: {}
lodash.uniq@4.5.0: {}
lodash.upperfirst@4.3.1: {}
lodash@4.17.21: {}
log-symbols@4.1.0:
dependencies:
chalk: 4.1.2
is-unicode-supported: 0.1.0
log-update@6.1.0:
dependencies:
ansi-escapes: 7.1.1
cli-cursor: 5.0.0
slice-ansi: 7.1.2
strip-ansi: 7.1.2
wrap-ansi: 9.0.2
longest@2.0.1: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
magic-string@0.30.19:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
math-intrinsics@1.1.0: {}
mathml-tag-names@2.1.3: {}
mdn-data@2.12.2: {}
mdn-data@2.24.0: {}
memoize-one@6.0.0: {}
meow@12.1.1: {}
meow@13.2.0: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
merge@2.1.1: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-match@1.0.2:
dependencies:
wildcard: 1.1.2
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mimic-fn@2.1.0: {}
mimic-fn@4.0.0: {}
mimic-function@5.0.1: {}
minimatch@3.1.2:
dependencies:
brace-expansion: 1.1.12
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.2
minimist@1.2.7: {}
minimist@1.2.8: {}
minipass@7.1.2: {}
minizlib@3.1.0:
dependencies:
minipass: 7.1.2
mitt@3.0.1: {}
mlly@1.8.0:
dependencies:
acorn: 8.15.0
pathe: 2.0.3
pkg-types: 1.3.1
ufo: 1.6.1
mrmime@2.0.1: {}
ms@2.1.3: {}
muggle-string@0.4.1: {}
mute-stream@0.0.8: {}
namespace-emitter@2.0.1: {}
nanoid@3.3.11: {}
nanoid@5.1.6: {}
natural-compare@1.4.0: {}
next-tick@1.1.0: {}
node-addon-api@7.1.1:
optional: true
node-releases@2.0.21: {}
normalize-path@3.0.0: {}
normalize-wheel-es@1.2.0: {}
npm-run-path@5.3.0:
dependencies:
path-key: 4.0.0
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
unicorn-magic: 0.3.0
nprogress@0.2.0: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
onetime@5.1.2:
dependencies:
mimic-fn: 2.1.0
onetime@6.0.0:
dependencies:
mimic-fn: 4.0.0
onetime@7.0.0:
dependencies:
mimic-function: 5.0.1
open@10.2.0:
dependencies:
default-browser: 5.2.1
define-lazy-prop: 3.0.0
is-inside-container: 1.0.0
wsl-utils: 0.1.0
open@8.4.2:
dependencies:
define-lazy-prop: 2.0.0
is-docker: 2.2.1
is-wsl: 2.2.0
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
fast-levenshtein: 2.0.6
levn: 0.4.1
prelude-ls: 1.2.1
type-check: 0.4.0
word-wrap: 1.2.5
ora@5.4.1:
dependencies:
bl: 4.1.0
chalk: 4.1.2
cli-cursor: 3.1.0
cli-spinners: 2.9.2
is-interactive: 1.0.0
is-unicode-supported: 0.1.0
log-symbols: 4.1.0
strip-ansi: 6.0.1
wcwidth: 1.0.1
os-tmpdir@1.0.2: {}
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-limit@4.0.0:
dependencies:
yocto-queue: 1.2.1
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-locate@6.0.0:
dependencies:
p-limit: 4.0.0
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.27.1
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4
parse-ms@4.0.0: {}
parse-passwd@1.0.0: {}
path-browserify@1.0.1: {}
path-exists@4.0.0: {}
path-exists@5.0.0: {}
path-is-absolute@1.0.1: {}
path-key@3.1.1: {}
path-key@4.0.0: {}
path-type@4.0.0: {}
pathe@2.0.3: {}
perfect-debounce@1.0.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
picomatch@4.0.3: {}
pidtree@0.6.0: {}
pinia-plugin-persistedstate@4.5.0(pinia@3.0.3(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))):
dependencies:
deep-pick-omit: 1.2.1
defu: 6.1.4
destr: 2.0.5
optionalDependencies:
pinia: 3.0.3(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3))
pinia@3.0.3(typescript@5.6.3)(vue@3.5.22(typescript@5.6.3)):
dependencies:
'@vue/devtools-api': 7.7.7
vue: 3.5.22(typescript@5.6.3)
optionalDependencies:
typescript: 5.6.3
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
mlly: 1.8.0
pathe: 2.0.3
pkg-types@2.3.0:
dependencies:
confbox: 0.2.2
exsolve: 1.0.7
pathe: 2.0.3
postcss-html@1.8.0:
dependencies:
htmlparser2: 8.0.2
js-tokens: 9.0.1
postcss: 8.5.6
postcss-safe-parser: 6.0.0(postcss@8.5.6)
postcss-media-query-parser@0.2.3: {}
postcss-resolve-nested-selector@0.1.6: {}
postcss-safe-parser@6.0.0(postcss@8.5.6):
dependencies:
postcss: 8.5.6
postcss-safe-parser@7.0.1(postcss@8.5.6):
dependencies:
postcss: 8.5.6
postcss-scss@4.0.9(postcss@8.5.6):
dependencies:
postcss: 8.5.6
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-selector-parser@7.1.0:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-sorting@8.0.2(postcss@8.5.6):
dependencies:
postcss: 8.5.6
postcss-value-parser@4.2.0: {}
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
picocolors: 1.1.1
source-map-js: 1.2.1
preact@10.27.2: {}
prelude-ls@1.2.1: {}
prettier-linter-helpers@1.0.0:
dependencies:
fast-diff: 1.3.0
prettier@3.6.2: {}
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
prismjs@1.30.0: {}
proxy-from-env@1.1.0: {}
punycode@2.3.1: {}
qrcode.vue@3.6.0(vue@3.5.22(typescript@5.6.3)):
dependencies:
vue: 3.5.22(typescript@5.6.3)
quansync@0.2.11: {}
queue-microtask@1.2.3: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
readdirp@4.1.2: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
resolve-dir@1.0.1:
dependencies:
expand-tilde: 2.0.2
global-modules: 1.0.0
resolve-from@4.0.0: {}
resolve-from@5.0.0: {}
resolve-pkg-maps@1.0.0: {}
restore-cursor@3.1.0:
dependencies:
onetime: 5.1.2
signal-exit: 3.0.7
restore-cursor@5.1.0:
dependencies:
onetime: 7.0.0
signal-exit: 4.1.0
reusify@1.1.0: {}
rfdc@1.4.1: {}
rollup-plugin-visualizer@5.14.0(rollup@4.52.3):
dependencies:
open: 8.4.2
picomatch: 4.0.3
source-map: 0.7.6
yargs: 17.7.2
optionalDependencies:
rollup: 4.52.3
rollup@4.52.3:
dependencies:
'@types/estree': 1.0.8
optionalDependencies:
'@rollup/rollup-android-arm-eabi': 4.52.3
'@rollup/rollup-android-arm64': 4.52.3
'@rollup/rollup-darwin-arm64': 4.52.3
'@rollup/rollup-darwin-x64': 4.52.3
'@rollup/rollup-freebsd-arm64': 4.52.3
'@rollup/rollup-freebsd-x64': 4.52.3
'@rollup/rollup-linux-arm-gnueabihf': 4.52.3
'@rollup/rollup-linux-arm-musleabihf': 4.52.3
'@rollup/rollup-linux-arm64-gnu': 4.52.3
'@rollup/rollup-linux-arm64-musl': 4.52.3
'@rollup/rollup-linux-loong64-gnu': 4.52.3
'@rollup/rollup-linux-ppc64-gnu': 4.52.3
'@rollup/rollup-linux-riscv64-gnu': 4.52.3
'@rollup/rollup-linux-riscv64-musl': 4.52.3
'@rollup/rollup-linux-s390x-gnu': 4.52.3
'@rollup/rollup-linux-x64-gnu': 4.52.3
'@rollup/rollup-linux-x64-musl': 4.52.3
'@rollup/rollup-openharmony-arm64': 4.52.3
'@rollup/rollup-win32-arm64-msvc': 4.52.3
'@rollup/rollup-win32-ia32-msvc': 4.52.3
'@rollup/rollup-win32-x64-gnu': 4.52.3
'@rollup/rollup-win32-x64-msvc': 4.52.3
fsevents: 2.3.3
run-applescript@7.1.0: {}
run-async@2.4.1: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
rxjs@7.8.2:
dependencies:
tslib: 2.8.1
safe-buffer@5.2.1: {}
safer-buffer@2.1.2: {}
sass@1.93.2:
dependencies:
chokidar: 4.0.3
immutable: 5.1.3
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.1
scroll-into-view-if-needed@2.2.31:
dependencies:
compute-scroll-into-view: 1.0.20
scule@1.3.0: {}
semver@6.3.1: {}
semver@7.7.2: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
slash@3.0.0: {}
slate-history@0.66.0(slate@0.72.8):
dependencies:
is-plain-object: 5.0.0
slate: 0.72.8
slate@0.72.8:
dependencies:
immer: 9.0.21
is-plain-object: 5.0.0
tiny-warning: 1.0.3
slice-ansi@4.0.0:
dependencies:
ansi-styles: 4.3.0
astral-regex: 2.0.0
is-fullwidth-code-point: 3.0.0
slice-ansi@5.0.0:
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 4.0.0
slice-ansi@7.1.2:
dependencies:
ansi-styles: 6.2.3
is-fullwidth-code-point: 5.1.0
snabbdom@3.6.2: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21:
dependencies:
buffer-from: 1.1.2
source-map: 0.6.1
source-map@0.6.1: {}
source-map@0.7.6: {}
spark-md5@3.0.2: {}
speakingurl@14.0.1: {}
split2@4.2.0: {}
ssf@0.11.2:
dependencies:
frac: 1.1.2
ssr-window@3.0.0: {}
string-argv@0.3.2: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string-width@7.2.0:
dependencies:
emoji-regex: 10.5.0
get-east-asian-width: 1.4.0
strip-ansi: 7.1.2
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.2.2
strip-bom@4.0.0: {}
strip-final-newline@3.0.0: {}
strip-final-newline@4.0.0: {}
strip-json-comments@3.1.1: {}
strip-literal@3.1.0:
dependencies:
js-tokens: 9.0.1
stylelint-config-html@1.1.0(postcss-html@1.8.0)(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
postcss-html: 1.8.0
stylelint: 16.24.0(typescript@5.6.3)
stylelint-config-recess-order@4.6.0(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
stylelint: 16.24.0(typescript@5.6.3)
stylelint-order: 6.0.4(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-recommended-scss@14.1.0(postcss@8.5.6)(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
postcss-scss: 4.0.9(postcss@8.5.6)
stylelint: 16.24.0(typescript@5.6.3)
stylelint-config-recommended: 14.0.1(stylelint@16.24.0(typescript@5.6.3))
stylelint-scss: 6.12.1(stylelint@16.24.0(typescript@5.6.3))
optionalDependencies:
postcss: 8.5.6
stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.0)(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
postcss-html: 1.8.0
semver: 7.7.2
stylelint: 16.24.0(typescript@5.6.3)
stylelint-config-html: 1.1.0(postcss-html@1.8.0)(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-recommended: 17.0.0(stylelint@16.24.0(typescript@5.6.3))
stylelint-config-recommended@14.0.1(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
stylelint: 16.24.0(typescript@5.6.3)
stylelint-config-recommended@17.0.0(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
stylelint: 16.24.0(typescript@5.6.3)
stylelint-config-standard@36.0.1(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
stylelint: 16.24.0(typescript@5.6.3)
stylelint-config-recommended: 14.0.1(stylelint@16.24.0(typescript@5.6.3))
stylelint-order@6.0.4(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
postcss: 8.5.6
postcss-sorting: 8.0.2(postcss@8.5.6)
stylelint: 16.24.0(typescript@5.6.3)
stylelint-scss@6.12.1(stylelint@16.24.0(typescript@5.6.3)):
dependencies:
css-tree: 3.1.0
is-plain-object: 5.0.0
known-css-properties: 0.36.0
mdn-data: 2.24.0
postcss-media-query-parser: 0.2.3
postcss-resolve-nested-selector: 0.1.6
postcss-selector-parser: 7.1.0
postcss-value-parser: 4.2.0
stylelint: 16.24.0(typescript@5.6.3)
stylelint@16.24.0(typescript@5.6.3):
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.0)
'@dual-bundle/import-meta-resolve': 4.2.1
balanced-match: 2.0.0
colord: 2.9.3
cosmiconfig: 9.0.0(typescript@5.6.3)
css-functions-list: 3.2.3
css-tree: 3.1.0
debug: 4.4.3
fast-glob: 3.3.3
fastest-levenshtein: 1.0.16
file-entry-cache: 10.1.4
global-modules: 2.0.0
globby: 11.1.0
globjoin: 0.1.4
html-tags: 3.3.1
ignore: 7.0.5
imurmurhash: 0.1.4
is-plain-object: 5.0.0
known-css-properties: 0.37.0
mathml-tag-names: 2.1.3
meow: 13.2.0
micromatch: 4.0.8
normalize-path: 3.0.0
picocolors: 1.1.1
postcss: 8.5.6
postcss-resolve-nested-selector: 0.1.6
postcss-safe-parser: 7.0.1(postcss@8.5.6)
postcss-selector-parser: 7.1.0
postcss-value-parser: 4.2.0
resolve-from: 5.0.0
string-width: 4.2.3
supports-hyperlinks: 3.2.0
svg-tags: 1.0.0
table: 6.9.0
write-file-atomic: 5.0.1
transitivePeerDependencies:
- supports-color
- typescript
superjson@2.2.2:
dependencies:
copy-anything: 3.0.5
supports-color@5.5.0:
dependencies:
has-flag: 3.0.0
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
supports-hyperlinks@3.2.0:
dependencies:
has-flag: 4.0.0
supports-color: 7.2.0
svg-tags@1.0.0: {}
synckit@0.11.11:
dependencies:
'@pkgr/core': 0.2.9
table@6.9.0:
dependencies:
ajv: 8.17.1
lodash.truncate: 4.4.2
slice-ansi: 4.0.0
string-width: 4.2.3
strip-ansi: 6.0.1
tailwindcss@4.1.14: {}
tapable@2.3.0: {}
tar@7.5.1:
dependencies:
'@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0
minipass: 7.1.2
minizlib: 3.1.0
yallist: 5.0.0
terser@5.44.0:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
text-extensions@2.4.0: {}
through@2.3.8: {}
tiny-warning@1.0.3: {}
tinyexec@1.0.1: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tmp@0.0.33:
dependencies:
os-tmpdir: 1.0.2
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
totalist@3.0.1: {}
ts-api-utils@2.1.0(typescript@5.6.3):
dependencies:
typescript: 5.6.3
tslib@2.3.0: {}
tslib@2.8.1: {}
tsx@4.20.6:
dependencies:
esbuild: 0.25.10
get-tsconfig: 4.10.1
optionalDependencies:
fsevents: 2.3.3
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
type-fest@0.20.2: {}
type-fest@0.21.3: {}
type@2.7.3: {}
typescript-eslint@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3):
dependencies:
'@typescript-eslint/eslint-plugin': 8.44.1(@typescript-eslint/parser@8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3))(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@typescript-eslint/parser': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
'@typescript-eslint/typescript-estree': 8.44.1(typescript@5.6.3)
'@typescript-eslint/utils': 8.44.1(eslint@9.36.0(jiti@2.6.0))(typescript@5.6.3)
eslint: 9.36.0(jiti@2.6.0)
typescript: 5.6.3
transitivePeerDependencies:
- supports-color
typescript@5.6.3: {}
ufo@1.6.1: {}
undici-types@7.14.0: {}
unicorn-magic@0.1.0: {}
unicorn-magic@0.3.0: {}
unimport@5.4.0:
dependencies:
acorn: 8.15.0
escape-string-regexp: 5.0.0
estree-walker: 3.0.3
local-pkg: 1.1.2
magic-string: 0.30.19
mlly: 1.8.0
pathe: 2.0.3
picomatch: 4.0.3
pkg-types: 2.3.0
scule: 1.3.0
strip-literal: 3.1.0
tinyglobby: 0.2.15
unplugin: 2.3.10
unplugin-utils: 0.3.0
universalify@2.0.1: {}
unplugin-auto-import@20.2.0(@vueuse/core@13.9.0(vue@3.5.22(typescript@5.6.3))):
dependencies:
local-pkg: 1.1.2
magic-string: 0.30.19
picomatch: 4.0.3
unimport: 5.4.0
unplugin: 2.3.10
unplugin-utils: 0.3.0
optionalDependencies:
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.6.3))
unplugin-element-plus@0.10.0:
dependencies:
es-module-lexer: 1.7.0
magic-string: 0.30.19
unplugin: 2.3.10
unplugin-utils: 0.2.5
unplugin-utils@0.2.5:
dependencies:
pathe: 2.0.3
picomatch: 4.0.3
unplugin-utils@0.3.0:
dependencies:
pathe: 2.0.3
picomatch: 4.0.3
unplugin-vue-components@29.1.0(@babel/parser@7.28.4)(vue@3.5.22(typescript@5.6.3)):
dependencies:
chokidar: 3.6.0
debug: 4.4.3
local-pkg: 1.1.2
magic-string: 0.30.19
mlly: 1.8.0
tinyglobby: 0.2.15
unplugin: 2.3.10
unplugin-utils: 0.3.0
vue: 3.5.22(typescript@5.6.3)
optionalDependencies:
'@babel/parser': 7.28.4
transitivePeerDependencies:
- supports-color
unplugin@2.3.10:
dependencies:
'@jridgewell/remapping': 2.3.5
acorn: 8.15.0
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
update-browserslist-db@1.1.3(browserslist@4.26.2):
dependencies:
browserslist: 4.26.2
escalade: 3.2.0
picocolors: 1.1.1
uri-js@4.4.1:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
vite-hot-client@2.1.0(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
vite-plugin-compression@0.5.1(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
chalk: 4.1.2
debug: 4.4.3
fs-extra: 10.1.0
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
vite-plugin-inspect@0.8.9(rollup@4.52.3)(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@antfu/utils': 0.7.10
'@rollup/pluginutils': 5.3.0(rollup@4.52.3)
debug: 4.4.3
error-stack-parser-es: 0.1.5
fs-extra: 11.3.2
open: 10.2.0
perfect-debounce: 1.0.0
picocolors: 1.1.1
sirv: 3.0.2
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
- rollup
- supports-color
vite-plugin-vue-devtools@7.7.7(rollup@4.52.3)(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3)):
dependencies:
'@vue/devtools-core': 7.7.7(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.22(typescript@5.6.3))
'@vue/devtools-kit': 7.7.7
'@vue/devtools-shared': 7.7.7
execa: 9.6.0
sirv: 3.0.2
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
vite-plugin-inspect: 0.8.9(rollup@4.52.3)(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
vite-plugin-vue-inspector: 5.3.2(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1))
transitivePeerDependencies:
- '@nuxt/kit'
- rollup
- supports-color
- vue
vite-plugin-vue-inspector@5.3.2(vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)):
dependencies:
'@babel/core': 7.28.4
'@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.28.4)
'@babel/plugin-syntax-import-attributes': 7.27.1(@babel/core@7.28.4)
'@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.28.4)
'@babel/plugin-transform-typescript': 7.28.0(@babel/core@7.28.4)
'@vue/babel-plugin-jsx': 1.5.0(@babel/core@7.28.4)
'@vue/compiler-dom': 3.5.22
kolorist: 1.8.0
magic-string: 0.30.19
vite: 7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1)
transitivePeerDependencies:
- supports-color
vite@7.1.7(@types/node@24.8.1)(jiti@2.6.0)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.44.0)(tsx@4.20.6)(yaml@2.8.1):
dependencies:
esbuild: 0.25.10
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.52.3
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 24.8.1
fsevents: 2.3.3
jiti: 2.6.0
lightningcss: 1.30.1
sass: 1.93.2
terser: 5.44.0
tsx: 4.20.6
yaml: 2.8.1
vscode-uri@3.1.0: {}
vue-demi@0.14.10(vue@3.5.22(typescript@5.6.3)):
dependencies:
vue: 3.5.22(typescript@5.6.3)
vue-draggable-plus@0.6.0(@types/sortablejs@1.15.8):
dependencies:
'@types/sortablejs': 1.15.8
vue-eslint-parser@9.4.3(eslint@9.36.0(jiti@2.6.0)):
dependencies:
debug: 4.4.3
eslint: 9.36.0(jiti@2.6.0)
eslint-scope: 7.2.2
eslint-visitor-keys: 3.4.3
espree: 9.6.1
esquery: 1.6.0
lodash: 4.17.21
semver: 7.7.2
transitivePeerDependencies:
- supports-color
vue-i18n@9.14.5(vue@3.5.22(typescript@5.6.3)):
dependencies:
'@intlify/core-base': 9.14.5
'@intlify/shared': 9.14.5
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.6.3)
vue-img-cutter@3.0.7(typescript@5.6.3):
dependencies:
core-js: 3.45.1
vue: 3.5.22(typescript@5.6.3)
vue-i18n: 9.14.5(vue@3.5.22(typescript@5.6.3))
transitivePeerDependencies:
- typescript
vue-router@4.5.1(vue@3.5.22(typescript@5.6.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.22(typescript@5.6.3)
vue-tsc@2.1.10(typescript@5.6.3):
dependencies:
'@volar/typescript': 2.4.23
'@vue/language-core': 2.1.10(typescript@5.6.3)
semver: 7.7.2
typescript: 5.6.3
vue@3.5.22(typescript@5.6.3):
dependencies:
'@vue/compiler-dom': 3.5.22
'@vue/compiler-sfc': 3.5.22
'@vue/runtime-dom': 3.5.22
'@vue/server-renderer': 3.5.22(vue@3.5.22(typescript@5.6.3))
'@vue/shared': 3.5.22
optionalDependencies:
typescript: 5.6.3
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4
webpack-virtual-modules@0.6.2: {}
which@1.3.1:
dependencies:
isexe: 2.0.0
which@2.0.2:
dependencies:
isexe: 2.0.0
wildcard@1.1.2: {}
wmf@1.0.2: {}
word-wrap@1.2.5: {}
word@0.3.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.1.2
wrappy@1.0.2: {}
write-file-atomic@5.0.1:
dependencies:
imurmurhash: 0.1.4
signal-exit: 4.1.0
wsl-utils@0.1.0:
dependencies:
is-wsl: 3.1.0
xgplayer-subtitles@3.0.23(core-js@3.45.1):
dependencies:
core-js: 3.45.1
eventemitter3: 4.0.7
xgplayer@3.0.23(core-js@3.45.1):
dependencies:
core-js: 3.45.1
danmu.js: 1.1.13
delegate: 3.2.0
downloadjs: 1.4.7
eventemitter3: 4.0.7
xgplayer-subtitles: 3.0.23(core-js@3.45.1)
xlsx@0.18.5:
dependencies:
adler-32: 1.3.1
cfb: 1.2.2
codepage: 1.15.0
crc-32: 1.2.2
ssf: 0.11.2
wmf: 1.0.2
word: 0.3.0
xml-name-validator@4.0.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yallist@5.0.0: {}
yaml@2.8.1: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
yocto-queue@1.2.1: {}
yoctocolors@2.1.2: {}
zrender@6.0.0:
dependencies:
tslib: 2.3.0

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,838 @@
// scripts/clean-dev.ts
import fs from 'fs/promises'
import path from 'path'
// 现代化颜色主题
const theme = {
// 基础颜色
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
// 前景色
primary: '\x1b[38;5;75m', // 亮蓝色
success: '\x1b[38;5;82m', // 亮绿色
warning: '\x1b[38;5;220m', // 亮黄色
error: '\x1b[38;5;196m', // 亮红色
info: '\x1b[38;5;159m', // 青色
purple: '\x1b[38;5;141m', // 紫色
orange: '\x1b[38;5;208m', // 橙色
gray: '\x1b[38;5;245m', // 灰色
white: '\x1b[38;5;255m', // 白色
// 背景色
bgDark: '\x1b[48;5;235m', // 深灰背景
bgBlue: '\x1b[48;5;24m', // 蓝色背景
bgGreen: '\x1b[48;5;22m', // 绿色背景
bgRed: '\x1b[48;5;52m' // 红色背景
}
// 现代化图标集
const icons = {
rocket: '🚀',
fire: '🔥',
star: '⭐',
gem: '💎',
crown: '👑',
magic: '✨',
warning: '⚠️',
success: '✅',
error: '❌',
info: '',
folder: '📁',
file: '📄',
image: '🖼️',
code: '💻',
data: '📊',
globe: '🌐',
map: '🗺️',
chat: '💬',
bolt: '⚡',
shield: '🛡️',
key: '🔑',
link: '🔗',
clean: '🧹',
trash: '🗑️',
check: '✓',
cross: '✗',
arrow: '→',
loading: '⏳'
}
// 格式化工具
const fmt = {
title: (text: string) => `${theme.bold}${theme.primary}${text}${theme.reset}`,
subtitle: (text: string) => `${theme.purple}${text}${theme.reset}`,
success: (text: string) => `${theme.success}${text}${theme.reset}`,
error: (text: string) => `${theme.error}${text}${theme.reset}`,
warning: (text: string) => `${theme.warning}${text}${theme.reset}`,
info: (text: string) => `${theme.info}${text}${theme.reset}`,
highlight: (text: string) => `${theme.bold}${theme.white}${text}${theme.reset}`,
dim: (text: string) => `${theme.dim}${theme.gray}${text}${theme.reset}`,
orange: (text: string) => `${theme.orange}${text}${theme.reset}`,
// 带背景的文本
badge: (text: string, bg: string = theme.bgBlue) =>
`${bg}${theme.white}${theme.bold} ${text} ${theme.reset}`,
// 渐变效果模拟
gradient: (text: string) => {
const colors = ['\x1b[38;5;75m', '\x1b[38;5;81m', '\x1b[38;5;87m', '\x1b[38;5;159m']
const chars = text.split('')
return chars.map((char, i) => `${colors[i % colors.length]}${char}`).join('') + theme.reset
}
}
// 创建现代化标题横幅
function createModernBanner() {
console.log()
console.log(
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
`${icons.rocket} ${fmt.title('ART DESIGN PRO')} ${fmt.subtitle('· 代码精简程序')} ${icons.magic}`
)
console.log(
`${fmt.dim('为项目移除演示数据,快速切换至开发模式')}`
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
)
console.log()
}
// 创建分割线
function createDivider(char = '─', color = theme.primary) {
console.log(`${color}${' ' + char.repeat(66)}${theme.reset}`)
}
// 创建卡片样式容器
function createCard(title: string, content: string[]) {
console.log(` ${fmt.badge('', theme.bgBlue)} ${fmt.title(title)}`)
console.log()
content.forEach((line) => {
console.log(` ${line}`)
})
console.log()
}
// 进度条动画
function createProgressBar(current: number, total: number, text: string, width = 40) {
const percentage = Math.round((current / total) * 100)
const filled = Math.round((current / total) * width)
const empty = width - filled
const filledBar = '█'.repeat(filled)
const emptyBar = '░'.repeat(empty)
process.stdout.write(
`\r ${fmt.info('进度')} [${theme.success}${filledBar}${theme.gray}${emptyBar}${theme.reset}] ${fmt.highlight(percentage + '%')})}`
)
if (current === total) {
console.log()
}
}
// 统计信息
const stats = {
deletedFiles: 0,
deletedPaths: 0,
failedPaths: 0,
startTime: Date.now(),
totalFiles: 0
}
// 清理目标
const targets = [
'README.md',
'README.zh-CN.md',
'CHANGELOG.md',
'CHANGELOG.zh-CN.md',
'src/views/change',
'src/views/safeguard',
'src/views/article',
'src/views/examples',
'src/views/system/nested',
'src/views/widgets',
'src/views/template',
'src/views/dashboard/analysis',
'src/views/dashboard/ecommerce',
'src/mock/json',
'src/mock/temp/articleList.ts',
'src/mock/temp/commentDetail.ts',
'src/mock/temp/commentList.ts',
'src/assets/images/cover',
'src/assets/images/safeguard',
'src/assets/images/3d',
'src/components/core/charts/art-map-chart',
'src/components/business/comment-widget'
]
// 递归统计文件数量
async function countFiles(targetPath: string): Promise<number> {
const fullPath = path.resolve(process.cwd(), targetPath)
try {
const stat = await fs.stat(fullPath)
if (stat.isFile()) {
return 1
} else if (stat.isDirectory()) {
const entries = await fs.readdir(fullPath)
let count = 0
for (const entry of entries) {
const entryPath = path.join(targetPath, entry)
count += await countFiles(entryPath)
}
return count
}
} catch {
return 0
}
return 0
}
// 统计所有目标的文件数量
async function countAllFiles(): Promise<number> {
let totalCount = 0
for (const target of targets) {
const count = await countFiles(target)
totalCount += count
}
return totalCount
}
// 删除文件和目录
async function remove(targetPath: string, index: number) {
const fullPath = path.resolve(process.cwd(), targetPath)
createProgressBar(index + 1, targets.length, targetPath)
try {
const fileCount = await countFiles(targetPath)
await fs.rm(fullPath, { recursive: true, force: true })
stats.deletedFiles += fileCount
stats.deletedPaths++
await new Promise((resolve) => setTimeout(resolve, 50))
} catch (err) {
stats.failedPaths++
console.log()
console.log(` ${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(targetPath)}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理路由模块
async function cleanRouteModules() {
const modulesPath = path.resolve(process.cwd(), 'src/router/modules')
try {
// 删除演示相关的路由模块
const modulesToRemove = [
'template.ts',
'widgets.ts',
'examples.ts',
'article.ts',
'safeguard.ts',
'help.ts'
]
for (const module of modulesToRemove) {
const modulePath = path.join(modulesPath, module)
try {
await fs.rm(modulePath, { force: true })
} catch {
// 文件不存在时忽略错误
}
}
// 重写 dashboard.ts - 只保留 console
const dashboardContent = `import { AppRouteRecord } from '@/types/router'
export const dashboardRoutes: AppRouteRecord = {
name: 'Dashboard',
path: '/dashboard',
component: '/index/index',
meta: {
title: 'menus.dashboard.title',
icon: 'ri:pie-chart-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'console',
name: 'Console',
component: '/dashboard/console',
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
}
]
}
`
await fs.writeFile(path.join(modulesPath, 'dashboard.ts'), dashboardContent, 'utf-8')
// 重写 system.ts - 移除 nested 嵌套菜单
const systemContent = `import { AppRouteRecord } from '@/types/router'
export const systemRoutes: AppRouteRecord = {
path: '/system',
name: 'System',
component: '/index/index',
meta: {
title: 'menus.system.title',
icon: 'ri:user-3-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: '/system/user',
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: '/system/role',
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center',
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: '/system/menu',
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{ title: '新增', authMark: 'add' },
{ title: '编辑', authMark: 'edit' },
{ title: '删除', authMark: 'delete' }
]
}
}
]
}
`
await fs.writeFile(path.join(modulesPath, 'system.ts'), systemContent, 'utf-8')
// 重写 index.ts - 只导入保留的模块
const indexContent = `import { AppRouteRecord } from '@/types/router'
import { dashboardRoutes } from './dashboard'
import { systemRoutes } from './system'
import { resultRoutes } from './result'
import { exceptionRoutes } from './exception'
/**
* 导出所有模块化路由
*/
export const routeModules: AppRouteRecord[] = [
dashboardRoutes,
systemRoutes,
resultRoutes,
exceptionRoutes
]
`
await fs.writeFile(path.join(modulesPath, 'index.ts'), indexContent, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清理路由模块完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理路由模块失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理路由别名
async function cleanRoutesAlias() {
const routesAliasPath = path.resolve(process.cwd(), 'src/router/routesAlias.ts')
try {
const cleanedAlias = `/**
* 公共路由别名
# 存放系统级公共路由路径,如布局容器、登录页等
*/
export enum RoutesAlias {
Layout = '/index/index', // 布局容器
Login = '/auth/login' // 登录页
}
`
await fs.writeFile(routesAliasPath, cleanedAlias, 'utf-8')
console.log(` ${icons.success} ${fmt.success('重写路由别名配置完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理路由别名失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理变更日志
async function cleanChangeLog() {
const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts')
try {
const cleanedChangeLog = `import { ref } from 'vue'
interface UpgradeLog {
version: string // 版本号
title: string // 更新标题
date: string // 更新日期
detail?: string[] // 更新内容
requireReLogin?: boolean // 是否需要重新登录
remark?: string // 备注
}
export const upgradeLogList = ref<UpgradeLog[]>([])
`
await fs.writeFile(changeLogPath, cleanedChangeLog, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清空变更日志数据完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理变更日志失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理语言文件
async function cleanLanguageFiles() {
const languageFiles = [
{ path: 'src/locales/langs/zh.json', name: '中文语言文件' },
{ path: 'src/locales/langs/en.json', name: '英文语言文件' }
]
for (const { path: langPath, name } of languageFiles) {
try {
const fullPath = path.resolve(process.cwd(), langPath)
const content = await fs.readFile(fullPath, 'utf-8')
const langData = JSON.parse(content)
const menusToRemove = [
'widgets',
'template',
'article',
'examples',
'safeguard',
'plan',
'help'
]
if (langData.menus) {
menusToRemove.forEach((menuKey) => {
if (langData.menus[menuKey]) {
delete langData.menus[menuKey]
}
})
if (langData.menus.dashboard) {
if (langData.menus.dashboard.analysis) {
delete langData.menus.dashboard.analysis
}
if (langData.menus.dashboard.ecommerce) {
delete langData.menus.dashboard.ecommerce
}
}
if (langData.menus.system) {
const systemKeysToRemove = [
'nested',
'menu1',
'menu2',
'menu21',
'menu3',
'menu31',
'menu32',
'menu321'
]
systemKeysToRemove.forEach((key) => {
if (langData.menus.system[key]) {
delete langData.menus.system[key]
}
})
}
}
await fs.writeFile(fullPath, JSON.stringify(langData, null, 2), 'utf-8')
console.log(` ${icons.success} ${fmt.success(`清理${name}完成`)}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error(`清理${name}失败`)}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
}
// 清理快速入口组件
async function cleanFastEnterComponent() {
const fastEnterPath = path.resolve(process.cwd(), 'src/config/fastEnter.ts')
try {
const cleanedFastEnter = `/**
* 快速入口配置
* 包含:应用列表、快速链接等配置
*/
import { WEB_LINKS } from '@/utils/constants'
import type { FastEnterConfig } from '@/types/config'
const fastEnterConfig: FastEnterConfig = {
// 显示条件(屏幕宽度)
minWidth: 1200,
// 应用列表
applications: [
{
name: '工作台',
description: '系统概览与数据统计',
icon: 'ri:pie-chart-line',
iconColor: '#377dff',
enabled: true,
order: 1,
routeName: 'Console'
},
{
name: '官方文档',
description: '使用指南与开发文档',
icon: 'ri:bill-line',
iconColor: '#ffb100',
enabled: true,
order: 2,
link: WEB_LINKS.DOCS
},
{
name: '技术支持',
description: '技术支持与问题反馈',
icon: 'ri:user-location-line',
iconColor: '#ff6b6b',
enabled: true,
order: 3,
link: WEB_LINKS.COMMUNITY
},
{
name: '哔哩哔哩',
description: '技术分享与交流',
icon: 'ri:bilibili-line',
iconColor: '#FB7299',
enabled: true,
order: 4,
link: WEB_LINKS.BILIBILI
}
],
// 快速链接
quickLinks: [
{
name: '登录',
enabled: true,
order: 1,
routeName: 'Login'
},
{
name: '注册',
enabled: true,
order: 2,
routeName: 'Register'
},
{
name: '忘记密码',
enabled: true,
order: 3,
routeName: 'ForgetPassword'
},
{
name: '个人中心',
enabled: true,
order: 4,
routeName: 'UserCenter'
}
]
}
export default Object.freeze(fastEnterConfig)
`
await fs.writeFile(fastEnterPath, cleanedFastEnter, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清理快速入口配置完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理快速入口配置失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 更新菜单接口
async function updateMenuApi() {
const apiPath = path.resolve(process.cwd(), 'src/api/system-manage.ts')
try {
const content = await fs.readFile(apiPath, 'utf-8')
const updatedContent = content.replace(
"url: '/api/v3/system/menus'",
"url: '/api/v3/system/menus/simple'"
)
await fs.writeFile(apiPath, updatedContent, 'utf-8')
console.log(` ${icons.success} ${fmt.success('更新菜单接口完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('更新菜单接口失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 用户确认函数
async function getUserConfirmation(): Promise<boolean> {
const { createInterface } = await import('readline')
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout
})
console.log(
` ${fmt.highlight('请输入')} ${fmt.success('yes')} ${fmt.highlight('确认执行清理操作,或按 Enter 取消')}`
)
console.log()
process.stdout.write(` ${icons.arrow} `)
rl.question('', (answer: string) => {
rl.close()
resolve(answer.toLowerCase().trim() === 'yes')
})
})
}
// 显示清理警告
async function showCleanupWarning() {
createCard('安全警告', [
`${fmt.warning('此操作将永久删除以下演示内容,且无法恢复!')}`,
`${fmt.dim('请仔细阅读清理列表,确认后再继续操作')}`
])
const cleanupItems = [
{
icon: icons.image,
name: '图片资源',
desc: '演示用的封面图片、3D图片、运维图片等',
color: theme.orange
},
{
icon: icons.file,
name: '演示页面',
desc: 'widgets、template、article、examples、safeguard等页面',
color: theme.purple
},
{
icon: icons.code,
name: '路由模块文件',
desc: '删除演示路由模块只保留核心模块dashboard、system、result、exception',
color: theme.primary
},
{
icon: icons.link,
name: '路由别名',
desc: '重写routesAlias.ts移除演示路由别名',
color: theme.info
},
{
icon: icons.data,
name: 'Mock数据',
desc: '演示用的JSON数据、文章列表、评论数据等',
color: theme.success
},
{
icon: icons.globe,
name: '多语言文件',
desc: '清理中英文语言包中的演示菜单项',
color: theme.warning
},
{ icon: icons.map, name: '地图组件', desc: '移除art-map-chart地图组件', color: theme.error },
{ icon: icons.chat, name: '评论组件', desc: '移除comment-widget评论组件', color: theme.orange },
{
icon: icons.bolt,
name: '快速入口',
desc: '移除分析页、礼花效果、聊天、更新日志、定价、留言管理等无效项目',
color: theme.purple
}
]
console.log(` ${fmt.badge('', theme.bgRed)} ${fmt.title('将要清理的内容')}`)
console.log()
cleanupItems.forEach((item, index) => {
console.log(` ${item.color}${theme.reset} ${fmt.highlight(`${index + 1}. ${item.name}`)}`)
console.log(` ${fmt.dim(item.desc)}`)
})
console.log()
console.log(` ${fmt.badge('', theme.bgGreen)} ${fmt.title('保留的功能模块')}`)
console.log()
const preservedModules = [
{ name: 'Dashboard', desc: '工作台页面' },
{ name: 'System', desc: '系统管理模块' },
{ name: 'Result', desc: '结果页面' },
{ name: 'Exception', desc: '异常页面' },
{ name: 'Auth', desc: '登录注册功能' },
{ name: 'Core Components', desc: '核心组件库' }
]
preservedModules.forEach((module) => {
console.log(` ${icons.check} ${fmt.success(module.name)} ${fmt.dim(`- ${module.desc}`)}`)
})
console.log()
createDivider()
console.log()
}
// 显示统计信息
async function showStats() {
const duration = Date.now() - stats.startTime
const seconds = (duration / 1000).toFixed(2)
console.log()
createCard('清理统计', [
`${fmt.success('成功删除')}: ${fmt.highlight(stats.deletedFiles.toString())} 个文件`,
`${fmt.info('涉及路径')}: ${fmt.highlight(stats.deletedPaths.toString())} 个目录/文件`,
...(stats.failedPaths > 0
? [
`${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(stats.failedPaths.toString())} 个路径`
]
: []),
`${fmt.info('耗时')}: ${fmt.highlight(seconds)}`
])
}
// 创建成功横幅
function createSuccessBanner() {
console.log()
console.log(
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
`${icons.star} ${fmt.success('清理完成!项目已准备就绪')} ${icons.rocket}`
)
console.log(
`${fmt.dim('现在可以开始您的开发之旅了!')}`
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
)
console.log()
}
// 主函数
async function main() {
// 清屏并显示横幅
console.clear()
createModernBanner()
// 显示清理警告
await showCleanupWarning()
// 统计文件数量
console.log(` ${fmt.info('正在统计文件数量...')}`)
stats.totalFiles = await countAllFiles()
console.log(` ${fmt.info('即将清理')}: ${fmt.highlight(stats.totalFiles.toString())} 个文件`)
console.log(` ${fmt.dim(`涉及 ${targets.length} 个目录/文件路径`)}`)
console.log()
// 用户确认
const confirmed = await getUserConfirmation()
if (!confirmed) {
console.log(` ${fmt.warning('操作已取消,清理中止')}`)
console.log()
return
}
console.log()
console.log(` ${icons.check} ${fmt.success('确认成功,开始清理...')}`)
console.log()
// 开始清理过程
console.log(` ${fmt.badge('步骤 1/6', theme.bgBlue)} ${fmt.title('删除演示文件')}`)
console.log()
for (let i = 0; i < targets.length; i++) {
await remove(targets[i], i)
}
console.log()
console.log(` ${fmt.badge('步骤 2/6', theme.bgBlue)} ${fmt.title('清理路由模块')}`)
console.log()
await cleanRouteModules()
console.log()
console.log(` ${fmt.badge('步骤 3/6', theme.bgBlue)} ${fmt.title('重写路由别名')}`)
console.log()
await cleanRoutesAlias()
console.log()
console.log(` ${fmt.badge('步骤 4/6', theme.bgBlue)} ${fmt.title('清空变更日志')}`)
console.log()
await cleanChangeLog()
console.log()
console.log(` ${fmt.badge('步骤 5/6', theme.bgBlue)} ${fmt.title('清理语言文件')}`)
console.log()
await cleanLanguageFiles()
console.log()
console.log(` ${fmt.badge('步骤 6/7', theme.bgBlue)} ${fmt.title('清理快速入口')}`)
console.log()
await cleanFastEnterComponent()
console.log()
console.log(` ${fmt.badge('步骤 7/7', theme.bgBlue)} ${fmt.title('更新菜单接口')}`)
console.log()
await updateMenuApi()
// 显示统计信息
await showStats()
// 显示成功横幅
createSuccessBanner()
}
main().catch((err) => {
console.log()
console.log(` ${icons.error} ${fmt.error('清理脚本执行出错')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
console.log()
process.exit(1)
})

34
saiadmin-artd/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>

View File

@@ -0,0 +1,191 @@
import request from '@/utils/http'
import { AppRouteRecord } from '@/types/router'
/**
* 获取验证码
* @returns 响应
*/
export function fetchCaptcha() {
return request.get<Api.Auth.CaptchaResponse>({
url: '/core/captcha'
})
}
/**
* 登录
* @param params 登录参数
* @returns 登录响应
*/
export function fetchLogin(params: Api.Auth.LoginParams) {
return request.post<Api.Auth.LoginResponse>({
url: '/core/login',
params
})
}
/**
* 获取用户信息
* @returns 用户信息
*/
export function fetchGetUserInfo() {
return request.get<Api.Auth.UserInfo>({
url: '/core/system/user'
})
}
/**
* 修改资料
* @param params 修改资料参数
* @returns 响应
*/
export function updateUserInfo(params: Record<string, any>) {
return request.post<any>({
url: '/core/user/updateInfo',
params
})
}
/**
* 修改密码
* @param params 修改密码参数
* @returns 响应
*/
export function modifyPassword(params: Record<string, any>) {
return request.post<any>({
url: '/core/user/modifyPassword',
params
})
}
/**
* 获取登录日志
* @returns 登录日志数组
*/
export function fetchGetLogin(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/system/getLoginLogList',
params
})
}
/**
* 获取操作日志
* @returns 操作日志数组
*/
export function fetchGetOperate(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/system/getOperationLogList',
params
})
}
/**
* 清理缓存
* @returns
*/
export function fetchClearCache() {
return request.get<any>({
url: '/core/system/clearAllCache'
})
}
/**
* 获取字典数据
* @returns 字典数组
*/
export function fetchGetDictList() {
return request.get<Api.Auth.DictData>({
url: '/core/system/dictAll'
})
}
/**
* 获取菜单列表
* @returns 菜单数组
*/
export function fetchGetMenuList() {
return request.get<AppRouteRecord[]>({
url: '/core/system/menu'
})
}
/**
* 上传图片
* @param params
* @returns
*/
export function uploadImage(params: any) {
return request.post<any>({
url: '/core/system/uploadImage',
headers: {
'Content-Type': 'multipart/form-data'
},
params
})
}
/**
* 上传文件
* @param params
* @returns
*/
export function uploadFile(params: any) {
return request.post<any>({
url: '/core/system/uploadFile',
headers: {
'Content-Type': 'multipart/form-data'
},
params
})
}
/**
* 切片上传
* @param params
* @returns
*/
export function chunkUpload(params: any) {
return request.post<any>({
url: '/core/system/chunkUpload',
headers: {
'Content-Type': 'multipart/form-data'
},
params
})
}
/**
* 资源分类
* @param params
* @returns
*/
export function getResourceCategory(params: any) {
return request.get<Api.Common.ApiData[]>({
url: '/core/system/getResourceCategory',
params
})
}
/**
* 图片资源列表
* @param params
* @returns
*/
export function getResourceList(params: any) {
return request.get<Api.Common.ApiPage>({
url: '/core/system/getResourceList',
params
})
}
/**
* 用户列表
* @param params
* @returns
*/
export function getUserList(params: any) {
return request.get<Api.Common.ApiPage>({
url: '/core/system/getUserList',
params
})
}

View File

@@ -0,0 +1,46 @@
import request from '@/utils/http'
/**
* 通用API
*/
export default {
/**
* GET请求
* @param url 请求URL
* @param params 搜索参数
* @returns 数据列表
*/
get(url: string, params?: Record<string, any>) {
return request.get<any>({
url: url,
params
})
},
/**
* POST请求
* @param url 请求URL
* @param data 请求参数
* @returns 数据列表
*/
post(url: string, data: Record<string, any>) {
return request.post<any>({
url: url,
data
})
},
/**
* 下载文件
* @param url
* @returns
*/
download(url: string) {
return request.request<any>({
url: url,
method: 'post',
timeout: 0,
responseType: 'blob'
})
}
}

View File

@@ -0,0 +1,31 @@
import request from '@/utils/http'
/**
* 基础数据统计
* @returns 响应
*/
export function fetchStatistics() {
return request.get<any>({
url: '/core/system/statistics'
})
}
/**
* 登录统计图表数据
* @returns 响应
*/
export function fetchLoginChart() {
return request.get<any>({
url: '/core/system/loginChart'
})
}
/**
* 登录统计图表数据
* @returns 响应
*/
export function fetchLoginBarChart() {
return request.get<any>({
url: '/core/system/loginBarChart'
})
}

View File

@@ -0,0 +1,54 @@
import request from '@/utils/http'
/**
* 附件API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/attachment/index',
params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/attachment/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/attachment/destroy',
data: params
})
},
/**
* 移动文件到分类
* @param params 参数包含文件ID数组和目标分类ID
* @returns 执行结果
*/
move(params: Record<string, any>) {
return request.post<any>({
url: '/core/attachment/move',
data: params
})
}
}

View File

@@ -0,0 +1,65 @@
import request from '@/utils/http'
/**
* 附件分类API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/category/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/category/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/category/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/category/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/category/destroy',
data: params
})
}
}

View File

@@ -0,0 +1,95 @@
import request from '@/utils/http'
/**
* 数据表维护API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/database/index',
params
})
},
/**
* 获取数据源
* @returns
*/
getDataSource(params: Record<string, any> = {}) {
return request.get<any>({
url: '/core/database/dataSource',
params
})
},
/**
* 获取表字段列表
* @returns
*/
getDetailed(params: Record<string, any> = {}) {
return request.get<Api.Common.ApiData[]>({
url: '/core/database/detailed',
params
})
},
/**
* 获取回收站数据
* @returns
*/
getRecycle(params: Record<string, any> = {}) {
return request.get<Api.Common.ApiPage>({
url: '/core/database/recycle',
params
})
},
/**
* 销毁数据
* @returns
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/database/delete',
data: params
})
},
/**
* 恢复数据
* @returns
*/
recovery(params: Record<string, any>) {
return request.post<any>({
url: '/core/database/recovery',
data: params
})
},
/**
* 优化表
* @returns
*/
optimize(params: Record<string, any>) {
return request.post<any>({
url: '/core/database/optimize',
data: params
})
},
/**
* 清理表碎片
* @returns
*/
fragment(params: Record<string, any>) {
return request.post<any>({
url: '/core/database/fragment',
data: params
})
}
}

View File

@@ -0,0 +1,102 @@
import request from '@/utils/http'
/**
* 字典数据API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
typeList(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/dictType/index',
params
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/dictType/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dictType/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/dictType/destroy',
data: params
})
},
/**
* 字典项数据列表
* @param params 搜索参数
* @returns 数据列表
*/
dataList(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/dictData/index',
params
})
},
/**
* 创建字典项数据
* @param params 数据参数
* @returns 执行结果
*/
dataSave(params: Record<string, any>) {
return request.post<any>({
url: '/core/dictData/save',
data: params
})
},
/**
* 更新字典项数据
* @param params 数据参数
* @returns 执行结果
*/
dataUpdate(params: Record<string, any>) {
return request.put<any>({
url: '/core/dictData/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
dataDelete(params: Record<string, any>) {
return request.del<any>({
url: '/core/dictData/destroy',
data: params
})
}
}

View File

@@ -0,0 +1,30 @@
import request from '@/utils/http'
/**
* 邮件日志数据API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/email/index',
params
})
},
/**
* 删除数据
* @param params
* @returns
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/email/destroy',
data: params
})
}
}

View File

@@ -0,0 +1,30 @@
import request from '@/utils/http'
/**
* 登录日志数据API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/logs/getLoginLogPageList',
params
})
},
/**
* 删除数据
* @param params
* @returns
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/logs/deleteLoginLog',
data: params
})
}
}

View File

@@ -0,0 +1,30 @@
import request from '@/utils/http'
/**
* 操作日志数据API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/logs/getOperLogPageList',
params
})
},
/**
* 删除数据
* @param params
* @returns
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/logs/deleteOperLog',
data: params
})
}
}

View File

@@ -0,0 +1,42 @@
import request from '@/utils/http'
/**
* 服务器信息API
*/
export default {
/**
* 服务监控
* @param params 搜索参数
* @returns 数据列表
*/
monitor(params: Record<string, any>) {
return request.get<any>({
url: '/core/server/monitor',
params
})
},
/**
* 缓存列表
* @param params 搜索参数
* @returns 数据列表
*/
cache(params: Record<string, any>) {
return request.get<any>({
url: '/core/server/cache',
params
})
},
/**
* 清理缓存
* @param params 搜索参数
* @returns 数据列表
*/
clear(params: Record<string, any>) {
return request.post<any>({
url: '/core/server/clear',
params
})
}
}

View File

@@ -0,0 +1,126 @@
import request from '@/utils/http'
/**
* 系统设置API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
groupList(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/configGroup/index',
params
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<Api.Common.ApiData>({
url: '/core/configGroup/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/configGroup/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/configGroup/destroy',
data: params
})
},
/**
* 系统设置项数据列表
* @param params 搜索参数
* @returns 数据列表
*/
configList(params: Record<string, any>) {
return request.get<Api.Common.ApiData>({
url: '/core/config/index',
params
})
},
/**
* 创建系统设置项数据
* @param params 数据参数
* @returns 执行结果
*/
configSave(params: Record<string, any>) {
return request.post<any>({
url: '/core/config/save',
data: params
})
},
/**
* 更新系统设置项数据
* @param params 数据参数
* @returns 执行结果
*/
configUpdate(params: Record<string, any>) {
return request.put<any>({
url: '/core/config/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
configDelete(params: Record<string, any>) {
return request.del<any>({
url: '/core/config/destroy',
data: params
})
},
/**
* 批量修改配置
* @param params
* @returns
*/
batchUpdate(params: Record<string, any>) {
return request.post<any>({
url: '/core/config/batchUpdate',
data: params
})
},
/**
* 邮件测试
* @param params
* @returns
*/
emailTest(params: Record<string, any>) {
return request.post<any>({
url: '/core/configGroup/email',
data: params
})
}
}

View File

@@ -0,0 +1,75 @@
import request from '@/utils/http'
/**
* 部门API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/dept/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/dept/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/dept/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/dept/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/dept/destroy',
data: params
})
},
/**
* 可操作部门
* @returns 数据列表
*/
accessDept() {
return request.get<Api.Common.ApiData[]>({
url: '/core/dept/accessDept'
})
}
}

View File

@@ -0,0 +1,76 @@
import request from '@/utils/http'
/**
* 菜单API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/menu/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/menu/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/menu/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/menu/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/menu/destroy',
data: params
})
},
/**
* 可操作角色
* @returns 数据列表
*/
accessMenu(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/menu/accessMenu',
params
})
}
}

View File

@@ -0,0 +1,75 @@
import request from '@/utils/http'
/**
* 岗位API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/post/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/post/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/post/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/post/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/post/destroy',
data: params
})
},
/**
* 可操作岗位
* @returns 数据列表
*/
accessPost() {
return request.get<Api.Common.ApiData[]>({
url: '/core/post/accessPost'
})
}
}

View File

@@ -0,0 +1,123 @@
import request from '@/utils/http'
/**
* 角色API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/role/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/role/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/role/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/role/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/role/destroy',
data: params
})
},
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
menuByRole(params: Record<string, any>) {
return request.get<Api.Common.ApiData>({
url: '/core/role/getMenuByRole',
params
})
},
/**
* 可操作角色
* @returns 数据列表
*/
accessRole() {
return request.get<Api.Common.ApiData[]>({
url: '/core/role/accessRole'
})
},
/**
* 保存菜单权限
* @param params
* @returns
*/
menuPermission(params: Record<string, any>) {
return request.post<any>({
url: '/core/role/menuPermission',
data: params
})
},
/**
* 获取部门数据列表
* @param params 搜索参数
* @returns 数据列表
*/
deptByRole(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/core/role/getDeptByRole',
params
})
},
/**
* 保存数据权限
* @param params
* @returns
*/
dataPermission(params: Record<string, any>) {
return request.post<any>({
url: '/core/role/dataPermission',
data: params
})
}
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/http'
/**
* 用户API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/core/user/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/core/user/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/core/user/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/core/user/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/core/user/destroy',
data: params
})
},
/**
* 设置首页
* @param params 数据参数
* @returns 执行结果
*/
setHomePage(params: Record<string, any>) {
return request.post<any>({
url: '/core/user/setHomePage',
data: params
})
},
/**
* 修改密码
* @param params 数据参数
* @returns 执行结果
*/
changePassword(params: Record<string, any>) {
return request.post<any>({
url: '/core/user/initUserPassword',
data: params
})
},
/**
* 清理缓存
* @param params
* @returns
*/
clearCache(params: Record<string, any>) {
return request.post<any>({
url: '/core/user/clearCache',
data: params
})
}
}

View File

@@ -0,0 +1,101 @@
import request from '@/utils/http'
/**
* 定时任务API
*/
export default {
/**
* 获取数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/tool/crontab/index',
params
})
},
/**
* 读取数据
* @param id 数据ID
* @returns 数据详情
*/
read(id: number | string) {
return request.get<Api.Common.ApiData>({
url: '/tool/crontab/read?id=' + id
})
},
/**
* 创建数据
* @param params 数据参数
* @returns 执行结果
*/
save(params: Record<string, any>) {
return request.post<any>({
url: '/tool/crontab/save',
data: params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/tool/crontab/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/tool/crontab/destroy',
data: params
})
},
/**
* 运行
* @param params 数据参数
* @returns 执行结果
*/
run(params: Record<string, any>) {
return request.post<any>({
url: '/tool/crontab/run',
data: params
})
},
/**
* 获取运行日志列表
* @param params 搜索参数
* @returns 数据列表
*/
logPageList(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/tool/crontab/logPageList',
params
})
},
/**
* 删除运行日志
* @param params 数据参数
* @returns 执行结果
*/
deleteCrontabLog(params: Record<string, any>) {
return request.del<any>({
url: '/tool/crontab/deleteCrontabLog',
data: params
})
}
}

View File

@@ -0,0 +1,126 @@
import request from '@/utils/http'
/**
* 代码生成API
*/
export default {
/**
* 数据列表
* @param params 搜索参数
* @returns 数据列表
*/
list(params: Record<string, any>) {
return request.get<Api.Common.ApiPage>({
url: '/tool/code/index',
params
})
},
/**
* 读取表结构
* @param params 搜索参数
* @returns 表结构
*/
read(params: Record<string, any>) {
return request.get<Api.Common.ApiData>({
url: '/tool/code/read',
params
})
},
/**
* 更新数据
* @param params 数据参数
* @returns 执行结果
*/
update(params: Record<string, any>) {
return request.put<any>({
url: '/tool/code/update',
data: params
})
},
/**
* 删除数据
* @param id 数据ID
* @returns 执行结果
*/
delete(params: Record<string, any>) {
return request.del<any>({
url: '/tool/code/destroy',
data: params
})
},
/**
* 获取表字段
* @param params 搜索参数
* @returns 表字段
*/
getTableColumns(params: Record<string, any>) {
return request.get<Api.Common.ApiData[]>({
url: '/tool/code/getTableColumns',
params
})
},
/**
* 装载数据表
* @param params 搜索参数
* @returns 装载结果
*/
loadTable(params: Record<string, any>) {
return request.post<any>({
url: '/tool/code/loadTable',
params
})
},
/**
* 同步数据表
* @param params 搜索参数
* @returns 装载结果
*/
async(params: Record<string, any>) {
return request.post<any>({
url: '/tool/code/sync',
params
})
},
/**
* 预览代码
* @param params 搜索参数
* @returns 预览结果
*/
preview(params: Record<string, any>) {
return request.get<any>({
url: '/tool/code/preview',
params
})
},
/**
* 生成代码
* @returns
*/
generateCode(params: Record<string, any>) {
return request.post<any>({
url: '/tool/code/generate',
responseType: 'blob',
timeout: 20 * 1000,
params
})
},
/**
* 生成到文件
* @returns
*/
generateFile(params: Record<string, any>) {
return request.post<any>({
url: '/tool/code/generateFile',
params
})
}
}

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: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 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: 86 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;
}
}

View File

@@ -0,0 +1,1036 @@
/* 文章标题设置h1-h6*/
/* ------------------------------------------------ */
$font-color: #24292e;
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: var(--art-gray-800) !important;
margin: 30px 0 10px 0;
font-weight: 600;
}
.markdown-body h1 {
font-size: 30px;
}
@media only screen and (max-width: 550px) {
.markdown-body h1 {
font-size: 26px;
}
.markdown-body h2 {
font-size: 22px;
}
.markdown-body h3 {
font-size: 18px;
}
}
/* 块引用 */
/* ------------------------------------------------ */
.markdown-body blockquote {
color: rgba(60, 60, 67, 0.7);
font-size: 15px !important;
border-left: 0.18em solid #e7e7e8;
background: #f8f8f8;
padding: 15px 1em;
font-weight: 400 !important;
}
/* 详情页文章字体颜色 */
/* ------------------------------------------------ */
.markdown-body p {
line-height: 28px;
margin-bottom: 10px;
}
.markdown-body li,
.markdown-body p {
color: var(--art-gray-800) !important;
font-size: 16px !important;
}
.dark .markdown-body li span {
color: var(--art-gray-800) !important;
background-color: transparent !important;
}
.dark .markdown-body p span {
color: var(--art-gray-800) !important;
background-color: transparent !important;
}
.line-numbers-mode {
background-color: var(--art-code-bg);
border-radius: 8px;
position: relative;
padding-left: 32px;
box-sizing: border-box;
}
.line-numbers-mode pre {
flex: 1;
border-radius: 0 8px 8px 0;
background-color: var(--art-code-bg);
}
.line-numbers-mode .line-numbers-wrapper {
width: 32px;
height: 100%;
text-align: center;
padding: 16px 0;
box-sizing: border-box;
border-right: 1px solid #000000;
position: absolute;
left: 0;
top: 0;
}
.line-numbers-mode .line-numbers-wrapper span {
height: 23.6px;
line-height: 23.6px;
display: block;
color: #72747b;
font-size: 13px;
box-sizing: border-box;
}
.line-numbers-mode .copy-btn {
display: inline-block;
display: flex;
position: absolute;
right: 10px;
top: 10px;
cursor: pointer;
opacity: 0;
background-color: #000;
border-radius: 5px;
text-align: center;
color: rgba(255, 255, 255, 0.6);
transition: opacity 0.3s;
}
.line-numbers-mode .copy-btn div {
width: 34px;
height: 34px;
line-height: 34px;
cursor: pointer;
text-align: center;
font-size: 20px;
}
.line-numbers-mode:hover .copy-btn {
opacity: 1;
}
.line-numbers-mode .copy-btn span {
height: 34px;
line-height: 34px;
font-size: 13px;
padding-left: 10px;
display: none;
}
.line-numbers-mode .copy-btn .show-copy {
opacity: 1;
display: block;
}
.line-numbers-mode ::-webkit-scrollbar-track {
background-color: #292b30 !important;
}
.markdown-body .anchor {
float: left;
line-height: 1;
margin-left: -20px;
padding-right: 4px;
}
.markdown-body .anchor:focus {
outline: none;
}
.markdown-body h1 .octicon-link,
.markdown-body h2 .octicon-link,
.markdown-body h3 .octicon-link,
.markdown-body h4 .octicon-link,
.markdown-body h5 .octicon-link,
.markdown-body h6 .octicon-link {
color: #1b1f23;
vertical-align: middle;
visibility: hidden;
}
.markdown-body h1:hover .anchor,
.markdown-body h2:hover .anchor,
.markdown-body h3:hover .anchor,
.markdown-body h4:hover .anchor,
.markdown-body h5:hover .anchor,
.markdown-body h6:hover .anchor {
text-decoration: none;
}
.markdown-body h1:hover .anchor .octicon-link,
.markdown-body h2:hover .anchor .octicon-link,
.markdown-body h3:hover .anchor .octicon-link,
.markdown-body h4:hover .anchor .octicon-link,
.markdown-body h5:hover .anchor .octicon-link,
.markdown-body h6:hover .anchor .octicon-link {
visibility: visible;
}
.markdown-body h1:hover .anchor .octicon-link:before,
.markdown-body h2:hover .anchor .octicon-link:before,
.markdown-body h3:hover .anchor .octicon-link:before,
.markdown-body h4:hover .anchor .octicon-link:before,
.markdown-body h5:hover .anchor .octicon-link:before,
.markdown-body h6:hover .anchor .octicon-link:before {
width: 16px;
height: 16px;
content: ' ';
display: inline-block;
}
.markdown-body {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
line-height: 1.5;
color: $font-color;
font-size: 16px;
line-height: 1.5;
word-wrap: break-word;
}
.markdown-body details {
display: block;
}
.markdown-body summary {
display: list-item;
}
.markdown-body a {
background-color: initial;
}
.markdown-body a:active,
.markdown-body a:hover {
outline-width: 0;
}
.markdown-body strong {
font-weight: inherit;
font-weight: bolder;
}
.markdown-body p br {
display: inline;
line-height: 11px;
}
.markdown-body img {
border-style: none;
}
.markdown-body hr {
box-sizing: initial;
height: 0;
overflow: visible;
}
.markdown-body input {
font: inherit;
margin: 0;
}
.markdown-body input {
overflow: visible;
}
.markdown-body [type='checkbox'] {
box-sizing: border-box;
padding: 0;
}
.markdown-body * {
box-sizing: border-box;
}
.markdown-body input {
font-size: inherit;
line-height: inherit;
}
.markdown-body a {
color: #0366d6;
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
.markdown-body strong {
font-weight: 600;
}
.markdown-body hr {
height: 0;
margin: 15px 0;
overflow: hidden;
background: transparent;
border: 0;
border-bottom: 1px solid #dfe2e5;
}
.markdown-body hr:after,
.markdown-body hr:before {
display: table;
content: '';
}
.markdown-body hr:after {
clear: both;
}
.markdown-body table {
border-spacing: 0;
border-collapse: collapse;
}
.markdown-body td,
.markdown-body th {
padding: 0;
}
.markdown-body details summary {
cursor: pointer;
}
.markdown-body kbd {
display: inline-block;
padding: 3px 5px;
font:
11px SFMono-Regular,
Consolas,
Liberation Mono,
Menlo,
monospace;
line-height: 10px;
color: #444d56;
vertical-align: middle;
background-color: #fafbfc;
border: 1px solid #d1d5da;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #d1d5da;
}
.markdown-body blockquote {
margin: 0;
}
.markdown-body ol,
.markdown-body ul {
padding-left: 0;
margin-top: 0;
margin-bottom: 0;
}
.markdown-body ol ol,
.markdown-body ul ol {
list-style-type: lower-roman;
}
.markdown-body ol ol ol,
.markdown-body ol ul ol,
.markdown-body ul ol ol,
.markdown-body ul ul ol {
list-style-type: lower-alpha;
}
.markdown-body dd {
margin-left: 0;
}
.markdown-body code,
.markdown-body pre,
.markdown-body .line-number {
font-size: 14px !important;
border-radius: 8px;
background-color: #282c34;
}
.dark {
.markdown-body code,
.markdown-body pre,
.markdown-body .line-number {
background-color: #252525;
}
}
.markdown-body pre {
margin-top: 0;
margin-bottom: 0;
}
.markdown-body input::-webkit-inner-spin-button,
.markdown-body input::-webkit-outer-spin-button {
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.markdown-body :checked + .radio-label {
position: relative;
z-index: 1;
border-color: #0366d6;
}
.markdown-body .border {
border: 1px solid #e1e4e8 !important;
}
.markdown-body .border-0 {
border: 0 !important;
}
.markdown-body .border-bottom {
border-bottom: 1px solid #e1e4e8 !important;
}
.markdown-body .rounded-1 {
border-radius: 3px !important;
}
.markdown-body .bg-white {
background-color: #fff !important;
}
.markdown-body .bg-gray-light {
background-color: #fafbfc !important;
}
.markdown-body .text-gray-light {
color: #6a737d !important;
}
.markdown-body .mb-0 {
margin-bottom: 0 !important;
}
.markdown-body .my-2 {
margin-top: 8px !important;
margin-bottom: 8px !important;
}
.markdown-body .pl-0 {
padding-left: 0 !important;
}
.markdown-body .py-0 {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.markdown-body .pl-1 {
padding-left: 4px !important;
}
.markdown-body .pl-2 {
padding-left: 8px !important;
}
.markdown-body .py-2 {
padding-top: 8px !important;
padding-bottom: 8px !important;
}
.markdown-body .pl-3,
.markdown-body .px-3 {
padding-left: 16px !important;
}
.markdown-body .px-3 {
padding-right: 16px !important;
}
.markdown-body .pl-4 {
padding-left: 24px !important;
}
.markdown-body .pl-5 {
padding-left: 32px !important;
}
.markdown-body .pl-6 {
padding-left: 40px !important;
}
.markdown-body .f6 {
font-size: 12px !important;
}
.markdown-body .lh-condensed {
line-height: 1.25 !important;
}
.markdown-body .text-bold {
font-weight: 600 !important;
}
.markdown-body .pl-c {
color: #6a737d;
}
.markdown-body .pl-c1,
.markdown-body .pl-s .pl-v {
color: #005cc5;
}
.markdown-body .pl-e,
.markdown-body .pl-en {
color: #6f42c1;
}
.markdown-body .pl-s .pl-s1,
.markdown-body .pl-smi {
color: $font-color;
}
.markdown-body .pl-ent {
color: #22863a;
}
.markdown-body .pl-k {
color: #d73a49;
}
.markdown-body .pl-pds,
.markdown-body .pl-s,
.markdown-body .pl-s .pl-pse .pl-s1,
.markdown-body .pl-sr,
.markdown-body .pl-sr .pl-cce,
.markdown-body .pl-sr .pl-sra,
.markdown-body .pl-sr .pl-sre {
color: #032f62;
}
.markdown-body .pl-smw,
.markdown-body .pl-v {
color: #e36209;
}
.markdown-body .pl-bu {
color: #b31d28;
}
.markdown-body .pl-ii {
color: #fafbfc;
background-color: #b31d28;
}
.markdown-body .pl-c2 {
color: #fafbfc;
background-color: #d73a49;
}
.markdown-body .pl-c2:before {
content: '^M';
}
.markdown-body .pl-sr .pl-cce {
font-weight: 700;
color: #22863a;
}
.markdown-body .pl-ml {
color: #735c0f;
}
.markdown-body .pl-mh,
.markdown-body .pl-mh .pl-en,
.markdown-body .pl-ms {
font-weight: 700;
color: #005cc5;
}
.markdown-body .pl-mi {
font-style: italic;
color: $font-color;
}
.markdown-body .pl-mb {
font-weight: 700;
color: $font-color;
}
.markdown-body .pl-md {
color: #b31d28;
background-color: #ffeef0;
}
.markdown-body .pl-mi1 {
color: #22863a;
background-color: #f0fff4;
}
.markdown-body .pl-mc {
color: #e36209;
background-color: #ffebda;
}
.markdown-body .pl-mi2 {
color: #f6f8fa;
background-color: #005cc5;
}
.markdown-body .pl-mdr {
font-weight: 700;
color: #6f42c1;
}
.markdown-body .pl-ba {
color: #586069;
}
.markdown-body .pl-sg {
color: #959da5;
}
.markdown-body .pl-corl {
text-decoration: underline;
color: #032f62;
}
.markdown-body .mb-0 {
margin-bottom: 0 !important;
}
.markdown-body .my-2 {
margin-bottom: 8px !important;
}
.markdown-body .my-2 {
margin-top: 8px !important;
}
.markdown-body .pl-0 {
padding-left: 0 !important;
}
.markdown-body .py-0 {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.markdown-body .pl-1 {
padding-left: 4px !important;
}
.markdown-body .pl-2 {
padding-left: 8px !important;
}
.markdown-body .py-2 {
padding-top: 8px !important;
padding-bottom: 8px !important;
}
.markdown-body .pl-3 {
padding-left: 16px !important;
}
.markdown-body .pl-4 {
padding-left: 24px !important;
}
.markdown-body .pl-5 {
padding-left: 32px !important;
}
.markdown-body .pl-6 {
padding-left: 40px !important;
}
.markdown-body .pl-7 {
padding-left: 48px !important;
}
.markdown-body .pl-8 {
padding-left: 64px !important;
}
.markdown-body .pl-9 {
padding-left: 80px !important;
}
.markdown-body .pl-10 {
padding-left: 96px !important;
}
.markdown-body .pl-11 {
padding-left: 112px !important;
}
.markdown-body .pl-12 {
padding-left: 128px !important;
}
.markdown-body hr {
border-bottom-color: #eee;
}
.markdown-body kbd {
display: inline-block;
padding: 3px 5px;
font:
11px SFMono-Regular,
Consolas,
Liberation Mono,
Menlo,
monospace;
line-height: 10px;
color: #444d56;
vertical-align: middle;
background-color: #fafbfc;
border: 1px solid #d1d5da;
border-radius: 3px;
box-shadow: inset 0 -1px 0 #d1d5da;
}
.markdown-body:after,
.markdown-body:before {
display: table;
content: '';
}
.markdown-body:after {
clear: both;
}
.markdown-body > :first-child {
margin-top: 0 !important;
}
.markdown-body > :last-child {
margin-bottom: 0 !important;
}
.markdown-body a:not([href]) {
color: inherit;
text-decoration: none;
}
.markdown-body blockquote,
.markdown-body details,
.markdown-body dl,
.markdown-body ol,
.markdown-body pre,
.markdown-body table,
.markdown-body ul {
margin-top: 0;
margin-bottom: 16px;
}
.markdown-body hr {
height: 0.25em;
padding: 0;
margin: 24px 0;
background-color: #e1e4e8;
border: 0;
}
.markdown-body blockquote > :first-child {
margin-top: 0;
}
.markdown-body blockquote > :last-child {
margin-bottom: 0;
}
.markdown-body ol,
.markdown-body ul {
padding-left: 1em;
}
.markdown-body ol ol,
.markdown-body ol ul,
.markdown-body ul ol,
.markdown-body ul ul {
margin-top: 0;
margin-bottom: 0;
}
.markdown-body li {
line-height: 28px;
font-size: 14px;
word-wrap: break-all;
list-style: disc;
margin-left: 10px;
}
.markdown-body li > p {
margin-top: 16px;
}
.markdown-body li + li {
margin-top: 0.25em;
}
.markdown-body dl {
padding: 0;
}
.markdown-body dl dt {
padding: 0;
margin-top: 16px;
font-size: 1em;
font-style: italic;
font-weight: 600;
}
.markdown-body dl dd {
padding: 0 16px;
margin-bottom: 16px;
}
.markdown-body table {
display: block;
width: 100%;
overflow: auto;
}
.markdown-body table th {
font-weight: 600;
}
.markdown-body table td,
.markdown-body table th {
padding: 6px 13px;
border: 1px solid #dfe2e5;
}
.markdown-body table tr {
background-color: #fff;
border-top: 1px solid #c6cbd1;
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
}
.markdown-body img {
max-width: 100%;
box-sizing: initial;
background-color: #fff;
border: 1px solid #eee;
border: 1px solid var(--art-c-border-2);
cursor: zoom-in;
}
.markdown-body img[align='right'] {
padding-left: 20px;
}
.markdown-body img[align='left'] {
padding-right: 20px;
}
.markdown-body code {
padding: 0.2em 0.4em;
margin: 0;
font-size: 85%;
background-color: rgba(27, 31, 35, 0.05);
border-radius: 3px;
}
.markdown-body pre {
word-wrap: normal;
}
.markdown-body pre > code {
padding: 0;
margin: 0;
font-size: 100%;
word-break: normal;
white-space: pre;
background: transparent;
border: 0;
}
.markdown-body .highlight {
margin-bottom: 16px;
}
.markdown-body .highlight pre {
margin-bottom: 0;
word-break: normal;
}
.markdown-body .highlight pre,
.markdown-body pre {
padding: 15px 20px 15px 0;
overflow: auto;
font-size: 92%;
line-height: 1.6;
}
.markdown-body pre code {
display: inline;
max-width: auto;
padding: 0;
margin: 0;
overflow: visible;
line-height: inherit;
word-wrap: normal;
background-color: initial;
border: 0;
}
.markdown-body .commit-tease-sha {
display: inline-block;
font-size: 90%;
color: #444d56;
}
.markdown-body .full-commit .btn-outline:not(:disabled):hover {
color: #005cc5;
border-color: #005cc5;
}
.markdown-body .blob-wrapper {
overflow-x: auto;
overflow-y: hidden;
}
.markdown-body .blob-wrapper-embedded {
max-height: 240px;
overflow-y: auto;
}
.markdown-body .blob-num {
width: 1%;
min-width: 50px;
padding-right: 10px;
padding-left: 10px;
font-size: 12px;
line-height: 20px;
color: rgba(27, 31, 35, 0.3);
text-align: right;
white-space: nowrap;
vertical-align: top;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.markdown-body .blob-num:hover {
color: rgba(27, 31, 35, 0.6);
}
.markdown-body .blob-num:before {
content: attr(data-line-number);
}
.markdown-body .blob-code {
position: relative;
padding-right: 10px;
padding-left: 10px;
line-height: 20px;
vertical-align: top;
}
.markdown-body .blob-code-inner {
overflow: visible;
font-size: 12px;
color: $font-color;
word-wrap: normal;
white-space: pre;
}
.markdown-body .pl-token.active,
.markdown-body .pl-token:hover {
cursor: pointer;
background: #ffea7f;
}
.markdown-body .tab-size[data-tab-size='1'] {
-moz-tab-size: 1;
tab-size: 1;
}
.markdown-body .tab-size[data-tab-size='2'] {
-moz-tab-size: 2;
tab-size: 2;
}
.markdown-body .tab-size[data-tab-size='3'] {
-moz-tab-size: 3;
tab-size: 3;
}
.markdown-body .tab-size[data-tab-size='4'] {
-moz-tab-size: 4;
tab-size: 4;
}
.markdown-body .tab-size[data-tab-size='5'] {
-moz-tab-size: 5;
tab-size: 5;
}
.markdown-body .tab-size[data-tab-size='6'] {
-moz-tab-size: 6;
tab-size: 6;
}
.markdown-body .tab-size[data-tab-size='7'] {
-moz-tab-size: 7;
tab-size: 7;
}
.markdown-body .tab-size[data-tab-size='8'] {
-moz-tab-size: 8;
tab-size: 8;
}
.markdown-body .tab-size[data-tab-size='9'] {
-moz-tab-size: 9;
tab-size: 9;
}
.markdown-body .tab-size[data-tab-size='10'] {
-moz-tab-size: 10;
tab-size: 10;
}
.markdown-body .tab-size[data-tab-size='11'] {
-moz-tab-size: 11;
tab-size: 11;
}
.markdown-body .tab-size[data-tab-size='12'] {
-moz-tab-size: 12;
tab-size: 12;
}
.markdown-body .task-list-item {
list-style-type: none;
}
.markdown-body .task-list-item + .task-list-item {
margin-top: 3px;
}
.markdown-body .task-list-item input {
margin: 0 0.2em 0.25em -1.6em;
vertical-align: middle;
}

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';

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.png" 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;
}
}
}

View File

@@ -0,0 +1,142 @@
<!-- 面包屑导航 -->
<template>
<nav class="ml-2.5 max-lg:!hidden" aria-label="breadcrumb">
<ul class="flex-c h-full">
<li
v-for="(item, index) in breadcrumbItems"
:key="item.path"
class="box-border flex-c h-7 text-sm leading-7"
>
<div
:class="
isClickable(item, index)
? 'c-p py-1 rounded tad-200 hover:bg-active-color hover:[&_span]:text-g-600'
: ''
"
@click="handleBreadcrumbClick(item, index)"
>
<span
class="block max-w-46 overflow-hidden text-ellipsis whitespace-nowrap px-1.5 text-sm text-g-600 dark:text-g-800"
>{{ formatMenuTitle(item.meta?.title as string) }}</span
>
</div>
<div
v-if="!isLastItem(index) && item.meta?.title"
class="mx-1 text-sm not-italic text-g-500"
aria-hidden="true"
>
/
</div>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import type { RouteLocationMatched, RouteRecordRaw } from 'vue-router'
import { formatMenuTitle } from '@/utils/router'
defineOptions({ name: 'ArtBreadcrumb' })
export interface BreadcrumbItem {
path: string
meta: RouteRecordRaw['meta']
}
const route = useRoute()
const router = useRouter()
// 使用computed替代watch提高性能
const breadcrumbItems = computed<BreadcrumbItem[]>(() => {
const { matched } = route
const matchedLength = matched.length
// 处理首页情况
if (!matchedLength || isHomeRoute(matched[0])) {
return []
}
// 处理一级菜单和普通路由
const firstRoute = matched[0]
const isFirstLevel = firstRoute.meta?.isFirstLevel
const lastIndex = matchedLength - 1
const currentRoute = matched[lastIndex]
const currentRouteMeta = currentRoute.meta
let items = isFirstLevel
? [createBreadcrumbItem(currentRoute)]
: matched.map(createBreadcrumbItem)
// 过滤包裹容器:如果有多个项目且第一个是容器路由(如 /outside则移除它
if (items.length > 1 && isWrapperContainer(items[0])) {
items = items.slice(1)
}
// IFrame 页面特殊处理:如果过滤后只剩一个 iframe 页面,或者所有项都是包裹容器,则仅展示当前页
if (currentRouteMeta?.isIframe && (items.length === 1 || items.every(isWrapperContainer))) {
return [createBreadcrumbItem(currentRoute)]
}
return items
})
// 辅助函数:判断是否为包裹容器路由
const isWrapperContainer = (item: BreadcrumbItem): boolean =>
item.path === '/outside' && !!item.meta?.isIframe
// 辅助函数:创建面包屑项目
const createBreadcrumbItem = (route: RouteLocationMatched): BreadcrumbItem => ({
path: route.path,
meta: route.meta
})
// 辅助函数:判断是否为首页
const isHomeRoute = (route: RouteLocationMatched): boolean => route.name === '/'
// 辅助函数:判断是否为最后一项
const isLastItem = (index: number): boolean => {
const itemsLength = breadcrumbItems.value.length
return index === itemsLength - 1
}
// 辅助函数:判断是否可点击
const isClickable = (item: BreadcrumbItem, index: number): boolean =>
item.path !== '/outside' && !isLastItem(index)
// 辅助函数:查找路由的第一个有效子路由
const findFirstValidChild = (route: RouteRecordRaw) =>
route.children?.find((child) => !child.redirect && !child.meta?.isHide)
// 辅助函数:构建完整路径
const buildFullPath = (childPath: string): string => `/${childPath}`.replace('//', '/')
// 处理面包屑点击事件
async function handleBreadcrumbClick(item: BreadcrumbItem, index: number): Promise<void> {
// 如果是最后一项或外部链接,不处理
if (isLastItem(index) || item.path === '/outside') {
return
}
try {
// 缓存路由表查找结果
const routes = router.getRoutes()
const targetRoute = routes.find((route) => route.path === item.path)
if (!targetRoute?.children?.length) {
await router.push(item.path)
return
}
const firstValidChild = findFirstValidChild(targetRoute)
if (firstValidChild) {
await router.push(buildFullPath(firstValidChild.path))
} else {
await router.push(item.path)
}
} catch (error) {
console.error('导航失败:', error)
}
}
</script>

View File

@@ -0,0 +1,262 @@
<!-- 系统聊天窗口 -->
<template>
<div>
<ElDrawer v-model="isDrawerVisible" :size="isMobile ? '100%' : '480px'" :with-header="false">
<div class="mb-5 flex-cb">
<div>
<span class="text-base font-medium">Art Bot</span>
<div class="mt-1.5 flex-c gap-1">
<div
class="h-2 w-2 rounded-full"
:class="isOnline ? 'bg-success/100' : 'bg-danger/100'"
></div>
<span class="text-xs text-g-600">{{ isOnline ? '在线' : '离线' }}</span>
</div>
</div>
<div>
<ElIcon class="c-p" :size="20" @click="closeChat">
<Close />
</ElIcon>
</div>
</div>
<div class="flex h-[calc(100%-70px)] flex-col">
<!-- 聊天消息区域 -->
<div
class="flex-1 overflow-y-auto border-t-d px-4 py-7.5 [&::-webkit-scrollbar]:!w-1"
ref="messageContainer"
>
<template v-for="(message, index) in messages" :key="index">
<div
:class="[
'mb-7.5 flex w-full items-start gap-2',
message.isMe ? 'flex-row-reverse' : 'flex-row'
]"
>
<ElAvatar :size="32" :src="message.avatar" class="shrink-0" />
<div
:class="['flex max-w-[70%] flex-col', message.isMe ? 'items-end' : 'items-start']"
>
<div
:class="[
'mb-1 flex gap-2 text-xs',
message.isMe ? 'flex-row-reverse' : 'flex-row'
]"
>
<span class="font-medium">{{ message.sender }}</span>
<span class="text-g-600">{{ message.time }}</span>
</div>
<div
:class="[
'rounded-md px-3.5 py-2.5 text-sm leading-[1.4] text-g-900',
message.isMe ? 'message-right bg-theme/15' : 'message-left bg-g-300/50'
]"
>{{ message.content }}</div
>
</div>
</div>
</template>
</div>
<!-- 聊天输入区域 -->
<div class="px-4 pt-4">
<ElInput
v-model="messageText"
type="textarea"
:rows="3"
placeholder="输入消息"
resize="none"
@keyup.enter.prevent="sendMessage"
>
<template #append>
<div class="flex gap-2 py-2">
<ElButton :icon="Paperclip" circle plain />
<ElButton :icon="Picture" circle plain />
<ElButton type="primary" @click="sendMessage" v-ripple>发送</ElButton>
</div>
</template>
</ElInput>
<div class="mt-3 flex-cb">
<div class="flex-c">
<ArtSvgIcon icon="ri:image-line" class="mr-5 c-p text-g-600 text-lg" />
<ArtSvgIcon icon="ri:emotion-happy-line" class="mr-5 c-p text-g-600 text-lg" />
</div>
<ElButton type="primary" @click="sendMessage" v-ripple class="min-w-20">发送</ElButton>
</div>
</div>
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
import { Picture, Paperclip, Close } from '@element-plus/icons-vue'
import { mittBus } from '@/utils/sys'
import meAvatar from '@/assets/images/avatar/avatar5.webp'
import aiAvatar from '@/assets/images/avatar/avatar10.webp'
defineOptions({ name: 'ArtChatWindow' })
// 类型定义
interface ChatMessage {
id: number
sender: string
content: string
time: string
isMe: boolean
avatar: string
}
// 常量定义
const MOBILE_BREAKPOINT = 640
const SCROLL_DELAY = 100
const BOT_NAME = 'Art Bot'
const USER_NAME = 'Ricky'
// 响应式布局
const { width } = useWindowSize()
const isMobile = computed(() => width.value < MOBILE_BREAKPOINT)
// 组件状态
const isDrawerVisible = ref(false)
const isOnline = ref(true)
// 消息相关状态
const messageText = ref('')
const messageId = ref(10)
const messageContainer = ref<HTMLElement | null>(null)
// 初始化聊天消息数据
const initializeMessages = (): ChatMessage[] => [
{
id: 1,
sender: BOT_NAME,
content: '你好我是你的AI助手有什么我可以帮你的吗',
time: '10:00',
isMe: false,
avatar: aiAvatar
},
{
id: 2,
sender: USER_NAME,
content: '我想了解一下系统的使用方法。',
time: '10:01',
isMe: true,
avatar: meAvatar
},
{
id: 3,
sender: BOT_NAME,
content: '好的,我来为您介绍系统的主要功能。首先,您可以通过左侧菜单访问不同的功能模块...',
time: '10:02',
isMe: false,
avatar: aiAvatar
},
{
id: 4,
sender: USER_NAME,
content: '听起来很不错,能具体讲讲数据分析部分吗?',
time: '10:05',
isMe: true,
avatar: meAvatar
},
{
id: 5,
sender: BOT_NAME,
content: '当然可以。数据分析模块可以帮助您实时监控关键指标,并生成详细的报表...',
time: '10:06',
isMe: false,
avatar: aiAvatar
},
{
id: 6,
sender: USER_NAME,
content: '太好了,那我如何开始使用呢?',
time: '10:08',
isMe: true,
avatar: meAvatar
},
{
id: 7,
sender: BOT_NAME,
content: '您可以先创建一个项目,然后在项目中添加相关的数据源,系统会自动进行分析。',
time: '10:09',
isMe: false,
avatar: aiAvatar
},
{
id: 8,
sender: USER_NAME,
content: '明白了,谢谢你的帮助!',
time: '10:10',
isMe: true,
avatar: meAvatar
},
{
id: 9,
sender: BOT_NAME,
content: '不客气,有任何问题随时联系我。',
time: '10:11',
isMe: false,
avatar: aiAvatar
}
]
const messages = ref<ChatMessage[]>(initializeMessages())
// 工具函数
const formatCurrentTime = (): string => {
return new Date().toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})
}
const scrollToBottom = (): void => {
nextTick(() => {
setTimeout(() => {
if (messageContainer.value) {
messageContainer.value.scrollTop = messageContainer.value.scrollHeight
}
}, SCROLL_DELAY)
})
}
// 消息处理方法
const sendMessage = (): void => {
const text = messageText.value.trim()
if (!text) return
const newMessage: ChatMessage = {
id: messageId.value++,
sender: USER_NAME,
content: text,
time: formatCurrentTime(),
isMe: true,
avatar: meAvatar
}
messages.value.push(newMessage)
messageText.value = ''
scrollToBottom()
}
// 聊天窗口控制方法
const openChat = (): void => {
isDrawerVisible.value = true
scrollToBottom()
}
const closeChat = (): void => {
isDrawerVisible.value = false
}
// 生命周期
onMounted(() => {
scrollToBottom()
mittBus.on('openChat', openChat)
})
onUnmounted(() => {
mittBus.off('openChat', openChat)
})
</script>

View File

@@ -0,0 +1,113 @@
<!-- 顶部快速入口面板 -->
<template>
<ElPopover
ref="popoverRef"
:width="700"
:offset="0"
:show-arrow="false"
trigger="hover"
placement="bottom-start"
popper-class="fast-enter-popover"
:popper-style="{
border: '1px solid var(--default-border)',
borderRadius: 'calc(var(--custom-radius) / 2 + 4px)'
}"
>
<template #reference>
<div class="flex-c gap-2">
<slot />
</div>
</template>
<div class="grid grid-cols-[2fr_0.8fr]">
<div>
<div class="grid grid-cols-2 gap-1.5">
<!-- 应用列表 -->
<div
v-for="application in enabledApplications"
:key="application.name"
class="mr-3 c-p flex-c gap-3 rounded-lg p-2 hover:bg-g-200/70 dark:hover:bg-g-200/90 hover:[&_.app-icon]:!bg-transparent"
@click="handleApplicationClick(application)"
>
<div class="app-icon size-12 flex-cc rounded-lg bg-g-200/80 dark:bg-g-300/30">
<ArtSvgIcon
class="text-xl"
:icon="application.icon"
:style="{ color: application.iconColor }"
/>
</div>
<div>
<h3 class="m-0 text-sm font-medium text-g-800">{{ application.name }}</h3>
<p class="mt-1 text-xs text-g-600">{{ application.description }}</p>
</div>
</div>
</div>
</div>
<div class="border-l-d pl-6 pt-2">
<h3 class="mb-2.5 text-base font-medium text-g-800">快速链接</h3>
<ul>
<li
v-for="quickLink in enabledQuickLinks"
:key="quickLink.name"
class="c-p py-2 hover:[&_span]:text-theme"
@click="handleQuickLinkClick(quickLink)"
>
<span class="text-g-600 no-underline">{{ quickLink.name }}</span>
</li>
</ul>
</div>
</div>
</ElPopover>
</template>
<script setup lang="ts">
import { useFastEnter } from '@/hooks/core/useFastEnter'
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
defineOptions({ name: 'ArtFastEnter' })
const router = useRouter()
const popoverRef = ref()
// 使用快速入口配置
const { enabledApplications, enabledQuickLinks } = useFastEnter()
/**
* 处理导航跳转
* @param routeName 路由名称
* @param link 外部链接
*/
const handleNavigate = (routeName?: string, link?: string): void => {
const targetPath = routeName || link
if (!targetPath) {
console.warn('导航配置无效:缺少路由名称或链接')
return
}
if (targetPath.startsWith('http')) {
window.open(targetPath, '_blank')
} else {
router.push({ name: targetPath })
}
popoverRef.value?.hide()
}
/**
* 处理应用项点击
* @param application 应用配置对象
*/
const handleApplicationClick = (application: FastEnterApplication): void => {
handleNavigate(application.routeName, application.link)
}
/**
* 处理快速链接点击
* @param quickLink 快速链接配置对象
*/
const handleQuickLinkClick = (quickLink: FastEnterQuickLink): void => {
handleNavigate(quickLink.routeName, quickLink.link)
}
</script>

View File

@@ -0,0 +1,633 @@
<!-- 烟花效果 | 礼花效果 -->
<template>
<canvas
ref="canvasRef"
class="fixed top-0 left-0 z-[9999] w-full h-full pointer-events-none"
></canvas>
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { mittBus } from '@/utils/sys'
import type { Handler } from 'mitt'
import bp from '@/assets/images/ceremony/hb.png'
import sd from '@/assets/images/ceremony/sd.png'
import yd from '@/assets/images/ceremony/yd.png'
defineOptions({ name: 'ArtFireworksEffect' })
/**
* 烟花系统配置接口
* 定义了烟花效果的所有可配置参数
*/
interface FireworkConfig {
/** 对象池大小 - 预先创建的粒子对象数量 */
readonly POOL_SIZE: number
/** 每次爆炸产生的粒子数量 */
readonly PARTICLES_PER_BURST: number
/** 各种形状的尺寸配置 */
readonly SIZES: {
/** 矩形粒子的宽高 */
readonly RECTANGLE: { readonly WIDTH: number; readonly HEIGHT: number }
/** 正方形粒子的边长 */
readonly SQUARE: { readonly SIZE: number }
/** 圆形粒子的直径 */
readonly CIRCLE: { readonly SIZE: number }
/** 三角形粒子的边长 */
readonly TRIANGLE: { readonly SIZE: number }
/** 椭圆粒子的宽高 */
readonly OVAL: { readonly WIDTH: number; readonly HEIGHT: number }
/** 图片粒子的宽高 */
readonly IMAGE: { readonly WIDTH: number; readonly HEIGHT: number }
}
/** 旋转相关参数 */
readonly ROTATION: {
/** 基础旋转速度 */
readonly BASE_SPEED: number
/** 随机旋转速度增量 */
readonly RANDOM_SPEED: number
/** 旋转衰减系数 - 控制旋转速度递减 */
readonly DECAY: number
}
/** 物理效果参数 */
readonly PHYSICS: {
/** 重力加速度 */
readonly GRAVITY: number
/** 下落速度阈值 - 超过此值开始淡出 */
readonly VELOCITY_THRESHOLD: number
/** 透明度衰减速度 */
readonly OPACITY_DECAY: number
}
/** 烟花粒子颜色配置(带透明度) */
readonly COLORS: readonly string[]
/** 烟花粒子形状配置(矩形出现概率更高) */
readonly SHAPES: readonly string[]
}
/**
* 单个烟花粒子对象
* 包含粒子的位置、速度、外观等所有属性
*/
interface Firework {
/** X坐标位置 */
x: number
/** Y坐标位置 */
y: number
/** X方向速度 */
vx: number
/** Y方向速度 */
vy: number
/** 粒子颜色 (RGBA格式) */
color: string
/** 当前旋转角度 */
rotation: number
/** 旋转速度 */
rotationSpeed: number
/** 缩放比例 */
scale: number
/** 粒子形状类型 */
shape: string
/** 透明度 (0-1) */
opacity: number
/** 是否处于活动状态 */
active: boolean
/** 图片URL (当shape为image时使用) */
imageUrl?: string
}
/**
* 图片缓存接口
* 用于缓存预加载的图片资源
*/
interface ImageCache {
[url: string]: HTMLImageElement
}
/**
* 烟花效果的全局配置
* 使用 as const 确保配置的不可变性
*/
const CONFIG: FireworkConfig = {
// 性能相关配置
POOL_SIZE: 600, // 对象池大小,影响同时存在的最大粒子数
PARTICLES_PER_BURST: 200, // 每次爆炸的粒子数量,影响视觉效果密度
// 粒子尺寸配置
SIZES: {
RECTANGLE: { WIDTH: 24, HEIGHT: 12 }, // 矩形粒子尺寸
SQUARE: { SIZE: 12 }, // 正方形粒子尺寸
CIRCLE: { SIZE: 12 }, // 圆形粒子尺寸
TRIANGLE: { SIZE: 10 }, // 三角形粒子尺寸
OVAL: { WIDTH: 24, HEIGHT: 12 }, // 椭圆粒子尺寸
IMAGE: { WIDTH: 30, HEIGHT: 30 } // 图片粒子尺寸
},
// 旋转动画配置
ROTATION: {
BASE_SPEED: 2, // 基础旋转速度
RANDOM_SPEED: 3, // 额外随机旋转速度范围
DECAY: 0.98 // 旋转速度衰减系数 (越小衰减越快)
},
// 物理效果配置
PHYSICS: {
GRAVITY: 0.525, // 重力加速度,影响粒子下落速度
VELOCITY_THRESHOLD: 10, // 速度阈值,超过时开始透明度衰减
OPACITY_DECAY: 0.02 // 透明度衰减速度,影响粒子消失快慢
},
// 粒子颜色配置 - 使用RGBA格式支持透明度
COLORS: [
'rgba(255, 68, 68, 1)', // 红色系
'rgba(255, 68, 68, 0.9)',
'rgba(255, 68, 68, 0.8)',
'rgba(255, 116, 188, 1)', // 粉色系
'rgba(255, 116, 188, 0.9)',
'rgba(255, 116, 188, 0.8)',
'rgba(68, 68, 255, 0.8)', // 蓝色系
'rgba(92, 202, 56, 0.7)', // 绿色系
'rgba(255, 68, 255, 0.8)', // 紫色系
'rgba(68, 255, 255, 0.7)', // 青色系
'rgba(255, 136, 68, 0.7)', // 橙色系
'rgba(68, 136, 255, 1)', // 蓝色系
'rgba(250, 198, 122, 0.8)' // 金色系
],
// 粒子形状配置 - 矩形出现概率更高,营造更丰富的视觉效果
SHAPES: [
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'rectangle',
'circle',
'triangle',
'oval'
]
} as const
/** Canvas DOM 元素引用 */
const canvasRef = ref<HTMLCanvasElement>()
/** Canvas 2D 绘制上下文 */
const ctx = ref<CanvasRenderingContext2D | null>(null)
/**
* 烟花系统核心类
* 负责管理粒子生命周期、渲染和动画
*/
class FireworkSystem {
/** 粒子对象池 - 预先创建的粒子对象数组 */
private particlePool: Firework[] = []
/** 当前活动的粒子数组 */
private activeParticles: Firework[] = []
/** 对象池索引指针 - 用于循环分配粒子 */
private poolIndex = 0
/** 图片资源缓存 */
private imageCache: ImageCache = {}
/** 动画帧ID - 用于取消动画 */
private animationId = 0
/** 画布宽度缓存 */
private canvasWidth = 0
/** 画布高度缓存 */
private canvasHeight = 0
constructor() {
this.initializePool()
}
/**
* 初始化对象池
* 预先创建指定数量的粒子对象,避免运行时频繁创建
*/
private initializePool(): void {
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
this.particlePool.push(this.createParticle())
}
}
/**
* 创建一个新的粒子对象
* 返回初始化状态的粒子
*/
private createParticle(): Firework {
return {
x: 0,
y: 0,
vx: 0,
vy: 0,
color: '',
rotation: 0,
rotationSpeed: 0,
scale: 1,
shape: 'circle',
opacity: 1,
active: false
}
}
/**
* 从对象池获取可用粒子 (性能优化版本)
* 使用循环索引而非Array.find()时间复杂度从O(n)降至O(1)
* @returns 可用的粒子对象或null
*/
private getAvailableParticle(): Firework | null {
for (let i = 0; i < CONFIG.POOL_SIZE; i++) {
const index = (this.poolIndex + i) % CONFIG.POOL_SIZE
const particle = this.particlePool[index]
if (!particle.active) {
this.poolIndex = (index + 1) % CONFIG.POOL_SIZE
particle.active = true
return particle
}
}
return null
}
/**
* 预加载单个图片资源
* @param url 图片URL
* @returns Promise<HTMLImageElement>
*/
async preloadImage(url: string): Promise<HTMLImageElement> {
// 如果已缓存,直接返回
if (this.imageCache[url]) {
return this.imageCache[url]
}
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous' // 处理跨域问题
img.onload = () => {
this.imageCache[url] = img
resolve(img)
}
img.onerror = reject
img.src = url
})
}
/**
* 预加载所有需要的图片资源
* 在组件初始化时调用确保图片ready
*/
async preloadAllImages(): Promise<void> {
const imageUrls = [bp, sd, yd]
try {
await Promise.all(imageUrls.map((url) => this.preloadImage(url)))
} catch (error) {
console.error('Image preloading failed:', error)
}
}
/**
* 创建烟花爆炸效果
* @param imageUrl 可选的图片URL如果提供则使用图片粒子
*/
createFirework(imageUrl?: string): void {
// 随机确定爆炸起始位置
const startX = Math.random() * this.canvasWidth
const startY = this.canvasHeight
// 根据是否有图片确定可用形状
const availableShapes = imageUrl && this.imageCache[imageUrl] ? ['image'] : CONFIG.SHAPES
// 批量创建粒子数组,减少频繁的数组操作
const particles: Firework[] = []
for (let i = 0; i < CONFIG.PARTICLES_PER_BURST; i++) {
const particle = this.getAvailableParticle()
if (!particle) continue
// 计算粒子发射角度和速度 (恢复原始算法)
const angle = (Math.PI * i) / (CONFIG.PARTICLES_PER_BURST / 2) // 扇形分布
const speed = (12 + Math.random() * 6) * 1.5 // 随机速度
const spread = Math.random() * Math.PI * 2 // 360度随机扩散
// 直接属性赋值避免Object.assign的性能开销
particle.x = startX
particle.y = startY
// 复杂的速度计算,模拟真实烟花爆炸轨迹
particle.vx = Math.cos(angle) * Math.cos(spread) * speed * (Math.random() * 0.5 + 0.5)
particle.vy = Math.sin(angle) * speed - 15 // 向上初始速度
particle.color = CONFIG.COLORS[Math.floor(Math.random() * CONFIG.COLORS.length)]
particle.rotation = Math.random() * 360
particle.rotationSpeed =
(Math.random() * CONFIG.ROTATION.RANDOM_SPEED + CONFIG.ROTATION.BASE_SPEED) *
(Math.random() > 0.5 ? 1 : -1) // 随机旋转方向
particle.scale = 0.8 + Math.random() * 0.4 // 随机缩放
particle.shape = availableShapes[Math.floor(Math.random() * availableShapes.length)]
particle.opacity = 1
particle.imageUrl = imageUrl && this.imageCache[imageUrl] ? imageUrl : undefined
particles.push(particle)
}
// 批量添加到活动粒子数组,减少多次数组操作
this.activeParticles.push(...particles)
}
/**
* 更新所有粒子的物理状态 (性能优化版本)
* 包括位置、速度、旋转、透明度等
*/
private updateParticles(): void {
const { GRAVITY, VELOCITY_THRESHOLD, OPACITY_DECAY } = CONFIG.PHYSICS
const { DECAY } = CONFIG.ROTATION
// 使用倒序遍历,避免删除元素时的索引混乱问题
for (let i = this.activeParticles.length - 1; i >= 0; i--) {
const particle = this.activeParticles[i]
// 更新粒子位置 (匀加速运动)
particle.x += particle.vx
particle.y += particle.vy
particle.vy += GRAVITY // 重力影响
// 更新旋转状态
particle.rotation += particle.rotationSpeed
particle.rotationSpeed *= DECAY // 旋转速度衰减
// 透明度衰减逻辑 - 当粒子下落速度超过阈值时开始淡出
if (particle.vy > VELOCITY_THRESHOLD) {
particle.opacity -= OPACITY_DECAY
if (particle.opacity <= 0) {
this.recycleParticle(i)
continue
}
}
// 边界检查 - 移除超出屏幕范围的粒子
if (this.isOutOfBounds(particle)) {
this.recycleParticle(i)
}
}
}
/**
* 回收粒子到对象池
* @param index 要回收的粒子在活动数组中的索引
*/
private recycleParticle(index: number): void {
const particle = this.activeParticles[index]
particle.active = false // 标记为非活动状态
this.activeParticles.splice(index, 1) // 从活动数组中移除
}
/**
* 检查粒子是否超出屏幕边界
* @param particle 要检查的粒子
* @returns 是否超出边界
*/
private isOutOfBounds(particle: Firework): boolean {
const margin = 100 // 边界缓冲区
return (
particle.x < -margin ||
particle.x > this.canvasWidth + margin ||
particle.y < -margin ||
particle.y > this.canvasHeight + margin
)
}
/**
* 绘制单个粒子
* @param particle 要绘制的粒子对象
*/
private drawParticle(particle: Firework): void {
if (!ctx.value) return
// 保存当前画布状态
ctx.value.save()
ctx.value.globalAlpha = particle.opacity // 设置透明度
ctx.value.translate(particle.x, particle.y) // 移动到粒子位置
ctx.value.rotate((particle.rotation * Math.PI) / 180) // 应用旋转
ctx.value.scale(particle.scale, particle.scale) // 应用缩放
// 渲染粒子形状
this.renderShape(particle)
// 恢复画布状态
ctx.value.restore()
}
/**
* 根据粒子类型渲染对应的形状
* @param particle 要渲染的粒子
*/
private renderShape(particle: Firework): void {
if (!ctx.value) return
const { SIZES } = CONFIG
ctx.value.fillStyle = particle.color
switch (particle.shape) {
case 'rectangle':
// 绘制矩形
ctx.value.fillRect(
-SIZES.RECTANGLE.WIDTH / 2,
-SIZES.RECTANGLE.HEIGHT / 2,
SIZES.RECTANGLE.WIDTH,
SIZES.RECTANGLE.HEIGHT
)
break
case 'square':
// 绘制正方形
ctx.value.fillRect(
-SIZES.SQUARE.SIZE / 2,
-SIZES.SQUARE.SIZE / 2,
SIZES.SQUARE.SIZE,
SIZES.SQUARE.SIZE
)
break
case 'circle':
// 绘制圆形
ctx.value.beginPath()
ctx.value.arc(0, 0, SIZES.CIRCLE.SIZE / 2, 0, Math.PI * 2)
ctx.value.fill()
break
case 'triangle':
// 绘制三角形
ctx.value.beginPath()
ctx.value.moveTo(0, -SIZES.TRIANGLE.SIZE)
ctx.value.lineTo(SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE)
ctx.value.lineTo(-SIZES.TRIANGLE.SIZE, SIZES.TRIANGLE.SIZE)
ctx.value.closePath()
ctx.value.fill()
break
case 'oval':
// 绘制椭圆
ctx.value.beginPath()
ctx.value.ellipse(0, 0, SIZES.OVAL.WIDTH / 2, SIZES.OVAL.HEIGHT / 2, 0, 0, Math.PI * 2)
ctx.value.fill()
break
case 'image':
// 绘制图片
this.renderImage(particle)
break
}
}
/**
* 渲染图片类型的粒子
* @param particle 包含图片URL的粒子对象
*/
private renderImage(particle: Firework): void {
if (!ctx.value || !particle.imageUrl) return
const img = this.imageCache[particle.imageUrl]
if (img?.complete) {
const { WIDTH, HEIGHT } = CONFIG.SIZES.IMAGE
ctx.value.drawImage(img, -WIDTH / 2, -HEIGHT / 2, WIDTH, HEIGHT)
}
}
/**
* 渲染所有活动粒子到画布
* 清除画布并重新绘制所有粒子
*/
private render(): void {
if (!ctx.value || !canvasRef.value) return
// 清除整个画布
ctx.value.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
// 设置混合模式为"变亮",增强视觉效果
ctx.value.globalCompositeOperation = 'lighter'
// 渲染所有活动粒子
for (const particle of this.activeParticles) {
this.drawParticle(particle)
}
}
/**
* 动画主循环
* 使用箭头函数保持this绑定
*/
private animate = (): void => {
this.updateParticles() // 更新粒子状态
this.render() // 渲染粒子
this.animationId = requestAnimationFrame(this.animate) // 请求下一帧
}
/**
* 更新画布尺寸缓存
* 在窗口大小改变时调用
* @param width 新的画布宽度
* @param height 新的画布高度
*/
updateCanvasSize(width: number, height: number): void {
this.canvasWidth = width
this.canvasHeight = height
}
/**
* 启动动画循环
*/
start(): void {
this.animate()
}
/**
* 停止动画循环
* 在组件卸载时调用,避免内存泄漏
*/
stop(): void {
if (this.animationId) {
cancelAnimationFrame(this.animationId)
this.animationId = 0
}
}
/**
* 获取当前活动粒子数量
* 用于调试和性能监控
* @returns 活动粒子数量
*/
getActiveParticleCount(): number {
return this.activeParticles.length
}
}
/** 烟花系统实例 */
const fireworkSystem = new FireworkSystem()
/**
* 处理键盘快捷键
* 监听 Ctrl+Shift+P 或 Cmd+Shift+P 组合键触发烟花
* @param event 键盘事件对象
*/
const handleKeyPress = (event: KeyboardEvent): void => {
const isFireworkShortcut =
(event.ctrlKey && event.shiftKey && event.key.toLowerCase() === 'p') ||
(event.metaKey && event.shiftKey && event.key.toLowerCase() === 'p')
if (isFireworkShortcut) {
event.preventDefault()
fireworkSystem.createFirework()
}
}
/**
* 调整Canvas画布大小
* 响应窗口大小变化,确保画布始终覆盖整个视口
*/
const resizeCanvas = (): void => {
if (!canvasRef.value) return
const { innerWidth, innerHeight } = window
canvasRef.value.width = innerWidth
canvasRef.value.height = innerHeight
fireworkSystem.updateCanvasSize(innerWidth, innerHeight)
}
/**
* 处理外部触发的烟花事件
* 通过 mittBus 事件总线接收触发指令
* @param event 事件数据可能包含图片URL
*/
const handleFireworkTrigger: Handler<unknown> = (event: unknown) => {
const imageUrl = event as string | undefined
fireworkSystem.createFirework(imageUrl)
}
/**
* 组件挂载时的初始化逻辑
*/
onMounted(async () => {
if (!canvasRef.value) return
// 获取Canvas 2D绘制上下文
ctx.value = canvasRef.value.getContext('2d')
if (!ctx.value) return
// 设置初始画布大小
resizeCanvas()
// 预加载所有图片资源
await fireworkSystem.preloadAllImages()
// 启动动画循环
fireworkSystem.start()
// 注册事件监听器
useEventListener(window, 'keydown', handleKeyPress) // 键盘快捷键
useEventListener(window, 'resize', resizeCanvas) // 窗口大小变化
mittBus.on('triggerFireworks', handleFireworkTrigger) // 外部触发事件
})
/**
* 组件卸载时的清理逻辑
* 停止动画循环并移除事件监听器,防止内存泄漏
*/
onUnmounted(() => {
fireworkSystem.stop()
mittBus.off('triggerFireworks', handleFireworkTrigger)
})
</script>

View File

@@ -0,0 +1,14 @@
<!-- 全局组件 -->
<template>
<component
v-for="componentConfig in enabledComponents"
:key="componentConfig.key"
:is="componentConfig.component"
/>
</template>
<script setup lang="ts">
import { getEnabledGlobalComponents } from '@/config/modules/component'
defineOptions({ name: 'ArtGlobalComponent' })
const enabledComponents = computed(() => getEnabledGlobalComponents())
</script>

View File

@@ -0,0 +1,426 @@
<!-- 全局搜索组件 -->
<template>
<div class="layout-search">
<ElDialog
v-model="showSearchDialog"
width="600"
:show-close="false"
:lock-scroll="false"
modal-class="search-modal"
@close="closeSearchDialog"
>
<ElInput
v-model.trim="searchVal"
:placeholder="$t('search.placeholder')"
@input="search"
@blur="searchBlur"
ref="searchInput"
:prefix-icon="Search"
class="h-12"
>
<template #suffix>
<div
class="h-4.5 flex-cc rounded border border-g-300 dark:!bg-g-200/50 !bg-box px-1.5 text-g-500"
>
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" />
</div>
</template>
</ElInput>
<ElScrollbar class="mt-5" max-height="370px" ref="searchResultScrollbar" always>
<div class="result w-full" v-show="searchResult.length">
<div
class="box !mt-0 c-p text-base leading-none"
v-for="(item, index) in searchResult"
:key="index"
>
<div
class="mt-2 h-12 flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-700"
:class="isHighlighted(index) ? 'highlighted !bg-theme/70 !text-white' : ''"
@click="searchGoPage(item)"
@mouseenter="highlightOnHover(index)"
>
{{ formatMenuTitle(item.meta.title) }}
<ArtSvgIcon v-show="isHighlighted(index)" icon="fluent:arrow-enter-left-20-filled" />
</div>
</div>
</div>
<div v-show="!searchVal && searchResult.length === 0 && historyResult.length > 0">
<p class="text-xs text-g-500">{{ $t('search.historyTitle') }}</p>
<div class="mt-1.5 w-full">
<div
class="box mt-2 h-12 c-p flex-cb rounded-custom-sm bg-g-200/80 px-4 text-sm text-g-800"
v-for="(item, index) in historyResult"
:key="index"
:class="
historyHIndex === index
? 'highlighted !bg-theme/70 !text-white [&_.selected-icon]:!text-white'
: ''
"
@click="searchGoPage(item)"
@mouseenter="highlightOnHoverHistory(index)"
>
{{ formatMenuTitle(item.meta.title) }}
<div
class="size-5 selected-icon select-none rounded-full text-g-500 flex-cc c-p"
@click.stop="deleteHistory(index)"
>
<ArtSvgIcon icon="ri:close-large-fill" class="text-xs" />
</div>
</div>
</div>
</div>
</ElScrollbar>
<template #footer>
<div class="dialog-footer box-border flex-c border-t-d pt-4.5 pb-1">
<div class="flex-cc">
<ArtSvgIcon icon="fluent:arrow-enter-left-20-filled" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.selectKeydown') }}</span>
</div>
<div class="flex-c">
<ArtSvgIcon icon="ri:arrow-up-wide-fill" class="keyboard" />
<ArtSvgIcon icon="ri:arrow-down-wide-fill" class="keyboard" />
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.switchKeydown') }}</span>
</div>
<div class="flex-c">
<i class="keyboard !w-8 flex-cc"><p class="text-[10px] font-medium">ESC</p></i>
<span class="mr-3.5 text-xs text-g-700">{{ $t('search.exitKeydown') }}</span>
</div>
</div>
</template>
</ElDialog>
</div>
</template>
<script lang="ts" setup>
import { useUserStore } from '@/store/modules/user'
import { AppRouteRecord } from '@/types/router'
import { Search } from '@element-plus/icons-vue'
import { mittBus } from '@/utils/sys'
import { useMenuStore } from '@/store/modules/menu'
import { formatMenuTitle } from '@/utils/router'
import { type ScrollbarInstance } from 'element-plus'
defineOptions({ name: 'ArtGlobalSearch' })
const router = useRouter()
const userStore = useUserStore()
const { menuList } = storeToRefs(useMenuStore())
const showSearchDialog = ref(false)
const searchVal = ref('')
const searchResult = ref<AppRouteRecord[]>([])
const historyMaxLength = 10
const { searchHistory: historyResult } = storeToRefs(userStore)
const searchInput = ref<HTMLInputElement | null>(null)
const highlightedIndex = ref(0)
const historyHIndex = ref(0)
const searchResultScrollbar = ref<ScrollbarInstance>()
const isKeyboardNavigating = ref(false) // 新增状态:是否正在使用键盘导航
// 生命周期钩子
onMounted(() => {
mittBus.on('openSearchDialog', openSearchDialog)
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
// 键盘快捷键处理
const handleKeydown = (event: KeyboardEvent) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
const isCommandKey = isMac ? event.metaKey : event.ctrlKey
if (isCommandKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
showSearchDialog.value = true
focusInput()
}
// 当搜索对话框打开时,处理方向键和回车键
if (showSearchDialog.value) {
if (event.key === 'ArrowUp') {
event.preventDefault()
highlightPrevious()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
highlightNext()
} else if (event.key === 'Enter') {
event.preventDefault()
selectHighlighted()
} else if (event.key === 'Escape') {
event.preventDefault()
showSearchDialog.value = false
}
}
}
const focusInput = () => {
setTimeout(() => {
searchInput.value?.focus()
}, 100)
}
// 搜索逻辑
const search = (val: string) => {
if (val) {
searchResult.value = flattenAndFilterMenuItems(menuList.value, val)
} else {
searchResult.value = []
}
}
const flattenAndFilterMenuItems = (items: AppRouteRecord[], val: string): AppRouteRecord[] => {
const lowerVal = val.toLowerCase()
const result: AppRouteRecord[] = []
const flattenAndMatch = (item: AppRouteRecord) => {
if (item.meta?.isHide) return
const lowerItemTitle = formatMenuTitle(item.meta.title).toLowerCase()
if (item.children && item.children.length > 0) {
item.children.forEach(flattenAndMatch)
return
}
if (lowerItemTitle.includes(lowerVal) && item.path) {
result.push({ ...item, children: undefined })
}
}
items.forEach(flattenAndMatch)
return result
}
// 高亮控制并实现滚动条跟随
const highlightPrevious = () => {
isKeyboardNavigating.value = true
if (searchVal.value) {
highlightedIndex.value =
(highlightedIndex.value - 1 + searchResult.value.length) % searchResult.value.length
scrollToHighlightedItem()
} else {
historyHIndex.value =
(historyHIndex.value - 1 + historyResult.value.length) % historyResult.value.length
scrollToHighlightedHistoryItem()
}
// 延迟重置键盘导航状态,防止立即被 hover 覆盖
setTimeout(() => {
isKeyboardNavigating.value = false
}, 100)
}
const highlightNext = () => {
isKeyboardNavigating.value = true
if (searchVal.value) {
highlightedIndex.value = (highlightedIndex.value + 1) % searchResult.value.length
scrollToHighlightedItem()
} else {
historyHIndex.value = (historyHIndex.value + 1) % historyResult.value.length
scrollToHighlightedHistoryItem()
}
setTimeout(() => {
isKeyboardNavigating.value = false
}, 100)
}
const scrollToHighlightedItem = () => {
nextTick(() => {
if (!searchResultScrollbar.value || !searchResult.value.length) return
const scrollWrapper = searchResultScrollbar.value.wrapRef
if (!scrollWrapper) return
const highlightedElements = scrollWrapper.querySelectorAll('.result .box')
if (!highlightedElements[highlightedIndex.value]) return
const highlightedElement = highlightedElements[highlightedIndex.value] as HTMLElement
const itemHeight = highlightedElement.offsetHeight
const scrollTop = scrollWrapper.scrollTop
const containerHeight = scrollWrapper.clientHeight
const itemTop = highlightedElement.offsetTop
const itemBottom = itemTop + itemHeight
if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop)
} else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
}
})
}
const scrollToHighlightedHistoryItem = () => {
nextTick(() => {
if (!searchResultScrollbar.value || !historyResult.value.length) return
const scrollWrapper = searchResultScrollbar.value.wrapRef
if (!scrollWrapper) return
const historyItems = scrollWrapper.querySelectorAll('.history-result .box')
if (!historyItems[historyHIndex.value]) return
const highlightedElement = historyItems[historyHIndex.value] as HTMLElement
const itemHeight = highlightedElement.offsetHeight
const scrollTop = scrollWrapper.scrollTop
const containerHeight = scrollWrapper.clientHeight
const itemTop = highlightedElement.offsetTop
const itemBottom = itemTop + itemHeight
if (itemTop < scrollTop) {
searchResultScrollbar.value.setScrollTop(itemTop)
} else if (itemBottom > scrollTop + containerHeight) {
searchResultScrollbar.value.setScrollTop(itemBottom - containerHeight)
}
})
}
const selectHighlighted = () => {
if (searchVal.value && searchResult.value.length) {
searchGoPage(searchResult.value[highlightedIndex.value])
} else if (!searchVal.value && historyResult.value.length) {
searchGoPage(historyResult.value[historyHIndex.value])
}
}
const isHighlighted = (index: number) => {
return highlightedIndex.value === index
}
const searchBlur = () => {
highlightedIndex.value = 0
}
const searchGoPage = (item: AppRouteRecord) => {
showSearchDialog.value = false
addHistory(item)
router.push(item.path)
searchVal.value = ''
searchResult.value = []
}
// 历史记录管理
const updateHistory = () => {
if (Array.isArray(historyResult.value)) {
userStore.setSearchHistory(historyResult.value)
}
}
const addHistory = (item: AppRouteRecord) => {
const hasItemIndex = historyResult.value.findIndex(
(historyItem: AppRouteRecord) => historyItem.path === item.path
)
if (hasItemIndex !== -1) {
historyResult.value.splice(hasItemIndex, 1)
} else if (historyResult.value.length >= historyMaxLength) {
historyResult.value.pop()
}
const cleanedItem = { ...item }
delete cleanedItem.children
delete cleanedItem.meta.authList
historyResult.value.unshift(cleanedItem)
updateHistory()
}
const deleteHistory = (index: number) => {
historyResult.value.splice(index, 1)
updateHistory()
}
// 对话框控制
const openSearchDialog = () => {
showSearchDialog.value = true
focusInput()
}
const closeSearchDialog = () => {
searchVal.value = ''
searchResult.value = []
highlightedIndex.value = 0
historyHIndex.value = 0
}
// 修改 hover 高亮逻辑,只有在非键盘导航时才生效
const highlightOnHover = (index: number) => {
if (!isKeyboardNavigating.value && searchVal.value) {
highlightedIndex.value = index
}
}
const highlightOnHoverHistory = (index: number) => {
if (!isKeyboardNavigating.value && !searchVal.value) {
historyHIndex.value = index
}
}
</script>
<style lang="scss" scoped>
.layout-search {
:deep(.search-modal) {
background-color: rgb(0 0 0 / 20%);
}
:deep(.el-dialog__body) {
padding: 5px 0 0 !important;
}
:deep(.el-dialog__header) {
padding: 0;
}
.el-input {
:deep(.el-input__wrapper) {
background-color: var(--art-gray-200);
border: 1px solid var(--default-border-dashed);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
box-shadow: none;
}
:deep(.el-input__inner) {
color: var(--art-gray-800) !important;
}
}
}
.dark .layout-search {
.el-input {
:deep(.el-input__wrapper) {
background-color: #333;
border: 1px solid #4c4d50;
}
}
:deep(.search-modal) {
background-color: rgb(23 23 26 / 60%);
backdrop-filter: none;
}
:deep(.el-dialog) {
background-color: #252526;
}
}
</style>
<style scoped>
@reference '@styles/core/tailwind.css';
.keyboard {
@apply mr-2
box-border
h-5
w-5.5
rounded
border
border-g-400
px-1
text-g-500
shadow-[0_2px_0_var(--default-border-dashed)]
last-of-type:mr-1.5;
}
</style>

View File

@@ -0,0 +1,485 @@
<!-- 顶部栏 -->
<template>
<div
class="w-full bg-[var(--default-bg-color)]"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google' ? 'mb-5 max-sm:mb-3 !bg-box' : ''
]"
>
<div
class="relative box-border flex-b h-15 leading-15 select-none"
:class="[
tabStyle === 'tab-card' || tabStyle === 'tab-google'
? 'border-b border-[var(--art-card-border)]'
: ''
]"
>
<div class="flex-c flex-1 min-w-0 leading-15" style="display: flex">
<!-- 系统信息 -->
<div class="flex-c c-p" @click="toHome" v-if="isTopMenu">
<ArtLogo class="pl-4.5" />
<p v-if="width >= 1400" class="my-0 mx-2 ml-2 text-lg">{{ AppConfig.systemInfo.name }}</p>
</div>
<ArtLogo
class="!hidden pl-3.5 overflow-hidden align-[-0.15em] fill-current"
@click="toHome"
/>
<!-- 菜单按钮 -->
<ArtIconButton
v-if="isLeftMenu && shouldShowMenuButton"
icon="ri:menu-2-fill"
class="ml-3 max-sm:ml-[7px]"
@click="visibleMenu"
/>
<!-- 刷新按钮 -->
<ArtIconButton
v-if="shouldShowRefreshButton"
icon="ri:refresh-line"
class="!ml-3 refresh-btn max-sm:!hidden"
:style="{ marginLeft: !isLeftMenu ? '10px' : '0' }"
@click="reload"
/>
<!-- 快速入口 -->
<!-- <ArtFastEnter v-if="shouldShowFastEnter && width >= headerBarFastEnterMinWidth">
<ArtIconButton icon="ri:function-line" class="ml-3" />
</ArtFastEnter> -->
<!-- 面包屑 -->
<ArtBreadcrumb
v-if="(shouldShowBreadcrumb && isLeftMenu) || (shouldShowBreadcrumb && isDualMenu)"
/>
<!-- 顶部菜单 -->
<ArtHorizontalMenu v-if="isTopMenu" :list="menuList" />
<!-- 混合菜单-顶部 -->
<ArtMixedMenu v-if="isTopLeftMenu" :list="menuList" />
</div>
<div class="flex-c gap-2.5">
<!-- 搜索 -->
<div
v-if="shouldShowGlobalSearch"
class="flex-cb w-40 h-9 px-2.5 c-p border border-g-400 rounded-custom-sm max-md:!hidden"
@click="openSearchDialog"
>
<div class="flex-c">
<ArtSvgIcon icon="ri:search-line" class="text-sm text-g-500" />
<span class="ml-1 text-xs font-normal text-g-500">{{ $t('topBar.search.title') }}</span>
</div>
<div class="flex-c h-5 px-1.5 text-g-500/80 border border-g-400 rounded">
<ArtSvgIcon v-if="isWindows" icon="vaadin:ctrl-a" class="text-sm" />
<ArtSvgIcon v-else icon="ri:command-fill" class="text-xs" />
<span class="ml-0.5 text-xs">k</span>
</div>
</div>
<!-- 全屏按钮 -->
<ArtIconButton
v-if="shouldShowFullscreen"
:icon="isFullscreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-fill'"
:class="[!isFullscreen ? 'full-screen-btn' : 'exit-full-screen-btn', 'ml-3']"
class="max-md:!hidden"
@click="toggleFullScreen"
/>
<!-- 国际化按钮 -->
<ElDropdown
@command="changeLanguage"
popper-class="langDropDownStyle"
v-if="shouldShowLanguage"
>
<ArtIconButton icon="ri:translate-2" class="language-btn text-[19px]" />
<template #dropdown>
<ElDropdownMenu>
<div v-for="item in languageOptions" :key="item.value" class="lang-btn-item">
<ElDropdownItem
:command="item.value"
:class="{ 'is-selected': locale === item.value }"
>
<span class="menu-txt">{{ item.label }}</span>
<ArtSvgIcon icon="ri:check-fill" v-if="locale === item.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<!-- 通知按钮 -->
<!-- <ArtIconButton
v-if="shouldShowNotification"
icon="ri:notification-2-line"
class="notice-button relative"
@click="visibleNotice"
>
<div class="absolute top-2 right-2 size-1.5 !bg-danger rounded-full"></div>
</ArtIconButton> -->
<!-- 聊天按钮 -->
<!-- <ArtIconButton
v-if="shouldShowChat"
icon="ri:message-3-line"
class="chat-button relative"
@click="openChat"
>
<div class="breathing-dot absolute top-2 right-2 size-1.5 !bg-success rounded-full"></div>
</ArtIconButton> -->
<!-- 设置按钮 -->
<div v-if="shouldShowSettings">
<ElPopover placement="bottom-start" :width="190" :offset="0">
<template #reference>
<div class="flex-cc">
<ArtIconButton icon="ri:settings-line" class="setting-btn" @click="openSetting" />
</div>
</template>
<template #default>
<p
>{{ $t('topBar.guide.title')
}}<span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.theme') }} </span
> <span :style="{ color: systemThemeColor }"> {{ $t('topBar.guide.menu') }} </span
>{{ $t('topBar.guide.description') }}
</p>
</template>
</ElPopover>
</div>
<!-- 主题切换按钮 -->
<ArtIconButton
v-if="shouldShowThemeToggle"
@click="themeAnimation"
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
/>
<!-- 用户头像菜单 -->
<ArtUserMenu />
</div>
</div>
<!-- 标签页 -->
<ArtWorkTab />
<!-- 通知 -->
<ArtNotification v-model:value="showNotice" ref="notice" />
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useFullscreen, useWindowSize } from '@vueuse/core'
import { LanguageEnum, MenuTypeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import AppConfig from '@/config'
import { languageOptions } from '@/locales'
import { mittBus } from '@/utils/sys'
import { themeAnimation } from '@/utils/ui/animation'
import { useCommon } from '@/hooks/core/useCommon'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import ArtUserMenu from './widget/ArtUserMenu.vue'
defineOptions({ name: 'ArtHeaderBar' })
// 检测操作系统类型
const isWindows = navigator.userAgent.includes('Windows')
const router = useRouter()
const { locale } = useI18n()
const { width } = useWindowSize()
const settingStore = useSettingStore()
const userStore = useUserStore()
const menuStore = useMenuStore()
// 顶部栏功能配置
const {
shouldShowMenuButton,
shouldShowRefreshButton,
shouldShowFastEnter,
shouldShowBreadcrumb,
shouldShowGlobalSearch,
shouldShowFullscreen,
shouldShowNotification,
shouldShowChat,
shouldShowLanguage,
shouldShowSettings,
shouldShowThemeToggle,
fastEnterMinWidth: headerBarFastEnterMinWidth
} = useHeaderBar()
const { menuOpen, systemThemeColor, showSettingGuide, menuType, isDark, tabStyle } =
storeToRefs(settingStore)
const { language } = storeToRefs(userStore)
const { menuList } = storeToRefs(menuStore)
const showNotice = ref(false)
const notice = ref(null)
// 菜单类型判断
const isLeftMenu = computed(() => menuType.value === MenuTypeEnum.LEFT)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const { isFullscreen, toggle: toggleFullscreen } = useFullscreen()
onMounted(() => {
initLanguage()
document.addEventListener('click', bodyCloseNotice)
})
onUnmounted(() => {
document.removeEventListener('click', bodyCloseNotice)
})
/**
* 切换全屏状态
*/
const toggleFullScreen = (): void => {
toggleFullscreen()
}
/**
* 切换菜单显示/隐藏状态
*/
const visibleMenu = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
}
const { homePath } = useCommon()
const { refresh } = useCommon()
/**
* 跳转到首页
*/
const toHome = (): void => {
router.push(homePath.value)
}
/**
* 刷新页面
* @param {number} time - 延迟时间默认为0毫秒
*/
const reload = (time: number = 0): void => {
setTimeout(() => {
refresh()
}, time)
}
/**
* 初始化语言设置
*/
const initLanguage = (): void => {
locale.value = language.value
}
/**
* 切换系统语言
* @param {LanguageEnum} lang - 目标语言类型
*/
const changeLanguage = (lang: LanguageEnum): void => {
if (locale.value === lang) return
locale.value = lang
userStore.setLanguage(lang)
reload(50)
}
/**
* 打开设置面板
*/
const openSetting = (): void => {
mittBus.emit('openSetting')
// 隐藏设置引导提示
if (showSettingGuide.value) {
settingStore.hideSettingGuide()
}
}
/**
* 打开全局搜索对话框
*/
const openSearchDialog = (): void => {
mittBus.emit('openSearchDialog')
}
/**
* 点击页面其他区域关闭通知面板
* @param {Event} e - 点击事件对象
*/
const bodyCloseNotice = (e: any): void => {
if (!showNotice.value) return
const target = e.target as HTMLElement
// 检查是否点击了通知按钮或通知面板内部
const isNoticeButton = target.closest('.notice-button')
const isNoticePanel = target.closest('.art-notification-panel')
if (!isNoticeButton && !isNoticePanel) {
showNotice.value = false
}
}
/**
* 切换通知面板显示状态
*/
const visibleNotice = (): void => {
showNotice.value = !showNotice.value
}
/**
* 打开聊天窗口
*/
const openChat = (): void => {
mittBus.emit('openChat')
}
</script>
<style lang="scss" scoped>
/* Custom animations */
@keyframes rotate180 {
0% {
transform: rotate(0);
}
100% {
transform: rotate(180deg);
}
}
@keyframes shake {
0% {
transform: rotate(0);
}
25% {
transform: rotate(-5deg);
}
50% {
transform: rotate(5deg);
}
75% {
transform: rotate(-5deg);
}
100% {
transform: rotate(0);
}
}
@keyframes expand {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes shrink {
0% {
transform: scale(1);
}
50% {
transform: scale(0.9);
}
100% {
transform: scale(1);
}
}
@keyframes moveUp {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-3px);
}
100% {
transform: translateY(0);
}
}
@keyframes breathing {
0% {
opacity: 0.4;
transform: scale(0.9);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.4;
transform: scale(0.9);
}
}
/* Hover animation classes */
.refresh-btn:hover :deep(.art-svg-icon) {
animation: rotate180 0.5s;
}
.language-btn:hover :deep(.art-svg-icon) {
animation: moveUp 0.4s;
}
.setting-btn:hover :deep(.art-svg-icon) {
animation: rotate180 0.5s;
}
.full-screen-btn:hover :deep(.art-svg-icon) {
animation: expand 0.6s forwards;
}
.exit-full-screen-btn:hover :deep(.art-svg-icon) {
animation: shrink 0.6s forwards;
}
.notice-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out;
}
.chat-button:hover :deep(.art-svg-icon) {
animation: shake 0.5s ease-in-out;
}
/* Breathing animation for chat dot */
.breathing-dot {
animation: breathing 1.5s ease-in-out infinite;
}
/* iPad breakpoint adjustments */
@media screen and (width <= 768px) {
.logo2 {
display: block !important;
}
}
@media screen and (width <= 640px) {
.btn-box {
width: 40px;
}
}
</style>

View File

@@ -0,0 +1,156 @@
<!-- 用户菜单 -->
<template>
<ElPopover
ref="userMenuPopover"
placement="bottom-end"
:width="240"
:hide-after="0"
:offset="10"
trigger="hover"
:show-arrow="false"
popper-class="user-menu-popover"
popper-style="padding: 5px 16px;"
>
<template #reference>
<img
class="size-8.5 mr-5 c-p rounded-full max-sm:w-6.5 max-sm:h-6.5 max-sm:mr-[16px]"
:src="userInfo.avatar || '@imgs/user/avatar.webp'"
alt="avatar"
/>
</template>
<template #default>
<div class="pt-3">
<div class="flex-c pb-1 px-0">
<img
class="w-10 h-10 mr-3 ml-0 overflow-hidden rounded-full float-left"
:src="userInfo.avatar || '@imgs/user/avatar.webp'"
/>
<div class="w-[calc(100%-60px)] h-full">
<span class="block text-sm font-medium text-g-800 truncate">{{
userInfo.username
}}</span>
<span class="block mt-0.5 text-xs text-g-500 truncate">{{ userInfo.email }}</span>
</div>
</div>
<ul class="py-4 mt-3 border-t border-g-300/80">
<li class="btn-item" @click="goPage('/dashboard/user-center')">
<ArtSvgIcon icon="ri:user-3-line" />
<span>{{ $t('topBar.user.userCenter') }}</span>
</li>
<li class="btn-item" @click="clearCache()">
<ArtSvgIcon icon="ri:eraser-line" />
<span>清除缓存</span>
</li>
<li class="btn-item" @click="lockScreen()">
<ArtSvgIcon icon="ri:lock-line" />
<span>{{ $t('topBar.user.lockScreen') }}</span>
</li>
<div class="w-full h-px my-2 bg-g-300/80"></div>
<div class="log-out c-p" @click="loginOut">
{{ $t('topBar.user.logout') }}
</div>
</ul>
</div>
</template>
</ElPopover>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ElMessageBox, ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { mittBus } from '@/utils/sys'
defineOptions({ name: 'ArtUserMenu' })
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const { getUserInfo: userInfo } = storeToRefs(userStore)
const userMenuPopover = ref()
/**
* 页面跳转
* @param {string} path - 目标路径
*/
const goPage = (path: string): void => {
router.push(path)
}
/**
* 清理缓存
*/
const clearCache = (): void => {
userStore.clearCache()
ElMessage.success('清理缓存成功')
}
/**
* 打开锁屏功能
*/
const lockScreen = (): void => {
mittBus.emit('openLockScreen')
}
/**
* 用户登出确认
*/
const loginOut = (): void => {
closeUserMenu()
setTimeout(() => {
ElMessageBox.confirm(t('common.logOutTips'), t('common.tips'), {
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
customClass: 'login-out-dialog'
}).then(() => {
userStore.logOut()
})
}, 200)
}
/**
* 关闭用户菜单弹出层
*/
const closeUserMenu = (): void => {
setTimeout(() => {
userMenuPopover.value.hide()
}, 100)
}
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
@layer components {
.btn-item {
@apply flex items-center p-2 mb-3 select-none rounded-md cursor-pointer last:mb-0;
span {
@apply text-sm;
}
.art-svg-icon {
@apply mr-2 text-base;
}
&:hover {
background-color: var(--art-gray-200);
}
}
}
.log-out {
@apply py-1.5
mt-5
text-xs
text-center
border
border-g-400
rounded-md
transition-all
duration-200
hover:shadow-xl;
}
</style>

View File

@@ -0,0 +1,110 @@
<!-- 水平菜单 -->
<template>
<div class="flex-1 overflow-hidden">
<ElMenu
:ellipsis="true"
mode="horizontal"
:default-active="routerPath"
:text-color="isDark ? 'var(--art-gray-800)' : 'var(--art-gray-700)'"
:popper-offset="-6"
background-color="transparent"
:show-timeout="50"
:hide-timeout="50"
popper-class="horizontal-menu-popper"
class="w-full border-none"
>
<HorizontalSubmenu
v-for="item in filteredMenuItems"
:key="item.path"
:item="item"
:isMobile="false"
:level="0"
/>
</ElMenu>
</div>
</template>
<script setup lang="ts">
import type { AppRouteRecord } from '@/types/router'
import HorizontalSubmenu from './widget/HorizontalSubmenu.vue'
import { useSettingStore } from '@/store/modules/setting'
defineOptions({ name: 'ArtHorizontalMenu' })
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
interface Props {
/** 菜单列表数据 */
list: AppRouteRecord[]
}
const route = useRoute()
const props = withDefaults(defineProps<Props>(), {
list: () => []
})
/**
* 过滤后的菜单项列表
* 只显示未隐藏的菜单项
*/
const filteredMenuItems = computed(() => {
return filterMenuItems(props.list)
})
/**
* 当前激活的路由路径
* 用于菜单高亮显示
*/
const routerPath = computed(() => String(route.meta.activePath || route.path))
/**
* 递归过滤菜单项,移除隐藏的菜单
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
* @param items 菜单项数组
* @returns 过滤后的菜单项数组
*/
const filterMenuItems = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items
.filter((item) => {
// 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) {
return false
}
// 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) {
const filteredChildren = filterMenuItems(item.children)
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
return filteredChildren.length > 0
}
// 叶子节点且未被隐藏,保留
return true
})
.map((item) => ({
...item,
children: item.children ? filterMenuItems(item.children) : undefined
}))
}
</script>
<style scoped>
/* Remove el-menu bottom border */
:deep(.el-menu) {
border-bottom: none !important;
}
/* Remove default styles for first-level menu items */
:deep(.el-menu-item[tabindex='0']) {
background-color: transparent !important;
border: none !important;
}
/* Remove bottom border from submenu titles */
:deep(.el-menu--horizontal .el-sub-menu__title) {
padding: 0 30px 0 10px !important;
border: 0 !important;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<ElSubMenu v-if="hasChildren" :index="item.path || item.meta.title" class="!p-0">
<template #title>
<ArtSvgIcon :icon="item.meta.icon" :color="theme?.iconColor" class="mr-1 text-lg" />
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-horizontal" />
<div v-if="item.meta.showTextBadge" class="art-text-badge">
{{ item.meta.showTextBadge }}
</div>
</template>
<!-- 递归调用自身处理子菜单 -->
<HorizontalSubmenu
v-for="child in filteredChildren"
:key="child.path"
:item="child"
:theme="theme"
:is-mobile="isMobile"
:level="level + 1"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else-if="!item.meta.isHide"
:index="item.path || item.meta.title"
@click="goPage(item)"
>
<ArtSvgIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
class="mr-1 text-lg"
:style="{ color: theme.iconColor }"
/>
<span class="text-md">{{ formatMenuTitle(item.meta.title) }}</span>
<div
v-if="item.meta.showBadge"
class="art-badge"
:style="{ right: level === 0 ? '10px' : '20px' }"
/>
<div v-if="item.meta.showTextBadge && level !== 0" class="art-text-badge">
{{ item.meta.showTextBadge }}
</div>
</ElMenuItem>
</template>
<script lang="ts" setup>
import { computed, type PropType } from 'vue'
import { AppRouteRecord } from '@/types/router'
import { handleMenuJump } from '@/utils/navigation'
import { formatMenuTitle } from '@/utils/router'
const props = defineProps({
item: {
type: Object as PropType<AppRouteRecord>,
required: true
},
theme: {
type: Object,
default: () => ({})
},
isMobile: Boolean,
level: {
type: Number,
default: 0
}
})
const emit = defineEmits(['close'])
// 过滤后的子菜单项(不包含隐藏的)
const filteredChildren = computed(() => {
return props.item.children?.filter((child) => !child.meta.isHide) || []
})
// 计算当前项是否有可见的子菜单
const hasChildren = computed(() => {
return filteredChildren.value.length > 0
})
const goPage = (item: AppRouteRecord) => {
closeMenu()
handleMenuJump(item)
}
const closeMenu = () => {
emit('close')
}
</script>
<style scoped>
:deep(.el-sub-menu__title .el-sub-menu__icon-arrow) {
right: 10px !important;
}
</style>

View File

@@ -0,0 +1,279 @@
<!-- 混合菜单 -->
<template>
<div class="relative box-border flex-c w-full overflow-hidden">
<!-- 左侧滚动按钮 -->
<div v-show="showLeftArrow" class="button-arrow" @click="scroll('left')">
<ElIcon>
<ArrowLeft />
</ElIcon>
</div>
<!-- 滚动容器 -->
<ElScrollbar
ref="scrollbarRef"
wrap-class="scrollbar-wrapper"
:horizontal="true"
@scroll="handleScroll"
@wheel="handleWheel"
>
<div class="box-border flex-c flex-shrink-0 flex-nowrap h-15 whitespace-nowrap">
<template v-for="item in processedMenuList" :key="item.meta.title">
<div
v-if="!item.meta.isHide"
class="menu-item relative flex-shrink-0 h-10 px-3 text-sm flex-c c-p hover:text-theme"
:class="{
'menu-item-active text-theme': item.isActive
}"
@click="handleMenuJump(item, true)"
>
<ArtSvgIcon
:icon="item.meta.icon"
class="text-lg text-g-700 dark:text-g-800 mr-1"
:class="item.isActive && '!text-theme'"
/>
<span
class="text-md text-g-700 dark:text-g-800"
:class="item.isActive && '!text-theme'"
>
{{ item.formattedTitle }}
</span>
<div v-if="item.meta.showBadge" class="art-badge art-badge-mixed" />
</div>
</template>
</div>
</ElScrollbar>
<!-- 右侧滚动按钮 -->
<div v-show="showRightArrow" class="button-arrow right-2" @click="scroll('right')">
<ElIcon>
<ArrowRight />
</ElIcon>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { useThrottleFn } from '@vueuse/core'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import type { AppRouteRecord } from '@/types/router'
defineOptions({ name: 'ArtMixedMenu' })
interface Props {
/** 菜单列表数据 */
list: AppRouteRecord[]
}
interface ProcessedMenuItem extends AppRouteRecord {
isActive: boolean
formattedTitle: string
}
type ScrollDirection = 'left' | 'right'
const route = useRoute()
const props = withDefaults(defineProps<Props>(), {
list: () => []
})
const scrollbarRef = ref<any>()
const showLeftArrow = ref(false)
const showRightArrow = ref(false)
/** 滚动配置 */
const SCROLL_CONFIG = {
/** 点击按钮时的滚动距离 */
BUTTON_SCROLL_DISTANCE: 200,
/** 鼠标滚轮快速滚动时的步长 */
WHEEL_FAST_STEP: 35,
/** 鼠标滚轮慢速滚动时的步长 */
WHEEL_SLOW_STEP: 30,
/** 区分快慢滚动的阈值 */
WHEEL_FAST_THRESHOLD: 100
}
/**
* 获取当前激活路径
* 使用computed缓存避免重复计算
*/
const currentActivePath = computed(() => {
return String(route.meta.activePath || route.path)
})
/**
* 判断菜单项是否为激活状态
* 递归检查子菜单中是否包含当前路径
* @param item 菜单项数据
* @returns 是否为激活状态
*/
const isMenuItemActive = (item: AppRouteRecord): boolean => {
const activePath = currentActivePath.value
// 如果有子菜单,递归检查子菜单
if (item.children?.length) {
return item.children.some((child) => {
if (child.children?.length) {
return isMenuItemActive(child)
}
return child.path === activePath
})
}
// 直接比较路径
return item.path === activePath
}
/**
* 预处理菜单列表
* 缓存每个菜单项的激活状态和格式化标题
*/
const processedMenuList = computed<ProcessedMenuItem[]>(() => {
return props.list.map((item) => ({
...item,
isActive: isMenuItemActive(item),
formattedTitle: formatMenuTitle(item.meta.title)
}))
})
/**
* 处理滚动事件的核心逻辑
* 根据滚动位置显示/隐藏滚动按钮
*/
const handleScrollCore = (): void => {
if (!scrollbarRef.value?.wrapRef) return
const { scrollLeft, scrollWidth, clientWidth } = scrollbarRef.value.wrapRef
// 判断是否显示左侧滚动按钮
showLeftArrow.value = scrollLeft > 0
// 判断是否显示右侧滚动按钮
showRightArrow.value = scrollLeft + clientWidth < scrollWidth
}
/**
* 节流后的滚动事件处理函数
* 调整节流间隔为16ms约等于60fps
*/
const handleScroll = useThrottleFn(handleScrollCore, 16)
/**
* 滚动菜单容器
* @param direction 滚动方向left 或 right
*/
const scroll = (direction: ScrollDirection): void => {
if (!scrollbarRef.value?.wrapRef) return
const currentScroll = scrollbarRef.value.wrapRef.scrollLeft
const targetScroll =
direction === 'left'
? currentScroll - SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
: currentScroll + SCROLL_CONFIG.BUTTON_SCROLL_DISTANCE
// 平滑滚动到目标位置
scrollbarRef.value.wrapRef.scrollTo({
left: targetScroll,
behavior: 'smooth'
})
}
/**
* 处理鼠标滚轮事件
* 优化滚轮响应性能
* @param event 滚轮事件
*/
const handleWheel = (event: WheelEvent): void => {
// 立即阻止默认滚动行为和事件冒泡,避免页面滚动
event.preventDefault()
event.stopPropagation()
// 直接处理滚动,提升响应性
if (!scrollbarRef.value?.wrapRef) return
const { wrapRef } = scrollbarRef.value
const { scrollLeft, scrollWidth, clientWidth } = wrapRef
// 使用更小的滚动步长,让滚动更平滑
const scrollStep =
Math.abs(event.deltaY) > SCROLL_CONFIG.WHEEL_FAST_THRESHOLD
? SCROLL_CONFIG.WHEEL_FAST_STEP
: SCROLL_CONFIG.WHEEL_SLOW_STEP
const scrollDelta = event.deltaY > 0 ? scrollStep : -scrollStep
const targetScroll = Math.max(0, Math.min(scrollLeft + scrollDelta, scrollWidth - clientWidth))
// 立即滚动,无动画
wrapRef.scrollLeft = targetScroll
// 更新滚动按钮状态
handleScrollCore()
}
/**
* 初始化滚动状态
*/
const initScrollState = (): void => {
nextTick(() => {
handleScrollCore()
})
}
onMounted(initScrollState)
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button-arrow {
@apply absolute
top-1/2
z-2
flex
items-center
justify-center
size-7.5
text-g-600
cursor-pointer
rounded
transition-all
duration-300
-translate-y-1/2
hover:text-g-900
hover:bg-g-200;
}
</style>
<style scoped>
:deep(.el-scrollbar__bar.is-horizontal) {
bottom: 5px;
display: none;
height: 2px;
}
:deep(.scrollbar-wrapper) {
flex: 1;
min-width: 0;
margin: 0 50px 0 30px;
}
.menu-item-active::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
width: 40px;
height: 2px;
margin: auto;
content: '';
background-color: var(--theme-color);
}
@media (width <= 1440px) {
:deep(.scrollbar-wrapper) {
margin: 0 45px;
}
}
</style>

View File

@@ -0,0 +1,355 @@
<!-- 左侧菜单 双列菜单 -->
<template>
<div
class="layout-sidebar"
v-if="showLeftMenu || isDualMenu"
:class="{ 'no-border': menuList.length === 0 }"
>
<!-- 双列菜单左侧 -->
<div
v-if="isDualMenu"
class="dual-menu-left"
:style="{ width: dualMenuShowText ? '80px' : '64px', background: getMenuTheme.background }"
>
<ArtLogo class="logo" @click="navigateToHome" />
<ElScrollbar style="height: calc(100% - 135px)">
<ul>
<li v-for="menu in firstLevelMenus" :key="menu.path" @click="handleMenuJump(menu, true)">
<ElTooltip
class="box-item"
effect="dark"
:content="$t(menu.meta.title)"
placement="right"
:offset="15"
:hide-after="0"
:disabled="dualMenuShowText"
>
<div
:class="{
'is-active': menu.meta.isFirstLevel
? menu.path === route.path
: menu.path === firstLevelMenuPath
}"
:style="{
height: dualMenuShowText ? '60px' : '46px'
}"
>
<ArtSvgIcon
class="menu-icon text-g-700 dark:text-g-800"
:icon="menu.meta.icon"
:style="{
marginBottom: dualMenuShowText ? '5px' : '0'
}"
/>
<span v-if="dualMenuShowText" class="text-md text-g-700">
{{ $t(menu.meta.title) }}
</span>
<div v-if="menu.meta.showBadge" class="art-badge art-badge-dual" />
</div>
</ElTooltip>
</li>
</ul>
</ElScrollbar>
<ArtIconButton
class="switch-btn size-10"
icon="ri:arrow-left-right-fill"
@click="toggleDualMenuMode"
/>
</div>
<!-- 左侧菜单 || 双列菜单右侧 -->
<div
v-show="menuList.length > 0"
class="menu-left"
:class="`menu-left-${getMenuTheme.theme} menu-left-${!menuOpen ? 'close' : 'open'}`"
:style="{ background: getMenuTheme.background }"
>
<ElScrollbar :style="scrollbarStyle">
<!-- Logo系统名称 -->
<div
class="header"
@click="navigateToHome"
:style="{
background: getMenuTheme.background
}"
>
<ArtLogo v-if="!isDualMenu" class="logo" />
<p
:class="{ 'is-dual-menu-name': isDualMenu }"
:style="{
color: getMenuTheme.systemNameColor,
opacity: !menuOpen ? 0 : 1
}"
>
{{ AppConfig.systemInfo.name }}
</p>
</div>
<ElMenu
:class="'el-menu-' + getMenuTheme.theme"
:collapse="!menuOpen"
:default-active="routerPath"
:text-color="getMenuTheme.textColor"
:unique-opened="uniqueOpened"
:background-color="getMenuTheme.background"
:default-openeds="defaultOpenedMenus"
:popper-class="`menu-left-popper menu-left-${getMenuTheme.theme}-popper`"
:show-timeout="50"
:hide-timeout="50"
>
<SidebarSubmenu
:list="menuList"
:isMobile="isMobileMode"
:theme="getMenuTheme"
@close="handleMenuClose"
/>
</ElMenu>
</ElScrollbar>
<!-- 双列菜单右侧折叠按钮 -->
<div class="dual-menu-collapse-btn" v-if="isDualMenu" @click="toggleMenuVisibility">
<ArtSvgIcon
class="text-g-500/70"
:icon="menuOpen ? 'ri:arrow-left-wide-fill' : 'ri:arrow-right-wide-fill'"
/>
</div>
<div
class="menu-model"
@click="toggleMenuVisibility"
:style="{
opacity: !menuOpen ? 0 : 1,
transform: showMobileModal ? 'scale(1)' : 'scale(0)'
}"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useSettingStore } from '@/store/modules/setting'
import { MenuTypeEnum, MenuWidth } from '@/enums/appEnum'
import { useMenuStore } from '@/store/modules/menu'
import { isIframe } from '@/utils/navigation'
import { handleMenuJump } from '@/utils/navigation'
import SidebarSubmenu from './widget/SidebarSubmenu.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { useWindowSize, useTimeoutFn } from '@vueuse/core'
defineOptions({ name: 'ArtSidebarMenu' })
const MOBILE_BREAKPOINT = 800
const ANIMATION_DELAY = 350
const MENU_CLOSE_WIDTH = MenuWidth.CLOSE
const route = useRoute()
const router = useRouter()
const settingStore = useSettingStore()
const { getMenuOpenWidth, menuType, uniqueOpened, dualMenuShowText, menuOpen, getMenuTheme } =
storeToRefs(settingStore)
// 组件内部状态
const defaultOpenedMenus = ref<string[]>([])
const isMobileMode = ref(false)
const showMobileModal = ref(false)
// 使用 VueUse 的窗口尺寸监听
const { width } = useWindowSize()
// 菜单宽度相关
const menuopenwidth = computed(() => getMenuOpenWidth.value)
const menuclosewidth = computed(() => MENU_CLOSE_WIDTH)
// 菜单类型判断
const isTopLeftMenu = computed(() => menuType.value === MenuTypeEnum.TOP_LEFT)
const showLeftMenu = computed(
() => menuType.value === MenuTypeEnum.LEFT || menuType.value === MenuTypeEnum.TOP_LEFT
)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
// 移动端屏幕判断(使用 computed 避免重复计算)
const isMobileScreen = computed(() => width.value < MOBILE_BREAKPOINT)
// 路由相关
const firstLevelMenuPath = computed(() => route.matched[0]?.path)
const routerPath = computed(() => String(route.meta.activePath || route.path))
// 菜单数据
const firstLevelMenus = computed(() => {
return useMenuStore().menuList.filter((menu) => !menu.meta.isHide)
})
const menuList = computed(() => {
const menuStore = useMenuStore()
const allMenus = menuStore.menuList
// 如果不是顶部左侧菜单或双列菜单,直接返回完整菜单列表
if (!isTopLeftMenu.value && !isDualMenu.value) {
return allMenus
}
// 处理 iframe 路径
if (isIframe(route.path)) {
return findIframeMenuList(route.path, allMenus)
}
// 处理一级菜单
if (route.meta.isFirstLevel) {
return []
}
// 返回当前顶级路径对应的子菜单
const currentTopPath = `/${route.path.split('/')[1]}`
const currentMenu = allMenus.find((menu) => menu.path === currentTopPath)
return currentMenu?.children ?? []
})
// 双列菜单收起时的滚动条样式
const scrollbarStyle = computed(() => {
const isCollapsed = isDualMenu.value && !menuOpen.value
return {
transform: isCollapsed ? 'translateY(-50px)' : 'translateY(0)',
height: isCollapsed ? 'calc(100% + 50px)' : '100%',
transition: 'transform 0.3s ease'
}
})
/**
* 延迟隐藏移动端模态框(使用 VueUse 的 useTimeoutFn
*/
const { start: delayHideMobileModal } = useTimeoutFn(
() => {
showMobileModal.value = false
},
ANIMATION_DELAY,
{ immediate: false }
)
/**
* 查找 iframe 对应的二级菜单列表
*/
const findIframeMenuList = (currentPath: string, menuList: any[]) => {
// 递归查找包含当前路径的菜单项
const hasPath = (items: any[]): boolean => {
for (const item of items) {
if (item.path === currentPath) {
return true
}
if (item.children && hasPath(item.children)) {
return true
}
}
return false
}
// 遍历一级菜单查找匹配的子菜单
for (const menu of menuList) {
if (menu.children && hasPath(menu.children)) {
return menu.children
}
}
return []
}
const { homePath } = useCommon()
/**
* 导航到首页
*/
const navigateToHome = (): void => {
router.push(homePath.value)
}
/**
* 切换菜单显示/隐藏
*/
const toggleMenuVisibility = (): void => {
settingStore.setMenuOpen(!menuOpen.value)
// 移动端模态框控制逻辑
if (isMobileScreen.value) {
if (!menuOpen.value) {
// 菜单即将打开,立即显示模态框
showMobileModal.value = true
} else {
// 菜单即将关闭,延迟隐藏模态框确保动画完成
delayHideMobileModal()
}
}
}
/**
* 处理菜单关闭(来自子组件)
*/
const handleMenuClose = (): void => {
if (isMobileScreen.value) {
settingStore.setMenuOpen(false)
delayHideMobileModal()
}
}
/**
* 切换双列菜单模式
*/
const toggleDualMenuMode = (): void => {
settingStore.setDualMenuShowText(!dualMenuShowText.value)
}
/**
* 监听窗口尺寸变化,自动处理移动端菜单
*/
watch(width, (newWidth) => {
if (newWidth < MOBILE_BREAKPOINT) {
settingStore.setMenuOpen(false)
if (!menuOpen.value) {
showMobileModal.value = false
}
} else {
showMobileModal.value = false
}
})
/**
* 监听菜单开关状态变化
*/
watch(menuOpen, (isMenuOpen: boolean) => {
if (!isMobileScreen.value) {
// 大屏幕设备上,模态框始终隐藏
showMobileModal.value = false
} else {
// 小屏幕设备上,根据菜单状态控制模态框
if (isMenuOpen) {
// 菜单打开时立即显示模态框
showMobileModal.value = true
} else {
// 菜单关闭时延迟隐藏模态框,确保动画完成
delayHideMobileModal()
}
}
})
</script>
<style lang="scss" scoped>
@use './style';
</style>
<style lang="scss">
@use './theme';
.layout-sidebar {
// 展开的宽度
.el-menu:not(.el-menu--collapse) {
width: v-bind(menuopenwidth);
}
// 折叠后宽度
.el-menu--collapse {
width: v-bind(menuclosewidth);
}
}
</style>

View File

@@ -0,0 +1,253 @@
.layout-sidebar {
display: flex;
height: 100vh;
user-select: none;
scrollbar-width: none;
border-right: 1px solid var(--art-card-border);
&.no-border {
border-right: none !important;
}
// 自定义滚动条宽度
:deep(.el-scrollbar__bar.is-vertical) {
width: 4px;
}
:deep(.el-scrollbar__thumb) {
right: -2px;
background-color: #ccc;
border-radius: 2px;
}
.dual-menu-left {
position: relative;
width: 80px;
height: 100%;
border-right: 1px solid var(--art-card-border) !important;
transition: width 0.25s;
.logo {
margin: auto;
margin-top: 12px;
margin-bottom: 3px;
cursor: pointer;
}
ul {
li {
> div {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 8px;
overflow: hidden;
text-align: center;
cursor: pointer;
border-radius: 5px;
.art-svg-icon {
display: block;
margin: 0 auto;
font-size: 20px;
}
span {
display: -webkit-box;
width: 100%;
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
}
&.is-active {
background: var(--el-color-primary-light-9);
.art-svg-icon,
span {
color: var(--theme-color) !important;
}
}
}
}
}
.switch-btn {
position: absolute;
right: 0;
bottom: 15px;
left: 0;
margin: auto;
}
}
.menu-left {
position: relative;
box-sizing: border-box;
height: 100vh;
@media only screen and (width <= 640px) {
height: 100dvh;
}
.el-menu {
height: 100%;
}
&:hover {
.dual-menu-collapse-btn {
opacity: 1 !important;
}
}
.dual-menu-collapse-btn {
position: absolute;
top: 50%;
right: -11px;
z-index: 10;
width: 11px;
height: 50px;
cursor: pointer;
background-color: var(--default-box-color);
border: 1px solid var(--art-card-border);
border-radius: 0 15px 15px 0;
opacity: 0;
transition: opacity 0.2s;
transform: translateY(-50%);
&:hover {
.art-svg-icon {
color: var(--art-gray-800) !important;
}
}
.art-svg-icon {
position: absolute;
top: 0;
bottom: 0;
left: -4px;
margin: auto;
transition: all 0.3s;
}
}
}
.header {
position: relative;
box-sizing: border-box;
display: flex;
align-items: center;
width: 100%;
height: 60px;
overflow: hidden;
line-height: 60px;
cursor: pointer;
.logo {
margin-left: 22px;
}
p {
position: absolute;
top: 0;
bottom: 0;
left: 58px;
box-sizing: border-box;
margin-left: 10px;
font-size: 18px;
&.is-dual-menu-name {
left: 25px;
margin: auto;
}
}
}
.el-menu {
box-sizing: border-box;
height: calc(100vh - 60px);
overflow-y: auto;
// 防止菜单内的滚动影响整个页面滚动
overscroll-behavior: contain;
border-right: 0;
scrollbar-width: none;
-ms-scroll-chaining: contain;
&::-webkit-scrollbar {
width: 0 !important;
}
}
.menu-model {
display: none;
}
}
@media only screen and (width <= 800px) {
.layout-sidebar {
width: 0;
.header {
height: 50px;
line-height: 50px;
}
.el-menu {
height: calc(100vh - 60px);
}
.el-menu--collapse {
width: 0;
}
// 折叠状态下的header样式
.menu-left-close .header {
.logo {
display: none;
}
p {
left: 16px;
font-size: 0;
opacity: 0 !important;
}
}
.menu-model {
position: fixed;
top: 0;
left: 0;
z-index: -1;
display: block;
width: 100%;
height: 100vh;
background: rgba($color: #000, $alpha: 50%);
transition: opacity 0.2s ease-in-out;
}
}
}
@media only screen and (width <= 640px) {
.layout-sidebar {
border-right: 0 !important;
}
}
.dark {
.layout-sidebar {
border-right: 1px solid rgb(255 255 255 / 13%);
:deep(.el-scrollbar__thumb) {
background-color: #777;
}
.dual-menu-left {
border-right: 1px solid rgb(255 255 255 / 9%) !important;
}
}
}

View File

@@ -0,0 +1,258 @@
@use '@styles/core/mixin.scss' as *;
// 菜单样式变量
$menu-height: 42px;
$menu-icon-size: 20px;
$menu-font-size: 14px;
$hover-bg-color: var(--art-gray-200);
$popup-menu-height: 40px;
$popup-menu-padding: 8px;
$popup-menu-margin: 5px;
$popup-menu-radius: 6px;
// 通用菜单项样式
@mixin menu-item-base {
width: calc(100% - 16px);
margin-left: 8px;
border-radius: 6px;
.menu-icon {
margin-left: -7px;
}
}
// 通用 hover 样式
@mixin menu-hover($bg-color) {
.el-sub-menu__title:hover,
.el-menu-item:not(.is-active):hover {
background: $bg-color !important;
}
}
// 通用选中样式
@mixin menu-active($color, $bg-color, $icon-color: var(--theme-color)) {
.el-menu-item.is-active {
color: $color !important;
background-color: $bg-color;
.menu-icon {
.art-svg-icon {
color: $icon-color !important;
}
}
}
}
// 弹窗菜单项样式
@mixin popup-menu-item {
height: $popup-menu-height;
margin-bottom: $popup-menu-margin;
border-radius: $popup-menu-radius;
.menu-icon {
margin-right: 5px;
}
&:last-of-type {
margin-bottom: 0;
}
}
// 主题菜单通用样式(合并 design 和 dark 主题的共同逻辑)
@mixin theme-menu-base {
.el-sub-menu__title,
.el-menu-item {
@include menu-item-base;
}
}
// 弹窗菜单通用样式
@mixin popup-menu-base($hover-bg, $active-color, $active-bg) {
.el-menu--popup {
padding: $popup-menu-padding;
.el-sub-menu__title:hover,
.el-menu-item:hover {
background-color: $hover-bg !important;
border-radius: $popup-menu-radius;
}
.el-menu-item {
@include popup-menu-item;
&.is-active {
color: $active-color !important;
background-color: $active-bg !important;
}
}
.el-sub-menu {
@include popup-menu-item;
height: $popup-menu-height !important;
.el-sub-menu__title {
height: $popup-menu-height !important;
border-radius: $popup-menu-radius;
}
}
}
}
.layout-sidebar {
// ---------------------- Modify default style ----------------------
// 菜单折叠样式
.menu-left-close {
.header {
.logo {
margin: 0 auto;
}
}
}
// 菜单图标
.menu-icon {
margin-right: 8px;
font-size: $menu-icon-size;
}
// 菜单高度
.el-sub-menu__title,
.el-menu-item {
height: $menu-height !important;
margin-bottom: 4px;
line-height: $menu-height !important;
span {
font-size: $menu-font-size !important;
@include ellipsis();
}
}
// 右侧箭头
.el-sub-menu__icon-arrow {
width: 13px !important;
font-size: 13px !important;
}
// 菜单折叠
.el-menu--collapse {
.el-sub-menu.is-active {
.el-sub-menu__title {
.menu-icon {
.art-svg-icon {
// 选中菜单图标颜色
color: var(--theme-color) !important;
}
}
}
}
}
// ---------------------- Design theme menu ----------------------
.el-menu-design {
@include theme-menu-base;
@include menu-active(var(--theme-color), var(--el-color-primary-light-9));
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--art-gray-600);
}
}
// ---------------------- Dark theme menu ----------------------
.el-menu-dark {
@include theme-menu-base;
@include menu-active(#fff, #27282d, #fff);
@include menu-hover(#0f1015);
.el-sub-menu__icon-arrow {
color: var(--art-gray-400);
}
}
// ---------------------- Light theme menu ----------------------
.el-menu-light {
.el-sub-menu__title,
.el-menu-item {
.menu-icon {
margin-left: 1px;
}
}
.el-menu-item.is-active {
background-color: var(--el-color-primary-light-9);
.art-svg-icon {
color: var(--theme-color) !important;
}
&::before {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
content: '';
background: var(--theme-color);
}
}
@include menu-hover($hover-bg-color);
.el-sub-menu__icon-arrow {
color: var(--art-gray-600);
}
}
}
@media only screen and (width <= 640px) {
.layout-sidebar {
.el-menu-design {
> .el-sub-menu {
margin-left: 0;
}
.el-sub-menu {
width: 100% !important;
}
}
}
}
// 菜单折叠 hover 弹窗样式(浅色主题)
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), var(--art-gray-200));
}
// 暗黑模式菜单样式
.dark {
.el-menu--vertical,
.el-menu--popup-container {
@include popup-menu-base(var(--art-gray-200), var(--art-gray-900), #292a2e);
}
.layout-sidebar {
// 图标颜色、文字颜色
.menu-icon .art-svg-icon,
.menu-name {
color: var(--art-gray-800) !important;
}
// 选中的文字颜色跟图标颜色
.el-menu-item.is-active {
span,
.menu-icon .art-svg-icon {
color: var(--theme-color) !important;
}
}
// 右侧箭头颜色
.el-sub-menu__icon-arrow {
color: #fff;
}
}
}

View File

@@ -0,0 +1,188 @@
<template>
<template v-for="(item, index) in filteredMenuItems" :key="getUniqueKey(item, index)">
<ElSubMenu v-if="hasChildren(item)" :index="item.path || item.meta.title" :level="level">
<template #title>
<div class="menu-icon flex-cc">
<ArtSvgIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
:style="{ color: theme.iconColor }"
/>
</div>
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
</span>
<div v-if="item.meta.showBadge" class="art-badge" style="right: 10px" />
</template>
<SidebarSubmenu
:list="item.children"
:is-mobile="isMobile"
:level="level + 1"
:theme="theme"
@close="closeMenu"
/>
</ElSubMenu>
<ElMenuItem
v-else
:index="isExternalLink(item) ? undefined : item.path || item.meta.title"
:level-item="level + 1"
@click="goPage(item)"
>
<div class="menu-icon flex-cc">
<ArtSvgIcon
:icon="item.meta.icon"
:color="theme?.iconColor"
:style="{ color: theme.iconColor }"
/>
</div>
<div
v-show="item.meta.showBadge && level === 0 && !menuOpen"
class="art-badge"
style="right: 5px"
/>
<template #title>
<span class="menu-name">
{{ formatMenuTitle(item.meta.title) }}
</span>
<div v-if="item.meta.showBadge" class="art-badge" />
<div v-if="item.meta.showTextBadge && (level > 0 || menuOpen)" class="art-text-badge">
{{ item.meta.showTextBadge }}
</div>
</template>
</ElMenuItem>
</template>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { AppRouteRecord } from '@/types/router'
import { formatMenuTitle } from '@/utils/router'
import { handleMenuJump } from '@/utils/navigation'
import { useSettingStore } from '@/store/modules/setting'
interface MenuTheme {
iconColor?: string
}
interface Props {
/** 菜单标题 */
title?: string
/** 菜单列表 */
list?: AppRouteRecord[]
/** 主题配置 */
theme?: MenuTheme
/** 是否为移动端模式 */
isMobile?: boolean
/** 菜单层级 */
level?: number
}
interface Emits {
/** 关闭菜单事件 */
(e: 'close'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '',
list: () => [],
theme: () => ({}),
isMobile: false,
level: 0
})
const emit = defineEmits<Emits>()
const settingStore = useSettingStore()
const { menuOpen } = storeToRefs(settingStore)
/**
* 过滤后的菜单项列表
* 只显示未隐藏的菜单项
*/
const filteredMenuItems = computed(() => filterRoutes(props.list))
/**
* 跳转到指定页面
* @param item 菜单项数据
*/
const goPage = (item: AppRouteRecord): void => {
closeMenu()
handleMenuJump(item)
}
/**
* 关闭菜单
* 触发父组件的关闭事件
*/
const closeMenu = (): void => {
emit('close')
}
/**
* 递归过滤菜单路由,移除隐藏的菜单项
* 如果一个父菜单的所有子菜单都被隐藏,则父菜单也会被隐藏
* @param items 菜单项数组
* @returns 过滤后的菜单项数组
*/
const filterRoutes = (items: AppRouteRecord[]): AppRouteRecord[] => {
return items
.filter((item) => {
// 如果当前项被隐藏,直接过滤掉
if (item.meta.isHide) {
return false
}
// 如果有子菜单,递归过滤子菜单
if (item.children && item.children.length > 0) {
const filteredChildren = filterRoutes(item.children)
// 如果所有子菜单都被过滤掉了,则隐藏父菜单
return filteredChildren.length > 0
}
// 叶子节点且未被隐藏,保留
return true
})
.map((item) => ({
...item,
children: item.children ? filterRoutes(item.children) : undefined
}))
}
/**
* 判断菜单项是否包含可见的子菜单
* @param item 菜单项数据
* @returns 是否包含可见的子菜单
*/
const hasChildren = (item: AppRouteRecord): boolean => {
if (!item.children || item.children.length === 0) {
return false
}
// 递归检查是否有可见的子菜单
const filteredChildren = filterRoutes(item.children)
return filteredChildren.length > 0
}
/**
* 判断是否为外部链接
* @param item 菜单项数据
* @returns 是否为外部链接
*/
const isExternalLink = (item: AppRouteRecord): boolean => {
return !!(item.meta.link && !item.meta.isIframe)
}
/**
* 生成唯一的 key
* 使用 path、title 和 index 组合确保唯一性
* @param item 菜单项数据
* @param index 索引
* @returns 唯一的 key
*/
const getUniqueKey = (item: AppRouteRecord, index: number): string => {
return `${item.path || item.meta.title || 'menu'}-${props.level}-${index}`
}
</script>

View File

@@ -0,0 +1,456 @@
<!-- 通知组件 -->
<template>
<div
class="art-notification-panel art-card-sm !shadow-xl"
:style="{
transform: show ? 'scaleY(1)' : 'scaleY(0.9)',
opacity: show ? 1 : 0
}"
v-show="visible"
@click.stop
>
<div class="flex-cb px-3.5 mt-3.5">
<span class="text-base font-medium text-g-800">{{ $t('notice.title') }}</span>
<span class="text-xs text-g-800 px-1.5 py-1 c-p select-none rounded hover:bg-g-200">
{{ $t('notice.btnRead') }}
</span>
</div>
<ul class="box-border flex items-end w-full h-12.5 px-3.5 border-b-d">
<li
v-for="(item, index) in barList"
:key="index"
class="h-12 leading-12 mr-5 overflow-hidden text-[13px] text-g-700 c-p select-none"
:class="{ 'bar-active': barActiveIndex === index }"
@click="changeBar(index)"
>
{{ item.name }} ({{ item.num }})
</li>
</ul>
<div class="w-full h-[calc(100%-95px)]">
<div class="h-[calc(100%-60px)] overflow-y-scroll scrollbar-thin">
<!-- 通知 -->
<ul v-show="barActiveIndex === 0">
<li
v-for="(item, index) in noticeList"
:key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
>
<div
class="size-9 leading-9 text-center rounded-lg flex-cc"
:class="[getNoticeStyle(item.type).iconClass]"
>
<ArtSvgIcon class="text-lg !bg-transparent" :icon="getNoticeStyle(item.type).icon" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-sm font-normal leading-5.5 text-g-900">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 消息 -->
<ul v-show="barActiveIndex === 1">
<li
v-for="(item, index) in msgList"
:key="index"
class="box-border flex-c px-3.5 py-3.5 c-p last:border-b-0 hover:bg-g-200/60"
>
<div class="w-9 h-9">
<img :src="item.avatar" class="w-full h-full rounded-lg" />
</div>
<div class="w-[calc(100%-45px)] ml-3.5">
<h4 class="text-xs font-normal leading-5.5">{{ item.title }}</h4>
<p class="mt-1.5 text-xs text-g-500">{{ item.time }}</p>
</div>
</li>
</ul>
<!-- 待办 -->
<ul v-show="barActiveIndex === 2">
<li
v-for="(item, index) in pendingList"
:key="index"
class="box-border px-5 py-3.5 last:border-b-0"
>
<h4>{{ item.title }}</h4>
<p class="text-xs text-g-500">{{ item.time }}</p>
</li>
</ul>
<!-- 空状态 -->
<div
v-show="currentTabIsEmpty"
class="relative top-25 h-full text-g-500 text-center !bg-transparent"
>
<ArtSvgIcon icon="system-uicons:inbox" class="text-5xl" />
<p class="mt-3.5 text-xs !bg-transparent"
>{{ $t('notice.text[0]') }}{{ barList[barActiveIndex].name }}</p
>
</div>
</div>
<div class="relative box-border w-full px-3.5">
<ElButton class="w-full mt-3" @click="handleViewAll" v-ripple>
{{ $t('notice.viewAll') }}
</ElButton>
</div>
</div>
<div class="h-25"></div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch, type Ref, type ComputedRef } from 'vue'
import { useI18n } from 'vue-i18n'
// 导入头像图片
import avatar1 from '@/assets/images/avatar/avatar1.webp'
import avatar2 from '@/assets/images/avatar/avatar2.webp'
import avatar3 from '@/assets/images/avatar/avatar3.webp'
import avatar4 from '@/assets/images/avatar/avatar4.webp'
import avatar5 from '@/assets/images/avatar/avatar5.webp'
import avatar6 from '@/assets/images/avatar/avatar6.webp'
defineOptions({ name: 'ArtNotification' })
interface NoticeItem {
/** 标题 */
title: string
/** 时间 */
time: string
/** 类型 */
type: NoticeType
}
interface MessageItem {
/** 标题 */
title: string
/** 时间 */
time: string
/** 头像 */
avatar: string
}
interface PendingItem {
/** 标题 */
title: string
/** 时间 */
time: string
}
interface BarItem {
/** 名称 */
name: ComputedRef<string>
/** 数量 */
num: number
}
interface NoticeStyle {
/** 图标 */
icon: string
/** icon 样式 */
iconClass: string
}
type NoticeType = 'email' | 'message' | 'collection' | 'user' | 'notice'
const { t } = useI18n()
const props = defineProps<{
value: boolean
}>()
const emit = defineEmits<{
'update:value': [value: boolean]
}>()
const show = ref(false)
const visible = ref(false)
const barActiveIndex = ref(0)
const useNotificationData = () => {
// 通知数据
const noticeList = ref<NoticeItem[]>([
{
title: '新增国际化',
time: '2024-6-13 0:10',
type: 'notice'
},
{
title: '冷月呆呆给你发了一条消息',
time: '2024-4-21 8:05',
type: 'message'
},
{
title: '小肥猪关注了你',
time: '2020-3-17 21:12',
type: 'collection'
},
{
title: '新增使用文档',
time: '2024-02-14 0:20',
type: 'notice'
},
{
title: '小肥猪给你发了一封邮件',
time: '2024-1-20 0:15',
type: 'email'
},
{
title: '菜单mock本地真实数据',
time: '2024-1-17 22:06',
type: 'notice'
}
])
// 消息数据
const msgList = ref<MessageItem[]>([
{
title: '池不胖 关注了你',
time: '2021-2-26 23:50',
avatar: avatar1
},
{
title: '唐不苦 关注了你',
time: '2021-2-21 8:05',
avatar: avatar2
},
{
title: '中小鱼 关注了你',
time: '2020-1-17 21:12',
avatar: avatar3
},
{
title: '何小荷 关注了你',
time: '2021-01-14 0:20',
avatar: avatar4
},
{
title: '誶誶淰 关注了你',
time: '2020-12-20 0:15',
avatar: avatar5
},
{
title: '冷月呆呆 关注了你',
time: '2020-12-17 22:06',
avatar: avatar6
}
])
// 待办数据
const pendingList = ref<PendingItem[]>([])
// 标签栏数据
const barList = computed<BarItem[]>(() => [
{
name: computed(() => t('notice.bar[0]')),
num: noticeList.value.length
},
{
name: computed(() => t('notice.bar[1]')),
num: msgList.value.length
},
{
name: computed(() => t('notice.bar[2]')),
num: pendingList.value.length
}
])
return {
noticeList,
msgList,
pendingList,
barList
}
}
// 样式管理
const useNotificationStyles = () => {
const noticeStyleMap: Record<NoticeType, NoticeStyle> = {
email: {
icon: 'ri:mail-line',
iconClass: 'bg-warning/12 text-warning'
},
message: {
icon: 'ri:volume-down-line',
iconClass: 'bg-success/12 text-success'
},
collection: {
icon: 'ri:heart-3-line',
iconClass: 'bg-danger/12 text-danger'
},
user: {
icon: 'ri:volume-down-line',
iconClass: 'bg-info/12 text-info'
},
notice: {
icon: 'ri:notification-3-line',
iconClass: 'bg-theme/12 text-theme'
}
}
const getNoticeStyle = (type: NoticeType): NoticeStyle => {
const defaultStyle: NoticeStyle = {
icon: 'ri:arrow-right-circle-line',
iconClass: 'bg-theme/12 text-theme'
}
return noticeStyleMap[type] || defaultStyle
}
return {
getNoticeStyle
}
}
// 动画管理
const useNotificationAnimation = () => {
const showNotice = (open: boolean) => {
if (open) {
visible.value = true
setTimeout(() => {
show.value = true
}, 5)
} else {
show.value = false
setTimeout(() => {
visible.value = false
}, 350)
}
}
return {
showNotice
}
}
// 标签页管理
const useTabManagement = (
noticeList: Ref<NoticeItem[]>,
msgList: Ref<MessageItem[]>,
pendingList: Ref<PendingItem[]>,
businessHandlers: {
handleNoticeAll: () => void
handleMsgAll: () => void
handlePendingAll: () => void
}
) => {
const changeBar = (index: number) => {
barActiveIndex.value = index
}
// 检查当前标签页是否为空
const currentTabIsEmpty = computed(() => {
const tabDataMap = [noticeList.value, msgList.value, pendingList.value]
const currentData = tabDataMap[barActiveIndex.value]
return currentData && currentData.length === 0
})
const handleViewAll = () => {
// 查看全部处理器映射
const viewAllHandlers: Record<number, () => void> = {
0: businessHandlers.handleNoticeAll,
1: businessHandlers.handleMsgAll,
2: businessHandlers.handlePendingAll
}
const handler = viewAllHandlers[barActiveIndex.value]
handler?.()
// 关闭通知面板
emit('update:value', false)
}
return {
changeBar,
currentTabIsEmpty,
handleViewAll
}
}
// 业务逻辑处理
const useBusinessLogic = () => {
const handleNoticeAll = () => {
// 处理查看全部通知
console.log('查看全部通知')
}
const handleMsgAll = () => {
// 处理查看全部消息
console.log('查看全部消息')
}
const handlePendingAll = () => {
// 处理查看全部待办
console.log('查看全部待办')
}
return {
handleNoticeAll,
handleMsgAll,
handlePendingAll
}
}
// 组合所有逻辑
const { noticeList, msgList, pendingList, barList } = useNotificationData()
const { getNoticeStyle } = useNotificationStyles()
const { showNotice } = useNotificationAnimation()
const { handleNoticeAll, handleMsgAll, handlePendingAll } = useBusinessLogic()
const { changeBar, currentTabIsEmpty, handleViewAll } = useTabManagement(
noticeList,
msgList,
pendingList,
{ handleNoticeAll, handleMsgAll, handlePendingAll }
)
// 监听属性变化
watch(
() => props.value,
(newValue) => {
showNotice(newValue)
}
)
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.art-notification-panel {
@apply absolute
top-14.5
right-5
w-90
h-125
overflow-hidden
transition-all
duration-300
origin-top
will-change-[top,left]
max-[640px]:top-[65px]
max-[640px]:right-0
max-[640px]:w-full
max-[640px]:h-[80vh];
}
.bar-active {
color: var(--theme-color) !important;
border-bottom: 2px solid var(--theme-color);
}
.scrollbar-thin::-webkit-scrollbar {
width: 5px !important;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background-color: var(--default-box-color);
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #222 !important;
}
</style>

View File

@@ -0,0 +1,136 @@
<!-- 布局内容 -->
<template>
<div class="layout-content" :class="{ 'overflow-auto': isFullPage }" :style="containerStyle">
<div id="app-content-header">
<!-- 节日滚动 -->
<ArtFestivalTextScroll v-if="!isFullPage" />
<!-- 路由信息调试 -->
<div
v-if="isOpenRouteInfo === 'true'"
class="px-2 py-1.5 mb-3 text-sm text-g-500 bg-g-200 border-full-d rounded-md"
>
router meta{{ route.meta }}
</div>
</div>
<RouterView v-if="isRefresh" v-slot="{ Component, route }" :style="contentStyle">
<!-- 缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<KeepAlive :max="10" :exclude="keepAliveExclude">
<component
class="art-page-view"
:is="Component"
:key="route.path"
v-if="route.meta.keepAlive"
/>
</KeepAlive>
</Transition>
<!-- 非缓存路由动画 -->
<Transition :name="showTransitionMask ? '' : actualTransition" mode="out-in" appear>
<component
class="art-page-view"
:is="Component"
:key="route.path"
v-if="!route.meta.keepAlive"
/>
</Transition>
</RouterView>
<!-- 全屏页面切换过渡遮罩用于提升页面切换视觉体验 -->
<Teleport to="body">
<div
v-show="showTransitionMask"
class="fixed top-0 left-0 z-[2000] w-screen h-screen pointer-events-none bg-box"
/>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { useRoute } from 'vue-router'
import { useAutoLayoutHeight } from '@/hooks/core/useLayoutHeight'
import { useSettingStore } from '@/store/modules/setting'
import { useWorktabStore } from '@/store/modules/worktab'
defineOptions({ name: 'ArtPageContent' })
const route = useRoute()
const { containerMinHeight } = useAutoLayoutHeight()
const { pageTransition, containerWidth, refresh } = storeToRefs(useSettingStore())
const { keepAliveExclude } = storeToRefs(useWorktabStore())
const isRefresh = shallowRef(true)
const isOpenRouteInfo = import.meta.env.VITE_OPEN_ROUTE_INFO
const showTransitionMask = ref(false)
// 标记是否是首次加载(浏览器刷新)
const isFirstLoad = ref(true)
// 检查当前路由是否需要使用无基础布局模式
const isFullPage = computed(() => route.matched.some((r) => r.meta?.isFullPage))
const prevIsFullPage = ref(isFullPage.value)
// 切换动画名称:首次加载、从全屏返回时不使用动画
const actualTransition = computed(() => {
if (isFirstLoad.value) return ''
if (prevIsFullPage.value && !isFullPage.value) return ''
return pageTransition.value
})
// 监听全屏状态变化,显示过渡遮罩
watch(isFullPage, (val, oldVal) => {
if (val !== oldVal) {
showTransitionMask.value = true
// 延迟隐藏遮罩,给足时间让页面完成切换
setTimeout(() => {
showTransitionMask.value = false
}, 50)
}
nextTick(() => {
prevIsFullPage.value = val
})
})
const containerStyle = computed(
(): CSSProperties =>
isFullPage.value
? {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100vh',
zIndex: 2500,
background: 'var(--default-bg-color)'
}
: {
maxWidth: containerWidth.value
}
)
const contentStyle = computed(
(): CSSProperties => ({
minHeight: containerMinHeight.value
})
)
const reload = () => {
isRefresh.value = false
nextTick(() => {
isRefresh.value = true
})
}
watch(refresh, reload, { flush: 'post' })
// 组件挂载后标记首次加载完成
onMounted(() => {
// 延迟一帧,确保首次渲染完成
nextTick(() => {
isFirstLoad.value = false
})
})
</script>

View File

@@ -0,0 +1,519 @@
<!-- 锁屏 -->
<template>
<div class="layout-lock-screen">
<!-- 开发者工具警告覆盖层 -->
<div
v-if="showDevToolsWarning"
class="fixed top-0 left-0 z-[999999] flex-cc w-full h-full text-white bg-gradient-to-br from-[#1e1e1e] to-black animate-fade-in"
>
<div class="p-5 text-center select-none">
<div class="mb-7.5 text-5xl">🔒</div>
<h1 class="m-0 mb-5 text-3xl font-semibold text-danger">系统已锁定</h1>
<p class="max-w-125 m-0 text-lg leading-relaxed text-white">
检测到开发者工具已打开<br />
为了系统安全请关闭开发者工具后继续使用
</p>
<div class="mt-7.5 text-sm text-gray-400">Security Lock Activated</div>
</div>
</div>
<!-- 锁屏弹窗 -->
<div v-if="!isLock">
<ElDialog v-model="visible" :width="370" :show-close="false" @open="handleDialogOpen">
<div class="flex-c flex-col">
<img class="w-16 h-16 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" />
<div class="mt-7.5 mb-3.5 text-base font-medium">{{ userInfo.username }}</div>
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
class="w-[90%]"
@submit.prevent="handleLock"
>
<ElFormItem prop="password">
<ElInput
v-model="formData.password"
type="password"
:placeholder="$t('lockScreen.lock.inputPlaceholder')"
:show-password="true"
autocomplete="new-password"
ref="lockInputRef"
class="w-full mt-9"
@keyup.enter="handleLock"
>
<template #suffix>
<ElIcon class="c-p" @click="handleLock">
<Lock />
</ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElButton type="primary" class="w-full mt-0.5" @click="handleLock" v-ripple>
{{ $t('lockScreen.lock.btnText') }}
</ElButton>
</ElForm>
</div>
</ElDialog>
</div>
<!-- 解锁界面 -->
<div v-else class="unlock-content">
<div class="flex-c flex-col w-80">
<img class="w-16 h-16 mt-5 rounded-full" src="@imgs/user/avatar.webp" alt="用户头像" />
<div class="mt-3 mb-3.5 text-base font-medium">
{{ userInfo.username }}
</div>
<ElForm
ref="unlockFormRef"
:model="unlockForm"
:rules="rules"
class="w-full !px-2.5"
@submit.prevent="handleUnlock"
>
<ElFormItem prop="password">
<ElInput
v-model="unlockForm.password"
type="password"
:placeholder="$t('lockScreen.unlock.inputPlaceholder')"
:show-password="true"
autocomplete="new-password"
ref="unlockInputRef"
class="mt-5"
>
<template #suffix>
<ElIcon class="c-p" @click="handleUnlock">
<Unlock />
</ElIcon>
</template>
</ElInput>
</ElFormItem>
<ElButton type="primary" class="w-full mt-2" @click="handleUnlock" v-ripple>
{{ $t('lockScreen.unlock.btnText') }}
</ElButton>
<div class="w-full text-center">
<ElButton
text
class="mt-2.5 !text-g-600 hover:!text-theme hover:!bg-transparent"
@click="toLogin"
>
{{ $t('lockScreen.unlock.backBtnText') }}
</ElButton>
</div>
</ElForm>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Lock, Unlock } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
import { useI18n } from 'vue-i18n'
import CryptoJS from 'crypto-js'
import { useUserStore } from '@/store/modules/user'
import { mittBus } from '@/utils/sys'
// 国际化
const { t } = useI18n()
// 环境变量
const ENCRYPT_KEY = import.meta.env.VITE_LOCK_ENCRYPT_KEY
// Store
const userStore = useUserStore()
const { info: userInfo, lockPassword, isLock } = storeToRefs(userStore)
// 响应式数据
const visible = ref<boolean>(false)
const lockInputRef = ref<any>(null)
const unlockInputRef = ref<any>(null)
const showDevToolsWarning = ref<boolean>(false)
// 表单相关
const formRef = ref<FormInstance>()
const unlockFormRef = ref<FormInstance>()
const formData = reactive({
password: ''
})
const unlockForm = reactive({
password: ''
})
// 表单验证规则
const rules = computed<FormRules>(() => ({
password: [
{
required: true,
message: t('lockScreen.lock.inputPlaceholder'),
trigger: 'blur'
}
]
}))
// 检测是否为移动设备
const isMobile = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent
)
}
// 添加禁用控制台的函数
const disableDevTools = () => {
// 禁用右键菜单
const handleContextMenu = (e: Event) => {
if (isLock.value) {
e.preventDefault()
e.stopPropagation()
return false
}
}
document.addEventListener('contextmenu', handleContextMenu, true)
// 禁用开发者工具相关快捷键
const handleKeyDown = (e: KeyboardEvent) => {
if (!isLock.value) return
// 禁用 F12
if (e.key === 'F12') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+Shift+I/J/C/K (开发者工具)
if (e.ctrlKey && e.shiftKey) {
const key = e.key.toLowerCase()
if (['i', 'j', 'c', 'k'].includes(key)) {
e.preventDefault()
e.stopPropagation()
return false
}
}
// 禁用 Ctrl+U (查看源代码)
if (e.ctrlKey && e.key.toLowerCase() === 'u') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+S (保存页面)
if (e.ctrlKey && e.key.toLowerCase() === 's') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+A (全选)
if (e.ctrlKey && e.key.toLowerCase() === 'a') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+P (打印)
if (e.ctrlKey && e.key.toLowerCase() === 'p') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+F (查找)
if (e.ctrlKey && e.key.toLowerCase() === 'f') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Alt+Tab (切换窗口)
if (e.altKey && e.key === 'Tab') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+Tab (切换标签页)
if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+W (关闭标签页)
if (e.ctrlKey && e.key.toLowerCase() === 'w') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+R 和 F5 (刷新页面)
if ((e.ctrlKey && e.key.toLowerCase() === 'r') || e.key === 'F5') {
e.preventDefault()
e.stopPropagation()
return false
}
// 禁用 Ctrl+Shift+R (强制刷新)
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'r') {
e.preventDefault()
e.stopPropagation()
return false
}
}
document.addEventListener('keydown', handleKeyDown, true)
// 禁用选择文本
const handleSelectStart = (e: Event) => {
if (isLock.value) {
e.preventDefault()
return false
}
}
document.addEventListener('selectstart', handleSelectStart, true)
// 禁用拖拽
const handleDragStart = (e: Event) => {
if (isLock.value) {
e.preventDefault()
return false
}
}
document.addEventListener('dragstart', handleDragStart, true)
// 监听开发者工具打开状态(仅在桌面端启用)
let devtools = { open: false }
const threshold = 160
let devToolsInterval: ReturnType<typeof setInterval> | null = null
const checkDevTools = () => {
if (!isLock.value || isMobile()) return
const isDevToolsOpen =
window.outerHeight - window.innerHeight > threshold ||
window.outerWidth - window.innerWidth > threshold
if (isDevToolsOpen && !devtools.open) {
devtools.open = true
showDevToolsWarning.value = true
} else if (!isDevToolsOpen && devtools.open) {
devtools.open = false
showDevToolsWarning.value = false
}
}
// 仅在桌面端启用开发者工具检测
if (!isMobile()) {
devToolsInterval = setInterval(checkDevTools, 500)
}
// 返回清理函数
return () => {
document.removeEventListener('contextmenu', handleContextMenu, true)
document.removeEventListener('keydown', handleKeyDown, true)
document.removeEventListener('selectstart', handleSelectStart, true)
document.removeEventListener('dragstart', handleDragStart, true)
if (devToolsInterval) {
clearInterval(devToolsInterval)
}
}
}
// 工具函数
const verifyPassword = (inputPassword: string, storedPassword: string): boolean => {
try {
const decryptedPassword = CryptoJS.AES.decrypt(storedPassword, ENCRYPT_KEY).toString(
CryptoJS.enc.Utf8
)
return inputPassword === decryptedPassword
} catch (error) {
console.error('密码解密失败:', error)
return false
}
}
// 事件处理函数
const handleKeydown = (event: KeyboardEvent) => {
if (event.altKey && event.key.toLowerCase() === '¬') {
event.preventDefault()
visible.value = true
}
}
const handleDialogOpen = () => {
setTimeout(() => {
lockInputRef.value?.input?.focus()
}, 100)
}
const handleLock = async () => {
if (!formRef.value) return
await formRef.value.validate((valid, fields) => {
if (valid) {
const encryptedPassword = CryptoJS.AES.encrypt(formData.password, ENCRYPT_KEY).toString()
userStore.setLockStatus(true)
userStore.setLockPassword(encryptedPassword)
visible.value = false
formData.password = ''
} else {
console.error('表单验证失败:', fields)
}
})
}
const handleUnlock = async () => {
if (!unlockFormRef.value) return
await unlockFormRef.value.validate((valid, fields) => {
if (valid) {
const isValid = verifyPassword(unlockForm.password, lockPassword.value)
if (isValid) {
try {
userStore.setLockStatus(false)
userStore.setLockPassword('')
unlockForm.password = ''
visible.value = false
showDevToolsWarning.value = false
} catch (error) {
console.error('更新store失败:', error)
}
} else {
// 触发抖动动画
const inputElement = unlockInputRef.value?.$el
if (inputElement) {
inputElement.classList.add('shake-animation')
setTimeout(() => {
inputElement.classList.remove('shake-animation')
}, 300)
}
ElMessage.error(t('lockScreen.pwdError'))
unlockForm.password = ''
}
} else {
console.error('表单验证失败:', fields)
}
})
}
const toLogin = () => {
userStore.logOut()
}
const openLockScreen = () => {
visible.value = true
}
// 监听锁屏状态变化
watch(isLock, (newValue) => {
if (newValue) {
document.body.style.overflow = 'hidden'
setTimeout(() => {
unlockInputRef.value?.input?.focus()
}, 100)
} else {
document.body.style.overflow = 'auto'
showDevToolsWarning.value = false
}
})
// 存储清理函数
let cleanupDevTools: (() => void) | null = null
// 生命周期钩子
onMounted(() => {
mittBus.on('openLockScreen', openLockScreen)
document.addEventListener('keydown', handleKeydown)
if (isLock.value) {
visible.value = true
setTimeout(() => {
unlockInputRef.value?.input?.focus()
}, 100)
}
// 初始化禁用开发者工具功能
cleanupDevTools = disableDevTools()
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
document.body.style.overflow = 'auto'
// 清理禁用开发者工具的事件监听器
if (cleanupDevTools) {
cleanupDevTools()
cleanupDevTools = null
}
})
</script>
<style lang="scss" scoped>
.layout-lock-screen :deep(.el-dialog) {
border-radius: 10px;
}
.unlock-content {
position: fixed;
inset: 0;
z-index: 2500;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #fff;
background-image: url('@imgs/lock/bg_light.webp');
background-size: cover;
transition: transform 0.3s ease-in-out;
}
.dark {
.unlock-content {
background-image: url('@imgs/lock/bg_dark.webp');
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-in-out;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-10px);
}
20%,
40%,
60%,
80% {
transform: translateX(10px);
}
}
.shake-animation {
animation: shake 0.5s ease-in-out;
}
</style>

View File

@@ -0,0 +1,248 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { ContainerWidthEnum } from '@/enums/appEnum'
import AppConfig from '@/config'
import { headerBarConfig } from '@/config/modules/headerBar'
/**
* 设置项配置选项管理
*/
export function useSettingsConfig() {
const { t } = useI18n()
// 标签页风格选项
const tabStyleOptions = computed(() => [
{
value: 'tab-default',
label: t('setting.tabStyle.default')
},
{
value: 'tab-card',
label: t('setting.tabStyle.card')
},
{
value: 'tab-google',
label: t('setting.tabStyle.google')
}
])
// 页面切换动画选项
const pageTransitionOptions = computed(() => [
{
value: '',
label: t('setting.transition.list.none')
},
{
value: 'fade',
label: t('setting.transition.list.fade')
},
{
value: 'slide-left',
label: t('setting.transition.list.slideLeft')
},
{
value: 'slide-bottom',
label: t('setting.transition.list.slideBottom')
},
{
value: 'slide-top',
label: t('setting.transition.list.slideTop')
}
])
// 圆角大小选项
const customRadiusOptions = [
{ value: '0', label: '0' },
{ value: '0.25', label: '0.25' },
{ value: '0.5', label: '0.5' },
{ value: '0.75', label: '0.75' },
{ value: '1', label: '1' }
]
// 容器宽度选项
const containerWidthOptions = computed(() => [
{
value: ContainerWidthEnum.FULL,
label: t('setting.container.list[0]'),
icon: 'icon-park-outline:auto-width'
},
{
value: ContainerWidthEnum.BOXED,
label: t('setting.container.list[1]'),
icon: 'ix:width'
}
])
// 盒子样式选项
const boxStyleOptions = computed(() => [
{
value: 'border-mode',
label: t('setting.box.list[0]'),
type: 'border-mode' as const
},
{
value: 'shadow-mode',
label: t('setting.box.list[1]'),
type: 'shadow-mode' as const
}
])
// 从配置文件获取的选项
const configOptions = {
// 主题色彩选项
mainColors: AppConfig.systemMainColor,
// 主题风格选项
themeList: AppConfig.settingThemeList,
// 菜单布局选项
menuLayoutList: AppConfig.menuLayoutList
}
// 基础设置项配置
const basicSettingsConfig = computed(() => {
// 定义所有基础设置项
const allSettings = [
{
key: 'showWorkTab',
label: t('setting.basics.list.multiTab'),
type: 'switch' as const,
handler: 'workTab',
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'uniqueOpened',
label: t('setting.basics.list.accordion'),
type: 'switch' as const,
handler: 'uniqueOpened',
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'showMenuButton',
label: t('setting.basics.list.collapseSidebar'),
type: 'switch' as const,
handler: 'menuButton',
headerBarKey: 'menuButton' as const
},
{
key: 'showFastEnter',
label: t('setting.basics.list.fastEnter'),
type: 'switch' as const,
handler: 'fastEnter',
headerBarKey: 'fastEnter' as const
},
{
key: 'showRefreshButton',
label: t('setting.basics.list.reloadPage'),
type: 'switch' as const,
handler: 'refreshButton',
headerBarKey: 'refreshButton' as const
},
{
key: 'showCrumbs',
label: t('setting.basics.list.breadcrumb'),
type: 'switch' as const,
handler: 'crumbs',
mobileHide: true,
headerBarKey: 'breadcrumb' as const
},
{
key: 'showLanguage',
label: t('setting.basics.list.language'),
type: 'switch' as const,
handler: 'language',
headerBarKey: 'language' as const
},
{
key: 'showNprogress',
label: t('setting.basics.list.progressBar'),
type: 'switch' as const,
handler: 'nprogress',
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'colorWeak',
label: t('setting.basics.list.weakMode'),
type: 'switch' as const,
handler: 'colorWeak',
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'watermarkVisible',
label: t('setting.basics.list.watermark'),
type: 'switch' as const,
handler: 'watermark',
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'menuOpenWidth',
label: t('setting.basics.list.menuWidth'),
type: 'input-number' as const,
handler: 'menuOpenWidth',
min: 180,
max: 320,
step: 10,
style: { width: '120px' },
controlsPosition: 'right' as const,
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'tabStyle',
label: t('setting.basics.list.tabStyle'),
type: 'select' as const,
handler: 'tabStyle',
options: tabStyleOptions.value,
style: { width: '120px' },
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'pageTransition',
label: t('setting.basics.list.pageTransition'),
type: 'select' as const,
handler: 'pageTransition',
options: pageTransitionOptions.value,
style: { width: '120px' },
headerBarKey: null // 不依赖headerBar配置
},
{
key: 'customRadius',
label: t('setting.basics.list.borderRadius'),
type: 'select' as const,
handler: 'customRadius',
options: customRadiusOptions,
style: { width: '120px' },
headerBarKey: null // 不依赖headerBar配置
}
]
// 根据 headerBarConfig 过滤设置项
return (
allSettings
.filter((setting) => {
// 如果设置项不依赖headerBar配置则始终显示
if (setting.headerBarKey === null) {
return true
}
// 如果依赖headerBar配置检查对应的功能是否启用
const headerBarFeature = headerBarConfig[setting.headerBarKey]
return headerBarFeature?.enabled !== false
})
// eslint-disable-next-line @typescript-eslint/no-unused-vars
.map(({ headerBarKey: _headerBarKey, ...setting }) => setting)
)
})
return {
// 选项配置
tabStyleOptions,
pageTransitionOptions,
customRadiusOptions,
containerWidthOptions,
boxStyleOptions,
configOptions,
// 设置项配置
basicSettingsConfig
}
}

View File

@@ -0,0 +1,167 @@
import { useSettingStore } from '@/store/modules/setting'
import { storeToRefs } from 'pinia'
import type { ContainerWidthEnum } from '@/enums/appEnum'
/**
* 设置项通用处理逻辑
*/
export function useSettingsHandlers() {
const settingStore = useSettingStore()
// DOM 操作相关
const domOperations = {
// 设置HTML类名
setHtmlClass: (className: string, add: boolean) => {
const el = document.getElementsByTagName('html')[0]
if (add) {
el.classList.add(className)
} else {
el.classList.remove(className)
}
},
// 设置根元素属性
setRootAttribute: (attribute: string, value: string) => {
const el = document.documentElement
el.setAttribute(attribute, value)
},
// 设置body类名
setBodyClass: (className: string, add: boolean) => {
const el = document.getElementsByTagName('body')[0]
if (add) {
el.classList.add(className)
} else {
el.classList.remove(className)
}
}
}
// 通用切换处理器
const createToggleHandler = (storeMethod: () => void, callback?: () => void) => {
return () => {
storeMethod()
callback?.()
}
}
// 通用值变更处理器
const createValueHandler = <T>(
storeMethod: (value: T) => void,
callback?: (value: T) => void
) => {
return (value: T) => {
if (value !== undefined && value !== null) {
storeMethod(value)
callback?.(value)
}
}
}
// 基础设置处理器
const basicHandlers = {
// 工作台标签页
workTab: createToggleHandler(() => settingStore.setWorkTab(!settingStore.showWorkTab)),
// 菜单手风琴
uniqueOpened: createToggleHandler(() => settingStore.setUniqueOpened()),
// 显示菜单按钮
menuButton: createToggleHandler(() => settingStore.setButton()),
// 显示快速入口
fastEnter: createToggleHandler(() => settingStore.setFastEnter()),
// 显示刷新按钮
refreshButton: createToggleHandler(() => settingStore.setShowRefreshButton()),
// 显示面包屑
crumbs: createToggleHandler(() => settingStore.setCrumbs()),
// 显示语言切换
language: createToggleHandler(() => settingStore.setLanguage()),
// 显示进度条
nprogress: createToggleHandler(() => settingStore.setNprogress()),
// 色弱模式
colorWeak: createToggleHandler(
() => settingStore.setColorWeak(),
() => {
domOperations.setHtmlClass('color-weak', settingStore.colorWeak)
}
),
// 水印显示
watermark: createToggleHandler(() =>
settingStore.setWatermarkVisible(!settingStore.watermarkVisible)
),
// 菜单展开宽度
menuOpenWidth: createValueHandler<number>((width: number) =>
settingStore.setMenuOpenWidth(width)
),
// 标签页风格
tabStyle: createValueHandler<string>((style: string) => settingStore.setTabStyle(style)),
// 页面切换动画
pageTransition: createValueHandler<string>((transition: string) =>
settingStore.setPageTransition(transition)
),
// 圆角大小
customRadius: createValueHandler<string>((radius: string) =>
settingStore.setCustomRadius(radius)
)
}
// 盒子样式处理器
const boxStyleHandlers = {
// 设置盒子模式
setBoxMode: (type: 'border-mode' | 'shadow-mode') => {
const { boxBorderMode } = storeToRefs(settingStore)
// 防止重复设置
if (
(type === 'shadow-mode' && boxBorderMode.value === false) ||
(type === 'border-mode' && boxBorderMode.value === true)
) {
return
}
setTimeout(() => {
domOperations.setRootAttribute('data-box-mode', type)
settingStore.setBorderMode()
}, 50)
}
}
// 颜色设置处理器
const colorHandlers = {
// 选择主题色
selectColor: (theme: string) => {
settingStore.setElementTheme(theme)
settingStore.reload()
}
}
// 容器设置处理器
const containerHandlers = {
// 设置容器宽度
setWidth: (type: ContainerWidthEnum) => {
settingStore.setContainerWidth(type)
settingStore.reload()
}
}
return {
domOperations,
basicHandlers,
boxStyleHandlers,
colorHandlers,
containerHandlers,
createToggleHandler,
createValueHandler
}
}

View File

@@ -0,0 +1,207 @@
import { ref, computed, watch } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
import { storeToRefs } from 'pinia'
import { useBreakpoints } from '@vueuse/core'
import AppConfig from '@/config'
import { SystemThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
import { mittBus } from '@/utils/sys'
import { useTheme } from '@/hooks/core/useTheme'
import { useCeremony } from '@/hooks/core/useCeremony'
import { useSettingsState } from './useSettingsState'
import { useSettingsHandlers } from './useSettingsHandlers'
/**
* 设置面板核心逻辑管理
*/
export function useSettingsPanel() {
const settingStore = useSettingStore()
const { systemThemeType, systemThemeMode, menuType } = storeToRefs(settingStore)
// Composables
const { openFestival, cleanup } = useCeremony()
const { setSystemTheme, setSystemAutoTheme } = useTheme()
const { initColorWeak } = useSettingsState()
const { domOperations } = useSettingsHandlers()
// 响应式状态
const showDrawer = ref(false)
// 使用 VueUse breakpoints 优化性能
const breakpoints = useBreakpoints({ tablet: 1000 })
const isMobile = breakpoints.smaller('tablet')
// 记录窗口宽度变化前的菜单类型
const beforeMenuType = ref<MenuTypeEnum>()
const hasChangedMenu = ref(false)
// 计算属性
const systemThemeColor = computed(() => settingStore.systemThemeColor as string)
// 主题相关处理
const useThemeHandlers = () => {
// 初始化系统颜色
const initSystemColor = () => {
if (!AppConfig.systemMainColor.includes(systemThemeColor.value)) {
settingStore.setElementTheme(AppConfig.systemMainColor[0])
settingStore.reload()
}
}
// 初始化系统主题
const initSystemTheme = () => {
if (systemThemeMode.value === SystemThemeEnum.AUTO) {
setSystemAutoTheme()
} else {
setSystemTheme(systemThemeType.value)
}
}
// 监听系统主题变化
const listenerSystemTheme = () => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', initSystemTheme)
return () => {
mediaQuery.removeEventListener('change', initSystemTheme)
}
}
return {
initSystemColor,
initSystemTheme,
listenerSystemTheme
}
}
// 响应式布局处理
const useResponsiveLayout = () => {
// 使用 watch 监听断点变化,性能更优
const stopWatch = watch(
isMobile,
(mobile: boolean) => {
if (mobile) {
// 切换到移动端布局
if (!hasChangedMenu.value) {
beforeMenuType.value = menuType.value
useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT)
settingStore.setMenuOpen(false)
hasChangedMenu.value = true
}
} else {
// 恢复桌面端布局
if (hasChangedMenu.value && beforeMenuType.value) {
useSettingsState().switchMenuLayouts(beforeMenuType.value)
settingStore.setMenuOpen(true)
hasChangedMenu.value = false
}
}
},
{ immediate: true }
)
return { stopWatch }
}
// 抽屉控制
const useDrawerControl = () => {
// 用于存储 setTimeout 的 ID以便在需要时清除
let themeChangeTimer: ReturnType<typeof setTimeout> | null = null
// 打开抽屉
const handleOpen = () => {
// 清除可能存在的旧定时器
if (themeChangeTimer) {
clearTimeout(themeChangeTimer)
}
// 延迟添加 theme-change class避免抽屉打开动画受影响
themeChangeTimer = setTimeout(() => {
domOperations.setBodyClass('theme-change', true)
themeChangeTimer = null
}, 500)
}
// 关闭抽屉
const handleClose = () => {
// 清除未执行的定时器,防止关闭后才添加 class
if (themeChangeTimer) {
clearTimeout(themeChangeTimer)
themeChangeTimer = null
}
// 立即移除 theme-change class
domOperations.setBodyClass('theme-change', false)
}
// 打开设置
const openSetting = () => {
showDrawer.value = true
}
// 关闭设置
const closeDrawer = () => {
showDrawer.value = false
}
return {
handleOpen,
handleClose,
openSetting,
closeDrawer
}
}
// Props 变化监听
const usePropsWatcher = (props: { open?: boolean }) => {
watch(
() => props.open,
(val: boolean | undefined) => {
if (val !== undefined) {
showDrawer.value = val
}
}
)
}
// 初始化设置
const useSettingsInitializer = () => {
const themeHandlers = useThemeHandlers()
const { openSetting } = useDrawerControl()
const { stopWatch } = useResponsiveLayout()
let themeCleanup: (() => void) | null = null
const initializeSettings = () => {
mittBus.on('openSetting', openSetting)
themeHandlers.initSystemColor()
themeCleanup = themeHandlers.listenerSystemTheme()
initColorWeak()
// 设置盒子模式
const boxMode = settingStore.boxBorderMode ? 'border-mode' : 'shadow-mode'
domOperations.setRootAttribute('data-box-mode', boxMode)
themeHandlers.initSystemTheme()
openFestival()
}
const cleanupSettings = () => {
stopWatch()
themeCleanup?.()
cleanup()
}
return {
initializeSettings,
cleanupSettings
}
}
return {
// 状态
showDrawer,
// 方法组合
useThemeHandlers,
useResponsiveLayout,
useDrawerControl,
usePropsWatcher,
useSettingsInitializer
}
}

View File

@@ -0,0 +1,37 @@
import { useSettingStore } from '@/store/modules/setting'
import { MenuThemeEnum, MenuTypeEnum } from '@/enums/appEnum'
/**
* 设置状态管理
*/
export function useSettingsState() {
const settingStore = useSettingStore()
// 色弱模式初始化
const initColorWeak = () => {
if (settingStore.colorWeak) {
const el = document.getElementsByTagName('html')[0]
setTimeout(() => {
el.classList.add('color-weak')
}, 100)
}
}
// 菜单布局切换
const switchMenuLayouts = (type: MenuTypeEnum) => {
if (type === MenuTypeEnum.LEFT || type === MenuTypeEnum.TOP_LEFT) {
settingStore.setMenuOpen(true)
}
settingStore.switchMenuLayouts(type)
if (type === MenuTypeEnum.DUAL_MENU) {
settingStore.switchMenuStyles(MenuThemeEnum.DESIGN)
settingStore.setMenuOpen(true)
}
}
return {
// 方法
initColorWeak,
switchMenuLayouts
}
}

View File

@@ -0,0 +1,72 @@
<!-- 设置面板 -->
<template>
<div class="layout-settings">
<SettingDrawer v-model="showDrawer" @open="handleOpen" @close="handleClose">
<!-- 头部关闭按钮 -->
<SettingHeader @close="closeDrawer" />
<!-- 主题风格 -->
<ThemeSettings />
<!-- 菜单布局 -->
<MenuLayoutSettings />
<!-- 菜单风格 -->
<MenuStyleSettings />
<!-- 系统主题色 -->
<ColorSettings />
<!-- 盒子样式 -->
<BoxStyleSettings />
<!-- 容器宽度 -->
<ContainerSettings />
<!-- 基础配置 -->
<BasicSettings />
<!-- 操作按钮 -->
<SettingActions />
</SettingDrawer>
</div>
</template>
<script setup lang="ts">
import { useSettingsPanel } from './composables/useSettingsPanel'
import SettingDrawer from './widget/SettingDrawer.vue'
import SettingHeader from './widget/SettingHeader.vue'
import ThemeSettings from './widget/ThemeSettings.vue'
import MenuLayoutSettings from './widget/MenuLayoutSettings.vue'
import MenuStyleSettings from './widget/MenuStyleSettings.vue'
import ColorSettings from './widget/ColorSettings.vue'
import BoxStyleSettings from './widget/BoxStyleSettings.vue'
import ContainerSettings from './widget/ContainerSettings.vue'
import BasicSettings from './widget/BasicSettings.vue'
import SettingActions from './widget/SettingActions.vue'
defineOptions({ name: 'ArtSettingsPanel' })
interface Props {
/** 是否打开 */
open?: boolean
}
const props = defineProps<Props>()
// 使用设置面板逻辑
const settingsPanel = useSettingsPanel()
const { showDrawer } = settingsPanel
// 获取各种处理器
const { handleOpen, handleClose, closeDrawer } = settingsPanel.useDrawerControl()
const { initializeSettings, cleanupSettings } = settingsPanel.useSettingsInitializer()
// 监听 props 变化
settingsPanel.usePropsWatcher(props)
onMounted(() => {
initializeSettings()
})
onUnmounted(() => {
cleanupSettings()
})
</script>
<style lang="scss">
@use './style';
</style>

View File

@@ -0,0 +1,92 @@
@use '@styles/core/mixin.scss' as *;
// 设置抽屉模态框样式
.setting-modal {
background: transparent !important;
.el-drawer {
// 背景滤镜效果
background: rgba($color: #fff, $alpha: 50%) !important;
box-shadow: 0 0 30px rgb(0 0 0 / 10%) !important;
@include backdropBlur();
.setting-box-wrap {
display: flex;
flex-wrap: wrap;
align-items: center;
width: calc(100% + 15px);
margin-bottom: 10px;
.setting-item {
box-sizing: border-box;
width: calc(33.333% - 15px);
margin-right: 15px;
text-align: center;
.box {
position: relative;
box-sizing: border-box;
display: flex;
height: 52px;
overflow: hidden;
cursor: pointer;
border: 2px solid var(--default-border);
border-radius: 8px;
box-shadow: 0 0 8px 0 rgb(0 0 0 / 10%);
transition: box-shadow 0.1s;
&.mt-16 {
margin-top: 16px;
}
&.is-active {
border: 2px solid var(--theme-color);
}
img {
width: 100%;
height: 100%;
}
}
.name {
margin-top: 6px;
font-size: 14px;
text-align: center;
}
}
}
}
// 去除滚动条
.el-drawer__body::-webkit-scrollbar {
width: 0 !important;
}
}
.dark {
.setting-modal {
.el-drawer {
background: rgba($color: #000, $alpha: 50%) !important;
.setting-item {
.box {
border: 2px solid transparent;
}
}
}
}
}
// 去除火狐浏览器滚动条
:deep(.el-drawer__body) {
scrollbar-width: none;
}
// 移动端隐藏
@media screen and (width <= 800px) {
.mobile-hide {
display: none !important;
}
}

View File

@@ -0,0 +1,77 @@
<template>
<div>
<SectionTitle :title="$t('setting.basics.title')" class="mt-10" />
<SettingItem
v-for="config in basicSettingsConfig"
:key="config.key"
:config="config"
:model-value="getSettingValue(config.key)"
@change="handleSettingChange(config.handler, $event)"
/>
</div>
</template>
<script setup lang="ts">
import SectionTitle from './SectionTitle.vue'
import SettingItem from './SettingItem.vue'
import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia'
const settingStore = useSettingStore()
const { basicSettingsConfig } = useSettingsConfig()
const { basicHandlers } = useSettingsHandlers()
// 获取store的响应式状态
const {
uniqueOpened,
showMenuButton,
showFastEnter,
showRefreshButton,
showCrumbs,
showWorkTab,
showLanguage,
showNprogress,
colorWeak,
watermarkVisible,
menuOpenWidth,
tabStyle,
pageTransition,
customRadius
} = storeToRefs(settingStore)
// 创建设置值映射
const settingValueMap = {
uniqueOpened,
showMenuButton,
showFastEnter,
showRefreshButton,
showCrumbs,
showWorkTab,
showLanguage,
showNprogress,
colorWeak,
watermarkVisible,
menuOpenWidth,
tabStyle,
pageTransition,
customRadius
}
// 获取设置值的方法
const getSettingValue = (key: string) => {
const settingRef = settingValueMap[key as keyof typeof settingValueMap]
return settingRef?.value ?? null
}
// 统一的设置变更处理
const handleSettingChange = (handlerName: string, value: any) => {
const handler = (basicHandlers as any)[handlerName]
if (typeof handler === 'function') {
handler(value)
} else {
console.warn(`Handler "${handlerName}" not found in basicHandlers`)
}
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div>
<SectionTitle :title="$t('setting.box.title')" class="mt-10" />
<div class="box-border flex-cb p-1 mt-5 rounded-lg bg-g-200">
<div
v-for="option in boxStyleOptions"
:key="option.value"
class="w-[calc(50%-3px)] h-8.5 leading-8.5 text-sm text-center c-p select-none rounded-md transition-all duration-200"
:class="
isActive(option.type)
? 'text-g-800 bg-[var(--default-box-color)] dark:!text-white dark:bg-g-300'
: 'hover:text-g-800 hover:bg-black/[0.04] dark:hover:bg-black/20'
"
@click="boxStyleHandlers.setBoxMode(option.type)"
>
{{ option.label }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia'
const settingStore = useSettingStore()
const { boxBorderMode } = storeToRefs(settingStore)
const { boxStyleOptions } = useSettingsConfig()
const { boxStyleHandlers } = useSettingsHandlers()
// 判断当前选项是否激活
const isActive = (type: 'border-mode' | 'shadow-mode') => {
return type === 'border-mode' ? boxBorderMode.value : !boxBorderMode.value
}
</script>

View File

@@ -0,0 +1,35 @@
<template>
<div>
<SectionTitle :title="$t('setting.color.title')" class="mt-10" />
<div class="-mr-4">
<div class="flex flex-wrap">
<div
v-for="color in configOptions.mainColors"
:key="color"
class="flex items-center justify-center size-[23px] mr-4 mb-2.5 cursor-pointer rounded-full transition-all duration-200 hover:opacity-85"
:style="{ background: `${color} !important` }"
@click="colorHandlers.selectColor(color)"
>
<ArtSvgIcon
icon="ri:check-fill"
class="text-base !text-white"
v-show="color === systemThemeColor"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia'
const settingStore = useSettingStore()
const { systemThemeColor } = storeToRefs(settingStore)
const { configOptions } = useSettingsConfig()
const { colorHandlers } = useSettingsHandlers()
</script>

View File

@@ -0,0 +1,33 @@
<template>
<div>
<SectionTitle :title="$t('setting.container.title')" class="mt-12.5" />
<div class="flex">
<div
v-for="option in containerWidthOptions"
:key="option.value"
class="flex-cc flex-1 h-16 mt-5 mr-3.5 mb-3.5 cursor-pointer !border-2 rounded-lg !text-g-800 last:mr-0"
:class="{
'border-theme [&_i]:!text-theme': containerWidth === option.value,
'border-full-d': containerWidth !== option.value
}"
@click="containerHandlers.setWidth(option.value)"
>
<ArtSvgIcon :icon="option.icon" class="mr-2 text-lg" />
<span class="text-sm">{{ option.label }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsHandlers } from '../composables/useSettingsHandlers'
import { storeToRefs } from 'pinia'
const settingStore = useSettingStore()
const { containerWidth } = storeToRefs(settingStore)
const { containerWidthOptions } = useSettingsConfig()
const { containerHandlers } = useSettingsHandlers()
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div v-if="width > 1000">
<SectionTitle :title="$t('setting.menuType.title')" />
<div class="setting-box-wrap">
<div
class="setting-item"
v-for="(item, index) in configOptions.menuLayoutList"
:key="item.value"
@click="switchMenuLayouts(item.value)"
>
<div class="box" :class="{ 'is-active': item.value === menuType, 'mt-16': index > 2 }">
<img :src="item.img" />
</div>
<p class="name">{{ $t(`setting.menuType.list[${index}]`) }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useSettingsState } from '../composables/useSettingsState'
const { width } = useWindowSize()
const settingStore = useSettingStore()
const { menuType } = storeToRefs(settingStore)
const { configOptions } = useSettingsConfig()
const { switchMenuLayouts } = useSettingsState()
</script>

View File

@@ -0,0 +1,44 @@
<template>
<SectionTitle :title="$t('setting.menu.title')" />
<div class="setting-box-wrap">
<div
class="setting-item"
v-for="item in menuThemeList"
:key="item.theme"
@click="switchMenuStyles(item.theme)"
>
<div
class="box"
:class="{ 'is-active': item.theme === menuThemeType }"
:style="{
cursor: disabled ? 'no-drop' : 'pointer'
}"
>
<img :src="item.img" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import SectionTitle from './SectionTitle.vue'
import { MenuTypeEnum, type MenuThemeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
const menuThemeList = AppConfig.themeList
const settingStore = useSettingStore()
const { menuThemeType, menuType, isDark } = storeToRefs(settingStore)
const isTopMenu = computed(() => menuType.value === MenuTypeEnum.TOP)
const isDualMenu = computed(() => menuType.value === MenuTypeEnum.DUAL_MENU)
const disabled = computed(() => isTopMenu.value || isDualMenu.value || isDark.value)
// 菜单样式切换
const switchMenuStyles = (theme: MenuThemeEnum) => {
if (isDualMenu.value || isTopMenu.value || isDark.value) {
return
}
settingStore.switchMenuStyles(theme)
}
</script>

View File

@@ -0,0 +1,17 @@
<template>
<p
class="relative mt-7.5 mb-5.5 text-sm text-center text-g-800 before:absolute before:top-[10px] before:left-0 before:w-[50px] before:m-auto before:content-[''] before:border-b before:border-[var(--art-gray-300)] after:absolute after:top-[10px] after:right-0 after:w-[50px] after:m-auto after:content-[''] after:border-b after:border-g-300"
:style="style"
>
{{ title }}
</p>
</template>
<script setup lang="ts">
interface Props {
title: string
style?: Record<string, any>
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,235 @@
<!-- 设置操作按钮 -->
<template>
<div
class="mt-10 flex gap-8 border-t border-[var(--default-border)] bg-[var(--art-bg-color)] pt-5"
>
<ElButton type="primary" class="flex-1 !h-8" @click="handleCopyConfig">
{{ $t('setting.actions.copyConfig') }}
</ElButton>
<ElButton type="danger" plain class="flex-1 !h-8" @click="handleResetConfig">
{{ $t('setting.actions.resetConfig') }}
</ElButton>
</div>
</template>
<script setup lang="ts">
import { nextTick } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
import { useClipboard } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import { MenuThemeEnum } from '@/enums/appEnum'
import { useTheme } from '@/hooks/core/useTheme'
defineOptions({ name: 'SettingActions' })
const { t } = useI18n()
const settingStore = useSettingStore()
const { copy, copied } = useClipboard()
const { switchThemeStyles } = useTheme()
/** 枚举映射表 */
const ENUM_MAPS = {
menuType: {
left: 'MenuTypeEnum.LEFT',
top: 'MenuTypeEnum.TOP',
'top-left': 'MenuTypeEnum.TOP_LEFT',
'dual-menu': 'MenuTypeEnum.DUAL_MENU'
},
systemTheme: {
auto: 'SystemThemeEnum.AUTO',
light: 'SystemThemeEnum.LIGHT',
dark: 'SystemThemeEnum.DARK'
},
menuTheme: {
design: 'MenuThemeEnum.DESIGN',
light: 'MenuThemeEnum.LIGHT',
dark: 'MenuThemeEnum.DARK'
},
containerWidth: {
'100%': 'ContainerWidthEnum.FULL',
'1200px': 'ContainerWidthEnum.BOXED'
}
} as const
/** 配置项定义 */
interface ConfigItem {
comment: string
key: keyof typeof settingStore
enumMap?: Record<string, string>
forceValue?: any
}
const CONFIG_ITEMS: ConfigItem[] = [
{ comment: '菜单类型', key: 'menuType', enumMap: ENUM_MAPS.menuType },
{ comment: '菜单展开宽度', key: 'menuOpenWidth' },
{ comment: '菜单是否展开', key: 'menuOpen' },
{ comment: '双菜单是否显示文本', key: 'dualMenuShowText' },
{ comment: '系统主题类型', key: 'systemThemeType', enumMap: ENUM_MAPS.systemTheme },
{ comment: '系统主题模式', key: 'systemThemeMode', enumMap: ENUM_MAPS.systemTheme },
{ comment: '菜单风格', key: 'menuThemeType', enumMap: ENUM_MAPS.menuTheme },
{ comment: '系统主题颜色', key: 'systemThemeColor' },
{ comment: '是否显示菜单按钮', key: 'showMenuButton' },
{ comment: '是否显示快速入口', key: 'showFastEnter' },
{ comment: '是否显示刷新按钮', key: 'showRefreshButton' },
{ comment: '是否显示面包屑', key: 'showCrumbs' },
{ comment: '是否显示工作台标签', key: 'showWorkTab' },
{ comment: '是否显示语言切换', key: 'showLanguage' },
{ comment: '是否显示进度条', key: 'showNprogress' },
{ comment: '是否显示设置引导', key: 'showSettingGuide' },
{ comment: '是否显示节日文本', key: 'showFestivalText' },
{ comment: '是否显示水印', key: 'watermarkVisible' },
{ comment: '是否自动关闭', key: 'autoClose' },
{ comment: '是否唯一展开', key: 'uniqueOpened' },
{ comment: '是否色弱模式', key: 'colorWeak' },
{ comment: '是否刷新', key: 'refresh' },
{ comment: '是否加载节日烟花', key: 'holidayFireworksLoaded' },
{ comment: '边框模式', key: 'boxBorderMode' },
{ comment: '页面过渡效果', key: 'pageTransition' },
{ comment: '标签页样式', key: 'tabStyle' },
{ comment: '自定义圆角', key: 'customRadius' },
{ comment: '容器宽度', key: 'containerWidth', enumMap: ENUM_MAPS.containerWidth },
{ comment: '节日日期', key: 'festivalDate', forceValue: '' }
]
/**
* 将值转换为代码字符串
*/
const valueToCode = (value: any, enumMap?: Record<string, string>): string => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
// 优先查找枚举映射
if (enumMap && typeof value === 'string' && enumMap[value]) {
return enumMap[value]
}
// 其他类型处理
if (typeof value === 'string') return `'${value}'`
if (typeof value === 'boolean' || typeof value === 'number') return String(value)
return JSON.stringify(value)
}
/**
* 生成配置代码
*/
const generateConfigCode = (): string => {
const lines = ['export const SETTING_DEFAULT_CONFIG = {']
CONFIG_ITEMS.forEach((item) => {
lines.push(` /** ${item.comment} */`)
const value = item.forceValue !== undefined ? item.forceValue : settingStore[item.key]
lines.push(` ${String(item.key)}: ${valueToCode(value, item.enumMap)},`)
})
lines.push('}')
return lines.join('\n')
}
/**
* 复制配置到剪贴板
*/
const handleCopyConfig = async () => {
try {
const configText = generateConfigCode()
await copy(configText)
if (copied.value) {
ElMessage.success({
message: t('setting.actions.copySuccess'),
duration: 3000
})
}
} catch (error) {
console.error('复制配置失败:', error)
ElMessage.error(t('setting.actions.copyFailed'))
}
}
/**
* 切换布尔值配置(如果当前值与默认值不同)
*/
const toggleIfDifferent = (
currentValue: boolean,
defaultValue: boolean,
toggleFn: () => void
) => {
if (currentValue !== defaultValue) {
toggleFn()
}
}
/**
* 重置配置为默认值
*/
const handleResetConfig = async () => {
try {
const config = SETTING_DEFAULT_CONFIG
// 菜单相关
settingStore.switchMenuLayouts(config.menuType)
settingStore.setMenuOpenWidth(config.menuOpenWidth)
settingStore.setMenuOpen(config.menuOpen)
settingStore.setDualMenuShowText(config.dualMenuShowText)
// 主题相关 - 使用 switchThemeStyles 确保正确处理 AUTO 模式
switchThemeStyles(config.systemThemeMode)
// 等待主题切换完成后,根据实际应用的主题设置菜单主题
await nextTick()
const menuTheme = settingStore.isDark ? MenuThemeEnum.DARK : config.menuThemeType
settingStore.switchMenuStyles(menuTheme)
settingStore.setElementTheme(config.systemThemeColor)
// 界面显示(切换类方法)
toggleIfDifferent(settingStore.showMenuButton, config.showMenuButton, () =>
settingStore.setButton()
)
toggleIfDifferent(settingStore.showFastEnter, config.showFastEnter, () =>
settingStore.setFastEnter()
)
toggleIfDifferent(settingStore.showRefreshButton, config.showRefreshButton, () =>
settingStore.setShowRefreshButton()
)
toggleIfDifferent(settingStore.showCrumbs, config.showCrumbs, () => settingStore.setCrumbs())
toggleIfDifferent(settingStore.showLanguage, config.showLanguage, () =>
settingStore.setLanguage()
)
toggleIfDifferent(settingStore.showNprogress, config.showNprogress, () =>
settingStore.setNprogress()
)
// 界面显示(直接设置类方法)
settingStore.setWorkTab(config.showWorkTab)
settingStore.setShowFestivalText(config.showFestivalText)
settingStore.setWatermarkVisible(config.watermarkVisible)
// 功能设置
toggleIfDifferent(settingStore.autoClose, config.autoClose, () => settingStore.setAutoClose())
toggleIfDifferent(settingStore.uniqueOpened, config.uniqueOpened, () =>
settingStore.setUniqueOpened()
)
toggleIfDifferent(settingStore.colorWeak, config.colorWeak, () => settingStore.setColorWeak())
// 样式设置
toggleIfDifferent(settingStore.boxBorderMode, config.boxBorderMode, () =>
settingStore.setBorderMode()
)
settingStore.setPageTransition(config.pageTransition)
settingStore.setTabStyle(config.tabStyle)
settingStore.setCustomRadius(config.customRadius)
settingStore.setContainerWidth(config.containerWidth)
// 节日相关
settingStore.setFestivalDate(config.festivalDate)
settingStore.setholidayFireworksLoaded(config.holidayFireworksLoaded)
location.reload()
} catch (error) {
console.error('重置配置失败:', error)
ElMessage.error(t('setting.actions.resetFailed'))
}
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<div class="setting-drawer">
<ElDrawer
size="300px"
v-model="visible"
:lock-scroll="true"
:with-header="false"
:before-close="handleClose"
:destroy-on-close="false"
modal-class="setting-modal"
@open="handleOpen"
@close="handleDrawerClose"
>
<div class="drawer-con">
<slot />
</div>
</ElDrawer>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'open'): void
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const visible = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
const handleOpen = () => {
emit('open')
}
const handleDrawerClose = () => {
emit('close')
}
const handleClose = () => {
visible.value = false
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<div>
<div class="flex justify-end">
<div
@click="$emit('close')"
class="flex-cc c-p size-7.5 !transition-all duration-200 rounded hover:bg-g-300/80"
>
<ArtSvgIcon icon="ri:close-fill" class="block text-xl text-g-600" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineEmits<{
close: []
}>()
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div class="flex-cb mb-4 last:mb-2" :class="{ 'mobile-hide': config.mobileHide }">
<span class="text-sm">{{ config.label }}</span>
<!-- 开关类型 -->
<ElSwitch v-if="config.type === 'switch'" :model-value="modelValue" @change="handleChange" />
<!-- 数字输入类型 -->
<ElInputNumber
v-else-if="config.type === 'input-number'"
:model-value="modelValue"
:min="config.min"
:max="config.max"
:step="config.step"
:style="config.style"
:controls-position="config.controlsPosition"
@change="handleChange"
/>
<!-- 选择器类型 -->
<ElSelect
v-else-if="config.type === 'select'"
:model-value="modelValue"
:style="config.style"
@change="handleChange"
>
<ElOption
v-for="option in normalizedOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</div>
</template>
<script setup lang="ts">
import type { ComputedRef } from 'vue'
interface SettingItemConfig {
key: string
label: string
type: 'switch' | 'input-number' | 'select'
handler: string
mobileHide?: boolean
min?: number
max?: number
step?: number
style?: Record<string, string>
controlsPosition?: '' | 'right'
options?:
| Array<{ value: any; label: string }>
| ComputedRef<Array<{ value: any; label: string }>>
}
interface Props {
config: SettingItemConfig
modelValue: any
}
interface Emits {
(e: 'change', value: any): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 标准化选项处理computed和普通数组
const normalizedOptions = computed(() => {
if (!props.config.options) return []
try {
// 如果是 ComputedRef则返回其值
if (typeof props.config.options === 'object' && 'value' in props.config.options) {
return props.config.options.value || []
}
// 如果是普通数组,直接返回
return Array.isArray(props.config.options) ? props.config.options : []
} catch (error) {
console.warn('Error processing options for config:', props.config.key, error)
return []
}
})
const handleChange = (value: any) => {
try {
emit('change', value)
} catch (error) {
console.error('Error handling change for config:', props.config.key, error)
}
}
</script>
<style lang="scss" scoped>
@media screen and (width <= 768px) {
.mobile-hide {
display: none !important;
}
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<SectionTitle :title="$t('setting.theme.title')" />
<div class="setting-box-wrap">
<div
class="setting-item"
v-for="(item, index) in configOptions.themeList"
:key="item.theme"
@click="switchThemeStyles(item.theme)"
>
<div class="box" :class="{ 'is-active': item.theme === systemThemeMode }">
<img :src="item.img" />
</div>
<p class="name">{{ $t(`setting.theme.list[${index}]`) }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import SectionTitle from './SectionTitle.vue'
import { useSettingStore } from '@/store/modules/setting'
import { useSettingsConfig } from '../composables/useSettingsConfig'
import { useTheme } from '@/hooks/core/useTheme'
const settingStore = useSettingStore()
const { systemThemeMode } = storeToRefs(settingStore)
const { configOptions } = useSettingsConfig()
const { switchThemeStyles } = useTheme()
</script>

View File

@@ -0,0 +1,584 @@
<!-- 标签页 -->
<template>
<div
v-if="showWorkTab"
class="box-border flex-b w-full px-5 mb-3 select-none max-sm:px-[15px]"
:class="[
tabStyle === 'tab-card' ? 'py-1 border-b border-[var(--art-card-border)]' : '',
tabStyle === 'tab-google' ? 'pt-1 pb-0 border-b border-[var(--art-card-border)]' : ''
]"
>
<div class="w-full overflow-hidden" ref="scrollRef">
<ul
class="float-left whitespace-nowrap !bg-transparent flex"
:class="[tabStyle === 'tab-google' ? 'pl-1' : '']"
ref="tabsRef"
:style="{
transform: `translateX(${scrollState.translateX}px)`,
transition: `${scrollState.transition}`
}"
>
<li
class="art-card-xs inline-flex flex-cc h-8 mr-1.5 text-xs c-p hover:text-theme group"
:class="[
item.path === activeTab ? 'activ-tab !text-theme' : 'text-g-600 dark:text-g-800',
tabStyle === 'tab-google' ? 'google-tab relative !h-8 !leading-8 !border-none' : ''
]"
:style="{
padding: item.fixedTab ? '0 10px' : '0 8px 0 12px',
borderRadius:
tabStyle === 'tab-google'
? 'calc(var(--custom-radius) / 2.5 + 4px) !important'
: 'calc(var(--custom-radius) / 2.5 + 2px) !important'
}"
v-for="(item, index) in list"
:key="item.path"
:ref="item.path"
:id="`scroll-li-${index}`"
@click="clickTab(item)"
@contextmenu.prevent="(e: MouseEvent) => showMenu(e, item.path)"
>
<ArtSvgIcon
v-show="item.icon"
:icon="item.icon"
class="text-base mr-1 group-hover:text-theme"
:class="item.path === activeTab ? 'text-theme' : 'text-g-600'"
/>
{{ item.customTitle || formatMenuTitle(item.title) }}
<span
v-if="list.length > 1 && !item.fixedTab"
class="inline-flex flex-cc relative ml-0.5 p-1 rounded-full tad-200 hover:bg-g-200"
@click.stop="closeWorktab('current', item.path)"
>
<ArtSvgIcon icon="ri:close-large-fill" class="text-[10px] text-g-600" />
</span>
<div
v-if="tabStyle === 'tab-google'"
class="line absolute top-0 bottom-0 left-0 w-px h-4 my-auto bg-g-400 transition-opacity duration-150"
/>
</li>
</ul>
</div>
<div class="flex">
<div
class="flex-cc art-card-xs relative top-0 size-8 leading-8 text-center c-p tad-200 hover:!bg-hover-color"
:style="{
borderRadius: 'calc(var(--custom-radius) / 2.5 + 0px)',
marginTop: tabStyle === 'tab-google' ? '-2px' : ''
}"
@click="(e: MouseEvent) => showMenu(e, activeTab)"
>
<ArtSvgIcon icon="iconamoon:arrow-down-2-thin" class="text-2xl text-g-700" />
</div>
</div>
<ArtMenuRight
ref="menuRef"
:menu-items="menuItems"
:menu-width="140"
:border-radius="10"
@select="handleSelect"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch, nextTick, onUnmounted } from 'vue'
import { LocationQueryRaw, useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useWorktabStore } from '@/store/modules/worktab'
import { useUserStore } from '@/store/modules/user'
import { formatMenuTitle } from '@/utils/router'
import { useSettingStore } from '@/store/modules/setting'
import { MenuItemType } from '../../others/art-menu-right/index.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { WorkTab } from '@/types'
defineOptions({ name: 'ArtWorkTab' })
// 类型定义
interface ScrollState {
translateX: number
transition: string
}
interface TouchState {
startX: number
currentX: number
}
type TabCloseType = 'current' | 'left' | 'right' | 'other' | 'all'
// 基础设置
const { t } = useI18n()
const store = useWorktabStore()
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
const { currentRoute } = router
const settingStore = useSettingStore()
const { tabStyle, showWorkTab } = storeToRefs(settingStore)
// DOM 引用
const scrollRef = ref<HTMLElement | null>(null)
const tabsRef = ref<HTMLElement | null>(null)
const menuRef = ref()
// 状态管理
const scrollState = ref<ScrollState>({
translateX: 0,
transition: ''
})
const touchState = ref<TouchState>({
startX: 0,
currentX: 0
})
const clickedPath = ref('')
// 计算属性
const list = computed(() => store.opened)
const activeTab = computed(() => currentRoute.value.path)
const activeTabIndex = computed(() => list.value.findIndex((tab) => tab.path === activeTab.value))
// 右键菜单逻辑
const useContextMenu = () => {
const getClickedTabInfo = () => {
const clickedIndex = list.value.findIndex((tab) => tab.path === clickedPath.value)
const currentTab = list.value[clickedIndex]
return {
clickedIndex,
currentTab,
isLastTab: clickedIndex === list.value.length - 1,
isOneTab: list.value.length === 1,
isCurrentTab: clickedPath.value === activeTab.value
}
}
// 检查标签页是否固定
const checkTabsFixedStatus = (clickedIndex: number) => {
const leftTabs = list.value.slice(0, clickedIndex)
const rightTabs = list.value.slice(clickedIndex + 1)
const otherTabs = list.value.filter((_, index) => index !== clickedIndex)
return {
areAllLeftTabsFixed: leftTabs.length > 0 && leftTabs.every((tab) => tab.fixedTab),
areAllRightTabsFixed: rightTabs.length > 0 && rightTabs.every((tab) => tab.fixedTab),
areAllOtherTabsFixed: otherTabs.length > 0 && otherTabs.every((tab) => tab.fixedTab),
areAllTabsFixed: list.value.every((tab) => tab.fixedTab)
}
}
// 右键菜单选项
const menuItems = computed(() => {
const { clickedIndex, currentTab, isLastTab, isOneTab, isCurrentTab } = getClickedTabInfo()
const fixedStatus = checkTabsFixedStatus(clickedIndex)
return [
{
key: 'refresh',
label: t('worktab.btn.refresh'),
icon: 'ri:refresh-line',
disabled: !isCurrentTab
},
{
key: 'fixed',
label: currentTab?.fixedTab ? t('worktab.btn.unfixed') : t('worktab.btn.fixed'),
icon: 'ri:pushpin-2-line',
disabled: false,
showLine: true
},
{
key: 'left',
label: t('worktab.btn.closeLeft'),
icon: 'ri:arrow-left-s-line',
disabled: clickedIndex === 0 || fixedStatus.areAllLeftTabsFixed
},
{
key: 'right',
label: t('worktab.btn.closeRight'),
icon: 'ri:arrow-right-s-line',
disabled: isLastTab || fixedStatus.areAllRightTabsFixed
},
{
key: 'other',
label: t('worktab.btn.closeOther'),
icon: 'ri:close-fill',
disabled: isOneTab || fixedStatus.areAllOtherTabsFixed
},
{
key: 'all',
label: t('worktab.btn.closeAll'),
icon: 'ri:close-circle-line',
disabled: isOneTab || fixedStatus.areAllTabsFixed
}
]
})
return { menuItems }
}
// 滚动逻辑
const useScrolling = () => {
const setTransition = () => {
scrollState.value.transition = 'transform 0.5s cubic-bezier(0.15, 0, 0.15, 1)'
setTimeout(() => {
scrollState.value.transition = ''
}, 250)
}
const getCurrentTabElement = (): HTMLElement | null => {
return document.getElementById(`scroll-li-${activeTabIndex.value}`)
}
const calculateScrollPosition = () => {
if (!scrollRef.value || !tabsRef.value) return
const scrollWidth = scrollRef.value.offsetWidth
const ulWidth = tabsRef.value.offsetWidth
const curTabEl = getCurrentTabElement()
if (!curTabEl) return
const { offsetLeft, clientWidth } = curTabEl
const curTabRight = offsetLeft + clientWidth
const targetLeft = scrollWidth - curTabRight
return {
scrollWidth,
ulWidth,
offsetLeft,
clientWidth,
curTabRight,
targetLeft
}
}
const autoPositionTab = () => {
const positions = calculateScrollPosition()
if (!positions) return
const { scrollWidth, ulWidth, offsetLeft, curTabRight, targetLeft } = positions
if (
(offsetLeft > Math.abs(scrollState.value.translateX) && curTabRight <= scrollWidth) ||
(scrollState.value.translateX < targetLeft && targetLeft < 0)
) {
return
}
requestAnimationFrame(() => {
if (curTabRight > scrollWidth) {
scrollState.value.translateX = Math.max(targetLeft - 6, scrollWidth - ulWidth)
} else if (offsetLeft < Math.abs(scrollState.value.translateX)) {
scrollState.value.translateX = -offsetLeft
}
})
}
const adjustPositionAfterClose = () => {
const positions = calculateScrollPosition()
if (!positions) return
const { scrollWidth, ulWidth, offsetLeft, clientWidth } = positions
const curTabLeft = offsetLeft + clientWidth
requestAnimationFrame(() => {
scrollState.value.translateX = curTabLeft > scrollWidth ? scrollWidth - ulWidth : 0
})
}
return {
setTransition,
autoPositionTab,
adjustPositionAfterClose
}
}
// 事件处理逻辑
const useEventHandlers = () => {
const { setTransition, adjustPositionAfterClose } = useScrolling()
const handleWheelScroll = (event: WheelEvent) => {
if (!scrollRef.value || !tabsRef.value) return
event.preventDefault()
if (tabsRef.value.offsetWidth <= scrollRef.value.offsetWidth) return
const xMax = 0
const xMin = scrollRef.value.offsetWidth - tabsRef.value.offsetWidth
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY
scrollState.value.translateX = Math.min(
Math.max(scrollState.value.translateX - delta, xMin),
xMax
)
}
const handleTouchStart = (event: TouchEvent) => {
touchState.value.startX = event.touches[0].clientX
}
const handleTouchMove = (event: TouchEvent) => {
if (!scrollRef.value || !tabsRef.value) return
touchState.value.currentX = event.touches[0].clientX
const deltaX = touchState.value.currentX - touchState.value.startX
const xMin = scrollRef.value.offsetWidth - tabsRef.value.offsetWidth
scrollState.value.translateX = Math.min(
Math.max(scrollState.value.translateX + deltaX, xMin),
0
)
touchState.value.startX = touchState.value.currentX
}
const handleTouchEnd = () => {
setTransition()
}
const setupEventListeners = () => {
if (tabsRef.value) {
tabsRef.value.addEventListener('wheel', handleWheelScroll, { passive: false })
tabsRef.value.addEventListener('touchstart', handleTouchStart, { passive: true })
tabsRef.value.addEventListener('touchmove', handleTouchMove, { passive: true })
tabsRef.value.addEventListener('touchend', handleTouchEnd, { passive: true })
}
}
const cleanupEventListeners = () => {
if (tabsRef.value) {
tabsRef.value.removeEventListener('wheel', handleWheelScroll)
tabsRef.value.removeEventListener('touchstart', handleTouchStart)
tabsRef.value.removeEventListener('touchmove', handleTouchMove)
tabsRef.value.removeEventListener('touchend', handleTouchEnd)
}
}
return {
setupEventListeners,
cleanupEventListeners,
adjustPositionAfterClose
}
}
// 标签页操作逻辑
const useTabOperations = (adjustPositionAfterClose: () => void) => {
const clickTab = (item: WorkTab) => {
router.push({
path: item.path,
query: item.query as LocationQueryRaw
})
}
const closeWorktab = (type: TabCloseType, tabPath: string) => {
const path = typeof tabPath === 'string' ? tabPath : route.path
const closeActions = {
current: () => store.removeTab(path),
left: () => store.removeLeft(path),
right: () => store.removeRight(path),
other: () => store.removeOthers(path),
all: () => store.removeAll()
}
closeActions[type]?.()
setTimeout(() => {
adjustPositionAfterClose()
}, 100)
}
const showMenu = (e: MouseEvent, path?: string) => {
clickedPath.value = path || ''
menuRef.value?.show(e)
e.preventDefault()
e.stopPropagation()
}
const handleSelect = (item: MenuItemType) => {
const { key } = item
if (key === 'refresh') {
useCommon().refresh()
return
}
if (key === 'fixed') {
useWorktabStore().toggleFixedTab(clickedPath.value)
return
}
const activeIndex = list.value.findIndex((tab) => tab.path === activeTab.value)
const clickedIndex = list.value.findIndex((tab) => tab.path === clickedPath.value)
const navigationRules = {
left: activeIndex < clickedIndex,
right: activeIndex > clickedIndex,
other: true
} as const
const shouldNavigate = navigationRules[key as keyof typeof navigationRules]
if (shouldNavigate) {
router.push(clickedPath.value)
}
closeWorktab(key as TabCloseType, clickedPath.value)
}
return {
clickTab,
closeWorktab,
showMenu,
handleSelect
}
}
// 组合所有逻辑
const { menuItems } = useContextMenu()
const { setTransition, autoPositionTab } = useScrolling()
const { setupEventListeners, cleanupEventListeners, adjustPositionAfterClose } =
useEventHandlers()
const { clickTab, closeWorktab, showMenu, handleSelect } =
useTabOperations(adjustPositionAfterClose)
// 生命周期
onMounted(() => {
setupEventListeners()
autoPositionTab()
})
onUnmounted(() => {
cleanupEventListeners()
})
// 监听器
watch(
() => currentRoute.value,
() => {
setTransition()
autoPositionTab()
}
)
watch(
() => userStore.language,
() => {
scrollState.value.translateX = 0
nextTick(() => {
autoPositionTab()
})
}
)
</script>
<style scoped>
.google-tab.activ-tab {
color: var(--theme-color) !important;
background-color: var(--el-color-primary-light-9) !important;
border-bottom: 0 !important;
border-bottom-right-radius: 0 !important;
border-bottom-left-radius: 0 !important;
}
.google-tab.activ-tab::before,
.google-tab.activ-tab::after {
position: absolute;
bottom: 0;
width: 20px;
height: 20px;
content: '';
border-radius: 50%;
box-shadow: 0 0 0 30px var(--el-color-primary-light-9);
}
.google-tab.activ-tab::before {
left: -20px;
clip-path: inset(50% -10px 0 50%);
}
.google-tab.activ-tab::after {
right: -20px;
clip-path: inset(50% 50% 0 -10px);
}
.dark .google-tab.activ-tab {
color: var(--art-gray-800) !important;
background-color: var(--art-hover-color) !important;
}
.dark .google-tab.activ-tab::before,
.dark .google-tab.activ-tab::after {
box-shadow: 0 0 0 30px var(--art-hover-color);
}
.google-tab:not(.activ-tab):hover {
box-sizing: border-box;
color: var(--art-gray-600) !important;
background-color: var(--art-gray-200) !important;
border-bottom: 1px solid var(--default-box-color) !important;
border-radius: calc(var(--custom-radius) / 2.5 + 4px) !important;
}
.dark .google-tab:not(.activ-tab):hover {
background-color: var(--art-hover-color) !important;
}
.google-tab:hover .line,
.google-tab.activ-tab .line,
.google-tab:first-child .line {
opacity: 0;
}
.google-tab:hover + .google-tab .line,
.google-tab.activ-tab + .google-tab .line {
opacity: 0;
}
.google-tab::before,
.google-tab::after {
position: absolute;
bottom: 0;
width: 20px;
height: 20px;
content: '';
border-radius: 50%;
box-shadow: 0 0 0 30px transparent;
}
.google-tab::before {
left: -20px;
clip-path: inset(50% -10px 0 50%);
}
.google-tab::after {
right: -20px;
clip-path: inset(50% 50% 0 -10px);
}
.google-tab i:hover {
color: var(--art-gray-700);
background: var(--art-gray-300);
}
@media only screen and (width <= 768px) {
.box-border.flex.justify-between {
padding-right: 0.625rem;
padding-left: 0.625rem;
}
}
@media only screen and (width <= 640px) {
.box-border.flex.justify-between {
padding-right: 0.9375rem;
padding-left: 0.9375rem;
}
}
</style>

View File

@@ -0,0 +1,350 @@
<!-- 图片裁剪组件 github: https://github.com/acccccccb/vue-img-cutter/tree/master -->
<template>
<div class="cutter-container">
<div class="cutter-component">
<div class="title">{{ title }}</div>
<ImgCutter
ref="imgCutterModal"
@cutDown="cutDownImg"
@onPrintImg="cutterPrintImg"
@onImageLoadComplete="handleImageLoadComplete"
@onImageLoadError="handleImageLoadError"
@onClearAll="handleClearAll"
v-bind="cutterProps"
class="img-cutter"
>
<template #choose>
<ElButton type="primary" plain v-ripple>选择图片</ElButton>
</template>
<template #cancel>
<ElButton type="danger" plain v-ripple>清除</ElButton>
</template>
<template #confirm>
<!-- <ElButton type="primary" style="margin-left: 10px">确定</ElButton> -->
<div></div>
</template>
</ImgCutter>
</div>
<div v-if="showPreview" class="preview-container">
<div class="title">{{ previewTitle }}</div>
<div
class="preview-box"
:style="{
width: `${cutterProps.cutWidth}px`,
height: `${cutterProps.cutHeight}px`
}"
>
<img class="preview-img" :src="temImgPath" alt="预览图" v-if="temImgPath" />
</div>
<ElButton class="download-btn" @click="downloadImg" :disabled="!temImgPath" v-ripple
>下载图片</ElButton
>
</div>
</div>
</template>
<script setup lang="ts">
import ImgCutter from 'vue-img-cutter'
defineOptions({ name: 'ArtCutterImg' })
interface CutterProps {
// 基础配置
/** 是否模态框 */
isModal?: boolean
/** 是否显示工具栏 */
tool?: boolean
/** 工具栏背景色 */
toolBgc?: string
/** 标题 */
title?: string
/** 预览标题 */
previewTitle?: string
/** 是否显示预览 */
showPreview?: boolean
// 尺寸相关
/** 容器宽度 */
boxWidth?: number
/** 容器高度 */
boxHeight?: number
/** 裁剪宽度 */
cutWidth?: number
/** 裁剪高度 */
cutHeight?: number
/** 是否允许大小调整 */
sizeChange?: boolean
// 移动和缩放
/** 是否允许移动 */
moveAble?: boolean
/** 是否允许图片移动 */
imgMove?: boolean
/** 是否允许缩放 */
scaleAble?: boolean
// 图片相关
/** 是否显示原始图片 */
originalGraph?: boolean
/** 是否允许跨域 */
crossOrigin?: boolean
/** 文件类型 */
fileType?: 'png' | 'jpeg' | 'webp'
/** 质量 */
quality?: number
// 水印
/** 水印文本 */
watermarkText?: string
/** 水印字体大小 */
watermarkFontSize?: number
/** 水印颜色 */
watermarkColor?: string
// 其他功能
/** 是否保存裁剪位置 */
saveCutPosition?: boolean
/** 是否预览模式 */
previewMode?: boolean
// 输入图片
imgUrl?: string
}
interface CutterResult {
fileName: string
file: File
blob: Blob
dataURL: string
}
const props = withDefaults(defineProps<CutterProps>(), {
// 基础配置默认值
isModal: false,
tool: true,
toolBgc: '#fff',
title: '',
previewTitle: '',
showPreview: true,
// 尺寸相关默认值
boxWidth: 700,
boxHeight: 458,
cutWidth: 470,
cutHeight: 270,
sizeChange: true,
// 移动和缩放默认值
moveAble: true,
imgMove: true,
scaleAble: true,
// 图片相关默认值
originalGraph: true,
crossOrigin: true,
fileType: 'png',
quality: 0.9,
// 水印默认值
watermarkText: '',
watermarkFontSize: 20,
watermarkColor: '#ffffff',
// 其他功能默认值
saveCutPosition: true,
previewMode: true
})
const emit = defineEmits(['update:imgUrl', 'error', 'imageLoadComplete', 'imageLoadError'])
const temImgPath = ref('')
const imgCutterModal = ref()
// 计算属性整合所有ImgCutter的props
const cutterProps = computed(() => ({
...props,
WatermarkText: props.watermarkText,
WatermarkFontSize: props.watermarkFontSize,
WatermarkColor: props.watermarkColor
}))
// 图片预加载
function preloadImage(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.onload = () => resolve()
img.onerror = reject
img.src = url
})
}
// 初始化裁剪器
async function initImgCutter() {
if (props.imgUrl) {
try {
await preloadImage(props.imgUrl)
imgCutterModal.value?.handleOpen({
name: '封面图片',
src: props.imgUrl
})
} catch (error) {
emit('error', error)
console.error('图片加载失败:', error)
}
}
}
// 生命周期钩子
onMounted(() => {
if (props.imgUrl) {
temImgPath.value = props.imgUrl
initImgCutter()
}
})
// 监听图片URL变化
watch(
() => props.imgUrl,
(newVal) => {
if (newVal) {
temImgPath.value = newVal
initImgCutter()
}
}
)
// 实时预览
function cutterPrintImg(result: { dataURL: string }) {
temImgPath.value = result.dataURL
}
// 裁剪完成
function cutDownImg(result: CutterResult) {
emit('update:imgUrl', result.dataURL)
}
// 图片加载完成
function handleImageLoadComplete(result: any) {
emit('imageLoadComplete', result)
}
// 图片加载失败
function handleImageLoadError(error: any) {
emit('error', error)
emit('imageLoadError', error)
}
// 清除所有
function handleClearAll() {
temImgPath.value = ''
}
// 下载图片
function downloadImg() {
console.log('下载图片')
const a = document.createElement('a')
a.href = temImgPath.value
a.download = 'image.png'
a.click()
}
</script>
<style lang="scss" scoped>
.cutter-container {
display: flex;
flex-flow: row wrap;
.title {
padding-bottom: 10px;
font-size: 18px;
font-weight: 500;
}
.cutter-component {
margin-right: 30px;
}
.preview-container {
.preview-box {
background-color: var(--art-active-color) !important;
.preview-img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.download-btn {
display: block;
margin: 20px auto;
}
}
:deep(.toolBoxControl) {
z-index: 100;
}
:deep(.dockMain) {
right: 0;
bottom: -40px;
left: 0;
z-index: 10;
padding: 0;
background-color: transparent !important;
opacity: 1;
}
:deep(.copyright) {
display: none !important;
}
:deep(.i-dialog-footer) {
margin-top: 60px !important;
}
:deep(.dockBtn) {
height: 26px;
padding: 0 10px;
font-size: 12px;
line-height: 26px;
color: var(--el-color-primary) !important;
background-color: var(--el-color-primary-light-9) !important;
border: 1px solid var(--el-color-primary-light-4) !important;
}
:deep(.dockBtnScrollBar) {
margin: 0 10px 0 6px;
background-color: var(--el-color-primary-light-1);
}
:deep(.scrollBarControl) {
border-color: var(--el-color-primary);
}
:deep(.closeIcon) {
line-height: 15px !important;
}
}
.dark {
.cutter-container {
:deep(.toolBox) {
border: transparent;
}
:deep(.dialogMain) {
background-color: transparent !important;
}
:deep(.i-dialog-footer) {
.btn {
background-color: var(--el-color-primary) !important;
border: transparent;
}
}
}
}
</style>

View File

@@ -0,0 +1,111 @@
<!-- 视频播放器组件https://h5player.bytedance.com/-->
<template>
<div :id="playerId" />
</template>
<script setup lang="ts">
import Player from 'xgplayer'
import 'xgplayer/dist/index.min.css'
defineOptions({ name: 'ArtVideoPlayer' })
interface Props {
/** 播放器容器 ID */
playerId: string
/** 视频源URL */
videoUrl: string
/** 视频封面图URL */
posterUrl: string
/** 是否自动播放 */
autoplay?: boolean
/** 音量大小(0-1) */
volume?: number
/** 可选的播放速率 */
playbackRates?: number[]
/** 是否循环播放 */
loop?: boolean
/** 是否静音 */
muted?: boolean
commonStyle?: VideoPlayerStyle
}
const props = withDefaults(defineProps<Props>(), {
playerId: '',
videoUrl: '',
posterUrl: '',
autoplay: false,
volume: 1,
loop: false,
muted: false
})
// 设置属性默认值
// 播放器实例引用
const playerInstance = ref<Player | null>(null)
// 播放器样式接口定义
interface VideoPlayerStyle {
progressColor?: string // 进度条背景色
playedColor?: string // 已播放部分颜色
cachedColor?: string // 缓存部分颜色
sliderBtnStyle?: Record<string, string> // 滑块按钮样式
volumeColor?: string // 音量控制器颜色
}
// 默认样式配置
const defaultStyle: VideoPlayerStyle = {
progressColor: 'rgba(255, 255, 255, 0.3)',
playedColor: '#00AEED',
cachedColor: 'rgba(255, 255, 255, 0.6)',
sliderBtnStyle: {
width: '10px',
height: '10px',
backgroundColor: '#00AEED'
},
volumeColor: '#00AEED'
}
// 组件挂载时初始化播放器
onMounted(() => {
playerInstance.value = new Player({
id: props.playerId,
lang: 'zh', // 设置界面语言为中文
volume: props.volume,
autoplay: props.autoplay,
screenShot: true, // 启用截图功能
url: props.videoUrl,
poster: props.posterUrl,
fluid: true, // 启用流式布局,自适应容器大小
playbackRate: props.playbackRates,
loop: props.loop,
muted: props.muted,
commonStyle: {
...defaultStyle,
...props.commonStyle
}
})
// 播放事件监听器
playerInstance.value.on('play', () => {
console.log('Video is playing')
})
// 暂停事件监听器
playerInstance.value.on('pause', () => {
console.log('Video is paused')
})
// 错误事件监听器
playerInstance.value.on('error', (error) => {
console.error('Error occurred:', error)
})
})
// 组件卸载前清理播放器实例
onBeforeUnmount(() => {
if (playerInstance.value) {
playerInstance.value.destroy()
}
})
</script>

View File

@@ -0,0 +1,415 @@
<!-- 右键菜单 -->
<template>
<div class="menu-right">
<Transition name="context-menu" @before-enter="onBeforeEnter" @after-leave="onAfterLeave">
<div
v-show="visible"
:style="menuStyle"
class="context-menu art-card-xs !shadow-xl min-w-[var(--menu-width)] w-[var(--menu-width)]"
>
<ul class="menu-list m-0 list-none" :style="menuListStyle">
<template v-for="item in menuItems" :key="item.key">
<!-- 普通菜单项 -->
<li
v-if="!item.children"
class="menu-item relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': item.disabled, 'has-line': item.showLine }"
:style="menuItemStyle"
@click="handleMenuClick(item)"
>
<ArtSvgIcon
v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ item.label }}</span
>
</li>
<!-- 子菜单 -->
<li
v-else
class="menu-item submenu relative flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:style="menuItemStyle"
>
<div class="submenu-title flex-c w-full">
<ArtSvgIcon
v-if="item.icon"
class="mr-2 shrink-0 text-base text-g-800"
:icon="item.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ item.label }}</span
>
<ArtSvgIcon
icon="ri:arrow-right-s-line"
class="ubmenu-arrow ml-auto mr-0 text-base text-g-500 transition-transform duration-150"
/>
</div>
<ul
class="submenu-list art-card-xs absolute left-full top-0 z-[2001] hidden w-max min-w-max list-none !shadow-xl"
:style="submenuListStyle"
>
<li
v-for="child in item.children"
:key="child.key"
class="menu-item relative mx-1.5 flex-c c-p select-none rounded text-xs transition-colors duration-150 hover:bg-g-200"
:class="{ 'is-disabled': child.disabled, 'has-line': child.showLine }"
:style="menuItemStyle"
@click="handleMenuClick(child)"
>
<ArtSvgIcon
v-if="child.icon"
class="r-2 shrink-0 text-base text-g-800 mr-1"
:icon="child.icon"
/>
<span
class="menu-label flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-g-800"
>{{ child.label }}</span
>
</li>
</ul>
</li>
</template>
</ul>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
defineOptions({ name: 'ArtMenuRight' })
export interface MenuItemType {
/** 菜单项唯一标识 */
key: string
/** 菜单项标签 */
label: string
/** 菜单项图标 */
icon?: string
/** 菜单项是否禁用 */
disabled?: boolean
/** 菜单项是否显示分割线 */
showLine?: boolean
/** 子菜单 */
children?: MenuItemType[]
[key: string]: any
}
interface Props {
menuItems: MenuItemType[]
/** 菜单宽度 */
menuWidth?: number
/** 子菜单宽度 */
submenuWidth?: number
/** 菜单项高度 */
itemHeight?: number
/** 边界距离 */
boundaryDistance?: number
/** 菜单内边距 */
menuPadding?: number
/** 菜单项水平内边距 */
itemPaddingX?: number
/** 菜单圆角 */
borderRadius?: number
/** 动画持续时间 */
animationDuration?: number
}
const props = withDefaults(defineProps<Props>(), {
menuWidth: 120,
submenuWidth: 150,
itemHeight: 32,
boundaryDistance: 10,
menuPadding: 5,
itemPaddingX: 6,
borderRadius: 6,
animationDuration: 100
})
const emit = defineEmits<{
(e: 'select', item: MenuItemType): void
(e: 'show'): void
(e: 'hide'): void
}>()
const visible = ref(false)
const position = ref({ x: 0, y: 0 })
// 用于清理定时器和事件监听器
let showTimer: number | null = null
let eventListenersAdded = false
// 计算菜单样式
const menuStyle = computed(
(): CSSProperties => ({
position: 'fixed' as const,
left: `${position.value.x}px`,
top: `${position.value.y}px`,
zIndex: 2000,
width: `${props.menuWidth}px`
})
)
// 计算菜单列表样式
const menuListStyle = computed(
(): CSSProperties => ({
padding: `${props.menuPadding}px`
})
)
// 计算菜单项样式
const menuItemStyle = computed(
(): CSSProperties => ({
height: `${props.itemHeight}px`,
padding: `0 ${props.itemPaddingX}px`,
borderRadius: '4px'
})
)
// 计算子菜单列表样式
const submenuListStyle = computed(
(): CSSProperties => ({
minWidth: `${props.submenuWidth}px`,
padding: `${props.menuPadding}px 0`,
borderRadius: `${props.borderRadius}px`
})
)
// 计算菜单高度(用于边界检测)
const calculateMenuHeight = (): number => {
let totalHeight = props.menuPadding * 2 // 上下内边距
props.menuItems.forEach((item) => {
totalHeight += props.itemHeight
if (item.showLine) {
totalHeight += 10 // 分割线额外高度
}
})
return totalHeight
}
// 优化的位置计算函数
const calculatePosition = (e: MouseEvent) => {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuHeight = calculateMenuHeight()
let x = e.clientX
let y = e.clientY
// 检查右边界 - 优先显示在鼠标右侧,如果空间不足则显示在左侧
if (x + props.menuWidth > screenWidth - props.boundaryDistance) {
x = Math.max(props.boundaryDistance, x - props.menuWidth)
}
// 检查下边界 - 优先显示在鼠标下方,如果空间不足则向上调整
if (y + menuHeight > screenHeight - props.boundaryDistance) {
y = Math.max(props.boundaryDistance, screenHeight - menuHeight - props.boundaryDistance)
}
// 确保不会超出边界
x = Math.max(
props.boundaryDistance,
Math.min(x, screenWidth - props.menuWidth - props.boundaryDistance)
)
y = Math.max(
props.boundaryDistance,
Math.min(y, screenHeight - menuHeight - props.boundaryDistance)
)
return { x, y }
}
// 添加事件监听器
const addEventListeners = () => {
if (eventListenersAdded) return
document.addEventListener('click', handleDocumentClick)
document.addEventListener('contextmenu', handleDocumentContextmenu)
document.addEventListener('keydown', handleKeydown)
eventListenersAdded = true
}
// 移除事件监听器
const removeEventListeners = () => {
if (!eventListenersAdded) return
document.removeEventListener('click', handleDocumentClick)
document.removeEventListener('contextmenu', handleDocumentContextmenu)
document.removeEventListener('keydown', handleKeydown)
eventListenersAdded = false
}
// 处理文档点击事件
const handleDocumentClick = (e: Event) => {
// 检查点击是否在菜单内部
const target = e.target as Element
const menuElement = document.querySelector('.context-menu')
if (menuElement && menuElement.contains(target)) {
return
}
hide()
}
// 处理文档右键事件
const handleDocumentContextmenu = () => {
hide()
}
// 处理键盘事件
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
hide()
}
}
const show = (e: MouseEvent) => {
e.preventDefault()
e.stopPropagation()
// 清理之前的定时器
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
// 计算位置
position.value = calculatePosition(e)
visible.value = true
emit('show')
// 延迟添加事件监听器,避免立即触发关闭
showTimer = window.setTimeout(() => {
if (visible.value) {
addEventListeners()
}
showTimer = null
}, 50) // 减少延迟时间,提升响应性
}
const hide = () => {
if (!visible.value) return
visible.value = false
emit('hide')
// 清理定时器
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
// 移除事件监听器
removeEventListeners()
}
const handleMenuClick = (item: MenuItemType) => {
if (item.disabled) return
emit('select', item)
hide()
}
// 动画钩子函数
const onBeforeEnter = (el: Element) => {
const element = el as HTMLElement
element.style.transformOrigin = 'top left'
}
const onAfterLeave = () => {
// 确保清理所有资源
removeEventListeners()
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
}
// 组件卸载时清理资源
onUnmounted(() => {
removeEventListeners()
if (showTimer) {
window.clearTimeout(showTimer)
showTimer = null
}
})
// 导出方法供父组件调用
defineExpose({
show,
hide,
visible: computed(() => visible.value)
})
</script>
<style scoped>
.menu-right {
--menu-width: v-bind('props.menuWidth + "px"');
--border-radius: v-bind('props.borderRadius + "px"');
}
.menu-item.has-line {
margin-bottom: 10px;
}
.menu-item.has-line::after {
position: absolute;
right: 0;
bottom: -5px;
left: 0;
height: 1px;
content: '';
background-color: var(--art-gray-300);
}
.menu-item.is-disabled {
color: var(--el-text-color-disabled);
cursor: not-allowed;
}
.menu-item.is-disabled:hover {
background-color: transparent !important;
}
.menu-item.is-disabled i:not(.submenu-arrow),
.menu-item.is-disabled :deep(.art-svg-icon) {
color: var(--el-text-color-disabled) !important;
}
.menu-item.is-disabled .menu-label {
color: var(--el-text-color-disabled) !important;
}
.menu-item.submenu:hover .submenu-list {
display: block;
}
.menu-item.submenu:hover .submenu-title .submenu-arrow {
transform: rotate(90deg);
}
/* 动画样式 */
.context-menu-enter-active,
.context-menu-leave-active {
transition: all v-bind('props.animationDuration + "ms"') ease-out;
}
.context-menu-enter-from,
.context-menu-leave-to {
opacity: 0;
transform: scale(0.9);
}
.context-menu-enter-to,
.context-menu-leave-from {
opacity: 1;
transform: scale(1);
}
</style>

View File

@@ -0,0 +1,64 @@
<!-- 水印组件 -->
<template>
<div
v-if="watermarkVisible"
class="fixed left-0 top-0 h-screen w-screen pointer-events-none"
:style="{ zIndex: zIndex }"
>
<ElWatermark
:content="content"
:font="{ fontSize: fontSize, color: fontColor }"
:rotate="rotate"
:gap="[gapX, gapY]"
:offset="[offsetX, offsetY]"
>
<div style="height: 100vh"></div>
</ElWatermark>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useSettingStore } from '@/store/modules/setting'
defineOptions({ name: 'ArtWatermark' })
const settingStore = useSettingStore()
const { watermarkVisible } = storeToRefs(settingStore)
interface WatermarkProps {
/** 水印内容 */
content?: string
/** 水印是否可见 */
visible?: boolean
/** 水印字体大小 */
fontSize?: number
/** 水印字体颜色 */
fontColor?: string
/** 水印旋转角度 */
rotate?: number
/** 水印间距X */
gapX?: number
/** 水印间距Y */
gapY?: number
/** 水印偏移X */
offsetX?: number
/** 水印偏移Y */
offsetY?: number
/** 水印层级 */
zIndex?: number
}
withDefaults(defineProps<WatermarkProps>(), {
content: AppConfig.systemInfo.name,
visible: false,
fontSize: 16,
fontColor: 'rgba(128, 128, 128, 0.2)',
rotate: -22,
gapX: 100,
gapY: 100,
offsetX: 50,
offsetY: 50,
zIndex: 3100
})
</script>

View File

@@ -0,0 +1,339 @@
<!-- 表格头部包含表格大小刷新全屏列设置其他设置 -->
<template>
<div class="flex-cb max-md:!block" id="art-table-header">
<div class="flex-wrap">
<slot name="left"></slot>
</div>
<div class="flex-c md:justify-end max-md:mt-3 max-sm:!hidden">
<div
v-if="showSearchBar != null"
class="button"
@click="search"
:class="showSearchBar ? 'active !bg-theme hover:!bg-theme/80' : ''"
>
<ArtSvgIcon icon="ri:search-line" :class="showSearchBar ? 'text-white' : 'text-g-700'" />
</div>
<div
v-if="shouldShow('refresh')"
class="button"
@click="refresh"
:class="{ loading: loading && isManualRefresh }"
>
<ArtSvgIcon
icon="ri:refresh-line"
:class="loading && isManualRefresh ? 'animate-spin text-g-600' : ''"
/>
</div>
<ElDropdown v-if="shouldShow('size')" @command="handleTableSizeChange">
<div class="button">
<ArtSvgIcon icon="ri:arrow-up-down-fill" />
</div>
<template #dropdown>
<ElDropdownMenu>
<div
v-for="item in tableSizeOptions"
:key="item.value"
class="table-size-btn-item [&_.el-dropdown-menu__item]:!mb-[3px] last:[&_.el-dropdown-menu__item]:!mb-0"
>
<ElDropdownItem
:key="item.value"
:command="item.value"
:class="tableSize === item.value ? '!bg-g-300/55' : ''"
>
{{ item.label }}
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<div v-if="shouldShow('fullscreen')" class="button" @click="toggleFullScreen">
<ArtSvgIcon :icon="isFullScreen ? 'ri:fullscreen-exit-line' : 'ri:fullscreen-line'" />
</div>
<!-- 列设置 -->
<ElPopover v-if="shouldShow('columns')" placement="bottom" trigger="click">
<template #reference>
<div class="button">
<ArtSvgIcon icon="ri:align-right" />
</div>
</template>
<div>
<ElScrollbar max-height="380px">
<VueDraggable
v-model="columns"
:disabled="false"
filter=".fixed-column"
:prevent-on-filter="false"
@move="checkColumnMove"
>
<div
v-for="item in columns"
:key="item.prop || item.type"
class="column-option flex-c"
:class="{ 'fixed-column': item.fixed }"
>
<div
class="drag-icon mr-2 h-4.5 flex-cc text-g-500"
:class="item.fixed ? 'cursor-default text-g-300' : 'cursor-move'"
>
<ArtSvgIcon
:icon="item.fixed ? 'ri:unpin-line' : 'ri:drag-move-2-fill'"
class="text-base"
/>
</div>
<ElCheckbox
:model-value="getColumnVisibility(item)"
@update:model-value="(val) => updateColumnVisibility(item, val)"
:disabled="item.disabled"
class="flex-1 min-w-0 [&_.el-checkbox__label]:overflow-hidden [&_.el-checkbox__label]:text-ellipsis [&_.el-checkbox__label]:whitespace-nowrap"
>{{
item.label || (item.type === 'selection' ? t('table.selection') : '')
}}</ElCheckbox
>
</div>
</VueDraggable>
</ElScrollbar>
</div>
</ElPopover>
<!-- 其他设置 -->
<ElPopover v-if="shouldShow('settings')" placement="bottom" trigger="click">
<template #reference>
<div class="button">
<ArtSvgIcon icon="ri:settings-line" />
</div>
</template>
<div>
<ElCheckbox v-if="showZebra" v-model="isZebra" :value="true">{{
t('table.zebra')
}}</ElCheckbox>
<ElCheckbox v-if="showBorder" v-model="isBorder" :value="true">{{
t('table.border')
}}</ElCheckbox>
<ElCheckbox v-if="showHeaderBackground" v-model="isHeaderBackground" :value="true">{{
t('table.headerBackground')
}}</ElCheckbox>
</div>
</ElPopover>
<slot name="right"></slot>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, onMounted, onUnmounted } from 'vue'
import { storeToRefs } from 'pinia'
import { TableSizeEnum } from '@/enums/formEnum'
import { useTableStore } from '@/store/modules/table'
import { VueDraggable } from 'vue-draggable-plus'
import { useI18n } from 'vue-i18n'
import type { ColumnOption } from '@/types/component'
import { ElScrollbar } from 'element-plus'
defineOptions({ name: 'ArtTableHeader' })
const { t } = useI18n()
interface Props {
/** 斑马纹 */
showZebra?: boolean
/** 边框 */
showBorder?: boolean
/** 表头背景 */
showHeaderBackground?: boolean
/** 全屏 class */
fullClass?: string
/** 组件布局,子组件名用逗号分隔 */
layout?: string
/** 加载中 */
loading?: boolean
/** 搜索栏显示状态 */
showSearchBar?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showZebra: true,
showBorder: true,
showHeaderBackground: true,
fullClass: 'art-page-view',
layout: 'search,refresh,size,fullscreen,columns,settings',
showSearchBar: undefined
})
const columns = defineModel<ColumnOption[]>('columns', {
required: false,
default: () => []
})
const emit = defineEmits<{
(e: 'refresh'): void
(e: 'search'): void
(e: 'update:showSearchBar', value: boolean): void
}>()
/**
* 获取列的显示状态
* 优先使用 visible 字段,如果不存在则使用 checked 字段
*/
const getColumnVisibility = (col: ColumnOption): boolean => {
if (col.visible !== undefined) {
return col.visible
}
return col.checked ?? true
}
/**
* 更新列的显示状态
* 同时更新 checked 和 visible 字段以保持兼容性
*/
const updateColumnVisibility = (col: ColumnOption, value: boolean | string | number): void => {
const boolValue = !!value
col.checked = boolValue
col.visible = boolValue
}
/** 表格大小选项配置 */
const tableSizeOptions = [
{ value: TableSizeEnum.SMALL, label: t('table.sizeOptions.small') },
{ value: TableSizeEnum.DEFAULT, label: t('table.sizeOptions.default') },
{ value: TableSizeEnum.LARGE, label: t('table.sizeOptions.large') }
]
const tableStore = useTableStore()
const { tableSize, isZebra, isBorder, isHeaderBackground } = storeToRefs(tableStore)
/** 解析 layout 属性,转换为数组 */
const layoutItems = computed(() => {
return props.layout.split(',').map((item) => item.trim())
})
/**
* 检查组件是否应该显示
* @param componentName 组件名称
* @returns 是否显示
*/
const shouldShow = (componentName: string) => {
return layoutItems.value.includes(componentName)
}
/**
* 拖拽移动事件处理 - 防止固定列位置改变
* @param evt move事件对象
* @returns 是否允许移动
*/
const checkColumnMove = (event: any) => {
// 拖拽进入的目标 DOM 元素
const toElement = event.related as HTMLElement
// 如果目标位置是 fixed 列,则不允许移动
if (toElement && toElement.classList.contains('fixed-column')) {
return false
}
return true
}
/** 搜索事件处理 */
const search = () => {
// 切换搜索栏显示状态
emit('update:showSearchBar', !props.showSearchBar)
emit('search')
}
/** 刷新事件处理 */
const refresh = () => {
isManualRefresh.value = true
emit('refresh')
}
/**
* 表格大小变化处理
* @param command 表格大小枚举值
*/
const handleTableSizeChange = (command: TableSizeEnum) => {
useTableStore().setTableSize(command)
}
/** 是否手动点击刷新 */
const isManualRefresh = ref(false)
/** 加载中 */
const isFullScreen = ref(false)
/** 保存原始的 overflow 样式,用于退出全屏时恢复 */
const originalOverflow = ref('')
/**
* 切换全屏状态
* 进入全屏时会隐藏页面滚动条,退出时恢复原状态
*/
const toggleFullScreen = () => {
const el = document.querySelector(`.${props.fullClass}`)
if (!el) return
isFullScreen.value = !isFullScreen.value
if (isFullScreen.value) {
// 进入全屏:保存原始样式并隐藏滚动条
originalOverflow.value = document.body.style.overflow
document.body.style.overflow = 'hidden'
el.classList.add('el-full-screen')
tableStore.setIsFullScreen(true)
} else {
// 退出全屏:恢复原始样式
document.body.style.overflow = originalOverflow.value
el.classList.remove('el-full-screen')
tableStore.setIsFullScreen(false)
}
}
/**
* ESC键退出全屏的事件处理器
* 需要保存引用以便在组件卸载时正确移除监听器
*/
const handleEscapeKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullScreen.value) {
toggleFullScreen()
}
}
/** 组件挂载时注册全局事件监听器 */
onMounted(() => {
document.addEventListener('keydown', handleEscapeKey)
})
/** 组件卸载时清理资源 */
onUnmounted(() => {
// 移除事件监听器
document.removeEventListener('keydown', handleEscapeKey)
// 如果组件在全屏状态下被卸载,恢复页面滚动状态
if (isFullScreen.value) {
document.body.style.overflow = originalOverflow.value
const el = document.querySelector(`.${props.fullClass}`)
if (el) {
el.classList.remove('el-full-screen')
}
}
})
</script>
<style scoped>
@reference '@styles/core/tailwind.css';
.button {
@apply ml-2
size-8
flex
items-center
justify-center
cursor-pointer
rounded-md
bg-g-300/55
dark:bg-g-300/40
text-g-700
hover:bg-g-300
md:ml-0
md:mr-2.5;
}
</style>

View File

@@ -0,0 +1,410 @@
<!-- 表格组件 -->
<!-- 支持el-table 全部属性事件插槽同官方文档写法 -->
<!-- 扩展功能分页组件渲染自定义列loading表格全局边框斑马纹表格尺寸表头背景配置 -->
<!-- 获取 ref默认暴露了 elTableRef 外部通过 ref.value.elTableRef 可以调用 el-table 方法 -->
<template>
<div class="art-table" :class="{ 'is-empty': isEmpty }" :style="containerHeight">
<ElTable
ref="elTableRef"
v-loading="!!loading"
v-bind="{ ...$attrs, ...props, height, stripe, border, size, headerCellStyle }"
@sort-change="handleSortChange"
>
<template v-for="col in columns" :key="col.prop || col.type">
<!-- 渲染全局序号列 -->
<ElTableColumn v-if="col.type === 'globalIndex'" v-bind="{ ...col }">
<template #default="{ $index }">
<span>{{ getGlobalIndex($index) }}</span>
</template>
</ElTableColumn>
<!-- 渲染展开行 -->
<ElTableColumn v-else-if="col.type === 'expand'" v-bind="cleanColumnProps(col)">
<template #default="{ row }">
<component :is="col.formatter ? col.formatter(row) : null" />
</template>
</ElTableColumn>
<!-- 渲染图片列 -->
<ElTableColumn v-else-if="col.saiType === 'image'" v-bind="cleanColumnProps(col)">
<template #default="{ row }">
<ElImage
:src="
col.prop ? (Array.isArray(row[col.prop]) ? row[col.prop][0] : row[col.prop]) : ''
"
:preview-src-list="
col.prop ? (Array.isArray(row[col.prop]) ? row[col.prop] : [row[col.prop]]) : []
"
:preview-teleported="true"
class="size-9.5 rounded-md"
>
<template #error>
<div class="flex items-center justify-center">
<QuestionFilled />
</div>
</template>
</ElImage>
</template>
</ElTableColumn>
<!-- 渲染图片列 -->
<ElTableColumn v-else-if="col.saiType === 'imageAndText'" v-bind="cleanColumnProps(col)">
<template #default="{ row }">
<div class="flex-c">
<ElImage
:src="col.prop ? row[col.prop] : ''"
:preview-src-list="col.prop ? [row[col.prop]] : []"
:preview-teleported="true"
class="size-9.5 rounded-md"
/>
<div class="ml-2">
<p>{{ col.saiFirst ? row[col.saiFirst] : '' }}</p>
<p>{{ col.saiSecond ? row[col.saiSecond] : '' }}</p>
</div>
</div>
</template>
</ElTableColumn>
<!-- 渲染字典列 -->
<ElTableColumn v-else-if="col.saiType === 'dict'" v-bind="cleanColumnProps(col)">
<template #default="{ row }">
<SaDict
:dict="col.saiDict"
:value="col.prop ? row[col.prop] : ''"
:render="col.saiRender || 'tag'"
:size="col.size || 'default'"
:round="col.round || false"
/>
</template>
</ElTableColumn>
<!-- 渲染普通列 -->
<ElTableColumn v-else v-bind="cleanColumnProps(col)">
<template v-if="col.useHeaderSlot && col.prop" #header="headerScope">
<slot
:name="col.headerSlotName || `${col.prop}-header`"
v-bind="{ ...headerScope, prop: col.prop, label: col.label }"
>
{{ col.label }}
</slot>
</template>
<template v-if="col.useSlot && col.prop" #default="slotScope">
<slot
:name="col.slotName || col.prop"
v-bind="{
...slotScope,
prop: col.prop,
value: col.prop ? slotScope.row[col.prop] : undefined
}"
/>
</template>
</ElTableColumn>
</template>
<template v-if="$slots.default" #default><slot /></template>
<template #empty>
<div v-if="loading"></div>
<ElEmpty v-else :description="emptyText" :image-size="120" />
</template>
</ElTable>
<div
class="pagination custom-pagination"
v-if="showPagination"
:class="mergedPaginationOptions?.align"
ref="paginationRef"
>
<ElPagination
v-bind="mergedPaginationOptions"
:total="pagination?.total"
:disabled="loading"
:page-size="pagination?.size"
:current-page="pagination?.current"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, nextTick, watchEffect } from 'vue'
import type { ElTable, TableProps } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import { storeToRefs } from 'pinia'
import { ColumnOption } from '@/types'
import { useTableStore } from '@/store/modules/table'
import { useCommon } from '@/hooks/core/useCommon'
import { useTableHeight } from '@/hooks/core/useTableHeight'
import { useResizeObserver, useWindowSize } from '@vueuse/core'
defineOptions({ name: 'ArtTable' })
const { width } = useWindowSize()
const elTableRef = ref<InstanceType<typeof ElTable> | null>(null)
const paginationRef = ref<HTMLElement>()
const tableHeaderRef = ref<HTMLElement>()
const tableStore = useTableStore()
const { isBorder, isZebra, tableSize, isFullScreen, isHeaderBackground } = storeToRefs(tableStore)
/** 分页配置接口 */
interface PaginationConfig {
/** 当前页码 */
current: number
/** 每页显示条目个数 */
size: number
/** 总条目数 */
total: number
}
/** 分页器配置选项接口 */
interface PaginationOptions {
/** 每页显示个数选择器的选项列表 */
pageSizes?: number[]
/** 分页器的对齐方式 */
align?: 'left' | 'center' | 'right'
/** 分页器的布局 */
layout?: string
/** 是否显示分页器背景 */
background?: boolean
/** 只有一页时是否隐藏分页器 */
hideOnSinglePage?: boolean
/** 分页器的大小 */
size?: 'small' | 'default' | 'large'
/** 分页器的页码数量 */
pagerCount?: number
}
/** ArtTable 组件的 Props 接口 */
interface ArtTableProps extends TableProps<Record<string, any>> {
/** 加载状态 */
loading?: boolean
/** 列渲染配置 */
columns?: ColumnOption[]
/** 分页状态 */
pagination?: PaginationConfig
/** 分页配置 */
paginationOptions?: PaginationOptions
/** 空数据表格高度 */
emptyHeight?: string
/** 空数据时显示的文本 */
emptyText?: string
/** 是否开启 ArtTableHeader解决表格高度自适应问题 */
showTableHeader?: boolean
}
const props = withDefaults(defineProps<ArtTableProps>(), {
columns: () => [],
fit: true,
showHeader: true,
stripe: undefined,
border: undefined,
size: undefined,
emptyHeight: '100%',
emptyText: '暂无数据',
showTableHeader: true
})
const LAYOUT = {
MOBILE: 'prev, pager, next, sizes, jumper, total',
IPAD: 'prev, pager, next, jumper, total',
DESKTOP: 'total, prev, pager, next, sizes, jumper'
}
const layout = computed(() => {
if (width.value < 768) {
return LAYOUT.MOBILE
} else if (width.value < 1024) {
return LAYOUT.IPAD
} else {
return LAYOUT.DESKTOP
}
})
// 默认分页常量
const DEFAULT_PAGINATION_OPTIONS: PaginationOptions = {
pageSizes: [10, 20, 30, 50, 100],
align: 'center',
background: true,
layout: layout.value,
hideOnSinglePage: false,
size: 'default',
pagerCount: width.value > 1200 ? 7 : 5
}
// 合并分页配置
const mergedPaginationOptions = computed(() => ({
...DEFAULT_PAGINATION_OPTIONS,
...props.paginationOptions
}))
// 边框 (优先级props > store)
const border = computed(() => props.border ?? isBorder.value)
// 斑马纹
const stripe = computed(() => props.stripe ?? isZebra.value)
// 表格尺寸
const size = computed(() => props.size ?? tableSize.value)
// 数据是否为空
const isEmpty = computed(() => props.data?.length === 0)
const paginationHeight = ref(0)
const tableHeaderHeight = ref(0)
// 使用 useResizeObserver 监听分页器高度变化
useResizeObserver(paginationRef, (entries) => {
const entry = entries[0]
if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => {
paginationHeight.value = entry.contentRect.height
})
}
})
// 使用 useResizeObserver 监听表格头部高度变化
useResizeObserver(tableHeaderRef, (entries) => {
const entry = entries[0]
if (entry) {
// 使用 requestAnimationFrame 避免 ResizeObserver loop 警告
requestAnimationFrame(() => {
tableHeaderHeight.value = entry.contentRect.height
})
}
})
// 分页器与表格之间的间距常量(计算属性,响应 showTableHeader 变化)
const PAGINATION_SPACING = computed(() => (props.showTableHeader ? 6 : 15))
// 使用表格高度计算 Hook
const { containerHeight } = useTableHeight({
showTableHeader: computed(() => props.showTableHeader),
paginationHeight,
tableHeaderHeight,
paginationSpacing: PAGINATION_SPACING
})
// 表格高度逻辑
const height = computed(() => {
// 全屏模式下占满全屏
if (isFullScreen.value) return '100%'
// 空数据且非加载状态时固定高度
if (isEmpty.value && !props.loading) return props.emptyHeight
// 使用传入的高度
if (props.height) return props.height
// 默认占满容器高度
return '100%'
})
// 表头背景颜色样式
const headerCellStyle = computed(() => ({
background: isHeaderBackground.value
? 'var(--el-fill-color-lighter)'
: 'var(--default-box-color)',
...(props.headerCellStyle || {}) // 合并用户传入的样式
}))
// 是否显示分页器
const showPagination = computed(() => props.pagination && !isEmpty.value)
// 清理列属性,移除插槽相关的自定义属性,确保它们不会被 ElTableColumn 错误解释
const cleanColumnProps = (col: ColumnOption) => {
const columnProps = { ...col }
// 删除自定义的插槽控制属性
delete columnProps.useHeaderSlot
delete columnProps.headerSlotName
delete columnProps.useSlot
delete columnProps.slotName
return columnProps
}
// 分页大小变化
const handleSizeChange = (val: number) => {
emit('pagination:size-change', val)
}
// 分页当前页变化
const handleCurrentChange = (val: number) => {
emit('pagination:current-change', val)
scrollToTop() // 页码改变后滚动到表格顶部
}
// 表格排序变化
const handleSortChange = (payload: {
column?: any
prop?: string
order?: 'ascending' | 'descending' | null
}) => {
emit('sort-change', payload)
}
const { scrollToTop: scrollPageToTop } = useCommon()
// 滚动表格内容到顶部,并可以联动页面滚动到顶部
const scrollToTop = () => {
nextTick(() => {
elTableRef.value?.setScrollTop(0) // 滚动 ElTable 内部滚动条到顶部
scrollPageToTop() // 调用公共 composable 滚动页面到顶部
})
}
// 全局序号
const getGlobalIndex = (index: number) => {
if (!props.pagination) return index + 1
const { current, size } = props.pagination
return (current - 1) * size + index + 1
}
const emit = defineEmits<{
(e: 'pagination:size-change', val: number): void
(e: 'pagination:current-change', val: number): void
(
e: 'sort-change',
payload: { column?: any; prop?: string; order?: 'ascending' | 'descending' | null }
): void
}>()
// 查找并绑定表格头部元素 - 使用 VueUse 优化
const findTableHeader = () => {
if (!props.showTableHeader) {
tableHeaderRef.value = undefined
return
}
const tableHeader = document.getElementById('art-table-header')
if (tableHeader) {
tableHeaderRef.value = tableHeader
} else {
// 如果找不到表格头部,设置为 undefineduseElementSize 会返回 0
tableHeaderRef.value = undefined
}
}
watchEffect(
() => {
// 访问响应式数据以建立依赖追踪
void props.data?.length // 追踪数据变化
const shouldShow = props.showTableHeader
// 只有在需要显示表格头部时才查找
if (shouldShow) {
nextTick(() => {
findTableHeader()
})
} else {
// 不显示时清空引用
tableHeaderRef.value = undefined
}
},
{ flush: 'post' }
)
defineExpose({
scrollToTop,
elTableRef
})
</script>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,99 @@
.art-table {
position: relative;
height: 100%;
.el-table {
height: 100%;
margin-top: 10px;
}
:deep(.el-loading-mask) {
z-index: 100;
background-color: var(--default-box-color) !important;
}
// Loading 过渡动画 - 消失时淡出
.loading-fade-leave-active {
transition: opacity 0.3s ease-out;
}
.loading-fade-leave-to {
opacity: 0;
}
// 空状态垂直居中
&.is-empty {
:deep(.el-scrollbar__wrap) {
display: flex;
}
}
.pagination {
display: flex;
margin-top: 13px;
:deep(.el-select) {
width: 102px !important;
}
// 分页对齐方式
&.left {
justify-content: flex-start;
}
&.center {
justify-content: center;
}
&.right {
justify-content: flex-end;
}
// 自定义分页组件样式
&.custom-pagination {
:deep(.el-pagination) {
.btn-prev,
.btn-next {
background-color: transparent;
border: 1px solid var(--art-gray-300);
transition: border-color 0.15s;
&:hover:not(.is-disabled) {
color: var(--theme-color);
border-color: var(--theme-color);
}
}
li {
box-sizing: border-box;
font-weight: 400 !important;
background-color: transparent;
border: 1px solid var(--art-gray-300);
transition: border-color 0.15s;
&.is-active {
font-weight: 400;
color: #fff;
background-color: var(--theme-color);
border: 1px solid var(--theme-color);
}
&:hover:not(.is-disabled) {
border-color: var(--theme-color);
}
}
}
}
}
}
// 移动端分页
@media (width <= 640px) {
:deep(.el-pagination) {
display: flex;
flex-wrap: wrap;
gap: 15px 0;
align-items: center;
justify-content: center;
}
}

View File

@@ -0,0 +1,310 @@
<!-- 数字滚动 -->
<template>
<span
class="text-g-900 tabular-nums"
:class="isRunning ? 'transition-opacity duration-300 ease-in-out' : ''"
>
{{ formattedValue }}
</span>
</template>
<script setup lang="ts">
import { computed, watch, nextTick, onUnmounted, shallowRef } from 'vue'
import { useTransition, TransitionPresets } from '@vueuse/core'
// 类型定义
interface CountToProps {
/** 目标值 */
target: number
/** 动画持续时间(毫秒) */
duration?: number
/** 是否自动开始 */
autoStart?: boolean
/** 小数位数 */
decimals?: number
/** 小数点符号 */
decimal?: string
/** 千分位分隔符 */
separator?: string
/** 前缀 */
prefix?: string
/** 后缀 */
suffix?: string
/** 缓动函数 */
easing?: keyof typeof TransitionPresets
/** 是否禁用动画 */
disabled?: boolean
}
interface CountToEmits {
started: [value: number]
finished: [value: number]
paused: [value: number]
reset: []
}
interface CountToExpose {
start: (target?: number) => void
pause: () => void
reset: (newTarget?: number) => void
stop: () => void
setTarget: (target: number) => void
readonly isRunning: boolean
readonly isPaused: boolean
readonly currentValue: number
readonly targetValue: number
readonly progress: number
}
// 常量定义
const EPSILON = Number.EPSILON
const MIN_DURATION = 100
const MAX_DURATION = 60000
const MAX_DECIMALS = 10
const DEFAULT_EASING = 'easeOutExpo'
const DEFAULT_DURATION = 2000
const props = withDefaults(defineProps<CountToProps>(), {
target: 0,
duration: DEFAULT_DURATION,
autoStart: true,
decimals: 0,
decimal: '.',
separator: '',
prefix: '',
suffix: '',
easing: DEFAULT_EASING,
disabled: false
})
const emit = defineEmits<CountToEmits>()
// 工具函数
const validateNumber = (value: number, name: string, defaultValue: number): number => {
if (!Number.isFinite(value)) {
console.warn(`[CountTo] Invalid ${name} value:`, value)
return defaultValue
}
return value
}
const clamp = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(value, max))
}
const formatNumber = (
value: number,
decimals: number,
decimal: string,
separator: string
): string => {
let result = decimals > 0 ? value.toFixed(decimals) : Math.floor(value).toString()
// 处理小数点符号
if (decimal !== '.' && result.includes('.')) {
result = result.replace('.', decimal)
}
// 处理千分位分隔符
if (separator) {
const parts = result.split(decimal)
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, separator)
result = parts.join(decimal)
}
return result
}
// 安全计算值
const safeTarget = computed(() => validateNumber(props.target, 'target', 0))
const safeDuration = computed(() =>
clamp(validateNumber(props.duration, 'duration', DEFAULT_DURATION), MIN_DURATION, MAX_DURATION)
)
const safeDecimals = computed(() =>
clamp(validateNumber(props.decimals, 'decimals', 0), 0, MAX_DECIMALS)
)
const safeEasing = computed(() => {
const easing = props.easing
if (!(easing in TransitionPresets)) {
console.warn('[CountTo] Invalid easing value:', easing)
return DEFAULT_EASING
}
return easing
})
// 状态管理
const currentValue = shallowRef(0)
const targetValue = shallowRef(safeTarget.value)
const isRunning = shallowRef(false)
const isPaused = shallowRef(false)
const pausedValue = shallowRef(0)
// 动画控制
const transitionValue = useTransition(currentValue, {
duration: safeDuration,
transition: computed(() => TransitionPresets[safeEasing.value]),
onStarted: () => {
isRunning.value = true
isPaused.value = false
emit('started', targetValue.value)
},
onFinished: () => {
isRunning.value = false
isPaused.value = false
emit('finished', targetValue.value)
}
})
// 格式化显示值
const formattedValue = computed(() => {
const value = isPaused.value ? pausedValue.value : transitionValue.value
if (!Number.isFinite(value)) {
return `${props.prefix}0${props.suffix}`
}
const formattedNumber = formatNumber(value, safeDecimals.value, props.decimal, props.separator)
return `${props.prefix}${formattedNumber}${props.suffix}`
})
// 私有方法
const shouldSkipAnimation = (target: number): boolean => {
const current = isPaused.value ? pausedValue.value : transitionValue.value
return Math.abs(current - target) < EPSILON
}
const resetPauseState = (): void => {
isPaused.value = false
pausedValue.value = 0
}
// 公共方法
const start = (target?: number): void => {
if (props.disabled) {
console.warn('[CountTo] Animation is disabled')
return
}
const finalTarget = target !== undefined ? target : targetValue.value
if (!Number.isFinite(finalTarget)) {
console.warn('[CountTo] Invalid target value for start:', finalTarget)
return
}
targetValue.value = finalTarget
if (shouldSkipAnimation(finalTarget)) {
return
}
// 从暂停值开始(如果存在)
if (isPaused.value) {
currentValue.value = pausedValue.value
resetPauseState()
}
nextTick(() => {
currentValue.value = finalTarget
})
}
const pause = (): void => {
if (!isRunning.value || isPaused.value) {
return
}
isPaused.value = true
pausedValue.value = transitionValue.value
currentValue.value = pausedValue.value
emit('paused', pausedValue.value)
}
const reset = (newTarget = 0): void => {
const target = validateNumber(newTarget, 'reset target', 0)
currentValue.value = target
targetValue.value = target
resetPauseState()
emit('reset')
}
const setTarget = (target: number): void => {
if (!Number.isFinite(target)) {
console.warn('[CountTo] Invalid target value for setTarget:', target)
return
}
targetValue.value = target
if ((isRunning.value || props.autoStart) && !props.disabled) {
start(target)
}
}
const stop = (): void => {
if (isRunning.value || isPaused.value) {
currentValue.value = 0
resetPauseState()
emit('paused', 0)
}
}
// 监听器
watch(
safeTarget,
(newTarget) => {
if (props.autoStart && !props.disabled) {
start(newTarget)
} else {
targetValue.value = newTarget
}
},
{ immediate: props.autoStart && !props.disabled }
)
watch(
() => props.disabled,
(disabled) => {
if (disabled && isRunning.value) {
stop()
}
}
)
// 清理
onUnmounted(() => {
if (isRunning.value) {
stop()
}
})
// 暴露 API
defineExpose<CountToExpose>({
start,
pause,
reset,
stop,
setTarget,
get isRunning() {
return isRunning.value
},
get isPaused() {
return isPaused.value
},
get currentValue() {
return isPaused.value ? pausedValue.value : transitionValue.value
},
get targetValue() {
return targetValue.value
},
get progress() {
const current = isPaused.value ? pausedValue.value : transitionValue.value
const target = targetValue.value
if (target === 0) return current === 0 ? 1 : 0
return Math.abs(current / target)
}
})
</script>

View File

@@ -0,0 +1,32 @@
<!-- 节日文本滚动 -->
<template>
<div
class="overflow-hidden transition-[height] duration-600 ease-in-out"
:style="{
height: showFestivalText ? '48px' : '0'
}"
>
<ArtTextScroll
v-if="showFestivalText && currentFestivalData?.scrollText !== ''"
:text="currentFestivalData?.scrollText || ''"
style="margin-bottom: 12px"
showClose
@close="handleClose"
/>
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/store/modules/setting'
import { useCeremony } from '@/hooks/core/useCeremony'
defineOptions({ name: 'ArtFestivalTextScroll' })
const settingStore = useSettingStore()
const { showFestivalText } = storeToRefs(settingStore)
const { currentFestivalData } = useCeremony()
const handleClose = () => {
settingStore.setShowFestivalText(false)
}
</script>

View File

@@ -0,0 +1,285 @@
<!-- 文字滚动 -->
<template>
<div
ref="containerRef"
class="relative overflow-hidden rounded-custom-sm border flex-c box-border text-sm"
:class="themeClasses"
:style="containerStyle"
>
<div class="flex-cc absolute left-0 h-full w-9 z-10" :style="{ backgroundColor: bgColor }">
<ArtSvgIcon icon="ri:volume-down-line" class="text-lg" />
</div>
<div
ref="contentRef"
class="whitespace-nowrap inline-block transition-opacity duration-600 [&_a]:text-danger [&_a:hover]:underline [&_a:hover]:text-danger/80 px-9"
:class="[contentClass, { 'opacity-0': !isReady, 'opacity-100': isReady }]"
:style="contentStyle"
@click="handleContentClick"
>
<!-- 原始内容 -->
<span ref="textRef" class="inline-block">
<slot>
<span v-html="text"></span>
</slot>
</span>
<!-- 克隆内容用于无缝循环 -->
<span v-if="shouldClone" class="inline-block" :style="cloneSpacing">
<slot>
<span v-html="text"></span>
</slot>
</span>
</div>
<div
v-if="showClose"
class="flex-cc absolute right-0 h-full w-9 c-p"
:style="{ backgroundColor: bgColor }"
@click="handleClose"
>
<ArtSvgIcon icon="ri:close-fill" class="text-lg" />
</div>
</div>
</template>
<script setup lang="ts">
import {
useElementSize,
useRafFn,
useElementHover,
useDebounceFn,
useTimeoutFn
} from '@vueuse/core'
import { useSettingStore } from '@/store/modules/setting'
type ThemeType =
| 'theme'
| 'primary'
| 'secondary'
| 'error'
| 'info'
| 'success'
| 'warning'
| 'danger'
/**
* 文本滚动组件属性接口
*/
export interface TextScrollProps {
/** 滚动文本内容 */
text?: string
/** 主题类型 */
type?: ThemeType
/** 滚动方向 */
direction?: 'left' | 'right' | 'up' | 'down'
/** 滚动速度,单位:像素/秒 */
speed?: number
/** 容器宽度 */
width?: string
/** 容器高度 */
height?: string
/** 鼠标悬停时是否暂停滚动 */
pauseOnHover?: boolean
/** 是否显示关闭按钮 */
showClose?: boolean
/** 始终滚动(即使文字未溢出) */
alwaysScroll?: boolean
}
const props = withDefaults(defineProps<TextScrollProps>(), {
text: '',
direction: 'left',
speed: 80,
width: '100%',
height: '36px',
pauseOnHover: true,
type: 'theme',
showClose: false,
alwaysScroll: true
})
const emit = defineEmits<{
close: []
}>()
const handleClose = () => {
emit('close')
}
const settingStore = useSettingStore()
const { isDark } = storeToRefs(settingStore)
const containerRef = ref<HTMLElement>()
const contentRef = ref<HTMLElement>()
const textRef = ref<HTMLElement>()
const isReady = ref(false)
const currentPosition = ref(0)
const textSize = ref(0)
const containerSize = ref(0)
const shouldClone = ref(false)
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
const isReverse = computed(() => props.direction === 'right' || props.direction === 'down')
// 使用 VueUse 的 useElementSize 监听容器尺寸变化
const { width: containerWidth, height: containerHeight } = useElementSize(containerRef)
// 使用 VueUse 的 useElementHover 检测鼠标悬停
const isHovered = useElementHover(containerRef)
// 计算是否应该暂停动画
const isPaused = computed(() => {
// 如果未启用 alwaysScroll且文字未超出容器则暂停滚动
if (!props.alwaysScroll && textSize.value <= containerSize.value) {
return true
}
return props.pauseOnHover && isHovered.value
})
// 主题样式映射
const themeClasses = computed(() => {
const themeMap: Record<ThemeType, string> = {
theme: 'text-theme/90 !border-theme/50',
primary: 'text-primary/90 !border-primary/50',
secondary: 'text-secondary/90 !border-secondary/50',
error: 'text-error/90 !border-error/50',
info: 'text-info/90 !border-info/50',
success: 'text-success/90 !border-success/50',
warning: 'text-warning/90 !border-warning/50',
danger: 'text-danger/90 !border-danger/50'
}
return themeMap[props.type] || themeMap.theme
})
// 背景色
const bgColor = computed(
() =>
`color-mix(in oklch, var(--color-${props.type}) ${isDark.value ? '25' : '10'}%, var(--art-color))`
)
const containerStyle = computed(() => ({
width: props.width,
height: props.height,
backgroundColor: bgColor.value
}))
const contentClass = computed(() => {
if (!isHorizontal.value) {
return 'flex flex-col'
}
return ''
})
const contentStyle = computed(() => {
const transform = isHorizontal.value
? `translateX(${currentPosition.value}px)`
: `translateY(${currentPosition.value}px)`
return {
transform,
willChange: 'transform'
}
})
// 克隆元素的间距
const cloneSpacing = computed(() => {
const spacing = '2em'
return isHorizontal.value ? { marginLeft: spacing } : { marginTop: spacing }
})
const measureSizes = () => {
if (!containerRef.value || !textRef.value) return
const text = textRef.value
if (isHorizontal.value) {
containerSize.value = containerWidth.value
textSize.value = text.offsetWidth
} else {
containerSize.value = containerHeight.value
textSize.value = text.offsetHeight
}
const isOverflow = textSize.value > containerSize.value
shouldClone.value = isOverflow
// 居中显示
currentPosition.value = (containerSize.value - textSize.value) / 2
// 测量完成后才显示内容
if (!isReady.value) {
isReady.value = true
}
}
// 使用 VueUse 的 useDebounceFn 防抖测量
const debouncedMeasure = useDebounceFn(measureSizes, 150)
let lastTimestamp = 0
// 使用 VueUse 的 useRafFn 替代手动 requestAnimationFrame
const { pause, resume } = useRafFn(
({ timestamp }) => {
if (!lastTimestamp) lastTimestamp = timestamp
if (!isPaused.value) {
const delta = (timestamp - lastTimestamp) / 1000
const distance = props.speed * delta
const spacing = textSize.value * 0.1
currentPosition.value += isReverse.value ? distance : -distance
// 循环边界检测
if (isReverse.value) {
if (currentPosition.value > containerSize.value) {
currentPosition.value = -(textSize.value + spacing)
}
} else {
if (currentPosition.value < -(textSize.value + spacing)) {
currentPosition.value = containerSize.value
}
}
}
lastTimestamp = timestamp
},
{ immediate: false }
)
const handleContentClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (target.tagName === 'A') {
e.stopPropagation()
}
}
// 监听容器尺寸变化
watch([containerWidth, containerHeight], () => {
debouncedMeasure()
})
// 监听属性变化
watch(
() => [props.direction, props.speed, props.text],
() => {
measureSizes()
lastTimestamp = 0
}
)
// 使用 VueUse 的 useTimeoutFn 替代 setTimeout
const { start: startMeasure } = useTimeoutFn(() => {
measureSizes()
// 测量完成后立即开始动画
resume()
}, 100)
onMounted(() => {
startMeasure()
})
onBeforeUnmount(() => {
pause()
})
</script>

View File

@@ -0,0 +1,100 @@
<!-- 一个让 SVG 图片跟随主题的组件只对特定 svg 图片生效不建议开发者使用 -->
<!-- 图片地址 https://iconpark.oceanengine.com/illustrations/13 -->
<template>
<div class="theme-svg" :style="sizeStyle">
<div v-if="src" class="svg-container" v-html="svgContent"></div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
interface Props {
size?: string | number
themeColor?: string
src?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 500,
themeColor: 'var(--el-color-primary)'
})
const svgContent = ref('')
// 计算样式
const sizeStyle = computed(() => {
const sizeValue = typeof props.size === 'number' ? `${props.size}px` : props.size
return {
width: sizeValue,
height: sizeValue
}
})
// 颜色映射配置
const COLOR_MAPPINGS = {
'#C7DEFF': 'var(--el-color-primary-light-6)',
'#071F4D': 'var(--el-color-primary-dark-2)',
'#00E4E5': 'var(--el-color-primary-light-1)',
'#006EFF': 'var(--el-color-primary)',
'#fff': 'var(--default-box-color)',
'#ffffff': 'var(--default-box-color)',
'#DEEBFC': 'var(--el-color-primary-light-7)'
} as const
// 将主题色应用到 SVG 内容
const applyThemeToSvg = (content: string): string => {
return Object.entries(COLOR_MAPPINGS).reduce(
(processedContent, [originalColor, themeColor]) => {
const fillRegex = new RegExp(`fill="${originalColor}"`, 'gi')
const strokeRegex = new RegExp(`stroke="${originalColor}"`, 'gi')
return processedContent
.replace(fillRegex, `fill="${themeColor}"`)
.replace(strokeRegex, `stroke="${themeColor}"`)
},
content
)
}
// 加载 SVG 文件内容
const loadSvgContent = async () => {
if (!props.src) {
svgContent.value = ''
return
}
try {
const response = await fetch(props.src)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const content = await response.text()
svgContent.value = applyThemeToSvg(content)
} catch (error) {
console.error('Failed to load SVG:', error)
svgContent.value = ''
}
}
watchEffect(() => {
loadSvgContent()
})
</script>
<style lang="scss" scoped>
.theme-svg {
display: inline-block;
.svg-container {
width: 100%;
height: 100%;
:deep(svg) {
width: 100%;
height: 100%;
}
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="page-content !border-0 !bg-transparent min-h-screen flex-cc">
<div class="flex-cc max-md:!block max-md:text-center">
<ThemeSvg :src="data.imgUrl" size="100%" class="!w-100" />
<div class="ml-15 w-75 max-md:mx-auto max-md:mt-10 max-md:w-full max-md:text-center">
<p class="text-xl leading-7 text-g-600 max-md:text-lg">{{ data.desc }}</p>
<ElButton type="primary" size="large" @click="backHome" v-ripple class="mt-5">{{
data.btnText
}}</ElButton>
<ElButton type="danger" size="large" @click="backLogin" v-ripple class="mt-5">
<template #icon>
<ArtSvgIcon icon="ri:login-box-line" />
</template>
重新登录
</ElButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useCommon } from '@/hooks/core/useCommon'
const router = useRouter()
interface ExceptionData {
/** 标题 */
title: string
/** 描述 */
desc: string
/** 按钮文本 */
btnText: string
/** 图片地址 */
imgUrl: string
}
withDefaults(
defineProps<{
data: ExceptionData
}>(),
{}
)
const { homePath } = useCommon()
const backHome = () => {
router.push(homePath.value)
}
const backLogin = () => {
router.push('/auth/login')
}
</script>

View File

@@ -0,0 +1,149 @@
<!-- 授权页右上角组件 -->
<template>
<div
class="absolute w-full flex-cb top-4.5 z-10 flex-c !justify-end max-[1180px]:!justify-between"
>
<div class="flex-cc !hidden max-[1180px]:!flex ml-2 max-sm:ml-6">
<ArtLogo class="icon" size="46" />
<h1 class="text-xl ont-mediumf ml-2">{{ AppConfig.systemInfo.name }}</h1>
</div>
<div class="flex-cc gap-1.5 mr-2 max-sm:mr-5">
<div class="color-picker-expandable relative flex-c max-sm:!hidden">
<div
class="color-dots absolute right-0 rounded-full flex-c gap-2 rounded-5 px-2.5 py-2 pr-9 pl-2.5 opacity-0"
>
<div
v-for="(color, index) in mainColors"
:key="color"
class="color-dot relative size-5 c-p flex-cc rounded-full opacity-0"
:class="{ active: color === systemThemeColor }"
:style="{ background: color, '--index': index }"
@click="changeThemeColor(color)"
>
<ArtSvgIcon v-if="color === systemThemeColor" icon="ri:check-fill" class="text-white" />
</div>
</div>
<div class="btn palette-btn relative z-[2] h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:palette-line"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
</div>
<ElDropdown
v-if="shouldShowLanguage"
@command="changeLanguage"
popper-class="langDropDownStyle"
>
<div class="btn language-btn h-8 w-8 c-p flex-cc tad-300">
<ArtSvgIcon
icon="ri:translate-2"
class="text-[19px] text-g-800 transition-colors duration-300"
/>
</div>
<template #dropdown>
<ElDropdownMenu>
<div v-for="lang in languageOptions" :key="lang.value" class="lang-btn-item">
<ElDropdownItem
:command="lang.value"
:class="{ 'is-selected': locale === lang.value }"
>
<span class="menu-txt">{{ lang.label }}</span>
<ArtSvgIcon icon="ri:check-fill" class="text-base" v-if="locale === lang.value" />
</ElDropdownItem>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<div
v-if="shouldShowThemeToggle"
class="btn theme-btn h-8 w-8 c-p flex-cc tad-300"
@click="themeAnimation"
>
<ArtSvgIcon
:icon="isDark ? 'ri:sun-fill' : 'ri:moon-line'"
class="text-xl text-g-800 transition-colors duration-300"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useHeaderBar } from '@/hooks/core/useHeaderBar'
import { themeAnimation } from '@/utils/ui/animation'
import { languageOptions } from '@/locales'
import { LanguageEnum } from '@/enums/appEnum'
import AppConfig from '@/config'
defineOptions({ name: 'AuthTopBar' })
const settingStore = useSettingStore()
const userStore = useUserStore()
const { isDark, systemThemeColor } = storeToRefs(settingStore)
const { shouldShowThemeToggle, shouldShowLanguage } = useHeaderBar()
const { locale } = useI18n()
const mainColors = AppConfig.systemMainColor
const color = systemThemeColor // css v-bind 使用
const changeLanguage = (lang: LanguageEnum) => {
if (locale.value === lang) return
locale.value = lang
userStore.setLanguage(lang)
}
const changeThemeColor = (color: string) => {
if (systemThemeColor.value === color) return
settingStore.setElementTheme(color)
settingStore.reload()
}
</script>
<style scoped>
.color-dots {
pointer-events: none;
backdrop-filter: blur(10px);
box-shadow: 0 2px 12px var(--art-gray-300);
transition:
opacity 0.3s ease,
transform 0.3s ease;
transform: translateX(10px);
}
.color-dot {
box-shadow: 0 2px 4px rgb(0 0 0 / 15%);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition-delay: calc(var(--index) * 0.05s);
transform: translateX(20px) scale(0.8);
}
.color-dot:hover {
box-shadow: 0 4px 8px rgb(0 0 0 / 20%);
transform: translateX(0) scale(1.1);
}
.color-picker-expandable:hover .color-dots {
pointer-events: auto;
opacity: 1;
transform: translateX(0);
}
.color-picker-expandable:hover .color-dot {
opacity: 1;
transform: translateX(0) scale(1);
}
.dark .color-dots {
background-color: var(--art-gray-200);
box-shadow: none;
}
.color-picker-expandable:hover .palette-btn :deep(.art-svg-icon) {
color: v-bind(color);
}
</style>

View File

@@ -0,0 +1,602 @@
<!-- 登录注册忘记密码左侧背景 -->
<template>
<div class="login-left-view">
<div class="logo">
<ArtLogo class="icon" size="46" />
<h1 class="title">{{ AppConfig.systemInfo.name }}</h1>
</div>
<div class="left-img">
<ThemeSvg :src="loginIcon" size="100%" />
</div>
<div class="text-wrap">
<h1> {{ $t('login.leftView.title') }} </h1>
<p> {{ $t('login.leftView.subTitle') }} </p>
</div>
<!-- 几何装饰元素 -->
<div class="geometric-decorations">
<!-- 基础几何形状 -->
<div class="geo-element circle-outline animate-fade-in-up" style="animation-delay: 0s"></div>
<div
class="geo-element square-rotated animate-fade-in-left"
style="animation-delay: 0s"
></div>
<div class="geo-element circle-small animate-fade-in-up" style="animation-delay: 0.3s"></div>
<div
class="geo-element square-bottom-right animate-fade-in-right"
style="animation-delay: 0s"
></div>
<!-- 背景泡泡 -->
<div class="geo-element bg-bubble animate-scale-in" style="animation-delay: 0.5"></div>
<!-- 太阳/月亮 -->
<div
class="geo-element circle-top-right animate-fade-in-down"
style="animation-delay: 0.5"
@click="themeAnimation"
></div>
<!-- 装饰点 -->
<div class="geo-element dot dot-top-left animate-bounce-in" style="animation-delay: 0s"></div>
<div
class="geo-element dot dot-top-right animate-bounce-in"
style="animation-delay: 0s"
></div>
<div
class="geo-element dot dot-center-right animate-bounce-in"
style="animation-delay: 0s"
></div>
<!-- 叠加方块组 -->
<div class="squares-group">
<i
class="geo-element square square-blue animate-fade-in-left-rotated-blue"
style="animation-delay: 0.2s"
></i>
<i
class="geo-element square square-pink animate-fade-in-left-rotated-pink"
style="animation-delay: 0.4s"
></i>
<i
class="geo-element square square-purple animate-fade-in-left-no-rotation"
style="animation-delay: 0.6s"
></i>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import loginIcon from '@imgs/svg/login_icon.svg'
import { themeAnimation } from '@/utils/ui/animation'
// 定义 props
defineProps<{
hideContent?: boolean // 是否隐藏内容,只显示 logo
}>()
</script>
<style lang="scss" scoped>
// 颜色变量定义
$primary-light-7: var(--el-color-primary-light-7);
$primary-light-8: var(--el-color-primary-light-8);
$primary-light-9: var(--el-color-primary-light-9);
$primary-base: var(--el-color-primary);
$main-bg: var(--default-box-color);
// 混合颜色函数
$bg-mix-light-9: color-mix(in srgb, $primary-light-9 100%, $main-bg);
$bg-mix-light-8: color-mix(in srgb, $primary-light-8 80%, $main-bg);
$bg-mix-light-7: color-mix(in srgb, $primary-light-7 80%, $main-bg);
.login-left-view {
position: relative;
box-sizing: border-box;
width: 65vw;
height: 100%;
padding: 15px;
overflow: hidden;
background-color: $bg-mix-light-9;
.logo {
position: relative;
z-index: 100;
display: flex;
align-items: center;
.title {
margin-left: 10px;
font-size: 20px;
font-weight: 400;
}
}
.left-img {
position: absolute;
inset: 0 0 10.5%;
z-index: 10;
width: 40%;
margin: auto;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
.text-wrap {
position: absolute;
bottom: 80px;
width: 100%;
text-align: center;
animation: slideInLeft 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
h1 {
font-size: 24px;
font-weight: 400;
color: var(--art-gray-900) !important;
}
p {
margin-top: 10px;
font-size: 14px;
color: var(--art-gray-600) !important;
}
}
.geometric-decorations {
.geo-element {
position: absolute;
opacity: 0;
animation-fill-mode: forwards;
animation-duration: 0.8s;
animation-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
// 动画 mixin
@mixin fadeAnimation($direction: '', $rotation: 0deg) {
from {
opacity: 0;
@if $direction == 'up' {
transform: translateY(30px) rotate($rotation);
} @else if $direction == 'down' {
transform: translateY(-30px) rotate($rotation);
} @else if $direction == 'left' {
transform: translateX(-30px) rotate($rotation);
} @else if $direction == 'right' {
transform: translateX(30px) rotate($rotation);
}
}
to {
opacity: 1;
@if $direction == 'up' or $direction == 'down' {
transform: translateY(0) rotate($rotation);
} @else {
transform: translateX(0) rotate($rotation);
}
}
}
// 动画定义
@keyframes fadeInUp {
@include fadeAnimation('up');
}
@keyframes fadeInDown {
@include fadeAnimation('down');
}
@keyframes fadeInLeft {
@include fadeAnimation('left');
}
@keyframes fadeInLeftRotated {
@include fadeAnimation('left', -25deg);
}
@keyframes fadeInRight {
@include fadeAnimation('right');
}
@keyframes fadeInRightRotated {
@include fadeAnimation('right', 45deg);
}
@keyframes fadeInLeftRotatedBlue {
@include fadeAnimation('left', -10deg);
}
@keyframes fadeInLeftRotatedPink {
@include fadeAnimation('left', 10deg);
}
@keyframes fadeInLeftNoRotation {
@include fadeAnimation('left');
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.8);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes bounceIn {
0% {
opacity: 0;
transform: scale(0.3);
}
50% {
opacity: 1;
transform: scale(1.05);
}
70% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes lineGrow {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from {
opacity: 0;
transform: translateX(-30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
// 动画类
.animate-fade-in-up {
animation-name: fadeInUp;
}
.animate-fade-in-down {
animation-name: fadeInDown;
}
.animate-fade-in-left {
animation-name: fadeInLeft;
}
.animate-fade-in-right {
animation-name: fadeInRight;
}
.animate-scale-in {
animation-name: scaleIn;
animation-duration: 1.2s;
}
.animate-bounce-in {
animation-name: bounceIn;
animation-duration: 0.6s;
}
.animate-fade-in-left-rotated-blue {
animation-name: fadeInLeftRotatedBlue;
}
.animate-fade-in-left-rotated-pink {
animation-name: fadeInLeftRotatedPink;
}
.animate-fade-in-left-no-rotation {
animation-name: fadeInLeftNoRotation;
}
// 基础几何形状
.circle-outline {
top: 10%;
left: 25%;
width: 42px;
height: 42px;
border: 2px solid $primary-light-8;
border-radius: 50%;
}
.square-rotated {
top: 50%;
left: 16%;
width: 60px;
height: 60px;
background-color: $bg-mix-light-8;
&.animate-fade-in-left {
animation-name: fadeInLeftRotated;
}
}
.circle-small {
bottom: 26%;
left: 30%;
width: 18px;
height: 18px;
background-color: $primary-light-8;
border-radius: 50%;
}
// 太阳/月亮效果
.circle-top-right {
top: 3%;
right: 3%;
z-index: 100;
width: 50px;
height: 50px;
cursor: pointer;
background: $bg-mix-light-7;
border-radius: 50%;
transition: all 0.3s;
&::after {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
content: '';
background: linear-gradient(to right, #fcbb04, #fffc00);
border-radius: 50%;
opacity: 0;
transition: all 0.5s;
transform: translate(-50%, -50%);
}
&:hover {
box-shadow: 0 0 36px #fffc00;
&::after {
opacity: 1;
}
}
}
.square-bottom-right {
right: 10%;
bottom: 10%;
width: 50px;
height: 50px;
background-color: $primary-light-8;
&.animate-fade-in-right {
animation-name: fadeInRightRotated;
}
}
// 背景泡泡
.bg-bubble {
top: -120px;
right: -120px;
width: 360px;
height: 360px;
background-color: $bg-mix-light-8;
border-radius: 50%;
}
// 装饰点
.dot {
width: 14px;
height: 14px;
background-color: $primary-light-7;
border-radius: 50%;
&.dot-top-left {
top: 140px;
left: 100px;
}
&.dot-top-right {
top: 140px;
right: 120px;
}
&.dot-center-right {
top: 46%;
right: 22%;
background-color: $primary-light-8;
}
}
// 叠加方块组
.squares-group {
position: absolute;
bottom: 18px;
left: 20px;
width: 140px;
height: 140px;
pointer-events: none;
.square {
position: absolute;
display: block;
border-radius: 8px;
box-shadow: 0 8px 24px rgb(64 87 167 / 12%);
&.square-blue {
top: 12px;
left: 30px;
z-index: 2;
width: 50px;
height: 50px;
background-color: rgb(from $primary-base r g b / 30%);
}
&.square-pink {
top: 30px;
left: 48px;
z-index: 1;
width: 70px;
height: 70px;
background-color: rgb(from $primary-base r g b / 15%);
}
&.square-purple {
top: 66px;
left: 86px;
z-index: 3;
width: 32px;
height: 32px;
background-color: rgb(from $primary-base r g b / 45%);
}
}
// 装饰线条
&::after {
position: absolute;
top: 86px;
left: 72px;
width: 80px;
height: 1px;
content: '';
background: linear-gradient(90deg, var(--el-color-primary-light-6), transparent);
opacity: 0;
transform: rotate(50deg);
animation: lineGrow 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
animation-delay: 1.2s;
}
}
}
@media only screen and (width <= 1600px) {
width: 60vw;
.text-wrap {
bottom: 40px;
}
}
@media only screen and (width <= 1180px) {
width: auto;
height: auto;
padding: 0;
// 隐藏背景和其他内容,只保留 logo
background: transparent;
.left-img,
.text-wrap,
.geometric-decorations {
display: none;
}
.logo {
display: none;
}
}
}
// 暗色主题
.dark .login-left-view {
background-color: color-mix(in srgb, $primary-light-9 60%, #070707);
@media only screen and (width <= 1180px) {
background: transparent;
}
.geometric-decorations {
// 月亮效果
.circle-top-right {
background-color: $bg-mix-light-8;
box-shadow: 0 0 25px #333 inset;
transition: all 0.3s ease-in-out 0.1s;
rotate: -48deg;
&::before {
position: absolute;
top: 0;
left: 15px;
width: 50px;
height: 50px;
content: '';
background-color: $bg-mix-light-9;
border-radius: 50%;
transition: all 0.3s ease-in-out;
}
&:hover {
background-color: transparent;
box-shadow: 0 40px 25px #ddd inset;
&::before {
left: 18px;
}
&::after {
opacity: 0;
}
}
}
.bg-bubble {
background-color: $bg-mix-light-9;
}
// 其他元素颜色调整
.square-rotated {
background-color: $bg-mix-light-9;
}
.circle-small,
.dot {
background-color: $primary-light-8;
}
.square-bottom-right {
background-color: $primary-light-9;
}
.dot.dot-top-right {
background-color: $primary-light-8;
}
}
// 方块组暗色调整
.squares-group {
.square {
box-shadow: none;
&.square-blue {
background-color: rgb(from $primary-base r g b / 18%);
}
&.square-pink {
background-color: rgb(from $primary-base r g b / 10%);
}
&.square-purple {
background-color: rgb(from $primary-base r g b / 20%);
}
}
&::after {
background: linear-gradient(90deg, $primary-light-8, transparent);
}
}
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="page-content box-border !px-20 py-3.5 text-center max-md:!px-5" :class="type">
<ArtSvgIcon
class="icon size-22 p-2 mt-16 block rounded-full !text-white"
:icon="iconCode"
:class="type === 'success' ? 'bg-[#19BE6B]' : 'bg-[#ED4014]'"
/>
<h1 class="title mt-8 text-3xl font-medium !text-g-900 max-md:mt-2.5 max-md:text-2xl">{{
title
}}</h1>
<p class="msg mt-5 text-base text-g-600">{{ message }}</p>
<div
class="res mt-7.5 rounded bg-g-200/80 dark:bg-g-300/40 px-7.5 py-5.5 text-left max-md:px-7.5 max-md:py-2.5 [&_p]:flex [&_p]:items-center [&_p]:py-2 [&_p]:text-sm [&_p]:text-[#808695] [&_p_i]:mr-1.5"
>
<slot name="content"></slot>
</div>
<div class="btn-group mt-12.5">
<slot name="buttons"></slot>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ArtResultPage' })
interface ResultPageProps {
/** 成功/失败 */
type: 'success' | 'fail'
/** 标题 */
title: string
/** 消息 */
message: string
/** 图标 */
iconCode: string
}
withDefaults(defineProps<ResultPageProps>(), {
type: 'success',
title: '',
message: '',
iconCode: ''
})
</script>

View File

@@ -0,0 +1,23 @@
<!-- 按钮组件 -->
<template>
<div
class="size-8.5 inline-flex flex-cc c-p text-g-600 dark:text-g-800 text-xl rounded tad-300 hover:bg-hover-color"
:class="{ 'rounded-full': circle }"
>
<ArtSvgIcon :icon="icon"></ArtSvgIcon>
<slot></slot>
</div>
</template>
<script lang="ts" setup>
defineOptions({ name: 'ArtIconButton' })
interface Props {
/** 图标名称 */
icon: string
/** 圆角按钮 */
circle?: boolean
}
withDefaults(defineProps<Props>(), {})
</script>

View File

@@ -0,0 +1,71 @@
<!-- 表格按钮 -->
<template>
<div>
<el-tooltip :disabled="toolTip === ''" :content="toolTip" placement="top">
<div
:class="[
'inline-flex items-center justify-center min-w-8 h-8 px-2.5 text-sm c-p rounded-md align-middle',
buttonClass
]"
:style="{ backgroundColor: buttonBgColor, color: iconColor }"
@click="handleClick"
>
<Icon v-bind="bindAttrs" :icon="iconContent" class="art-svg-icon inline" />
</div>
</el-tooltip>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue'
defineOptions({ name: 'SaButton' })
interface Props {
/** 按钮类型 */
type?: 'primary' | 'secondary' | 'error' | 'info' | 'success'
/** 按钮图标 */
icon?: string
/** 按钮工具提示 */
toolTip?: string
/** icon 颜色 */
iconColor?: string
/** 按钮背景色 */
buttonBgColor?: string
}
const props = withDefaults(defineProps<Props>(), { toolTip: '' })
const attrs = useAttrs()
const bindAttrs = computed<{ class: string; style: string }>(() => ({
class: (attrs.class as string) || '',
style: (attrs.style as string) || ''
}))
const emit = defineEmits<{
(e: 'click'): void
}>()
// 默认按钮配置
const defaultButtons = {
primary: { icon: 'ri:add-fill', class: 'bg-primary/12 text-primary' },
secondary: { icon: 'ri:pencil-line', class: 'bg-secondary/12 text-secondary' },
error: { icon: 'ri:delete-bin-5-line', class: 'bg-error/12 text-error' },
info: { icon: 'ri:more-2-fill', class: 'bg-info/12 text-info' },
success: { icon: 'ri:eye-line', class: 'bg-success/12 text-success' }
} as const
// 获取图标内容
const iconContent = computed(() => {
return props.icon || (props.type ? defaultButtons[props.type]?.icon : '') || ''
})
// 获取按钮样式类
const buttonClass = computed(() => {
return props.type ? defaultButtons[props.type]?.class : ''
})
const handleClick = () => {
emit('click')
}
</script>

View File

@@ -0,0 +1,108 @@
<template>
<el-checkbox-group
v-model="modelValue"
v-bind="$attrs"
:disabled="disabled"
:size="size"
:fill="fill"
:text-color="textColor"
>
<!-- 模式1: 按钮样式 -->
<template v-if="type === 'button'">
<el-checkbox-button
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</el-checkbox-button>
</template>
<!-- 模式2: 普通/边框样式 -->
<template v-else>
<el-checkbox
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:border="type === 'border'"
:disabled="item.disabled"
>
{{ item.label }}
</el-checkbox>
</template>
</el-checkbox-group>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaCheckbox', inheritAttrs: false })
interface Props {
dict: string
type?: 'checkbox' | 'button' | 'border'
disabled?: boolean
size?: 'large' | 'default' | 'small'
fill?: string
textColor?: string
/**
* 强制转换字典值的类型
* 可选值: 'number' | 'string'
* 默认使用 'number'
*/
valueType?: 'number' | 'string'
}
const props = withDefaults(defineProps<Props>(), {
type: 'checkbox',
disabled: false,
size: 'default',
fill: '',
textColor: '',
valueType: 'number'
})
const modelValue = defineModel<(string | number)[]>()
const dictStore = useDictStore()
const canConvertToNumberStrict = (value: any) => {
if (value == null) return false
if (typeof value === 'boolean') return false
if (Array.isArray(value) && value.length !== 1) return false
if (typeof value === 'object' && !Array.isArray(value)) return false
const num = Number(value)
return !isNaN(num)
}
const options = computed(() => {
const list = dictStore.getByCode(props.dict) || []
if (!props.valueType) return list
return list.map((item) => {
let newValue = item.value
switch (props.valueType) {
case 'number':
if (canConvertToNumberStrict(item.value)) {
newValue = Number(item.value)
}
break
case 'string':
newValue = String(item.value)
break
}
return {
...item,
value: newValue
}
})
})
</script>

View File

@@ -0,0 +1,216 @@
# sa-chunk-upload 切片上传组件
一个支持大文件分片上传的 Vue 3 组件,基于 Element Plus 的 el-upload 封装。
## 功能特性
-**分片上传**: 自动将大文件切分成小块上传,支持自定义分片大小
-**MD5 校验**: 自动计算文件 MD5 哈希值,用于文件去重和完整性校验
-**进度跟踪**: 实时显示上传进度、当前分片、上传速度
-**断点续传**: 支持上传失败后重试(需后端配合)
-**取消上传**: 可随时取消正在进行的上传任务
-**拖拽上传**: 支持拖拽文件到指定区域上传
-**文件验证**: 支持文件类型和大小验证
-**并发控制**: 串行上传多个文件,避免服务器压力过大
## Props 参数
| 参数 | 说明 | 类型 | 默认值 |
|------|------|------|--------|
| modelValue | v-model 绑定值,文件 URL | `string \| string[]` | `[]` |
| multiple | 是否支持多选文件 | `boolean` | `false` |
| limit | 最大上传文件数量 | `number` | `1` |
| maxSize | 单个文件最大大小MB | `number` | `1024` |
| chunkSize | 分片大小MB | `number` | `5` |
| accept | 接受的文件类型 | `string` | `'*'` |
| acceptHint | 文件类型提示文本 | `string` | `''` |
| disabled | 是否禁用 | `boolean` | `false` |
| drag | 是否启用拖拽上传 | `boolean` | `true` |
| buttonText | 按钮文本 | `string` | `'选择文件'` |
| autoUpload | 是否自动上传 | `boolean` | `false` |
## Events 事件
| 事件名 | 说明 | 回调参数 |
|--------|------|----------|
| update:modelValue | 文件 URL 更新 | `(value: string \| string[])` |
| success | 上传成功 | `(response: any)` |
| error | 上传失败 | `(error: any)` |
| progress | 上传进度更新 | `(progress: number)` |
## 基本用法
```vue
<template>
<sa-chunk-upload v-model="fileUrl" />
</template>
<script setup>
import { ref } from 'vue'
const fileUrl = ref('')
</script>
```
## 高级用法
### 1. 大视频文件上传
```vue
<template>
<sa-chunk-upload
v-model="videoUrl"
accept="video/*"
accept-hint="MP4AVIMOV"
:max-size="2048"
:chunk-size="10"
:drag="true"
@success="handleSuccess"
@progress="handleProgress"
/>
</template>
<script setup>
import { ref } from 'vue'
const videoUrl = ref('')
const handleSuccess = (response) => {
console.log('上传成功:', response)
}
const handleProgress = (progress) => {
console.log('上传进度:', progress + '%')
}
</script>
```
### 2. 多文件上传
```vue
<template>
<sa-chunk-upload
v-model="fileUrls"
:multiple="true"
:limit="5"
:chunk-size="5"
/>
</template>
<script setup>
import { ref } from 'vue'
const fileUrls = ref([])
</script>
```
### 3. 限制文件类型
```vue
<template>
<!-- 只允许上传压缩文件 -->
<sa-chunk-upload
v-model="zipUrl"
accept=".zip,.rar,.7z"
accept-hint="ZIPRAR7Z"
:max-size="500"
/>
<!-- 只允许上传 PDF -->
<sa-chunk-upload
v-model="pdfUrl"
accept=".pdf"
accept-hint="PDF"
/>
</template>
```
### 4. 自动上传模式
```vue
<template>
<sa-chunk-upload
v-model="fileUrl"
:auto-upload="true"
/>
</template>
```
### 5. 手动控制上传
```vue
<template>
<sa-chunk-upload
v-model="fileUrl"
:auto-upload="false"
/>
<!-- 组件会自动显示"开始上传"按钮 -->
</template>
```
## 后端接口要求
组件会调用 `chunkUpload` API每个分片上传时会发送以下参数
```typescript
{
file: Blob, // 分片文件数据
hash: string, // 文件 MD5 哈希值
chunkIndex: number, // 当前分片索引(从 0 开始)
totalChunks: number, // 总分片数
fileName: string // 原始文件名
}
```
### 后端实现建议
1. **接收分片**: 根据 `hash``chunkIndex` 保存分片
2. **合并文件**: 当接收到所有分片后(`chunkIndex === totalChunks - 1`),合并所有分片
3. **返回 URL**: 合并完成后返回文件访问 URL
4. **断点续传**: 可以实现接口检查已上传的分片,避免重复上传
### 示例后端响应
```json
{
"code": 200,
"data": {
"url": "/uploads/abc123def456/example.mp4",
"hash": "abc123def456"
},
"message": "上传成功"
}
```
## 工作原理
1. **文件选择**: 用户选择文件后,组件计算文件需要分成多少片
2. **MD5 计算**: 计算整个文件的 MD5 哈希值(用于去重和校验)
3. **分片上传**: 按顺序上传每个分片,实时更新进度
4. **进度显示**: 显示当前分片、总分片数、上传速度
5. **完成处理**: 所有分片上传完成后,更新 v-model 值
## 性能优化建议
1. **分片大小**:
- 网络较好: 可设置 10-20MB
- 网络一般: 建议 5-10MB
- 网络较差: 建议 2-5MB
2. **文件大小限制**: 根据实际需求设置合理的 `maxSize`
3. **并发控制**: 组件默认串行上传文件,避免同时上传多个大文件
## 注意事项
1. 需要安装依赖: `spark-md5``@types/spark-md5`
2. 后端需要实现分片接收和合并逻辑
3. 建议在后端实现文件去重(通过 MD5 哈希)
4. 大文件上传时注意服务器超时设置
## 依赖安装
```bash
pnpm add spark-md5
pnpm add -D @types/spark-md5
```

View File

@@ -0,0 +1,623 @@
<template>
<div class="sa-chunk-upload">
<el-upload
ref="uploadRef"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:auto-upload="false"
:on-change="handleFileChange"
:on-remove="handleRemove"
:on-exceed="handleExceed"
:disabled="disabled || uploading"
:drag="drag"
class="upload-container"
>
<template #default>
<div v-if="drag" class="upload-dragger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="upload-text">将文件拖到此处<em>点击选择</em></div>
<div class="upload-hint">支持大文件上传自动分片处理</div>
</div>
<el-button v-else type="primary" :icon="Upload" :disabled="disabled || uploading">
{{ buttonText }}
</el-button>
</template>
<template #tip>
<div class="el-upload__tip">
<span v-if="acceptHint">支持 {{ acceptHint }} 格式</span>
单个文件不超过 {{ maxSize }}MB最多上传 {{ limit }} 个文件
<span v-if="chunkSize">分片大小: {{ chunkSize }}MB</span>
</div>
</template>
</el-upload>
<!-- 上传进度 -->
<div v-if="uploadingFiles.length > 0" class="upload-progress-list">
<div v-for="item in uploadingFiles" :key="item.uid" class="upload-progress-item">
<div class="file-info">
<el-icon class="file-icon"><Document /></el-icon>
<span class="file-name">{{ item.name }}</span>
<span class="file-size">{{ formatFileSize(item.size) }}</span>
</div>
<div class="progress-bar">
<el-progress
:percentage="item.progress"
:status="getProgressStatus(item.status)"
:stroke-width="8"
/>
</div>
<div class="progress-info">
<span class="progress-text">
{{ item.currentChunk }}/{{ item.totalChunks }} 分片
<span v-if="item.speed"> - {{ item.speed }}</span>
</span>
<div class="action-buttons">
<el-button
v-if="item.status === 'exception'"
type="text"
size="small"
@click="retryUpload(item)"
>
重试
</el-button>
<el-button
v-else-if="item.status !== 'success'"
type="text"
size="small"
@click="cancelUpload(item)"
>
取消
</el-button>
<el-button type="text" size="small" @click="removeUploadingFile(item)">
删除
</el-button>
</div>
</div>
</div>
</div>
<!-- 开始上传按钮 -->
<div v-if="pendingFiles.length > 0 && !uploading" class="upload-actions">
<el-button type="primary" @click="startUpload">
开始上传 ({{ pendingFiles.length }} 个文件)
</el-button>
<el-button @click="clearPending">清空列表</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { Upload, UploadFilled, Document } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile, UploadFile } from 'element-plus'
import { chunkUpload } from '@/api/auth'
import SparkMD5 from 'spark-md5'
defineOptions({ name: 'SaChunkUpload' })
// 定义 Props
interface Props {
modelValue?: string | string[] // v-model 绑定值
multiple?: boolean // 是否支持多选
limit?: number // 最大上传数量
maxSize?: number // 最大文件大小(MB)
chunkSize?: number // 分片大小(MB),默认 5MB
accept?: string // 接受的文件类型
acceptHint?: string // 接受文件类型的提示文本
disabled?: boolean // 是否禁用
drag?: boolean // 是否启用拖拽上传
buttonText?: string // 按钮文本
autoUpload?: boolean // 是否自动上传
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
limit: 1,
maxSize: 1024, // 默认最大 1GB
chunkSize: 5, // 默认 5MB 分片
accept: '*',
acceptHint: '',
disabled: false,
drag: true,
buttonText: '选择文件',
autoUpload: false
})
// 定义 Emits
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
success: [response: any]
error: [error: any]
progress: [progress: number]
}>()
// 上传文件信息接口
interface UploadingFile {
uid: number
name: string
size: number
file: File
progress: number
status: 'ready' | 'uploading' | 'success' | 'exception'
currentChunk: number
totalChunks: number
speed: string
hash?: string
uploadedChunks: Set<number>
canceled: boolean
}
// 状态
const uploadRef = ref()
const fileList = ref<UploadUserFile[]>([])
const uploadingFiles = ref<UploadingFile[]>([])
const uploading = ref(false)
const ext = ref<string | Blob>('')
// 待上传文件
const pendingFiles = computed(() => {
return uploadingFiles.value.filter((f) => f.status === 'ready')
})
// 将上传状态映射到进度条状态
const getProgressStatus = (status: 'ready' | 'uploading' | 'success' | 'exception') => {
if (status === 'success') return 'success'
if (status === 'exception') return 'exception'
return undefined // ready 和 uploading 使用默认状态
}
// 监听 modelValue 变化,同步组件状态
watch(
() => props.modelValue,
(newVal) => {
// 如果 modelValue 被清空(表单重置),清空所有状态
if (!newVal || (Array.isArray(newVal) && newVal.length === 0)) {
uploadingFiles.value = []
fileList.value = []
uploadRef.value?.clearFiles()
return
}
// 如果 modelValue 有值,同步到 fileList用于编辑场景
const urls = Array.isArray(newVal) ? newVal : [newVal]
const existingUrls = fileList.value.map((f) => f.url)
// 只添加新的 URL避免重复
urls
.filter((url) => url && !existingUrls.includes(url))
.forEach((url, index) => {
const fileName = url.split('/').pop() || `file-${index + 1}`
fileList.value.push({
name: fileName,
url: url,
uid: Date.now() + index
})
})
},
{ immediate: true }
)
// 文件选择变化
const handleFileChange: UploadProps['onChange'] = (uploadFile: UploadFile) => {
const file = uploadFile.raw
if (!file) return
// 验证文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
return
}
ext.value = '' + file.name.split('.').pop()?.toLowerCase()
// 验证文件类型
if (props.accept && props.accept !== '*') {
const acceptTypes = props.accept.split(',').map((type) => type.trim())
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()
const fileMimeType = file.type
const isAccepted = acceptTypes.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase()
}
if (type.includes('/*')) {
const mainType = type.split('/')[0]
return fileMimeType.startsWith(mainType)
}
return fileMimeType === type
})
if (!isAccepted) {
ElMessage.error(
`不支持的文件类型!${props.acceptHint ? '请上传 ' + props.acceptHint + ' 格式的文件' : ''}`
)
return
}
}
// 计算分片数量
const chunkSizeBytes = props.chunkSize * 1024 * 1024
const totalChunks = Math.ceil(file.size / chunkSizeBytes)
// 添加到上传列表
const uploadingFile: UploadingFile = {
uid: uploadFile.uid,
name: file.name,
size: file.size,
file: file,
progress: 0,
status: 'ready',
currentChunk: 0,
totalChunks: totalChunks,
speed: '',
uploadedChunks: new Set(),
canceled: false
}
uploadingFiles.value.push(uploadingFile)
// 如果是自动上传,立即开始
if (props.autoUpload) {
startUpload()
}
}
// 删除文件(从 el-upload 触发)
const handleRemove: UploadProps['onRemove'] = (uploadFile) => {
removeUploadingFile({ uid: uploadFile.uid } as UploadingFile)
}
// 删除上传中的文件
const removeUploadingFile = (uploadingFile: UploadingFile) => {
// 从 uploadingFiles 中删除
const uploadingIndex = uploadingFiles.value.findIndex((item) => item.uid === uploadingFile.uid)
if (uploadingIndex > -1) {
uploadingFiles.value.splice(uploadingIndex, 1)
}
// 从 fileList 中删除
const fileIndex = fileList.value.findIndex((item) => item.uid === uploadingFile.uid)
if (fileIndex > -1) {
fileList.value.splice(fileIndex, 1)
updateModelValue()
}
// 如果所有文件都被删除,清空 el-upload 的内部状态
if (uploadingFiles.value.length === 0 && fileList.value.length === 0) {
uploadRef.value?.clearFiles()
}
}
// 超出限制提示
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件`)
}
// 计算文件 MD5 哈希
const calculateFileHash = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const chunkSize = 2 * 1024 * 1024 // 2MB per chunk for hash calculation
const chunks = Math.ceil(file.size / chunkSize)
let currentChunk = 0
const spark = new SparkMD5.ArrayBuffer()
const fileReader = new FileReader()
fileReader.onload = (e) => {
spark.append(e.target?.result as ArrayBuffer)
currentChunk++
if (currentChunk < chunks) {
loadNext()
} else {
resolve(spark.end())
}
}
fileReader.onerror = () => {
reject(new Error('文件读取失败'))
}
const loadNext = () => {
const start = currentChunk * chunkSize
const end = Math.min(start + chunkSize, file.size)
fileReader.readAsArrayBuffer(file.slice(start, end))
}
loadNext()
})
}
// 上传单个文件
const uploadFile = async (uploadingFile: UploadingFile) => {
try {
uploadingFile.status = 'uploading'
uploadingFile.canceled = false
// 计算文件哈希
const hash = await calculateFileHash(uploadingFile.file)
uploadingFile.hash = hash
const chunkSizeBytes = props.chunkSize * 1024 * 1024
const totalChunks = uploadingFile.totalChunks
const startTime = Date.now()
let result: any = {}
// 上传所有分片
for (let i = 0; i < totalChunks; i++) {
if (uploadingFile.canceled) {
throw new Error('上传已取消')
}
const start = i * chunkSizeBytes
const end = Math.min(start + chunkSizeBytes, uploadingFile.size)
const chunk = uploadingFile.file.slice(start, end)
// 创建 FormData
const formData = new FormData()
formData.append('file', chunk)
formData.append('ext', ext.value)
formData.append('size', uploadingFile.size.toString())
formData.append('type', uploadingFile.file.type)
formData.append('hash', hash)
formData.append('index', i.toString())
formData.append('total', totalChunks.toString())
formData.append('name', uploadingFile.name)
// 上传分片
result = await chunkUpload(formData)
// 检查后端是否返回了 URL秒传文件已存在
if (i == 0 && result?.url) {
// 文件已存在,直接使用返回的 URL跳过后续分片上传
uploadingFile.progress = 100
uploadingFile.currentChunk = totalChunks
uploadingFile.speed = '秒传'
emit('progress', 100)
ElMessage.success(`${uploadingFile.name} 秒传成功!`)
break
}
// 更新进度
uploadingFile.currentChunk = i + 1
uploadingFile.uploadedChunks.add(i)
uploadingFile.progress = Math.floor(((i + 1) / totalChunks) * 100)
// 计算上传速度
const elapsed = (Date.now() - startTime) / 1000
const uploaded = (i + 1) * chunkSizeBytes
const speed = uploaded / elapsed
uploadingFile.speed = formatSpeed(speed)
// 触发进度事件
emit('progress', uploadingFile.progress)
}
// 上传完成
uploadingFile.status = 'success'
uploadingFile.progress = 100
// 获取文件 URL支持秒传和正常上传两种情况
const fileUrl = result?.url || ''
if (!fileUrl) {
throw new Error('上传完成但未返回文件地址')
}
// 更新文件列表
const newFile: UploadUserFile = {
name: uploadingFile.name,
url: fileUrl,
uid: uploadingFile.uid
}
fileList.value.push(newFile)
updateModelValue()
emit('success', { url: fileUrl, hash })
// 如果不是秒传,显示普通上传成功消息
if (uploadingFile.speed !== '秒传') {
ElMessage.success(`${uploadingFile.name} 上传成功!`)
}
} catch (error: any) {
console.error('上传失败:', error)
uploadingFile.status = 'exception'
emit('error', error)
ElMessage.error(`${uploadingFile.name} 上传失败: ${error.message}`)
}
}
// 开始上传
const startUpload = async () => {
if (pendingFiles.value.length === 0) {
ElMessage.warning('没有待上传的文件')
return
}
uploading.value = true
try {
// 串行上传所有文件
for (const file of pendingFiles.value) {
if (!file.canceled) {
await uploadFile(file)
}
}
} finally {
uploading.value = false
}
}
// 重试上传
const retryUpload = async (uploadingFile: UploadingFile) => {
uploadingFile.status = 'ready'
uploadingFile.progress = 0
uploadingFile.currentChunk = 0
uploadingFile.uploadedChunks.clear()
await uploadFile(uploadingFile)
}
// 取消上传
const cancelUpload = (uploadingFile: UploadingFile) => {
uploadingFile.canceled = true
uploadingFile.status = 'exception'
ElMessage.info(`已取消上传: ${uploadingFile.name}`)
}
// 清空待上传列表
const clearPending = () => {
uploadingFiles.value = uploadingFiles.value.filter((f) => f.status !== 'ready')
fileList.value = []
}
// 更新 v-model 值
const updateModelValue = () => {
const urls = fileList.value.map((file) => file.url).filter((url) => url) as string[]
if (props.multiple) {
emit('update:modelValue', urls)
} else {
emit('update:modelValue', urls[0] || '')
}
}
// 格式化文件大小
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
}
// 格式化速度
const formatSpeed = (bytesPerSecond: number): string => {
return formatFileSize(bytesPerSecond) + '/s'
}
</script>
<style scoped lang="scss">
.sa-chunk-upload {
width: 100%;
.upload-container {
:deep(.el-upload) {
width: 100%;
}
:deep(.el-upload-dragger) {
width: 100%;
padding: 20px 10px;
}
:deep(.el-upload-list) {
display: none; // 隐藏默认的文件列表,使用自定义进度显示
}
}
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.upload-icon {
font-size: 36px;
color: #c0c4cc;
margin-bottom: 16px;
}
.upload-text {
font-size: 14px;
color: #606266;
margin-bottom: 8px;
em {
color: var(--el-color-primary);
font-style: normal;
}
}
.upload-hint {
font-size: 12px;
color: #909399;
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
line-height: 1.5;
}
.upload-progress-list {
margin-top: 20px;
.upload-progress-item {
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 10px;
.file-info {
display: flex;
align-items: center;
margin-bottom: 10px;
.file-icon {
font-size: 20px;
color: #409eff;
margin-right: 8px;
}
.file-name {
flex: 1;
font-size: 14px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
font-size: 12px;
color: #909399;
margin-left: 10px;
}
}
.progress-bar {
margin-bottom: 8px;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
font-size: 12px;
color: #606266;
}
.action-buttons {
display: flex;
gap: 5px;
}
}
}
}
.upload-actions {
margin-top: 15px;
display: flex;
gap: 10px;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<pre class="code-pre"><code class="hljs" v-html="highlightedCode"></code></pre>
</template>
<script type="ts" setup>
import { ref, watch, onMounted } from 'vue'
import hljs from 'highlight.js/lib/core' // 核心
import 'highlight.js/styles/github-dark.css' // 主题示例(可换成其他,如 atom-one-light.css、vscode-dark.css 等)
const props = defineProps({
code: {
type: String,
required: true
},
language: {
type: String,
default: 'javascript' // 默认语言,可传入 'vue' 或 'php'
}
})
const highlightedCode = ref('')
const doHighlight = () => {
if (!props.code) {
highlightedCode.value = ''
return
}
try {
// ignoreIllegals 防止非法语法报错
highlightedCode.value = hljs.highlight(props.code, {
language: props.language,
ignoreIllegals: true
}).value
} catch (__) {
console.error('代码高亮失败', __)
highlightedCode.value = props.code // 降级:语法不支持时纯文本显示
}
}
watch(() => [props.code, props.language], doHighlight)
onMounted(doHighlight)
</script>
<style scoped>
.code-pre {
border-radius: 8px;
overflow-x: auto;
font-size: 14px;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,113 @@
<!-- 字典组件 -->
<template>
<div class="sa-dict-wrapper">
<template v-if="render === 'tag'">
<ElTag
v-for="(item, index) in normalizedValues"
:key="index"
:size="size"
:style="{
backgroundColor: getColor(getData(item)?.color, 'bg'),
borderColor: getColor(getData(item)?.color, 'border'),
color: getColor(getData(item)?.color, 'text')
}"
:round="round"
class="mr-1 last:mr-0"
>
{{ getData(item)?.label || item }}
</ElTag>
</template>
<template v-else>
<span v-for="(item, index) in normalizedValues" :key="index">
{{ getData(item)?.label || item }}{{ index < normalizedValues.length - 1 ? '' : '' }}
</span>
</template>
</div>
</template>
<script setup lang="ts">
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaDict' })
interface Props {
/** 字典类型 */
dict: string
/** 字典值(支持字符串或数组) */
value: string | string[] | number | number[]
/** 渲染方式 */
render?: string
/** 标签大小 */
size?: 'large' | 'default' | 'small'
/** 是否圆角 */
round?: boolean
}
const props = withDefaults(defineProps<Props>(), {
render: 'tag',
size: 'default',
round: false
})
const dictStore = useDictStore()
// 统一处理 value转换为数组格式
const normalizedValues = computed(() => {
if (Array.isArray(props.value)) {
return props.value.map((v) => String(v))
}
return props.value !== undefined && props.value !== null && props.value !== ''
? [String(props.value)]
: []
})
// 根据值获取字典数据
const getData = (value: string) => dictStore.getDataByValue(props.dict, value)
const getColor = (color: string | undefined, type: 'bg' | 'border' | 'text') => {
// 如果没有指定颜色,使用默认主色调
if (!color) {
const colors = {
bg: 'var(--el-color-primary-light-9)',
border: 'var(--el-color-primary-light-8)',
text: 'var(--el-color-primary)'
}
return colors[type]
}
// 如果是 hex 颜色,转换为 RGB
let r, g, b
if (color.startsWith('#')) {
const hex = color.slice(1)
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
} else if (color.startsWith('rgb')) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (match) {
r = parseInt(match[1])
g = parseInt(match[2])
b = parseInt(match[3])
} else {
return color
}
} else {
return color
}
// 根据类型返回不同的颜色变体
switch (type) {
case 'bg':
// 背景色 - 更浅的版本
return `rgba(${r}, ${g}, ${b}, 0.1)`
case 'border':
// 边框色 - 中等亮度
return `rgba(${r}, ${g}, ${b}, 0.3)`
case 'text':
// 文字色 - 原始颜色
return `rgb(${r}, ${g}, ${b})`
default:
return color
}
}
</script>

View File

@@ -0,0 +1,297 @@
<!-- WangEditor 富文本编辑器 插件地址https://www.wangeditor.com/ -->
<template>
<div class="editor-wrapper">
<div class="editor-toolbar-wrapper">
<Toolbar
class="editor-toolbar"
:editor="editorRef"
:mode="mode"
:defaultConfig="toolbarConfig"
/>
<!-- 自定义图库选择按钮 -->
<el-tooltip content="从图库选择" placement="top">
<el-button class="gallery-btn" :icon="FolderOpened" @click="openImageDialog" />
</el-tooltip>
</div>
<Editor
:style="{ height: height, overflowY: 'hidden' }"
v-model="modelValue"
:mode="mode"
:defaultConfig="editorConfig"
@onCreated="onCreateEditor"
/>
<!-- 图片选择弹窗 -->
<SaImageDialog
v-model:visible="imageDialogVisible"
:multiple="true"
:limit="10"
@confirm="onImageSelect"
/>
</div>
</template>
<script setup lang="ts">
import '@wangeditor/editor/dist/css/style.css'
import { onBeforeUnmount, onMounted, shallowRef, computed, ref } from 'vue'
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import EmojiText from '@/utils/ui/emojo'
import { IDomEditor, IToolbarConfig, IEditorConfig } from '@wangeditor/editor'
import { uploadImage } from '@/api/auth'
import { FolderOpened } from '@element-plus/icons-vue'
import SaImageDialog from '@/components/sai/sa-image-dialog/index.vue'
defineOptions({ name: 'SaEditor' })
// 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 imageDialogVisible = ref(false)
// 常量配置
const DEFAULT_UPLOAD_CONFIG = {
maxFileSize: 3 * 1024 * 1024, // 3MB
maxNumberOfFiles: 10,
fieldName: 'file',
allowedFileTypes: ['image/*']
} as const
// 合并上传配置
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: {
// 自定义上传
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
try {
// 验证文件大小
if (file.size > mergedUploadConfig.value.maxFileSize) {
const maxSizeMB = (mergedUploadConfig.value.maxFileSize / 1024 / 1024).toFixed(1)
ElMessage.error(`图片大小不能超过 ${maxSizeMB}MB`)
return
}
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
// 调用上传接口
const response: any = await uploadImage(formData)
// 获取图片 URL
const imageUrl = response?.data?.url || response?.url || ''
if (!imageUrl) {
throw new Error('上传失败,未返回图片地址')
}
// 插入图片到编辑器
insertFn(imageUrl, file.name, imageUrl)
ElMessage.success(`图片上传成功 ${EmojiText[200]}`)
} catch (error: any) {
console.error('图片上传失败:', error)
ElMessage.error(`图片上传失败: ${error.message || EmojiText[500]}`)
}
},
// 其他配置
maxFileSize: mergedUploadConfig.value.maxFileSize,
maxNumberOfFiles: mergedUploadConfig.value.maxNumberOfFiles,
allowedFileTypes: mergedUploadConfig.value.allowedFileTypes
}
}
}
// 打开图片选择弹窗
const openImageDialog = () => {
imageDialogVisible.value = true
}
// 图片选择回调
const onImageSelect = (urls: string | string[]) => {
const editor = editorRef.value
if (!editor) return
const urlList = Array.isArray(urls) ? urls : [urls]
urlList.forEach((url) => {
editor.insertNode({
type: 'image',
src: url,
alt: '',
href: '',
style: {},
children: [{ text: '' }]
} as any)
})
}
// 编辑器创建回调
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(),
/** 打开图库选择 */
openImageDialog
})
// 生命周期
onMounted(() => {
// 图标替换已在 onCreateEditor 中处理
})
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) {
editor.destroy()
}
})
</script>
<style lang="scss">
@use './style';
.editor-toolbar-wrapper {
display: flex;
align-items: center;
border-bottom: 1px solid var(--el-border-color-light);
.editor-toolbar {
flex: 1;
border-bottom: none !important;
}
.gallery-btn {
margin: 0 8px;
padding: 8px;
height: 32px;
width: 32px;
}
}
</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;
}
}
}

View File

@@ -0,0 +1,127 @@
<template>
<div class="sa-export-wrap" @click="handleExport">
<slot>
<ElButton :icon="Download" :loading="loading">
{{ label }}
</ElButton>
</slot>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Download } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import axios from 'axios'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'SaExport' })
const props = withDefaults(
defineProps<{
url: string
params?: Record<string, any>
fileName?: string
method?: string
label?: string
}>(),
{
method: 'post',
label: '导出',
fileName: '导出数据.xlsx'
}
)
const emit = defineEmits<{
success: []
error: [error: any]
}>()
const loading = ref(false)
const handleExport = async () => {
if (loading.value) return
if (!props.url) {
ElMessage.error('未配置导出接口')
return
}
let finalFileName = props.fileName
try {
const { value } = await ElMessageBox.prompt('请输入导出文件名称', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputValue: props.fileName,
inputValidator: (val) => !!val.trim() || '文件名不能为空'
})
finalFileName = value
} catch {
// User cancelled
return
}
try {
loading.value = true
const { VITE_API_URL } = import.meta.env
const { accessToken } = useUserStore()
axios.defaults.baseURL = VITE_API_URL
const config = {
method: props.method,
url: props.url,
data: props.method.toLowerCase() === 'post' ? props.params : undefined,
params: props.method.toLowerCase() === 'get' ? props.params : undefined,
responseType: 'blob' as const,
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined
}
}
const res = await axios(config)
// Check if response is json (error case)
if (res.data.type === 'application/json') {
const reader = new FileReader()
reader.onload = () => {
try {
const result = JSON.parse(reader.result as string)
ElMessage.error(result.msg || '导出失败')
emit('error', result)
} catch (e) {
ElMessage.error('导出失败')
emit('error', e)
}
}
reader.readAsText(res.data)
return
}
const blob = new Blob([res.data], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = finalFileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
emit('success')
} catch (error: any) {
console.error(error)
ElMessage.error(error.message || '导出失败')
emit('error', error)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.sa-export-wrap {
display: inline-block;
}
</style>

View File

@@ -0,0 +1,119 @@
<!--
sa-file-upload 组件使用示例
基本用法:
<sa-file-upload v-model="fileUrl" />
多文件上传:
<sa-file-upload v-model="fileUrls" :multiple="true" :limit="5" />
拖拽上传:
<sa-file-upload v-model="fileUrl" :drag="true" />
限制文件类型:
<sa-file-upload
v-model="fileUrl"
accept=".pdf,.doc,.docx"
accept-hint="PDF、Word"
/>
-->
<template>
<div class="demo-container">
<h2>文件上传组件示例</h2>
<!-- 示例1: 基本用法 -->
<el-card header="基本用法" class="demo-card">
<sa-file-upload v-model="singleFile" />
<div class="result">当前文件: {{ singleFile }}</div>
</el-card>
<!-- 示例2: 多文件上传 -->
<el-card header="多文件上传" class="demo-card">
<sa-file-upload
v-model="multipleFiles"
:multiple="true"
:limit="5"
/>
<div class="result">当前文件: {{ multipleFiles }}</div>
</el-card>
<!-- 示例3: 拖拽上传 -->
<el-card header="拖拽上传" class="demo-card">
<sa-file-upload
v-model="dragFile"
:drag="true"
button-text="点击或拖拽上传"
/>
<div class="result">当前文件: {{ dragFile }}</div>
</el-card>
<!-- 示例4: 限制文件类型 - PDF/Word -->
<el-card header="限制文件类型 (PDF、Word)" class="demo-card">
<sa-file-upload
v-model="docFile"
accept=".pdf,.doc,.docx"
accept-hint="PDF、Word"
:max-size="20"
/>
<div class="result">当前文件: {{ docFile }}</div>
</el-card>
<!-- 示例5: 限制文件类型 - Excel -->
<el-card header="限制文件类型 (Excel)" class="demo-card">
<sa-file-upload
v-model="excelFile"
accept=".xls,.xlsx"
accept-hint="Excel"
/>
<div class="result">当前文件: {{ excelFile }}</div>
</el-card>
<!-- 示例6: 压缩文件 -->
<el-card header="压缩文件上传" class="demo-card">
<sa-file-upload
v-model="zipFile"
accept=".zip,.rar,.7z"
accept-hint="ZIP、RAR、7Z"
:max-size="50"
:drag="true"
/>
<div class="result">当前文件: {{ zipFile }}</div>
</el-card>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
const singleFile = ref('')
const multipleFiles = ref<string[]>([])
const dragFile = ref('')
const docFile = ref('')
const excelFile = ref('')
const zipFile = ref('')
</script>
<style scoped lang="scss">
.demo-container {
padding: 20px;
h2 {
margin-bottom: 20px;
}
.demo-card {
margin-bottom: 20px;
.result {
margin-top: 15px;
padding: 10px;
background-color: #f5f7fa;
border-radius: 4px;
font-size: 12px;
color: #606266;
word-break: break-all;
}
}
}
</style>

View File

@@ -0,0 +1,269 @@
<template>
<div class="sa-file-upload">
<el-upload
ref="uploadRef"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:http-request="handleUpload"
:before-upload="beforeUpload"
:on-remove="handleRemove"
:on-preview="handlePreview"
:on-exceed="handleExceed"
:disabled="disabled"
:drag="drag"
class="upload-container"
>
<template #default>
<div v-if="drag" class="upload-dragger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div class="upload-text">将文件拖到此处<em>点击上传</em></div>
</div>
<el-button v-else type="primary" :icon="Upload">
{{ buttonText }}
</el-button>
</template>
<template #tip>
<div class="el-upload__tip">
<span v-if="acceptHint">支持 {{ acceptHint }} 格式</span>
单个文件不超过 {{ maxSize }}MB最多上传 {{ limit }} 个文件
</div>
</template>
</el-upload>
</div>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Upload, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile, UploadRequestOptions } from 'element-plus'
import { uploadFile } from '@/api/auth'
defineOptions({ name: 'SaFileUpload' })
// 定义 Props
interface Props {
modelValue?: string | string[] // v-model 绑定值
multiple?: boolean // 是否支持多选
limit?: number // 最大上传数量
maxSize?: number // 最大文件大小(MB)
accept?: string // 接受的文件类型
acceptHint?: string // 接受文件类型的提示文本
disabled?: boolean // 是否禁用
drag?: boolean // 是否启用拖拽上传
buttonText?: string // 按钮文本
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
limit: 1,
maxSize: 10,
accept: '*',
acceptHint: '',
disabled: false,
drag: false,
buttonText: '选择文件'
})
// 定义 Emits
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
success: [response: any]
error: [error: any]
}>()
// 状态
const uploadRef = ref()
const fileList = ref<UploadUserFile[]>([])
// 监听 modelValue 变化,同步到 fileList
watch(
() => props.modelValue,
(newVal) => {
if (!newVal || (Array.isArray(newVal) && newVal.length === 0)) {
fileList.value = []
uploadRef.value?.clearFiles()
return
}
const urls = Array.isArray(newVal) ? newVal : [newVal]
fileList.value = urls
.filter((url) => url)
.map((url, index) => {
// 从 URL 中提取文件名
const fileName = url.split('/').pop() || `file-${index + 1}`
return {
name: fileName,
url: url,
uid: Date.now() + index
}
})
},
{ immediate: true }
)
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 验证文件类型(如果指定了 accept
if (props.accept && props.accept !== '*') {
const acceptTypes = props.accept.split(',').map((type) => type.trim())
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase()
const fileMimeType = file.type
const isAccepted = acceptTypes.some((type) => {
if (type.startsWith('.')) {
return fileExtension === type.toLowerCase()
}
if (type.includes('/*')) {
const mainType = type.split('/')[0]
return fileMimeType.startsWith(mainType)
}
return fileMimeType === type
})
if (!isAccepted) {
ElMessage.error(
`不支持的文件类型!${props.acceptHint ? '请上传 ' + props.acceptHint + ' 格式的文件' : ''}`
)
return false
}
}
// 验证文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`文件大小不能超过 ${props.maxSize}MB!`)
return false
}
return true
}
// 自定义上传
const handleUpload = async (options: UploadRequestOptions) => {
const { file, onSuccess, onError } = options
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
// 调用上传接口
const response: any = await uploadFile(formData)
// 尝试从不同的响应格式中获取文件URL
const fileUrl = response?.data?.url || response?.data || response?.url || ''
if (!fileUrl) {
throw new Error('上传失败,未返回文件地址')
}
// 更新文件列表
const newFile: UploadUserFile = {
name: file.name,
url: fileUrl,
uid: file.uid
}
fileList.value.push(newFile)
updateModelValue()
// 触发成功回调
onSuccess?.(response)
emit('success', response)
ElMessage.success('上传成功!')
} catch (error: any) {
console.error('上传失败:', error)
onError?.(error)
emit('error', error)
ElMessage.error(error.message || '上传失败!')
}
}
// 删除文件
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
updateModelValue()
}
}
// 超出限制提示
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 个文件,请先删除已有文件后再上传`)
}
// 预览文件
const handlePreview: UploadProps['onPreview'] = (file) => {
if (file.url) {
// 在新窗口打开文件
window.open(file.url, '_blank')
}
}
// 更新 v-model 值
const updateModelValue = () => {
const urls = fileList.value.map((file) => file.url).filter((url) => url) as string[]
if (props.multiple) {
emit('update:modelValue', urls)
} else {
emit('update:modelValue', urls[0] || '')
}
}
</script>
<style scoped lang="scss">
.sa-file-upload {
width: 100%;
.upload-container {
:deep(.el-upload) {
width: 250px;
justify-content: start;
}
:deep(.el-upload-dragger) {
width: 250px;
padding: 20px 10px;
}
:deep(.el-upload-list) {
margin-top: 10px;
}
}
.upload-dragger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.upload-icon {
font-size: 48px;
color: #c0c4cc;
margin-bottom: 16px;
}
.upload-text {
font-size: 14px;
color: #606266;
em {
color: var(--el-color-primary);
font-style: normal;
}
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<el-popover placement="bottom-start" :width="460" trigger="click" v-model:visible="visible">
<template #reference>
<div
class="w-full relative cursor-pointer"
@mouseenter="hovering = true"
@mouseleave="hovering = false"
>
<el-input v-bind="$attrs" v-model="modelValue" readonly placeholder="点击选择图标">
<template #prepend>
<div class="w-8 flex items-center justify-center">
<Icon v-if="modelValue" :icon="modelValue" class="text-lg" />
<el-icon v-else class="text-lg text-gray-400"><Search /></el-icon>
</div>
</template>
</el-input>
<div
v-if="hovering && modelValue && !disabled"
class="absolute right-2 top-0 h-full flex items-center cursor-pointer text-gray-400 hover:text-gray-600"
@click.stop="handleClear"
>
<el-icon><CircleClose /></el-icon>
</div>
</div>
</template>
<div class="icon-picker">
<div class="mb-3">
<el-input
v-model="searchText"
placeholder="搜索图标(英文关键词)"
clearable
prefix-icon="Search"
@input="handleSearch"
/>
</div>
<div class="h-[300px] overflow-y-auto custom-scrollbar">
<div v-if="searchText" class="search-results">
<div v-if="filteredIcons.length === 0" class="text-center text-gray-400 py-8">
未找到相关图标
</div>
<div v-else class="grid grid-cols-6 gap-2">
<div
v-for="icon in filteredIcons"
:key="icon.name"
class="icon-item flex flex-col items-center justify-center p-2 rounded cursor-pointer hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary': modelValue === icon.name }"
@click="handleSelect(icon.name)"
:title="icon.name"
>
<Icon :icon="icon.name" class="text-2xl mb-1" />
</div>
</div>
</div>
<div v-else>
<el-collapse v-model="activeNames">
<el-collapse-item
v-for="category in categories"
:key="category.name"
:title="category.name"
:name="category.name"
>
<div class="grid grid-cols-6 gap-2">
<div
v-for="icon in category.icons"
:key="icon.name"
class="icon-item flex flex-col items-center justify-center p-2 rounded cursor-pointer hover:bg-gray-100 transition-colors"
:class="{ 'bg-primary-50 text-primary': modelValue === icon.name }"
@click="handleSelect(icon.name)"
:title="icon.name"
>
<Icon :icon="icon.name" class="text-2xl mb-1" />
</div>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
</div>
</el-popover>
</template>
<script lang="ts" setup>
import { Search, CircleClose } from '@element-plus/icons-vue'
import { Icon } from '@iconify/vue'
import rawIcons from './lib/RemixIcon.json'
defineOptions({ name: 'SaIconPicker', inheritAttrs: false })
interface Props {
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
disabled: false
})
const modelValue = defineModel<string>()
const visible = ref(false)
const searchText = ref('')
const hovering = ref(false)
const activeNames = ref(['Arrows']) // 默认展开第一个分类
// 处理图标数据
interface IconItem {
name: string
tags: string
}
interface Category {
name: string
icons: IconItem[]
}
// 计算属性缓存处理后的图标数据
const { allIcons, categories } = useMemo(() => {
const all: IconItem[] = []
const cats: Category[] = []
for (const [categoryName, icons] of Object.entries(rawIcons)) {
const iconList = icons as string[]
const categoryIcons: IconItem[] = []
for (const name of iconList) {
const iconName = `ri:${name}`
const item = { name: iconName, tags: name }
categoryIcons.push(item)
all.push(item)
}
cats.push({
name: categoryName,
icons: categoryIcons
})
}
return { allIcons: all, categories: cats }
})
// 简单的 hook 模拟 useMemo在 vue 中直接执行即可,因为 rawIcons 是静态的
function useMemo<T>(fn: () => T): T {
return fn()
}
// 搜索过滤
const filteredIcons = computed(() => {
if (!searchText.value) return []
const query = searchText.value.toLowerCase()
return allIcons
.filter(
(icon) => icon.name.toLowerCase().includes(query) || icon.tags.toLowerCase().includes(query)
)
.slice(0, 100) // 限制显示数量以提高性能
})
const handleSearch = () => {
// 搜索逻辑主要由 computed 处理
}
const handleSelect = (icon: string) => {
modelValue.value = icon
visible.value = false
searchText.value = ''
}
const handleClear = () => {
modelValue.value = ''
}
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: #e5e7eb;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background-color: transparent;
}
</style>

View File

@@ -0,0 +1,3175 @@
{
"Arrows": [
"arrow-down-box-fill",
"arrow-down-box-line",
"arrow-down-circle-fill",
"arrow-down-circle-line",
"arrow-down-double-fill",
"arrow-down-double-line",
"arrow-down-fill",
"arrow-down-line",
"arrow-down-long-fill",
"arrow-down-long-line",
"arrow-down-s-fill",
"arrow-down-s-line",
"arrow-down-wide-fill",
"arrow-down-wide-line",
"arrow-drop-down-fill",
"arrow-drop-down-line",
"arrow-drop-left-fill",
"arrow-drop-left-line",
"arrow-drop-right-fill",
"arrow-drop-right-line",
"arrow-drop-up-fill",
"arrow-drop-up-line",
"arrow-go-back-fill",
"arrow-go-back-line",
"arrow-go-forward-fill",
"arrow-go-forward-line",
"arrow-left-box-fill",
"arrow-left-box-line",
"arrow-left-circle-fill",
"arrow-left-circle-line",
"arrow-left-double-fill",
"arrow-left-double-line",
"arrow-left-down-box-fill",
"arrow-left-down-box-line",
"arrow-left-down-fill",
"arrow-left-down-line",
"arrow-left-down-long-fill",
"arrow-left-down-long-line",
"arrow-left-fill",
"arrow-left-line",
"arrow-left-long-fill",
"arrow-left-long-line",
"arrow-left-right-fill",
"arrow-left-right-line",
"arrow-left-s-fill",
"arrow-left-s-line",
"arrow-left-up-box-fill",
"arrow-left-up-box-line",
"arrow-left-up-fill",
"arrow-left-up-line",
"arrow-left-up-long-fill",
"arrow-left-up-long-line",
"arrow-left-wide-fill",
"arrow-left-wide-line",
"arrow-right-box-fill",
"arrow-right-box-line",
"arrow-right-circle-fill",
"arrow-right-circle-line",
"arrow-right-double-fill",
"arrow-right-double-line",
"arrow-right-down-box-fill",
"arrow-right-down-box-line",
"arrow-right-down-fill",
"arrow-right-down-line",
"arrow-right-down-long-fill",
"arrow-right-down-long-line",
"arrow-right-fill",
"arrow-right-line",
"arrow-right-long-fill",
"arrow-right-long-line",
"arrow-right-s-fill",
"arrow-right-s-line",
"arrow-right-up-box-fill",
"arrow-right-up-box-line",
"arrow-right-up-fill",
"arrow-right-up-line",
"arrow-right-up-long-fill",
"arrow-right-up-long-line",
"arrow-right-wide-fill",
"arrow-right-wide-line",
"arrow-turn-back-fill",
"arrow-turn-back-line",
"arrow-turn-forward-fill",
"arrow-turn-forward-line",
"arrow-up-box-fill",
"arrow-up-box-line",
"arrow-up-circle-fill",
"arrow-up-circle-line",
"arrow-up-double-fill",
"arrow-up-double-line",
"arrow-up-down-fill",
"arrow-up-down-line",
"arrow-up-fill",
"arrow-up-line",
"arrow-up-long-fill",
"arrow-up-long-line",
"arrow-up-s-fill",
"arrow-up-s-line",
"arrow-up-wide-fill",
"arrow-up-wide-line",
"collapse-diagonal-2-fill",
"collapse-diagonal-2-line",
"collapse-diagonal-fill",
"collapse-diagonal-line",
"collapse-horizontal-fill",
"collapse-horizontal-line",
"collapse-vertical-fill",
"collapse-vertical-line",
"contract-left-fill",
"contract-left-line",
"contract-left-right-fill",
"contract-left-right-line",
"contract-right-fill",
"contract-right-line",
"contract-up-down-fill",
"contract-up-down-line",
"corner-down-left-fill",
"corner-down-left-line",
"corner-down-right-fill",
"corner-down-right-line",
"corner-left-down-fill",
"corner-left-down-line",
"corner-left-up-fill",
"corner-left-up-line",
"corner-right-down-fill",
"corner-right-down-line",
"corner-right-up-fill",
"corner-right-up-line",
"corner-up-left-double-fill",
"corner-up-left-double-line",
"corner-up-left-fill",
"corner-up-left-line",
"corner-up-right-double-fill",
"corner-up-right-double-line",
"corner-up-right-fill",
"corner-up-right-line",
"drag-move-2-fill",
"drag-move-2-line",
"drag-move-fill",
"drag-move-line",
"expand-diagonal-2-fill",
"expand-diagonal-2-line",
"expand-diagonal-fill",
"expand-diagonal-line",
"expand-diagonal-s-2-fill",
"expand-diagonal-s-2-line",
"expand-diagonal-s-fill",
"expand-diagonal-s-line",
"expand-height-fill",
"expand-height-line",
"expand-horizontal-fill",
"expand-horizontal-line",
"expand-horizontal-s-fill",
"expand-horizontal-s-line",
"expand-left-fill",
"expand-left-line",
"expand-left-right-fill",
"expand-left-right-line",
"expand-right-fill",
"expand-right-line",
"expand-up-down-fill",
"expand-up-down-line",
"expand-vertical-fill",
"expand-vertical-line",
"expand-vertical-s-fill",
"expand-vertical-s-line",
"expand-width-fill",
"expand-width-line",
"scroll-to-bottom-fill",
"scroll-to-bottom-line",
"skip-down-fill",
"skip-down-line",
"skip-left-fill",
"skip-left-line",
"skip-right-fill",
"skip-right-line",
"skip-up-fill",
"skip-up-line"
],
"Buildings": [
"ancient-gate-fill",
"ancient-gate-line",
"ancient-pavilion-fill",
"ancient-pavilion-line",
"bank-fill",
"bank-line",
"building-2-fill",
"building-2-line",
"building-3-fill",
"building-3-line",
"building-4-fill",
"building-4-line",
"building-fill",
"building-line",
"community-fill",
"community-line",
"government-fill",
"government-line",
"home-2-fill",
"home-2-line",
"home-3-fill",
"home-3-line",
"home-4-fill",
"home-4-line",
"home-5-fill",
"home-5-line",
"home-6-fill",
"home-6-line",
"home-7-fill",
"home-7-line",
"home-8-fill",
"home-8-line",
"home-9-fill",
"home-9-line",
"home-fill",
"home-gear-fill",
"home-gear-line",
"home-heart-fill",
"home-heart-line",
"home-line",
"home-office-fill",
"home-office-line",
"home-smile-2-fill",
"home-smile-2-line",
"home-smile-fill",
"home-smile-line",
"home-wifi-fill",
"home-wifi-line",
"hospital-fill",
"hospital-line",
"hotel-fill",
"hotel-line",
"school-fill",
"school-line",
"store-2-fill",
"store-2-line",
"store-3-fill",
"store-3-line",
"store-fill",
"store-line",
"tent-fill",
"tent-line"
],
"Business": [
"advertisement-fill",
"advertisement-line",
"archive-2-fill",
"archive-2-line",
"archive-drawer-fill",
"archive-drawer-line",
"archive-fill",
"archive-line",
"archive-stack-fill",
"archive-stack-line",
"at-fill",
"at-line",
"attachment-fill",
"attachment-line",
"award-fill",
"award-line",
"bar-chart-2-fill",
"bar-chart-2-line",
"bar-chart-box-ai-fill",
"bar-chart-box-ai-line",
"bar-chart-box-fill",
"bar-chart-box-line",
"bar-chart-fill",
"bar-chart-grouped-fill",
"bar-chart-grouped-line",
"bar-chart-horizontal-fill",
"bar-chart-horizontal-line",
"bar-chart-line",
"bookmark-2-fill",
"bookmark-2-line",
"bookmark-3-fill",
"bookmark-3-line",
"bookmark-fill",
"bookmark-line",
"briefcase-2-fill",
"briefcase-2-line",
"briefcase-3-fill",
"briefcase-3-line",
"briefcase-4-fill",
"briefcase-4-line",
"briefcase-5-fill",
"briefcase-5-line",
"briefcase-fill",
"briefcase-line",
"bubble-chart-fill",
"bubble-chart-line",
"calculator-fill",
"calculator-line",
"calendar-2-fill",
"calendar-2-line",
"calendar-check-fill",
"calendar-check-line",
"calendar-close-fill",
"calendar-close-line",
"calendar-event-fill",
"calendar-event-line",
"calendar-fill",
"calendar-line",
"calendar-schedule-fill",
"calendar-schedule-line",
"calendar-todo-fill",
"calendar-todo-line",
"cloud-fill",
"cloud-line",
"cloud-off-fill",
"cloud-off-line",
"copyleft-fill",
"copyleft-line",
"copyright-fill",
"copyright-line",
"creative-commons-by-fill",
"creative-commons-by-line",
"creative-commons-fill",
"creative-commons-line",
"creative-commons-nc-fill",
"creative-commons-nc-line",
"creative-commons-nd-fill",
"creative-commons-nd-line",
"creative-commons-sa-fill",
"creative-commons-sa-line",
"creative-commons-zero-fill",
"creative-commons-zero-line",
"customer-service-2-fill",
"customer-service-2-line",
"customer-service-fill",
"customer-service-line",
"donut-chart-fill",
"donut-chart-line",
"flag-2-fill",
"flag-2-line",
"flag-fill",
"flag-line",
"flag-off-fill",
"flag-off-line",
"global-fill",
"global-line",
"honour-fill",
"honour-line",
"id-card-fill",
"id-card-line",
"inbox-2-fill",
"inbox-2-line",
"inbox-archive-fill",
"inbox-archive-line",
"inbox-fill",
"inbox-line",
"inbox-unarchive-fill",
"inbox-unarchive-line",
"info-card-fill",
"info-card-line",
"line-chart-fill",
"line-chart-line",
"links-fill",
"links-line",
"mail-add-fill",
"mail-add-line",
"mail-ai-fill",
"mail-ai-line",
"mail-check-fill",
"mail-check-line",
"mail-close-fill",
"mail-close-line",
"mail-download-fill",
"mail-download-line",
"mail-fill",
"mail-forbid-fill",
"mail-forbid-line",
"mail-line",
"mail-lock-fill",
"mail-lock-line",
"mail-open-fill",
"mail-open-line",
"mail-send-fill",
"mail-send-line",
"mail-settings-fill",
"mail-settings-line",
"mail-star-fill",
"mail-star-line",
"mail-unread-fill",
"mail-unread-line",
"mail-volume-fill",
"mail-volume-line",
"medal-2-fill",
"medal-2-line",
"medal-fill",
"medal-line",
"megaphone-fill",
"megaphone-line",
"pass-expired-fill",
"pass-expired-line",
"pass-pending-fill",
"pass-pending-line",
"pass-valid-fill",
"pass-valid-line",
"pie-chart-2-fill",
"pie-chart-2-line",
"pie-chart-box-fill",
"pie-chart-box-line",
"pie-chart-fill",
"pie-chart-line",
"presentation-fill",
"presentation-line",
"printer-cloud-fill",
"printer-cloud-line",
"printer-fill",
"printer-line",
"profile-fill",
"profile-line",
"projector-2-fill",
"projector-2-line",
"projector-fill",
"projector-line",
"record-mail-fill",
"record-mail-line",
"registered-fill",
"registered-line",
"reply-all-fill",
"reply-all-line",
"reply-fill",
"reply-line",
"send-plane-2-fill",
"send-plane-2-line",
"send-plane-fill",
"send-plane-line",
"seo-fill",
"seo-line",
"service-fill",
"service-line",
"shake-hands-fill",
"shake-hands-line",
"slideshow-2-fill",
"slideshow-2-line",
"slideshow-3-fill",
"slideshow-3-line",
"slideshow-4-fill",
"slideshow-4-line",
"slideshow-fill",
"slideshow-line",
"stack-fill",
"stack-line",
"trademark-fill",
"trademark-line",
"triangular-flag-fill",
"triangular-flag-line",
"verified-badge-fill",
"verified-badge-line",
"window-2-fill",
"window-2-line",
"window-fill",
"window-line"
],
"Communication": [
"chat-1-fill",
"chat-1-line",
"chat-2-fill",
"chat-2-line",
"chat-3-fill",
"chat-3-line",
"chat-4-fill",
"chat-4-line",
"chat-ai-2-fill",
"chat-ai-2-line",
"chat-ai-3-fill",
"chat-ai-3-line",
"chat-ai-4-fill",
"chat-ai-4-line",
"chat-ai-fill",
"chat-ai-line",
"chat-check-fill",
"chat-check-line",
"chat-delete-fill",
"chat-delete-line",
"chat-download-fill",
"chat-download-line",
"chat-follow-up-fill",
"chat-follow-up-line",
"chat-forward-fill",
"chat-forward-line",
"chat-heart-fill",
"chat-heart-line",
"chat-history-fill",
"chat-history-line",
"chat-new-fill",
"chat-new-line",
"chat-off-fill",
"chat-off-line",
"chat-poll-fill",
"chat-poll-line",
"chat-private-fill",
"chat-private-line",
"chat-quote-fill",
"chat-quote-line",
"chat-search-fill",
"chat-search-line",
"chat-settings-fill",
"chat-settings-line",
"chat-smile-2-fill",
"chat-smile-2-line",
"chat-smile-3-fill",
"chat-smile-3-line",
"chat-smile-ai-3-fill",
"chat-smile-ai-3-line",
"chat-smile-ai-fill",
"chat-smile-ai-line",
"chat-smile-fill",
"chat-smile-line",
"chat-thread-fill",
"chat-thread-line",
"chat-unread-fill",
"chat-unread-line",
"chat-upload-fill",
"chat-upload-line",
"chat-voice-ai-fill",
"chat-voice-ai-line",
"chat-voice-fill",
"chat-voice-line",
"discuss-fill",
"discuss-line",
"emoji-sticker-fill",
"emoji-sticker-line",
"feedback-fill",
"feedback-line",
"message-2-fill",
"message-2-line",
"message-3-fill",
"message-3-line",
"message-ai-3-fill",
"message-ai-3-line",
"message-fill",
"message-line",
"question-answer-fill",
"question-answer-line",
"questionnaire-fill",
"questionnaire-line",
"speak-ai-fill",
"speak-ai-line",
"speak-fill",
"speak-line",
"speech-to-text-fill",
"speech-to-text-line",
"text-to-speech-fill",
"text-to-speech-line",
"video-chat-fill",
"video-chat-line"
],
"Design": [
"ai-generate-2-fill",
"ai-generate-2-line",
"align-item-bottom-fill",
"align-item-bottom-line",
"align-item-horizontal-center-fill",
"align-item-horizontal-center-line",
"align-item-left-fill",
"align-item-left-line",
"align-item-right-fill",
"align-item-right-line",
"align-item-top-fill",
"align-item-top-line",
"align-item-vertical-center-fill",
"align-item-vertical-center-line",
"anticlockwise-2-fill",
"anticlockwise-2-line",
"anticlockwise-fill",
"anticlockwise-line",
"artboard-2-fill",
"artboard-2-line",
"artboard-fill",
"artboard-line",
"ball-pen-fill",
"ball-pen-line",
"blur-off-fill",
"blur-off-line",
"brush-2-fill",
"brush-2-line",
"brush-3-fill",
"brush-3-line",
"brush-4-fill",
"brush-4-line",
"brush-ai-3-fill",
"brush-ai-3-line",
"brush-ai-fill",
"brush-ai-line",
"brush-fill",
"brush-line",
"circle-fill",
"circle-line",
"clockwise-2-fill",
"clockwise-2-line",
"clockwise-fill",
"clockwise-line",
"collage-fill",
"collage-line",
"color-filter-ai-fill",
"color-filter-ai-line",
"color-filter-fill",
"color-filter-line",
"compasses-2-fill",
"compasses-2-line",
"compasses-fill",
"compasses-line",
"contrast-2-fill",
"contrast-2-line",
"contrast-drop-2-fill",
"contrast-drop-2-line",
"contrast-drop-fill",
"contrast-drop-line",
"contrast-fill",
"contrast-line",
"crop-2-fill",
"crop-2-line",
"crop-fill",
"crop-line",
"crosshair-2-fill",
"crosshair-2-line",
"crosshair-fill",
"crosshair-line",
"drag-drop-fill",
"drag-drop-line",
"drop-fill",
"drop-line",
"edit-2-fill",
"edit-2-line",
"edit-box-fill",
"edit-box-line",
"edit-circle-fill",
"edit-circle-line",
"edit-fill",
"edit-line",
"eraser-fill",
"eraser-line",
"flip-horizontal-2-fill",
"flip-horizontal-2-line",
"flip-horizontal-fill",
"flip-horizontal-line",
"flip-vertical-2-fill",
"flip-vertical-2-line",
"flip-vertical-fill",
"flip-vertical-line",
"focus-2-fill",
"focus-2-line",
"focus-3-fill",
"focus-3-line",
"focus-fill",
"focus-line",
"grid-fill",
"grid-line",
"hammer-fill",
"hammer-line",
"hexagon-fill",
"hexagon-line",
"ink-bottle-fill",
"ink-bottle-line",
"input-method-fill",
"input-method-line",
"layout-2-fill",
"layout-2-line",
"layout-3-fill",
"layout-3-line",
"layout-4-fill",
"layout-4-line",
"layout-5-fill",
"layout-5-line",
"layout-6-fill",
"layout-6-line",
"layout-bottom-2-fill",
"layout-bottom-2-line",
"layout-bottom-fill",
"layout-bottom-line",
"layout-column-fill",
"layout-column-line",
"layout-fill",
"layout-grid-2-fill",
"layout-grid-2-line",
"layout-grid-fill",
"layout-grid-line",
"layout-horizontal-fill",
"layout-horizontal-line",
"layout-left-2-fill",
"layout-left-2-line",
"layout-left-fill",
"layout-left-line",
"layout-line",
"layout-masonry-fill",
"layout-masonry-line",
"layout-right-2-fill",
"layout-right-2-line",
"layout-right-fill",
"layout-right-line",
"layout-row-fill",
"layout-row-line",
"layout-top-2-fill",
"layout-top-2-line",
"layout-top-fill",
"layout-top-line",
"layout-vertical-fill",
"layout-vertical-line",
"magic-fill",
"magic-line",
"mark-pen-fill",
"mark-pen-line",
"markup-fill",
"markup-line",
"octagon-fill",
"octagon-line",
"paint-brush-fill",
"paint-brush-line",
"paint-fill",
"paint-line",
"painting-ai-fill",
"painting-ai-line",
"painting-fill",
"painting-line",
"palette-fill",
"palette-line",
"pantone-fill",
"pantone-line",
"pen-nib-fill",
"pen-nib-line",
"pencil-ai-2-fill",
"pencil-ai-2-line",
"pencil-ai-fill",
"pencil-ai-line",
"pencil-fill",
"pencil-line",
"pencil-ruler-2-fill",
"pencil-ruler-2-line",
"pencil-ruler-fill",
"pencil-ruler-line",
"pentagon-fill",
"pentagon-line",
"quill-pen-ai-fill",
"quill-pen-ai-line",
"quill-pen-fill",
"quill-pen-line",
"rectangle-fill",
"rectangle-line",
"remix-fill",
"remix-line",
"ruler-2-fill",
"ruler-2-line",
"ruler-fill",
"ruler-line",
"scissors-2-fill",
"scissors-2-line",
"scissors-cut-fill",
"scissors-cut-line",
"scissors-fill",
"scissors-line",
"screenshot-2-fill",
"screenshot-2-line",
"screenshot-fill",
"screenshot-line",
"shadow-fill",
"shadow-line",
"shape-2-fill",
"shape-2-line",
"shape-fill",
"shape-line",
"shapes-fill",
"shapes-line",
"sip-fill",
"sip-line",
"slice-fill",
"slice-line",
"square-fill",
"square-line",
"t-box-fill",
"t-box-line",
"table-alt-fill",
"table-alt-line",
"table-fill",
"table-line",
"tools-fill",
"tools-line",
"triangle-fill",
"triangle-line",
"wrench-fill",
"wrench-line"
],
"Development": [
"braces-fill",
"braces-line",
"brackets-fill",
"brackets-line",
"bug-2-fill",
"bug-2-line",
"bug-fill",
"bug-line",
"code-ai-fill",
"code-ai-line",
"code-box-fill",
"code-box-line",
"code-fill",
"code-line",
"code-s-fill",
"code-s-line",
"code-s-slash-fill",
"code-s-slash-line",
"command-fill",
"command-line",
"css3-fill",
"css3-line",
"cursor-fill",
"cursor-line",
"git-branch-fill",
"git-branch-line",
"git-close-pull-request-fill",
"git-close-pull-request-line",
"git-commit-fill",
"git-commit-line",
"git-fork-fill",
"git-fork-line",
"git-merge-fill",
"git-merge-line",
"git-pr-draft-fill",
"git-pr-draft-line",
"git-pull-request-fill",
"git-pull-request-line",
"git-repository-commits-fill",
"git-repository-commits-line",
"git-repository-fill",
"git-repository-line",
"git-repository-private-fill",
"git-repository-private-line",
"html5-fill",
"html5-line",
"javascript-fill",
"javascript-line",
"parentheses-fill",
"parentheses-line",
"php-fill",
"php-line",
"puzzle-2-fill",
"puzzle-2-line",
"puzzle-fill",
"puzzle-line",
"terminal-box-fill",
"terminal-box-line",
"terminal-fill",
"terminal-line",
"terminal-window-fill",
"terminal-window-line"
],
"Device": [
"airplay-fill",
"airplay-line",
"barcode-box-fill",
"barcode-box-line",
"barcode-fill",
"barcode-line",
"base-station-fill",
"base-station-line",
"battery-2-charge-fill",
"battery-2-charge-line",
"battery-2-fill",
"battery-2-line",
"battery-charge-fill",
"battery-charge-line",
"battery-fill",
"battery-line",
"battery-low-fill",
"battery-low-line",
"battery-saver-fill",
"battery-saver-line",
"battery-share-fill",
"battery-share-line",
"bluetooth-connect-fill",
"bluetooth-connect-line",
"bluetooth-fill",
"bluetooth-line",
"cast-fill",
"cast-line",
"cellphone-fill",
"cellphone-line",
"computer-fill",
"computer-line",
"cpu-fill",
"cpu-line",
"dashboard-2-fill",
"dashboard-2-line",
"dashboard-3-fill",
"dashboard-3-line",
"database-2-fill",
"database-2-line",
"database-fill",
"database-line",
"device-fill",
"device-line",
"device-recover-fill",
"device-recover-line",
"dual-sim-1-fill",
"dual-sim-1-line",
"dual-sim-2-fill",
"dual-sim-2-line",
"fingerprint-2-fill",
"fingerprint-2-line",
"fingerprint-fill",
"fingerprint-line",
"gamepad-fill",
"gamepad-line",
"gps-fill",
"gps-line",
"gradienter-fill",
"gradienter-line",
"hard-drive-2-fill",
"hard-drive-2-line",
"hard-drive-3-fill",
"hard-drive-3-line",
"hard-drive-fill",
"hard-drive-line",
"hotspot-fill",
"hotspot-line",
"install-fill",
"install-line",
"instance-fill",
"instance-line",
"keyboard-box-fill",
"keyboard-box-line",
"keyboard-fill",
"keyboard-line",
"mac-fill",
"mac-line",
"macbook-fill",
"macbook-line",
"mobile-download-fill",
"mobile-download-line",
"mouse-fill",
"mouse-line",
"phone-fill",
"phone-find-fill",
"phone-find-line",
"phone-line",
"phone-lock-fill",
"phone-lock-line",
"qr-code-fill",
"qr-code-line",
"qr-scan-2-fill",
"qr-scan-2-line",
"qr-scan-fill",
"qr-scan-line",
"radar-fill",
"radar-line",
"ram-2-fill",
"ram-2-line",
"ram-fill",
"ram-line",
"remote-control-2-fill",
"remote-control-2-line",
"remote-control-fill",
"remote-control-line",
"restart-fill",
"restart-line",
"rfid-fill",
"rfid-line",
"rotate-lock-fill",
"rotate-lock-line",
"router-fill",
"router-line",
"rss-fill",
"rss-line",
"save-2-fill",
"save-2-line",
"save-3-fill",
"save-3-line",
"save-fill",
"save-line",
"scan-2-fill",
"scan-2-line",
"scan-fill",
"scan-line",
"sd-card-fill",
"sd-card-line",
"sd-card-mini-fill",
"sd-card-mini-line",
"sensor-fill",
"sensor-line",
"server-fill",
"server-line",
"shut-down-fill",
"shut-down-line",
"signal-wifi-1-fill",
"signal-wifi-1-line",
"signal-wifi-2-fill",
"signal-wifi-2-line",
"signal-wifi-3-fill",
"signal-wifi-3-line",
"signal-wifi-error-fill",
"signal-wifi-error-line",
"signal-wifi-fill",
"signal-wifi-line",
"signal-wifi-off-fill",
"signal-wifi-off-line",
"sim-card-2-fill",
"sim-card-2-line",
"sim-card-fill",
"sim-card-line",
"smartphone-fill",
"smartphone-line",
"tablet-fill",
"tablet-line",
"tv-2-fill",
"tv-2-line",
"tv-fill",
"tv-line",
"u-disk-fill",
"u-disk-line",
"uninstall-fill",
"uninstall-line",
"usb-fill",
"usb-line",
"wifi-fill",
"wifi-line",
"wifi-off-fill",
"wifi-off-line",
"wireless-charging-fill",
"wireless-charging-line"
],
"Document": [
"article-fill",
"article-line",
"bill-fill",
"bill-line",
"book-2-fill",
"book-2-line",
"book-3-fill",
"book-3-line",
"book-ai-fill",
"book-ai-line",
"book-fill",
"book-line",
"book-marked-fill",
"book-marked-line",
"book-open-fill",
"book-open-line",
"book-read-fill",
"book-read-line",
"booklet-fill",
"booklet-line",
"clipboard-fill",
"clipboard-line",
"contacts-book-2-fill",
"contacts-book-2-line",
"contacts-book-3-fill",
"contacts-book-3-line",
"contacts-book-fill",
"contacts-book-line",
"contacts-book-upload-fill",
"contacts-book-upload-line",
"contract-fill",
"contract-line",
"draft-fill",
"draft-line",
"file-2-fill",
"file-2-line",
"file-3-fill",
"file-3-line",
"file-4-fill",
"file-4-line",
"file-add-fill",
"file-add-line",
"file-ai-2-fill",
"file-ai-2-line",
"file-ai-fill",
"file-ai-line",
"file-chart-2-fill",
"file-chart-2-line",
"file-chart-fill",
"file-chart-line",
"file-check-fill",
"file-check-line",
"file-close-fill",
"file-close-line",
"file-cloud-fill",
"file-cloud-line",
"file-code-fill",
"file-code-line",
"file-copy-2-fill",
"file-copy-2-line",
"file-copy-fill",
"file-copy-line",
"file-damage-fill",
"file-damage-line",
"file-download-fill",
"file-download-line",
"file-edit-fill",
"file-edit-line",
"file-excel-2-fill",
"file-excel-2-line",
"file-excel-fill",
"file-excel-line",
"file-fill",
"file-forbid-fill",
"file-forbid-line",
"file-gif-fill",
"file-gif-line",
"file-history-fill",
"file-history-line",
"file-hwp-fill",
"file-hwp-line",
"file-image-fill",
"file-image-line",
"file-info-fill",
"file-info-line",
"file-line",
"file-list-2-fill",
"file-list-2-line",
"file-list-3-fill",
"file-list-3-line",
"file-list-fill",
"file-list-line",
"file-lock-fill",
"file-lock-line",
"file-marked-fill",
"file-marked-line",
"file-music-fill",
"file-music-line",
"file-paper-2-fill",
"file-paper-2-line",
"file-paper-fill",
"file-paper-line",
"file-pdf-2-fill",
"file-pdf-2-line",
"file-pdf-fill",
"file-pdf-line",
"file-ppt-2-fill",
"file-ppt-2-line",
"file-ppt-fill",
"file-ppt-line",
"file-reduce-fill",
"file-reduce-line",
"file-search-fill",
"file-search-line",
"file-settings-fill",
"file-settings-line",
"file-shield-2-fill",
"file-shield-2-line",
"file-shield-fill",
"file-shield-line",
"file-shred-fill",
"file-shred-line",
"file-text-fill",
"file-text-line",
"file-transfer-fill",
"file-transfer-line",
"file-unknow-fill",
"file-unknow-line",
"file-upload-fill",
"file-upload-line",
"file-user-fill",
"file-user-line",
"file-video-fill",
"file-video-line",
"file-warning-fill",
"file-warning-line",
"file-word-2-fill",
"file-word-2-line",
"file-word-fill",
"file-word-line",
"file-zip-fill",
"file-zip-line",
"folder-2-fill",
"folder-2-line",
"folder-3-fill",
"folder-3-line",
"folder-4-fill",
"folder-4-line",
"folder-5-fill",
"folder-5-line",
"folder-6-fill",
"folder-6-line",
"folder-add-fill",
"folder-add-line",
"folder-chart-2-fill",
"folder-chart-2-line",
"folder-chart-fill",
"folder-chart-line",
"folder-check-fill",
"folder-check-line",
"folder-close-fill",
"folder-close-line",
"folder-cloud-fill",
"folder-cloud-line",
"folder-download-fill",
"folder-download-line",
"folder-fill",
"folder-forbid-fill",
"folder-forbid-line",
"folder-history-fill",
"folder-history-line",
"folder-image-fill",
"folder-image-line",
"folder-info-fill",
"folder-info-line",
"folder-keyhole-fill",
"folder-keyhole-line",
"folder-line",
"folder-lock-fill",
"folder-lock-line",
"folder-music-fill",
"folder-music-line",
"folder-open-fill",
"folder-open-line",
"folder-received-fill",
"folder-received-line",
"folder-reduce-fill",
"folder-reduce-line",
"folder-settings-fill",
"folder-settings-line",
"folder-shared-fill",
"folder-shared-line",
"folder-shield-2-fill",
"folder-shield-2-line",
"folder-shield-fill",
"folder-shield-line",
"folder-transfer-fill",
"folder-transfer-line",
"folder-unknow-fill",
"folder-unknow-line",
"folder-upload-fill",
"folder-upload-line",
"folder-user-fill",
"folder-user-line",
"folder-video-fill",
"folder-video-line",
"folder-warning-fill",
"folder-warning-line",
"folder-zip-fill",
"folder-zip-line",
"folders-fill",
"folders-line",
"keynote-fill",
"keynote-line",
"markdown-fill",
"markdown-line",
"news-fill",
"news-line",
"newspaper-fill",
"newspaper-line",
"numbers-fill",
"numbers-line",
"pages-fill",
"pages-line",
"receipt-fill",
"receipt-line",
"sticky-note-2-fill",
"sticky-note-2-line",
"sticky-note-add-fill",
"sticky-note-add-line",
"sticky-note-fill",
"sticky-note-line",
"survey-fill",
"survey-line",
"task-fill",
"task-line",
"todo-fill",
"todo-line"
],
"Editor": [
"a-b",
"ai",
"ai-generate",
"ai-generate-2",
"ai-generate-text",
"align-bottom",
"align-center",
"align-justify",
"align-left",
"align-right",
"align-top",
"align-vertically",
"asterisk",
"attachment-2",
"bold",
"bring-forward",
"bring-to-front",
"calendar-view",
"carousel-view",
"code-block",
"code-view",
"custom-size",
"delete-column",
"delete-row",
"double-quotes-l",
"double-quotes-r",
"draggable",
"dropdown-list",
"emphasis",
"emphasis-cn",
"english-input",
"flow-chart",
"focus-mode",
"font-color",
"font-family",
"font-mono",
"font-sans",
"font-sans-serif",
"font-size",
"font-size-2",
"font-size-ai",
"format-clear",
"formula",
"functions",
"gallery-view",
"gallery-view-2",
"h-1",
"h-2",
"h-3",
"h-4",
"h-5",
"h-6",
"hand",
"hashtag",
"heading",
"indent-decrease",
"indent-increase",
"info-i",
"input-cursor-move",
"input-field",
"insert-column-left",
"insert-column-right",
"insert-row-bottom",
"insert-row-top",
"italic",
"kanban-view",
"kanban-view-2",
"letter-spacing-2",
"line-height",
"line-height-2",
"link",
"link-m",
"link-unlink",
"link-unlink-m",
"list-check",
"list-check-2",
"list-check-3",
"list-indefinite",
"list-ordered",
"list-ordered-2",
"list-radio",
"list-unordered",
"list-view",
"merge-cells-horizontal",
"merge-cells-vertical",
"mind-map",
"node-tree",
"number-0",
"number-1",
"number-2",
"number-3",
"number-4",
"number-5",
"number-6",
"number-7",
"number-8",
"number-9",
"omega",
"organization-chart",
"overline",
"page-separator",
"paragraph",
"pinyin-input",
"question-mark",
"quote-text",
"rounded-corner",
"send-backward",
"send-to-back",
"separator",
"single-quotes-l",
"single-quotes-r",
"sketching",
"slash-commands",
"slash-commands-2",
"slideshow-view",
"sort-alphabet-asc",
"sort-alphabet-desc",
"sort-asc",
"sort-desc",
"sort-number-asc",
"sort-number-desc",
"space",
"split-cells-horizontal",
"split-cells-vertical",
"square-root",
"stacked-view",
"strikethrough",
"strikethrough-2",
"subscript",
"subscript-2",
"superscript",
"superscript-2",
"table-2",
"table-3",
"table-view",
"text",
"text-block",
"text-direction-l",
"text-direction-r",
"text-snippet",
"text-spacing",
"text-wrap",
"timeline-view",
"translate",
"translate-2",
"translate-ai",
"translate-ai-2",
"underline",
"wubi-input"
],
"Finance": [
"24-hours-fill",
"24-hours-line",
"auction-fill",
"auction-line",
"bank-card-2-fill",
"bank-card-2-line",
"bank-card-fill",
"bank-card-line",
"bit-coin-fill",
"bit-coin-line",
"bnb-fill",
"bnb-line",
"btc-fill",
"btc-line",
"cash-fill",
"cash-line",
"coin-fill",
"coin-line",
"coins-fill",
"coins-line",
"copper-coin-fill",
"copper-coin-line",
"copper-diamond-fill",
"copper-diamond-line",
"coupon-2-fill",
"coupon-2-line",
"coupon-3-fill",
"coupon-3-line",
"coupon-4-fill",
"coupon-4-line",
"coupon-5-fill",
"coupon-5-line",
"coupon-fill",
"coupon-line",
"currency-fill",
"currency-line",
"diamond-fill",
"diamond-line",
"diamond-ring-fill",
"diamond-ring-line",
"discount-percent-fill",
"discount-percent-line",
"eth-fill",
"eth-line",
"exchange-2-fill",
"exchange-2-line",
"exchange-box-fill",
"exchange-box-line",
"exchange-cny-fill",
"exchange-cny-line",
"exchange-dollar-fill",
"exchange-dollar-line",
"exchange-fill",
"exchange-funds-fill",
"exchange-funds-line",
"exchange-line",
"funds-box-fill",
"funds-box-line",
"funds-fill",
"funds-line",
"gift-2-fill",
"gift-2-line",
"gift-fill",
"gift-line",
"hand-coin-fill",
"hand-coin-line",
"hand-heart-fill",
"hand-heart-line",
"increase-decrease-fill",
"increase-decrease-line",
"jewelry-fill",
"jewelry-line",
"money-cny-box-fill",
"money-cny-box-line",
"money-cny-circle-fill",
"money-cny-circle-line",
"money-dollar-box-fill",
"money-dollar-box-line",
"money-dollar-circle-fill",
"money-dollar-circle-line",
"money-euro-box-fill",
"money-euro-box-line",
"money-euro-circle-fill",
"money-euro-circle-line",
"money-pound-box-fill",
"money-pound-box-line",
"money-pound-circle-fill",
"money-pound-circle-line",
"money-rupee-circle-fill",
"money-rupee-circle-line",
"nft-fill",
"nft-line",
"no-credit-card-fill",
"no-credit-card-line",
"p2p-fill",
"p2p-line",
"percent-fill",
"percent-line",
"price-tag-2-fill",
"price-tag-2-line",
"price-tag-3-fill",
"price-tag-3-line",
"price-tag-fill",
"price-tag-line",
"red-packet-fill",
"red-packet-line",
"refund-2-fill",
"refund-2-line",
"refund-fill",
"refund-line",
"safe-2-fill",
"safe-2-line",
"safe-3-fill",
"safe-3-line",
"safe-fill",
"safe-line",
"secure-payment-fill",
"secure-payment-line",
"shopping-bag-2-fill",
"shopping-bag-2-line",
"shopping-bag-3-fill",
"shopping-bag-3-line",
"shopping-bag-4-fill",
"shopping-bag-4-line",
"shopping-bag-fill",
"shopping-bag-line",
"shopping-basket-2-fill",
"shopping-basket-2-line",
"shopping-basket-fill",
"shopping-basket-line",
"shopping-cart-2-fill",
"shopping-cart-2-line",
"shopping-cart-fill",
"shopping-cart-line",
"stock-fill",
"stock-line",
"swap-2-fill",
"swap-2-line",
"swap-3-fill",
"swap-3-line",
"swap-box-fill",
"swap-box-line",
"swap-fill",
"swap-line",
"ticket-2-fill",
"ticket-2-line",
"ticket-fill",
"ticket-line",
"token-swap-fill",
"token-swap-line",
"trophy-fill",
"trophy-line",
"vip-crown-2-fill",
"vip-crown-2-line",
"vip-crown-fill",
"vip-crown-line",
"vip-diamond-fill",
"vip-diamond-line",
"vip-fill",
"vip-line",
"wallet-2-fill",
"wallet-2-line",
"wallet-3-fill",
"wallet-3-line",
"wallet-fill",
"wallet-line",
"water-flash-fill",
"water-flash-line",
"xrp-fill",
"xrp-line",
"xtz-fill",
"xtz-line"
],
"Food": [
"beer-fill",
"beer-line",
"bowl-fill",
"bowl-line",
"bread-fill",
"bread-line",
"cake-2-fill",
"cake-2-line",
"cake-3-fill",
"cake-3-line",
"cake-fill",
"cake-line",
"cup-fill",
"cup-line",
"drinks-2-fill",
"drinks-2-line",
"drinks-fill",
"drinks-line",
"goblet-2-fill",
"goblet-2-line",
"goblet-broken-fill",
"goblet-broken-line",
"goblet-fill",
"goblet-line",
"knife-blood-fill",
"knife-blood-line",
"knife-fill",
"knife-line",
"restaurant-2-fill",
"restaurant-2-line",
"restaurant-fill",
"restaurant-line"
],
"Health & Medical": [
"aed-electrodes-fill",
"aed-electrodes-line",
"aed-fill",
"aed-line",
"atom-fill",
"atom-line",
"brain-2-fill",
"brain-2-line",
"brain-3-fill",
"brain-3-line",
"brain-ai-3-fill",
"brain-ai-3-line",
"brain-fill",
"brain-line",
"capsule-fill",
"capsule-line",
"dislike-fill",
"dislike-line",
"dna-fill",
"dna-line",
"dossier-fill",
"dossier-line",
"dropper-fill",
"dropper-line",
"empathize-fill",
"empathize-line",
"first-aid-kit-fill",
"first-aid-kit-line",
"flask-fill",
"flask-line",
"hand-sanitizer-fill",
"hand-sanitizer-line",
"health-book-fill",
"health-book-line",
"heart-2-fill",
"heart-2-line",
"heart-3-fill",
"heart-3-line",
"heart-add-2-fill",
"heart-add-2-line",
"heart-add-fill",
"heart-add-line",
"heart-fill",
"heart-line",
"heart-pulse-fill",
"heart-pulse-line",
"hearts-fill",
"hearts-line",
"infrared-thermometer-fill",
"infrared-thermometer-line",
"lungs-fill",
"lungs-line",
"medicine-bottle-fill",
"medicine-bottle-line",
"mental-health-fill",
"mental-health-line",
"microscope-fill",
"microscope-line",
"nurse-fill",
"nurse-line",
"psychotherapy-fill",
"psychotherapy-line",
"pulse-ai-fill",
"pulse-ai-line",
"pulse-fill",
"pulse-line",
"rest-time-fill",
"rest-time-line",
"stethoscope-fill",
"stethoscope-line",
"surgical-mask-fill",
"surgical-mask-line",
"syringe-fill",
"syringe-line",
"test-tube-fill",
"test-tube-line",
"thermometer-fill",
"thermometer-line",
"virus-fill",
"virus-line",
"zzz-fill",
"zzz-line"
],
"Logos": [
"alibaba-cloud-fill",
"alibaba-cloud-line",
"alipay-fill",
"alipay-line",
"amazon-fill",
"amazon-line",
"android-fill",
"android-line",
"angularjs-fill",
"angularjs-line",
"anthropic-fill",
"anthropic-line",
"app-store-fill",
"app-store-line",
"apple-fill",
"apple-line",
"baidu-fill",
"baidu-line",
"bard-fill",
"bard-line",
"behance-fill",
"behance-line",
"bilibili-fill",
"bilibili-line",
"blender-fill",
"blender-line",
"blogger-fill",
"blogger-line",
"bluesky-fill",
"bluesky-line",
"bootstrap-fill",
"bootstrap-line",
"centos-fill",
"centos-line",
"chrome-fill",
"chrome-line",
"claude-fill",
"claude-line",
"codepen-fill",
"codepen-line",
"copilot-fill",
"copilot-line",
"coreos-fill",
"coreos-line",
"deepseek-fill",
"deepseek-line",
"dingding-fill",
"dingding-line",
"discord-fill",
"discord-line",
"disqus-fill",
"disqus-line",
"douban-fill",
"douban-line",
"dribbble-fill",
"dribbble-line",
"drive-fill",
"drive-line",
"dropbox-fill",
"dropbox-line",
"edge-fill",
"edge-line",
"edge-new-fill",
"edge-new-line",
"evernote-fill",
"evernote-line",
"facebook-box-fill",
"facebook-box-line",
"facebook-circle-fill",
"facebook-circle-line",
"facebook-fill",
"facebook-line",
"fediverse-fill",
"fediverse-line",
"figma-fill",
"figma-line",
"finder-fill",
"finder-line",
"firebase-fill",
"firebase-line",
"firefox-browser-fill",
"firefox-browser-line",
"firefox-fill",
"firefox-line",
"flickr-fill",
"flickr-line",
"flutter-fill",
"flutter-line",
"friendica-fill",
"friendica-line",
"gatsby-fill",
"gatsby-line",
"gemini-fill",
"gemini-line",
"github-fill",
"github-line",
"gitlab-fill",
"gitlab-line",
"google-fill",
"google-line",
"google-play-fill",
"google-play-line",
"honor-of-kings-fill",
"honor-of-kings-line",
"ie-fill",
"ie-line",
"instagram-fill",
"instagram-line",
"invision-fill",
"invision-line",
"java-fill",
"java-line",
"kakao-talk-fill",
"kakao-talk-line",
"kick-fill",
"kick-line",
"line-fill",
"line-line",
"linkedin-box-fill",
"linkedin-box-line",
"linkedin-fill",
"linkedin-line",
"mastercard-fill",
"mastercard-line",
"mastodon-fill",
"mastodon-line",
"medium-fill",
"medium-line",
"messenger-fill",
"messenger-line",
"meta-fill",
"meta-line",
"microsoft-fill",
"microsoft-line",
"microsoft-loop-fill",
"microsoft-loop-line",
"mini-program-fill",
"mini-program-line",
"mixtral-fill",
"mixtral-line",
"netease-cloud-music-fill",
"netease-cloud-music-line",
"netflix-fill",
"netflix-line",
"nextjs-fill",
"nextjs-line",
"nodejs-fill",
"nodejs-line",
"notion-fill",
"notion-line",
"npmjs-fill",
"npmjs-line",
"open-source-fill",
"open-source-line",
"openai-fill",
"openai-line",
"openbase-fill",
"openbase-line",
"opera-fill",
"opera-line",
"patreon-fill",
"patreon-line",
"paypal-fill",
"paypal-line",
"perplexity-fill",
"perplexity-line",
"pinterest-fill",
"pinterest-line",
"pix-fill",
"pix-line",
"pixelfed-fill",
"pixelfed-line",
"playstation-fill",
"playstation-line",
"product-hunt-fill",
"product-hunt-line",
"qq-fill",
"qq-line",
"reactjs-fill",
"reactjs-line",
"reddit-fill",
"reddit-line",
"remix-run-fill",
"remix-run-line",
"remixicon-fill",
"remixicon-line",
"safari-fill",
"safari-line",
"skype-fill",
"skype-line",
"slack-fill",
"slack-line",
"snapchat-fill",
"snapchat-line",
"soundcloud-fill",
"soundcloud-line",
"spectrum-fill",
"spectrum-line",
"spotify-fill",
"spotify-line",
"stack-overflow-fill",
"stack-overflow-line",
"stackshare-fill",
"stackshare-line",
"steam-fill",
"steam-line",
"supabase-fill",
"supabase-line",
"svelte-fill",
"svelte-line",
"switch-fill",
"switch-line",
"tailwind-css-fill",
"tailwind-css-line",
"taobao-fill",
"taobao-line",
"telegram-2-fill",
"telegram-2-line",
"telegram-fill",
"telegram-line",
"threads-fill",
"threads-line",
"tiktok-fill",
"tiktok-line",
"trello-fill",
"trello-line",
"tumblr-fill",
"tumblr-line",
"twitch-fill",
"twitch-line",
"twitter-fill",
"twitter-line",
"twitter-x-fill",
"twitter-x-line",
"ubuntu-fill",
"ubuntu-line",
"unsplash-fill",
"unsplash-line",
"vercel-fill",
"vercel-line",
"vimeo-fill",
"vimeo-line",
"visa-fill",
"visa-line",
"vk-fill",
"vk-line",
"vuejs-fill",
"vuejs-line",
"webhook-fill",
"webhook-line",
"wechat-2-fill",
"wechat-2-line",
"wechat-channels-fill",
"wechat-channels-line",
"wechat-fill",
"wechat-line",
"wechat-pay-fill",
"wechat-pay-line",
"weibo-fill",
"weibo-line",
"whatsapp-fill",
"whatsapp-line",
"windows-fill",
"windows-line",
"wordpress-fill",
"wordpress-line",
"xbox-fill",
"xbox-line",
"xing-fill",
"xing-line",
"youtube-fill",
"youtube-line",
"yuque-fill",
"yuque-line",
"zcool-fill",
"zcool-line",
"zhihu-fill",
"zhihu-line"
],
"Map": [
"anchor-fill",
"anchor-line",
"barricade-fill",
"barricade-line",
"bike-fill",
"bike-line",
"bus-2-fill",
"bus-2-line",
"bus-fill",
"bus-line",
"bus-wifi-fill",
"bus-wifi-line",
"car-fill",
"car-line",
"car-washing-fill",
"car-washing-line",
"caravan-fill",
"caravan-line",
"charging-pile-2-fill",
"charging-pile-2-line",
"charging-pile-fill",
"charging-pile-line",
"china-railway-fill",
"china-railway-line",
"compass-2-fill",
"compass-2-line",
"compass-3-fill",
"compass-3-line",
"compass-4-fill",
"compass-4-line",
"compass-discover-fill",
"compass-discover-line",
"compass-fill",
"compass-line",
"direction-fill",
"direction-line",
"e-bike-2-fill",
"e-bike-2-line",
"e-bike-fill",
"e-bike-line",
"earth-fill",
"earth-line",
"flight-land-fill",
"flight-land-line",
"flight-takeoff-fill",
"flight-takeoff-line",
"footprint-fill",
"footprint-line",
"gas-station-fill",
"gas-station-line",
"globe-fill",
"globe-line",
"guide-fill",
"guide-line",
"hotel-bed-fill",
"hotel-bed-line",
"lifebuoy-fill",
"lifebuoy-line",
"luggage-cart-fill",
"luggage-cart-line",
"luggage-deposit-fill",
"luggage-deposit-line",
"map-2-fill",
"map-2-line",
"map-fill",
"map-line",
"map-pin-2-fill",
"map-pin-2-line",
"map-pin-3-fill",
"map-pin-3-line",
"map-pin-4-fill",
"map-pin-4-line",
"map-pin-5-fill",
"map-pin-5-line",
"map-pin-add-fill",
"map-pin-add-line",
"map-pin-fill",
"map-pin-line",
"map-pin-range-fill",
"map-pin-range-line",
"map-pin-time-fill",
"map-pin-time-line",
"map-pin-user-fill",
"map-pin-user-line",
"motorbike-fill",
"motorbike-line",
"navigation-fill",
"navigation-line",
"oil-fill",
"oil-line",
"parking-box-fill",
"parking-box-line",
"parking-fill",
"parking-line",
"passport-fill",
"passport-line",
"pin-distance-fill",
"pin-distance-line",
"plane-fill",
"plane-line",
"planet-fill",
"planet-line",
"police-car-fill",
"police-car-line",
"pushpin-2-fill",
"pushpin-2-line",
"pushpin-fill",
"pushpin-line",
"riding-fill",
"riding-line",
"road-map-fill",
"road-map-line",
"roadster-fill",
"roadster-line",
"rocket-2-fill",
"rocket-2-line",
"rocket-fill",
"rocket-line",
"route-fill",
"route-line",
"run-fill",
"run-line",
"sailboat-fill",
"sailboat-line",
"ship-2-fill",
"ship-2-line",
"ship-fill",
"ship-line",
"signal-tower-fill",
"signal-tower-line",
"signpost-fill",
"signpost-line",
"space-ship-fill",
"space-ship-line",
"steering-2-fill",
"steering-2-line",
"steering-fill",
"steering-line",
"subway-fill",
"subway-line",
"subway-wifi-fill",
"subway-wifi-line",
"suitcase-2-fill",
"suitcase-2-line",
"suitcase-3-fill",
"suitcase-3-line",
"suitcase-fill",
"suitcase-line",
"takeaway-fill",
"takeaway-line",
"taxi-fill",
"taxi-line",
"taxi-wifi-fill",
"taxi-wifi-line",
"time-zone-fill",
"time-zone-line",
"traffic-light-fill",
"traffic-light-line",
"train-fill",
"train-line",
"train-wifi-fill",
"train-wifi-line",
"treasure-map-fill",
"treasure-map-line",
"truck-fill",
"truck-line",
"unpin-fill",
"unpin-line",
"walk-fill",
"walk-line"
],
"Media": [
"4k-fill",
"4k-line",
"album-fill",
"album-line",
"aspect-ratio-fill",
"aspect-ratio-line",
"broadcast-fill",
"broadcast-line",
"camera-2-fill",
"camera-2-line",
"camera-3-fill",
"camera-3-line",
"camera-4-fill",
"camera-4-line",
"camera-ai-2-fill",
"camera-ai-2-line",
"camera-ai-fill",
"camera-ai-line",
"camera-fill",
"camera-lens-ai-fill",
"camera-lens-ai-line",
"camera-lens-fill",
"camera-lens-line",
"camera-line",
"camera-off-fill",
"camera-off-line",
"camera-switch-fill",
"camera-switch-line",
"clapperboard-ai-fill",
"clapperboard-ai-line",
"clapperboard-fill",
"clapperboard-line",
"closed-captioning-ai-fill",
"closed-captioning-ai-line",
"closed-captioning-fill",
"closed-captioning-line",
"disc-fill",
"disc-line",
"dv-fill",
"dv-line",
"dvd-ai-fill",
"dvd-ai-line",
"dvd-fill",
"dvd-line",
"eject-fill",
"eject-line",
"equalizer-2-fill",
"equalizer-2-line",
"equalizer-3-fill",
"equalizer-3-line",
"equalizer-fill",
"equalizer-line",
"film-ai-fill",
"film-ai-line",
"film-fill",
"film-line",
"forward-10-fill",
"forward-10-line",
"forward-15-fill",
"forward-15-line",
"forward-30-fill",
"forward-30-line",
"forward-5-fill",
"forward-5-line",
"forward-end-fill",
"forward-end-line",
"forward-end-mini-fill",
"forward-end-mini-line",
"fullscreen-exit-fill",
"fullscreen-exit-line",
"fullscreen-fill",
"fullscreen-line",
"gallery-fill",
"gallery-line",
"gallery-upload-fill",
"gallery-upload-line",
"hd-fill",
"hd-line",
"headphone-fill",
"headphone-line",
"hq-fill",
"hq-line",
"image-2-fill",
"image-2-line",
"image-add-fill",
"image-add-line",
"image-ai-fill",
"image-ai-line",
"image-circle-ai-fill",
"image-circle-ai-line",
"image-circle-fill",
"image-circle-line",
"image-edit-fill",
"image-edit-line",
"image-fill",
"image-line",
"landscape-ai-fill",
"landscape-ai-line",
"landscape-fill",
"landscape-line",
"live-fill",
"live-line",
"memories-fill",
"memories-line",
"mic-2-ai-fill",
"mic-2-ai-line",
"mic-2-fill",
"mic-2-line",
"mic-ai-fill",
"mic-ai-line",
"mic-fill",
"mic-line",
"mic-off-fill",
"mic-off-line",
"movie-2-ai-fill",
"movie-2-ai-line",
"movie-2-fill",
"movie-2-line",
"movie-ai-fill",
"movie-ai-line",
"movie-fill",
"movie-line",
"multi-image-fill",
"multi-image-line",
"music-2-fill",
"music-2-line",
"music-ai-fill",
"music-ai-line",
"music-fill",
"music-line",
"mv-ai-fill",
"mv-ai-line",
"mv-fill",
"mv-line",
"notification-2-fill",
"notification-2-line",
"notification-3-fill",
"notification-3-line",
"notification-4-fill",
"notification-4-line",
"notification-fill",
"notification-line",
"notification-off-fill",
"notification-off-line",
"notification-snooze-fill",
"notification-snooze-line",
"order-play-fill",
"order-play-line",
"pause-circle-fill",
"pause-circle-line",
"pause-fill",
"pause-large-fill",
"pause-large-line",
"pause-line",
"pause-mini-fill",
"pause-mini-line",
"phone-camera-fill",
"phone-camera-line",
"picture-in-picture-2-fill",
"picture-in-picture-2-line",
"picture-in-picture-exit-fill",
"picture-in-picture-exit-line",
"picture-in-picture-fill",
"picture-in-picture-line",
"play-circle-fill",
"play-circle-line",
"play-fill",
"play-large-fill",
"play-large-line",
"play-line",
"play-list-2-fill",
"play-list-2-line",
"play-list-add-fill",
"play-list-add-line",
"play-list-fill",
"play-list-line",
"play-mini-fill",
"play-mini-line",
"play-reverse-fill",
"play-reverse-large-fill",
"play-reverse-large-line",
"play-reverse-line",
"play-reverse-mini-fill",
"play-reverse-mini-line",
"polaroid-2-fill",
"polaroid-2-line",
"polaroid-fill",
"polaroid-line",
"radio-2-fill",
"radio-2-line",
"radio-fill",
"radio-line",
"record-circle-fill",
"record-circle-line",
"repeat-2-fill",
"repeat-2-line",
"repeat-fill",
"repeat-line",
"repeat-one-fill",
"repeat-one-line",
"replay-10-fill",
"replay-10-line",
"replay-15-fill",
"replay-15-line",
"replay-30-fill",
"replay-30-line",
"replay-5-fill",
"replay-5-line",
"rewind-fill",
"rewind-line",
"rewind-mini-fill",
"rewind-mini-line",
"rewind-start-fill",
"rewind-start-line",
"rewind-start-mini-fill",
"rewind-start-mini-line",
"rhythm-fill",
"rhythm-line",
"shuffle-fill",
"shuffle-line",
"skip-back-fill",
"skip-back-line",
"skip-back-mini-fill",
"skip-back-mini-line",
"skip-forward-fill",
"skip-forward-line",
"skip-forward-mini-fill",
"skip-forward-mini-line",
"slow-down-fill",
"slow-down-line",
"sound-module-fill",
"sound-module-line",
"speaker-2-fill",
"speaker-2-line",
"speaker-3-fill",
"speaker-3-line",
"speaker-fill",
"speaker-line",
"speed-fill",
"speed-line",
"speed-mini-fill",
"speed-mini-line",
"speed-up-fill",
"speed-up-line",
"stop-circle-fill",
"stop-circle-line",
"stop-fill",
"stop-large-fill",
"stop-large-line",
"stop-line",
"stop-mini-fill",
"stop-mini-line",
"surround-sound-fill",
"surround-sound-line",
"tape-fill",
"tape-line",
"video-add-fill",
"video-add-line",
"video-ai-fill",
"video-ai-line",
"video-download-fill",
"video-download-line",
"video-fill",
"video-line",
"video-off-fill",
"video-off-line",
"video-on-ai-fill",
"video-on-ai-line",
"video-on-fill",
"video-on-line",
"video-upload-fill",
"video-upload-line",
"vidicon-2-fill",
"vidicon-2-line",
"vidicon-fill",
"vidicon-line",
"voice-ai-fill",
"voice-ai-line",
"voiceprint-fill",
"voiceprint-line",
"volume-down-fill",
"volume-down-line",
"volume-mute-fill",
"volume-mute-line",
"volume-off-vibrate-fill",
"volume-off-vibrate-line",
"volume-up-fill",
"volume-up-line",
"volume-vibrate-fill",
"volume-vibrate-line",
"webcam-fill",
"webcam-line"
],
"Others": [
"accessibility-fill",
"accessibility-line",
"ai-generate-3d-fill",
"ai-generate-3d-line",
"armchair-fill",
"armchair-line",
"basketball-fill",
"basketball-line",
"bell-fill",
"bell-line",
"billiards-fill",
"billiards-line",
"book-shelf-fill",
"book-shelf-line",
"box-1-fill",
"box-1-line",
"box-2-fill",
"box-2-line",
"box-3-fill",
"box-3-line",
"boxing-fill",
"boxing-line",
"cactus-fill",
"cactus-line",
"candle-fill",
"candle-line",
"character-recognition-fill",
"character-recognition-line",
"chess-fill",
"chess-line",
"cross-fill",
"cross-line",
"dice-1-fill",
"dice-1-line",
"dice-2-fill",
"dice-2-line",
"dice-3-fill",
"dice-3-line",
"dice-4-fill",
"dice-4-line",
"dice-5-fill",
"dice-5-line",
"dice-6-fill",
"dice-6-line",
"dice-fill",
"dice-line",
"door-closed-fill",
"door-closed-line",
"door-fill",
"door-line",
"door-lock-box-fill",
"door-lock-box-line",
"door-lock-fill",
"door-lock-line",
"door-open-fill",
"door-open-line",
"flower-fill",
"flower-line",
"football-fill",
"football-line",
"fridge-fill",
"fridge-line",
"game-2-fill",
"game-2-line",
"game-fill",
"game-line",
"glasses-2-fill",
"glasses-2-line",
"glasses-fill",
"glasses-line",
"goggles-fill",
"goggles-line",
"golf-ball-fill",
"golf-ball-line",
"graduation-cap-fill",
"graduation-cap-line",
"handbag-fill",
"handbag-line",
"infinity-fill",
"infinity-line",
"key-2-fill",
"key-2-line",
"key-fill",
"key-line",
"leaf-fill",
"leaf-line",
"lightbulb-ai-fill",
"lightbulb-ai-line",
"lightbulb-fill",
"lightbulb-flash-fill",
"lightbulb-flash-line",
"lightbulb-line",
"outlet-2-fill",
"outlet-2-line",
"outlet-fill",
"outlet-line",
"ping-pong-fill",
"ping-pong-line",
"plant-fill",
"plant-line",
"plug-2-fill",
"plug-2-line",
"plug-fill",
"plug-line",
"poker-clubs-fill",
"poker-clubs-line",
"poker-diamonds-fill",
"poker-diamonds-line",
"poker-hearts-fill",
"poker-hearts-line",
"poker-spades-fill",
"poker-spades-line",
"police-badge-fill",
"police-badge-line",
"recycle-fill",
"recycle-line",
"reserved-fill",
"reserved-line",
"scales-2-fill",
"scales-2-line",
"scales-3-fill",
"scales-3-line",
"scales-fill",
"scales-line",
"seedling-fill",
"seedling-line",
"service-bell-fill",
"service-bell-line",
"shirt-fill",
"shirt-line",
"sofa-fill",
"sofa-line",
"stairs-fill",
"stairs-line",
"sword-fill",
"sword-line",
"t-shirt-2-fill",
"t-shirt-2-line",
"t-shirt-air-fill",
"t-shirt-air-line",
"t-shirt-fill",
"t-shirt-line",
"target-fill",
"target-line",
"tooth-fill",
"tooth-line",
"tree-fill",
"tree-line",
"umbrella-fill",
"umbrella-line",
"voice-recognition-fill",
"voice-recognition-line",
"weight-fill",
"weight-line",
"wheelchair-fill",
"wheelchair-line"
],
"System": [
"add-box-fill",
"add-box-line",
"add-circle-fill",
"add-circle-line",
"add-fill",
"add-large-fill",
"add-large-line",
"add-line",
"alarm-add-fill",
"alarm-add-line",
"alarm-fill",
"alarm-line",
"alarm-snooze-fill",
"alarm-snooze-line",
"alarm-warning-fill",
"alarm-warning-line",
"alert-fill",
"alert-line",
"apps-2-add-fill",
"apps-2-add-line",
"apps-2-ai-fill",
"apps-2-ai-line",
"apps-2-fill",
"apps-2-line",
"apps-ai-fill",
"apps-ai-line",
"apps-fill",
"apps-line",
"check-double-fill",
"check-double-line",
"check-fill",
"check-line",
"checkbox-blank-circle-fill",
"checkbox-blank-circle-line",
"checkbox-blank-fill",
"checkbox-blank-line",
"checkbox-circle-fill",
"checkbox-circle-line",
"checkbox-fill",
"checkbox-indeterminate-fill",
"checkbox-indeterminate-line",
"checkbox-line",
"checkbox-multiple-blank-fill",
"checkbox-multiple-blank-line",
"checkbox-multiple-fill",
"checkbox-multiple-line",
"close-circle-fill",
"close-circle-line",
"close-fill",
"close-large-fill",
"close-large-line",
"close-line",
"dashboard-fill",
"dashboard-horizontal-fill",
"dashboard-horizontal-line",
"dashboard-line",
"delete-back-2-fill",
"delete-back-2-line",
"delete-back-fill",
"delete-back-line",
"delete-bin-2-fill",
"delete-bin-2-line",
"delete-bin-3-fill",
"delete-bin-3-line",
"delete-bin-4-fill",
"delete-bin-4-line",
"delete-bin-5-fill",
"delete-bin-5-line",
"delete-bin-6-fill",
"delete-bin-6-line",
"delete-bin-7-fill",
"delete-bin-7-line",
"delete-bin-fill",
"delete-bin-line",
"divide-fill",
"divide-line",
"download-2-fill",
"download-2-line",
"download-cloud-2-fill",
"download-cloud-2-line",
"download-cloud-fill",
"download-cloud-line",
"download-fill",
"download-line",
"equal-fill",
"equal-line",
"error-warning-fill",
"error-warning-line",
"export-fill",
"export-line",
"external-link-fill",
"external-link-line",
"eye-2-fill",
"eye-2-line",
"eye-close-fill",
"eye-close-line",
"eye-fill",
"eye-line",
"eye-off-fill",
"eye-off-line",
"filter-2-fill",
"filter-2-line",
"filter-3-fill",
"filter-3-line",
"filter-fill",
"filter-line",
"filter-off-fill",
"filter-off-line",
"find-replace-fill",
"find-replace-line",
"forbid-2-fill",
"forbid-2-line",
"forbid-fill",
"forbid-line",
"function-add-fill",
"function-add-line",
"function-ai-fill",
"function-ai-line",
"function-fill",
"function-line",
"history-fill",
"history-line",
"hourglass-2-fill",
"hourglass-2-line",
"hourglass-fill",
"hourglass-line",
"import-fill",
"import-line",
"indeterminate-circle-fill",
"indeterminate-circle-line",
"information-2-fill",
"information-2-line",
"information-fill",
"information-line",
"information-off-fill",
"information-off-line",
"list-settings-fill",
"list-settings-line",
"loader-2-fill",
"loader-2-line",
"loader-3-fill",
"loader-3-line",
"loader-4-fill",
"loader-4-line",
"loader-5-fill",
"loader-5-line",
"loader-fill",
"loader-line",
"lock-2-fill",
"lock-2-line",
"lock-fill",
"lock-line",
"lock-password-fill",
"lock-password-line",
"lock-star-fill",
"lock-star-line",
"lock-unlock-fill",
"lock-unlock-line",
"login-box-fill",
"login-box-line",
"login-circle-fill",
"login-circle-line",
"logout-box-fill",
"logout-box-line",
"logout-box-r-fill",
"logout-box-r-line",
"logout-circle-fill",
"logout-circle-line",
"logout-circle-r-fill",
"logout-circle-r-line",
"loop-left-ai-fill",
"loop-left-ai-line",
"loop-left-fill",
"loop-left-line",
"loop-right-ai-fill",
"loop-right-ai-line",
"loop-right-fill",
"loop-right-line",
"menu-2-fill",
"menu-2-line",
"menu-3-fill",
"menu-3-line",
"menu-4-fill",
"menu-4-line",
"menu-5-fill",
"menu-5-line",
"menu-add-fill",
"menu-add-line",
"menu-fill",
"menu-fold-2-fill",
"menu-fold-2-line",
"menu-fold-3-fill",
"menu-fold-3-line",
"menu-fold-4-fill",
"menu-fold-4-line",
"menu-fold-fill",
"menu-fold-line",
"menu-line",
"menu-search-fill",
"menu-search-line",
"menu-unfold-2-fill",
"menu-unfold-2-line",
"menu-unfold-3-fill",
"menu-unfold-3-line",
"menu-unfold-4-fill",
"menu-unfold-4-line",
"menu-unfold-fill",
"menu-unfold-line",
"more-2-fill",
"more-2-line",
"more-fill",
"more-line",
"notification-badge-fill",
"notification-badge-line",
"progress-1-fill",
"progress-1-line",
"progress-2-fill",
"progress-2-line",
"progress-3-fill",
"progress-3-line",
"progress-4-fill",
"progress-4-line",
"progress-5-fill",
"progress-5-line",
"progress-6-fill",
"progress-6-line",
"progress-7-fill",
"progress-7-line",
"progress-8-fill",
"progress-8-line",
"prohibited-2-fill",
"prohibited-2-line",
"prohibited-fill",
"prohibited-line",
"question-fill",
"question-line",
"radio-button-fill",
"radio-button-line",
"refresh-fill",
"refresh-line",
"reset-left-fill",
"reset-left-line",
"reset-right-fill",
"reset-right-line",
"search-2-fill",
"search-2-line",
"search-ai-2-fill",
"search-ai-2-line",
"search-ai-3-fill",
"search-ai-3-line",
"search-ai-4-fill",
"search-ai-4-line",
"search-ai-fill",
"search-ai-line",
"search-eye-fill",
"search-eye-line",
"search-fill",
"search-line",
"settings-2-fill",
"settings-2-line",
"settings-3-fill",
"settings-3-line",
"settings-4-fill",
"settings-4-line",
"settings-5-fill",
"settings-5-line",
"settings-6-fill",
"settings-6-line",
"settings-fill",
"settings-line",
"share-2-fill",
"share-2-line",
"share-box-fill",
"share-box-line",
"share-circle-fill",
"share-circle-line",
"share-fill",
"share-forward-2-fill",
"share-forward-2-line",
"share-forward-box-fill",
"share-forward-box-line",
"share-forward-fill",
"share-forward-line",
"share-line",
"shield-check-fill",
"shield-check-line",
"shield-cross-fill",
"shield-cross-line",
"shield-fill",
"shield-flash-fill",
"shield-flash-line",
"shield-keyhole-fill",
"shield-keyhole-line",
"shield-line",
"shield-star-fill",
"shield-star-line",
"shield-user-fill",
"shield-user-line",
"side-bar-fill",
"side-bar-line",
"sidebar-fold-fill",
"sidebar-fold-line",
"sidebar-unfold-fill",
"sidebar-unfold-line",
"spam-2-fill",
"spam-2-line",
"spam-3-fill",
"spam-3-line",
"spam-fill",
"spam-line",
"star-fill",
"star-half-fill",
"star-half-line",
"star-half-s-fill",
"star-half-s-line",
"star-line",
"star-off-fill",
"star-off-line",
"star-s-fill",
"star-s-line",
"subtract-fill",
"subtract-line",
"thumb-down-fill",
"thumb-down-line",
"thumb-up-fill",
"thumb-up-line",
"time-fill",
"time-line",
"timer-2-fill",
"timer-2-line",
"timer-fill",
"timer-flash-fill",
"timer-flash-line",
"timer-line",
"toggle-fill",
"toggle-line",
"upload-2-fill",
"upload-2-line",
"upload-cloud-2-fill",
"upload-cloud-2-line",
"upload-cloud-fill",
"upload-cloud-line",
"upload-fill",
"upload-line",
"zoom-in-fill",
"zoom-in-line",
"zoom-out-fill",
"zoom-out-line"
],
"User & Faces": [
"account-box-2-fill",
"account-box-2-line",
"account-box-fill",
"account-box-line",
"account-circle-2-fill",
"account-circle-2-line",
"account-circle-fill",
"account-circle-line",
"account-pin-box-fill",
"account-pin-box-line",
"account-pin-circle-fill",
"account-pin-circle-line",
"admin-fill",
"admin-line",
"ai-agent-fill",
"ai-agent-line",
"aliens-fill",
"aliens-line",
"bear-smile-fill",
"bear-smile-line",
"body-scan-fill",
"body-scan-line",
"contacts-fill",
"contacts-line",
"criminal-fill",
"criminal-line",
"emotion-2-fill",
"emotion-2-line",
"emotion-fill",
"emotion-happy-fill",
"emotion-happy-line",
"emotion-laugh-fill",
"emotion-laugh-line",
"emotion-line",
"emotion-normal-fill",
"emotion-normal-line",
"emotion-sad-fill",
"emotion-sad-line",
"emotion-unhappy-fill",
"emotion-unhappy-line",
"genderless-fill",
"genderless-line",
"ghost-2-fill",
"ghost-2-line",
"ghost-fill",
"ghost-line",
"ghost-smile-fill",
"ghost-smile-line",
"group-2-fill",
"group-2-line",
"group-3-fill",
"group-3-line",
"group-fill",
"group-line",
"men-fill",
"men-line",
"mickey-fill",
"mickey-line",
"open-arm-fill",
"open-arm-line",
"parent-fill",
"parent-line",
"robot-2-fill",
"robot-2-line",
"robot-3-fill",
"robot-3-line",
"robot-fill",
"robot-line",
"skull-2-fill",
"skull-2-line",
"skull-fill",
"skull-line",
"spy-fill",
"spy-line",
"star-smile-fill",
"star-smile-line",
"team-fill",
"team-line",
"travesti-fill",
"travesti-line",
"user-2-fill",
"user-2-line",
"user-3-fill",
"user-3-line",
"user-4-fill",
"user-4-line",
"user-5-fill",
"user-5-line",
"user-6-fill",
"user-6-line",
"user-add-fill",
"user-add-line",
"user-community-fill",
"user-community-line",
"user-fill",
"user-follow-fill",
"user-follow-line",
"user-forbid-fill",
"user-forbid-line",
"user-heart-fill",
"user-heart-line",
"user-line",
"user-location-fill",
"user-location-line",
"user-minus-fill",
"user-minus-line",
"user-received-2-fill",
"user-received-2-line",
"user-received-fill",
"user-received-line",
"user-search-fill",
"user-search-line",
"user-settings-fill",
"user-settings-line",
"user-shared-2-fill",
"user-shared-2-line",
"user-shared-fill",
"user-shared-line",
"user-smile-fill",
"user-smile-line",
"user-star-fill",
"user-star-line",
"user-unfollow-fill",
"user-unfollow-line",
"user-voice-fill",
"user-voice-line",
"women-fill",
"women-line"
],
"Weather": [
"blaze-fill",
"blaze-line",
"celsius-fill",
"celsius-line",
"cloud-windy-fill",
"cloud-windy-line",
"cloudy-2-fill",
"cloudy-2-line",
"cloudy-fill",
"cloudy-line",
"drizzle-fill",
"drizzle-line",
"earthquake-fill",
"earthquake-line",
"fahrenheit-fill",
"fahrenheit-line",
"fire-fill",
"fire-line",
"flashlight-fill",
"flashlight-line",
"flood-fill",
"flood-line",
"foggy-fill",
"foggy-line",
"hail-fill",
"hail-line",
"haze-2-fill",
"haze-2-line",
"haze-fill",
"haze-line",
"heavy-showers-fill",
"heavy-showers-line",
"meteor-fill",
"meteor-line",
"mist-fill",
"mist-line",
"moon-clear-fill",
"moon-clear-line",
"moon-cloudy-fill",
"moon-cloudy-line",
"moon-fill",
"moon-foggy-fill",
"moon-foggy-line",
"moon-line",
"rainbow-fill",
"rainbow-line",
"rainy-fill",
"rainy-line",
"shining-2-fill",
"shining-2-line",
"shining-fill",
"shining-line",
"showers-fill",
"showers-line",
"snowflake-fill",
"snowflake-line",
"snowy-fill",
"snowy-line",
"sparkling-2-fill",
"sparkling-2-line",
"sparkling-fill",
"sparkling-line",
"sun-cloudy-fill",
"sun-cloudy-line",
"sun-fill",
"sun-foggy-fill",
"sun-foggy-line",
"sun-line",
"temp-cold-fill",
"temp-cold-line",
"temp-hot-fill",
"temp-hot-line",
"thunderstorms-fill",
"thunderstorms-line",
"tornado-fill",
"tornado-line",
"typhoon-fill",
"typhoon-line",
"water-percent-fill",
"water-percent-line",
"windy-fill",
"windy-line"
]
}

View File

@@ -0,0 +1,389 @@
<template>
<el-dialog
v-model="visible"
title="选择图片"
width="1024px"
:close-on-click-modal="false"
@closed="onClosed"
>
<div class="resource-dialog">
<!-- 搜索和筛选 -->
<div class="dialog-header">
<div class="flex items-center justify-between gap-2 flex-1">
<el-tree-select
class="search-tree"
v-model="searchForm.category_id"
:data="categoryList"
:render-after-expand="false"
check-strictly
clearable
/>
<el-input v-model="searchForm.keywords" placeholder="搜索图片名称" clearable>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
<el-button type="primary" :icon="Refresh" @click="loadResources"> 搜索 </el-button>
<el-upload
class="upload-btn"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/*"
>
<el-button type="success" :icon="UploadFilled">上传图片</el-button>
</el-upload>
</div>
<!-- 图片列表 -->
<div class="image-list">
<div v-loading="loading" class="image-grid">
<div
v-for="item in imageList"
:key="item.id"
class="image-item"
:class="{ selected: selectedIds.has(item.id) }"
@click="selectImage(item)"
>
<el-image :src="item.url" fit="cover" class="grid-image" />
<div class="image-info">
<div class="image-name" :title="item.origin_name">{{ item.origin_name }}</div>
<div class="image-size">{{ item.size_info }}</div>
</div>
<div v-if="selectedIds.has(item.id)" class="selected-badge">
<el-icon><Check /></el-icon>
</div>
</div>
<!-- 空状态 -->
<el-empty
v-if="!loading && imageList.length === 0"
description="暂无图片资源"
class="empty-placeholder"
/>
</div>
</div>
<!-- 分页 -->
<div class="pagination">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[14, 28, 42, 56]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadResources"
@size-change="loadResources"
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="confirmSelection" :disabled="selectedItems.length === 0">
确定
</el-button>
</template>
</el-dialog>
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue'
import { Search, Refresh, Check, UploadFilled } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions, UploadProps } from 'element-plus'
import { getResourceCategory, getResourceList, uploadImage } from '@/api/auth'
defineOptions({ name: 'SaImageDialog' })
// Props 定义
interface Props {
multiple?: boolean // 是否多选
limit?: number // 多选限制
initialUrls?: string | string[] // 初始选中的 URL用于回显
}
const props = withDefaults(defineProps<Props>(), {
multiple: false,
limit: 3,
initialUrls: ''
})
// visible 使用 defineModel
const visible = defineModel<boolean>('visible', { default: false })
// Emits 定义
const emit = defineEmits<{
confirm: [value: string | string[]]
}>()
// 图片资源接口
interface ImageResource {
id: string | number
origin_name: string
url: string
size_info: string
type?: string
createTime?: string
}
// 状态
const loading = ref(false)
const searchForm = ref({
keywords: '',
category_id: null
})
const selectedIds = ref<Set<string | number>>(new Set())
const unselectedIds = ref<Set<string | number>>(new Set())
const selectedItems = ref<ImageResource[]>([])
const imageList = ref<ImageResource[]>([])
const categoryList = ref<any>([])
const currentPage = ref(1)
const pageSize = ref(14)
const total = ref(0)
// 监听弹窗打开
watch(
() => visible.value,
(newVal) => {
if (newVal) {
// 初始化选中状态
selectedIds.value.clear()
unselectedIds.value.clear()
selectedItems.value = []
if (imageList.value.length === 0) {
loadResources()
} else {
syncSelectionFromInitial()
}
}
}
)
// 根据 initialUrls 同步选中状态
const syncSelectionFromInitial = () => {
const urls = Array.isArray(props.initialUrls)
? props.initialUrls
: props.initialUrls
? [props.initialUrls]
: []
if (!urls.length) return
imageList.value.forEach((item) => {
if (
urls.includes(item.url) &&
!unselectedIds.value.has(item.id) &&
!selectedIds.value.has(item.id)
) {
selectedIds.value.add(item.id)
selectedItems.value.push(item)
}
})
}
// 加载资源列表
const loadResources = async () => {
loading.value = true
try {
const category = await getResourceCategory({ tree: 'true' })
categoryList.value = category || []
const response: any = await getResourceList({
page: currentPage.value,
limit: pageSize.value,
object_name: searchForm.value.keywords,
category_id: searchForm.value.category_id
})
const data = response
imageList.value = data?.data || []
total.value = data?.total || imageList.value.length
syncSelectionFromInitial()
} catch (error: any) {
console.error('加载图片资源失败:', error)
ElMessage.error('加载图片资源失败: ' + (error.message || ''))
} finally {
loading.value = false
}
}
// 选择图片
const selectImage = (item: ImageResource) => {
if (props.multiple) {
if (selectedIds.value.has(item.id)) {
selectedIds.value.delete(item.id)
unselectedIds.value.add(item.id)
const index = selectedItems.value.findIndex((i) => i.id === item.id)
if (index > -1) selectedItems.value.splice(index, 1)
} else {
if (selectedIds.value.size >= props.limit) {
ElMessage.warning(`最多只能选择 ${props.limit} 张图片`)
return
}
selectedIds.value.add(item.id)
unselectedIds.value.delete(item.id)
selectedItems.value.push(item)
}
} else {
selectedIds.value.clear()
selectedItems.value = []
selectedIds.value.add(item.id)
selectedItems.value.push(item)
}
}
// 确认选择
const confirmSelection = () => {
if (selectedItems.value.length === 0) {
ElMessage.warning('请选择图片')
return
}
const urls = selectedItems.value.map((item) => item.url)
const result = props.multiple ? urls : urls[0]
emit('confirm', result)
visible.value = false
ElMessage.success('图片选择成功')
}
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
// 处理上传
const handleUpload = async (options: UploadRequestOptions) => {
const { file } = options
loading.value = true
try {
const formData = new FormData()
formData.append('file', file)
formData.append('category_id', searchForm.value.category_id || '1')
await uploadImage(formData)
ElMessage.success('上传成功')
currentPage.value = 1
await loadResources()
} catch (error: any) {
console.error('上传失败:', error)
ElMessage.error(error.message || '上传失败')
} finally {
loading.value = false
}
}
// 弹窗关闭时重置
const onClosed = () => {
// 可选:重置搜索等状态
}
</script>
<style scoped lang="scss">
.resource-dialog {
.dialog-header {
display: flex;
gap: 10px;
margin-bottom: 20px;
.search-tree {
width: 250px;
}
}
.image-list {
min-height: 450px;
max-height: 660px;
overflow-y: auto;
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
padding: 10px;
.image-item {
position: relative;
border: 2px solid transparent;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: all 0.3s;
background: #f5f7fa;
&:hover {
border-color: var(--el-color-primary);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.grid-image {
width: 100%;
height: 100px;
}
.image-info {
padding: 4px 8px;
background: #fff;
.image-name {
font-size: 12px;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.image-size {
font-size: 11px;
color: #909399;
}
}
.selected-badge {
position: absolute;
top: 8px;
right: 8px;
width: 24px;
height: 24px;
background: var(--el-color-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 14px;
}
}
.empty-placeholder {
grid-column: 1 / -1;
}
}
.pagination {
margin-top: 0px;
display: flex;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<div class="sa-image-picker" :style="containerStyle">
<!-- 多选模式下的图片列表 -->
<div
v-if="multiple && Array.isArray(selectedImage) && selectedImage.length > 0"
class="image-list-display"
>
<div
v-for="(url, index) in selectedImage"
:key="url"
class="picker-trigger mini"
:class="{ round: round }"
>
<el-image :src="url" fit="cover" class="preview-image" />
<div class="image-mask" :class="{ round: round }">
<el-icon class="mask-icon" @click.stop="handlePreview(index)"><ZoomIn /></el-icon>
<el-icon class="mask-icon" @click.stop="removeImage(index)"><Delete /></el-icon>
</div>
</div>
<!-- 添加按钮 -->
<div
v-if="selectedImage.length < limit"
class="picker-trigger mini add-btn"
:class="{ round: round }"
@click="openDialog"
>
<el-icon class="picker-icon"><Plus /></el-icon>
</div>
</div>
<!-- 单选模式或空状态 -->
<div v-else class="picker-trigger" :style="triggerStyle" :class="{ round: round }">
<div
v-if="!selectedImage || (Array.isArray(selectedImage) && selectedImage.length === 0)"
class="empty-state"
@click="openDialog"
>
<el-icon class="picker-icon"><Plus v-if="multiple" /><Picture v-else /></el-icon>
<div class="picker-text">点击选择</div>
</div>
<div v-else class="selected-image">
<el-image
:src="Array.isArray(selectedImage) ? selectedImage[0] : selectedImage"
fit="cover"
class="preview-image"
:class="{ round: round }"
/>
<div class="image-mask" :class="{ round: round }">
<el-icon class="mask-icon" @click.stop="handlePreview(0)"><ZoomIn /></el-icon>
<el-icon class="mask-icon" @click.stop="openDialog"><Edit /></el-icon>
<el-icon class="mask-icon" @click.stop="clearImage"><Delete /></el-icon>
</div>
</div>
</div>
<!-- 使用独立的图片选择弹窗组件 -->
<SaImageDialog
v-model:visible="dialogVisible"
:multiple="multiple"
:limit="limit"
:initial-urls="modelValue"
@confirm="onDialogConfirm"
/>
<!-- 图片预览 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewList"
:initial-index="previewIndex"
@close="closePreview"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue'
import { Picture, Delete, Edit, ZoomIn, Plus } from '@element-plus/icons-vue'
import SaImageDialog from '@/components/sai/sa-image-dialog/index.vue'
defineOptions({ name: 'SaImagePicker' })
// Props 定义
interface Props {
modelValue?: string | string[] // v-model 绑定值
placeholder?: string // 占位符文本
multiple?: boolean // 是否多选
limit?: number // 多选限制
round?: boolean // 是否圆角
width?: string | number // 宽度
height?: string | number // 高度
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: '点击选择图片',
multiple: false,
limit: 3,
round: false,
width: '120px',
height: '120px'
})
// 计算容器样式
const containerStyle = computed(() => {
return {
width: typeof props.width === 'number' ? `${props.width}px` : props.width,
height: typeof props.height === 'number' ? `${props.height}px` : props.height
}
})
// 计算触发器样式
const triggerStyle = computed(() => {
return {
width: '100%',
height: '100%'
}
})
// Emits 定义
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
change: [value: string | string[]]
}>()
// 状态
const dialogVisible = ref(false)
const previewVisible = ref(false)
const previewIndex = ref(0)
const selectedImage = ref<string | string[]>(props.modelValue)
// 监听 modelValue 变化
watch(
() => props.modelValue,
(newVal) => {
if (Array.isArray(newVal)) {
selectedImage.value = [...newVal]
} else {
selectedImage.value = newVal
}
},
{ deep: true, immediate: true }
)
// 打开对话框
const openDialog = () => {
dialogVisible.value = true
}
// 弹窗确认回调
const onDialogConfirm = (result: string | string[]) => {
selectedImage.value = result
emit('update:modelValue', result)
emit('change', result)
}
// 清除图片
const clearImage = () => {
selectedImage.value = props.multiple ? [] : ''
emit('update:modelValue', selectedImage.value)
emit('change', selectedImage.value)
}
// 移除单个图片(多选模式)
const removeImage = (index: number) => {
if (Array.isArray(selectedImage.value)) {
const newList = [...selectedImage.value]
newList.splice(index, 1)
selectedImage.value = newList
emit('update:modelValue', newList)
emit('change', newList)
}
}
// 预览处理
const handlePreview = (index: number = 0) => {
if (selectedImage.value) {
previewIndex.value = index
previewVisible.value = true
}
}
// 计算预览列表
const previewList = computed(() => {
if (Array.isArray(selectedImage.value)) {
return selectedImage.value
}
return selectedImage.value ? [selectedImage.value] : []
})
const closePreview = () => {
previewVisible.value = false
}
</script>
<style scoped lang="scss">
.sa-image-picker {
width: 100%;
height: 100%;
.image-list-display {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.picker-trigger {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
transition: var(--el-transition-duration-fast);
overflow: hidden;
position: relative;
box-sizing: border-box;
&:hover {
border-color: var(--el-color-primary);
}
&.mini {
width: 60px;
height: 60px;
}
&.round {
border-radius: 50%;
&.mini {
border-radius: 50%;
}
}
&.add-btn {
display: flex;
align-items: center;
justify-content: center;
.picker-icon {
font-size: 28px;
color: #8c939d;
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
&.round {
border-radius: 50%;
}
.picker-icon {
font-size: clamp(20px, 3vw, 28px);
color: #8c939d;
margin-bottom: 4px;
}
.picker-text {
font-size: clamp(10px, 2vw, 12px);
color: #606266;
}
}
.selected-image {
width: 100%;
height: 100%;
position: relative;
&.round {
border-radius: 50%;
}
.preview-image {
width: 100%;
height: 100%;
&.round {
border-radius: 50%;
}
}
}
.image-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s;
z-index: 10;
&.round {
border-radius: 50%;
}
.mask-icon {
font-size: clamp(16px, 2vw, 20px);
color: #fff;
cursor: pointer;
transition: transform 0.2s;
&:hover {
transform: scale(1.2);
}
}
}
&:hover .image-mask {
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<div class="sa-image-upload">
<el-upload
ref="uploadRef"
:file-list="fileList"
:limit="limit"
:multiple="multiple"
:accept="accept"
:list-type="listType"
:http-request="handleUpload"
:before-upload="beforeUpload"
:on-remove="handleRemove"
:on-preview="handlePreview"
:on-exceed="handleExceed"
:disabled="disabled"
class="upload-container"
:class="{ 'is-round': round, 'hide-upload': hideUploadTrigger }"
>
<template #default>
<div
class="upload-trigger"
:style="{ width: width + 'px', height: height + 'px' }"
v-if="!hideUploadTrigger"
>
<el-icon class="upload-icon"><Plus /></el-icon>
<div class="upload-text">上传图片</div>
</div>
</template>
<template #tip>
<div class="el-upload__tip" v-if="showTips">
单个文件不超过 {{ maxSize }}MB最多上传 {{ limit }}
</div>
</template>
</el-upload>
<!-- 图片预览器 -->
<el-image-viewer
v-if="previewVisible"
:url-list="previewUrlList"
:initial-index="previewIndex"
@close="handleCloseViewer"
:hide-on-click-modal="true"
:teleported="true"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, watch, computed } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadProps, UploadUserFile, UploadRequestOptions } from 'element-plus'
import { uploadImage } from '@/api/auth'
defineOptions({ name: 'SaImageUpload' })
// 定义 Props
interface Props {
modelValue?: string | string[] // v-model 绑定值
multiple?: boolean // 是否支持多选
limit?: number // 最大上传数量
maxSize?: number // 最大文件大小(MB)
accept?: string // 接受的文件类型
disabled?: boolean // 是否禁用
listType?: 'text' | 'picture' | 'picture-card' // 文件列表类型
width?: number // 上传区域宽度(px)
height?: number // 上传区域高度(px)
round?: boolean // 是否圆形
showTips?: boolean // 是否显示上传提示
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
multiple: false,
limit: 1,
maxSize: 5,
accept: 'image/*',
disabled: false,
listType: 'picture-card',
width: 148,
height: 148,
round: false,
showTips: true
})
// 定义 Emits
const emit = defineEmits<{
'update:modelValue': [value: string | string[]]
success: [response: any]
error: [error: any]
change: [value: string | string[]]
}>()
// 状态
const uploadRef = ref()
const fileList = ref<UploadUserFile[]>([])
const previewVisible = ref(false)
const previewIndex = ref(0)
// 计算预览图片列表
const previewUrlList = computed(() => {
return fileList.value.map((file) => file.url).filter((url) => url) as string[]
})
// 计算是否隐藏上传按钮(单图片模式且已有图片时隐藏)
const hideUploadTrigger = computed(() => {
return !props.multiple && fileList.value.length >= props.limit
})
// 监听 modelValue 变化,同步到 fileList
watch(
() => props.modelValue,
(newVal) => {
if (!newVal || (Array.isArray(newVal) && newVal.length === 0)) {
fileList.value = []
uploadRef.value?.clearFiles()
return
}
const urls = Array.isArray(newVal) ? newVal : [newVal]
fileList.value = urls
.filter((url) => url)
.map((url, index) => ({
name: `image-${index + 1}`,
url: url,
uid: Date.now() + index
}))
},
{ immediate: true }
)
// 上传前验证
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
// 验证文件类型
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
// 验证文件大小
const isLtMaxSize = file.size / 1024 / 1024 < props.maxSize
if (!isLtMaxSize) {
ElMessage.error(`图片大小不能超过 ${props.maxSize}MB!`)
return false
}
return true
}
// 自定义上传
const handleUpload = async (options: UploadRequestOptions) => {
const { file, onSuccess, onError } = options
try {
// 创建 FormData
const formData = new FormData()
formData.append('file', file)
// 调用上传接口
const response: any = await uploadImage(formData)
// 尝试从不同的响应格式中获取图片URL
const imageUrl = response?.data?.url || response?.data || response?.url || ''
if (!imageUrl) {
throw new Error('上传失败,未返回图片地址')
}
// 更新文件列表
const newFile: UploadUserFile = {
name: file.name,
url: imageUrl,
uid: file.uid
}
fileList.value.push(newFile)
updateModelValue()
// 触发成功回调
onSuccess?.(response)
emit('success', response)
ElMessage.success('上传成功!')
} catch (error: any) {
console.error('上传失败:', error)
onError?.(error)
emit('error', error)
ElMessage.error(error.message || '上传失败!')
}
}
// 删除文件
const handleRemove: UploadProps['onRemove'] = (file) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid)
if (index > -1) {
fileList.value.splice(index, 1)
updateModelValue()
}
}
// 超出限制提示
const handleExceed: UploadProps['onExceed'] = () => {
ElMessage.warning(`最多只能上传 ${props.limit} 张图片,请先删除已有图片后再上传`)
}
// 预览图片
const handlePreview: UploadProps['onPreview'] = (file) => {
const index = fileList.value.findIndex((item) => item.uid === file.uid)
previewIndex.value = index > -1 ? index : 0
previewVisible.value = true
}
// 关闭预览器
const handleCloseViewer = () => {
previewVisible.value = false
}
// 更新 v-model 值
const updateModelValue = () => {
const urls = fileList.value.map((file) => file.url).filter((url) => url) as string[]
if (props.multiple) {
emit('update:modelValue', urls)
emit('change', urls)
} else {
emit('update:modelValue', urls[0] || '')
emit('change', urls[0] || '')
}
}
</script>
<style scoped lang="scss">
.sa-image-upload {
.upload-container {
:deep(.el-upload) {
border: 1px dashed var(--el-border-color);
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: var(--el-transition-duration-fast);
width: v-bind('width + "px"');
height: v-bind('height + "px"');
&:hover {
border-color: var(--el-color-primary);
}
}
:deep(.el-icon--close-tip) {
display: none !important;
}
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
width: v-bind('width + "px"');
height: v-bind('height + "px"');
transition: all 0.3s;
&:hover {
transform: scale(1.05);
}
}
}
&.is-round {
:deep(.el-upload) {
border-radius: 50%;
}
:deep(.el-upload-list--picture-card) {
.el-upload-list__item {
border-radius: 50%;
}
}
}
&.hide-upload {
:deep(.el-upload) {
display: none;
}
}
}
.upload-trigger {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.upload-icon {
font-size: 28px;
color: #8c939d;
margin-bottom: 8px;
}
.upload-text {
font-size: 14px;
color: #606266;
}
}
.el-upload__tip {
font-size: 12px;
color: #909399;
margin-top: 7px;
line-height: 1.5;
}
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<div class="sa-import-wrap" @click="open">
<div class="trigger">
<slot>
<ElButton :icon="Upload">
{{ title }}
</ElButton>
</slot>
</div>
<el-dialog
v-model="visible"
:title="title"
:width="width"
append-to-body
destroy-on-close
class="sa-import-dialog"
:close-on-click-modal="false"
>
<div class="import-container">
<el-upload
ref="uploadRef"
class="upload-area"
drag
action="#"
:accept="accept"
:limit="1"
:auto-upload="true"
:on-exceed="handleExceed"
:on-remove="handleRemove"
v-model:file-list="fileList"
:http-request="customUpload"
>
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
<div class="el-upload__text"> 将文件拖到此处 <em>点击上传</em> </div>
<template #tip>
<div class="el-upload__tip">
{{ tip || `请上传 ${accept.replace(/,/g, '/')} 格式文件` }}
</div>
</template>
</el-upload>
<div class="template-download" v-if="showTemplate">
<el-link type="primary" :underline="false" @click="downloadTemplate">
<el-icon class="el-icon--left"><Download /></el-icon> 下载导入模板
</el-link>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import axios from 'axios'
import { ref, computed } from 'vue'
import { useUserStore } from '@/store/modules/user'
import { UploadFilled, Upload, Download } from '@element-plus/icons-vue'
import { ElMessage, genFileId } from 'element-plus'
import type {
UploadInstance,
UploadProps,
UploadRawFile,
UploadUserFile,
UploadRequestOptions
} from 'element-plus'
defineOptions({ name: 'SaImport' })
const props = withDefaults(
defineProps<{
title?: string
width?: string | number
uploadUrl?: string
downloadUrl?: string
accept?: string
tip?: string
data?: Record<string, any>
}>(),
{
title: '导入',
width: '600px',
accept: '.xlsx,.xls'
}
)
const emit = defineEmits<{
success: [response: any]
error: [error: any]
'download-template': []
}>()
const visible = ref(false)
const loading = ref(false)
const uploadRef = ref<UploadInstance>()
const fileList = ref<UploadUserFile[]>([])
const showTemplate = computed(() => {
return props.downloadUrl
})
const open = () => {
visible.value = true
fileList.value = []
}
const handleExceed: UploadProps['onExceed'] = (files) => {
uploadRef.value!.clearFiles()
const file = files[0] as UploadRawFile
file.uid = genFileId()
uploadRef.value!.handleStart(file)
}
const handleRemove = () => {
fileList.value = []
}
const customUpload = async (options: UploadRequestOptions) => {
if (!props.uploadUrl) {
ElMessage.error('未配置上传接口')
options.onError('未配置上传接口' as any)
return
}
try {
loading.value = true
const formData = new FormData()
formData.append('file', options.file)
if (props.data) {
Object.keys(props.data).forEach((key) => {
formData.append(key, props.data![key])
})
}
const { VITE_API_URL } = import.meta.env
const { accessToken } = useUserStore()
axios.defaults.baseURL = VITE_API_URL
const res = await axios.post(props.uploadUrl, formData, {
headers: {
Authorization: `Bearer ` + accessToken,
'Content-Type': 'multipart/form-data'
}
})
ElMessage.success(res?.data?.msg || '导入成功')
emit('success', res.data)
visible.value = false
} catch (error: any) {
console.error(error)
ElMessage.error(error?.response?.data?.msg || '导入失败')
emit('error', error)
} finally {
loading.value = false
}
}
const downloadTemplate = async () => {
if (props.downloadUrl) {
try {
const { VITE_API_URL } = import.meta.env
const { accessToken } = useUserStore()
axios.defaults.baseURL = VITE_API_URL
const config = {
method: 'post',
url: props.downloadUrl,
data: props.data,
responseType: 'blob' as const,
headers: {
Authorization: accessToken ? `Bearer ${accessToken}` : undefined
}
}
const res = await axios(config)
const blob = new Blob([res.data], { type: 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '导入模板.xlsx'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (error) {
console.error(error)
ElMessage.error('下载模板失败')
}
} else {
emit('download-template')
}
}
defineExpose({
open
})
</script>
<style scoped lang="scss">
.sa-import-wrap {
display: inline-block;
}
.import-container {
padding: 10px 0;
position: relative;
.upload-area {
:deep(.el-upload-dragger) {
width: 100%;
}
}
.template-download {
margin-top: 10px;
text-align: right;
display: flex;
justify-content: flex-end;
align-items: center;
}
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="flex items-center">
<span class="label">{{ props.label }}</span>
<el-tooltip :content="props.tooltip" :placement="props.placement">
<el-icon class="ml-0.5">
<QuestionFilled />
</el-icon>
</el-tooltip>
</div>
</template>
<script lang="ts" setup>
import { QuestionFilled } from '@element-plus/icons-vue'
defineOptions({ name: 'SaLabel', inheritAttrs: false })
interface Props {
/** 标签内容 */
label: string
/** 提示内容 */
tooltip?: string
/** 提示位置 */
placement?: 'top' | 'bottom' | 'left' | 'right'
}
const props = withDefaults(defineProps<Props>(), {
tooltip: '',
placement: 'top'
})
</script>

View File

@@ -0,0 +1,113 @@
<!-- Markdown编辑器封装 -->
<template>
<div style="width: 100%">
<MdEditor
ref="editorRef"
v-model="modelValue"
:theme="theme"
previewTheme="github"
:toolbars="toolbars"
:preview="preview"
:style="{ height: height, minHeight: minHeight }"
>
<template #defToolbars>
<NormalToolbar title="图片" @onClick="openImageDialog">
<template #trigger>
<el-icon><Picture /></el-icon>
</template>
</NormalToolbar>
</template>
</MdEditor>
<SaImageDialog
v-model:visible="imageDialogVisible"
:multiple="true"
:limit="10"
@confirm="onImageSelect"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { MdEditor, NormalToolbar } from 'md-editor-v3'
import type { ExposeParam } from 'md-editor-v3'
import 'md-editor-v3/lib/style.css'
import { useSettingStore } from '@/store/modules/setting'
import { Picture } from '@element-plus/icons-vue'
import SaImageDialog from '@/components/sai/sa-image-dialog/index.vue'
defineOptions({ name: 'SaMdEditor' })
const settingStore = useSettingStore()
interface Props {
height?: string
preview?: boolean
minHeight?: string
}
withDefaults(defineProps<Props>(), {
height: '500px',
minHeight: '500px',
preview: true
})
const modelValue = defineModel<string>({ default: '' })
const editorRef = ref<ExposeParam>()
// 主题处理
const theme = computed(() => (settingStore.isDark ? 'dark' : 'light'))
// 图片弹窗
const imageDialogVisible = ref(false)
const openImageDialog = () => {
imageDialogVisible.value = true
}
const onImageSelect = (urls: string | string[]) => {
const urlList = Array.isArray(urls) ? urls : [urls]
const markdownImages = urlList.map((url) => `![](${url})`).join('\n')
editorRef.value?.insert(() => {
// 插入图片并配置光标位置
return {
targetValue: markdownImages,
select: true,
deviationStart: 0,
deviationEnd: 0
}
})
}
const toolbars = [
'bold',
'underline',
'italic',
'-',
'title',
'strikeThrough',
'sub',
'sup',
'quote',
'unorderedList',
'orderedList',
'task',
'-',
'codeRow',
'code',
'link',
0,
'table',
'mermaid',
'katex',
'-',
'revoke',
'next',
'=',
'pageFullscreen',
'preview',
'previewOnly',
'catalog'
] as any[]
</script>

View File

@@ -0,0 +1,126 @@
<template>
<el-radio-group
v-model="modelValue"
v-bind="$attrs"
:disabled="disabled"
:size="size"
:fill="fill"
:text-color="textColor"
>
<!-- 模式1: 按钮样式 -->
<template v-if="type === 'button'">
<el-radio-button v-if="allowNull" :value="nullValue" :label="nullLabel">
{{ nullLabel }}
</el-radio-button>
<el-radio-button
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</el-radio-button>
</template>
<!-- 模式2: 普通/边框样式 -->
<template v-else>
<el-radio v-if="allowNull" :value="nullValue" :label="nullLabel" :border="type === 'border'">
{{ nullLabel }}
</el-radio>
<el-radio
v-for="(item, index) in options"
:key="index"
:value="item.value"
:label="item.value"
:border="type === 'border'"
:disabled="item.disabled"
>
{{ item.label }}
</el-radio>
</template>
</el-radio-group>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaRadio', inheritAttrs: false })
interface Props {
dict: string
type?: 'radio' | 'button' | 'border'
disabled?: boolean
size?: 'large' | 'default' | 'small'
fill?: string
textColor?: string
allowNull?: boolean
nullValue?: string | number
nullLabel?: string
/**
* 强制转换字典值的类型
* 可选值: 'number' | 'string'
* 默认使用 'number'
*/
valueType?: 'number' | 'string'
}
const props = withDefaults(defineProps<Props>(), {
type: 'radio',
disabled: false,
size: 'default',
fill: '',
textColor: '',
allowNull: false,
nullValue: '',
nullLabel: '全部',
valueType: 'number' // 默认不转换
})
// 这里支持泛型,保证外部接收到的类型是正确的
const modelValue = defineModel<string | number | undefined>()
const dictStore = useDictStore()
// 判断能否转成数字
const canConvertToNumberStrict = (value: any) => {
// 严格模式:排除 null、undefined、布尔值、空数组等
if (value == null) return false
if (typeof value === 'boolean') return false
if (Array.isArray(value) && value.length !== 1) return false
if (typeof value === 'object' && !Array.isArray(value)) return false
const num = Number(value)
return !isNaN(num)
}
// 核心逻辑:在 computed 中处理数据类型转换
const options = computed(() => {
const list = dictStore.getByCode(props.dict) || []
// 如果没有指定 valueType直接返回原始字典
if (!props.valueType) return list
// 如果指定了类型,进行映射转换
return list.map((item) => {
let newValue = item.value
switch (props.valueType) {
case 'number':
if (canConvertToNumberStrict(item.value)) {
newValue = Number(item.value)
}
break
case 'string':
newValue = String(item.value)
break
}
return {
...item,
value: newValue
}
})
})
</script>

View File

@@ -0,0 +1,236 @@
<!-- 表格搜索组件 -->
<!-- 支持常用表单组件自定义组件插槽校验隐藏表单项 -->
<!-- 写法同 ElementPlus 官方文档组件把属性写在 props 里面就可以了 -->
<template>
<section class="art-search-bar art-card-sm" :class="{ 'is-expanded': isExpanded }">
<ElForm
ref="formRef"
:model="modelValue"
:label-position="labelPosition"
v-bind="{ ...$attrs }"
>
<ElRow :gutter="gutter">
<slot name="default" />
<ElCol :xs="24" :sm="24" :md="6" :lg="6" :xl="6" 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>
<template #icon>
<ArtSvgIcon icon="ri:reset-right-line" />
</template>
{{ t('table.searchBar.reset') }}
</ElButton>
<ElButton
v-if="showSearch"
type="primary"
class="search-button"
@click="handleSearch"
v-ripple
:disabled="disabledSearch"
>
<template #icon>
<ArtSvgIcon icon="ri:search-line" />
</template>
{{ t('table.searchBar.search') }}
</ElButton>
</div>
<div v-if="showExpand" 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 { FormInstance } from 'element-plus'
defineOptions({ name: 'SaSearchBar' })
const { width } = useWindowSize()
const { t } = useI18n()
const isMobile = computed(() => width.value < 500)
const formInstance = useTemplateRef<FormInstance>('formRef')
// 表单配置
interface SearchBarProps {
/** 表单控件间隙 */
gutter?: number
/** 展开/收起 */
isExpand?: boolean
/** 默认是否展开(仅在 showExpand 为 true 且 isExpand 为 false 时生效) */
defaultExpanded?: boolean
/** 表单域标签的位置 */
labelPosition?: 'left' | 'right' | 'top'
/** 是否需要展示,收起 */
showExpand?: boolean
/** 按钮靠左对齐限制(表单项小于等于该值时) */
buttonLeftLimit?: number
/** 是否显示重置按钮 */
showReset?: boolean
/** 是否显示搜索按钮 */
showSearch?: boolean
/** 是否禁用搜索按钮 */
disabledSearch?: boolean
}
const props = withDefaults(defineProps<SearchBarProps>(), {
items: () => [],
gutter: 12,
isExpand: false,
labelPosition: 'right',
showExpand: true,
defaultExpanded: false,
buttonLeftLimit: 2,
showReset: true,
showSearch: true,
disabledSearch: false
})
interface SearchBarEmits {
(e: 'reset'): void
(e: 'search'): void
(e: 'expand', expanded: boolean): void
}
const emit = defineEmits<SearchBarEmits>()
const modelValue = defineModel<Record<string, any>>({ default: {} })
/**
* 是否展开状态
*/
const isExpanded = ref(props.defaultExpanded)
/**
* 展开/收起按钮文本
*/
const expandToggleText = computed(() => {
return isExpanded.value ? t('table.searchBar.collapse') : t('table.searchBar.expand')
})
/**
* 操作按钮样式
*/
const actionButtonsStyle = computed(() => ({
'justify-content': isMobile.value ? 'flex-end' : 'flex-end'
}))
/**
* 切换展开/收起状态
*/
const toggleExpand = () => {
isExpanded.value = !isExpanded.value
emit('expand', isExpanded.value)
}
/**
* 处理重置事件
*/
const handleReset = () => {
// 触发 reset 事件
emit('reset')
}
/**
* 处理搜索事件
*/
const handleSearch = () => {
emit('search')
}
defineExpose({
ref: formInstance,
reset: handleReset
})
// 解构 props 以便在模板中直接使用
const { gutter, labelPosition } = 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,125 @@
<template>
<!--
v-bind="$attrs" 透传父组件传递的 width, class, style 以及 change, focus 等事件
-->
<el-select
v-model="modelValue"
v-bind="$attrs"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="filterable"
:multiple="multiple"
:collapse-tags="collapseTags"
:collapse-tags-tooltip="collapseTagsTooltip"
>
<!-- 遍历生成选项 -->
<el-option
v-for="(item, index) in options"
:key="index"
:label="item.label"
:value="item.value"
:disabled="item.disabled"
>
<!-- 支持自定义 option 模板 (可选)不传则只显示 label -->
<slot name="option" :item="item">
<span>{{ item.label }}</span>
</slot>
</el-option>
<!-- 透传 el-select 的其他插槽 ( prefix, empty) -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot v-if="name !== 'option'" :name="name" v-bind="slotData || {}"></slot>
</template>
</el-select>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useDictStore } from '@/store/modules/dict'
defineOptions({ name: 'SaSelect', inheritAttrs: false })
interface Props {
/** 字典编码 (必填) */
dict: string
/**
* 强制转换字典值的类型
* 解决后端返回字符串但前端表单需要数字的问题
*/
valueType?: 'number' | 'string'
// --- 以下为常用属性显式定义,为了 IDE 提示友好 ---
placeholder?: string
disabled?: boolean
clearable?: boolean
filterable?: boolean
multiple?: boolean
collapseTags?: boolean
collapseTagsTooltip?: boolean
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择',
disabled: false,
clearable: true, // 下拉框默认开启清除,体验更好
filterable: false, // 下拉框默认关闭搜索
multiple: false,
collapseTags: false,
collapseTagsTooltip: false,
valueType: 'number'
})
// 支持单选(string/number) 或 多选(Array)
const modelValue = defineModel<string | number | Array<string | number>>()
const dictStore = useDictStore()
// 判断能否转成数字
const canConvertToNumberStrict = (value: any) => {
// 严格模式:排除 null、undefined、布尔值、空数组等
if (value == null) return false
if (typeof value === 'boolean') return false
if (Array.isArray(value) && value.length !== 1) return false
if (typeof value === 'object' && !Array.isArray(value)) return false
const num = Number(value)
return !isNaN(num)
}
// 计算属性:获取字典数据并处理类型转换
const options = computed(() => {
const list = dictStore.getByCode(props.dict) || []
// 1. 如果没有指定 valueType直接返回
if (!props.valueType) return list
// 2. 如果指定了类型,进行映射转换
return list.map((item) => {
let newValue = item.value
switch (props.valueType) {
case 'number':
if (canConvertToNumberStrict(item.value)) {
newValue = Number(item.value)
}
break
case 'string':
newValue = String(item.value)
break
}
return {
...item,
value: newValue
}
})
})
</script>
<style scoped>
/* 让 Select 默认宽度占满父容器,通常在表单中体验更好,可视情况删除 */
.el-select {
width: 100%;
}
</style>

View File

@@ -0,0 +1,45 @@
<template>
<el-switch
v-model="modelValue"
v-bind="$attrs"
:loading="props.loading"
:inline-prompt="props.inlinePrompt"
:active-value="props.activeValue"
:inactive-value="props.inactiveValue"
:active-text="props.showText ? props.activeText : undefined"
:inactive-text="props.showText ? props.inactiveText : undefined"
/>
</template>
<script lang="ts" setup>
defineOptions({ name: 'SaSwitch', inheritAttrs: false })
interface Props {
/** 是否显示加载中 */
loading?: boolean
/** 是否在开关内显示文字 */
inlinePrompt?: boolean
/** 打开时的值 */
activeValue?: string | number | boolean
/** 关闭时的值 */
inactiveValue?: string | number | boolean
/** 是否显示文字 */
showText?: boolean
/** 打开时的文字描述 */
activeText?: string
/** 关闭时的文字描述 */
inactiveText?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
inlinePrompt: true,
activeValue: 1,
inactiveValue: 2,
showText: true,
activeText: '启用',
inactiveText: '禁用'
})
const modelValue = defineModel<string | number | boolean | undefined>()
</script>

View File

@@ -0,0 +1,402 @@
<template>
<el-select
v-model="selectedValue"
v-bind="$attrs"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
:filterable="false"
:multiple="multiple"
:collapse-tags="collapseTags"
:collapse-tags-tooltip="collapseTagsTooltip"
:loading="loading"
popper-class="sa-user-select-popper"
@visible-change="handleVisibleChange"
@clear="handleClear"
>
<template #header>
<div class="sa-user-select-header" @click.stop>
<el-input
v-model="searchKeyword"
placeholder="搜索用户名、姓名、手机号"
clearable
@input="handleSearch"
@click.stop
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
</div>
</template>
<!-- 隐藏的选项用于显示已选中的用户 -->
<el-option
v-for="user in selectedUsers"
:key="user.id"
:value="user.id"
:label="user.username"
style="display: none"
/>
<!-- 使用 el-option 包装表格内容 -->
<el-option value="" disabled style="height: auto; padding: 0">
<div class="sa-user-select-table" @click.stop>
<el-table
ref="tableRef"
:data="userList"
@row-click="handleRowClick"
@selection-change="handleSelectionChange"
size="small"
v-loading="loading"
>
<el-table-column
v-if="multiple"
type="selection"
width="45"
:selectable="checkSelectable"
/>
<el-table-column prop="id" label="编号" align="center" width="80" />
<el-table-column prop="avatar" label="头像" width="60">
<template #default="{ row }">
<el-avatar :size="32" :src="row.avatar">
{{ row.username?.charAt(0) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column prop="username" label="用户名" width="100" show-overflow-tooltip />
<el-table-column prop="realname" label="姓名" width="100" show-overflow-tooltip />
<el-table-column prop="phone" label="手机号" width="110" show-overflow-tooltip />
</el-table>
<div class="sa-user-select-pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
layout="total, prev, pager, next"
small
background
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
</div>
</el-option>
<template #empty>
<el-empty description="暂无用户数据" :image-size="60" />
</template>
</el-select>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { getUserList } from '@/api/auth'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { TableInstance } from 'element-plus'
defineOptions({ name: 'SaUser', inheritAttrs: false })
interface UserItem {
id: number
username: string
email: string
phone: string
avatar?: string
status: string
[key: string]: any
}
interface Props {
/** 占位符 */
placeholder?: string
/** 是否禁用 */
disabled?: boolean
/** 是否可清空 */
clearable?: boolean
/** 是否可搜索 */
filterable?: boolean
/** 是否多选 */
multiple?: boolean
/** 多选时是否折叠标签 */
collapseTags?: boolean
/** 多选折叠时是否显示提示 */
collapseTagsTooltip?: boolean
/** 返回值类型:'id' 返回用户ID'object' 返回完整用户对象 */
valueType?: 'id' | 'object'
}
const props = withDefaults(defineProps<Props>(), {
placeholder: '请选择用户',
disabled: false,
clearable: true,
filterable: true,
multiple: false,
collapseTags: true,
collapseTagsTooltip: true,
valueType: 'id'
})
// 支持单选(number/object) 或 多选(Array)
const modelValue = defineModel<number | null | UserItem | Array<number | UserItem>>()
// 内部选中值
const selectedValue = ref<any>()
const searchKeyword = ref('')
const loading = ref(false)
const userList = ref<UserItem[]>([])
const tableRef = ref<TableInstance>()
// 缓存所有已选中的用户信息
const allSelectedUsers = ref<UserItem[]>([])
// 计算已选中的用户列表(用于显示)
const selectedUsers = computed(() => {
if (!selectedValue.value) return []
const selectedIds = props.multiple
? Array.isArray(selectedValue.value)
? selectedValue.value
: []
: [selectedValue.value]
// 从缓存中查找用户信息
return selectedIds
.map((id) => {
const cached = allSelectedUsers.value.find((u) => u.id === id)
if (cached) return cached
// 从当前列表中查找
const fromList = userList.value.find((u) => u.id === id)
if (fromList) {
// 添加到缓存
allSelectedUsers.value.push(fromList)
return fromList
}
// 如果都找不到,返回一个临时对象
return { id, username: `用户${id}`, email: '', phone: '', status: '1' }
})
.filter(Boolean)
})
// 分页参数
const pagination = ref({
page: 1,
limit: 5,
total: 0
})
// 获取用户列表
const fetchUserList = async () => {
loading.value = true
try {
const params: any = {
page: pagination.value.page,
limit: pagination.value.limit
}
// 添加搜索条件
if (searchKeyword.value) {
params.keyword = searchKeyword.value
}
const response = await getUserList(params)
if (response && response.data) {
userList.value = response.data || []
pagination.value.total = response.total || 0
}
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
// 搜索防抖
let searchTimer: any = null
const handleSearch = () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
pagination.value.page = 1
fetchUserList()
}, 300)
}
// 下拉框显示/隐藏
const handleVisibleChange = (visible: boolean) => {
if (visible) {
// 打开时加载数据
fetchUserList()
}
}
// 清空选择
const handleClear = () => {
selectedValue.value = props.multiple ? [] : null
if (tableRef.value) {
if (props.multiple) {
tableRef.value.clearSelection()
} else {
tableRef.value.setCurrentRow(null)
}
}
}
// 单选 - 行点击
const handleRowClick = (row: UserItem) => {
if (!props.multiple) {
handleCurrentChange(row)
}
}
// 单选 - 当前行改变
const handleCurrentChange = (row: UserItem | undefined) => {
if (!props.multiple && row) {
// 添加到缓存
const existingIndex = allSelectedUsers.value.findIndex((u) => u.id === row.id)
if (existingIndex === -1) {
allSelectedUsers.value.push(row)
} else {
allSelectedUsers.value[existingIndex] = row
}
selectedValue.value = props.valueType === 'id' ? row.id : row
}
}
// 多选 - 选择改变
const handleSelectionChange = (selection: UserItem[]) => {
if (props.multiple) {
// 更新缓存
selection.forEach((row) => {
const existingIndex = allSelectedUsers.value.findIndex((u) => u.id === row.id)
if (existingIndex === -1) {
allSelectedUsers.value.push(row)
} else {
allSelectedUsers.value[existingIndex] = row
}
})
selectedValue.value = selection.map((item) => (props.valueType === 'id' ? item.id : item))
}
}
// 检查行是否可选
const checkSelectable = () => {
// 可以根据需要添加更多条件
return !props.disabled
}
// 分页改变
const handlePageChange = () => {
fetchUserList()
}
const handleSizeChange = () => {
pagination.value.page = 1
fetchUserList()
}
// 监听内部选中值变化,同步到 v-model
watch(
selectedValue,
(newVal) => {
modelValue.value = newVal
},
{ deep: true }
)
// 监听 v-model 变化,同步到内部选中值
watch(
() => modelValue.value,
(newVal) => {
selectedValue.value = newVal
},
{ immediate: true, deep: true }
)
// 组件挂载时初始化
onMounted(() => {
// 如果有初始值,加载数据
if (modelValue.value) {
fetchUserList()
}
})
</script>
<style scoped lang="scss">
.sa-user-select-header {
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-light);
}
.sa-user-select-table {
min-height: 480px;
max-height: 600px;
display: flex;
flex-direction: column;
:deep(.el-table) {
.el-table__header-wrapper {
th {
background-color: var(--el-fill-color-light);
}
}
.el-table__row {
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light);
}
}
}
}
.sa-user-select-pagination {
padding: 8px 12px;
border-top: 1px solid var(--el-border-color-light);
background-color: var(--el-fill-color-blank);
display: flex;
justify-content: center;
}
</style>
<style lang="scss">
// 全局样式,不使用 scoped
.sa-user-select-popper {
max-width: 90vw !important;
.el-select-dropdown__item {
height: auto !important;
min-height: 320px !important;
max-height: 360px !important;
padding: 0 !important;
line-height: normal !important;
&.is-disabled {
cursor: default;
background-color: transparent !important;
}
}
.el-select-dropdown__wrap {
max-height: 340px !important;
}
// 确保下拉框列表容器也不限制高度
.el-select-dropdown__list {
padding: 0 !important;
}
// 确保滚动容器正确显示
.el-scrollbar__view {
padding: 0 !important;
}
}
</style>

View File

@@ -0,0 +1,91 @@
import { ref, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
/**
* SaiAdmin Composable
* SaiAdmin状态管理
*/
export function useSaiAdmin() {
// 弹窗相关
const dialogType = ref('add')
const dialogVisible = ref(false)
const dialogData = ref<Partial<Record<string, any>>>({})
// 选中行
const selectedRows = ref<Record<string, any>[]>([])
// 显示弹窗
const showDialog = (type: string, row?: Record<string, any>): void => {
dialogType.value = type
dialogData.value = row || {}
nextTick(() => {
dialogVisible.value = true
})
}
// 隐藏弹窗
const hideDialog = (): void => {
dialogVisible.value = false
}
// 表格行选择变化
const handleSelectionChange = (selection: Record<string, any>[]): void => {
selectedRows.value = selection
}
// 删除数据
const deleteRow = (
row: Record<string, any>,
apiFn: (params: any) => Promise<any>,
callback?: () => void
): void => {
ElMessageBox.confirm(`确定要删除该数据吗?`, '删除数据', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
apiFn({ ids: [row.id] }).then(() => {
ElMessage.success('删除成功')
if (callback) callback()
})
})
}
// 批量删除数据
const deleteSelectedRows = (
apiFn: (params: any) => Promise<any>,
callback?: () => void
): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要删除的行')
return
}
ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 条数据吗?`,
'删除选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
apiFn({ ids: selectedRows.value.map((row) => row.id) }).then(() => {
ElMessage.success('删除成功')
if (callback) callback()
selectedRows.value = []
})
})
}
return {
dialogType,
dialogVisible,
dialogData,
selectedRows,
showDialog,
hideDialog,
handleSelectionChange,
deleteRow,
deleteSelectedRows
}
}

View File

@@ -0,0 +1,61 @@
/**
* 配置图片资源
*
* 统一管理设置中心使用的预览图片资源。
* 包含主题样式、菜单布局、菜单风格的预览图。
*
* ## 图片分类
*
* - themeStyles: 系统主题预览图(亮色/暗色/自动)
* - menuLayouts: 菜单布局预览图(左侧/顶部/混合/双栏)
* - menuStyles: 菜单风格预览图(设计/暗色/亮色)
*
* @module config/assets/images
* @author Art Design Pro Team
*/
import lightTheme from '@imgs/settings/theme_styles/light.png'
import darkTheme from '@imgs/settings/theme_styles/dark.png'
import systemTheme from '@imgs/settings/theme_styles/system.png'
import verticalLayout from '@imgs/settings/menu_layouts/vertical.png'
import horizontalLayout from '@imgs/settings/menu_layouts/horizontal.png'
import mixedLayout from '@imgs/settings/menu_layouts/mixed.png'
import dualColumnLayout from '@imgs/settings/menu_layouts/dual_column.png'
import designStyle from '@imgs/settings/menu_styles/design.png'
import darkStyle from '@imgs/settings/menu_styles/dark.png'
import lightStyle from '@imgs/settings/menu_styles/light.png'
/**
* 配置中心图片资源对象
*/
export const configImages = {
/** 系统主题预览图 */
themeStyles: {
/** 亮色主题 */
light: lightTheme,
/** 暗色主题 */
dark: darkTheme,
/** 自动主题(跟随系统) */
system: systemTheme
},
/** 菜单布局预览图 */
menuLayouts: {
/** 左侧菜单 */
vertical: verticalLayout,
/** 顶部菜单 */
horizontal: horizontalLayout,
/** 混合菜单 */
mixed: mixedLayout,
/** 双栏菜单 */
dualColumn: dualColumnLayout
},
/** 菜单风格预览图 */
menuStyles: {
/** 设计风格 */
design: designStyle,
/** 暗色风格 */
dark: darkStyle,
/** 亮色风格 */
light: lightStyle
}
}

View File

@@ -0,0 +1,79 @@
/**
* 快速入口配置
* 包含:应用列表、快速链接等配置
*/
import { WEB_LINKS } from '@/utils/constants'
import type { FastEnterConfig } from '@/types/config'
const fastEnterConfig: FastEnterConfig = {
// 显示条件(屏幕宽度)
minWidth: 1200,
// 应用列表
applications: [
{
name: '工作台',
description: '系统概览与数据统计',
icon: 'ri:pie-chart-line',
iconColor: '#377dff',
enabled: true,
order: 1,
routeName: 'Console'
},
{
name: '官方文档',
description: '使用指南与开发文档',
icon: 'ri:bill-line',
iconColor: '#ffb100',
enabled: true,
order: 2,
link: WEB_LINKS.DOCS
},
{
name: '技术支持',
description: '技术支持与问题反馈',
icon: 'ri:user-location-line',
iconColor: '#ff6b6b',
enabled: true,
order: 3,
link: WEB_LINKS.COMMUNITY
},
{
name: '哔哩哔哩',
description: '技术分享与交流',
icon: 'ri:bilibili-line',
iconColor: '#FB7299',
enabled: true,
order: 4,
link: WEB_LINKS.BILIBILI
}
],
// 快速链接
quickLinks: [
{
name: '登录',
enabled: true,
order: 1,
routeName: 'Login'
},
{
name: '注册',
enabled: true,
order: 2,
routeName: 'Register'
},
{
name: '忘记密码',
enabled: true,
order: 3,
routeName: 'ForgetPassword'
},
{
name: '个人中心',
enabled: true,
order: 4,
routeName: 'UserCenter'
}
]
}
export default Object.freeze(fastEnterConfig)

View File

@@ -0,0 +1,135 @@
/**
* 系统全局配置
*
* 这是系统的核心配置文件,集中管理所有全局配置项。
* 包含系统信息、主题样式、菜单布局、颜色方案等所有可配置项。
*
* ## 主要功能
*
* - 系统信息 - 系统名称等基础信息
* - 主题配置 - 亮色/暗色/自动主题的样式配置
* - 菜单配置 - 菜单布局、主题、宽度等配置
* - 颜色方案 - 系统主色和预设颜色列表
* - 快速入口 - 快速入口应用和链接配置
* - 顶部栏配置 - 顶部栏功能模块配置
*
* ## 配置项说明
*
* - systemInfo: 系统基础信息(名称等)
* - systemThemeStyles: 系统主题样式映射
* - settingThemeList: 可选的系统主题列表
* - menuLayoutList: 可选的菜单布局列表
* - themeList: 菜单主题样式列表
* - darkMenuStyles: 暗黑模式下的菜单样式
* - systemMainColor: 预设的系统主色列表
* - fastEnter: 快速入口配置
* - headerBar: 顶部栏功能配置
*
* @module config
* @author Art Design Pro Team
*/
import { MenuThemeEnum, MenuTypeEnum, SystemThemeEnum } from '@/enums/appEnum'
import { SystemConfig } from '@/types/config'
import { configImages } from './assets/images'
import fastEnterConfig from './modules/fastEnter'
import { headerBarConfig } from './modules/headerBar'
const appConfig: SystemConfig = {
// 系统信息
systemInfo: {
name: 'SaiAdmin' // 系统名称
},
// 系统主题
systemThemeStyles: {
[SystemThemeEnum.LIGHT]: { className: '' },
[SystemThemeEnum.DARK]: { className: SystemThemeEnum.DARK }
},
// 系统主题列表
settingThemeList: [
{
name: 'Light',
theme: SystemThemeEnum.LIGHT,
color: ['#fff', '#fff'],
leftLineColor: '#EDEEF0',
rightLineColor: '#EDEEF0',
img: configImages.themeStyles.light
},
{
name: 'Dark',
theme: SystemThemeEnum.DARK,
color: ['#22252A'],
leftLineColor: '#3F4257',
rightLineColor: '#3F4257',
img: configImages.themeStyles.dark
},
{
name: 'System',
theme: SystemThemeEnum.AUTO,
color: ['#fff', '#22252A'],
leftLineColor: '#EDEEF0',
rightLineColor: '#3F4257',
img: configImages.themeStyles.system
}
],
// 菜单布局列表
menuLayoutList: [
{ name: 'Left', value: MenuTypeEnum.LEFT, img: configImages.menuLayouts.vertical },
{ name: 'Top', value: MenuTypeEnum.TOP, img: configImages.menuLayouts.horizontal },
{ name: 'Mixed', value: MenuTypeEnum.TOP_LEFT, img: configImages.menuLayouts.mixed },
{ name: 'Dual Column', value: MenuTypeEnum.DUAL_MENU, img: configImages.menuLayouts.dualColumn }
],
// 菜单主题列表
themeList: [
{
theme: MenuThemeEnum.DESIGN,
background: '#FFFFFF',
systemNameColor: 'var(--art-gray-800)',
iconColor: '#6B6B6B',
textColor: '#29343D',
img: configImages.menuStyles.design
},
{
theme: MenuThemeEnum.DARK,
background: '#191A23',
systemNameColor: '#D9DADB',
iconColor: '#BABBBD',
textColor: '#BABBBD',
img: configImages.menuStyles.dark
},
{
theme: MenuThemeEnum.LIGHT,
background: '#ffffff',
systemNameColor: 'var(--art-gray-800)',
iconColor: '#6B6B6B',
textColor: '#29343D',
img: configImages.menuStyles.light
}
],
// 暗黑模式菜单样式
darkMenuStyles: [
{
theme: MenuThemeEnum.DARK,
background: 'var(--default-box-color)',
systemNameColor: '#DDDDDD',
iconColor: '#BABBBD',
textColor: 'rgba(#FFFFFF, 0.7)'
}
],
// 系统主色
systemMainColor: [
'#5D87FF',
'#B48DF3',
'#1D84FF',
'#60C041',
'#38C0FC',
'#F9901F',
'#FF80C8'
] as const,
// 快速入口配置
fastEnter: fastEnterConfig,
// 顶部栏功能配置
headerBar: headerBarConfig
}
export default Object.freeze(appConfig)

View File

@@ -0,0 +1,105 @@
/**
* 全局组件配置
*
* 统一管理系统级全局组件的注册。
* 这些组件会在应用启动时全局注册,可在任何地方使用。
*
* ## 主要功能
*
* - 组件配置 - 集中管理全局组件的配置信息
* - 异步加载 - 使用 defineAsyncComponent 实现按需加载
* - 开关控制 - 支持通过 enabled 字段启用/禁用组件
* - 配置查询 - 提供工具函数快速查询组件配置
*
* @module config/component
* @author Art Design Pro Team
*/
import { defineAsyncComponent } from 'vue'
/**
* 全局组件配置列表
*/
export const globalComponentsConfig: GlobalComponentConfig[] = [
{
name: '设置面板',
key: 'settings-panel',
component: defineAsyncComponent(
() => import('@/components/core/layouts/art-settings-panel/index.vue')
),
enabled: true
},
{
name: '全局搜索',
key: 'global-search',
component: defineAsyncComponent(
() => import('@/components/core/layouts/art-global-search/index.vue')
),
enabled: true
},
{
name: '锁屏',
key: 'screen-lock',
component: defineAsyncComponent(
() => import('@/components/core/layouts/art-screen-lock/index.vue')
),
enabled: true
},
{
name: '聊天窗口',
key: 'chat-window',
component: defineAsyncComponent(
() => import('@/components/core/layouts/art-chat-window/index.vue')
),
enabled: true
},
{
name: '礼花效果',
key: 'fireworks-effect',
component: defineAsyncComponent(
() => import('@/components/core/layouts/art-fireworks-effect/index.vue')
),
enabled: true
},
{
name: '水印效果',
key: 'watermark',
component: defineAsyncComponent(
() => import('@/components/core/others/art-watermark/index.vue')
),
enabled: true
}
]
/**
* 全局组件配置接口
*/
export interface GlobalComponentConfig {
/** 组件名称 */
name: string
/** 组件标识 */
key: string
/** 组件 */
component: any
/** 是否启用 */
enabled?: boolean
/** 组件描述 */
description?: string
}
/**
* 获取启用的全局组件
* @returns 已启用的组件配置列表
*/
export const getEnabledGlobalComponents = () => {
return globalComponentsConfig.filter((config) => config.enabled !== false)
}
/**
* 根据 key 获取组件配置
* @param key 组件标识
* @returns 组件配置对象
*/
export const getGlobalComponentByKey = (key: string) => {
return globalComponentsConfig.find((config) => config.key === key)
}

View File

@@ -0,0 +1,127 @@
/**
* 快速入口配置
* 包含:应用列表、快速链接等配置
*/
import { WEB_LINKS } from '@/utils/constants'
import type { FastEnterConfig } from '@/types/config'
const fastEnterConfig: FastEnterConfig = {
// 显示条件(屏幕宽度)
minWidth: 1200,
// 应用列表
applications: [
{
name: '工作台',
description: '系统概览与数据统计',
icon: 'ri:pie-chart-line',
iconColor: '#377dff',
enabled: true,
order: 1,
routeName: 'Console'
},
{
name: '分析页',
description: '数据分析与可视化',
icon: 'ri:game-line',
iconColor: '#ff3b30',
enabled: true,
order: 2,
routeName: 'Analysis'
},
{
name: '礼花效果',
description: '动画特效展示',
icon: 'ri:loader-line',
iconColor: '#7A7FFF',
enabled: true,
order: 3,
routeName: 'Fireworks'
},
{
name: '聊天',
description: '即时通讯功能',
icon: 'ri:user-line',
iconColor: '#13DEB9',
enabled: true,
order: 4,
routeName: 'Chat'
},
{
name: '官方文档',
description: '使用指南与开发文档',
icon: 'ri:bill-line',
iconColor: '#ffb100',
enabled: true,
order: 5,
link: WEB_LINKS.DOCS
},
{
name: '技术支持',
description: '技术支持与问题反馈',
icon: 'ri:user-location-line',
iconColor: '#ff6b6b',
enabled: true,
order: 6,
link: WEB_LINKS.COMMUNITY
},
{
name: '更新日志',
description: '版本更新与变更记录',
icon: 'ri:gamepad-line',
iconColor: '#38C0FC',
enabled: true,
order: 7,
routeName: 'ChangeLog'
},
{
name: '哔哩哔哩',
description: '技术分享与交流',
icon: 'ri:bilibili-line',
iconColor: '#FB7299',
enabled: true,
order: 8,
link: WEB_LINKS.BILIBILI
}
],
// 快速链接
quickLinks: [
{
name: '登录',
enabled: true,
order: 1,
routeName: 'Login'
},
{
name: '注册',
enabled: true,
order: 2,
routeName: 'Register'
},
{
name: '忘记密码',
enabled: true,
order: 3,
routeName: 'ForgetPassword'
},
{
name: '定价',
enabled: true,
order: 4,
routeName: 'Pricing'
},
{
name: '个人中心',
enabled: true,
order: 5,
routeName: 'UserCenter'
},
{
name: '留言管理',
enabled: true,
order: 6,
routeName: 'ArticleComment'
}
]
}
export default Object.freeze(fastEnterConfig)

View File

@@ -0,0 +1,51 @@
/**
* 节日庆祝配置
*
* 配置系统的节日烟花效果和祝福文本。
* 支持单日节日和跨日期节日,可自定义烟花播放次数。
*
* ## 配置说明
*
* - name: 节日名称
* - date: 节日开始日期格式YYYY-MM-DD
* - endDate: 节日结束日期(可选,用于跨日期节日)
* - image: 烟花图片(需要预先导入)
* - scrollText: 滚动显示的祝福文本
* - count: 烟花播放次数(可选,默认为 3 次)
*
* ## 注意事项
*
* - 图片需要预先导入并在配置中引用
* - 跨日期节日会在整个日期范围内生效
* - 每个用户每天只会播放一次烟花效果
*
* @module config/modules/festival
* @author Art Design Pro Team
*/
import { FestivalConfig } from '@/types/config'
// 导入烟花图片(根据需要取消注释)
// import sd from '@imgs/ceremony/sd.png'
// import yd from '@imgs/ceremony/yd.png'
export const festivalConfigList: FestivalConfig[] = [
// 跨日期示例
// {
// name: 'v3.0 Sass 升级至 TailwindCSS',
// date: '2025-11-03',
// endDate: '2025-11-09',
// image: '',
// count: 3,
// scrollText:
// '🚀 系统 v3.0 测试阶段正式开启!测试周期为 11 月 3 日 - 11 月 16 日,通过 TailwindCSS 重构样式体系、统一 Iconify 图标方案,带来更高效现代的开发体验,正式发布敬请期待~'
// }
// 单日示例:圣诞节
// {
// name: '圣诞节',
// date: '2024-12-25',
// image: sd,
// count: 3 // 可选,不设置则使用默认值 3 次
// scrollText: 'Merry ChristmasArt Design Pro 祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!',
// }
]

View File

@@ -0,0 +1,63 @@
/**
* 顶部栏功能配置
*
* 统一管理顶部栏各个功能模块的启用状态。
* 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。
*
* @module config/headerBar
* @author Art Design Pro Team
*/
import { HeaderBarFeatureConfig } from '@/types'
/**
* 顶部栏功能配置对象
*/
export const headerBarConfig: HeaderBarFeatureConfig = {
menuButton: {
enabled: true,
description: '控制左侧菜单的展开/收起按钮'
},
refreshButton: {
enabled: true,
description: '页面刷新按钮'
},
fastEnter: {
enabled: true,
description: '快速入口功能,提供常用应用和链接的快速访问'
},
breadcrumb: {
enabled: true,
description: '面包屑导航,显示当前页面路径'
},
globalSearch: {
enabled: true,
description: '全局搜索功能,支持快捷键 Ctrl+K 或 Cmd+K'
},
fullscreen: {
enabled: true,
description: '全屏切换功能'
},
notification: {
enabled: true,
description: '通知中心,显示系统通知和消息'
},
chat: {
enabled: true,
description: '聊天功能,提供实时沟通'
},
language: {
enabled: true,
description: '多语言切换功能'
},
settings: {
enabled: true,
description: '系统设置面板'
},
themeToggle: {
enabled: true,
description: '主题切换功能(明暗主题)'
}
}
export default headerBarConfig

View File

@@ -0,0 +1,109 @@
/**
* 系统设置默认值配置
*
* 统一管理系统设置的所有默认值
*
* ## 主要功能
*
* - 菜单相关默认配置
* - 主题相关默认配置
* - 界面显示默认配置
* - 功能开关默认配置
* - 样式相关默认配置
*
* ## 注意事项
*
* 1. 修改此文件的配置项时,需要同步更新以下文件:
* - src/components/core/layouts/art-settings-panel/widget/SettingActions.vue复制配置和重置配置逻辑
* - src/store/modules/setting.tsStore 状态定义)
* 2. 可以通过设置面板的"复制配置"按钮快速生成配置代码
* 3. 枚举类型的值需要与 src/enums/appEnum.ts 中的定义保持一致
*/
import AppConfig from '@/config'
import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum'
/**
* 系统设置默认值配置
*/
export const SETTING_DEFAULT_CONFIG = {
/** 菜单类型 */
menuType: MenuTypeEnum.LEFT,
/** 菜单展开宽度 */
menuOpenWidth: 230,
/** 菜单是否展开 */
menuOpen: true,
/** 双菜单是否显示文本 */
dualMenuShowText: false,
/** 系统主题类型 */
systemThemeType: SystemThemeEnum.AUTO,
/** 系统主题模式 */
systemThemeMode: SystemThemeEnum.AUTO,
/** 菜单风格 */
menuThemeType: MenuThemeEnum.DESIGN,
/** 系统主题颜色 */
systemThemeColor: AppConfig.systemMainColor[0],
/** 是否显示菜单按钮 */
showMenuButton: true,
/** 是否显示快速入口 */
showFastEnter: true,
/** 是否显示刷新按钮 */
showRefreshButton: true,
/** 是否显示面包屑 */
showCrumbs: true,
/** 是否显示工作台标签 */
showWorkTab: true,
/** 是否显示语言切换 */
showLanguage: true,
/** 是否显示进度条 */
showNprogress: false,
/** 是否显示设置引导 */
showSettingGuide: true,
/** 是否显示节日文本 */
showFestivalText: false,
/** 是否显示水印 */
watermarkVisible: false,
/** 是否自动关闭 */
autoClose: false,
/** 是否唯一展开 */
uniqueOpened: true,
/** 是否色弱模式 */
colorWeak: false,
/** 是否刷新 */
refresh: false,
/** 是否加载节日烟花 */
holidayFireworksLoaded: false,
/** 边框模式 */
boxBorderMode: true,
/** 页面过渡效果 */
pageTransition: 'slide-left',
/** 标签页样式 */
tabStyle: 'tab-default',
/** 自定义圆角 */
customRadius: '0.75',
/** 容器宽度 */
containerWidth: ContainerWidthEnum.FULL,
/** 节日日期 */
festivalDate: ''
}
/**
* 获取设置默认值
* @returns 设置默认值对象
*/
export function getSettingDefaults() {
return { ...SETTING_DEFAULT_CONFIG }
}
/**
* 重置为默认设置
* @param currentSettings 当前设置对象
*/
export function resetToDefaults(currentSettings: Record<string, any>) {
const defaults = getSettingDefaults()
Object.keys(defaults).forEach((key) => {
if (key in currentSettings) {
currentSettings[key] = defaults[key as keyof typeof defaults]
}
})
}

View File

@@ -0,0 +1,248 @@
/**
* v-highlight 代码高亮指令
*
* 为代码块提供语法高亮、行号显示和一键复制功能。
* 基于 highlight.js 实现,支持多种编程语言的语法高亮。
*
* ## 主要功能
*
* - 语法高亮 - 使用 highlight.js 自动识别并高亮代码
* - 行号显示 - 自动为每行代码添加行号
* - 一键复制 - 提供复制按钮,点击即可复制代码(自动过滤行号)
* - 性能优化 - 批量处理代码块,避免阻塞渲染
* - 动态监听 - 使用 MutationObserver 监听新增代码块
* - 防重复处理 - 自动标记已处理的代码块,避免重复处理
*
* ## 使用示例
*
* ```vue
* <template>
* <!-- 基础用法 -->
* <div v-highlight v-html="codeContent"></div>
*
* <!-- 配合 Markdown 渲染 -->
* <div v-highlight>
* <pre><code class="language-javascript">
* const hello = 'world';
* console.log(hello);
* </code></pre>
* </div>
* </template>
* ```
*
* ## 性能优化
*
* - 批量处理:每次处理 10 个代码块,避免长时间阻塞
* - 延迟处理:使用 requestAnimationFrame 分批处理
* - 重试机制:自动重试处理失败的代码块
* - 智能监听:只在有新代码块时才触发处理
*
* @module directives/highlight
* @author Art Design Pro Team
*/
import { App, Directive } from 'vue'
import hljs from 'highlight.js'
// 高亮代码
function highlightCode(block: HTMLElement) {
hljs.highlightElement(block)
}
// 插入行号
function insertLineNumbers(block: HTMLElement) {
const lines = block.innerHTML.split('\n')
const numberedLines = lines
.map((line, index) => {
return `<span class="line-number">${index + 1}</span> ${line}`
})
.join('\n')
block.innerHTML = numberedLines
}
// 添加复制按钮:调整 DOM 结构,将代码部分包裹在 .code-wrapper 内
function addCopyButton(block: HTMLElement) {
const copyButton = document.createElement('i')
copyButton.className = 'copy-button'
copyButton.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M7 6V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1 1 0 0 1 3 21l.003-14c0-.552.45-1 1.006-1zM5.002 8L5 20h10V8zM9 6h8v10h2V4H9z"/></svg>'
copyButton.onclick = () => {
// 过滤掉行号,只复制代码内容
const codeContent = block.innerText.replace(/^\d+\s+/gm, '')
navigator.clipboard.writeText(codeContent).then(() => {
ElMessage.success('复制成功')
})
}
const preElement = block.parentElement
if (preElement) {
let codeWrapper: HTMLElement
// 如果代码块还没有被包裹,则创建包裹容器
if (!block.parentElement.classList.contains('code-wrapper')) {
codeWrapper = document.createElement('div')
codeWrapper.className = 'code-wrapper'
preElement.replaceChild(codeWrapper, block)
codeWrapper.appendChild(block)
} else {
codeWrapper = block.parentElement
}
// 将复制按钮添加到 pre 元素(而非 codeWrapper 内),这样它不会随滚动条滚动
preElement.appendChild(copyButton)
}
}
// 检查代码块是否已经被处理过
function isBlockProcessed(block: HTMLElement): boolean {
return (
block.hasAttribute('data-highlighted') ||
!!block.querySelector('.line-number') ||
!!block.parentElement?.querySelector('.copy-button')
)
}
// 标记代码块为已处理
function markBlockAsProcessed(block: HTMLElement) {
block.setAttribute('data-highlighted', 'true')
}
// 处理单个代码块
function processBlock(block: HTMLElement) {
if (isBlockProcessed(block)) {
return
}
try {
highlightCode(block)
insertLineNumbers(block)
addCopyButton(block)
markBlockAsProcessed(block)
} catch (error) {
console.warn('处理代码块时出错:', error)
}
}
// 查找并处理所有代码块
function processAllCodeBlocks(el: HTMLElement) {
const blocks = Array.from(el.querySelectorAll<HTMLElement>('pre code'))
const unprocessedBlocks = blocks.filter((block) => !isBlockProcessed(block))
if (unprocessedBlocks.length === 0) {
return
}
if (unprocessedBlocks.length <= 10) {
// 如果代码块数量少于等于10直接处理所有代码块
unprocessedBlocks.forEach((block) => processBlock(block))
} else {
// 定义每次处理的代码块数
const batchSize = 10
let currentIndex = 0
const processBatch = () => {
const batch = unprocessedBlocks.slice(currentIndex, currentIndex + batchSize)
batch.forEach((block) => {
processBlock(block)
})
// 更新索引并继续处理下一批
currentIndex += batchSize
if (currentIndex < unprocessedBlocks.length) {
// 使用 requestAnimationFrame 确保下一帧再处理
requestAnimationFrame(processBatch)
}
}
// 开始处理第一批代码块
processBatch()
}
}
// 重试处理函数
function retryProcessing(el: HTMLElement, maxRetries: number = 3, delay: number = 200) {
let retryCount = 0
const tryProcess = () => {
processAllCodeBlocks(el)
// 检查是否还有未处理的代码块
const remainingBlocks = Array.from(el.querySelectorAll<HTMLElement>('pre code')).filter(
(block) => !isBlockProcessed(block)
)
if (remainingBlocks.length > 0 && retryCount < maxRetries) {
retryCount++
setTimeout(tryProcess, delay * retryCount) // 递增延迟
}
}
tryProcess()
}
// 代码高亮、插入行号、复制按钮
const highlightDirective: Directive<HTMLElement> = {
mounted(el: HTMLElement) {
// 立即尝试处理一次
processAllCodeBlocks(el)
// 延迟处理,确保 v-html 内容已经渲染
setTimeout(() => {
retryProcessing(el)
}, 100)
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver((mutations) => {
let hasNewCodeBlocks = false
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement
// 检查新添加的节点是否包含代码块
if (element.tagName === 'PRE' || element.querySelector('pre code')) {
hasNewCodeBlocks = true
}
}
})
}
})
if (hasNewCodeBlocks) {
// 延迟处理新添加的代码块
setTimeout(() => {
processAllCodeBlocks(el)
}, 50)
}
})
// 开始观察
observer.observe(el, {
childList: true,
subtree: true
})
// 将 observer 存储到元素上,以便在 unmounted 时清理
;(el as any)._highlightObserver = observer
},
updated(el: HTMLElement) {
// 当组件更新时,重新处理代码块
setTimeout(() => {
processAllCodeBlocks(el)
}, 50)
},
unmounted(el: HTMLElement) {
// 清理 MutationObserver
const observer = (el as any)._highlightObserver
if (observer) {
observer.disconnect()
delete (el as any)._highlightObserver
}
}
}
export function setupHighlightDirective(app: App) {
app.directive('highlight', highlightDirective)
}

View File

@@ -0,0 +1,114 @@
/**
* v-ripple 水波纹效果指令
*
* 为元素添加 Material Design 风格的水波纹点击效果。
* 点击时从点击位置扩散出圆形水波纹动画,提升交互体验。
*
* ## 主要功能
*
* - 水波纹动画 - 点击时从点击位置扩散圆形波纹
* - 自适应大小 - 根据元素尺寸自动调整波纹大小和动画时长
* - 智能配色 - 自动识别按钮类型,使用合适的波纹颜色
* - 自定义颜色 - 支持通过参数自定义波纹颜色
* - 性能优化 - 使用 requestAnimationFrame 和自动清理机制
*
* ## 使用示例
*
* ```vue
* <template>
* <!-- 基础用法 - 使用默认颜色 -->
* <el-button v-ripple>点击我</el-button>
*
* <!-- 自定义颜色 -->
* <el-button v-ripple="{ color: 'rgba(255, 0, 0, 0.3)' }">自定义颜色</el-button>
*
* <!-- 应用到任意元素 -->
* <div v-ripple class="custom-card">卡片内容</div>
* </template>
* ```
*
* ## 颜色规则
*
* - 有色按钮primary、success、warning 等):使用白色半透明波纹
* - 默认按钮:使用主题色半透明波纹
* - 自定义:通过 color 参数指定任意颜色
*
* @module directives/ripple
* @author Art Design Pro Team
*/
import type { App, Directive, DirectiveBinding } from 'vue'
export interface RippleOptions {
/** 水波纹颜色 */
color?: string
}
export const vRipple: Directive = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
// 获取指令的配置参数
const options: RippleOptions = binding.value || {}
// 设置元素为相对定位,并隐藏溢出部分
el.style.position = 'relative'
el.style.overflow = 'hidden'
// 点击事件处理
el.addEventListener('mousedown', (e: MouseEvent) => {
const rect = el.getBoundingClientRect()
const left = e.clientX - rect.left
const top = e.clientY - rect.top
// 创建水波纹元素
const ripple = document.createElement('div')
const diameter = Math.max(el.clientWidth, el.clientHeight)
const radius = diameter / 2
// 根据直径计算动画时间(直径越大,动画时间越长)
const baseTime = 600 // 基础动画时间(毫秒)
const scaleFactor = 0.5 // 缩放因子
const animationDuration = baseTime + diameter * scaleFactor
// 设置水波纹的尺寸和位置
ripple.style.width = ripple.style.height = `${diameter}px`
ripple.style.left = `${left - radius}px`
ripple.style.top = `${top - radius}px`
ripple.style.position = 'absolute'
ripple.style.borderRadius = '50%'
ripple.style.pointerEvents = 'none'
// 判断是否为有色按钮Element Plus 按钮类型)
const buttonTypes = ['primary', 'info', 'warning', 'danger', 'success'].map(
(type) => `el-button--${type}`
)
const isColoredButton = buttonTypes.some((type) => el.classList.contains(type))
const defaultColor = isColoredButton
? 'rgba(255, 255, 255, 0.25)' // 有色按钮使用白色水波纹
: 'var(--el-color-primary-light-7)' // 默认按钮使用主题色水波纹
// 设置水波纹颜色、初始状态和过渡效果
ripple.style.backgroundColor = options.color || defaultColor
ripple.style.transform = 'scale(0)'
ripple.style.transition = `transform ${animationDuration}ms cubic-bezier(0.3, 0, 0.2, 1), opacity ${animationDuration}ms cubic-bezier(0.3, 0, 0.5, 1)`
ripple.style.zIndex = '1'
// 添加水波纹元素到DOM中
el.appendChild(ripple)
// 触发动画
requestAnimationFrame(() => {
ripple.style.transform = 'scale(2)'
ripple.style.opacity = '0'
})
// 动画结束后移除水波纹元素
setTimeout(() => {
ripple.remove()
}, animationDuration + 500) // 增加500ms缓冲时间
})
}
}
export function setupRippleDirective(app: App) {
app.directive('ripple', vRipple)
}

View File

@@ -0,0 +1,78 @@
/**
* v-auth 权限指令
*
* 适用于后端权限控制模式,基于权限标识控制 DOM 元素的显示和隐藏。
* 如果用户没有对应权限,元素将从 DOM 中移除。
*
* ## 主要功能
*
* - 权限验证 - 根据路由 meta 中的权限列表验证用户权限
* - DOM 控制 - 无权限时自动移除元素,而非隐藏
* - 响应式更新 - 权限变化时自动更新元素状态
*
* ## 使用示例
*
* ```vue
* <!-- 只有拥有 'add' 权限的用户才能看到新增按钮 -->
* <el-button v-auth="'add'">新增</el-button>
*
* <!-- 只有拥有 'edit' 权限的用户才能看到编辑按钮 -->
* <el-button v-auth="'edit'">编辑</el-button>
*
* <!-- 只有拥有 'delete' 权限的用户才能看到删除按钮 -->
* <el-button v-auth="'delete'">删除</el-button>
* ```
*
* ## 注意事项
*
* - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏
* - 权限列表从当前路由的 meta.authList 中获取
*
* @module directives/auth
* @author Art Design Pro Team
*/
import { useUserStore } from '@/store/modules/user'
import { App, Directive, DirectiveBinding } from 'vue'
interface AuthBinding extends DirectiveBinding {
value: string
}
function checkAuthPermission(el: HTMLElement, binding: AuthBinding): void {
const userStore = useUserStore()
const userButtons = userStore.getUserInfo.buttons
if (userButtons?.includes('*')) {
return
}
// 如果按钮为空或未定义,移除元素
if (!userButtons?.length) {
removeElement(el)
return
}
// 检查是否有对应的权限标识
const hasPermission = userButtons.some((item) => item === binding.value)
// 如果没有权限,移除元素
if (!hasPermission) {
removeElement(el)
}
}
function removeElement(el: HTMLElement): void {
if (el.parentNode) {
el.parentNode.removeChild(el)
}
}
const authDirective: Directive = {
mounted: checkAuthPermission,
updated: checkAuthPermission
}
export function setupAuthDirective(app: App): void {
app.directive('auth', authDirective)
}

View File

@@ -0,0 +1,89 @@
/**
* v-roles 角色权限指令
*
* 基于用户角色控制 DOM 元素的显示和隐藏。
* 只要用户拥有指定角色中的任意一个,元素就会显示,否则从 DOM 中移除。
*
* ## 主要功能
*
* - 角色验证 - 检查用户是否拥有指定角色
* - 多角色支持 - 支持单个角色或多个角色(满足其一即可)
* - DOM 控制 - 无权限时自动移除元素,而非隐藏
* - 响应式更新 - 角色变化时自动更新元素状态
*
* ## 使用示例
*
* ```vue
* <template>
* <!-- 单个角色 - 只有超级管理员可见 -->
* <el-button v-roles="'R_SUPER'">超级管理员功能</el-button>
*
* <!-- 多个角色 - 超级管理员或普通管理员可见 -->
* <el-button v-roles="['R_SUPER', 'R_ADMIN']">管理员功能</el-button>
*
* <!-- 应用到任意元素 -->
* <div v-roles="['R_SUPER', 'R_ADMIN', 'R_USER']">
* 所有登录用户可见的内容
* </div>
* </template>
* ```
*
* ## 权限逻辑
*
* - 用户角色从 userStore.getUserInfo.roles 获取
* - 只要用户拥有指定角色中的任意一个,元素就会显示
* - 如果用户没有任何角色或不满足条件,元素将被移除
*
* ## 注意事项
*
* - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏
* - 适用于基于角色的粗粒度权限控制
* - 如需基于具体操作的细粒度权限控制,请使用 v-auth 指令
*
* @module directives/roles
* @author Art Design Pro Team
*/
import { useUserStore } from '@/store/modules/user'
import { App, Directive, DirectiveBinding } from 'vue'
interface RolesBinding extends DirectiveBinding {
value: string | string[]
}
function checkRolePermission(el: HTMLElement, binding: RolesBinding): void {
const userStore = useUserStore()
const userRoles = userStore.getUserInfo.roles
// 如果用户角色为空或未定义,移除元素
if (!userRoles?.length) {
removeElement(el)
return
}
// 确保指令值为数组格式
const requiredRoles = Array.isArray(binding.value) ? binding.value : [binding.value]
// 检查用户是否具有所需角色之一
const hasPermission = requiredRoles.some((role: string) => userRoles.includes(role))
// 如果没有权限,安全地移除元素
if (!hasPermission) {
removeElement(el)
}
}
function removeElement(el: HTMLElement): void {
if (el.parentNode) {
el.parentNode.removeChild(el)
}
}
const rolesDirective: Directive = {
mounted: checkRolePermission,
updated: checkRolePermission
}
export function setupRolesDirective(app: App): void {
app.directive('roles', rolesDirective)
}

View File

@@ -0,0 +1,14 @@
import type { App } from 'vue'
import { setupAuthDirective } from './core/auth'
import { setupHighlightDirective } from './business/highlight'
import { setupRippleDirective } from './business/ripple'
import { setupRolesDirective } from './core/roles'
import { setupPermissionDirective } from './sai/permission'
export function setupGlobDirectives(app: App) {
setupAuthDirective(app) // 权限指令
setupRolesDirective(app) // 角色权限指令
setupHighlightDirective(app) // 高亮指令
setupRippleDirective(app) // 水波纹指令
setupPermissionDirective(app) // 权限指令
}

View File

@@ -0,0 +1,78 @@
/**
* v-permission 权限指令
*
* 适用于后端权限控制模式,基于权限标识控制 DOM 元素的显示和隐藏。
* 如果用户没有对应权限,元素将从 DOM 中移除。
*
* ## 主要功能
*
* - 权限验证 - 判断用户buttons里面是否有对应权限标识
*
* ## 使用示例
*
* ```vue
* <!-- 只有拥有 'core:user:save' 权限的用户才能看到新增按钮 -->
* <el-button v-permission="'core:user:save'">新增</el-button>
*
* <!-- 只有拥有 'edit' 权限的用户才能看到编辑按钮 -->
* <el-button v-permission="'core:user:update'">编辑</el-button>
*
* <!-- 只有拥有 'delete' 权限的用户才能看到删除按钮 -->
* <el-button v-permission="'core:user:destroy'">删除</el-button>
*
* <!-- 只有拥有 'read' 权限的用户才能看到读取按钮 -->
* <el-button v-permission="'core:user:read'">读取</el-button>
* ```
*
* ## 注意事项
*
* - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏
*
* @module directives/permission
* @author sai
*/
import { useUserStore } from '@/store/modules/user'
import { App, Directive, DirectiveBinding } from 'vue'
interface PermissionBinding extends DirectiveBinding {
value: string
}
function checkPermission(el: HTMLElement, binding: PermissionBinding): void {
const userStore = useUserStore()
const userButtons = userStore.getUserInfo.buttons
if (userButtons?.includes('*')) {
return
}
// 如果按钮为空或未定义,移除元素
if (!userButtons?.length) {
removeElement(el)
return
}
// 检查是否有对应的权限标识
const hasPermission = userButtons.some((item) => item === binding.value)
// 如果没有权限,移除元素
if (!hasPermission) {
removeElement(el)
}
}
function removeElement(el: HTMLElement): void {
if (el.parentNode) {
el.parentNode.removeChild(el)
}
}
const permissionDirective: Directive = {
mounted: checkPermission,
updated: checkPermission
}
export function setupPermissionDirective(app: App): void {
app.directive('permission', permissionDirective)
}

View File

@@ -0,0 +1,81 @@
/**
* 系统级别枚举定义模块
*
* ## 主要功能
*
* - 菜单类型枚举(左侧、顶部、混合、双栏)
* - 主题类型枚举(亮色、暗色、自动)
* - 菜单主题枚举(设计、亮色、暗色)
* - 语言类型枚举(中文、英文)
* - 容器宽度枚举(全屏、固定)
* - 菜单宽度枚举(收起宽度)
*
* @module enums/appEnum
* @author Art Design Pro Team
*/
/**
* 菜单类型
*/
export enum MenuTypeEnum {
/** 左侧菜单 */
LEFT = 'left',
/** 顶部菜单 */
TOP = 'top',
/** 顶部+左侧菜单 */
TOP_LEFT = 'top-left',
/** 双栏菜单 */
DUAL_MENU = 'dual-menu'
}
/**
* 系统主题
*/
export enum SystemThemeEnum {
/** 暗色主题 */
DARK = 'dark',
/** 亮色主题 */
LIGHT = 'light',
/** 自动主题(跟随系统) */
AUTO = 'auto'
}
/**
* 菜单主题
*/
export enum MenuThemeEnum {
/** 暗色主题 */
DARK = 'dark',
/** 亮色主题 */
LIGHT = 'light',
/** 设计主题 */
DESIGN = 'design'
}
/**
* 菜单宽度
*/
export enum MenuWidth {
/** 收起宽度 */
CLOSE = '64px'
}
/**
* 语言类型
*/
export enum LanguageEnum {
/** 中文 */
ZH = 'zh',
/** 英文 */
EN = 'en'
}
/**
* 容器宽度
*/
export enum ContainerWidthEnum {
/** 全屏宽度 */
FULL = '100%',
/** 固定宽度 */
BOXED = '1200px'
}

View File

@@ -0,0 +1,24 @@
/**
* 表单相关枚举定义模块
*
* ## 主要功能
*
* - 页面模式枚举(新增、编辑)
* - 表格尺寸枚举(默认、紧凑、宽松)
*
* @module enums/formEnum
* @author Art Design Pro Team
*/
// 页面类型
export enum PageModeEnum {
Add, // 新增
Edit // 编辑
}
// 表格大小
export enum TableSizeEnum {
DEFAULT = 'default',
SMALL = 'small',
LARGE = 'large'
}

34
saiadmin-artd/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
/// <reference types="vite/client" />
declare module 'nprogress'
declare module 'crypto-js'
declare module 'vue-img-cutter'
declare module 'file-saver'
declare module 'qrcode.vue' {
export type Level = 'L' | 'M' | 'Q' | 'H'
export type RenderAs = 'canvas' | 'svg'
export type GradientType = 'linear' | 'radial'
export interface ImageSettings {
src: string
height: number
width: number
excavate: boolean
}
export interface QRCodeProps {
value: string
size?: number
level?: Level
background?: string
foreground?: string
renderAs?: RenderAs
}
const QrcodeVue: any
export default QrcodeVue
}
// 全局变量声明
declare const __APP_VERSION__: string // 版本号

View File

@@ -0,0 +1,45 @@
/**
* useAppMode - 应用模式管理
*
* 提供应用访问模式的判断和管理功能,支持前端和后端两种权限控制模式。
* 根据环境变量 VITE_ACCESS_MODE 自动识别当前运行模式。
*
* ## 主要功能
*
* 1. 模式识别 - 自动识别前端模式或后端模式
* 2. 前端模式 - 权限由前端路由配置控制,适合小型项目或演示环境
* 3. 后端模式 - 权限由后端接口返回的菜单数据控制,适合企业级应用
* 4. 响应式状态 - 提供响应式的模式判断,方便在组件中使用
*
* @module useAppMode
* @author Art Design Pro Team
*/
import { computed } from 'vue'
export function useAppMode() {
// 获取访问模式配置
const accessMode = import.meta.env.VITE_ACCESS_MODE
/**
* 是否为前端控制模式
* 前端模式:权限由前端路由配置控制
*/
const isFrontendMode = computed(() => accessMode === 'frontend')
/**
* 是否为后端控制模式
* 后端模式:权限由后端接口返回的菜单数据控制
*/
const isBackendMode = computed(() => accessMode === 'backend')
/**
* 当前应用模式
*/
const currentMode = computed(() => accessMode)
return {
isFrontendMode,
isBackendMode,
currentMode
}
}

View File

@@ -0,0 +1,74 @@
/**
* useAuth - 权限验证管理
*
* 提供统一的权限验证功能,支持前端和后端两种权限模式。
* 用于控制页面按钮、操作等功能的显示和访问权限。
*
* ## 主要功能
*
* 1. 权限检查 - 检查用户是否拥有指定的权限标识
* 2. 双模式支持 - 自动适配前端模式和后端模式的权限验证
* 3. 前端模式 - 从用户信息中获取按钮权限列表(如 ['add', 'edit', 'delete']
* 4. 后端模式 - 从路由 meta 配置中获取权限列表(如 [{ authMark: 'add' }]
*
* ## 使用示例
*
* ```typescript
* const { hasAuth } = useAuth()
*
* // 检查是否有新增权限
* if (hasAuth('add')) {
* // 显示新增按钮
* }
*
* // 在模板中使用
* <el-button v-if="hasAuth('edit')">编辑</el-button>
* <el-button v-if="hasAuth('delete')">删除</el-button>
* ```
*
* @module useAuth
* @author Art Design Pro Team
*/
import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/store/modules/user'
import { useAppMode } from '@/hooks/core/useAppMode'
import type { AppRouteRecord } from '@/types/router'
type AuthItem = NonNullable<AppRouteRecord['meta']['authList']>[number]
const userStore = useUserStore()
export const useAuth = () => {
const route = useRoute()
const { isFrontendMode } = useAppMode()
const { info } = storeToRefs(userStore)
// 前端按钮权限(例如:['add', 'edit']
const frontendAuthList = info.value?.buttons ?? []
// 后端路由 meta 配置的权限列表(例如:[{ authMark: 'add' }]
const backendAuthList: AuthItem[] = Array.isArray(route.meta.authList)
? (route.meta.authList as AuthItem[])
: []
/**
* 检查是否拥有某权限标识(前后端模式通用)
* @param auth 权限标识
* @returns 是否有权限
*/
const hasAuth = (auth: string): boolean => {
// 前端模式
if (isFrontendMode.value) {
return frontendAuthList.includes(auth)
}
// 后端模式
return backendAuthList.some((item) => item?.authMark === auth)
}
return {
hasAuth
}
}

View File

@@ -0,0 +1,184 @@
/**
* useCeremony - 节日庆祝管理
*
* 提供节日烟花效果和祝福文本展示功能,为系统增添节日氛围。
* 自动检测当前日期是否为节日,并在首次进入时播放烟花动画和显示祝福语。
*
* ## 主要功能
*
* 1. 节日检测 - 自动匹配当前日期与节日配置列表,支持单日和跨日期节日
* 2. 烟花动画 - 播放节日烟花特效,支持自定义图片和触发次数
* 3. 祝福文本 - 烟花结束后显示节日祝福文本
* 4. 状态管理 - 记录烟花播放状态,避免重复播放
* 5. 清理机制 - 提供清理方法,支持手动停止和重置
*
* ## 使用示例
*
* ```typescript
* // 在配置文件中定义节日
* // 单日节日
* {
* date: '2024-12-25',
* name: '圣诞节',
* image: christmasImage,
* count: 3 // 可选,不设置则使用默认值 3 次
* scrollText: 'Merry Christmas!',
* }
*
* // 跨日期节日
* {
* date: '2025-11-07',
* endDate: '2025-11-10',
* name: 'v3.0 测试阶段',
* image: '',
* count: 5 // 自定义烟花播放次数
* scrollText: '系统 v3.0 测试阶段正式开启!',
* }
* ```
*
* @module useCeremony
* @author Art Design Pro Team
*/
import { useTimeoutFn, useIntervalFn, useDateFormat } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
import { mittBus } from '@/utils/sys'
import { festivalConfigList } from '@/config/modules/festival'
/**
* 节日庆祝配置常量
*/
const FESTIVAL_CONFIG = {
/** 初始延迟(毫秒) */
INITIAL_DELAY: 300,
/** 烟花播放间隔(毫秒) */
FIREWORK_INTERVAL: 1000,
/** 文本显示延迟(毫秒) */
TEXT_DELAY: 2000,
/** 默认烟花播放次数 */
DEFAULT_FIREWORKS_COUNT: 3
} as const
/**
* 节日庆祝功能
* 提供节日烟花效果和祝福文本展示
*/
export function useCeremony() {
const settingStore = useSettingStore()
const { holidayFireworksLoaded, isShowFireworks } = storeToRefs(settingStore)
let fireworksInterval: { pause: () => void } | null = null
/**
* 检查日期是否在节日范围内
* @param currentDate 当前日期
* @param festivalDate 节日开始日期
* @param festivalEndDate 节日结束日期(可选)
*/
const isDateInRange = (
currentDate: string,
festivalDate: string,
festivalEndDate?: string
): boolean => {
if (!festivalEndDate) {
// 单日节日
return currentDate === festivalDate
}
// 跨日期节日
const current = new Date(currentDate)
const start = new Date(festivalDate)
const end = new Date(festivalEndDate)
return current >= start && current <= end
}
/**
* 获取当前日期对应的节日数据
*/
const currentFestivalData = computed(() => {
const currentDate = useDateFormat(new Date(), 'YYYY-MM-DD').value
return festivalConfigList.find((item) => isDateInRange(currentDate, item.date, item.endDate))
})
/**
* 更新节日日期到 store
*/
const updateFestivalDate = () => {
settingStore.setFestivalDate(currentFestivalData.value?.date || '')
}
/**
* 触发烟花效果
*/
const triggerFirework = () => {
mittBus.emit('triggerFireworks', currentFestivalData.value?.image)
}
/**
* 完成烟花效果后显示文本
*/
const showFestivalText = () => {
settingStore.setholidayFireworksLoaded(true)
useTimeoutFn(() => {
settingStore.setShowFestivalText(true)
updateFestivalDate()
}, FESTIVAL_CONFIG.TEXT_DELAY)
}
/**
* 启动烟花循环
*/
const startFireworksLoop = () => {
let playedCount = 0
// 使用节日配置的播放次数,如果没有则使用默认值
const count = currentFestivalData.value?.count ?? FESTIVAL_CONFIG.DEFAULT_FIREWORKS_COUNT
const { pause } = useIntervalFn(() => {
triggerFirework()
playedCount++
if (playedCount >= count) {
pause()
showFestivalText()
}
}, FESTIVAL_CONFIG.FIREWORK_INTERVAL)
fireworksInterval = { pause }
}
/**
* 开启节日庆祝
*/
const openFestival = () => {
if (!currentFestivalData.value || !isShowFireworks.value) {
return
}
const { start } = useTimeoutFn(startFireworksLoop, FESTIVAL_CONFIG.INITIAL_DELAY)
start()
}
/**
* 清理烟花效果
*/
const cleanup = () => {
if (fireworksInterval) {
fireworksInterval.pause()
fireworksInterval = null
}
settingStore.setShowFestivalText(false)
updateFestivalDate()
}
return {
openFestival,
cleanup,
holidayFireworksLoaded,
currentFestivalData,
isShowFireworks
}
}

View File

@@ -0,0 +1,745 @@
/**
* useChart - ECharts 图表管理
*
* 提供完整的 ECharts 图表生命周期管理和配置能力,简化图表开发流程。
* 自动处理图表初始化、更新、销毁、主题切换、响应式调整等复杂逻辑。
*
* ## 核心功能
*
* 1. 图表生命周期管理 - 自动处理初始化、更新、销毁,支持延迟加载和可见性检测
* 2. 主题自动适配 - 响应系统主题变化,自动更新图表样式和配色
* 3. 响应式调整 - 监听窗口大小、菜单展开等变化,自动调整图表尺寸
* 4. 空状态处理 - 优雅的空数据展示,自动显示"暂无数据"提示
* 5. 样式配置统一 - 提供坐标轴、图例、提示框等统一的样式配置方法
* 6. 性能优化 - 防抖处理、样式缓存、requestAnimationFrame 优化
* 7. 高级组件抽象 - useChartComponent 提供更高层次的图表组件封装
*
* ## 使用示例
*
* ```typescript
* // 基础用法
* const {
* chartRef,
* initChart,
* updateChart,
* getAxisLineStyle,
* getTooltipStyle
* } = useChart()
*
* onMounted(() => {
* initChart({
* xAxis: { type: 'category', data: ['Mon', 'Tue', 'Wed'] },
* yAxis: { type: 'value' },
* series: [{ data: [120, 200, 150], type: 'bar' }]
* })
* })
*
* // 高级用法 - 组件抽象
* const chart = useChartComponent({
* props,
* generateOptions: () => ({
* // ECharts 配置
* }),
* checkEmpty: () => data.value.length === 0,
* watchSources: [() => props.data]
* })
* ```
*
* @module useChart
* @author Art Design Pro Team
*/
import { echarts, type EChartsOption } from '@/plugins/echarts'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/store/modules/setting'
import { getCssVar } from '@/utils/ui'
import type { BaseChartProps, ChartThemeConfig, UseChartOptions } from '@/types/component/chart'
// 图表主题配置
export const useChartOps = (): ChartThemeConfig => ({
/** */
chartHeight: '16rem',
/** 字体大小 */
fontSize: 13,
/** 字体颜色 */
fontColor: '#999',
/** 主题颜色 */
themeColor: getCssVar('--el-color-primary-light-1'),
/** 颜色组 */
colors: [
getCssVar('--el-color-primary-light-1'),
'#4ABEFF',
'#EDF2FF',
'#14DEBA',
'#FFAF20',
'#FA8A6C',
'#FFAF20'
]
})
// 常量定义
const RESIZE_DELAYS = [50, 100, 200, 350] as const
const MENU_RESIZE_DELAYS = [50, 100, 200] as const
const RESIZE_DEBOUNCE_DELAY = 100
export function useChart(options: UseChartOptions = {}) {
const { initOptions, initDelay = 0, threshold = 0.1, autoTheme = true } = options
const settingStore = useSettingStore()
const { isDark, menuOpen, menuType } = storeToRefs(settingStore)
const chartRef = ref<HTMLElement>()
let chart: echarts.ECharts | null = null
let intersectionObserver: IntersectionObserver | null = null
let pendingOptions: EChartsOption | null = null
let resizeTimeoutId: number | null = null
let resizeFrameId: number | null = null
let isDestroyed = false
let emptyStateDiv: HTMLElement | null = null
// 清理定时器的统一方法
const clearTimers = () => {
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId)
resizeTimeoutId = null
}
if (resizeFrameId) {
cancelAnimationFrame(resizeFrameId)
resizeFrameId = null
}
}
// 使用 requestAnimationFrame 优化 resize 处理
const requestAnimationResize = () => {
if (resizeFrameId) {
cancelAnimationFrame(resizeFrameId)
}
resizeFrameId = requestAnimationFrame(() => {
handleResize()
resizeFrameId = null
})
}
// 防抖的resize处理用于窗口resize事件
const debouncedResize = () => {
if (resizeTimeoutId) {
clearTimeout(resizeTimeoutId)
}
resizeTimeoutId = window.setTimeout(() => {
requestAnimationResize()
resizeTimeoutId = null
}, RESIZE_DEBOUNCE_DELAY)
}
// 多延迟resize处理 - 统一方法
const multiDelayResize = (delays: readonly number[]) => {
// 立即调用一次,快速响应
nextTick(requestAnimationResize)
// 使用延迟时间,确保图表正确适应变化
delays.forEach((delay) => {
setTimeout(requestAnimationResize, delay)
})
}
// 收缩菜单时,重新计算图表大小(仅在图表存在时监听)
let menuOpenStopHandle: (() => void) | null = null
let menuTypeStopHandle: (() => void) | null = null
const setupMenuWatchers = () => {
menuOpenStopHandle = watch(menuOpen, () => multiDelayResize(RESIZE_DELAYS))
menuTypeStopHandle = watch(menuType, () => {
nextTick(requestAnimationResize)
setTimeout(() => multiDelayResize(MENU_RESIZE_DELAYS), 0)
})
}
const cleanupMenuWatchers = () => {
menuOpenStopHandle?.()
menuTypeStopHandle?.()
menuOpenStopHandle = null
menuTypeStopHandle = null
}
// 主题变化时重新设置图表选项
let themeStopHandle: (() => void) | null = null
const setupThemeWatcher = () => {
if (autoTheme) {
themeStopHandle = watch(isDark, () => {
// 更新空状态样式
emptyStateManager.updateStyle()
if (chart && !isDestroyed) {
// 使用 requestAnimationFrame 优化主题更新
requestAnimationFrame(() => {
if (chart && !isDestroyed) {
const currentOptions = chart.getOption()
if (currentOptions) {
updateChart(currentOptions as EChartsOption)
}
}
})
}
})
}
}
const cleanupThemeWatcher = () => {
themeStopHandle?.()
themeStopHandle = null
}
// 样式生成器 - 统一的样式配置
const createLineStyle = (color: string, width = 1, type?: 'solid' | 'dashed') => ({
color,
width,
...(type && { type })
})
// 缓存样式配置以减少重复计算
const styleCache = {
axisLine: null as any,
splitLine: null as any,
axisLabel: null as any,
lastDarkValue: isDark.value
}
const clearStyleCache = () => {
styleCache.axisLine = null
styleCache.splitLine = null
styleCache.axisLabel = null
styleCache.lastDarkValue = isDark.value
}
// 坐标轴线样式
const getAxisLineStyle = (show: boolean = true) => {
if (styleCache.lastDarkValue !== isDark.value) {
clearStyleCache()
}
if (!styleCache.axisLine) {
styleCache.axisLine = {
show,
lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED')
}
}
return styleCache.axisLine
}
// 分割线样式
const getSplitLineStyle = (show: boolean = true) => {
if (styleCache.lastDarkValue !== isDark.value) {
clearStyleCache()
}
if (!styleCache.splitLine) {
styleCache.splitLine = {
show,
lineStyle: createLineStyle(isDark.value ? '#444' : '#EDEDED', 1, 'dashed')
}
}
return styleCache.splitLine
}
// 坐标轴标签样式
const getAxisLabelStyle = (show: boolean = true) => {
if (styleCache.lastDarkValue !== isDark.value) {
clearStyleCache()
}
if (!styleCache.axisLabel) {
const { fontColor, fontSize } = useChartOps()
styleCache.axisLabel = {
show,
color: fontColor,
fontSize
}
}
return styleCache.axisLabel
}
// 坐标轴刻度样式(静态配置,无需缓存)
const getAxisTickStyle = () => ({
show: false
})
// 获取动画配置
const getAnimationConfig = (animationDelay: number = 50, animationDuration: number = 1500) => ({
animationDelay: (idx: number) => idx * animationDelay + 200,
animationDuration: (idx: number) => animationDuration - idx * 50,
animationEasing: 'quarticOut' as const
})
// 获取统一的 tooltip 配置
const getTooltipStyle = (trigger: 'item' | 'axis' = 'axis', customOptions: any = {}) => ({
trigger,
backgroundColor: isDark.value ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.9)',
borderColor: isDark.value ? '#333' : '#ddd',
borderWidth: 1,
textStyle: {
color: isDark.value ? '#fff' : '#333'
},
...customOptions
})
// 获取统一的图例配置
const getLegendStyle = (
position: 'bottom' | 'top' | 'left' | 'right' = 'bottom',
customOptions: any = {}
) => {
const baseConfig = {
textStyle: {
color: isDark.value ? '#fff' : '#333'
},
itemWidth: 12,
itemHeight: 12,
itemGap: 20,
...customOptions
}
// 根据位置设置不同的配置
switch (position) {
case 'bottom':
return {
...baseConfig,
bottom: 0,
left: 'center',
orient: 'horizontal',
icon: 'roundRect'
}
case 'top':
return {
...baseConfig,
top: 0,
left: 'center',
orient: 'horizontal',
icon: 'roundRect'
}
case 'left':
return {
...baseConfig,
left: 0,
top: 'center',
orient: 'vertical',
icon: 'roundRect'
}
case 'right':
return {
...baseConfig,
right: 0,
top: 'center',
orient: 'vertical',
icon: 'roundRect'
}
default:
return baseConfig
}
}
// 根据图例位置计算 grid 配置
const getGridWithLegend = (
showLegend: boolean,
legendPosition: 'bottom' | 'top' | 'left' | 'right' = 'bottom',
baseGrid: any = {}
) => {
const defaultGrid = {
top: 15,
right: 15,
bottom: 8,
left: 0,
containLabel: true,
...baseGrid
}
if (!showLegend) {
return defaultGrid
}
// 根据图例位置调整 grid
switch (legendPosition) {
case 'bottom':
return {
...defaultGrid,
bottom: 40
}
case 'top':
return {
...defaultGrid,
top: 40
}
case 'left':
return {
...defaultGrid,
left: 120
}
case 'right':
return {
...defaultGrid,
right: 120
}
default:
return defaultGrid
}
}
// 创建IntersectionObserver
const createIntersectionObserver = () => {
if (intersectionObserver || !chartRef.value) return
intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && pendingOptions && !isDestroyed) {
// 使用 requestAnimationFrame 确保在下一帧初始化图表
requestAnimationFrame(() => {
if (!isDestroyed && pendingOptions) {
try {
// 元素变为可见,初始化图表
if (!chart) {
chart = echarts.init(entry.target as HTMLElement)
}
// 触发自定义事件,让组件处理动画逻辑
const event = new CustomEvent('chartVisible', {
detail: { options: pendingOptions }
})
entry.target.dispatchEvent(event)
pendingOptions = null
cleanupIntersectionObserver()
} catch (error) {
console.error('图表初始化失败:', error)
}
}
})
}
})
},
{ threshold }
)
intersectionObserver.observe(chartRef.value)
}
// 清理IntersectionObserver
const cleanupIntersectionObserver = () => {
if (intersectionObserver) {
intersectionObserver.disconnect()
intersectionObserver = null
}
}
// 检查容器是否可见
const isContainerVisible = (element: HTMLElement): boolean => {
const rect = element.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && rect.top < window.innerHeight && rect.bottom > 0
}
// 图表初始化核心逻辑
const performChartInit = (options: EChartsOption) => {
if (!chart && chartRef.value && !isDestroyed) {
chart = echarts.init(chartRef.value)
// 图表创建后立即设置监听器
setupMenuWatchers()
setupThemeWatcher()
}
if (chart && !isDestroyed) {
chart.setOption(options)
pendingOptions = null
}
}
// 空状态管理器
const emptyStateManager = {
create: () => {
if (!chartRef.value || emptyStateDiv) return
emptyStateDiv = document.createElement('div')
emptyStateDiv.style.cssText = `
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: 12px;
color: ${isDark.value ? '#555555' : '#B3B2B2'};
background: transparent;
z-index: 10;
`
emptyStateDiv.innerHTML = `<span>暂无数据</span>`
// 确保父容器有相对定位
if (
chartRef.value.style.position !== 'relative' &&
chartRef.value.style.position !== 'absolute'
) {
chartRef.value.style.position = 'relative'
}
chartRef.value.appendChild(emptyStateDiv)
},
remove: () => {
if (emptyStateDiv && chartRef.value) {
chartRef.value.removeChild(emptyStateDiv)
emptyStateDiv = null
}
},
updateStyle: () => {
if (emptyStateDiv) {
emptyStateDiv.style.color = isDark.value ? '#666' : '#999'
}
}
}
// 初始化图表
const initChart = (options: EChartsOption = {}, isEmpty: boolean = false) => {
if (!chartRef.value || isDestroyed) return
const mergedOptions = { ...initOptions, ...options }
try {
if (isEmpty) {
// 处理空数据情况 - 显示自定义空状态div
if (chart) {
chart.clear()
}
emptyStateManager.create()
return
} else {
// 有数据时移除空状态div
emptyStateManager.remove()
}
if (isContainerVisible(chartRef.value)) {
// 容器可见,正常初始化
if (initDelay > 0) {
setTimeout(() => performChartInit(mergedOptions), initDelay)
} else {
performChartInit(mergedOptions)
}
} else {
// 容器不可见,保存选项并设置监听器
pendingOptions = mergedOptions
createIntersectionObserver()
}
} catch (error) {
console.error('图表初始化失败:', error)
}
}
// 更新图表
const updateChart = (options: EChartsOption) => {
if (isDestroyed) return
try {
if (!chart) {
// 如果图表不存在,先初始化
initChart(options)
return
}
chart.setOption(options)
} catch (error) {
console.error('图表更新失败:', error)
}
}
// 处理窗口大小变化
const handleResize = () => {
if (chart && !isDestroyed) {
try {
chart.resize()
} catch (error) {
console.error('图表resize失败:', error)
}
}
}
// 销毁图表
const destroyChart = () => {
isDestroyed = true
if (chart) {
try {
chart.dispose()
} catch (error) {
console.error('图表销毁失败:', error)
} finally {
chart = null
}
}
// 清理所有监听器和资源
cleanupMenuWatchers()
cleanupThemeWatcher()
emptyStateManager.remove()
cleanupIntersectionObserver()
clearTimers()
clearStyleCache()
pendingOptions = null
}
// 获取图表实例
const getChartInstance = () => chart
// 获取图表是否已初始化
const isChartInitialized = () => chart !== null
onMounted(() => {
window.addEventListener('resize', debouncedResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', debouncedResize)
})
onUnmounted(() => {
destroyChart()
})
return {
isDark,
chartRef,
initChart,
updateChart,
handleResize,
destroyChart,
getChartInstance,
isChartInitialized,
emptyStateManager,
getAxisLineStyle,
getSplitLineStyle,
getAxisLabelStyle,
getAxisTickStyle,
getAnimationConfig,
getTooltipStyle,
getLegendStyle,
useChartOps,
getGridWithLegend
}
}
// 高级图表组件抽象
interface UseChartComponentOptions<T extends BaseChartProps> {
/** Props响应式对象 */
props: T
/** 图表配置生成函数 */
generateOptions: () => EChartsOption
/** 空数据检查函数 */
checkEmpty?: () => boolean
/** 自定义监听的响应式数据 */
watchSources?: (() => any)[]
/** 自定义可视事件处理 */
onVisible?: () => void
/** useChart选项 */
chartOptions?: UseChartOptions
}
export function useChartComponent<T extends BaseChartProps>(options: UseChartComponentOptions<T>) {
const {
props,
generateOptions,
checkEmpty,
watchSources = [],
onVisible,
chartOptions = {}
} = options
const chart = useChart(chartOptions)
const { chartRef, initChart, isDark, emptyStateManager } = chart
// 检查是否为空数据
const isEmpty = computed(() => {
if (props.isEmpty) return true
if (checkEmpty) return checkEmpty()
return false
})
// 更新图表
const updateChart = () => {
nextTick(() => {
if (isEmpty.value) {
// 处理空数据情况 - 显示自定义空状态div
if (chart.getChartInstance()) {
chart.getChartInstance()?.clear()
}
emptyStateManager.create()
} else {
// 有数据时移除空状态div并初始化图表
emptyStateManager.remove()
initChart(generateOptions())
}
})
}
// 处理图表进入可视区域时的逻辑
const handleChartVisible = () => {
if (onVisible) {
onVisible()
} else {
updateChart()
}
}
// 存储监听器停止函数
const stopHandles: (() => void)[] = []
// 设置数据监听
const setupWatchers = () => {
// 监听自定义数据源
if (watchSources.length > 0) {
const stopHandle = watch(watchSources, updateChart, { deep: true })
stopHandles.push(stopHandle)
}
// 监听主题变化
const themeStopHandle = watch(isDark, () => {
emptyStateManager.updateStyle()
updateChart()
})
stopHandles.push(themeStopHandle)
}
// 清理所有监听器
const cleanupWatchers = () => {
stopHandles.forEach((stop) => stop())
stopHandles.length = 0
}
// 设置生命周期
const setupLifecycle = () => {
onMounted(() => {
updateChart()
// 监听图表可见事件
if (chartRef.value) {
chartRef.value.addEventListener('chartVisible', handleChartVisible)
}
})
onBeforeUnmount(() => {
// 清理事件监听器
if (chartRef.value) {
chartRef.value.removeEventListener('chartVisible', handleChartVisible)
}
// 清理所有监听器
cleanupWatchers()
// 清理空状态div
emptyStateManager.remove()
})
}
// 初始化
setupWatchers()
setupLifecycle()
return {
...chart,
isEmpty,
updateChart,
handleChartVisible
}
}

View File

@@ -0,0 +1,87 @@
/**
* useCommon - 通用功能集合
*
* 提供常用的页面操作功能,包括页面刷新、滚动控制、路径获取等。
* 这些功能在多个页面和组件中都会用到,统一封装便于复用。
*
* ## 主要功能
*
* 1. 首页路径 - 获取系统配置的首页路径
* 2. 页面刷新 - 刷新当前页面内容
* 3. 滚动控制 - 提供多种滚动到顶部和指定位置的方法
* 4. 平滑滚动 - 支持平滑滚动动画效果
*
* @module useCommon
* @author Art Design Pro Team
*/
import { computed } from 'vue'
import { useMenuStore } from '@/store/modules/menu'
import { useSettingStore } from '@/store/modules/setting'
export function useCommon() {
const menuStore = useMenuStore()
const settingStore = useSettingStore()
/**
* 首页路径
* 从菜单 store 中获取配置的首页路径
*/
const homePath = computed(() => menuStore.getHomePath())
/**
* 刷新当前页面
* 通过切换 setting store 中的 refresh 状态触发页面重新渲染
*/
const refresh = () => {
settingStore.reload()
}
/**
* 滚动到页面顶部
* 查找主内容区域并将其滚动位置重置为顶部
*/
const scrollToTop = () => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
scrollContainer.scrollTop = 0
}
}
/**
* 平滑滚动到页面顶部
* 使用 smooth 行为实现平滑滚动效果
*/
const smoothScrollToTop = () => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
scrollContainer.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
/**
* 滚动到指定位置
* @param top 目标滚动位置(像素)
* @param smooth 是否使用平滑滚动
*/
const scrollTo = (top: number, smooth: boolean = false) => {
const scrollContainer = document.getElementById('app-main')
if (scrollContainer) {
scrollContainer.scrollTo({
top,
behavior: smooth ? 'smooth' : 'auto'
})
}
}
return {
homePath,
refresh,
scrollTo,
scrollToTop,
smoothScrollToTop
}
}

View File

@@ -0,0 +1,55 @@
/**
* useFastEnter - 快速入口管理
*
* 管理顶部栏的快速入口功能,提供应用列表和快速链接的配置和过滤。
* 支持动态启用/禁用、自定义排序、响应式宽度控制等功能。
*
* ## 主要功能
*
* 1. 应用列表管理 - 获取启用的应用列表,自动按排序权重排序
* 2. 快速链接管理 - 获取启用的快速链接,支持自定义排序
* 3. 响应式配置 - 所有配置自动响应变化,无需手动更新
* 4. 宽度控制 - 提供最小显示宽度配置,支持响应式布局
*
* @module useFastEnter
* @author Art Design Pro Team
*/
import { computed } from 'vue'
import appConfig from '@/config'
import type { FastEnterApplication, FastEnterQuickLink } from '@/types/config'
export function useFastEnter() {
// 获取快速入口配置
const fastEnterConfig = computed(() => appConfig.fastEnter)
// 获取启用的应用列表(按排序权重排序)
const enabledApplications = computed<FastEnterApplication[]>(() => {
if (!fastEnterConfig.value?.applications) return []
return fastEnterConfig.value.applications
.filter((app) => app.enabled !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0))
})
// 获取启用的快速链接(按排序权重排序)
const enabledQuickLinks = computed<FastEnterQuickLink[]>(() => {
if (!fastEnterConfig.value?.quickLinks) return []
return fastEnterConfig.value.quickLinks
.filter((link) => link.enabled !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0))
})
// 获取最小显示宽度
const minWidth = computed(() => {
return fastEnterConfig.value?.minWidth || 1200
})
return {
fastEnterConfig,
enabledApplications,
enabledQuickLinks,
minWidth
}
}

View File

@@ -0,0 +1,201 @@
/**
* useHeaderBar - 顶部栏功能管理
*
* 统一管理顶部栏各个功能模块的显示状态和配置信息。
* 提供灵活的功能开关控制,支持动态显示/隐藏顶部栏的各个功能按钮。
*
* ## 主要功能
*
* 1. 功能开关控制 - 统一管理菜单按钮、刷新按钮、快速入口等功能的显示状态
* 2. 配置信息获取 - 获取各个功能模块的详细配置信息
* 3. 功能列表查询 - 快速获取所有启用或禁用的功能列表
* 4. 响应式状态 - 所有状态自动响应配置和 store 变化
*
* @module useHeaderBar
* @author Art Design Pro Team
*/
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/store/modules/setting'
import { headerBarConfig } from '@/config/modules/headerBar'
import { HeaderBarFeatureConfig } from '@/types'
/**
* 顶部栏功能管理
* @returns 顶部栏功能相关的状态和方法
*/
export function useHeaderBar() {
const settingStore = useSettingStore()
// 获取顶部栏配置
const headerBarConfigRef = computed<HeaderBarFeatureConfig>(() => headerBarConfig)
// 从store中获取相关状态
const { showMenuButton, showFastEnter, showRefreshButton, showCrumbs, showLanguage } =
storeToRefs(settingStore)
/**
* 检查特定功能是否启用
* @param feature 功能名称
* @returns 是否启用
*/
const isFeatureEnabled = (feature: keyof HeaderBarFeatureConfig): boolean => {
return headerBarConfigRef.value[feature]?.enabled ?? false
}
/**
* 获取功能配置信息
* @param feature 功能名称
* @returns 功能配置信息
*/
const getFeatureConfig = (feature: keyof HeaderBarFeatureConfig) => {
return headerBarConfigRef.value[feature]
}
// 检查菜单按钮是否显示
const shouldShowMenuButton = computed(() => {
return isFeatureEnabled('menuButton') && showMenuButton.value
})
// 检查刷新按钮是否显示
const shouldShowRefreshButton = computed(() => {
return isFeatureEnabled('refreshButton') && showRefreshButton.value
})
// 检查快速入口是否显示
const shouldShowFastEnter = computed(() => {
return isFeatureEnabled('fastEnter') && showFastEnter.value
})
// 检查面包屑是否显示
const shouldShowBreadcrumb = computed(() => {
return isFeatureEnabled('breadcrumb') && showCrumbs.value
})
// 检查全局搜索是否显示
const shouldShowGlobalSearch = computed(() => {
return isFeatureEnabled('globalSearch')
})
// 检查全屏按钮是否显示
const shouldShowFullscreen = computed(() => {
return isFeatureEnabled('fullscreen')
})
// 检查通知中心是否显示
const shouldShowNotification = computed(() => {
return isFeatureEnabled('notification')
})
// 检查聊天功能是否显示
const shouldShowChat = computed(() => {
return isFeatureEnabled('chat')
})
// 检查语言切换是否显示
const shouldShowLanguage = computed(() => {
return isFeatureEnabled('language') && showLanguage.value
})
// 检查设置面板是否显示
const shouldShowSettings = computed(() => {
return isFeatureEnabled('settings')
})
// 检查主题切换是否显示
const shouldShowThemeToggle = computed(() => {
return isFeatureEnabled('themeToggle')
})
// 获取快速入口的最小宽度
const fastEnterMinWidth = computed(() => {
const config = getFeatureConfig('fastEnter')
return (config as any)?.minWidth || 1200
})
/**
* 检查功能是否启用(别名)
* @param feature 功能名称
* @returns 是否启用
*/
const isFeatureActive = (feature: keyof HeaderBarFeatureConfig): boolean => {
return isFeatureEnabled(feature)
}
/**
* 获取功能配置(别名)
* @param feature 功能名称
* @returns 功能配置
*/
const getFeatureInfo = (feature: keyof HeaderBarFeatureConfig) => {
return getFeatureConfig(feature)
}
/**
* 获取所有启用的功能列表
* @returns 启用的功能名称数组
*/
const getEnabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => {
return Object.keys(headerBarConfigRef.value).filter(
(key) => headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled
) as (keyof HeaderBarFeatureConfig)[]
}
/**
* 获取所有禁用的功能列表
* @returns 禁用的功能名称数组
*/
const getDisabledFeatures = (): (keyof HeaderBarFeatureConfig)[] => {
return Object.keys(headerBarConfigRef.value).filter(
(key) => !headerBarConfigRef.value[key as keyof HeaderBarFeatureConfig]?.enabled
) as (keyof HeaderBarFeatureConfig)[]
}
/**
* 获取所有启用的功能(别名)
* @returns 启用的功能列表
*/
const getActiveFeatures = () => {
return getEnabledFeatures()
}
/**
* 获取所有禁用的功能(别名)
* @returns 禁用的功能列表
*/
const getInactiveFeatures = () => {
return getDisabledFeatures()
}
return {
// 配置
headerBarConfig: headerBarConfigRef,
// 显示状态计算属性
shouldShowMenuButton, // 是否显示菜单按钮
shouldShowRefreshButton, // 是否显示刷新按钮
shouldShowFastEnter, // 是否显示快速入口
shouldShowBreadcrumb, // 是否显示面包屑
shouldShowGlobalSearch, // 是否显示全局搜索
shouldShowFullscreen, // 是否显示全屏按钮
shouldShowNotification, // 是否显示通知中心
shouldShowChat, // 是否显示聊天功能
shouldShowLanguage, // 是否显示语言切换
shouldShowSettings, // 是否显示设置面板
shouldShowThemeToggle, // 是否显示主题切换
// 配置相关
fastEnterMinWidth, // 快速入口最小宽度
// 方法
isFeatureEnabled, // 检查功能是否启用
isFeatureActive, // 检查功能是否启用(别名)
getFeatureConfig, // 获取功能配置
getFeatureInfo, // 获取功能配置(别名)
getEnabledFeatures, // 获取所有启用的功能
getDisabledFeatures, // 获取所有禁用的功能
getActiveFeatures, // 获取所有启用的功能(别名)
getInactiveFeatures // 获取所有禁用的功能(别名)
}
}

View File

@@ -0,0 +1,148 @@
/**
* useLayoutHeight - 页面布局高度管理
*
* 自动计算和管理页面内容区域的高度,确保内容区域能够正确填充剩余空间。
* 监听头部元素高度变化,动态调整内容区域高度,避免出现滚动条或布局错乱。
*
* ## 主要功能
*
* 1. 动态高度计算 - 根据头部元素高度自动计算内容区域高度
* 2. 响应式监听 - 自动监听元素尺寸变化并更新高度
* 3. CSS 变量同步 - 自动更新 CSS 变量,方便全局使用
* 4. 灵活配置 - 支持自定义间距、CSS 变量名等
* 5. 自动查找模式 - 提供通过 ID 自动查找元素的便捷方式
*
* @module useLayoutHeight
* @author Art Design Pro Team
*/
import { ref, computed, watch, onMounted } from 'vue'
import { useElementSize } from '@vueuse/core'
/**
* 页面容器高度配置
*/
interface LayoutHeightOptions {
/** 额外的间距(默认 15px */
extraSpacing?: number
/** 是否自动更新 CSS 变量(默认 true */
updateCssVar?: boolean
/** CSS 变量名称(默认 '--art-full-height' */
cssVarName?: string
}
export function useLayoutHeight(options: LayoutHeightOptions = {}) {
const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options
// 元素引用
const headerRef = ref<HTMLElement>()
const contentHeaderRef = ref<HTMLElement>()
// 使用 VueUse 自动监听元素尺寸变化
const { height: headerHeight } = useElementSize(headerRef)
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
// 计算容器最小高度(响应式)
const containerMinHeight = computed(() => {
const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing
return `calc(100vh - ${totalHeight}px)`
})
if (updateCssVar) {
watch(
containerMinHeight,
(newHeight) => {
requestAnimationFrame(() => {
document.documentElement.style.setProperty(cssVarName, newHeight)
})
},
{ immediate: true }
)
}
return {
/** 容器最小高度(响应式) */
containerMinHeight,
/** 头部元素引用 */
headerRef,
/** 内容头部元素引用 */
contentHeaderRef,
/** 头部高度(响应式) */
headerHeight,
/** 内容头部高度(响应式) */
contentHeaderHeight
}
}
/**
* 通过 ID 自动查找元素的布局高度管理
* 适用于无法直接获取元素引用的场景
*
* @param headerIds 头部元素的 ID 数组
* @param options 配置选项
*
* ```
*/
export function useAutoLayoutHeight(
headerIds: string[] = ['app-header', 'app-content-header'],
options: LayoutHeightOptions = {}
) {
const { extraSpacing = 15, updateCssVar = true, cssVarName = '--art-full-height' } = options
// 创建元素引用
const headerRef = ref<HTMLElement>()
const contentHeaderRef = ref<HTMLElement>()
// 使用 VueUse 自动监听元素尺寸变化
const { height: headerHeight } = useElementSize(headerRef)
const { height: contentHeaderHeight } = useElementSize(contentHeaderRef)
// 计算容器最小高度(响应式)
const containerMinHeight = computed(() => {
const totalHeight = headerHeight.value + contentHeaderHeight.value + extraSpacing
return `calc(100vh - ${totalHeight}px)`
})
if (updateCssVar) {
watch(
containerMinHeight,
(newHeight) => {
requestAnimationFrame(() => {
document.documentElement.style.setProperty(cssVarName, newHeight)
})
},
{ immediate: true }
)
}
// 在 DOM 挂载后查找元素
onMounted(() => {
if (typeof document !== 'undefined') {
// 使用 nextTick 确保 DOM 完全渲染
requestAnimationFrame(() => {
const header = document.getElementById(headerIds[0])
const contentHeader = document.getElementById(headerIds[1])
if (header) {
headerRef.value = header
}
if (contentHeader) {
contentHeaderRef.value = contentHeader
}
})
}
})
return {
/** 容器最小高度(响应式) */
containerMinHeight,
/** 头部元素引用 */
headerRef,
/** 内容头部元素引用 */
contentHeaderRef,
/** 头部高度(响应式) */
headerHeight,
/** 内容头部高度(响应式) */
contentHeaderHeight
}
}

View File

@@ -0,0 +1,767 @@
/**
* useTable - 企业级表格数据管理方案
*
* 功能完整的表格数据管理解决方案,专为后台管理系统设计。
* 封装了表格开发中的所有常见需求,让你专注于业务逻辑。
*
* ## 主要功能
*
* 1. 数据管理 - 自动处理 API 请求、响应转换、加载状态和错误处理
* 2. 分页控制 - 自动同步分页状态、移动端适配、智能页码边界处理
* 3. 搜索功能 - 防抖搜索优化、参数管理、一键重置、参数过滤
* 4. 缓存系统 - 智能请求缓存、多种清理策略、自动过期管理、统计信息
* 5. 刷新策略 - 提供 5 种刷新方法适配不同业务场景(新增/更新/删除/手动/定时)
* 6. 列配置管理 - 动态显示/隐藏列、列排序、配置持久化、批量操作(可选)
*
* @module useTable
* @author Art Design Pro Team
*/
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, readonly } from 'vue'
import { useWindowSize } from '@vueuse/core'
import { useTableColumns } from './useTableColumns'
import type { ColumnOption } from '@/types/component'
import {
TableCache,
CacheInvalidationStrategy,
type ApiResponse
} from '../../utils/table/tableCache'
import {
type TableError,
defaultResponseAdapter,
extractTableData,
updatePaginationFromResponse,
createSmartDebounce,
createErrorHandler
} from '../../utils/table/tableUtils'
import { tableConfig } from '../../utils/table/tableConfig'
// 类型推导工具类型
type InferApiParams<T> = T extends (params: infer P) => any ? P : never
type InferApiResponse<T> = T extends (params: any) => Promise<infer R> ? R : never
type InferRecordType<T> = T extends Api.Common.PaginatedResponse<infer U> ? U : never
// 优化的配置接口 - 支持自动类型推导
export interface UseTableConfig<
TApiFn extends (params: any) => Promise<any> = (params: any) => Promise<any>,
TRecord = InferRecordType<InferApiResponse<TApiFn>>,
TParams = InferApiParams<TApiFn>,
TResponse = InferApiResponse<TApiFn>
> {
// 核心配置
core: {
/** API 请求函数 */
apiFn: TApiFn
/** 默认请求参数 */
apiParams?: Partial<TParams>
/** 排除 apiParams 中的属性 */
excludeParams?: string[]
/** 是否立即加载数据 */
immediate?: boolean
/** 列配置工厂函数 */
columnsFactory?: () => ColumnOption<TRecord>[]
/** 自定义分页字段映射 */
paginationKey?: {
/** 当前页码字段名,默认为 'current' */
current?: string
/** 每页条数字段名,默认为 'size' */
size?: string
}
}
// 数据处理
transform?: {
/** 数据转换函数 */
dataTransformer?: (data: TRecord[]) => TRecord[]
/** 响应数据适配器 */
responseAdapter?: (response: TResponse) => ApiResponse<TRecord>
}
// 性能优化
performance?: {
/** 是否启用缓存 */
enableCache?: boolean
/** 缓存时间(毫秒) */
cacheTime?: number
/** 防抖延迟时间(毫秒) */
debounceTime?: number
/** 最大缓存条数限制 */
maxCacheSize?: number
}
// 生命周期钩子
hooks?: {
/** 数据加载成功回调(仅网络请求成功时触发) */
onSuccess?: (data: TRecord[], response: ApiResponse<TRecord>) => void
/** 错误处理回调 */
onError?: (error: TableError) => void
/** 缓存命中回调(从缓存获取数据时触发) */
onCacheHit?: (data: TRecord[], response: ApiResponse<TRecord>) => void
/** 加载状态变化回调 */
onLoading?: (loading: boolean) => void
/** 重置表单回调函数 */
resetFormCallback?: () => void
}
// 调试配置
debug?: {
/** 是否启用日志输出 */
enableLog?: boolean
/** 日志级别 */
logLevel?: 'info' | 'warn' | 'error'
}
}
export function useTable<TApiFn extends (params: any) => Promise<any>>(
config: UseTableConfig<TApiFn>
) {
return useTableImpl(config)
}
/**
* useTable 的核心实现 - 强大的表格数据管理 Hook
*
* 提供完整的表格解决方案,包括:
* - 数据获取与缓存
* - 分页控制
* - 搜索功能
* - 智能刷新策略
* - 错误处理
* - 列配置管理
*/
function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
config: UseTableConfig<TApiFn>
) {
type TRecord = InferRecordType<InferApiResponse<TApiFn>>
type TParams = InferApiParams<TApiFn>
const {
core: {
apiFn,
apiParams = {} as Partial<TParams>,
excludeParams = [],
immediate = true,
columnsFactory,
paginationKey
},
transform: { dataTransformer, responseAdapter = defaultResponseAdapter } = {},
performance: {
enableCache = false,
cacheTime = 5 * 60 * 1000,
debounceTime = 300,
maxCacheSize = 50
} = {},
hooks: { onSuccess, onError, onCacheHit, resetFormCallback } = {},
debug: { enableLog = false } = {}
} = config
// 分页字段名配置:优先使用传入的配置,否则使用全局配置
const pageKey = paginationKey?.current || tableConfig.paginationKey.current
const sizeKey = paginationKey?.size || tableConfig.paginationKey.size
const orderFieldKey = tableConfig.paginationKey.orderField
const orderTypeKey = tableConfig.paginationKey.orderType
// 响应式触发器,用于手动更新缓存统计信息
const cacheUpdateTrigger = ref(0)
// 日志工具函数
const logger = {
log: (message: string, ...args: unknown[]) => {
if (enableLog) {
console.log(`[useTable] ${message}`, ...args)
}
},
warn: (message: string, ...args: unknown[]) => {
if (enableLog) {
console.warn(`[useTable] ${message}`, ...args)
}
},
error: (message: string, ...args: unknown[]) => {
if (enableLog) {
console.error(`[useTable] ${message}`, ...args)
}
}
}
// 缓存实例
const cache = enableCache ? new TableCache<TRecord>(cacheTime, maxCacheSize, enableLog) : null
// 加载状态机
type LoadingState = 'idle' | 'loading' | 'success' | 'error'
const loadingState = ref<LoadingState>('idle')
const loading = computed(() => loadingState.value === 'loading')
// 错误状态
const error = ref<TableError | null>(null)
// 表格数据
const data = ref<TRecord[]>([])
// 请求取消控制器
let abortController: AbortController | null = null
// 缓存清理定时器
let cacheCleanupTimer: NodeJS.Timeout | null = null
// 搜索参数
const searchParams = reactive(
Object.assign(
{
[pageKey]: 1,
[sizeKey]: 10
},
apiParams || {}
) as TParams
)
// 分页配置
const pagination = reactive<Api.Common.PaginationParams>({
current: ((searchParams as Record<string, unknown>)[pageKey] as number) || 1,
size: ((searchParams as Record<string, unknown>)[sizeKey] as number) || 10,
total: 0
})
// 移动端分页 (响应式)
const { width } = useWindowSize()
const mobilePagination = computed(() => ({
...pagination,
small: width.value < 768
}))
// 列配置
const columnConfig = columnsFactory ? useTableColumns<TRecord>(columnsFactory) : null
const columns = columnConfig?.columns
const columnChecks = columnConfig?.columnChecks
// 是否有数据
const hasData = computed(() => data.value.length > 0)
// 缓存统计信息
const cacheInfo = computed(() => {
// 依赖触发器,确保缓存变化时重新计算
void cacheUpdateTrigger.value
if (!cache) return { total: 0, size: '0KB', hitRate: '0 avg hits' }
return cache.getStats()
})
// 错误处理函数
const handleError = createErrorHandler(onError, enableLog)
// 清理缓存,根据不同的业务场景选择性地清理缓存
const clearCache = (strategy: CacheInvalidationStrategy, context?: string): void => {
if (!cache) return
let clearedCount = 0
switch (strategy) {
case CacheInvalidationStrategy.CLEAR_ALL:
cache.clear()
logger.log(`清空所有缓存 - ${context || ''}`)
break
case CacheInvalidationStrategy.CLEAR_CURRENT:
clearedCount = cache.clearCurrentSearch(searchParams)
logger.log(`清空当前搜索缓存 ${clearedCount} 条 - ${context || ''}`)
break
case CacheInvalidationStrategy.CLEAR_PAGINATION:
clearedCount = cache.clearPagination()
logger.log(`清空分页缓存 ${clearedCount} 条 - ${context || ''}`)
break
case CacheInvalidationStrategy.KEEP_ALL:
default:
logger.log(`保持缓存不变 - ${context || ''}`)
break
}
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
}
// 获取数据的核心方法
const fetchData = async (
params?: Partial<TParams>,
useCache = enableCache
): Promise<ApiResponse<TRecord>> => {
// 取消上一个请求
if (abortController) {
abortController.abort()
}
// 创建新的取消控制器
const currentController = new AbortController()
abortController = currentController
// 状态机:进入 loading 状态
loadingState.value = 'loading'
error.value = null
try {
let requestParams = Object.assign(
{},
searchParams,
{
[pageKey]: pagination.current,
[sizeKey]: pagination.size
},
params || {}
) as TParams
// 剔除不需要的参数
if (excludeParams.length > 0) {
const filteredParams = { ...requestParams }
excludeParams.forEach((key) => {
delete (filteredParams as Record<string, unknown>)[key]
})
requestParams = filteredParams as TParams
}
// 检查缓存
if (useCache && cache) {
const cachedItem = cache.get(requestParams)
if (cachedItem) {
data.value = cachedItem.data
updatePaginationFromResponse(pagination, cachedItem.response)
// 修复:避免重复设置相同的值,防止响应式循环更新
const paramsRecord = searchParams as Record<string, unknown>
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current
}
if (paramsRecord[sizeKey] !== pagination.size) {
paramsRecord[sizeKey] = pagination.size
}
// 状态机:缓存命中,进入 success 状态
loadingState.value = 'success'
// 缓存命中时触发专门的回调,而不是 onSuccess
if (onCacheHit) {
onCacheHit(cachedItem.data, cachedItem.response)
}
logger.log(`缓存命中`)
return cachedItem.response
}
}
const response = await apiFn(requestParams)
// 检查请求是否被取消
if (currentController.signal.aborted) {
throw new Error('请求已取消')
}
// 使用响应适配器转换为标准格式
const standardResponse = responseAdapter(response)
// 处理响应数据
let tableData = extractTableData(standardResponse)
// 应用数据转换函数
if (dataTransformer) {
tableData = dataTransformer(tableData)
}
// 更新状态
data.value = tableData
updatePaginationFromResponse(pagination, standardResponse)
// 修复:避免重复设置相同的值,防止响应式循环更新
const paramsRecord = searchParams as Record<string, unknown>
if (paramsRecord[pageKey] !== pagination.current) {
paramsRecord[pageKey] = pagination.current
}
if (paramsRecord[sizeKey] !== pagination.size) {
paramsRecord[sizeKey] = pagination.size
}
// 缓存数据
if (useCache && cache) {
cache.set(requestParams, tableData, standardResponse)
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
logger.log(`数据已缓存`)
}
// 状态机:请求成功,进入 success 状态
loadingState.value = 'success'
// 成功回调
if (onSuccess) {
onSuccess(tableData, standardResponse)
}
return standardResponse
} catch (err) {
if (err instanceof Error && err.message === '请求已取消') {
// 请求被取消,回到 idle 状态
loadingState.value = 'idle'
return { records: [], total: 0, current: 1, size: 10 }
}
// 状态机:请求失败,进入 error 状态
loadingState.value = 'error'
data.value = []
const tableError = handleError(err, '获取表格数据失败')
throw tableError
} finally {
// 只有当前控制器是活跃的才清空
if (abortController === currentController) {
abortController = null
}
}
}
// 获取数据 (保持当前页)
const getData = async (params?: Partial<TParams>): Promise<ApiResponse<TRecord> | void> => {
try {
return await fetchData(params)
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve()
}
}
// 分页获取数据 (重置到第一页) - 专门用于搜索场景
const getDataByPage = async (params?: Partial<TParams>): Promise<ApiResponse<TRecord> | void> => {
pagination.current = 1
;(searchParams as Record<string, unknown>)[pageKey] = 1
// 搜索时清空当前搜索条件的缓存,确保获取最新数据
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据')
try {
return await fetchData(params, false) // 搜索时不使用缓存
} catch {
// 错误已在 fetchData 中处理
return Promise.resolve()
}
}
// 智能防抖搜索函数
const debouncedGetDataByPage = createSmartDebounce(getDataByPage, debounceTime)
// 重置搜索参数
const resetSearchParams = async (): Promise<void> => {
// 取消防抖的搜索
debouncedGetDataByPage.cancel()
// 保存分页相关的默认值
const paramsRecord = searchParams as Record<string, unknown>
const defaultPagination = {
[pageKey]: 1,
[sizeKey]: (paramsRecord[sizeKey] as number) || 10
}
// 清空所有搜索参数
Object.keys(searchParams).forEach((key) => {
delete paramsRecord[key]
})
// 重新设置默认参数
Object.assign(searchParams, apiParams || {}, defaultPagination)
// 重置分页
pagination.current = 1
pagination.size = defaultPagination[sizeKey] as number
// 清空错误状态
error.value = null
// 清空缓存
clearCache(CacheInvalidationStrategy.CLEAR_ALL, '重置搜索')
// 重新获取数据
await getData()
// 执行重置回调
if (resetFormCallback) {
await nextTick()
resetFormCallback()
}
}
// 防重复调用的标志
let isCurrentChanging = false
// 处理分页大小变化
const handleSizeChange = async (newSize: number): Promise<void> => {
if (newSize <= 0) return
debouncedGetDataByPage.cancel()
const paramsRecord = searchParams as Record<string, unknown>
pagination.size = newSize
pagination.current = 1
paramsRecord[sizeKey] = newSize
paramsRecord[pageKey] = 1
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '分页大小变化')
await getData()
}
// 处理当前页变化
const handleCurrentChange = async (newCurrent: number): Promise<void> => {
if (newCurrent <= 0) return
// 修复:防止重复调用
if (isCurrentChanging) {
return
}
// 修复:如果当前页没有变化,不需要重新请求
if (pagination.current === newCurrent) {
logger.log('分页页码未变化,跳过请求')
return
}
try {
isCurrentChanging = true
// 修复:只更新必要的状态
const paramsRecord = searchParams as Record<string, unknown>
pagination.current = newCurrent
// 只有当 searchParams 的分页字段与新值不同时才更新
if (paramsRecord[pageKey] !== newCurrent) {
paramsRecord[pageKey] = newCurrent
}
await getData()
} finally {
isCurrentChanging = false
}
}
// 处理表格排序变化:更新查询参数中的排序字段与排序类型,并请求后端数据
const handleSortChange = async (payload: {
column?: unknown
prop?: string
order?: 'ascending' | 'descending' | null
}): Promise<void> => {
const paramsRecord = searchParams as Record<string, unknown>
// 如果清除排序,则移除相关查询参数
if (!payload.order || !payload.prop) {
delete paramsRecord[orderFieldKey]
delete paramsRecord[orderTypeKey]
} else {
paramsRecord[orderFieldKey] = payload.prop
paramsRecord[orderTypeKey] = payload.order === 'ascending' ? 'asc' : 'desc'
}
// 排序变化通常回到第一页以保持数据一致性
pagination.current = 1
paramsRecord[pageKey] = 1
// 清除当前搜索缓存,确保拿到最新排序数据
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '排序变化')
await getData()
}
// 针对不同业务场景的刷新方法
// 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后)
const refreshCreate = async (): Promise<void> => {
debouncedGetDataByPage.cancel()
pagination.current = 1
;(searchParams as Record<string, unknown>)[pageKey] = 1
clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据')
await getData()
}
// 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后)
const refreshUpdate = async (): Promise<void> => {
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '编辑数据')
await getData()
}
// 删除后刷新:智能处理页码,避免空页面(适用于删除数据后)
const refreshRemove = async (): Promise<void> => {
const { current } = pagination
// 清除缓存并获取最新数据
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '删除数据')
await getData()
// 如果当前页为空且不是第一页,回到上一页
if (data.value.length === 0 && current > 1) {
pagination.current = current - 1
;(searchParams as Record<string, unknown>)[pageKey] = current - 1
await getData()
}
}
// 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮)
const refreshData = async (): Promise<void> => {
debouncedGetDataByPage.cancel()
clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新')
await getData()
}
// 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新)
const refreshSoft = async (): Promise<void> => {
clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '软刷新')
await getData()
}
// 取消当前请求
const cancelRequest = (): void => {
if (abortController) {
abortController.abort()
}
debouncedGetDataByPage.cancel()
}
// 清空数据
const clearData = (): void => {
data.value = []
error.value = null
clearCache(CacheInvalidationStrategy.CLEAR_ALL, '清空数据')
}
// 清理已过期的缓存条目,释放内存空间
const clearExpiredCache = (): number => {
if (!cache) return 0
const cleanedCount = cache.cleanupExpired()
if (cleanedCount > 0) {
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
}
return cleanedCount
}
// 设置定期清理过期缓存
if (enableCache && cache) {
cacheCleanupTimer = setInterval(() => {
const cleanedCount = cache.cleanupExpired()
if (cleanedCount > 0) {
logger.log(`自动清理 ${cleanedCount} 条过期缓存`)
// 手动触发缓存状态更新
cacheUpdateTrigger.value++
}
}, cacheTime / 2) // 每半个缓存周期清理一次
}
// 挂载时自动加载数据
if (immediate) {
onMounted(async () => {
await getData()
})
}
// 组件卸载时彻底清理
onUnmounted(() => {
cancelRequest()
if (cache) {
cache.clear()
}
if (cacheCleanupTimer) {
clearInterval(cacheCleanupTimer)
}
})
// 优化的返回值结构
return {
// 数据相关
/** 表格数据 */
data,
/** 数据加载状态 */
loading: readonly(loading),
/** 错误状态 */
error: readonly(error),
/** 数据是否为空 */
isEmpty: computed(() => data.value.length === 0),
/** 是否有数据 */
hasData,
// 分页相关
/** 分页状态信息 */
pagination: readonly(pagination),
/** 移动端分页配置 */
paginationMobile: mobilePagination,
/** 页面大小变化处理 */
handleSizeChange,
/** 当前页变化处理 */
handleCurrentChange,
/** 排序变化处理 */
handleSortChange,
// 搜索相关 - 统一前缀
/** 搜索参数 */
searchParams,
/** 重置搜索参数 */
resetSearchParams,
// 数据操作 - 更明确的操作意图
/** 加载数据 */
fetchData: getData,
/** 获取数据 */
getData: getDataByPage,
/** 获取数据(防抖) */
getDataDebounced: debouncedGetDataByPage,
/** 清空数据 */
clearData,
// 刷新策略
/** 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) */
refreshData,
/** 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) */
refreshSoft,
/** 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) */
refreshCreate,
/** 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) */
refreshUpdate,
/** 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) */
refreshRemove,
// 缓存控制
/** 缓存统计信息 */
cacheInfo,
/** 清除缓存,根据不同的业务场景选择性地清理缓存: */
clearCache,
// 支持4种清理策略
// clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') // 清空所有缓存
// clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '搜索数据') // 只清空当前搜索条件的缓存
// clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') // 清空分页相关缓存
// clearCache(CacheInvalidationStrategy.KEEP_ALL, '保持缓存') // 不清理任何缓存
/** 清理已过期的缓存条目,释放内存空间 */
clearExpiredCache,
// 请求控制
/** 取消当前请求 */
cancelRequest,
// 列配置 (如果提供了 columnsFactory)
...(columnConfig && {
/** 表格列配置 */
columns,
/** 列显示控制 */
columnChecks,
/** 新增列 */
addColumn: columnConfig.addColumn,
/** 删除列 */
removeColumn: columnConfig.removeColumn,
/** 切换列显示状态 */
toggleColumn: columnConfig.toggleColumn,
/** 更新列配置 */
updateColumn: columnConfig.updateColumn,
/** 批量更新列配置 */
batchUpdateColumns: columnConfig.batchUpdateColumns,
/** 重新排序列 */
reorderColumns: columnConfig.reorderColumns,
/** 获取指定列配置 */
getColumnConfig: columnConfig.getColumnConfig,
/** 获取所有列配置 */
getAllColumns: columnConfig.getAllColumns,
/** 重置所有列配置到默认状态 */
resetColumns: columnConfig.resetColumns
})
}
}
// 重新导出类型和枚举,方便使用
export { CacheInvalidationStrategy } from '../../utils/table/tableCache'
export type { ApiResponse, CacheItem } from '../../utils/table/tableCache'
export type { BaseRequestParams, TableError } from '../../utils/table/tableUtils'

View File

@@ -0,0 +1,312 @@
/**
* useTableColumns - 表格列配置管理
*
* 提供动态的表格列配置管理能力,支持运行时灵活控制列的显示、隐藏、排序等操作。
* 通常与 useTable 配合使用,为表格提供完整的列管理功能。
*
* ## 主要功能
*
* 1. 列显示控制 - 动态显示/隐藏列,支持批量操作
* 2. 列排序 - 拖拽或编程方式重新排列列顺序
* 3. 列配置管理 - 新增、删除、更新列配置
* 4. 特殊列支持 - 自动处理 selection、expand、index 等特殊列
* 5. 状态持久化 - 保持列的显示状态,支持重置到初始状态
*
* ## 使用示例
*
* ```typescript
* const { columns, columnChecks, toggleColumn, reorderColumns } = useTableColumns(() => [
* { prop: 'name', label: '姓名', visible: true },
* { prop: 'email', label: '邮箱', visible: true },
* { prop: 'status', label: '状态', visible: false }
* ])
*
* // 切换列显示
* toggleColumn('email', false)
*
* // 重新排序
* reorderColumns(0, 2)
* ```
*
* @module useTableColumns
* @author Art Design Pro Team
*/
import { ref, computed, watch } from 'vue'
import { $t } from '@/locales'
import type { ColumnOption } from '@/types/component'
/**
* 特殊列类型
*/
const SPECIAL_COLUMNS: Record<string, { prop: string; label: string }> = {
selection: { prop: '__selection__', label: $t('table.column.selection') },
expand: { prop: '__expand__', label: $t('table.column.expand') },
index: { prop: '__index__', label: $t('table.column.index') }
}
/**
* 获取列的唯一标识
*/
export const getColumnKey = <T>(col: ColumnOption<T>) =>
SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string)
/**
* 获取列的显示状态
* 优先使用 visible 字段,如果不存在则使用 checked 字段
*/
export const getColumnVisibility = <T>(col: ColumnOption<T>): boolean => {
// visible 优先级高于 checked
if (col.visible !== undefined) {
return col.visible
}
// 如果 visible 未定义,使用 checked默认为 true
return col.checked ?? true
}
/**
* 获取列的检查状态
*/
export const getColumnChecks = <T>(columns: ColumnOption<T>[]) =>
columns.map((col) => {
const special = col.type && SPECIAL_COLUMNS[col.type]
const visibility = getColumnVisibility(col)
if (special) {
return { ...col, prop: special.prop, label: special.label, checked: true, visible: true }
}
return { ...col, checked: visibility, visible: visibility }
})
/**
* 动态列配置接口
*/
export interface DynamicColumnConfig<T = any> {
/**
* 新增列(支持单个或批量)
* @param column 列配置或列配置数组
* @param index 可选的插入位置,默认末尾(批量时为第一个列的位置)
*/
addColumn: (column: ColumnOption<T> | ColumnOption<T>[], index?: number) => void
/**
* 删除列(支持单个或批量)
* @param prop 列的唯一标识或标识数组
*/
removeColumn: (prop: string | string[]) => void
/**
* 切换列显示状态(支持单个或批量)
* @param prop 列的唯一标识或标识数组
* @param visible 可选的显示状态,默认取反
*/
toggleColumn: (prop: string | string[], visible?: boolean) => void
/**
* 更新列(支持单个或批量)
* @param prop 列的唯一标识或更新配置数组
* @param updates 列配置更新(当 prop 为字符串时使用)
*/
updateColumn: (
prop: string | Array<{ prop: string; updates: Partial<ColumnOption<T>> }>,
updates?: Partial<ColumnOption<T>>
) => void
/**
* 批量更新列(兼容旧版本,推荐使用 updateColumn 的数组模式)
* @param updates 列更新配置
* @deprecated 推荐使用 updateColumn 的数组模式
*/
batchUpdateColumns: (updates: Array<{ prop: string; updates: Partial<ColumnOption<T>> }>) => void
/**
* 重新排序列
* @param fromIndex 源索引
* @param toIndex 目标索引
*/
reorderColumns: (fromIndex: number, toIndex: number) => void
/**
* 获取列配置
* @param prop 列的唯一标识
* @returns 列配置
*/
getColumnConfig: (prop: string) => ColumnOption<T> | undefined
/**
* 获取所有列配置
* @returns 所有列配置
*/
getAllColumns: () => ColumnOption<T>[]
/**
* 重置所有列
*/
resetColumns: () => void
}
export function useTableColumns<T = any>(
columnsFactory: () => ColumnOption<T>[]
): {
columns: any
columnChecks: any
} & DynamicColumnConfig<T> {
const dynamicColumns = ref<ColumnOption<T>[]>(columnsFactory())
const columnChecks = ref<ColumnOption<T>[]>(getColumnChecks(dynamicColumns.value))
// 当 dynamicColumns 变动时,重新生成 columnChecks 且保留已存在的显示状态
watch(
dynamicColumns,
(newCols) => {
const visibilityMap = new Map(
columnChecks.value.map((c) => [getColumnKey(c), getColumnVisibility(c)])
)
const newChecks = getColumnChecks(newCols).map((c) => {
const key = getColumnKey(c)
const visibility = visibilityMap.has(key) ? visibilityMap.get(key) : getColumnVisibility(c)
return {
...c,
checked: visibility,
visible: visibility
}
})
columnChecks.value = newChecks
},
{ deep: true }
)
// 当前显示列(基于 columnChecks 的 checked 或 visible
const columns = computed(() => {
const colMap = new Map(dynamicColumns.value.map((c) => [getColumnKey(c), c]))
return columnChecks.value
.filter((c) => getColumnVisibility(c))
.map((c) => colMap.get(getColumnKey(c)))
.filter(Boolean) as ColumnOption<T>[]
})
// 支持 updater 返回新数组或直接在传入数组上 mutate
const setDynamicColumns = (updater: (cols: ColumnOption<T>[]) => void | ColumnOption<T>[]) => {
const copy = [...dynamicColumns.value]
const result = updater(copy)
dynamicColumns.value = Array.isArray(result) ? result : copy
}
return {
columns,
columnChecks,
/**
* 新增列(支持单个或批量)
*/
addColumn: (column: ColumnOption<T> | ColumnOption<T>[], index?: number) =>
setDynamicColumns((cols) => {
const next = [...cols]
const columnsToAdd = Array.isArray(column) ? column : [column]
const insertIndex =
typeof index === 'number' && index >= 0 && index <= next.length ? index : next.length
// 批量插入
next.splice(insertIndex, 0, ...columnsToAdd)
return next
}),
/**
* 删除列(支持单个或批量)
*/
removeColumn: (prop: string | string[]) =>
setDynamicColumns((cols) => {
const propsToRemove = Array.isArray(prop) ? prop : [prop]
return cols.filter((c) => !propsToRemove.includes(getColumnKey(c)))
}),
/**
* 更新列(支持单个或批量)
*/
updateColumn: (
prop: string | Array<{ prop: string; updates: Partial<ColumnOption<T>> }>,
updates?: Partial<ColumnOption<T>>
) => {
// 批量模式prop 是数组
if (Array.isArray(prop)) {
setDynamicColumns((cols) => {
const map = new Map(prop.map((u) => [u.prop, u.updates]))
return cols.map((c) => {
const key = getColumnKey(c)
const upd = map.get(key)
return upd ? { ...c, ...upd } : c
})
})
}
// 单个模式prop 是字符串
else if (updates) {
setDynamicColumns((cols) =>
cols.map((c) => (getColumnKey(c) === prop ? { ...c, ...updates } : c))
)
}
},
/**
* 切换列显示状态(支持单个或批量)
*/
toggleColumn: (prop: string | string[], visible?: boolean) => {
const propsToToggle = Array.isArray(prop) ? prop : [prop]
const next = [...columnChecks.value]
propsToToggle.forEach((p) => {
const i = next.findIndex((c) => getColumnKey(c) === p)
if (i > -1) {
const currentVisibility = getColumnVisibility(next[i])
const newVisibility = visible ?? !currentVisibility
// 同时更新 checked 和 visible 以保持兼容性
next[i] = { ...next[i], checked: newVisibility, visible: newVisibility }
}
})
columnChecks.value = next
},
/**
* 重置所有列
*/
resetColumns: () => {
dynamicColumns.value = columnsFactory()
},
/**
* 批量更新列(兼容旧版本)
* @deprecated 推荐使用 updateColumn 的数组模式
*/
batchUpdateColumns: (updates) =>
setDynamicColumns((cols) => {
const map = new Map(updates.map((u) => [u.prop, u.updates]))
return cols.map((c) => {
const key = getColumnKey(c)
const upd = map.get(key)
return upd ? { ...c, ...upd } : c
})
}),
/**
* 重新排序列
*/
reorderColumns: (fromIndex: number, toIndex: number) =>
setDynamicColumns((cols) => {
if (
fromIndex < 0 ||
fromIndex >= cols.length ||
toIndex < 0 ||
toIndex >= cols.length ||
fromIndex === toIndex
) {
return cols
}
const next = [...cols]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
return next
}),
/**
* 获取列配置
*/
getColumnConfig: (prop: string) => dynamicColumns.value.find((c) => getColumnKey(c) === prop),
/**
* 获取所有列配置
*/
getAllColumns: () => [...dynamicColumns.value]
}
}

View File

@@ -0,0 +1,105 @@
/**
* useTableHeight - 表格高度自动计算
*
* 自动计算表格容器的最佳高度,确保表格在不同布局场景下都能正确显示。
* 根据表格头部、分页器等元素的高度动态调整容器高度,避免出现滚动条或布局错乱。
*
* ## 主要功能
*
* 1. 动态高度计算 - 根据表格头部、分页器高度自动计算容器高度
* 2. 响应式更新 - 配置变化时自动重新计算高度
* 3. 灵活配置 - 支持自定义各部分高度和间距
* 4. 智能适配 - 无额外元素时自动使用 100% 高度
*
* @module useTableHeight
* @author Art Design Pro Team
*/
import { computed, type Ref } from 'vue'
/**
* 表格高度计算器配置接口
*/
interface TableHeightOptions {
/** 是否显示表格头部 */
showTableHeader: Ref<boolean>
/** 分页器高度 */
paginationHeight: Ref<number>
/** 表格头部高度 */
tableHeaderHeight: Ref<number>
/** 分页器间距 */
paginationSpacing: Ref<number>
}
/**
* 表格高度计算器类
*/
class TableHeightCalculator {
// 常量配置
private static readonly DEFAULT_TABLE_HEADER_HEIGHT = 44
private static readonly TABLE_HEADER_SPACING = 12
constructor(private options: TableHeightOptions) {}
/**
* 计算容器高度
*/
calculate(): { height: string } {
const offset = this.calculateOffset()
return {
height: offset === 0 ? '100%' : `calc(100% - ${offset}px)`
}
}
/**
* 计算偏移量
*/
private calculateOffset(): number {
if (!this.options.showTableHeader.value) {
return this.calculatePaginationOffset()
}
const headerHeight = this.getHeaderHeight()
const paginationOffset = this.calculatePaginationOffset()
return headerHeight + paginationOffset + TableHeightCalculator.TABLE_HEADER_SPACING
}
/**
* 获取表格头部高度
*/
private getHeaderHeight(): number {
return this.options.tableHeaderHeight.value || TableHeightCalculator.DEFAULT_TABLE_HEADER_HEIGHT
}
/**
* 计算分页器偏移量
*/
private calculatePaginationOffset(): number {
const { paginationHeight, paginationSpacing } = this.options
return paginationHeight.value === 0 ? 0 : paginationHeight.value + paginationSpacing.value
}
}
/**
* 表格高度计算 Hook
*
* 提供表格容器高度的自动计算功能,支持:
* - 表格头部高度
* - 分页器高度
* - 动态间距计算
*
* @param options 配置选项
* @returns 容器高度计算结果
*/
export function useTableHeight(options: TableHeightOptions) {
const containerHeight = computed(() => {
const calculator = new TableHeightCalculator(options)
return calculator.calculate()
})
return {
/** 容器高度样式对象 */
containerHeight
}
}

View File

@@ -0,0 +1,174 @@
/**
* useTheme - 系统主题管理
*
* 提供完整的主题切换和管理功能,支持亮色、暗色和自动模式。
* 自动处理主题切换时的过渡效果,确保切换流畅无闪烁。
*
* ## 主要功能
*
* 1. 主题切换 - 支持亮色、暗色、自动三种主题模式
* 2. 自动模式 - 根据系统偏好自动切换主题
* 3. 颜色适配 - 自动调整主题色的明暗变体9 个层级)
* 4. 过渡优化 - 切换时临时禁用过渡效果,避免闪烁
* 5. 状态持久化 - 主题设置自动保存到 store
*
* ## 使用示例
*
* ```typescript
* const { switchThemeStyles } = useTheme()
*
* // 切换到暗色主题
* switchThemeStyles(SystemThemeEnum.DARK)
*
* // 切换到亮色主题
* switchThemeStyles(SystemThemeEnum.LIGHT)
*
* // 切换到自动模式(跟随系统)
* switchThemeStyles(SystemThemeEnum.AUTO)
* ```
*
* @module useTheme
* @author Art Design Pro Team
*/
import { useSettingStore } from '@/store/modules/setting'
import { SystemThemeEnum } from '@/enums/appEnum'
import AppConfig from '@/config'
import { SystemThemeTypes } from '@/types/store'
import { getDarkColor, getLightColor, setElementThemeColor } from '@/utils/ui'
import { usePreferredDark } from '@vueuse/core'
import { watch } from 'vue'
export function useTheme() {
const settingStore = useSettingStore()
// 禁用过渡效果
const disableTransitions = () => {
const style = document.createElement('style')
style.setAttribute('id', 'disable-transitions')
style.textContent = '* { transition: none !important; }'
document.head.appendChild(style)
}
// 启用过渡效果
const enableTransitions = () => {
const style = document.getElementById('disable-transitions')
if (style) {
style.remove()
}
}
// 设置系统主题
const setSystemTheme = (theme: SystemThemeEnum, themeMode?: SystemThemeEnum) => {
// 临时禁用过渡效果
disableTransitions()
const el = document.getElementsByTagName('html')[0]
const isDark = theme === SystemThemeEnum.DARK
if (!themeMode) {
themeMode = theme
}
const currentTheme = AppConfig.systemThemeStyles[theme as keyof SystemThemeTypes]
if (currentTheme) {
el.setAttribute('class', currentTheme.className)
}
// 设置按钮颜色加深或变浅
const primary = settingStore.systemThemeColor
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
isDark ? `${getDarkColor(primary, i / 10)}` : `${getLightColor(primary, i / 10)}`
)
}
// 更新store中的主题设置
settingStore.setGlopTheme(theme, themeMode)
// 使用 requestAnimationFrame 确保在下一帧恢复过渡效果
requestAnimationFrame(() => {
requestAnimationFrame(() => {
enableTransitions()
})
})
}
// 使用 VueUse 的 usePreferredDark 检测系统主题偏好
const prefersDark = usePreferredDark()
// 自动设置系统主题
const setSystemAutoTheme = () => {
const theme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT
setSystemTheme(theme, SystemThemeEnum.AUTO)
}
// 切换主题
const switchThemeStyles = (theme: SystemThemeEnum) => {
if (theme === SystemThemeEnum.AUTO) {
setSystemAutoTheme()
} else {
setSystemTheme(theme)
}
}
return {
setSystemTheme,
setSystemAutoTheme,
switchThemeStyles,
prefersDark
}
}
/**
* 初始化主题系统
*/
export function initializeTheme() {
const settingStore = useSettingStore()
const prefersDark = usePreferredDark()
// 根据系统偏好应用主题
const applyThemeByMode = () => {
const el = document.getElementsByTagName('html')[0]
let actualTheme = settingStore.systemThemeType
// 如果是 AUTO 模式,检测系统偏好
if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) {
actualTheme = prefersDark.value ? SystemThemeEnum.DARK : SystemThemeEnum.LIGHT
// 更新实际应用的主题类型
settingStore.systemThemeType = actualTheme
}
// 设置主题 class
const currentTheme = AppConfig.systemThemeStyles[actualTheme as keyof SystemThemeTypes]
if (currentTheme) {
el.setAttribute('class', currentTheme.className)
}
// 设置主题颜色
setElementThemeColor(settingStore.systemThemeColor)
// 设置圆角
document.documentElement.style.setProperty('--custom-radius', `${settingStore.customRadius}rem`)
}
// 应用主题
applyThemeByMode()
// 如果是 AUTO 模式,监听系统主题变化(使用 VueUse 的响应式特性)
if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) {
watch(
prefersDark,
() => {
// 只有在 AUTO 模式下才响应系统主题变化
if (settingStore.systemThemeMode === SystemThemeEnum.AUTO) {
applyThemeByMode()
}
},
{ immediate: false }
)
}
}

View File

@@ -0,0 +1,32 @@
// 通用功能集合
export { useCommon } from './core/useCommon'
// 应用模式
export { useAppMode } from './core/useAppMode'
// 权限控制
export { useAuth } from './core/useAuth'
// 表格数据管理方案
export { useTable } from './core/useTable'
// 表格列配置管理
export { useTableColumns } from './core/useTableColumns'
// 主题相关
export { useTheme } from './core/useTheme'
// 礼花+文字滚动
export { useCeremony } from './core/useCeremony'
// 顶栏快速入口
export { useFastEnter } from './core/useFastEnter'
// 顶栏功能管理
export { useHeaderBar } from './core/useHeaderBar'
// 图表相关
export { useChart, useChartComponent, useChartOps } from './core/useChart'
// 布局高度
export { useLayoutHeight, useAutoLayoutHeight } from './core/useLayoutHeight'

View File

@@ -0,0 +1,123 @@
/**
* 国际化配置
*
* 基于 vue-i18n 实现的多语言国际化解决方案。
* 支持中文和英文切换,自动从本地存储恢复用户的语言偏好。
*
* ## 主要功能
*
* - 多语言支持 - 支持中文(简体)和英文两种语言
* - 语言切换 - 运行时动态切换语言,无需刷新页面
* - 持久化存储 - 自动保存和恢复用户的语言偏好
* - 全局注入 - 在任何组件中都可以使用 $t 函数进行翻译
* - 类型安全 - 提供 TypeScript 类型支持
*
* ## 支持的语言
*
* - zh: 简体中文
* - en: English
*
* @module locales
* @author Art Design Pro Team
*/
import { createI18n } from 'vue-i18n'
import type { I18n, I18nOptions } from 'vue-i18n'
import { LanguageEnum } from '@/enums/appEnum'
import { getSystemStorage } from '@/utils/storage'
import { StorageKeyManager } from '@/utils/storage/storage-key-manager'
// 同步导入语言文件
import enMessages from './langs/en.json'
import zhMessages from './langs/zh.json'
/**
* 存储键管理器实例
*/
const storageKeyManager = new StorageKeyManager()
/**
* 语言消息对象
*/
const messages = {
[LanguageEnum.EN]: enMessages,
[LanguageEnum.ZH]: zhMessages
}
/**
* 语言选项列表
* 用于语言切换下拉框
*/
export const languageOptions = [
{ value: LanguageEnum.ZH, label: '简体中文' },
{ value: LanguageEnum.EN, label: 'English' }
]
/**
* 从存储中获取语言设置
* @returns 语言设置,如果获取失败则返回默认语言
*/
const getDefaultLanguage = (): LanguageEnum => {
// 尝试从版本化的存储中获取语言设置
try {
const storageKey = storageKeyManager.getStorageKey('user')
const userStore = localStorage.getItem(storageKey)
if (userStore) {
const { language } = JSON.parse(userStore)
if (language && Object.values(LanguageEnum).includes(language)) {
return language
}
}
} catch (error) {
console.warn('[i18n] 从版本化存储获取语言设置失败:', error)
}
// 尝试从系统存储中获取语言设置
try {
const sys = getSystemStorage()
if (sys) {
const { user } = JSON.parse(sys)
if (user?.language && Object.values(LanguageEnum).includes(user.language)) {
return user.language
}
}
} catch (error) {
console.warn('[i18n] 从系统存储获取语言设置失败:', error)
}
// 返回默认语言
console.debug('[i18n] 使用默认语言:', LanguageEnum.ZH)
return LanguageEnum.ZH
}
/**
* i18n 配置选项
*/
const i18nOptions: I18nOptions = {
locale: getDefaultLanguage(),
legacy: false,
globalInjection: true,
fallbackLocale: LanguageEnum.ZH,
messages
}
/**
* i18n 实例
*/
const i18n: I18n = createI18n(i18nOptions)
/**
* 翻译函数类型
*/
interface Translation {
(key: string): string
}
/**
* 全局翻译函数
* 可在任何地方使用,无需导入 useI18n
*/
export const $t = i18n.global.t as Translation
export default i18n

View File

@@ -0,0 +1,297 @@
{
"httpMsg": {
"unauthorized": "Unauthorized access, please login again",
"forbidden": "Access to this resource is forbidden",
"notFound": "The requested resource does not exist",
"methodNotAllowed": "Request method not allowed",
"requestTimeout": "Request timeout, please try again later",
"internalServerError": "Internal server error, please try again later",
"badGateway": "Bad gateway error, please try again later",
"serviceUnavailable": "Service temporarily unavailable, please try again later",
"gatewayTimeout": "Gateway timeout, please try again later",
"requestCancelled": "Request cancelled",
"networkError": "Network connection error, please check your connection",
"requestFailed": "Request failed",
"requestConfigError": "Request configuration error"
},
"topBar": {
"search": {
"title": "Search"
},
"user": {
"userCenter": "User center",
"docs": "Document",
"github": "Github",
"lockScreen": "Lock screen",
"logout": "Log out"
},
"guide": {
"title": "Click here to view",
"theme": "Theme style",
"menu": "Open top menu",
"description": "More configurations"
}
},
"common": {
"tips": "Prompt",
"cancel": "Cancel",
"confirm": "Confirm",
"logOutTips": "Do you want to log out?"
},
"search": {
"placeholder": "Search page",
"historyTitle": "Search history",
"switchKeydown": "Navigate",
"selectKeydown": "Select",
"exitKeydown": "Close"
},
"setting": {
"menuType": {
"title": "Menu Layout",
"list": [
"Vertical",
"Horizontal",
"Mixed",
"Dual"
]
},
"theme": {
"title": "Theme Style",
"list": [
"Light",
"Dark",
"System"
]
},
"menu": {
"title": "Menu Style"
},
"color": {
"title": "Theme Color"
},
"box": {
"title": "Box Style",
"list": [
"Border",
"Shadow"
]
},
"container": {
"title": "Container Width",
"list": [
"Full",
"Boxed"
]
},
"basics": {
"title": "Basic Config",
"list": {
"multiTab": "Show work tab",
"accordion": "Sidebar opens accordion",
"collapseSidebar": "Show sidebar button",
"reloadPage": "Show reload page button",
"fastEnter": "Show fast enter",
"breadcrumb": "Show crumb navigation",
"language": "Show multilingual selection",
"progressBar": "Show top progress bar",
"weakMode": "Color Weakness Mode",
"watermark": "Global watermark",
"menuWidth": "Menu width",
"tabStyle": "Tab style",
"pageTransition": "Page animation",
"borderRadius": "Custom radius"
}
},
"tabStyle": {
"default": "Default",
"card": "Card",
"google": "Chrome"
},
"transition": {
"list": {
"none": "None",
"fade": "Fade",
"slideLeft": "Slide Left",
"slideBottom": "Slide Bottom",
"slideTop": "Slide Top"
}
},
"actions": {
"resetConfig": "Reset Config",
"copyConfig": "Copy Config",
"copySuccess": "Configuration copied to clipboard, paste it into src/config/setting.ts file",
"copyFailed": "Copy failed, please try again",
"resetFailed": "Reset failed, please refresh the page and try again"
}
},
"notice": {
"title": "Notice",
"btnRead": "Mark as read",
"bar": [
"Notice",
"Message",
"Todo"
],
"text": [
"No"
],
"viewAll": "View all"
},
"worktab": {
"btn": {
"refresh": "Refresh",
"fixed": "Fixed",
"unfixed": "Unfixed",
"closeLeft": "Close left",
"closeRight": "Close right",
"closeOther": "Close other",
"closeAll": "Close all"
}
},
"login": {
"leftView": {
"title": "A backend system of beauty and efficiency",
"subTitle": "A sleek and practical interface for a great user experience"
},
"title": "Welcome back",
"subTitle": "Please enter your account and password to login",
"roles": {
"super": "Super Admin",
"admin": "Admin",
"user": "User"
},
"placeholder": {
"username": "Please enter your account",
"password": "Please enter your password",
"code": "Please enter the verification code",
"slider": "Please slide to verify"
},
"sliderText": "Please slide to verify",
"sliderSuccessText": "Verification successful",
"rememberPwd": "Remember password",
"forgetPwd": "Forgot password",
"btnText": "Login",
"noAccount": "No account yet?",
"register": "Register",
"success": {
"title": "Login successful",
"message": "Welcome back"
}
},
"forgetPassword": {
"title": "Forgot password?",
"subTitle": "Enter your email to reset your password",
"placeholder": "Please enter your email",
"submitBtnText": "Submit",
"backBtnText": "Back"
},
"register": {
"title": "Create account",
"subTitle": "Welcome to join us, please fill in the following information to complete the registration",
"placeholder": {
"username": "Please enter your account",
"password": "Please enter your password",
"confirmPassword": "Please enter your password again"
},
"rule": {
"confirmPasswordRequired": "Please enter your password again",
"passwordMismatch": "The two passwords are inconsistent!",
"usernameLength": "The length is 3 to 20 characters",
"passwordLength": "The password length cannot be less than 6 digits",
"agreementRequired": "Please agree to the privacy policy"
},
"agreeText": "I agree",
"privacyPolicy": "Privacy policy",
"submitBtnText": "Register",
"hasAccount": "Already have an account?",
"toLogin": "To login"
},
"lockScreen": {
"pwdError": "Password error",
"lock": {
"inputPlaceholder": "Please input lock screen password",
"btnText": "Lock"
},
"unlock": {
"inputPlaceholder": "Please input unlock password",
"btnText": "Unlock",
"backBtnText": "Back to login"
}
},
"greeting": {
"dawn": "Good morning!",
"morning": "Good morning!",
"afternoon": "Good afternoon!",
"evening": "Good evening!"
},
"exceptionPage": {
"403": "Sorry, you do not have permission to access this page",
"404": "Sorry, the page you are trying to access does not exist",
"500": "Sorry, there was an error on the server",
"gohome": "Go Home"
},
"menus": {
"login": {
"title": "Login"
},
"register": {
"title": "Register"
},
"forgetPassword": {
"title": "Forget Password"
},
"outside": {
"title": "Outside"
},
"dashboard": {
"title": "Dashboard",
"console": "Console"
},
"result": {
"title": "Result Page",
"success": "Success",
"fail": "Fail"
},
"exception": {
"title": "Exception",
"forbidden": "403",
"notFound": "404",
"serverError": "500"
},
"system": {
"title": "System Settings",
"user": "User Manage",
"role": "Role Manage",
"userCenter": "User Center",
"menu": "Menu Manage"
}
},
"table": {
"form": {
"reset": "Reset",
"submit": "Submit"
},
"searchBar": {
"reset": "Reset",
"search": "Search",
"expand": "Expand",
"collapse": "Collapse",
"searchInputPlaceholder": "Please enter",
"searchSelectPlaceholder": "Please select"
},
"selection": "Select",
"sizeOptions": {
"small": "Compact",
"default": "Default",
"large": "Loose"
},
"column": {
"selection": "Select",
"expand": "Expand",
"index": "Index"
},
"zebra": "Zebra",
"border": "Border",
"headerBackground": "Header BG"
}
}

View File

@@ -0,0 +1,297 @@
{
"httpMsg": {
"unauthorized": "未授权访问,请重新登录",
"forbidden": "禁止访问该资源",
"notFound": "请求的资源不存在",
"methodNotAllowed": "请求方法不允许",
"requestTimeout": "请求超时,请稍后重试",
"internalServerError": "服务器内部错误,请稍后重试",
"badGateway": "网关错误,请稍后重试",
"serviceUnavailable": "服务暂时不可用,请稍后重试",
"gatewayTimeout": "网关超时,请稍后重试",
"requestCancelled": "请求已取消",
"networkError": "网络连接异常,请检查网络连接",
"requestFailed": "请求失败",
"requestConfigError": "请求配置错误"
},
"topBar": {
"search": {
"title": "搜索"
},
"user": {
"userCenter": "个人中心",
"docs": "使用文档",
"github": "Github",
"lockScreen": "锁定屏幕",
"logout": "退出登录"
},
"guide": {
"title": "点击这里查看",
"theme": "主题风格",
"menu": "开启顶栏菜单",
"description": "等更多配置"
}
},
"common": {
"tips": "提示",
"cancel": "取消",
"confirm": "确定",
"logOutTips": "您是否要退出登录?"
},
"search": {
"placeholder": "搜索页面",
"historyTitle": "搜索历史",
"switchKeydown": "切换",
"selectKeydown": "选择",
"exitKeydown": "关闭"
},
"setting": {
"menuType": {
"title": "菜单布局",
"list": [
"垂直",
"水平",
"混合",
"双列"
]
},
"theme": {
"title": "主题风格",
"list": [
"浅色",
"深色",
"系统"
]
},
"menu": {
"title": "菜单风格"
},
"color": {
"title": "系统主题色"
},
"box": {
"title": "盒子样式",
"list": [
"边框",
"阴影"
]
},
"container": {
"title": "容器宽度",
"list": [
"铺满",
"定宽"
]
},
"basics": {
"title": "基础配置",
"list": {
"multiTab": "开启多标签栏",
"accordion": "侧边栏开启手风琴模式",
"collapseSidebar": "显示折叠侧边栏按钮",
"fastEnter": "显示快速入口",
"reloadPage": "显示重载页面按钮",
"breadcrumb": "显示全局面包屑导航",
"language": "显示多语言选择",
"progressBar": "显示顶部进度条",
"weakMode": "色弱模式",
"watermark": "全局水印",
"menuWidth": "菜单宽度",
"tabStyle": "标签页风格",
"pageTransition": "页面切换动画",
"borderRadius": "自定义圆角"
}
},
"tabStyle": {
"default": "默认",
"card": "卡片",
"google": "谷歌"
},
"transition": {
"list": {
"none": "无动画",
"fade": "淡入淡出",
"slideLeft": "左侧滑入",
"slideBottom": "下方滑入",
"slideTop": "上方滑入"
}
},
"actions": {
"resetConfig": "重置配置",
"copyConfig": "复制配置",
"copySuccess": "配置已复制到剪贴板,可粘贴到 src/config/setting.ts 文件中",
"copyFailed": "复制失败,请重试",
"resetFailed": "重置失败,请刷新页面后重试"
}
},
"notice": {
"title": "通知",
"btnRead": "标为已读",
"bar": [
"通知",
"消息",
"代办"
],
"text": [
"暂无"
],
"viewAll": "查看全部"
},
"worktab": {
"btn": {
"refresh": "刷新",
"fixed": "固定",
"unfixed": "取消固定",
"closeLeft": "关闭左侧",
"closeRight": "关闭右侧",
"closeOther": "关闭其他",
"closeAll": "关闭全部"
}
},
"login": {
"leftView": {
"title": "一款兼具设计美学与高效开发的后台系统",
"subTitle": "美观实用的界面,经过视觉优化,确保卓越的用户体验"
},
"title": "欢迎回来",
"subTitle": "输入您的账号和密码登录",
"roles": {
"super": "超级管理员",
"admin": "管理员",
"user": "普通用户"
},
"placeholder": {
"username": "请输入账号",
"password": "请输入密码",
"code": "请输入验证码",
"slider": "请拖动滑块完成验证"
},
"sliderText": "按住滑块拖动",
"sliderSuccessText": "验证成功",
"rememberPwd": "记住密码",
"forgetPwd": "忘记密码",
"btnText": "登录",
"noAccount": "还没有账号?",
"register": "注册",
"success": {
"title": "登录成功",
"message": "欢迎回来"
}
},
"forgetPassword": {
"title": "忘记密码?",
"subTitle": "输入您的电子邮件来重置您的密码",
"placeholder": "请输入您的电子邮件",
"submitBtnText": "提交",
"backBtnText": "返回"
},
"register": {
"title": "创建账号",
"subTitle": "欢迎加入我们,请填写以下信息完成注册",
"placeholder": {
"username": "请输入账号",
"password": "请输入密码",
"confirmPassword": "请再次输入密码"
},
"rule": {
"confirmPasswordRequired": "请再次输入密码",
"passwordMismatch": "两次输入密码不一致!",
"usernameLength": "长度在 3 到 20 个字符",
"passwordLength": "密码长度不能小于6位",
"agreementRequired": "请同意隐私协议"
},
"agreeText": "我同意",
"privacyPolicy": "《隐私政策》",
"submitBtnText": "注册",
"hasAccount": "已有账号?",
"toLogin": "去登录"
},
"lockScreen": {
"pwdError": "密码错误",
"lock": {
"inputPlaceholder": "请输入锁屏密码",
"btnText": "锁定"
},
"unlock": {
"inputPlaceholder": "请输入解锁密码",
"btnText": "解锁",
"backBtnText": "返回登录"
}
},
"greeting": {
"dawn": "凌晨了!",
"morning": "上午好!",
"afternoon": "下午好!",
"evening": "晚上好!"
},
"exceptionPage": {
"403": "抱歉,您无权访问该页面",
"404": "抱歉,您访问的页面不存在",
"500": "抱歉,服务器出错了",
"gohome": "返回首页"
},
"menus": {
"login": {
"title": "登录"
},
"register": {
"title": "注册"
},
"forgetPassword": {
"title": "忘记密码"
},
"outside": {
"title": "内嵌页面"
},
"dashboard": {
"title": "仪表盘",
"console": "工作台"
},
"result": {
"title": "结果页面",
"success": "成功页",
"fail": "失败页"
},
"exception": {
"title": "异常页面",
"forbidden": "403",
"notFound": "404",
"serverError": "500"
},
"system": {
"title": "系统管理",
"user": "用户管理",
"role": "角色管理",
"userCenter": "个人中心",
"menu": "菜单管理"
}
},
"table": {
"form": {
"reset": "重置",
"submit": "提交"
},
"searchBar": {
"reset": "重置",
"search": "查询",
"expand": "展开",
"collapse": "收起",
"searchInputPlaceholder": "请输入",
"searchSelectPlaceholder": "请选择"
},
"selection": "选择",
"sizeOptions": {
"small": "紧凑",
"default": "默认",
"large": "宽松"
},
"column": {
"selection": "勾选",
"expand": "展开",
"index": "序号"
},
"zebra": "斑马纹",
"border": "边框",
"headerBackground": "表头背景"
}
}

25
saiadmin-artd/src/main.ts Normal file
View File

@@ -0,0 +1,25 @@
import App from './App.vue'
import { createApp } from 'vue'
import { initStore } from './store' // Store
import { initRouter } from './router' // Router
import language from './locales' // 国际化
import '@styles/core/tailwind.css' // tailwind
import '@styles/index.scss' // 样式
import '@utils/sys/console.ts' // 控制台输出内容
import { setupGlobDirectives } from './directives'
import { setupErrorHandle } from './utils/sys/error-handle'
document.addEventListener(
'touchstart',
function () {},
{ passive: false }
)
const app = createApp(App)
initStore(app)
initRouter(app)
setupGlobDirectives(app)
setupErrorHandle(app)
app.use(language)
app.mount('#app')

View File

@@ -0,0 +1,273 @@
import avatar1 from '@/assets/images/avatar/avatar1.webp'
import avatar2 from '@/assets/images/avatar/avatar2.webp'
import avatar3 from '@/assets/images/avatar/avatar3.webp'
import avatar4 from '@/assets/images/avatar/avatar4.webp'
import avatar5 from '@/assets/images/avatar/avatar5.webp'
import avatar6 from '@/assets/images/avatar/avatar6.webp'
import avatar7 from '@/assets/images/avatar/avatar7.webp'
import avatar8 from '@/assets/images/avatar/avatar8.webp'
import avatar9 from '@/assets/images/avatar/avatar9.webp'
import avatar10 from '@/assets/images/avatar/avatar10.webp'
export interface User {
id: number
username: string
gender: 1 | 0
mobile: string
email: string
dep: string
status: string
create_time: string
avatar: string
}
// 用户列表
export const ACCOUNT_TABLE_DATA: User[] = [
{
id: 1,
username: 'alexmorgan',
gender: 1,
mobile: '18670001591',
email: 'alexmorgan@company.com',
dep: '研发部',
status: '1',
create_time: '2020-09-09 10:01:10',
avatar: avatar1
},
{
id: 2,
username: 'sophiabaker',
gender: 1,
mobile: '17766664444',
email: 'sophiabaker@company.com',
dep: '电商部',
status: '1',
create_time: '2020-10-10 13:01:12',
avatar: avatar2
},
{
id: 3,
username: 'liampark',
gender: 1,
mobile: '18670001597',
email: 'liampark@company.com',
dep: '人事部',
status: '1',
create_time: '2020-11-14 12:01:45',
avatar: avatar3
},
{
id: 4,
username: 'oliviagrant',
gender: 0,
mobile: '18670001596',
email: 'oliviagrant@company.com',
dep: '产品部',
status: '1',
create_time: '2020-11-14 09:01:20',
avatar: avatar4
},
{
id: 5,
username: 'emmawilson',
gender: 0,
mobile: '18670001595',
email: 'emmawilson@company.com',
dep: '财务部',
status: '1',
create_time: '2020-11-13 11:01:05',
avatar: avatar5
},
{
id: 6,
username: 'noahevan',
gender: 1,
mobile: '18670001594',
email: 'noahevan@company.com',
dep: '运营部',
status: '1',
create_time: '2020-10-11 13:10:26',
avatar: avatar6
},
{
id: 7,
username: 'avamartin',
gender: 1,
mobile: '18123820191',
email: 'avamartin@company.com',
dep: '客服部',
status: '2',
create_time: '2020-05-14 12:05:10',
avatar: avatar7
},
{
id: 8,
username: 'jacoblee',
gender: 1,
mobile: '18670001592',
email: 'jacoblee@company.com',
dep: '总经办',
status: '3',
create_time: '2020-11-12 07:22:25',
avatar: avatar8
},
{
id: 9,
username: 'miaclark',
gender: 0,
mobile: '18670001581',
email: 'miaclark@company.com',
dep: '研发部',
status: '4',
create_time: '2020-06-12 05:04:20',
avatar: avatar9
},
{
id: 10,
username: 'ethanharris',
gender: 1,
mobile: '13755554444',
email: 'ethanharris@company.com',
dep: '研发部',
status: '1',
create_time: '2020-11-12 16:01:10',
avatar: avatar10
},
{
id: 11,
username: 'isabellamoore',
gender: 1,
mobile: '13766660000',
email: 'isabellamoore@company.com',
dep: '研发部',
status: '1',
create_time: '2020-11-14 12:01:20',
avatar: avatar6
},
{
id: 12,
username: 'masonwhite',
gender: 1,
mobile: '18670001502',
email: 'masonwhite@company.com',
dep: '研发部',
status: '1',
create_time: '2020-11-14 12:01:20',
avatar: avatar7
},
{
id: 13,
username: 'charlottehall',
gender: 1,
mobile: '13006644977',
email: 'charlottehall@company.com',
dep: '研发部',
status: '1',
create_time: '2020-11-14 12:01:20',
avatar: avatar8
},
{
id: 14,
username: 'benjaminscott',
gender: 0,
mobile: '13599998888',
email: 'benjaminscott@company.com',
dep: '研发部',
status: '1',
create_time: '2020-11-14 12:01:20',
avatar: avatar9
},
{
id: 15,
username: 'ameliaking',
gender: 1,
mobile: '13799998888',
email: 'ameliaking@company.com',
dep: '研发部',
status: '1',
create_time: '2020-11-14 12:01:20',
avatar: avatar10
}
]
export interface Role {
roleName: string
roleCode: string
des: string
date: string
enable: boolean
}
// 角色列表
export const ROLE_LIST_DATA: Role[] = [
{
roleName: '超级管理员',
roleCode: 'R_SUPER',
des: '拥有系统全部权限',
date: '2025-05-15 12:30:45',
enable: true
},
{
roleName: '管理员',
roleCode: 'R_ADMIN',
des: '拥有系统管理权限',
date: '2025-05-15 12:30:45',
enable: true
},
{
roleName: '普通用户',
roleCode: 'R_USER',
des: '拥有系统普通权限',
date: '2025-05-15 12:30:45',
enable: true
},
{
roleName: '财务管理员',
roleCode: 'R_FINANCE',
des: '管理财务相关权限',
date: '2025-05-16 09:15:30',
enable: true
},
{
roleName: '数据分析师',
roleCode: 'R_ANALYST',
des: '拥有数据分析权限',
date: '2025-05-16 11:45:00',
enable: false
},
{
roleName: '客服专员',
roleCode: 'R_SUPPORT',
des: '处理客户支持请求',
date: '2025-05-17 14:30:22',
enable: true
},
{
roleName: '营销经理',
roleCode: 'R_MARKETING',
des: '管理营销活动权限',
date: '2025-05-17 15:10:50',
enable: true
},
{
roleName: '访客用户',
roleCode: 'R_GUEST',
des: '仅限浏览权限',
date: '2025-05-18 08:25:40',
enable: false
},
{
roleName: '系统维护员',
roleCode: 'R_MAINTAINER',
des: '负责系统维护和更新',
date: '2025-05-18 09:50:12',
enable: true
},
{
roleName: '项目经理',
roleCode: 'R_PM',
des: '管理项目相关权限',
date: '2025-05-19 13:40:35',
enable: true
}
]

View File

@@ -0,0 +1,12 @@
import { ref } from 'vue'
interface UpgradeLog {
version: string // 版本号
title: string // 更新标题
date: string // 更新日期
detail?: string[] // 更新内容
requireReLogin?: boolean // 是否需要重新登录
remark?: string // 备注
}
export const upgradeLogList = ref<UpgradeLog[]>([])

View File

@@ -0,0 +1,76 @@
/**
* ECharts 插件配置
*
* 按需导入 ECharts 图表和组件,减小打包体积。
* 只注册项目中实际使用的图表类型和组件。
*
* @module plugins/echarts
* @author Art Design Pro Team
*/
// ECharts 按需导入配置
import * as echarts from 'echarts/core'
// 导入图表类型
import {
BarChart,
LineChart,
PieChart,
ScatterChart,
RadarChart,
MapChart,
CandlestickChart
} from 'echarts/charts'
// 导入组件
import {
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
DataZoomComponent,
MarkPointComponent,
MarkLineComponent,
ToolboxComponent,
BrushComponent,
GeoComponent,
VisualMapComponent
} from 'echarts/components'
// 导入渲染器
import { CanvasRenderer } from 'echarts/renderers'
// 注册必要的组件
echarts.use([
// 图表类型
BarChart,
LineChart,
PieChart,
ScatterChart,
RadarChart,
MapChart,
CandlestickChart,
// 组件
TitleComponent,
TooltipComponent,
GridComponent,
LegendComponent,
DataZoomComponent,
MarkPointComponent,
MarkLineComponent,
ToolboxComponent,
BrushComponent,
GeoComponent,
VisualMapComponent,
// 渲染器
CanvasRenderer
])
// 导出 echarts 实例和类型
export { echarts }
export type { EChartsOption, BarSeriesOption } from 'echarts'
// 导出常用的图形工具
export const graphic = echarts.graphic

View File

@@ -0,0 +1,6 @@
/**
* 插件统一导出
* 集中管理第三方库的封装和配置
*/
export * from './echarts'

View File

@@ -0,0 +1,82 @@
/**
* 组件加载器
*
* 负责动态加载 Vue 组件
*
* @module router/core/ComponentLoader
* @author Art Design Pro Team
*/
import { h } from 'vue'
export class ComponentLoader {
private modules: Record<string, () => Promise<any>>
constructor() {
// 动态导入 views 目录下所有 .vue 组件
this.modules = import.meta.glob('../../views/**/*.vue')
}
/**
* 加载组件
*/
load(componentPath: string): () => Promise<any> {
if (!componentPath) {
return this.createEmptyComponent()
}
// 构建可能的路径
const fullPath = `../../views${componentPath}.vue`
const fullPathWithIndex = `../../views${componentPath}/index.vue`
// 先尝试直接路径,再尝试添加/index的路径
const module = this.modules[fullPath] || this.modules[fullPathWithIndex]
if (!module) {
console.error(
`[ComponentLoader] 未找到组件: ${componentPath},尝试过的路径: ${fullPath}${fullPathWithIndex}`
)
return this.createErrorComponent(componentPath)
}
return module
}
/**
* 加载布局组件
*/
loadLayout(): () => Promise<any> {
return () => import('@/views/index/index.vue')
}
/**
* 加载 iframe 组件
*/
loadIframe(): () => Promise<any> {
return () => import('@/views/outside/Iframe.vue')
}
/**
* 创建空组件
*/
private createEmptyComponent(): () => Promise<any> {
return () =>
Promise.resolve({
render() {
return h('div', {})
}
})
}
/**
* 创建错误提示组件
*/
private createErrorComponent(componentPath: string): () => Promise<any> {
return () =>
Promise.resolve({
render() {
return h('div', { class: 'route-error' }, `组件未找到: ${componentPath}`)
}
})
}
}

View File

@@ -0,0 +1,78 @@
/**
* Iframe 路由管理器
*
* 负责管理 iframe 类型的路由
*
* @module router/core/IframeRouteManager
* @author Art Design Pro Team
*/
import type { AppRouteRecord } from '@/types/router'
export class IframeRouteManager {
private static instance: IframeRouteManager
private iframeRoutes: AppRouteRecord[] = []
private constructor() {}
static getInstance(): IframeRouteManager {
if (!IframeRouteManager.instance) {
IframeRouteManager.instance = new IframeRouteManager()
}
return IframeRouteManager.instance
}
/**
* 添加 iframe 路由
*/
add(route: AppRouteRecord): void {
if (!this.iframeRoutes.find((r) => r.path === route.path)) {
this.iframeRoutes.push(route)
}
}
/**
* 获取所有 iframe 路由
*/
getAll(): AppRouteRecord[] {
return this.iframeRoutes
}
/**
* 根据路径查找 iframe 路由
*/
findByPath(path: string): AppRouteRecord | undefined {
return this.iframeRoutes.find((route) => route.path === path)
}
/**
* 清空所有 iframe 路由
*/
clear(): void {
this.iframeRoutes = []
}
/**
* 保存到 sessionStorage
*/
save(): void {
if (this.iframeRoutes.length > 0) {
sessionStorage.setItem('iframeRoutes', JSON.stringify(this.iframeRoutes))
}
}
/**
* 从 sessionStorage 加载
*/
load(): void {
try {
const data = sessionStorage.getItem('iframeRoutes')
if (data) {
this.iframeRoutes = JSON.parse(data)
}
} catch (error) {
console.error('[IframeRouteManager] 加载 iframe 路由失败:', error)
this.iframeRoutes = []
}
}
}

View File

@@ -0,0 +1,173 @@
/**
* 菜单处理器
*
* 负责菜单数据的获取、过滤和处理
*
* @module router/core/MenuProcessor
* @author Art Design Pro Team
*/
import type { AppRouteRecord } from '@/types/router'
import { useUserStore } from '@/store/modules/user'
import { useAppMode } from '@/hooks/core/useAppMode'
import { fetchGetMenuList } from '@/api/auth'
import { asyncRoutes } from '../routes/asyncRoutes'
import { RoutesAlias } from '../routesAlias'
export class MenuProcessor {
/**
* 获取菜单数据
*/
async getMenuList(): Promise<AppRouteRecord[]> {
const { isFrontendMode } = useAppMode()
let menuList: AppRouteRecord[]
if (isFrontendMode.value) {
menuList = await this.processFrontendMenu()
} else {
menuList = await this.processBackendMenu()
}
// 规范化路径(将相对路径转换为完整路径)
return this.normalizeMenuPaths(menuList)
}
/**
* 处理前端控制模式的菜单
*/
private async processFrontendMenu(): Promise<AppRouteRecord[]> {
const userStore = useUserStore()
const roles = userStore.info?.roles
let menuList = [...asyncRoutes]
// 根据角色过滤菜单
if (roles && roles.length > 0) {
menuList = this.filterMenuByRoles(menuList, roles)
}
return this.filterEmptyMenus(menuList)
}
/**
* 处理后端控制模式的菜单
*/
private async processBackendMenu(): Promise<AppRouteRecord[]> {
const list = await fetchGetMenuList()
return this.filterEmptyMenus(list)
}
/**
* 根据角色过滤菜单
*/
private filterMenuByRoles(menu: AppRouteRecord[], roles: string[]): AppRouteRecord[] {
return menu.reduce((acc: AppRouteRecord[], item) => {
const itemRoles = item.meta?.roles
const hasPermission = !itemRoles || itemRoles.some((role) => roles?.includes(role))
if (hasPermission) {
const filteredItem = { ...item }
if (filteredItem.children?.length) {
filteredItem.children = this.filterMenuByRoles(filteredItem.children, roles)
}
acc.push(filteredItem)
}
return acc
}, [])
}
/**
* 递归过滤空菜单项
*/
private filterEmptyMenus(menuList: AppRouteRecord[]): AppRouteRecord[] {
return menuList
.map((item) => {
// 如果有子菜单,先递归过滤子菜单
if (item.children && item.children.length > 0) {
const filteredChildren = this.filterEmptyMenus(item.children)
return {
...item,
children: filteredChildren
}
}
return item
})
.filter((item) => {
// 如果定义了 children 属性(即使是空数组),说明这是一个目录菜单,应该保留
if ('children' in item) {
return true
}
// 如果有外链或 iframe保留
if (item.meta?.isIframe === true || item.meta?.link) {
return true
}
// 如果有有效的 component保留
if (item.component && item.component !== '' && item.component !== RoutesAlias.Layout) {
return true
}
// 其他情况过滤掉
return false
})
}
/**
* 验证菜单列表是否有效
*/
validateMenuList(menuList: AppRouteRecord[]): boolean {
return Array.isArray(menuList) && menuList.length > 0
}
/**
* 规范化菜单路径
* 将相对路径转换为完整路径,确保菜单跳转正确
*/
private normalizeMenuPaths(menuList: AppRouteRecord[], parentPath = ''): AppRouteRecord[] {
return menuList.map((item) => {
// 构建完整路径
const fullPath = this.buildFullPath(item.path || '', parentPath)
// 递归处理子菜单
const children = item.children?.length
? this.normalizeMenuPaths(item.children, fullPath)
: item.children
return {
...item,
path: fullPath,
children
}
})
}
/**
* 构建完整路径
*/
private buildFullPath(path: string, parentPath: string): string {
if (!path) return ''
// 外部链接直接返回
if (path.startsWith('http://') || path.startsWith('https://')) {
return path
}
// 如果已经是绝对路径,直接返回
if (path.startsWith('/')) {
return path
}
// 拼接父路径和当前路径
if (parentPath) {
// 移除父路径末尾的斜杠,移除子路径开头的斜杠,然后拼接
const cleanParent = parentPath.replace(/\/$/, '')
const cleanChild = path.replace(/^\//, '')
return `${cleanParent}/${cleanChild}`
}
// 没有父路径,添加前导斜杠
return `/${path}`
}
}

View File

@@ -0,0 +1,119 @@
/**
* 路由权限验证模块
*
* 提供路由权限验证和路径检查功能
*
* ## 主要功能
*
* - 验证路径是否在用户菜单权限中
* - 构建菜单路径集合(扁平化处理)
* - 支持动态路由参数匹配
* - 路径前缀匹配
*
* ## 使用场景
*
* - 路由守卫中验证用户权限
* - 动态路由注册后的权限检查
* - 防止用户访问无权限的页面
*
* @module router/core/RoutePermissionValidator
* @author Art Design Pro Team
*/
import type { AppRouteRecord } from '@/types/router'
/**
* 路由权限验证器
*/
export class RoutePermissionValidator {
/**
* 验证路径是否在用户菜单权限中
* @param targetPath 目标路径
* @param menuList 菜单列表
* @returns 是否有权限访问
*/
static hasPermission(targetPath: string, menuList: AppRouteRecord[]): boolean {
// 根路径始终允许访问
if (targetPath === '/') {
return true
}
// 构建路径集合
const pathSet = this.buildMenuPathSet(menuList)
// 检查路径是否在集合中(精确匹配或前缀匹配)
return pathSet.has(targetPath) || this.checkPathPrefix(targetPath, pathSet)
}
/**
* 构建菜单路径集合(扁平化处理)
* @param menuList 菜单列表
* @param pathSet 路径集合
* @returns 路径集合
*/
static buildMenuPathSet(
menuList: AppRouteRecord[],
pathSet: Set<string> = new Set()
): Set<string> {
if (!Array.isArray(menuList) || menuList.length === 0) {
return pathSet
}
for (const menuItem of menuList) {
// 跳过隐藏的菜单项
if (menuItem.meta?.isHide || !menuItem.path) {
continue
}
// 标准化路径并添加到集合
const menuPath = menuItem.path.startsWith('/') ? menuItem.path : `/${menuItem.path}`
pathSet.add(menuPath)
// 递归处理子菜单
if (menuItem.children?.length) {
this.buildMenuPathSet(menuItem.children, pathSet)
}
}
return pathSet
}
/**
* 检查目标路径是否匹配集合中的某个路径前缀
* 用于支持动态路由参数匹配,如 /user/123 匹配 /user
* @param targetPath 目标路径
* @param pathSet 路径集合
* @returns 是否匹配
*/
static checkPathPrefix(targetPath: string, pathSet: Set<string>): boolean {
// 遍历路径集合,检查是否有前缀匹配
for (const menuPath of pathSet) {
if (targetPath.startsWith(`${menuPath}/`)) {
return true
}
}
return false
}
/**
* 验证并返回有效的路径
* 如果目标路径无权限,返回首页路径
* @param targetPath 目标路径
* @param menuList 菜单列表
* @param homePath 首页路径
* @returns 验证后的路径
*/
static validatePath(
targetPath: string,
menuList: AppRouteRecord[],
homePath: string = '/'
): { path: string; hasPermission: boolean } {
const hasPermission = this.hasPermission(targetPath, menuList)
if (hasPermission) {
return { path: targetPath, hasPermission: true }
}
return { path: homePath, hasPermission: false }
}
}

View File

@@ -0,0 +1,90 @@
/**
* 路由注册核心类
*
* 负责动态路由的注册、验证和管理
*
* @module router/core/RouteRegistry
* @author Art Design Pro Team
*/
import type { Router, RouteRecordRaw } from 'vue-router'
import type { AppRouteRecord } from '@/types/router'
import { ComponentLoader } from './ComponentLoader'
import { RouteValidator } from './RouteValidator'
import { RouteTransformer } from './RouteTransformer'
export class RouteRegistry {
private router: Router
private componentLoader: ComponentLoader
private validator: RouteValidator
private transformer: RouteTransformer
private removeRouteFns: (() => void)[] = []
private registered = false
constructor(router: Router) {
this.router = router
this.componentLoader = new ComponentLoader()
this.validator = new RouteValidator()
this.transformer = new RouteTransformer(this.componentLoader)
}
/**
* 注册动态路由
*/
register(menuList: AppRouteRecord[]): void {
if (this.registered) {
console.warn('[RouteRegistry] 路由已注册,跳过重复注册')
return
}
// 验证路由配置
const validationResult = this.validator.validate(menuList)
if (!validationResult.valid) {
throw new Error(`路由配置验证失败: ${validationResult.errors.join(', ')}`)
}
// 转换并注册路由
const removeRouteFns: (() => void)[] = []
menuList.forEach((route) => {
if (route.name && !this.router.hasRoute(route.name)) {
const routeConfig = this.transformer.transform(route)
const removeRouteFn = this.router.addRoute(routeConfig as RouteRecordRaw)
removeRouteFns.push(removeRouteFn)
}
})
this.removeRouteFns = removeRouteFns
this.registered = true
}
/**
* 移除所有动态路由
*/
unregister(): void {
this.removeRouteFns.forEach((fn) => fn())
this.removeRouteFns = []
this.registered = false
}
/**
* 检查是否已注册
*/
isRegistered(): boolean {
return this.registered
}
/**
* 获取移除函数列表(用于 store 管理)
*/
getRemoveRouteFns(): (() => void)[] {
return this.removeRouteFns
}
/**
* 标记为已注册(用于错误处理场景,避免重复请求)
*/
markAsRegistered(): void {
this.registered = true
}
}

View File

@@ -0,0 +1,144 @@
/**
* 路由转换器
*
* 负责将菜单数据转换为 Vue Router 路由配置
*
* @module router/core/RouteTransformer
* @author Art Design Pro Team
*/
import type { RouteRecordRaw } from 'vue-router'
import type { AppRouteRecord } from '@/types/router'
import { ComponentLoader } from './ComponentLoader'
import { IframeRouteManager } from './IframeRouteManager'
interface ConvertedRoute extends Omit<RouteRecordRaw, 'children'> {
id?: number
children?: ConvertedRoute[]
component?: RouteRecordRaw['component'] | (() => Promise<any>)
}
export class RouteTransformer {
private componentLoader: ComponentLoader
private iframeManager: IframeRouteManager
constructor(componentLoader: ComponentLoader) {
this.componentLoader = componentLoader
this.iframeManager = IframeRouteManager.getInstance()
}
/**
* 转换路由配置
*/
transform(route: AppRouteRecord, depth = 0): ConvertedRoute {
const { component, children, ...routeConfig } = route
// 基础路由配置
const converted: ConvertedRoute = {
...routeConfig,
component: undefined
}
// 处理不同类型的路由
if (route.meta.isIframe) {
this.handleIframeRoute(converted, route, depth)
} else if (route.meta.isFullPage) {
// 全屏页面:不继承 layout直接使用组件
this.handleFullPageRoute(converted, component as string)
} else if (this.isFirstLevelRoute(route, depth)) {
this.handleFirstLevelRoute(converted, route, component as string)
} else {
this.handleNormalRoute(converted, component as string)
}
// 递归处理子路由
if (children?.length) {
converted.children = children.map((child) => this.transform(child, depth + 1))
}
return converted
}
/**
* 判断是否为一级路由(需要 Layout 包裹)
*/
private isFirstLevelRoute(route: AppRouteRecord, depth: number): boolean {
return depth === 0 && (!route.children || route.children.length === 0) && !route.meta.isFullPage
}
/**
* 处理 iframe 类型路由
*/
private handleIframeRoute(
targetRoute: ConvertedRoute,
sourceRoute: AppRouteRecord,
depth: number
): void {
if (depth === 0) {
// 顶级 iframe用 Layout 包裹
targetRoute.component = this.componentLoader.loadLayout()
targetRoute.path = this.extractFirstSegment(sourceRoute.path || '')
targetRoute.name = ''
targetRoute.children = [
{
...sourceRoute,
component: this.componentLoader.loadIframe()
} as ConvertedRoute
]
} else {
// 非顶级嵌套iframe直接使用 Iframe.vue
targetRoute.component = this.componentLoader.loadIframe()
}
// 记录 iframe 路由
this.iframeManager.add(sourceRoute)
}
/**
* 处理一级菜单路由
*/
private handleFirstLevelRoute(
converted: ConvertedRoute,
route: AppRouteRecord,
component: string | undefined
): void {
converted.component = this.componentLoader.loadLayout()
converted.path = this.extractFirstSegment(route.path || '')
converted.name = ''
route.meta.isFirstLevel = true
converted.children = [
{
...route,
component: component ? this.componentLoader.load(component) : undefined
} as ConvertedRoute
]
}
/**
* 处理普通路由
*/
private handleNormalRoute(converted: ConvertedRoute, component: string | undefined): void {
if (component) {
converted.component = this.componentLoader.load(component)
}
}
/**
* 处理全屏页面路由(不继承 layout
*/
private handleFullPageRoute(converted: ConvertedRoute, component: string | undefined): void {
if (component) {
converted.component = this.componentLoader.load(component)
}
}
/**
* 提取路径的第一段
*/
private extractFirstSegment(path: string): string {
const segments = path.split('/').filter(Boolean)
return segments.length > 0 ? `/${segments[0]}` : '/'
}
}

View File

@@ -0,0 +1,187 @@
/**
* 路由验证器
*
* 负责验证路由配置的合法性
*
* @module router/core/RouteValidator
* @author Art Design Pro Team
*/
import type { AppRouteRecord } from '@/types/router'
import { RoutesAlias } from '../routesAlias'
export interface ValidationResult {
valid: boolean
errors: string[]
warnings: string[]
}
export class RouteValidator {
// 用于记录已经提示过的路由,避免重复提示
private warnedRoutes = new Set<string>()
/**
* 验证路由配置
*/
validate(routes: AppRouteRecord[]): ValidationResult {
const errors: string[] = []
const warnings: string[] = []
// 检测重复路由
this.checkDuplicates(routes, errors, warnings)
// 检测组件配置
this.checkComponents(routes, errors, warnings)
// 检测嵌套菜单的 /index/index 配置
this.checkNestedIndexComponent(routes)
return {
valid: errors.length === 0,
errors,
warnings
}
}
/**
* 检测重复路由
*/
private checkDuplicates(
routes: AppRouteRecord[],
errors: string[],
warnings: string[],
parentPath = ''
): void {
const routeNameMap = new Map<string, string>()
const componentPathMap = new Map<string, string>()
const checkRoutes = (routes: AppRouteRecord[], parentPath = '') => {
routes.forEach((route) => {
const currentPath = route.path || ''
const fullPath = this.resolvePath(parentPath, currentPath)
// 名称重复检测
if (route.name) {
const routeName = String(route.name)
if (routeNameMap.has(routeName)) {
warnings.push(`路由名称重复: "${routeName}" (${fullPath})`)
} else {
routeNameMap.set(routeName, fullPath)
}
}
// 组件路径重复检测
if (route.component && typeof route.component === 'string') {
const componentPath = route.component
if (componentPath !== RoutesAlias.Layout) {
const componentKey = `${parentPath}:${componentPath}`
if (componentPathMap.has(componentKey)) {
warnings.push(`组件路径重复: "${componentPath}" (${fullPath})`)
} else {
componentPathMap.set(componentKey, fullPath)
}
}
}
// 递归处理子路由
if (route.children?.length) {
checkRoutes(route.children, fullPath)
}
})
}
checkRoutes(routes, parentPath)
}
/**
* 检测组件配置
*/
private checkComponents(
routes: AppRouteRecord[],
errors: string[],
warnings: string[],
parentPath = ''
): void {
routes.forEach((route) => {
const hasExternalLink = !!route.meta?.link?.trim()
const hasChildren = Array.isArray(route.children) && route.children.length > 0
const routePath = route.path || '[未定义路径]'
const isIframe = route.meta?.isIframe
// 如果配置了 component则无需校验
if (route.component) {
// 递归检查子路由
if (route.children?.length) {
const fullPath = this.resolvePath(parentPath, route.path || '')
this.checkComponents(route.children, errors, warnings, fullPath)
}
return
}
// 一级菜单:必须指定 Layout除非是外链或 iframe
if (parentPath === '' && !hasExternalLink && !isIframe) {
errors.push(`一级菜单(${routePath}) 缺少 component必须指向 ${RoutesAlias.Layout}`)
return
}
// 非一级菜单如果既不是外链、iframe也没有子路由则必须配置 component
if (!hasExternalLink && !isIframe && !hasChildren) {
errors.push(`路由(${routePath}) 缺少 component 配置`)
}
// 递归检查子路由
if (route.children?.length) {
const fullPath = this.resolvePath(parentPath, route.path || '')
this.checkComponents(route.children, errors, warnings, fullPath)
}
})
}
/**
* 检测嵌套菜单的 Layout 组件配置
* 只有一级菜单才能使用 Layout二级及以下菜单不能使用
*/
private checkNestedIndexComponent(routes: AppRouteRecord[], level = 1): void {
routes.forEach((route) => {
// 检查二级及以下菜单是否错误使用了 Layout
if (level > 1 && route.component === RoutesAlias.Layout) {
this.logLayoutError(route, level)
}
// 递归检查子路由
if (route.children?.length) {
this.checkNestedIndexComponent(route.children, level + 1)
}
})
}
/**
* 输出 Layout 组件配置错误日志
*/
private logLayoutError(route: AppRouteRecord, level: number): void {
const routeName = String(route.name || route.path || '未知路由')
const routeKey = `${routeName}_${route.path}`
// 避免重复提示
if (this.warnedRoutes.has(routeKey)) return
this.warnedRoutes.add(routeKey)
const menuTitle = route.meta?.title || routeName
const routePath = route.path || '/'
console.error(
`[路由配置错误] 菜单 "${menuTitle}" (name: ${routeName}, path: ${routePath}) 配置错误\n` +
` 问题: ${level}级菜单不能使用 ${RoutesAlias.Layout} 作为 component\n` +
` 说明: 只有一级菜单才能使用 ${RoutesAlias.Layout},二级及以下菜单应该指向具体的组件路径\n` +
` 当前配置: component: '${RoutesAlias.Layout}'\n` +
` 应该改为: component: '/your/component/path' 或留空 ''(如果是目录菜单)`
)
}
/**
* 路径解析
*/
private resolvePath(parent: string, child: string): string {
return [parent.replace(/\/$/, ''), child.replace(/^\//, '')].filter(Boolean).join('/')
}
}

View File

@@ -0,0 +1,14 @@
/**
* 路由核心模块导出
*
* @module router/core
* @author Art Design Pro Team
*/
export { RouteRegistry } from './RouteRegistry'
export { ComponentLoader } from './ComponentLoader'
export { RouteValidator } from './RouteValidator'
export { RouteTransformer } from './RouteTransformer'
export { IframeRouteManager } from './IframeRouteManager'
export { MenuProcessor } from './MenuProcessor'
export { RoutePermissionValidator } from './RoutePermissionValidator'

View File

@@ -0,0 +1,34 @@
import { nextTick } from 'vue'
import { useSettingStore } from '@/store/modules/setting'
import { Router } from 'vue-router'
import NProgress from 'nprogress'
import { useCommon } from '@/hooks/core/useCommon'
import { loadingService } from '@/utils/ui'
import { getPendingLoading, resetPendingLoading } from './beforeEach'
/** 路由全局后置守卫 */
export function setupAfterEachGuard(router: Router) {
const { scrollToTop } = useCommon()
router.afterEach(() => {
scrollToTop()
// 关闭进度条
const settingStore = useSettingStore()
if (settingStore.showNprogress) {
NProgress.done()
// 确保进度条完全移除,避免残影
setTimeout(() => {
NProgress.remove()
}, 600)
}
// 关闭 loading 效果
if (getPendingLoading()) {
nextTick(() => {
loadingService.hideLoading()
resetPendingLoading()
})
}
})
}

View File

@@ -0,0 +1,413 @@
/**
* 路由全局前置守卫模块
*
* 提供完整的路由导航守卫功能
*
* ## 主要功能
*
* - 登录状态验证和重定向
* - 动态路由注册和权限控制
* - 菜单数据获取和处理(前端/后端模式)
* - 用户信息获取和缓存
* - 页面标题设置
* - 工作标签页管理
* - 进度条和加载动画控制
* - 静态路由识别和处理
* - 错误处理和异常跳转
*
* ## 使用场景
*
* - 路由跳转前的权限验证
* - 动态菜单加载和路由注册
* - 用户登录状态管理
* - 页面访问控制
* - 路由级别的加载状态管理
*
* ## 工作流程
*
* 1. 检查登录状态,未登录跳转到登录页
* 2. 首次访问时获取用户信息和菜单数据
* 3. 根据权限动态注册路由
* 4. 设置页面标题和工作标签页
* 5. 处理根路径重定向到首页
* 6. 未匹配路由跳转到 404 页面
*
* @module router/guards/beforeEach
* @author Art Design Pro Team
*/
import type { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
import { nextTick } from 'vue'
import NProgress from 'nprogress'
import { useSettingStore } from '@/store/modules/setting'
import { useUserStore } from '@/store/modules/user'
import { useMenuStore } from '@/store/modules/menu'
import { useDictStore } from '@/store/modules/dict'
import { setWorktab } from '@/utils/navigation'
import { setPageTitle } from '@/utils/router'
import { RoutesAlias } from '../routesAlias'
import { staticRoutes } from '../routes/staticRoutes'
import { loadingService } from '@/utils/ui'
import { useCommon } from '@/hooks/core/useCommon'
import { useWorktabStore } from '@/store/modules/worktab'
import { fetchGetUserInfo, fetchGetDictList } from '@/api/auth'
import { ApiStatus } from '@/utils/http/status'
import { isHttpError } from '@/utils/http/error'
import { RouteRegistry, MenuProcessor, IframeRouteManager, RoutePermissionValidator } from '../core'
// 路由注册器实例
let routeRegistry: RouteRegistry | null = null
// 菜单处理器实例
const menuProcessor = new MenuProcessor()
// 跟踪是否需要关闭 loading
let pendingLoading = false
// 路由初始化失败标记,防止死循环
// 一旦设置为 true只有刷新页面或重新登录才能重置
let routeInitFailed = false
// 路由初始化进行中标记,防止并发请求
let routeInitInProgress = false
/**
* 获取 pendingLoading 状态
*/
export function getPendingLoading(): boolean {
return pendingLoading
}
/**
* 重置 pendingLoading 状态
*/
export function resetPendingLoading(): void {
pendingLoading = false
}
/**
* 获取路由初始化失败状态
*/
export function getRouteInitFailed(): boolean {
return routeInitFailed
}
/**
* 重置路由初始化状态(用于重新登录场景)
*/
export function resetRouteInitState(): void {
routeInitFailed = false
routeInitInProgress = false
}
/**
* 设置路由全局前置守卫
*/
export function setupBeforeEachGuard(router: Router): void {
// 初始化路由注册器
routeRegistry = new RouteRegistry(router)
router.beforeEach(
async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
try {
await handleRouteGuard(to, from, next, router)
} catch (error) {
console.error('[RouteGuard] 路由守卫处理失败:', error)
closeLoading()
next({ name: 'Exception500' })
}
}
)
}
/**
* 关闭 loading 效果
*/
function closeLoading(): void {
if (pendingLoading) {
nextTick(() => {
loadingService.hideLoading()
pendingLoading = false
})
}
}
/**
* 处理路由守卫逻辑
*/
async function handleRouteGuard(
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
const settingStore = useSettingStore()
const userStore = useUserStore()
// 启动进度条
if (settingStore.showNprogress) {
NProgress.start()
}
// 1. 检查登录状态
if (!handleLoginStatus(to, userStore, next)) {
return
}
// 2. 检查路由初始化是否已失败(防止死循环)
if (routeInitFailed) {
// 已经失败过,直接放行到错误页面,不再重试
if (to.matched.length > 0) {
next()
} else {
// 未匹配到路由,跳转到 500 页面
next({ name: 'Exception500', replace: true })
}
return
}
// 3. 处理动态路由注册
if (!routeRegistry?.isRegistered() && userStore.isLogin) {
// 防止并发请求(快速连续导航场景)
if (routeInitInProgress) {
// 正在初始化中,等待完成后重新导航
next(false)
return
}
await handleDynamicRoutes(to, next, router)
return
}
// 4. 处理根路径重定向
if (handleRootPathRedirect(to, next)) {
return
}
// 5. 处理已匹配的路由
if (to.matched.length > 0) {
setWorktab(to)
setPageTitle(to)
next()
return
}
// 6. 未匹配到路由,跳转到 404
next({ name: 'Exception404' })
}
/**
* 处理登录状态
* @returns true 表示可以继续false 表示已处理跳转
*/
function handleLoginStatus(
to: RouteLocationNormalized,
userStore: ReturnType<typeof useUserStore>,
next: NavigationGuardNext
): boolean {
// 已登录或访问登录页或静态路由,直接放行
if (userStore.isLogin || to.path === RoutesAlias.Login || isStaticRoute(to.path)) {
return true
}
// 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数
userStore.logOut()
next({
name: 'Login',
query: { redirect: to.fullPath }
})
return false
}
/**
* 检查路由是否为静态路由
*/
function isStaticRoute(path: string): boolean {
const checkRoute = (routes: any[], targetPath: string): boolean => {
return routes.some((route) => {
// 处理动态路由参数匹配
const routePath = route.path
const pattern = routePath.replace(/:[^/]+/g, '[^/]+').replace(/\*/g, '.*')
const regex = new RegExp(`^${pattern}$`)
if (regex.test(targetPath)) {
return true
}
if (route.children && route.children.length > 0) {
return checkRoute(route.children, targetPath)
}
return false
})
}
return checkRoute(staticRoutes, path)
}
/**
* 处理动态路由注册
*/
async function handleDynamicRoutes(
to: RouteLocationNormalized,
next: NavigationGuardNext,
router: Router
): Promise<void> {
// 标记初始化进行中
routeInitInProgress = true
// 显示 loading
pendingLoading = true
loadingService.showLoading()
try {
// 1. 获取用户信息
await fetchUserInfo()
// + 获取字典数据
await fetchDictList()
// 2. 获取菜单数据
const menuList = await menuProcessor.getMenuList()
// 3. 验证菜单数据
if (!menuProcessor.validateMenuList(menuList)) {
throw new Error('获取菜单列表失败,请重新登录')
}
// 4. 注册动态路由
routeRegistry?.register(menuList)
// 5. 保存菜单数据到 store
const menuStore = useMenuStore()
menuStore.setMenuList(menuList)
menuStore.addRemoveRouteFns(routeRegistry?.getRemoveRouteFns() || [])
// 6. 保存 iframe 路由
IframeRouteManager.getInstance().save()
// 7. 验证工作标签页
useWorktabStore().validateWorktabs(router)
// 8. 验证目标路径权限
const { homePath } = useCommon()
const { path: validatedPath, hasPermission } = RoutePermissionValidator.validatePath(
to.path,
menuList,
homePath.value || '/'
)
// 初始化成功,重置进行中标记
routeInitInProgress = false
// 9. 重新导航到目标路由
if (!hasPermission) {
// 无权限访问,跳转到首页
closeLoading()
// 输出警告信息
console.warn(`[RouteGuard] 用户无权限访问路径: ${to.path},已跳转到首页`)
// 直接跳转到首页
next({
path: validatedPath,
replace: true
})
} else {
// 有权限,正常导航
next({
path: to.path,
query: to.query,
hash: to.hash,
replace: true
})
}
} catch (error) {
console.error('[RouteGuard] 动态路由注册失败:', error)
// 关闭 loading
closeLoading()
// 401 错误axios 拦截器已处理退出登录,取消当前导航
if (isUnauthorizedError(error)) {
// 重置状态,允许重新登录后再次初始化
routeInitInProgress = false
next(false)
return
}
// 标记初始化失败,防止死循环
routeInitFailed = true
routeInitInProgress = false
// 输出详细错误信息,便于排查
if (isHttpError(error)) {
console.error(`[RouteGuard] 错误码: ${error.code}, 消息: ${error.message}`)
}
// 跳转到 500 页面,使用 replace 避免产生历史记录
next({ name: 'Exception500', replace: true })
}
}
/**
* 获取用户信息
*/
async function fetchUserInfo(): Promise<void> {
const userStore = useUserStore()
const data = await fetchGetUserInfo()
userStore.setUserInfo(data)
// 检查并清理工作台标签页(如果是不同用户登录)
userStore.checkAndClearWorktabs()
}
/**
* 获取字典数据
*/
async function fetchDictList(): Promise<void> {
const dictStore = useDictStore()
const data = await fetchGetDictList()
dictStore.setDictList(data)
}
/**
* 重置路由相关状态
*/
export function resetRouterState(delay: number): void {
setTimeout(() => {
routeRegistry?.unregister()
IframeRouteManager.getInstance().clear()
const menuStore = useMenuStore()
menuStore.removeAllDynamicRoutes()
menuStore.setMenuList([])
// 重置路由初始化状态,允许重新登录后再次初始化
resetRouteInitState()
}, delay)
}
/**
* 处理根路径重定向到首页
* @returns true 表示已处理跳转false 表示无需跳转
*/
function handleRootPathRedirect(to: RouteLocationNormalized, next: NavigationGuardNext): boolean {
if (to.path !== '/') {
return false
}
const { homePath } = useCommon()
if (homePath.value && homePath.value !== '/') {
next({ path: homePath.value, replace: true })
return true
}
return false
}
/**
* 判断是否为未授权错误401
*/
function isUnauthorizedError(error: unknown): boolean {
return isHttpError(error) && error.code === ApiStatus.unauthorized
}

View File

@@ -0,0 +1,23 @@
import type { App } from 'vue'
import { createRouter, createWebHashHistory } from 'vue-router'
import { staticRoutes } from './routes/staticRoutes'
import { configureNProgress } from '@/utils/router'
import { setupBeforeEachGuard } from './guards/beforeEach'
import { setupAfterEachGuard } from './guards/afterEach'
// 创建路由实例
export const router = createRouter({
history: createWebHashHistory(),
routes: staticRoutes // 静态路由
})
// 初始化路由
export function initRouter(app: App<Element>): void {
configureNProgress() // 顶部进度条
setupBeforeEachGuard(router) // 路由前置守卫
setupAfterEachGuard(router) // 路由后置守卫
app.use(router)
}
// 主页路径,默认使用菜单第一个有效路径,配置后使用此路径
export const HOME_PAGE_PATH = ''

View File

@@ -0,0 +1,29 @@
import { AppRouteRecord } from '@/types/router'
export const dashboardRoutes: AppRouteRecord = {
name: 'Dashboard',
path: '/dashboard',
component: '/index/index',
meta: {
title: 'menus.dashboard.title',
icon: 'ri:pie-chart-line'
},
children: [
{
path: 'console',
name: 'Console',
component: '/dashboard/console',
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center/index.vue',
meta: { title: 'menus.userCenter.title', isHideTab: true }
}
]
}

View File

@@ -0,0 +1,46 @@
import { AppRouteRecord } from '@/types/router'
export const exceptionRoutes: AppRouteRecord = {
path: '/exception',
name: 'Exception',
component: '/index/index',
meta: {
title: 'menus.exception.title',
icon: 'ri:error-warning-line'
},
children: [
{
path: '403',
name: 'Exception403',
component: '/exception/403',
meta: {
title: 'menus.exception.forbidden',
keepAlive: true,
isHideTab: true,
isFullPage: true
}
},
{
path: '404',
name: 'Exception404',
component: '/exception/404',
meta: {
title: 'menus.exception.notFound',
keepAlive: true,
isHideTab: true,
isFullPage: true
}
},
{
path: '500',
name: 'Exception500',
component: '/exception/500',
meta: {
title: 'menus.exception.serverError',
keepAlive: true,
isHideTab: true,
isFullPage: true
}
}
]
}

View File

@@ -0,0 +1,15 @@
import { AppRouteRecord } from '@/types/router'
import { dashboardRoutes } from './dashboard'
import { systemRoutes } from './system'
import { resultRoutes } from './result'
import { exceptionRoutes } from './exception'
/**
* 导出所有模块化路由
*/
export const routeModules: AppRouteRecord[] = [
dashboardRoutes,
systemRoutes,
resultRoutes,
exceptionRoutes
]

View File

@@ -0,0 +1,33 @@
import { AppRouteRecord } from '@/types/router'
export const resultRoutes: AppRouteRecord = {
path: '/result',
name: 'Result',
component: '/index/index',
meta: {
title: 'menus.result.title',
icon: 'ri:checkbox-circle-line'
},
children: [
{
path: 'success',
name: 'ResultSuccess',
component: '/result/success',
meta: {
title: 'menus.result.success',
icon: 'ri:checkbox-circle-line',
keepAlive: true
}
},
{
path: 'fail',
name: 'ResultFail',
component: '/result/fail',
meta: {
title: 'menus.result.fail',
icon: 'ri:close-circle-line',
keepAlive: true
}
}
]
}

View File

@@ -0,0 +1,60 @@
import { AppRouteRecord } from '@/types/router'
export const systemRoutes: AppRouteRecord = {
path: '/system',
name: 'System',
component: '/index/index',
meta: {
title: 'menus.system.title',
icon: 'ri:user-3-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: '/system/user',
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: '/system/role',
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center',
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: '/system/menu',
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{ title: '新增', authMark: 'add' },
{ title: '编辑', authMark: 'edit' },
{ title: '删除', authMark: 'delete' }
]
}
}
]
}

View File

@@ -0,0 +1,9 @@
// 权限文档https://www.artd.pro/docs/zh/guide/in-depth/permission.html
import { AppRouteRecord } from '@/types/router'
import { routeModules } from '../modules'
/**
* 动态路由(需要权限才能访问的路由)
* 用于渲染菜单以及根据菜单权限动态加载路由,如果没有权限无法访问
*/
export const asyncRoutes: AppRouteRecord[] = routeModules

View File

@@ -0,0 +1,72 @@
import { AppRouteRecordRaw } from '@/utils/router'
/**
* 静态路由配置(不需要权限就能访问的路由)
*
* 属性说明:
* isHideTab: true 表示不在标签页中显示
*
* 注意事项:
* 1、path、name 不要和动态路由冲突,否则会导致路由冲突无法访问
* 2、静态路由不管是否登录都可以访问
*/
export const staticRoutes: AppRouteRecordRaw[] = [
// 不需要登录就能访问的路由示例
// {
// path: '/welcome',
// name: 'WelcomeStatic',
// component: () => import('@views/dashboard/console/index.vue'),
// meta: { title: 'menus.dashboard.title' }
// },
{
path: '/auth/login',
name: 'Login',
component: () => import('@views/auth/login/index.vue'),
meta: { title: 'menus.login.title', isHideTab: true }
},
{
path: '/auth/register',
name: 'Register',
component: () => import('@views/auth/register/index.vue'),
meta: { title: 'menus.register.title', isHideTab: true }
},
{
path: '/auth/forget-password',
name: 'ForgetPassword',
component: () => import('@views/auth/forget-password/index.vue'),
meta: { title: 'menus.forgetPassword.title', isHideTab: true }
},
{
path: '/403',
name: 'Exception403',
component: () => import('@views/exception/403/index.vue'),
meta: { title: '403', isHideTab: true }
},
{
path: '/:pathMatch(.*)*',
name: 'Exception404',
component: () => import('@views/exception/404/index.vue'),
meta: { title: '404', isHideTab: true }
},
{
path: '/500',
name: 'Exception500',
component: () => import('@views/exception/500/index.vue'),
meta: { title: '500', isHideTab: true }
},
{
path: '/outside',
component: () => import('@views/index/index.vue'),
name: 'Outside',
meta: { title: 'menus.outside.title' },
children: [
// iframe 内嵌页面
{
path: '/outside/iframe/:path',
name: 'Iframe',
component: () => import('@/views/outside/Iframe.vue'),
meta: { title: 'iframe' }
}
]
}
]

View File

@@ -0,0 +1,8 @@
/**
* 公共路由别名
# 存放系统级公共路由路径,如布局容器、登录页等
*/
export enum RoutesAlias {
Layout = '/index/index', // 布局容器
Login = '/auth/login' // 登录页
}

View File

@@ -0,0 +1,52 @@
/**
* Pinia Store 配置模块
*
* 提供全局状态管理的初始化和配置
*
* ## 主要功能
*
* - Pinia Store 实例创建
* - 持久化插件配置pinia-plugin-persistedstate
* - 版本化存储键管理
* - 自动数据迁移(跨版本)
* - LocalStorage 序列化配置
* - Store 初始化函数
*
* ## 持久化策略
*
* - 使用 StorageKeyManager 生成版本化的存储键
* - 格式sys-v{version}-{storeId}
* - 自动迁移旧版本数据到当前版本
* - 使用 localStorage 作为存储介质
*
* @module store/index
* @author Art Design Pro Team
*/
import type { App } from 'vue'
import { createPinia } from 'pinia'
import { createPersistedState } from 'pinia-plugin-persistedstate'
import { StorageKeyManager } from '@/utils/storage/storage-key-manager'
export const store = createPinia()
// 创建存储键管理器实例
const storageKeyManager = new StorageKeyManager()
// 配置持久化插件
store.use(
createPersistedState({
key: (storeId: string) => storageKeyManager.getStorageKey(storeId),
storage: localStorage,
serializer: {
serialize: JSON.stringify,
deserialize: JSON.parse
}
})
)
/**
* 初始化 Store
*/
export function initStore(app: App<Element>): void {
app.use(store)
}

View File

@@ -0,0 +1,80 @@
/**
* 字典状态管理模块
*
* 提供字典数据的状态管理
*
*
* @module store/modules/dict
* @author saithink
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchGetDictList } from '@/api/auth'
/**
* 字典状态管理模块
* - 负责全局字典的加载、缓存、查询
* - 建议在菜单加载完成后调用 ensureLoaded() 进行初始化
*/
export const useDictStore = defineStore(
'dictStore',
() => {
/** 字典是否已初始化加载 */
const initialized = ref(false)
/** 原始字典列表 */
const dictList = ref<Api.Auth.DictData>()
/**
* 加载字典数据并建立索引
*/
const refresh = async () => {
try {
const list = await fetchGetDictList()
dictList.value = list
initialized.value = true
} catch (e) {
// 保持状态一致:加载失败也标记为未初始化
initialized.value = false
throw e
}
}
/** 根据 code 获取字典数据 */
const getByCode = (code: any): Api.Auth.DictItem[] => {
return dictList.value?.[code] || []
}
/** 根据 code 和 value 获取字典标签 */
const getDataByValue = (code: any, value: any): Api.Auth.DictItem | undefined => {
const dict = getByCode(code)
if (!dict) return undefined
const item = dict.find((item) => item.value == value)
return item || undefined
}
/**
* 设置字典列表
* @param list 字典响应数组
*/
const setDictList = (list: Api.Auth.DictData) => {
dictList.value = list
initialized.value = true
}
return {
initialized,
dictList,
refresh,
setDictList,
getByCode,
getDataByValue
}
},
{
persist: {
key: 'dict',
storage: localStorage
}
}
)

View File

@@ -0,0 +1,109 @@
/**
* 菜单状态管理模块
*
* 提供菜单数据和动态路由的状态管理
*
* ## 主要功能
*
* - 菜单列表存储和管理
* - 首页路径配置
* - 动态路由注册和移除
* - 路由移除函数管理
* - 菜单宽度配置
*
* ## 使用场景
*
* - 动态菜单加载和渲染
* - 路由权限控制
* - 首页路径动态设置
* - 登出时清理动态路由
*
* ## 工作流程
*
* 1. 获取菜单数据(前端/后端模式)
* 2. 设置菜单列表和首页路径
* 3. 注册动态路由并保存移除函数
* 4. 登出时调用移除函数清理路由
*
* @module store/modules/menu
* @author Art Design Pro Team
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { AppRouteRecord } from '@/types/router'
import { getFirstMenuPath } from '@/utils'
import { HOME_PAGE_PATH } from '@/router'
/**
* 菜单状态管理
* 管理应用的菜单列表、首页路径、菜单宽度和动态路由移除函数
*/
export const useMenuStore = defineStore('menuStore', () => {
/** 首页路径 */
const homePath = ref(HOME_PAGE_PATH)
/** 菜单列表 */
const menuList = ref<AppRouteRecord[]>([])
/** 菜单宽度 */
const menuWidth = ref('')
/** 存储路由移除函数的数组 */
const removeRouteFns = ref<(() => void)[]>([])
/**
* 设置菜单列表
* @param list 菜单路由记录数组
*/
const setMenuList = (list: AppRouteRecord[]) => {
menuList.value = list
setHomePath(HOME_PAGE_PATH || getFirstMenuPath(list))
}
/**
* 获取首页路径
* @returns 首页路径字符串
*/
const getHomePath = () => homePath.value
/**
* 设置主页路径
* @param path 主页路径
*/
const setHomePath = (path: string) => {
homePath.value = path
}
/**
* 添加路由移除函数
* @param fns 要添加的路由移除函数数组
*/
const addRemoveRouteFns = (fns: (() => void)[]) => {
removeRouteFns.value.push(...fns)
}
/**
* 移除所有动态路由
* 执行所有存储的路由移除函数并清空数组
*/
const removeAllDynamicRoutes = () => {
removeRouteFns.value.forEach((fn) => fn())
removeRouteFns.value = []
}
/**
* 清空路由移除函数数组
*/
const clearRemoveRouteFns = () => {
removeRouteFns.value = []
}
return {
menuList,
menuWidth,
removeRouteFns,
setMenuList,
getHomePath,
setHomePath,
addRemoveRouteFns,
removeAllDynamicRoutes,
clearRemoveRouteFns
}
})

View File

@@ -0,0 +1,450 @@
/**
* 系统设置状态管理模块
*
* 提供完整的系统设置状态管理
*
* ## 主要功能
*
* - 菜单布局配置(左侧、顶部、混合、双栏)
* - 主题管理(亮色、暗色、自动)
* - 菜单主题样式配置
* - 界面显示开关(面包屑、标签页、语言切换等)
* - 功能开关(手风琴模式、色弱模式、水印等)
* - 样式配置(边框、圆角、容器宽度、页面过渡)
* - 节日功能配置
* - Element Plus 主题色动态设置
*
* ## 使用场景
*
* - 设置面板配置管理
* - 主题切换和样式定制
* - 界面功能开关控制
* - 用户偏好设置持久化
*
* ## 持久化
*
* - 使用 localStorage 存储
* - 存储键sys-v{version}-setting
* - 支持跨版本数据迁移
*
* @module store/modules/setting
* @author Art Design Pro Team
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { MenuThemeType } from '@/types/store'
import AppConfig from '@/config'
import { SystemThemeEnum, MenuThemeEnum, MenuTypeEnum, ContainerWidthEnum } from '@/enums/appEnum'
import { setElementThemeColor } from '@/utils/ui'
import { useCeremony } from '@/hooks/core/useCeremony'
import { StorageConfig } from '@/utils'
import { SETTING_DEFAULT_CONFIG } from '@/config/setting'
/**
* 系统设置状态管理
* 管理应用的菜单、主题、界面显示等各项设置
*/
export const useSettingStore = defineStore(
'settingStore',
() => {
// 菜单相关设置
/** 菜单类型 */
const menuType = ref(SETTING_DEFAULT_CONFIG.menuType)
/** 菜单展开宽度 */
const menuOpenWidth = ref(SETTING_DEFAULT_CONFIG.menuOpenWidth)
/** 菜单是否展开 */
const menuOpen = ref(SETTING_DEFAULT_CONFIG.menuOpen)
/** 双菜单是否显示文本 */
const dualMenuShowText = ref(SETTING_DEFAULT_CONFIG.dualMenuShowText)
// 主题相关设置
/** 系统主题类型 */
const systemThemeType = ref(SETTING_DEFAULT_CONFIG.systemThemeType)
/** 系统主题模式 */
const systemThemeMode = ref(SETTING_DEFAULT_CONFIG.systemThemeMode)
/** 菜单主题类型 */
const menuThemeType = ref(SETTING_DEFAULT_CONFIG.menuThemeType)
/** 系统主题颜色 */
const systemThemeColor = ref(SETTING_DEFAULT_CONFIG.systemThemeColor)
// 界面显示设置
/** 是否显示菜单按钮 */
const showMenuButton = ref(SETTING_DEFAULT_CONFIG.showMenuButton)
/** 是否显示快速入口 */
const showFastEnter = ref(SETTING_DEFAULT_CONFIG.showFastEnter)
/** 是否显示刷新按钮 */
const showRefreshButton = ref(SETTING_DEFAULT_CONFIG.showRefreshButton)
/** 是否显示面包屑 */
const showCrumbs = ref(SETTING_DEFAULT_CONFIG.showCrumbs)
/** 是否显示工作台标签 */
const showWorkTab = ref(SETTING_DEFAULT_CONFIG.showWorkTab)
/** 是否显示语言切换 */
const showLanguage = ref(SETTING_DEFAULT_CONFIG.showLanguage)
/** 是否显示进度条 */
const showNprogress = ref(SETTING_DEFAULT_CONFIG.showNprogress)
/** 是否显示设置引导 */
const showSettingGuide = ref(SETTING_DEFAULT_CONFIG.showSettingGuide)
/** 是否显示节日文本 */
const showFestivalText = ref(SETTING_DEFAULT_CONFIG.showFestivalText)
/** 是否显示水印 */
const watermarkVisible = ref(SETTING_DEFAULT_CONFIG.watermarkVisible)
// 功能设置
/** 是否自动关闭 */
const autoClose = ref(SETTING_DEFAULT_CONFIG.autoClose)
/** 是否唯一展开 */
const uniqueOpened = ref(SETTING_DEFAULT_CONFIG.uniqueOpened)
/** 是否色弱模式 */
const colorWeak = ref(SETTING_DEFAULT_CONFIG.colorWeak)
/** 是否刷新 */
const refresh = ref(SETTING_DEFAULT_CONFIG.refresh)
/** 是否加载节日烟花 */
const holidayFireworksLoaded = ref(SETTING_DEFAULT_CONFIG.holidayFireworksLoaded)
// 样式设置
/** 边框模式 */
const boxBorderMode = ref(SETTING_DEFAULT_CONFIG.boxBorderMode)
/** 页面过渡效果 */
const pageTransition = ref(SETTING_DEFAULT_CONFIG.pageTransition)
/** 标签页样式 */
const tabStyle = ref(SETTING_DEFAULT_CONFIG.tabStyle)
/** 自定义圆角 */
const customRadius = ref(SETTING_DEFAULT_CONFIG.customRadius)
/** 容器宽度 */
const containerWidth = ref(SETTING_DEFAULT_CONFIG.containerWidth)
// 节日相关
/** 节日日期 */
const festivalDate = ref('')
/**
* 获取菜单主题
* 根据当前主题类型和暗色模式返回对应的主题配置
*/
const getMenuTheme = computed((): MenuThemeType => {
const list = AppConfig.themeList.filter((item) => item.theme === menuThemeType.value)
if (isDark.value) {
return AppConfig.darkMenuStyles[0]
} else {
return list[0]
}
})
/**
* 判断是否为暗色模式
*/
const isDark = computed((): boolean => {
return systemThemeType.value === SystemThemeEnum.DARK
})
/**
* 获取菜单展开宽度
*/
const getMenuOpenWidth = computed((): string => {
return menuOpenWidth.value + 'px' || SETTING_DEFAULT_CONFIG.menuOpenWidth + 'px'
})
/**
* 获取自定义圆角
*/
const getCustomRadius = computed((): string => {
return customRadius.value + 'rem' || SETTING_DEFAULT_CONFIG.customRadius + 'rem'
})
/**
* 是否显示烟花
* 根据当前日期和节日日期判断是否显示烟花效果
*/
const isShowFireworks = computed((): boolean => {
return festivalDate.value === useCeremony().currentFestivalData.value?.date ? false : true
})
/**
* 切换菜单布局
* @param type 菜单类型
*/
const switchMenuLayouts = (type: MenuTypeEnum) => {
menuType.value = type
}
/**
* 设置菜单展开宽度
* @param width 宽度值
*/
const setMenuOpenWidth = (width: number) => {
menuOpenWidth.value = width
}
/**
* 设置全局主题
* @param theme 主题类型
* @param themeMode 主题模式
*/
const setGlopTheme = (theme: SystemThemeEnum, themeMode: SystemThemeEnum) => {
systemThemeType.value = theme
systemThemeMode.value = themeMode
localStorage.setItem(StorageConfig.THEME_KEY, theme)
}
/**
* 切换菜单样式
* @param theme 菜单主题
*/
const switchMenuStyles = (theme: MenuThemeEnum) => {
menuThemeType.value = theme
}
/**
* 设置Element Plus主题颜色
* @param theme 主题颜色
*/
const setElementTheme = (theme: string) => {
systemThemeColor.value = theme
setElementThemeColor(theme)
}
/**
* 切换边框模式
*/
const setBorderMode = () => {
boxBorderMode.value = !boxBorderMode.value
}
/**
* 设置容器宽度
* @param width 容器宽度枚举值
*/
const setContainerWidth = (width: ContainerWidthEnum) => {
containerWidth.value = width
}
/**
* 切换唯一展开模式
*/
const setUniqueOpened = () => {
uniqueOpened.value = !uniqueOpened.value
}
/**
* 切换菜单按钮显示
*/
const setButton = () => {
showMenuButton.value = !showMenuButton.value
}
/**
* 切换快速入口显示
*/
const setFastEnter = () => {
showFastEnter.value = !showFastEnter.value
}
/**
* 切换自动关闭
*/
const setAutoClose = () => {
autoClose.value = !autoClose.value
}
/**
* 切换刷新按钮显示
*/
const setShowRefreshButton = () => {
showRefreshButton.value = !showRefreshButton.value
}
/**
* 切换面包屑显示
*/
const setCrumbs = () => {
showCrumbs.value = !showCrumbs.value
}
/**
* 设置工作台标签显示
* @param show 是否显示
*/
const setWorkTab = (show: boolean) => {
showWorkTab.value = show
}
/**
* 切换语言切换显示
*/
const setLanguage = () => {
showLanguage.value = !showLanguage.value
}
/**
* 切换进度条显示
*/
const setNprogress = () => {
showNprogress.value = !showNprogress.value
}
/**
* 切换色弱模式
*/
const setColorWeak = () => {
colorWeak.value = !colorWeak.value
}
/**
* 隐藏设置引导
*/
const hideSettingGuide = () => {
showSettingGuide.value = false
}
/**
* 显示设置引导
*/
const openSettingGuide = () => {
showSettingGuide.value = true
}
/**
* 设置页面过渡效果
* @param transition 过渡效果名称
*/
const setPageTransition = (transition: string) => {
pageTransition.value = transition
}
/**
* 设置标签页样式
* @param style 样式名称
*/
const setTabStyle = (style: string) => {
tabStyle.value = style
}
/**
* 设置菜单展开状态
* @param open 是否展开
*/
const setMenuOpen = (open: boolean) => {
menuOpen.value = open
}
/**
* 刷新页面
*/
const reload = () => {
refresh.value = !refresh.value
}
/**
* 设置水印显示
* @param visible 是否显示
*/
const setWatermarkVisible = (visible: boolean) => {
watermarkVisible.value = visible
}
/**
* 设置自定义圆角
* @param radius 圆角值
*/
const setCustomRadius = (radius: string) => {
customRadius.value = radius
document.documentElement.style.setProperty('--custom-radius', `${radius}rem`)
}
/**
* 设置节日烟花加载状态
* @param isLoad 是否已加载
*/
const setholidayFireworksLoaded = (isLoad: boolean) => {
holidayFireworksLoaded.value = isLoad
}
/**
* 设置节日文本显示
* @param show 是否显示
*/
const setShowFestivalText = (show: boolean) => {
showFestivalText.value = show
}
const setFestivalDate = (date: string) => {
festivalDate.value = date
}
const setDualMenuShowText = (show: boolean) => {
dualMenuShowText.value = show
}
return {
menuType,
menuOpenWidth,
systemThemeType,
systemThemeMode,
menuThemeType,
systemThemeColor,
boxBorderMode,
uniqueOpened,
showMenuButton,
showFastEnter,
showRefreshButton,
showCrumbs,
autoClose,
showWorkTab,
showLanguage,
showNprogress,
colorWeak,
showSettingGuide,
pageTransition,
tabStyle,
menuOpen,
refresh,
watermarkVisible,
customRadius,
holidayFireworksLoaded,
showFestivalText,
festivalDate,
dualMenuShowText,
containerWidth,
getMenuTheme,
isDark,
getMenuOpenWidth,
getCustomRadius,
isShowFireworks,
switchMenuLayouts,
setMenuOpenWidth,
setGlopTheme,
switchMenuStyles,
setElementTheme,
setBorderMode,
setContainerWidth,
setUniqueOpened,
setButton,
setFastEnter,
setAutoClose,
setShowRefreshButton,
setCrumbs,
setWorkTab,
setLanguage,
setNprogress,
setColorWeak,
hideSettingGuide,
openSettingGuide,
setPageTransition,
setTabStyle,
setMenuOpen,
reload,
setWatermarkVisible,
setCustomRadius,
setholidayFireworksLoaded,
setShowFestivalText,
setFestivalDate,
setDualMenuShowText
}
},
{
persist: {
key: 'setting',
storage: localStorage
}
}
)

View File

@@ -0,0 +1,97 @@
/**
* 表格状态管理模块
*
* 提供表格显示配置的状态管理
*
* ## 主要功能
*
* - 表格尺寸配置(紧凑、默认、宽松)
* - 斑马纹显示开关
* - 边框显示开关
* - 表头背景显示开关
* - 全屏模式开关
*
* ## 使用场景
* - 表格组件样式配置
* - 用户表格偏好设置
* - 表格工具栏功能控制
*
* ## 持久化
*
* - 使用 localStorage 存储
* - 存储键sys-v{version}-table
* - 用户配置跨页面保持
*
* @module store/modules/table
* @author Art Design Pro Team
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { TableSizeEnum } from '@/enums/formEnum'
// 表格
export const useTableStore = defineStore(
'tableStore',
() => {
// 表格大小
const tableSize = ref(TableSizeEnum.DEFAULT)
// 斑马纹
const isZebra = ref(false)
// 边框
const isBorder = ref(false)
// 表头背景
const isHeaderBackground = ref(true)
// 是否全屏
const isFullScreen = ref(false)
/**
* 设置表格大小
* @param size 表格大小枚举值
*/
const setTableSize = (size: TableSizeEnum) => (tableSize.value = size)
/**
* 设置斑马纹显示状态
* @param value 是否显示斑马纹
*/
const setIsZebra = (value: boolean) => (isZebra.value = value)
/**
* 设置表格边框显示状态
* @param value 是否显示边框
*/
const setIsBorder = (value: boolean) => (isBorder.value = value)
/**
* 设置表头背景显示状态
* @param value 是否显示表头背景
*/
const setIsHeaderBackground = (value: boolean) => (isHeaderBackground.value = value)
/**
* 设置是否全屏
* @param value 是否全屏
*/
const setIsFullScreen = (value: boolean) => (isFullScreen.value = value)
return {
tableSize,
isZebra,
isBorder,
isHeaderBackground,
setTableSize,
setIsZebra,
setIsBorder,
setIsHeaderBackground,
isFullScreen,
setIsFullScreen
}
},
{
persist: {
key: 'table',
storage: localStorage
}
}
)

View File

@@ -0,0 +1,253 @@
/**
* 用户状态管理模块
*
* 提供用户相关的状态管理
*
* ## 主要功能
*
* - 用户登录状态管理
* - 用户信息存储
* - 访问令牌和刷新令牌管理
* - 语言设置
* - 搜索历史记录
* - 锁屏状态和密码管理
* - 登出清理逻辑
*
* ## 使用场景
*
* - 用户登录和认证
* - 权限验证
* - 个人信息展示
* - 多语言切换
* - 锁屏功能
* - 搜索历史管理
*
* ## 持久化
*
* - 使用 localStorage 存储
* - 存储键sys-v{version}-user
* - 登出时自动清理
*
* @module store/modules/user
* @author Art Design Pro Team
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { LanguageEnum } from '@/enums/appEnum'
import { router } from '@/router'
import { useSettingStore } from './setting'
import { useWorktabStore } from './worktab'
import { AppRouteRecord } from '@/types/router'
import { setPageTitle } from '@/utils/router'
import { resetRouterState } from '@/router/guards/beforeEach'
import { useMenuStore } from './menu'
import { StorageConfig } from '@/utils/storage/storage-config'
import { fetchClearCache } from '@/api/auth'
/**
* 用户状态管理
* 管理用户登录状态、个人信息、语言设置、搜索历史、锁屏状态等
*/
export const useUserStore = defineStore(
'userStore',
() => {
// 语言设置
const language = ref(LanguageEnum.ZH)
// 登录状态
const isLogin = ref(false)
// 锁屏状态
const isLock = ref(false)
// 锁屏密码
const lockPassword = ref('')
// 用户信息
const info = ref<Partial<Api.Auth.UserInfo>>({})
// 搜索历史记录
const searchHistory = ref<AppRouteRecord[]>([])
// 访问令牌
const accessToken = ref('')
// 刷新令牌
const refreshToken = ref('')
// 计算属性:获取用户信息
const getUserInfo = computed(() => info.value)
// 计算属性:获取设置状态
const getSettingState = computed(() => useSettingStore().$state)
// 计算属性:获取工作台状态
const getWorktabState = computed(() => useWorktabStore().$state)
/**
* 设置用户信息
* @param newInfo 新的用户信息
*/
const setUserInfo = (newInfo: Api.Auth.UserInfo) => {
info.value = newInfo
}
/**
* 设置头像
* @param newAvatar 新的头像 URL
*/
const setAvatar = (newAvatar: string) => {
info.value.avatar = newAvatar
}
/**
* 设置登录状态
* @param status 登录状态
*/
const setLoginStatus = (status: boolean) => {
isLogin.value = status
}
/**
* 设置语言
* @param lang 语言枚举值
*/
const setLanguage = (lang: LanguageEnum) => {
setPageTitle(router.currentRoute.value)
language.value = lang
}
/**
* 设置搜索历史
* @param list 搜索历史列表
*/
const setSearchHistory = (list: AppRouteRecord[]) => {
searchHistory.value = list
}
/**
* 设置锁屏状态
* @param status 锁屏状态
*/
const setLockStatus = (status: boolean) => {
isLock.value = status
}
/**
* 设置锁屏密码
* @param password 锁屏密码
*/
const setLockPassword = (password: string) => {
lockPassword.value = password
}
/**
* 设置令牌
* @param newAccessToken 访问令牌
* @param newRefreshToken 刷新令牌(可选)
*/
const setToken = (newAccessToken: string, newRefreshToken?: string) => {
accessToken.value = newAccessToken
if (newRefreshToken) {
refreshToken.value = newRefreshToken
}
}
/**
* 清理缓存
*/
const clearCache = () => {
fetchClearCache()
}
/**
* 退出登录
* 清空所有用户相关状态并跳转到登录页
* 如果是同一账号重新登录,保留工作台标签页
*/
const logOut = () => {
// 保存当前用户 ID用于下次登录时判断是否为同一用户
const currentUserId = info.value.id
if (currentUserId) {
localStorage.setItem(StorageConfig.LAST_USER_ID_KEY, String(currentUserId))
}
// 清空用户信息
info.value = {}
// 重置登录状态
isLogin.value = false
// 重置锁屏状态
isLock.value = false
// 清空锁屏密码
lockPassword.value = ''
// 清空访问令牌
accessToken.value = ''
// 清空刷新令牌
refreshToken.value = ''
// 注意:不清空工作台标签页,等下次登录时根据用户判断
// 移除iframe路由缓存
sessionStorage.removeItem('iframeRoutes')
// 清空主页路径
useMenuStore().setHomePath('')
// 重置路由状态
resetRouterState(500)
// 跳转到登录页,携带当前路由作为 redirect 参数
const currentRoute = router.currentRoute.value
const redirect = currentRoute.path !== '/login' ? currentRoute.fullPath : undefined
router.push({
name: 'Login',
query: redirect ? { redirect } : undefined
})
}
/**
* 检查并清理工作台标签页
* 如果不是同一用户登录,清空工作台标签页
* 应在登录成功后调用
*/
const checkAndClearWorktabs = () => {
const lastUserId = localStorage.getItem(StorageConfig.LAST_USER_ID_KEY)
const currentUserId = info.value.id
// 无法获取当前用户 ID跳过检查
if (!currentUserId) return
// 首次登录或缓存已清除,保留现有标签页
if (!lastUserId) {
return
}
// 不同用户登录,清空工作台标签页
if (String(currentUserId) !== lastUserId) {
const worktabStore = useWorktabStore()
worktabStore.opened = []
worktabStore.keepAliveExclude = []
}
// 清除临时存储
localStorage.removeItem(StorageConfig.LAST_USER_ID_KEY)
}
return {
language,
isLogin,
isLock,
lockPassword,
info,
searchHistory,
accessToken,
refreshToken,
getUserInfo,
getSettingState,
getWorktabState,
setUserInfo,
setAvatar,
setLoginStatus,
setLanguage,
setSearchHistory,
setLockStatus,
setLockPassword,
setToken,
clearCache,
logOut,
checkAndClearWorktabs
}
},
{
persist: {
key: 'user',
storage: localStorage
}
}
)

View File

@@ -0,0 +1,568 @@
/**
* 工作标签页状态管理模块
*
* 提供多标签页功能的完整状态管理
*
* ## 主要功能
*
* - 标签页打开和关闭
* - 标签页固定和取消固定
* - 批量关闭(左侧、右侧、其他、全部)
* - 标签页缓存管理KeepAlive
* - 标签页标题自定义
* - 标签页路由验证
* - 动态路由参数处理
*
* ## 使用场景
*
* - 多标签页导航
* - 页面缓存控制
* - 标签页右键菜单
* - 固定常用页面
* - 批量关闭标签
*
* ## 核心特性
*
* - 智能标签页复用(同路由名称复用)
* - 固定标签页保护(不可关闭)
* - KeepAlive 缓存排除管理
* - 路由有效性验证
* - 首页自动保留
*
* ## 持久化
* - 使用 localStorage 存储
* - 存储键sys-v{version}-worktab
* - 刷新页面保持标签状态
*
* @module store/modules/worktab
* @author Art Design Pro Team
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { router } from '@/router'
import { LocationQueryRaw, Router } from 'vue-router'
import { WorkTab } from '@/types'
import { useCommon } from '@/hooks/core/useCommon'
interface WorktabState {
current: Partial<WorkTab>
opened: WorkTab[]
keepAliveExclude: string[]
}
/**
* 工作台标签页管理 Store
*/
export const useWorktabStore = defineStore(
'worktabStore',
() => {
// 状态定义
const current = ref<Partial<WorkTab>>({})
const opened = ref<WorkTab[]>([])
const keepAliveExclude = ref<string[]>([])
// 计算属性
const hasOpenedTabs = computed(() => opened.value.length > 0)
const hasMultipleTabs = computed(() => opened.value.length > 1)
const currentTabIndex = computed(() =>
current.value.path ? opened.value.findIndex((tab) => tab.path === current.value.path) : -1
)
/**
* 查找标签页索引
*/
const findTabIndex = (path: string): number => {
return opened.value.findIndex((tab) => tab.path === path)
}
/**
* 获取标签页
*/
const getTab = (path: string): WorkTab | undefined => {
return opened.value.find((tab) => tab.path === path)
}
/**
* 检查标签页是否可关闭
*/
const isTabClosable = (tab: WorkTab): boolean => {
return !tab.fixedTab
}
/**
* 安全的路由跳转
*/
const safeRouterPush = (tab: Partial<WorkTab>): void => {
if (!tab.path) {
console.warn('尝试跳转到无效路径的标签页')
return
}
try {
router.push({
path: tab.path,
query: tab.query as LocationQueryRaw
})
} catch (error) {
console.error('路由跳转失败:', error)
}
}
/**
* 打开或激活一个选项卡
*/
const openTab = (tab: WorkTab): void => {
if (!tab.path) {
console.warn('尝试打开无效的标签页')
return
}
// 从 keepAlive 排除列表中移除
if (tab.name) {
removeKeepAliveExclude(tab.name)
}
// 先根据路由名称查找(应对动态路由参数导致的多开问题),找不到再根据路径查找
let existingIndex = -1
if (tab.name) {
existingIndex = opened.value.findIndex((t) => t.name === tab.name)
}
if (existingIndex === -1) {
existingIndex = findTabIndex(tab.path)
}
if (existingIndex === -1) {
// 新增标签页
const insertIndex = tab.fixedTab ? findFixedTabInsertIndex() : opened.value.length
const newTab = { ...tab }
if (tab.fixedTab) {
opened.value.splice(insertIndex, 0, newTab)
} else {
opened.value.push(newTab)
}
current.value = newTab
} else {
// 更新现有标签页(当动态路由参数或查询变更时,复用同一标签)
const existingTab = opened.value[existingIndex]
opened.value[existingIndex] = {
...existingTab,
path: tab.path,
params: tab.params,
query: tab.query,
title: tab.title || existingTab.title,
fixedTab: tab.fixedTab ?? existingTab.fixedTab,
keepAlive: tab.keepAlive ?? existingTab.keepAlive,
name: tab.name || existingTab.name,
icon: tab.icon || existingTab.icon
}
current.value = opened.value[existingIndex]
}
}
/**
* 查找固定标签页的插入位置
*/
const findFixedTabInsertIndex = (): number => {
let insertIndex = 0
for (let i = 0; i < opened.value.length; i++) {
if (opened.value[i].fixedTab) {
insertIndex = i + 1
} else {
break
}
}
return insertIndex
}
/**
* 关闭指定的选项卡
*/
const removeTab = (path: string): void => {
const targetTab = getTab(path)
const targetIndex = findTabIndex(path)
if (targetIndex === -1) {
console.warn(`尝试关闭不存在的标签页: ${path}`)
return
}
if (targetTab && !isTabClosable(targetTab)) {
console.warn(`尝试关闭固定标签页: ${path}`)
return
}
// 从标签页列表中移除
opened.value.splice(targetIndex, 1)
// 处理缓存排除
if (targetTab?.name) {
addKeepAliveExclude(targetTab)
}
const { homePath } = useCommon()
// 如果关闭后无标签页,跳转首页
if (!hasOpenedTabs.value) {
if (path !== homePath.value) {
current.value = {}
safeRouterPush({ path: homePath.value })
}
return
}
// 如果关闭的是当前激活标签,需要激活其他标签
if (current.value.path === path) {
const newIndex = targetIndex >= opened.value.length ? opened.value.length - 1 : targetIndex
current.value = opened.value[newIndex]
safeRouterPush(current.value)
}
}
/**
* 关闭左侧选项卡
*/
const removeLeft = (path: string): void => {
const targetIndex = findTabIndex(path)
if (targetIndex === -1) {
console.warn(`尝试关闭左侧标签页,但目标标签页不存在: ${path}`)
return
}
// 获取左侧可关闭的标签页
const leftTabs = opened.value.slice(0, targetIndex)
const closableLeftTabs = leftTabs.filter(isTabClosable)
if (closableLeftTabs.length === 0) {
console.warn('左侧没有可关闭的标签页')
return
}
// 标记为缓存排除
markTabsToRemove(closableLeftTabs)
// 移除左侧可关闭的标签页
opened.value = opened.value.filter(
(tab, index) => index >= targetIndex || !isTabClosable(tab)
)
// 确保当前标签是激活状态
const targetTab = getTab(path)
if (targetTab) {
current.value = targetTab
}
}
/**
* 关闭右侧选项卡
*/
const removeRight = (path: string): void => {
const targetIndex = findTabIndex(path)
if (targetIndex === -1) {
console.warn(`尝试关闭右侧标签页,但目标标签页不存在: ${path}`)
return
}
// 获取右侧可关闭的标签页
const rightTabs = opened.value.slice(targetIndex + 1)
const closableRightTabs = rightTabs.filter(isTabClosable)
if (closableRightTabs.length === 0) {
console.warn('右侧没有可关闭的标签页')
return
}
// 标记为缓存排除
markTabsToRemove(closableRightTabs)
// 移除右侧可关闭的标签页
opened.value = opened.value.filter(
(tab, index) => index <= targetIndex || !isTabClosable(tab)
)
// 确保当前标签是激活状态
const targetTab = getTab(path)
if (targetTab) {
current.value = targetTab
}
}
/**
* 关闭其他选项卡
*/
const removeOthers = (path: string): void => {
const targetTab = getTab(path)
if (!targetTab) {
console.warn(`尝试关闭其他标签页,但目标标签页不存在: ${path}`)
return
}
// 获取其他可关闭的标签页
const otherTabs = opened.value.filter((tab) => tab.path !== path)
const closableTabs = otherTabs.filter(isTabClosable)
if (closableTabs.length === 0) {
console.warn('没有其他可关闭的标签页')
return
}
// 标记为缓存排除
markTabsToRemove(closableTabs)
// 只保留当前标签和固定标签
opened.value = opened.value.filter((tab) => tab.path === path || !isTabClosable(tab))
// 确保当前标签是激活状态
current.value = targetTab
}
/**
* 关闭所有可关闭的标签页
*/
const removeAll = (): void => {
const { homePath } = useCommon()
const hasFixedTabs = opened.value.some((tab) => tab.fixedTab)
// 获取可关闭的标签页
const closableTabs = opened.value.filter((tab) => {
if (!isTabClosable(tab)) return false
// 如果有固定标签,则所有可关闭的都可以关闭;否则保留首页
return hasFixedTabs || tab.path !== homePath.value
})
if (closableTabs.length === 0) {
console.warn('没有可关闭的标签页')
return
}
// 标记为缓存排除
markTabsToRemove(closableTabs)
// 保留不可关闭的标签页和首页(当没有固定标签时)
opened.value = opened.value.filter((tab) => {
return !isTabClosable(tab) || (!hasFixedTabs && tab.path === homePath.value)
})
// 处理激活状态
if (!hasOpenedTabs.value) {
current.value = {}
safeRouterPush({ path: homePath.value })
return
}
// 选择激活的标签页:优先首页,其次第一个可用标签
const homeTab = opened.value.find((tab) => tab.path === homePath.value)
const targetTab = homeTab || opened.value[0]
current.value = targetTab
safeRouterPush(targetTab)
}
/**
* 将指定选项卡添加到 keepAlive 排除列表中
*/
const addKeepAliveExclude = (tab: WorkTab): void => {
if (!tab.keepAlive || !tab.name) return
if (!keepAliveExclude.value.includes(tab.name)) {
keepAliveExclude.value.push(tab.name)
}
}
/**
* 从 keepAlive 排除列表中移除指定组件名称
*/
const removeKeepAliveExclude = (name: string): void => {
if (!name) return
keepAliveExclude.value = keepAliveExclude.value.filter((item) => item !== name)
}
/**
* 将传入的一组选项卡的组件名称标记为排除缓存
*/
const markTabsToRemove = (tabs: WorkTab[]): void => {
tabs.forEach((tab) => {
if (tab.name) {
addKeepAliveExclude(tab)
}
})
}
/**
* 切换指定标签页的固定状态
*/
const toggleFixedTab = (path: string): void => {
const targetIndex = findTabIndex(path)
if (targetIndex === -1) {
console.warn(`尝试切换不存在标签页的固定状态: ${path}`)
return
}
const tab = { ...opened.value[targetIndex] }
tab.fixedTab = !tab.fixedTab
// 移除原位置
opened.value.splice(targetIndex, 1)
if (tab.fixedTab) {
// 固定标签插入到所有固定标签的末尾
const firstNonFixedIndex = opened.value.findIndex((t) => !t.fixedTab)
const insertIndex = firstNonFixedIndex === -1 ? opened.value.length : firstNonFixedIndex
opened.value.splice(insertIndex, 0, tab)
} else {
// 非固定标签插入到所有固定标签后
const fixedCount = opened.value.filter((t) => t.fixedTab).length
opened.value.splice(fixedCount, 0, tab)
}
// 更新当前标签引用
if (current.value.path === path) {
current.value = tab
}
}
/**
* 验证工作台标签页的路由有效性
*/
const validateWorktabs = (routerInstance: Router): void => {
try {
// 动态路由校验:优先使用路由 name 判断有效性;否则用 resolve 匹配参数化路径
const isTabRouteValid = (tab: Partial<WorkTab>): boolean => {
try {
if (tab.name) {
const routes = routerInstance.getRoutes()
if (routes.some((r) => r.name === tab.name)) return true
}
if (tab.path) {
const resolved = routerInstance.resolve({
path: tab.path,
query: (tab.query as LocationQueryRaw) || undefined
})
return resolved.matched.length > 0
}
return false
} catch {
return false
}
}
// 过滤出有效的标签页
const validTabs = opened.value.filter((tab) => isTabRouteValid(tab))
if (validTabs.length !== opened.value.length) {
console.warn('发现无效的标签页路由,已自动清理')
opened.value = validTabs
}
// 验证当前激活标签的有效性
const isCurrentValid = current.value && isTabRouteValid(current.value)
if (!isCurrentValid && validTabs.length > 0) {
console.warn('当前激活标签无效,已自动切换')
current.value = validTabs[0]
} else if (!isCurrentValid) {
current.value = {}
}
} catch (error) {
console.error('验证工作台标签页失败:', error)
}
}
/**
* 清空所有状态(用于登出等场景)
*/
const clearAll = (): void => {
current.value = {}
opened.value = []
keepAliveExclude.value = []
}
/**
* 获取状态快照(用于持久化存储)
*/
const getStateSnapshot = (): WorktabState => {
return {
current: { ...current.value },
opened: [...opened.value],
keepAliveExclude: [...keepAliveExclude.value]
}
}
/**
* 获取标签页标题
*/
const getTabTitle = (path: string): WorkTab | undefined => {
const tab = getTab(path)
return tab
}
/**
* 更新标签页标题
*/
const updateTabTitle = (path: string, title: string): void => {
const tab = getTab(path)
if (tab) {
tab.customTitle = title
}
}
/**
* 重置标签页标题
*/
const resetTabTitle = (path: string): void => {
const tab = getTab(path)
if (tab) {
tab.customTitle = ''
}
}
return {
// 状态
current,
opened,
keepAliveExclude,
// 计算属性
hasOpenedTabs,
hasMultipleTabs,
currentTabIndex,
// 方法
openTab,
removeTab,
removeLeft,
removeRight,
removeOthers,
removeAll,
toggleFixedTab,
validateWorktabs,
clearAll,
getStateSnapshot,
// 工具方法
findTabIndex,
getTab,
isTabClosable,
addKeepAliveExclude,
removeKeepAliveExclude,
markTabsToRemove,
getTabTitle,
updateTabTitle,
resetTabTitle
}
},
{
persist: {
key: 'worktab',
storage: localStorage
}
}
)

135
saiadmin-artd/src/types/api/api.d.ts vendored Normal file
View File

@@ -0,0 +1,135 @@
/**
* API 接口类型定义模块
*
* 提供所有后端接口的类型定义
*
* ## 主要功能
*
* - 通用类型(分页参数、响应结构等)
* - 认证类型(登录、用户信息等)
* - 系统管理类型(用户、角色等)
* - 全局命名空间声明
*
* ## 使用场景
*
* - API 请求参数类型约束
* - API 响应数据类型定义
* - 接口文档类型同步
*
* ## 注意事项
*
* - 在 .vue 文件使用需要在 eslint.config.mjs 中配置 globals: { Api: 'readonly' }
* - 使用全局命名空间,无需导入即可使用
*
* ## 使用方式
*
* ```typescript
* const params: Api.Auth.LoginParams = { userName: 'admin', password: '123456' }
* const response: Api.Auth.UserInfo = await fetchUserInfo()
* ```
*
* @module types/api/api
* @author Art Design Pro Team
*/
declare namespace Api {
/** 通用类型 */
namespace Common {
/** 分页参数 */
interface PaginationParams {
/** 当前页码 */
current: number
/** 每页条数 */
size: number
/** 总条数 */
total: number
}
/** 通用搜索参数 */
type CommonSearchParams = Pick<PaginationParams, 'current' | 'size'>
type SafeRecord = Record<string, unknown>
type ApiData = {
[key: string]: any
}
type ApiPage<T = any> = {
current_page: number
data: T[]
per_page: number
total: number
}
/** 分页响应基础结构 */
interface PaginatedResponse<T = any> {
records: T[]
current: number
size: number
total: number
}
/** 启用状态 */
type EnableStatus = '1' | '2'
}
/** 认证类型 */
namespace Auth {
/** 验证码参数 */
interface CaptchaResponse {
result: number
uuid: string
image: string
}
/** 登录参数 */
interface LoginParams {
username: string
password: string
code: string
uuid: string
}
/** 登录响应 */
interface LoginResponse {
token_type: string
expires_in: number
access_token: string
refresh_token: string
}
/** 用户信息 */
interface UserInfo {
buttons: string[]
roles: string[]
id: number
username: string
email: string
phone: string
avatar?: string
realname?: string
dashboard?: string
gender?: string
signed?: string
department?: {
id: number
name: string
}
}
// 基础项类型
interface DictItem {
id: number
label: string
value: string | number
color: string
disabled?: boolean
[key: string]: any
}
// 主对象类型
interface DictData {
[key: string]: DictItem[]
}
}
}

View File

@@ -0,0 +1,95 @@
/**
* 通用类型定义模块
*
* 提供项目中常用的通用类型定义
*
* ## 主要功能
*
* - 状态类型(启用/禁用)
* - 性别类型
* - 排序方向类型
* - 操作类型(增删改查)
* - 记录类型(键值对)
* - 时间范围类型
* - 文件信息类型
* - 坐标和尺寸类型
* - 响应式断点类型
* - 主题和语言类型
* - 环境和弹窗类型
*
* ## 使用场景
*
* - 通用数据结构定义
* - 类型约束和提示
* - 减少重复类型定义
*
* @module types/common/index
* @author Art Design Pro Team
*/
// 导出响应类型
export * from './response'
// 状态类型
export type Status = 0 | 1 // 0: 禁用, 1: 启用
// 性别类型
export type Gender = 'male' | 'female' | 'unknown'
// 排序方向
export type SortOrder = 'ascending' | 'descending'
// 操作类型
export type ActionType = 'create' | 'update' | 'delete' | 'view'
// 可选的记录类型
export type Recordable<T = any> = Record<string, T>
// 键值对类型
export type KeyValue<T = any> = {
key: string
value: T
label?: string
}
// 时间范围类型
export interface TimeRange {
startTime: string
endTime: string
}
// 文件类型
export interface FileInfo {
name: string
url: string
size: number
type: string
lastModified?: number
}
// 坐标类型
export interface Position {
x: number
y: number
}
// 尺寸类型
export interface Size {
width: number
height: number
}
// 响应式断点类型
export type Breakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
// 主题类型
export type ThemeMode = 'light' | 'dark' | 'auto'
// 语言类型
export type Language = 'zh-CN' | 'en-US'
// 环境类型
export type Environment = 'development' | 'production' | 'test'
// 弹窗类型
export type DialogType = 'add' | 'edit'

View File

@@ -0,0 +1,30 @@
/**
* API 响应类型定义模块
*
* 提供统一的 API 响应结构类型定义
*
* ## 主要功能
*
* - 基础响应结构定义
* - 泛型支持(适配不同数据类型)
* - 统一的响应格式约束
*
* ## 使用场景
*
* - API 请求响应类型约束
* - 接口数据类型定义
* - 响应数据解析
*
* @module types/common/response
* @author Art Design Pro Team
*/
/** 基础 API 响应结构 */
export interface BaseResponse<T = unknown> {
/** 状态码 */
code: number
/** 消息 */
message: string
/** 数据 */
data: T
}

View File

@@ -0,0 +1,324 @@
/**
* 图表组件类型定义模块
*
* 提供 ECharts 图表组件的完整类型定义
*
* ## 主要功能
*
* - 基础图表配置类型
* - 柱状图类型定义
* - 折线图类型定义
* - 饼图/环形图类型定义
* - 雷达图类型定义
* - K线图类型定义
* - 散点图类型定义
* - 地图图表类型定义
* - 双向堆叠柱状图类型定义
* - 图表主题配置类型
* - 图表事件回调类型
*
* ## 使用场景
*
* - 图表组件 Props 类型约束
* - 图表配置类型定义
* - 图表数据结构定义
* - 图表事件处理
*
* @module types/component/chart
* @author Art Design Pro Team
*/
import type { EChartsOption } from '@/plugins/echarts'
// 图例位置类型
export type LegendPosition = 'bottom' | 'top' | 'left' | 'right'
export type SymbolType =
| 'circle'
| 'rect'
| 'roundRect'
| 'triangle'
| 'diamond'
| 'pin'
| 'arrow'
| 'none'
// 图表主题配置
export interface ChartThemeConfig {
/** 图表高度 */
chartHeight: string
/** 字体大小 */
fontSize: number
/** 字体颜色 */
fontColor: string
/** 主题颜色 */
themeColor: string
/** 颜色组 */
colors: string[]
}
// 图表初始化选项
export interface UseChartOptions {
/** 初始化选项 */
initOptions?: EChartsOption
/** 延迟初始化时间(ms) */
initDelay?: number
/** IntersectionObserver阈值 */
threshold?: number
/** 是否自动响应主题变化 */
autoTheme?: boolean
}
// 基础图表 Props 接口 - 统一所有图表的基础属性
export interface BaseChartProps {
/** 图表高度 */
height?: string
/** 是否加载中 */
loading?: boolean
isEmpty?: boolean
/** 颜色配置 */
colors?: string[]
}
// 轴线显示控制接口 - 统一轴线相关配置
export interface AxisDisplayProps {
/** 是否显示坐标轴标签 */
showAxisLabel?: boolean
/** 是否显示坐标轴线 */
showAxisLine?: boolean
/** 是否显示分割线 */
showSplitLine?: boolean
}
// 交互显示控制接口 - 统一交互相关配置
export interface InteractionProps {
/** 是否显示提示框 */
showTooltip?: boolean
/** 是否显示图例 */
showLegend?: boolean
/** 图例位置 */
legendPosition?: LegendPosition
}
// 柱状图数据项接口
export interface BarDataItem {
/** 系列名称 */
name: string
/** 数据值 */
data: number[]
/** 柱状图宽度 */
barWidth?: string | number
/** 堆叠分组名称 */
stack?: string
}
// 柱状图 Props 接口 - 统一柱状图配置
export interface BarChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps {
/** 图表数据 - 支持单组数据或多组数据 */
data: number[] | BarDataItem[]
/** X轴标签数据 */
xAxisData?: string[]
/** 柱状图宽度 */
barWidth?: string | number
/** 是否堆叠显示 */
stack?: boolean
/** 圆角 */
borderRadius?: number | number[]
}
// 折线图数据项接口
export interface LineDataItem {
/** 系列名称 */
name: string
/** 数据值 */
data: number[]
/** 线条宽度 */
lineWidth?: number
/** 是否显示区域填充 */
showAreaColor?: boolean
/** 区域样式配置 */
areaStyle?: {
/** 渐变开始透明度 */
startOpacity?: number
/** 渐变结束透明度 */
endOpacity?: number
/** 自定义 ECharts areaStyle 配置 */
custom?: any
}
/** 是否平滑曲线 */
smooth?: boolean
/** 数据点符号 */
symbol?: SymbolType
/** 数据点大小 */
symbolSize?: number
}
// 折线图 Props 接口 - 统一折线图配置
export interface LineChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps {
/** 图表数据 - 支持单组数据或多组数据 */
data: number[] | LineDataItem[]
/** X轴标签数据 */
xAxisData?: string[]
/** 线条宽度 */
lineWidth?: number
/** 是否显示区域填充 */
showAreaColor?: boolean
/** 是否平滑曲线 */
smooth?: boolean
/** 数据点符号 */
symbol?: SymbolType
/** 数据点大小 */
symbolSize?: number
/** 多数据动画延迟间隔(毫秒) */
animationDelay?: number
}
// 雷达图数据项接口
export interface RadarDataItem {
/** 系列名称 */
name: string
/** 数据值 */
value: number[]
}
// 雷达图 Props 接口 - 统一雷达图配置
export interface RadarChartProps extends BaseChartProps, InteractionProps {
/** 雷达图指标配置 */
indicator?: Array<{ name: string; max: number }>
/** 图表数据 */
data?: RadarDataItem[]
}
// 饼图/环形图数据项接口
export interface PieDataItem {
/** 数据值 */
value: number
/** 数据名称 */
name: string
}
// 环形图 Props 接口 - 统一环形图配置
export interface RingChartProps extends BaseChartProps, InteractionProps {
/** 图表数据 */
data: PieDataItem[]
/** 内外半径 */
radius?: string[]
/** 边框圆角 */
borderRadius?: number
/** 中心文本 */
centerText?: string
/** 是否显示标签 */
showLabel?: boolean
}
// K线图数据项接口
export interface KLineDataItem {
/** 时间标签 */
time: string
/** 开盘价 */
open: number
/** 收盘价 */
close: number
/** 最高价 */
high: number
/** 最低价 */
low: number
}
// K线图 Props 接口 - 统一K线图配置
export interface KLineChartProps extends BaseChartProps {
/** 图表数据 */
data?: KLineDataItem[]
/** 是否显示数据缩放控件 */
showDataZoom?: boolean
/** 数据缩放初始开始位置 */
dataZoomStart?: number
/** 数据缩放初始结束位置 */
dataZoomEnd?: number
}
// 散点图数据项接口
export interface ScatterDataItem {
/** 坐标值 [x, y] */
value: number[]
}
// 散点图 Props 接口 - 统一散点图配置
export interface ScatterChartProps extends BaseChartProps, AxisDisplayProps, InteractionProps {
/** 图表数据 */
data?: ScatterDataItem[]
/** 散点大小 */
symbolSize?: number
}
// 双柱对比图 Props 接口 - 统一双柱对比图配置
export interface DualBarCompareChartProps extends BaseChartProps {
/** 上方数据 */
topData: number[]
/** 下方数据 */
bottomData: number[]
/** X轴标签数据 */
xAxisData: string[]
/** 上方柱子颜色 */
topColor?: string
/** 下方柱子颜色 */
bottomColor?: string
/** 柱状图宽度 */
barWidth?: number
}
// 地图图表 Props 接口 - 统一地图图表配置
export interface MapChartProps extends BaseChartProps {
/** 地图数据 */
mapData?: any[]
/** 选中区域 */
selectedRegion?: string
/** 是否显示标签 */
showLabels?: boolean
/** 是否显示散点 */
showScatter?: boolean
}
// 双向堆叠柱状图 Props 接口(人口金字塔样式)
export interface BidirectionalBarChartProps
extends BaseChartProps,
AxisDisplayProps,
InteractionProps {
/** 正向数据(向上显示) */
positiveData: number[]
/** 负向数据(向下显示) */
negativeData: number[]
/** X轴标签数据 */
xAxisData?: string[]
/** 正向数据名称 */
positiveName?: string
/** 负向数据名称 */
negativeName?: string
/** 柱状图宽度 */
barWidth?: string | number
/** Y轴最小值 */
yAxisMin?: number
/** Y轴最大值 */
yAxisMax?: number
/** 是否显示数据标签 */
showDataLabel?: boolean
/** 正向数据圆角配置 */
positiveBorderRadius?: number | number[]
/** 负向数据圆角配置 */
negativeBorderRadius?: number | number[]
}
// 图表配置生成器函数类型
export type ChartOptionGenerator = () => EChartsOption
// 图表事件回调类型
export type ChartEventCallback = (params: any) => void
// 图表错误信息接口
export interface ChartError {
/** 错误码 */
code: string
/** 错误信息 */
message: string
/** 错误详情 */
details?: any
}

View File

@@ -0,0 +1,145 @@
/**
* 组件类型定义模块
*
* 提供项目组件的类型定义
*
* ## 主要功能
*
* - 搜索组件类型定义
* - 表格列配置类型
* - 分页配置类型
* - 表单规则类型
* - 对话框配置类型
*
* ## 使用场景
*
* - 组件 Props 类型约束
* - 组件配置类型定义
* - 组件事件参数类型
*
* @module types/component/index
* @author Art Design Pro Team
*/
// 搜索组件类型
export type SearchComponentType =
| 'input'
| 'select'
| 'radio'
| 'checkbox'
| 'date'
| 'datetime'
| 'daterange'
| 'datetimerange'
| 'month'
| 'monthrange'
| 'year'
| 'yearrange'
| 'week'
| 'time'
| 'timerange'
// 搜索框值变化参数
export interface SearchChangeParams {
prop: string
val: unknown
}
// 表格列配置接口
export interface ColumnOption<T = any> {
// 列类型
type?: 'selection' | 'expand' | 'index' | 'globalIndex'
// 列属性名
prop?: string
// 列标题
label?: string
// 列宽度
width?: string | number
// 最小列宽度
minWidth?: string | number
// 固定列
fixed?: boolean | 'left' | 'right'
// 是否可排序
sortable?: boolean
// 过滤器选项
filters?: any[]
// 过滤方法
filterMethod?: (value: any, row: any) => boolean
// 过滤器位置
filterPlacement?: string
// 是否禁用
disabled?: boolean
// 是否显示列
visible?: boolean
// 是否选中显示
checked?: boolean
// 自定义渲染函数
formatter?: (row: T) => any
// 插槽相关配置
// 是否使用插槽渲染内容
useSlot?: boolean
// 插槽名称(默认为 prop 值)
slotName?: string
// 是否使用表头插槽
useHeaderSlot?: boolean
// 表头插槽名称(默认为 `${prop}-header`
headerSlotName?: string
// 其他属性
[key: string]: any
}
// 分页配置
export interface PaginationConfig {
// 当前页
currentPage: number
// 每页条数
pageSize: number
// 总条数
total: number
// 每页显示个数选择器的选项
pageSizes?: number[]
// 组件布局
layout?: string
// 是否为小型分页
small?: boolean
}
// 表单规则
export interface FormRule {
// 是否必填
required?: boolean
// 错误提示信息
message?: string
// 触发方式
trigger?: string | string[]
// 最小长度
min?: number
// 最大长度
max?: number
// 正则表达式
pattern?: RegExp
// 自定义验证函数
validator?: (rule: any, value: any, callback: any) => void
}
// 对话框配置
export interface DialogConfig {
// 标题
title: string
// 是否显示
visible: boolean
// 宽度
width?: string | number
// 是否可以通过点击 modal 关闭
closeOnClickModal?: boolean
// 是否可以通过按下 ESC 关闭
closeOnPressEscape?: boolean
// 是否显示关闭按钮
showClose?: boolean
// 是否在 Dialog 出现时将 body 滚动锁定
lockScroll?: boolean
// 是否显示遮罩层
modal?: boolean
// 自定义类名
customClass?: string
}

View File

@@ -0,0 +1,211 @@
/**
* 配置类型定义模块
*
* 提供系统配置相关的类型定义
*
* ## 主要功能
*
* - 主题设置类型
* - 菜单布局类型
* - 节日配置类型
* - 系统基础配置类型
* - 快速入口配置类型
* - 顶部栏功能配置类型
* - 环境配置类型
* - 应用配置类型
*
* ## 使用场景
*
* - 系统配置文件类型约束
* - 配置项类型定义
* - 配置数据验证
*
* @module types/config/index
* @author Art Design Pro Team
*/
import { MenuTypeEnum, SystemThemeEnum } from '@/enums/appEnum'
import { MenuThemeType, SystemThemeTypes } from '@/types/store'
// 主题设置
export interface ThemeSetting {
/** 主题名称 */
name: string
/** 系统主题类型 */
theme: SystemThemeEnum
/** 主题颜色数组 */
color: string[]
/** 左侧线条颜色 */
leftLineColor: string
/** 右侧线条颜色 */
rightLineColor: string
/** 主题图片 */
img: string
}
// 菜单布局
export interface MenuLayout {
/** 布局名称 */
name: string
/** 菜单类型值 */
value: MenuTypeEnum
/** 布局预览图 */
img: string
/** 布局描述 */
description?: string
}
// 节日配置
export interface FestivalConfig {
/** 节日日期(单日)或开始日期(日期范围) */
date: string
/** 节日结束日期(可选,用于跨日期节日) */
endDate?: string
/** 节日名称 */
name: string
/** 烟花图片 */
image: string
/** 滚动文本 */
scrollText: string
/** 是否激活 */
isActive?: boolean
/** 烟花播放次数(可选,默认为 3 次) */
count?: number
}
// 系统基础配置
export interface SystemBasicConfig {
// 系统名称
name: string
// 系统描述
description?: string
// 系统logo
logo?: string
// 系统favicon
favicon?: string
// 版权信息
copyright?: string
}
// 快速入口基础项
export interface FastEnterBaseItem {
/** 名称 */
name: string
/** 是否启用 */
enabled?: boolean
/** 排序权重 */
order?: number
/** 路由名称 */
routeName?: string
/** 外部链接 */
link?: string
}
// 快速入口应用项
export interface FastEnterApplication extends FastEnterBaseItem {
/** 应用描述 */
description: string
/** 图标代码 */
icon: string
/** 图标颜色 */
iconColor: string
}
// 快速链接项
export type FastEnterQuickLink = FastEnterBaseItem
// 快速入口配置
export interface FastEnterConfig {
/** 应用列表 */
applications: FastEnterApplication[]
/** 快速链接 */
quickLinks: FastEnterQuickLink[]
/** 显示条件(屏幕宽度) */
minWidth?: number
}
// 系统配置
export interface SystemConfig {
// 系统基础信息
systemInfo: SystemBasicConfig
// 系统主题样式
systemThemeStyles: SystemThemeTypes
// 设置主题列表
settingThemeList: ThemeSetting[]
// 菜单布局列表
menuLayoutList: MenuLayout[]
// 主题列表
themeList: MenuThemeType[]
// 暗色菜单样式
darkMenuStyles: MenuThemeType[]
// 系统主色调
systemMainColor: readonly string[]
// 快速入口配置
fastEnter?: FastEnterConfig
// 顶部栏功能配置
headerBar?: HeaderBarFeatureConfig
}
// 环境配置
export interface EnvConfig {
// 环境名称
NODE_ENV: string
// 应用版本
VITE_VERSION: string
// 应用端口
VITE_PORT: string
// 应用基础路径
VITE_BASE_URL: string
// API 地址
VITE_API_URL: string
// 是否开启 Mock
VITE_USE_MOCK?: string
// 是否开启压缩
VITE_USE_GZIP?: string
// 是否开启 CDN
VITE_USE_CDN?: string
}
// 应用配置
export interface AppConfig extends SystemConfig {
// 环境配置
env: EnvConfig
// 开发模式
isDev: boolean
// 生产模式
isProd: boolean
// 测试模式
isTest: boolean
}
// 功能配置项基础接口
export interface FeatureConfigItem {
enabled: boolean
description: string
}
// 顶部栏功能配置接口
export interface HeaderBarFeatureConfig {
/** 菜单按钮 */
menuButton: FeatureConfigItem
/** 刷新按钮 */
refreshButton: FeatureConfigItem
/** 快速入口 */
fastEnter: FeatureConfigItem
/** 面包屑导航 */
breadcrumb: FeatureConfigItem
/** 全局搜索 */
globalSearch: FeatureConfigItem
/** 全屏功能 */
fullscreen: FeatureConfigItem
/** 通知功能 */
notification: FeatureConfigItem
/** 聊天功能 */
chat: FeatureConfigItem
/** 多语言切换 */
language: FeatureConfigItem
/** 设置面板 */
settings: FeatureConfigItem
/** 主题切换 */
themeToggle: FeatureConfigItem
}

View File

@@ -0,0 +1,22 @@
/**
* 类型定义统一导出模块
* 提供全局类型定义的统一导出入口
*
* @module types/index
* @author Art Design Pro Team
*/
/** 通用类型定义(基础类型、工具类型等) */
export * from './common'
/** 组件相关类型定义 */
export * from './component'
/** 状态管理相关类型定义 */
export * from './store'
/** 路由相关类型定义 */
export * from './router'
/** 配置相关类型定义 */
export * from './config'

View File

@@ -0,0 +1,80 @@
/**
* 路由类型定义模块
*
* 提供路由相关的类型定义
*
* ## 主要功能
*
* - 路由元数据类型(标题、图标、权限等)
* - 应用路由记录类型
* - 路由配置扩展
*
* ## 使用场景
*
* - 路由配置类型约束
* - 路由元数据定义
* - 菜单生成
* - 权限控制
*
* @module types/router/index
* @author Art Design Pro Team
*/
import { RouteRecordRaw } from 'vue-router'
/**
* 路由元数据接口
* 定义路由的各种配置属性
*/
export interface RouteMeta extends Record<string | number | symbol, unknown> {
/** 路由标题 */
title: string
/** 路由图标 */
icon?: string
/** 是否显示徽章 */
showBadge?: boolean
/** 文本徽章 */
showTextBadge?: string
/** 是否在菜单中隐藏 */
isHide?: boolean
/** 是否在标签页中隐藏 */
isHideTab?: boolean
/** 外部链接 */
link?: string
/** 是否为iframe */
isIframe?: boolean
/** 是否缓存 */
keepAlive?: boolean
/** 操作权限 */
authList?: Array<{
title: string
authMark: string
}>
/** 是否为一级菜单 */
isFirstLevel?: boolean
/** 角色权限 */
roles?: string[]
/** 是否固定标签页 */
fixedTab?: boolean
/** 激活菜单路径 */
activePath?: string
/** 是否为全屏页面 */
isFullPage?: boolean
/** 是否为权限按钮行 */
isAuthButton?: boolean
/** 权限标识 */
authMark?: string
/** 父级路径 */
parentPath?: string
}
/**
* 应用路由记录接口
* 扩展 Vue Router 的路由记录类型
*/
export interface AppRouteRecord extends Omit<RouteRecordRaw, 'meta' | 'children' | 'component'> {
id?: number
meta: RouteMeta
children?: AppRouteRecord[]
component?: string | (() => Promise<any>)
}

View File

@@ -0,0 +1,23 @@
/**
* 基础类型定义模块
*
* @module types/sai/index
* @author saithink
*/
/**
* 对话框Props类型
*/
export interface Props {
visible: boolean
dialogType: string
dialogData?: Partial<Record<string, any>>
}
/**
* 对话框Emits类型
*/
export interface Emits {
(e: 'update:visible', value: boolean): void
(e: 'submit'): void
}

View File

@@ -0,0 +1,157 @@
/**
* Store 状态类型定义模块
*
* 提供 Pinia Store 的状态类型定义
*
* ## 主要功能
*
* - 系统主题类型
* - 菜单主题类型
* - 设置状态类型
* - 工作标签页类型
* - 用户状态类型
* - 菜单状态类型
* - 根状态类型
*
* ## 使用场景
*
* - Store 状态类型约束
* - 状态数据结构定义
* - 类型提示和自动补全
*
* @module types/store/index
* @author Art Design Pro Team
*/
import { MenuThemeEnum, SystemThemeEnum } from '@/enums/appEnum'
import { LocationQueryRaw } from 'vue-router'
// 系统主题样式light | dark
export interface SystemThemeType {
/** 主题类名 */
className: string
}
// 定义包含多个主题的类型
export type SystemThemeTypes = {
[key in Exclude<SystemThemeEnum, SystemThemeEnum.AUTO>]: SystemThemeType
}
// 菜单主题样式
export interface MenuThemeType {
/** 主题类型 */
theme: MenuThemeEnum
/** 背景颜色 */
background: string
/** 系统名称颜色 */
systemNameColor: string
/** 文本颜色 */
textColor: string
/** 图标颜色 */
iconColor: string
/** 背景图片 */
img?: string
}
// 设置中心
export interface SettingState {
/** 主题 */
theme: string
/** 是否只保持一个子菜单的展开 */
uniqueOpened: boolean
/** 是否显示菜单按钮 */
menuButton: boolean
/** 是否显示刷新按钮 */
showRefreshButton: boolean
/** 是否显示面包屑 */
showCrumbs: boolean
/** 是否自动关闭 */
autoClose: boolean
/** 是否显示工作标签页 */
showWorkTab: boolean
/** 是否显示语言切换 */
showLanguage: boolean
/** 是否显示进度条 */
showNprogress: boolean
/** 主题模式 */
themeModel: string
}
// 多标签
export interface WorkTab {
/** 标签标题 */
title: string
/** 自定义标题 */
customTitle?: string
/** 路由路径 */
path: string
/** 路由名称 */
name: string
/** 是否缓存 */
keepAlive: boolean
/** 是否固定标签 */
fixedTab?: boolean
/** 路由参数 */
params?: object
/** 路由查询参数 */
query?: LocationQueryRaw
/** 图标 */
icon?: string
/** 是否激活 */
isActive?: boolean
}
// 用户Store状态
export interface UserState {
/** 用户信息 */
userInfo: Api.Auth.UserInfo | null
/** 认证令牌 */
token: string | null
/** 用户角色列表 */
roles: string[]
/** 用户权限列表 */
permissions: string[]
}
// 设置Store状态
export interface SettingStoreState extends SettingState {
// 额外的设置状态
/** 菜单是否折叠 */
collapsed: boolean
/** 设备类型 */
device: 'desktop' | 'mobile'
/** 当前语言 */
language: string
}
// 工作标签页Store状态
export interface WorkTabState {
/** 标签页列表 */
tabs: WorkTab[]
/** 当前激活的标签页 */
activeTab: string
/** 缓存的标签页列表 */
cachedTabs: string[]
}
// 菜单Store状态
export interface MenuState {
/** 菜单列表 */
menuList: any[]
/** 菜单是否已加载 */
isLoaded: boolean
/** 菜单是否折叠 */
collapsed: boolean
}
// 根Store状态类型
export interface RootState {
/** 用户状态 */
user: UserState
/** 设置状态 */
setting: SettingStoreState
/** 工作标签页状态 */
workTab: WorkTabState
/** 菜单状态 */
menu: MenuState
}

View File

@@ -0,0 +1,8 @@
/**
* 常量定义相关工具函数统一导出
*
* @module utils/constants/index
* @author Art Design Pro Team
*/
export * from './links'

View File

@@ -0,0 +1,35 @@
/**
* 网站链接常量配置
* 集中管理便于维护和更新链接地址
*
* @module utils/constants/links
* @author Art Design Pro Team
*/
export const WEB_LINKS = {
// Github 主页
GITHUB_HOME: 'https://github.com/Daymychen/art-design-pro',
// 项目 Github 主页
GITHUB: 'https://github.com/Daymychen/art-design-pro',
// 个人博客
BLOG: 'https://www.artd.pro',
// 项目文档
DOCS: 'https://www.artd.pro/docs/zh/',
// 精简版本
LiteVersion: 'https://www.artd.pro/docs/zh/guide/lite-version.html',
// v2.6.1版本
OldVersion: 'https://www.artd.pro/v2/',
// 项目社区
COMMUNITY: 'https://www.artd.pro/docs/zh/community/communicate.html',
// 个人 Bilibili 主页
BILIBILI: 'https://space.bilibili.com/425500936?spm_id_from=333.1007.0.0',
// 项目介绍
INTRODUCE: 'https://www.artd.pro/docs/zh/guide/introduce.html'
}

View File

@@ -0,0 +1,12 @@
/**
* 表单工具函数统一导出
*
* @module utils/form
* @author Art Design Pro Team
*/
// 表单验证器
export * from './validator'
// 响应式布局
export * from './responsive'

View File

@@ -0,0 +1,122 @@
/**
* 表单响应式布局工具模块
*
* 提供表单项在不同屏幕尺寸下的智能布局计算
*
* ## 主要功能
*
* - 响应式断点管理xs/sm/md/lg/xl
* - 表单列宽自动降级(避免小屏幕压缩)
* - 基于阈值的智能 span 计算
* - 响应式计算器工厂函数
* - 可配置的断点规则
*
* ## 使用场景
*
* - 表单组件响应式布局
* - 搜索表单自适应
* - 移动端表单优化
* - 多列表单布局
*
* ## 断点说明(基于 Element Plus Grid 24 栅格系统):
* - xs (手机): < 768px小于 12 时降级为 24满宽
* - sm (平板): ≥ 768px小于 12 时降级为 12半宽
* - md (中等屏幕): ≥ 992px小于 8 时降级为 8三分之一宽
* - lg (大屏幕): ≥ 1200px直接使用设置的 span
* - xl (超大屏幕): ≥ 1920px直接使用设置的 span
*
* ## 核心功能
*
* - calculateResponsiveSpan: 计算响应式列宽
* - createResponsiveSpanCalculator: 创建 span 计算器(柯里化)
*
* @module utils/form/responsive
* @author Art Design Pro Team
*/
/**
* 响应式断点类型
*/
export type ResponsiveBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/**
* 断点配置映射
*/
interface BreakpointConfig {
/** 最小 span 阈值 */
threshold: number
/** 降级后的 span 值 */
fallback: number
}
/**
* 响应式断点配置
*/
const BREAKPOINT_CONFIG: Record<ResponsiveBreakpoint, BreakpointConfig | null> = {
xs: { threshold: 12, fallback: 24 }, // 手机:小于 12 时使用满宽
sm: { threshold: 12, fallback: 12 }, // 平板:小于 12 时使用半宽
md: { threshold: 8, fallback: 8 }, // 中等屏幕:小于 8 时使用三分之一宽
lg: null, // 大屏幕:直接使用设置的 span
xl: null // 超大屏幕:直接使用设置的 span
}
/**
* 计算响应式列宽
*
* 根据屏幕尺寸智能降级,避免小屏幕上表单项被压缩过小
*
* @param itemSpan 表单项自定义的 span 值
* @param defaultSpan 默认的 span 值
* @param breakpoint 当前断点
* @returns 计算后的 span 值
*
* @example
* ```ts
* // 在 xs 断点下span 为 6 会降级为 24满宽
* calculateResponsiveSpan(6, 6, 'xs') // 24
*
* // 在 md 断点下span 为 6 会降级为 8三分之一宽
* calculateResponsiveSpan(6, 6, 'md') // 8
*
* // 在 lg 断点下,直接使用原始 span
* calculateResponsiveSpan(6, 6, 'lg') // 6
* ```
*/
export function calculateResponsiveSpan(
itemSpan: number | undefined,
defaultSpan: number,
breakpoint: ResponsiveBreakpoint
): number {
const finalSpan = itemSpan ?? defaultSpan
const config = BREAKPOINT_CONFIG[breakpoint]
// 如果没有配置lg/xl直接返回原始 span
if (!config) {
return finalSpan
}
// 如果 span 小于阈值,使用降级值
return finalSpan >= config.threshold ? finalSpan : config.fallback
}
/**
* 创建响应式 span 计算器
*
* 返回一个函数,用于计算指定断点下的 span 值
*
* @param defaultSpan 默认的 span 值
* @returns span 计算函数
*
* @example
* ```ts
* const getColSpan = createResponsiveSpanCalculator(6)
* getColSpan(undefined, 'xs') // 24
* getColSpan(8, 'md') // 8
* getColSpan(12, 'lg') // 12
* ```
*/
export function createResponsiveSpanCalculator(defaultSpan: number) {
return (itemSpan: number | undefined, breakpoint: ResponsiveBreakpoint): number => {
return calculateResponsiveSpan(itemSpan, defaultSpan, breakpoint)
}
}

View File

@@ -0,0 +1,316 @@
/**
* 表单验证工具模块
*
* 提供全面的表单字段验证功能
*
* ## 主要功能
*
* - 手机号码验证(中国大陆格式)
* - 固定电话验证(支持区号格式)
* - 用户账号验证(字母开头,支持数字和下划线)
* - 密码强度验证(普通密码、强密码)
* - 密码强度评估(弱、中、强)
* - IPv4 地址验证
* - 邮箱地址验证RFC 5322 标准)
* - URL 地址验证
* - 身份证号码验证18位含校验码验证
* - 银行卡号验证Luhn 算法)
* - 字符串空格处理
*
* ## 验证规则
*
* - 手机号1开头第二位3-9共11位
* - 账号字母开头5-20位支持字母数字下划线
* - 普通密码6-20位必须包含字母和数字
* - 强密码8-20位必须包含大小写字母、数字和特殊字符
* - 身份证18位含出生日期和校验码验证
* - 银行卡13-19位通过 Luhn 算法验证
*
* @module utils/validation/formValidator
* @author Art Design Pro Team
*/
/**
* 密码强度级别枚举
*/
export enum PasswordStrength {
WEAK = '弱',
MEDIUM = '中',
STRONG = '强'
}
/**
* 去除字符串首尾空格
* @param value 待处理的字符串
* @returns 返回去除首尾空格后的字符串
*/
export function trimSpaces(value: string): string {
if (typeof value !== 'string') {
return ''
}
return value.trim()
}
/**
* 验证手机号码(中国大陆)
* @param value 手机号码字符串
* @returns 返回验证结果true表示格式正确
*/
export function validatePhone(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
// 中国大陆手机号码1开头第二位为3-9共11位数字
const phoneRegex = /^1[3-9]\d{9}$/
return phoneRegex.test(value.trim())
}
/**
* 验证固定电话号码(中国大陆)
* @param value 电话号码字符串
* @returns 返回验证结果true表示格式正确
*/
export function validateTelPhone(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
// 支持格式:区号-号码010-12345678、0755-1234567
const telRegex = /^0\d{2,3}-?\d{7,8}$/
return telRegex.test(value.trim().replace(/\s+/g, ''))
}
/**
* 验证用户账号
* @param value 账号字符串
* @returns 返回验证结果true表示格式正确
* @description 规则字母开头5-20位支持字母、数字、下划线
*/
export function validateAccount(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
// 字母开头5-20位支持字母、数字、下划线
const accountRegex = /^[a-zA-Z][a-zA-Z0-9_]{4,19}$/
return accountRegex.test(value.trim())
}
/**
* 验证密码
* @param value 密码字符串
* @returns 返回验证结果true表示格式正确
* @description 规则6-20位必须包含字母和数字
*/
export function validatePassword(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
const trimmedValue = value.trim()
// 长度检查
if (trimmedValue.length < 6 || trimmedValue.length > 20) {
return false
}
// 必须包含字母和数字
const hasLetter = /[a-zA-Z]/.test(trimmedValue)
const hasNumber = /\d/.test(trimmedValue)
return hasLetter && hasNumber
}
/**
* 验证强密码
* @param value 密码字符串
* @returns 返回验证结果true表示格式正确
* @description 规则8-20位必须包含大写字母、小写字母、数字和特殊字符
*/
export function validateStrongPassword(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
const trimmedValue = value.trim()
// 长度检查
if (trimmedValue.length < 8 || trimmedValue.length > 20) {
return false
}
// 必须包含:大写字母、小写字母、数字、特殊字符
const hasUpperCase = /[A-Z]/.test(trimmedValue)
const hasLowerCase = /[a-z]/.test(trimmedValue)
const hasNumber = /\d/.test(trimmedValue)
const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue)
return hasUpperCase && hasLowerCase && hasNumber && hasSpecialChar
}
/**
* 获取密码强度
* @param value 密码字符串
* @returns 返回密码强度:弱、中、强
* @description 弱:纯数字/纯字母/纯特殊字符;中:两种组合;强:三种或以上组合
*/
export function getPasswordStrength(value: string): PasswordStrength {
if (!value || typeof value !== 'string') {
return PasswordStrength.WEAK
}
const trimmedValue = value.trim()
if (trimmedValue.length < 6) {
return PasswordStrength.WEAK
}
const hasUpperCase = /[A-Z]/.test(trimmedValue)
const hasLowerCase = /[a-z]/.test(trimmedValue)
const hasNumber = /\d/.test(trimmedValue)
const hasSpecialChar = /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(trimmedValue)
const typeCount = [hasUpperCase, hasLowerCase, hasNumber, hasSpecialChar].filter(Boolean).length
if (typeCount >= 3) {
return PasswordStrength.STRONG
} else if (typeCount >= 2) {
return PasswordStrength.MEDIUM
} else {
return PasswordStrength.WEAK
}
}
/**
* 验证IPv4地址
* @param value IP地址字符串
* @returns 返回验证结果true表示格式正确
*/
export function validateIPv4Address(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
const trimmedValue = value.trim()
const ipRegex = /^((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})$/
if (!ipRegex.test(trimmedValue)) {
return false
}
// 额外检查每个段是否在有效范围内
const segments = trimmedValue.split('.')
return segments.every((segment) => {
const num = parseInt(segment, 10)
return num >= 0 && num <= 255
})
}
/**
* 验证邮箱地址
* @param value 邮箱地址字符串
* @returns 返回验证结果true表示格式正确
*/
export function validateEmail(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
const trimmedValue = value.trim()
// RFC 5322 标准的简化版邮箱正则
const emailRegex =
/^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
return emailRegex.test(trimmedValue) && trimmedValue.length <= 254
}
/**
* 验证URL地址
* @param value URL字符串
* @returns 返回验证结果true表示格式正确
*/
export function validateURL(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
try {
new URL(value.trim())
return true
} catch {
return false
}
}
/**
* 验证身份证号码(中国大陆)
* @param value 身份证号码字符串
* @returns 返回验证结果true表示格式正确
*/
export function validateChineseIDCard(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
const trimmedValue = value.trim()
// 18位身份证号码正则
const idCardRegex =
/^[1-9]\d{5}(18|19|20)\d{2}((0[1-9])|(1[0-2]))(([0-2][1-9])|10|20|30|31)\d{3}[0-9Xx]$/
if (!idCardRegex.test(trimmedValue)) {
return false
}
// 验证校验码
const weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
const checkCodes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
let sum = 0
for (let i = 0; i < 17; i++) {
sum += parseInt(trimmedValue[i]) * weights[i]
}
const checkCode = checkCodes[sum % 11]
return trimmedValue[17].toUpperCase() === checkCode
}
/**
* 验证银行卡号
* @param value 银行卡号字符串
* @returns 返回验证结果true表示格式正确
*/
export function validateBankCard(value: string): boolean {
if (!value || typeof value !== 'string') {
return false
}
const trimmedValue = value.trim().replace(/\s+/g, '')
// 银行卡号通常为13-19位数字
if (!/^\d{13,19}$/.test(trimmedValue)) {
return false
}
// Luhn算法验证
let sum = 0
let shouldDouble = false
for (let i = trimmedValue.length - 1; i >= 0; i--) {
let digit = parseInt(trimmedValue[i])
if (shouldDouble) {
digit *= 2
if (digit > 9) {
digit = (digit % 10) + 1
}
}
sum += digit
shouldDouble = !shouldDouble
}
return sum % 10 === 0
}

View File

@@ -0,0 +1,182 @@
/**
* HTTP 错误处理模块
*
* 提供统一的 HTTP 请求错误处理机制
*
* ## 主要功能
*
* - 自定义 HttpError 错误类,封装错误信息、状态码、时间戳等
* - 错误拦截和转换,将 Axios 错误转换为标准的 HttpError
* - 错误消息国际化处理,根据状态码返回对应的多语言错误提示
* - 错误日志记录,便于问题追踪和调试
* - 错误和成功消息的统一展示
* - 类型守卫函数,用于判断错误类型
*
* ## 使用场景
*
* - HTTP 请求拦截器中统一处理错误
* - 业务代码中捕获和处理特定错误
* - 错误日志收集和上报
*
* @module utils/http/error
* @author Art Design Pro Team
*/
import { AxiosError } from 'axios'
import { ApiStatus } from './status'
import { $t } from '@/locales'
// 错误响应接口
export interface ErrorResponse {
/** 错误状态码 */
code: number
/** 错误消息 */
msg: string
/** 错误附加数据 */
data?: unknown
}
// 错误日志数据接口
export interface ErrorLogData {
/** 错误状态码 */
code: number
/** 错误消息 */
message: string
/** 错误附加数据 */
data?: unknown
/** 错误发生时间戳 */
timestamp: string
/** 请求 URL */
url?: string
/** 请求方法 */
method?: string
/** 错误堆栈信息 */
stack?: string
}
// 自定义 HttpError 类
export class HttpError extends Error {
public readonly code: number
public readonly data?: unknown
public readonly timestamp: string
public readonly url?: string
public readonly method?: string
constructor(
message: string,
code: number,
options?: {
data?: unknown
url?: string
method?: string
}
) {
super(message)
this.name = 'HttpError'
this.code = code
this.data = options?.data
this.timestamp = new Date().toISOString()
this.url = options?.url
this.method = options?.method
}
public toLogData(): ErrorLogData {
return {
code: this.code,
message: this.message,
data: this.data,
timestamp: this.timestamp,
url: this.url,
method: this.method,
stack: this.stack
}
}
}
/**
* 获取错误消息
* @param status 错误状态码
* @returns 错误消息
*/
const getErrorMessage = (status: number): string => {
const errorMap: Record<number, string> = {
[ApiStatus.unauthorized]: 'httpMsg.unauthorized',
[ApiStatus.forbidden]: 'httpMsg.forbidden',
[ApiStatus.notFound]: 'httpMsg.notFound',
[ApiStatus.methodNotAllowed]: 'httpMsg.methodNotAllowed',
[ApiStatus.requestTimeout]: 'httpMsg.requestTimeout',
[ApiStatus.internalServerError]: 'httpMsg.internalServerError',
[ApiStatus.badGateway]: 'httpMsg.badGateway',
[ApiStatus.serviceUnavailable]: 'httpMsg.serviceUnavailable',
[ApiStatus.gatewayTimeout]: 'httpMsg.gatewayTimeout'
}
return $t(errorMap[status] || 'httpMsg.internalServerError')
}
/**
* 处理错误
* @param error 错误对象
* @returns 错误对象
*/
export function handleError(error: AxiosError<ErrorResponse>): never {
// 处理取消的请求
if (error.code === 'ERR_CANCELED') {
console.warn('Request cancelled:', error.message)
throw new HttpError($t('httpMsg.requestCancelled'), ApiStatus.error)
}
const statusCode = error.response?.status
const errorMessage = error.response?.data?.msg || error.message
const requestConfig = error.config
// 处理网络错误
if (!error.response) {
throw new HttpError($t('httpMsg.networkError'), ApiStatus.error, {
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase()
})
}
// 处理 HTTP 状态码错误
const message = statusCode
? getErrorMessage(statusCode)
: errorMessage || $t('httpMsg.requestFailed')
throw new HttpError(message, statusCode || ApiStatus.error, {
data: error.response.data,
url: requestConfig?.url,
method: requestConfig?.method?.toUpperCase()
})
}
/**
* 显示错误消息
* @param error 错误对象
* @param showMessage 是否显示错误消息
*/
export function showError(error: HttpError, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.error(error.message)
}
// 记录错误日志
// console.error('[HTTP Error]', error.toLogData())
}
/**
* 显示成功消息
* @param message 成功消息
* @param showMessage 是否显示消息
*/
export function showSuccess(message: string, showMessage: boolean = true): void {
if (showMessage) {
ElMessage.success(message)
}
}
/**
* 判断是否为 HttpError 类型
* @param error 错误对象
* @returns 是否为 HttpError 类型
*/
export const isHttpError = (error: unknown): error is HttpError => {
return error instanceof HttpError
}

View File

@@ -0,0 +1,217 @@
/**
* HTTP 请求封装模块
* 基于 Axios 封装的 HTTP 请求工具,提供统一的请求/响应处理
*
* ## 主要功能
*
* - 请求/响应拦截器(自动添加 Token、统一错误处理
* - 401 未授权自动登出(带防抖机制)
* - 请求失败自动重试(可配置)
* - 统一的成功/错误消息提示
* - 支持 GET/POST/PUT/DELETE 等常用方法
*
* @module utils/http
* @author Art Design Pro Team
*/
import axios, { AxiosRequestConfig, AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import { useUserStore } from '@/store/modules/user'
import { ApiStatus } from './status'
import { HttpError, handleError, showError, showSuccess } from './error'
import { $t } from '@/locales'
import { BaseResponse } from '@/types'
/** 请求配置常量 */
const REQUEST_TIMEOUT = 15000
const LOGOUT_DELAY = 500
const MAX_RETRIES = 0
const RETRY_DELAY = 1000
const UNAUTHORIZED_DEBOUNCE_TIME = 3000
/** 401防抖状态 */
let isUnauthorizedErrorShown = false
let unauthorizedTimer: NodeJS.Timeout | null = null
/** 扩展 AxiosRequestConfig */
interface ExtendedAxiosRequestConfig extends AxiosRequestConfig {
showErrorMessage?: boolean
showSuccessMessage?: boolean
}
const { VITE_API_URL, VITE_WITH_CREDENTIALS } = import.meta.env
/** Axios实例 */
const axiosInstance = axios.create({
timeout: REQUEST_TIMEOUT,
baseURL: VITE_API_URL,
withCredentials: VITE_WITH_CREDENTIALS === 'true',
validateStatus: (status) => status >= 200 && status < 300,
transformResponse: [
(data, headers) => {
const contentType = headers['content-type']
if (contentType?.includes('application/json')) {
try {
return JSON.parse(data)
} catch {
return data
}
}
return data
}
]
})
/** 请求拦截器 */
axiosInstance.interceptors.request.use(
(request: InternalAxiosRequestConfig) => {
const { accessToken } = useUserStore()
if (accessToken) request.headers.set('Authorization', `Bearer ` + accessToken)
if (request.data && !(request.data instanceof FormData) && !request.headers['Content-Type']) {
request.headers.set('Content-Type', 'application/json')
request.data = JSON.stringify(request.data)
}
return request
},
(error) => {
showError(createHttpError($t('httpMsg.requestConfigError'), ApiStatus.error))
return Promise.reject(error)
}
)
/** 响应拦截器 */
axiosInstance.interceptors.response.use(
(response: AxiosResponse<BaseResponse>) => {
if (response.config.responseType === 'blob') return response
const { code, message } = response.data
if (code === ApiStatus.success) return response
if (code === ApiStatus.unauthorized) handleUnauthorizedError(message)
throw createHttpError(message || $t('httpMsg.requestFailed'), code)
},
(error) => {
if (error.response?.status === ApiStatus.unauthorized) handleUnauthorizedError()
return Promise.reject(handleError(error))
}
)
/** 统一创建HttpError */
function createHttpError(message: string, code: number) {
return new HttpError(message, code)
}
/** 处理401错误带防抖 */
function handleUnauthorizedError(message?: string): never {
const error = createHttpError(message || $t('httpMsg.unauthorized'), ApiStatus.unauthorized)
if (!isUnauthorizedErrorShown) {
isUnauthorizedErrorShown = true
logOut()
unauthorizedTimer = setTimeout(resetUnauthorizedError, UNAUTHORIZED_DEBOUNCE_TIME)
showError(error, true)
throw error
}
throw error
}
/** 重置401防抖状态 */
function resetUnauthorizedError() {
isUnauthorizedErrorShown = false
if (unauthorizedTimer) clearTimeout(unauthorizedTimer)
unauthorizedTimer = null
}
/** 退出登录函数 */
function logOut() {
setTimeout(() => {
useUserStore().logOut()
}, LOGOUT_DELAY)
}
/** 是否需要重试 */
function shouldRetry(statusCode: number) {
return [
ApiStatus.requestTimeout,
ApiStatus.internalServerError,
ApiStatus.badGateway,
ApiStatus.serviceUnavailable,
ApiStatus.gatewayTimeout
].includes(statusCode)
}
/** 请求重试逻辑 */
async function retryRequest<T>(
config: ExtendedAxiosRequestConfig,
retries: number = MAX_RETRIES
): Promise<T> {
try {
return await request<T>(config)
} catch (error) {
if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) {
await delay(RETRY_DELAY)
return retryRequest<T>(config, retries - 1)
}
throw error
}
}
/** 延迟函数 */
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** 请求函数 */
async function request<T = any>(config: ExtendedAxiosRequestConfig): Promise<T> {
// POST | PUT 参数自动填充
if (
['POST', 'PUT'].includes(config.method?.toUpperCase() || '') &&
config.params &&
!config.data
) {
config.data = config.params
config.params = undefined
}
try {
const res = await axiosInstance.request<BaseResponse<T>>(config)
// 显示成功消息
if (config.showSuccessMessage && res.data.message) {
showSuccess(res.data.message)
}
if (config.responseType === 'blob') return res.data as T
return res.data.data as T
} catch (error) {
if (error instanceof HttpError && error.code !== ApiStatus.unauthorized) {
const showMsg = config.showErrorMessage !== false
showError(error, showMsg)
}
return Promise.reject(error)
}
}
/** API方法集合 */
const api = {
get<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'GET' })
},
post<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'POST' })
},
put<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'PUT' })
},
del<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>({ ...config, method: 'DELETE' })
},
request<T>(config: ExtendedAxiosRequestConfig) {
return retryRequest<T>(config)
}
}
export default api

View File

@@ -0,0 +1,18 @@
/**
* 接口状态码
*/
export enum ApiStatus {
success = 200, // 成功
error = 400, // 错误
unauthorized = 401, // 未授权
forbidden = 403, // 禁止访问
notFound = 404, // 未找到
methodNotAllowed = 405, // 方法不允许
requestTimeout = 408, // 请求超时
internalServerError = 500, // 服务器错误
notImplemented = 501, // 未实现
badGateway = 502, // 网关错误
serviceUnavailable = 503, // 服务不可用
gatewayTimeout = 504, // 网关超时
httpVersionNotSupported = 505 // HTTP版本不支持
}

View File

@@ -0,0 +1,34 @@
/**
* Utils 工具函数统一导出
* 提供向后兼容性和便捷导入
*
* @module utils/index
* @author Art Design Pro Team
*/
// UI 相关
export * from './ui'
// 路由相关
export * from './router'
// 路由导航相关
export * from './navigation'
// 系统管理相关
export * from './sys'
// 常量定义相关
export * from './constants'
// 存储相关
export * from './storage'
// HTTP 相关
export * from './http'
// 表单相关
export * from './form'
// socket 相关
export * from './socket'

View File

@@ -0,0 +1,10 @@
/**
* 路由和导航相关工具函数统一导出
*
* @module utils/navigation/index
* @author Art Design Pro Team
*/
export * from './jump'
export * from './worktab'
export * from './route'

View File

@@ -0,0 +1,62 @@
/**
* 导航跳转工具模块
*
* 提供统一的页面跳转和导航功能
*
* ## 主要功能
*
* - 外部链接打开(新窗口)
* - 菜单项跳转处理(支持内部路由和外部链接)
* - iframe 页面跳转支持
* - 递归查找并跳转到第一个可见的子菜单
* - 智能判断跳转目标类型(外部链接/内部路由)
*
* @module utils/navigation/jump
* @author Art Design Pro Team
*/
import { AppRouteRecord } from '@/types/router'
import { router } from '@/router'
// 打开外部链接
export const openExternalLink = (link: string) => {
window.open(link, '_blank')
}
/**
* 菜单跳转
* @param item 菜单项
* @param jumpToFirst 是否跳转到第一个子菜单
* @returns
*/
export const handleMenuJump = (item: AppRouteRecord, jumpToFirst: boolean = false) => {
// 处理外部链接
const { link, isIframe } = item.meta
if (link && !isIframe) {
return openExternalLink(link)
}
// 如果不需要跳转到第一个子菜单,或者没有子菜单,直接跳转当前路径
if (!jumpToFirst || !item.children?.length) {
return router.push(item.path)
}
// 递归查找第一个可见的叶子节点菜单
const findFirstLeafMenu = (items: AppRouteRecord[]): AppRouteRecord => {
for (const child of items) {
if (!child.meta.isHide) {
return child.children?.length ? findFirstLeafMenu(child.children) : child
}
}
return items[0]
}
const firstChild = findFirstLeafMenu(item.children)
// 如果第一个子菜单是外部链接则打开新窗口
if (firstChild.meta?.link) {
return openExternalLink(firstChild.meta.link)
}
// 跳转到子菜单路径
router.push(firstChild.path)
}

View File

@@ -0,0 +1,78 @@
/**
* 路由工具模块
*
* 提供路由处理和菜单路径相关的工具函数
*
* ## 主要功能
*
* - iframe 路由检测,判断是否为外部嵌入页面
* - 菜单项有效性验证,过滤隐藏和无效菜单
* - 路径标准化处理,统一路径格式
* - 递归查找菜单树中第一个有效路径
* - 支持多级嵌套菜单的路径解析
*
* ## 使用场景
*
* - 系统初始化时获取默认跳转路径
* - 菜单权限过滤后获取首个可访问页面
* - 路由重定向逻辑处理
* - iframe 页面特殊处理
*
* @module utils/navigation/route
* @author Art Design Pro Team
*/
import { AppRouteRecord } from '@/types'
// 检查是否为 iframe 路由
export function isIframe(url: string): boolean {
return url.startsWith('/outside/iframe/')
}
/**
* 验证菜单项是否有效
* @param menuItem 菜单项
* @returns 是否为有效菜单项
*/
const isValidMenuItem = (menuItem: AppRouteRecord): boolean => {
return !!(menuItem.path && menuItem.path.trim() && !menuItem.meta?.isHide)
}
/**
* 标准化路径格式
* @param path 路径
* @returns 标准化后的路径
*/
const normalizePath = (path: string): string => {
return path.startsWith('/') ? path : `/${path}`
}
/**
* 递归获取菜单的第一个有效路径
* @param menuList 菜单列表
* @returns 第一个有效路径,如果没有找到则返回空字符串
*/
export const getFirstMenuPath = (menuList: AppRouteRecord[]): string => {
if (!Array.isArray(menuList) || menuList.length === 0) {
return ''
}
for (const menuItem of menuList) {
if (!isValidMenuItem(menuItem)) {
continue
}
// 如果有子菜单,优先查找子菜单
if (menuItem.children?.length) {
const childPath = getFirstMenuPath(menuItem.children)
if (childPath) {
return childPath
}
}
// 返回当前菜单项的标准化路径
return normalizePath(menuItem.path!)
}
return ''
}

View File

@@ -0,0 +1,67 @@
/**
* 工作标签页管理模块
*
* 提供工作标签页Worktab的自动管理功能
*
* ## 主要功能
*
* - 根据路由导航自动创建和更新工作标签页
* - iframe 页面标签页特殊处理
* - 标签页信息提取(标题、路径、缓存状态等)
* - 固定标签页支持
* - 根据系统设置控制标签页显示
* - 首页标签页特殊处理
*
* ## 使用场景
*
* - 路由守卫中自动创建标签页
* - 页面切换时更新标签页状态
* - 多标签页导航系统
*
* @module utils/navigation/worktab
* @author Art Design Pro Team
*/
import { useWorktabStore } from '@/store/modules/worktab'
import { RouteLocationNormalized } from 'vue-router'
import { isIframe } from './route'
import { useSettingStore } from '@/store/modules/setting'
import { IframeRouteManager } from '@/router/core'
import { useCommon } from '@/hooks/core/useCommon'
/**
* 根据当前路由信息设置工作标签页worktab
* @param to 当前路由对象
*/
export const setWorktab = (to: RouteLocationNormalized): void => {
const worktabStore = useWorktabStore()
const { meta, path, name, params, query } = to
if (!meta.isHideTab) {
// 如果是 iframe 页面,则特殊处理工作标签页
if (isIframe(path)) {
const iframeRoute = IframeRouteManager.getInstance().findByPath(to.path)
if (iframeRoute?.meta) {
worktabStore.openTab({
title: iframeRoute.meta.title,
icon: meta.icon as string,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
params,
query
})
}
} else if (useSettingStore().showWorkTab || path === useCommon().homePath.value) {
worktabStore.openTab({
title: meta.title as string,
icon: meta.icon as string,
path,
name: name as string,
keepAlive: meta.keepAlive as boolean,
params,
query,
fixedTab: meta.fixedTab as boolean
})
}
}
}

View File

@@ -0,0 +1,61 @@
/**
* 路由工具函数
*
* 提供路由相关的工具函数
*
* @module utils/router
*/
import { RouteLocationNormalized, RouteRecordRaw } from 'vue-router'
import AppConfig from '@/config'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import i18n, { $t } from '@/locales'
/** 扩展的路由配置类型 */
export type AppRouteRecordRaw = RouteRecordRaw & {
hidden?: boolean
}
/** 顶部进度条配置 */
export const configureNProgress = () => {
NProgress.configure({
easing: 'ease',
speed: 600,
showSpinner: false,
parent: 'body'
})
}
/**
* 设置页面标题,根据路由元信息和系统信息拼接标题
* @param to 当前路由对象
*/
export const setPageTitle = (to: RouteLocationNormalized): void => {
const { title } = to.meta
if (title) {
setTimeout(() => {
document.title = `${formatMenuTitle(String(title))} - ${AppConfig.systemInfo.name}`
}, 150)
}
}
/**
* 格式化菜单标题
* @param title 菜单标题,可以是 i18n 的 key也可以是字符串
* @returns 格式化后的菜单标题
*/
export const formatMenuTitle = (title: string): string => {
if (title) {
if (title.startsWith('menus.')) {
// 使用 te() 方法检查翻译键值是否存在,避免控制台警告
if (i18n.global.te(title)) {
return $t(title)
} else {
// 如果翻译不存在返回键值的最后部分作为fallback
return title.split('.').pop() || title
}
}
return title
}
return ''
}

View File

@@ -0,0 +1,388 @@
interface WebSocketOptions {
url?: string
messageHandler: (event: MessageEvent) => void
reconnectInterval?: number // 重连间隔(ms)
heartbeatInterval?: number // 心跳检测间隔(ms)
pingInterval?: number // 发送ping间隔(ms)
reconnectTimeout?: number // 重连超时时间(ms)
maxReconnectAttempts?: number // 最大重连次数
connectionTimeout?: number // 连接建立超时时间(ms)
}
export default class WebSocketClient {
private static instance: WebSocketClient | null = null
private ws: WebSocket | null = null
private url: string
private messageHandler: (event: MessageEvent) => void
private reconnectInterval: number
private heartbeatInterval: number
private pingInterval: number
private reconnectTimeout: number
private maxReconnectAttempts: number
private connectionTimeout: number
private reconnectAttempts: number = 0 // 当前重连次数
// 消息队列 - 缓存连接建立前的消息
private messageQueue: Array<string | ArrayBufferLike | Blob | ArrayBufferView> = []
// 定时器
private detectionTimer: NodeJS.Timeout | null = null
private timeoutTimer: NodeJS.Timeout | null = null
private reconnectTimer: NodeJS.Timeout | null = null
private pingTimer: NodeJS.Timeout | null = null
private connectionTimer: NodeJS.Timeout | null = null // 连接超时定时器
// 状态标识
private isConnected: boolean = false
private isConnecting: boolean = false // 是否正在连接中
private stopReconnect: boolean = false
private constructor(options: WebSocketOptions) {
this.url = options.url || (process.env.VUE_APP_LOGIN_WEBSOCKET as string)
this.messageHandler = options.messageHandler
this.reconnectInterval = options.reconnectInterval || 20 * 1000 // 默认20秒
this.heartbeatInterval = options.heartbeatInterval || 5 * 1000 // 默认5秒
this.pingInterval = options.pingInterval || 10 * 1000 // 默认10秒
this.reconnectTimeout = options.reconnectTimeout || 30 * 1000 // 默认30秒
this.maxReconnectAttempts = options.maxReconnectAttempts || 10 // 默认最多重连10次
this.connectionTimeout = options.connectionTimeout || 10 * 1000 // 连接超时10秒
}
// 单例模式获取实例
static getInstance(options: WebSocketOptions): WebSocketClient {
if (!WebSocketClient.instance) {
WebSocketClient.instance = new WebSocketClient(options)
} else {
// 更新消息处理器
WebSocketClient.instance.messageHandler = options.messageHandler
// 如果提供了新的URL则更新并重新连接
if (options.url && WebSocketClient.instance.url !== options.url) {
WebSocketClient.instance.url = options.url
WebSocketClient.instance.reconnectAttempts = 0
WebSocketClient.instance.init()
}
}
return WebSocketClient.instance
}
// 初始化连接
init(): void {
// 如果正在连接中,不重复连接
if (this.isConnecting) {
console.log('正在建立WebSocket连接中...')
return
}
// 如果已连接,不重复连接
if (this.ws?.readyState === WebSocket.OPEN) {
console.warn('WebSocket连接已存在')
this.flushMessageQueue() // 确保队列中的消息被发送
return
}
try {
this.isConnecting = true
this.reconnectAttempts = 0 // 重置重连次数
this.ws = new WebSocket(this.url)
// 设置连接超时检测
this.clearTimer('connectionTimer')
this.connectionTimer = setTimeout(() => {
console.error(`WebSocket连接超时 (${this.connectionTimeout}ms)${this.url}`)
this.handleConnectionTimeout()
}, this.connectionTimeout)
this.ws.onopen = (event) => this.handleOpen(event)
this.ws.onmessage = (event) => this.handleMessage(event)
this.ws.onclose = (event) => this.handleClose(event)
this.ws.onerror = (event) => this.handleError(event)
} catch (error) {
console.error('WebSocket初始化失败:', error)
this.isConnecting = false
this.reconnect()
}
}
// 处理连接超时
private handleConnectionTimeout(): void {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.error('WebSocket连接超时强制关闭连接')
this.ws?.close(1000, 'Connection timeout')
this.isConnecting = false
this.reconnect()
}
}
// 关闭连接
close(force?: boolean): void {
this.clearAllTimers()
this.stopReconnect = true
this.isConnecting = false
if (this.ws) {
// 1000 表示正常关闭
this.ws.close(force ? 1001 : 1000, force ? 'Force closed' : 'Normal close')
this.ws = null
}
this.isConnected = false
}
// 发送消息 - 增加消息队列
send(data: string | ArrayBufferLike | Blob | ArrayBufferView, immediate: boolean = false): void {
// 如果要求立即发送且未连接,则直接报错
if (immediate && (!this.ws || this.ws.readyState !== WebSocket.OPEN)) {
console.error('WebSocket未连接无法立即发送消息')
return
}
// 如果未连接且不要求立即发送,则加入消息队列
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.log('WebSocket未连接消息已加入队列等待发送')
this.messageQueue.push(data)
// 如果未在重连中,则尝试重连
if (!this.isConnecting && !this.stopReconnect) {
this.init()
}
return
}
try {
this.ws.send(data)
} catch (error) {
console.error('WebSocket发送消息失败:', error)
// 发送失败时将消息加入队列,等待重连后重试
this.messageQueue.push(data)
this.reconnect()
}
}
// 发送队列中的消息
private flushMessageQueue(): void {
if (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
console.log(`发送队列中的${this.messageQueue.length}条消息`)
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift()
if (data) {
try {
this.ws?.send(data)
} catch (error) {
console.error('发送队列消息失败:', error)
// 如果发送失败,将消息放回队列头部
if (data) this.messageQueue.unshift(data)
break
}
}
}
}
}
// 处理连接打开
private handleOpen(event: Event): void {
console.log('WebSocket连接成功', event)
this.clearTimer('connectionTimer') // 清除连接超时定时器
this.isConnected = true
this.isConnecting = false
this.stopReconnect = false
this.reconnectAttempts = 0 // 重置重连次数
this.startHeartbeat()
this.startPing()
this.flushMessageQueue() // 发送队列中的消息
}
// 处理收到的消息
private handleMessage(event: MessageEvent): void {
console.log('收到WebSocket消息:', event)
this.resetHeartbeat()
this.messageHandler(event)
}
// 处理连接关闭
private handleClose(event: CloseEvent): void {
console.log(
`WebSocket断开: 代码=${event.code}, 原因=${event.reason}, 干净关闭=${event.wasClean}`
)
// 1000 是正常关闭代码
const isNormalClose = event.code === 1000
this.isConnected = false
this.isConnecting = false
this.clearAllTimers()
if (!this.stopReconnect && !isNormalClose) {
this.reconnect()
}
}
// 处理错误 - 增加详细错误信息
private handleError(event: Event): void {
console.error('WebSocket连接错误:')
console.error('错误事件:', event)
console.error(
'当前连接状态:',
this.ws?.readyState ? this.getReadyStateText(this.ws.readyState) : '未初始化'
)
this.isConnected = false
this.isConnecting = false
// 只有在未停止重连的情况下才尝试重连
if (!this.stopReconnect) {
this.reconnect()
}
}
// 转换连接状态为文本描述
private getReadyStateText(state: number): string {
switch (state) {
case WebSocket.CONNECTING:
return 'CONNECTING (0) - 正在连接'
case WebSocket.OPEN:
return 'OPEN (1) - 已连接'
case WebSocket.CLOSING:
return 'CLOSING (2) - 正在关闭'
case WebSocket.CLOSED:
return 'CLOSED (3) - 已关闭'
default:
return `未知状态 (${state})`
}
}
// 开始心跳检测
private startHeartbeat(): void {
this.clearTimer('detectionTimer')
this.clearTimer('timeoutTimer')
this.detectionTimer = setTimeout(() => {
this.isConnected = this.ws?.readyState === WebSocket.OPEN
if (!this.isConnected) {
console.warn('WebSocket心跳检测失败尝试重连')
this.reconnect()
this.timeoutTimer = setTimeout(() => {
console.warn('WebSocket重连超时')
this.close()
}, this.reconnectTimeout)
}
}, this.heartbeatInterval)
}
// 重置心跳检测
private resetHeartbeat(): void {
this.clearTimer('detectionTimer')
this.clearTimer('timeoutTimer')
this.startHeartbeat()
}
// 开始发送ping消息
private startPing(): void {
this.clearTimer('pingTimer')
this.pingTimer = setInterval(() => {
if (this.ws?.readyState !== WebSocket.OPEN) {
console.warn('WebSocket未连接停止发送ping')
this.clearTimer('pingTimer')
this.reconnect()
return
}
try {
this.ws.send('ping')
console.log('发送ping消息')
} catch (error) {
console.error('发送ping消息失败:', error)
this.clearTimer('pingTimer')
this.reconnect()
}
}, this.pingInterval)
}
// 重连 - 增加重连次数限制
private reconnect(): void {
if (this.stopReconnect || this.isConnecting) {
return
}
// 检查是否超过最大重连次数
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`)
this.close(true)
return
}
this.reconnectAttempts++
this.stopReconnect = true
this.close(true)
const delay = this.calculateReconnectDelay()
console.log(
`将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)`
)
this.clearTimer('reconnectTimer')
this.reconnectTimer = setTimeout(() => {
console.log(`尝试重新连接WebSocket${this.reconnectAttempts}次)`)
this.init()
this.stopReconnect = false
}, delay)
}
// 计算重连延迟 - 指数退避策略
private calculateReconnectDelay(): number {
// 基础延迟 + 随机值,避免多个客户端同时重连
const jitter = Math.random() * 1000 // 0-1秒的随机延迟
const baseDelay = Math.min(
this.reconnectInterval * Math.pow(1.5, this.reconnectAttempts - 1),
this.reconnectInterval * 5
)
return baseDelay + jitter
}
// 清除指定定时器
private clearTimer(
timerName:
| 'detectionTimer'
| 'timeoutTimer'
| 'reconnectTimer'
| 'pingTimer'
| 'connectionTimer'
): void {
if (this[timerName]) {
clearTimeout(this[timerName] as NodeJS.Timeout)
this[timerName] = null
}
}
// 清除所有定时器
private clearAllTimers(): void {
this.clearTimer('detectionTimer')
this.clearTimer('timeoutTimer')
this.clearTimer('reconnectTimer')
this.clearTimer('pingTimer')
this.clearTimer('connectionTimer')
}
// 获取当前连接状态
get isWebSocketConnected(): boolean {
return this.isConnected
}
// 获取当前连接状态文本
get connectionStatusText(): string {
if (this.isConnecting) return '正在连接'
if (this.isConnected) return '已连接'
if (this.reconnectAttempts > 0 && !this.stopReconnect)
return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts}`
return '已断开'
}
// 销毁实例
static destroyInstance(): void {
if (WebSocketClient.instance) {
WebSocketClient.instance.close()
WebSocketClient.instance = null
}
}
}

View File

@@ -0,0 +1,7 @@
/**
* 存储相关工具函数统一导出
*/
export * from './storage'
export * from './storage-config'
export * from './storage-key-manager'

View File

@@ -0,0 +1,122 @@
/**
* 存储配置管理模块
*
* 提供统一的本地存储配置和工具方法
*
* ## 主要功能
*
* - 版本化存储键管理,支持多版本数据隔离
* - 存储键名生成和解析(带版本前缀)
* - 版本号提取和验证
* - 存储键匹配的正则表达式生成
* - 旧版本存储键兼容处理
* - 升级和登出延迟配置
* - 主题存储键配置
*
* ## 使用场景
*
* - Pinia Store 持久化存储
* - 应用版本升级时的数据迁移
* - 多版本数据清理
* - 存储键的统一管理和规范
*
* 存储键格式sys-v{version}-{storeId}
* 例如sys-v1.0.0-user, sys-v1.0.0-setting
*
* @module utils/storage/storage-config
* @author Art Design Pro Team
*/
export class StorageConfig {
/** 当前应用版本 */
static readonly CURRENT_VERSION = __APP_VERSION__
/** 存储键前缀 */
static readonly STORAGE_PREFIX = 'sys-v'
/** 版本键名 */
static readonly VERSION_KEY = 'sys-version'
/** 主题键名index.html中使用了如果修改需要同步修改 */
static readonly THEME_KEY = 'sys-theme'
/** 上次登录用户ID键名用于判断是否为同一用户登录 */
static readonly LAST_USER_ID_KEY = 'sys-last-user-id'
/** 跳过升级检查的版本 */
static readonly SKIP_UPGRADE_VERSION = '1.0.0'
/** 升级处理延迟时间(毫秒) */
static readonly UPGRADE_DELAY = 1000
/** 登出延迟时间(毫秒) */
static readonly LOGOUT_DELAY = 1000
/**
* 生成版本化的存储键名
* @param storeId 存储ID
* @param version 版本号,默认使用当前版本
*/
static generateStorageKey(storeId: string, version: string = this.CURRENT_VERSION): string {
return `${this.STORAGE_PREFIX}${version}-${storeId}`
}
/**
* 生成旧版本的存储键名(不带分隔符)
* @param version 版本号,默认使用当前版本
*/
static generateLegacyKey(version: string = this.CURRENT_VERSION): string {
return `${this.STORAGE_PREFIX}${version}`
}
/**
* 创建存储键匹配的正则表达式
* @param storeId 存储ID
*/
static createKeyPattern(storeId: string): RegExp {
return new RegExp(`^${this.STORAGE_PREFIX}[^-]+-${storeId}$`)
}
/**
* 创建当前版本存储键匹配的正则表达式
*/
static createCurrentVersionPattern(): RegExp {
return new RegExp(`^${this.STORAGE_PREFIX}${this.CURRENT_VERSION}-`)
}
/**
* 创建任意版本存储键匹配的正则表达式
*/
static createVersionPattern(): RegExp {
return new RegExp(`^${this.STORAGE_PREFIX}`)
}
/**
* 检查是否为当前版本的键
*/
static isCurrentVersionKey(key: string): boolean {
return key.startsWith(`${this.STORAGE_PREFIX}${this.CURRENT_VERSION}`)
}
/**
* 检查是否为版本化的键
*/
static isVersionedKey(key: string): boolean {
return key.startsWith(this.STORAGE_PREFIX)
}
/**
* 从存储键中提取版本号
*/
static extractVersionFromKey(key: string): string | null {
const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}([^-]+)`))
return match ? match[1] : null
}
/**
* 从存储键中提取存储ID
*/
static extractStoreIdFromKey(key: string): string | null {
const match = key.match(new RegExp(`^${this.STORAGE_PREFIX}[^-]+-(.+)$`))
return match ? match[1] : null
}
}

View File

@@ -0,0 +1,97 @@
/**
* 存储键名管理器模块
*
* 提供智能的版本化存储键管理和数据迁移功能
*
* ## 主要功能
*
* - 自动生成当前版本的存储键名
* - 检测当前版本数据是否存在
* - 查找其他版本的同名存储数据
* - 自动将旧版本数据迁移到当前版本
* - 数据迁移日志记录
* - 迁移失败的错误处理
*
* ## 使用场景
*
* - Pinia Store 持久化插件中获取存储键
* - 应用版本升级时自动迁移用户数据
* - 避免版本升级导致的数据丢失
* - 实现平滑的版本过渡
*
* ## 工作流程
*
* 1. 优先使用当前版本的存储键
* 2. 如果当前版本无数据,查找其他版本的同名数据
* 3. 找到旧版本数据后自动迁移到当前版本
* 4. 返回当前版本的存储键供使用
*
* @module utils/storage/storage-key-manager
* @author Art Design Pro Team
*/
import { StorageConfig } from '@/utils/storage'
/**
* 存储键名管理器
* 负责处理版本化的存储键名生成和数据迁移
*/
export class StorageKeyManager {
/**
* 获取当前版本的存储键名
*/
private getCurrentVersionKey(storeId: string): string {
return StorageConfig.generateStorageKey(storeId)
}
/**
* 检查当前版本的数据是否存在
*/
private hasCurrentVersionData(key: string): boolean {
return localStorage.getItem(key) !== null
}
/**
* 查找其他版本的同名存储键
*/
private findExistingKey(storeId: string): string | null {
const storageKeys = Object.keys(localStorage)
const pattern = StorageConfig.createKeyPattern(storeId)
return storageKeys.find((key) => pattern.test(key) && localStorage.getItem(key)) || null
}
/**
* 将数据从旧版本迁移到当前版本
*/
private migrateData(fromKey: string, toKey: string): void {
try {
const existingData = localStorage.getItem(fromKey)
if (existingData) {
localStorage.setItem(toKey, existingData)
console.info(`[Storage] 已迁移数据: ${fromKey}${toKey}`)
}
} catch (error) {
console.warn(`[Storage] 数据迁移失败: ${fromKey}`, error)
}
}
/**
* 获取持久化存储的键名(支持自动数据迁移)
*/
getStorageKey(storeId: string): string {
const currentKey = this.getCurrentVersionKey(storeId)
// 优先使用当前版本的数据
if (this.hasCurrentVersionData(currentKey)) {
return currentKey
}
// 查找并迁移其他版本的数据
const existingKey = this.findExistingKey(storeId)
if (existingKey) {
this.migrateData(existingKey, currentKey)
}
return currentKey
}
}

View File

@@ -0,0 +1,250 @@
/**
* 存储兼容性管理模块
*
* 提供完整的本地存储兼容性检查和数据验证功能
*
* 主要功能
*
* - 多版本存储数据检测和验证
* - 新旧存储格式兼容处理
* - 存储数据完整性校验
* - 存储异常自动恢复(清理+登出)
* - 登录状态验证
* - 存储为空检测
* - 版本号管理
*
* ## 使用场景
*
* - 应用启动时检查存储数据有效性
* - 路由守卫中验证登录状态
* - 版本升级时的数据兼容性检查
* - 存储异常时的自动恢复
* - 防止因存储数据损坏导致的系统异常
*
* ## 工作流程
*
* 1. 优先检查当前版本的存储数据
* 2. 检查其他版本的存储数据
* 3. 兼容旧格式的存储数据
* 4. 验证数据完整性
* 5. 异常时提示用户并执行登出
*
* @module utils/storage/storage
* @author Art Design Pro Team
*/
import { router } from '@/router'
import { useUserStore } from '@/store/modules/user'
import { StorageConfig } from '@/utils/storage/storage-config'
/**
* 存储兼容性管理器
* 负责处理不同版本间的存储兼容性检查和数据验证
*/
class StorageCompatibilityManager {
/**
* 获取系统版本号
*/
getSystemVersion(): string | null {
return localStorage.getItem(StorageConfig.VERSION_KEY)
}
/**
* 获取系统存储数据(兼容旧格式)
*/
getSystemStorage(): any {
const version = this.getSystemVersion() || StorageConfig.CURRENT_VERSION
const legacyKey = StorageConfig.generateLegacyKey(version)
const data = localStorage.getItem(legacyKey)
return data ? JSON.parse(data) : null
}
/**
* 检查当前版本是否有存储数据
*/
private hasCurrentVersionStorage(): boolean {
const storageKeys = Object.keys(localStorage)
const currentVersionPattern = StorageConfig.createCurrentVersionPattern()
return storageKeys.some(
(key) => currentVersionPattern.test(key) && localStorage.getItem(key) !== null
)
}
/**
* 检查是否存在任何版本的存储数据
*/
private hasAnyVersionStorage(): boolean {
const storageKeys = Object.keys(localStorage)
const versionPattern = StorageConfig.createVersionPattern()
return storageKeys.some((key) => versionPattern.test(key) && localStorage.getItem(key) !== null)
}
/**
* 获取旧格式的本地存储数据
*/
private getLegacyStorageData(): Record<string, any> {
try {
const systemStorage = this.getSystemStorage()
return systemStorage || {}
} catch (error) {
console.warn('[Storage] 解析旧格式存储数据失败:', error)
return {}
}
}
/**
* 显示存储错误消息
*/
private showStorageError(): void {
ElMessage({
type: 'error',
offset: 40,
duration: 5000,
message: '系统检测到本地数据异常,请重新登录系统恢复使用!'
})
}
/**
* 执行系统登出
*/
private performSystemLogout(): void {
setTimeout(() => {
try {
localStorage.clear()
useUserStore().logOut()
router.push({ name: 'Login' })
console.info('[Storage] 已执行系统登出')
} catch (error) {
console.error('[Storage] 系统登出失败:', error)
}
}, StorageConfig.LOGOUT_DELAY)
}
/**
* 处理存储异常
*/
private handleStorageError(): void {
this.showStorageError()
this.performSystemLogout()
}
/**
* 验证存储数据完整性
* @param requireAuth 是否需要验证登录状态(默认 false
*/
validateStorageData(requireAuth: boolean = false): boolean {
try {
// 优先检查新版本存储结构
if (this.hasCurrentVersionStorage()) {
// console.debug('[Storage] 发现当前版本存储数据')
return true
}
// 检查是否有任何版本的存储数据
if (this.hasAnyVersionStorage()) {
// console.debug('[Storage] 发现其他版本存储数据,可能需要迁移')
return true
}
// 检查旧版本存储结构
const legacyData = this.getLegacyStorageData()
if (Object.keys(legacyData).length === 0) {
// 只有在需要验证登录状态时才执行登出操作
if (requireAuth) {
console.warn('[Storage] 未发现任何存储数据,需要重新登录')
this.performSystemLogout()
return false
}
// 首次访问或访问静态路由,不需要登出
// console.debug('[Storage] 未发现存储数据,首次访问或访问静态路由')
return true
}
console.debug('[Storage] 发现旧版本存储数据')
return true
} catch (error) {
console.error('[Storage] 存储数据验证失败:', error)
// 只有在需要验证登录状态时才处理错误
if (requireAuth) {
this.handleStorageError()
return false
}
return true
}
}
/**
* 检查存储是否为空
*/
isStorageEmpty(): boolean {
// 检查新版本存储结构
if (this.hasCurrentVersionStorage()) {
return false
}
// 检查是否有任何版本的存储数据
if (this.hasAnyVersionStorage()) {
return false
}
// 检查旧版本存储结构
const legacyData = this.getLegacyStorageData()
return Object.keys(legacyData).length === 0
}
/**
* 检查存储兼容性
* @param requireAuth 是否需要验证登录状态(默认 false
*/
checkCompatibility(requireAuth: boolean = false): boolean {
try {
const isValid = this.validateStorageData(requireAuth)
const isEmpty = this.isStorageEmpty()
if (isValid || isEmpty) {
// console.debug('[Storage] 存储兼容性检查通过')
return true
}
console.warn('[Storage] 存储兼容性检查失败')
return false
} catch (error) {
console.error('[Storage] 兼容性检查异常:', error)
return false
}
}
}
// 创建存储兼容性管理器实例
const storageManager = new StorageCompatibilityManager()
/**
* 获取系统存储数据
*/
export function getSystemStorage(): any {
return storageManager.getSystemStorage()
}
/**
* 获取系统版本号
*/
export function getSysVersion(): string | null {
return storageManager.getSystemVersion()
}
/**
* 验证本地存储数据
* @param requireAuth 是否需要验证登录状态(默认 false
*/
export function validateStorageData(requireAuth: boolean = false): boolean {
return storageManager.validateStorageData(requireAuth)
}
/**
* 检查存储兼容性
* @param requireAuth 是否需要验证登录状态(默认 false
*/
export function checkStorageCompatibility(requireAuth: boolean = false): boolean {
return storageManager.checkCompatibility(requireAuth)
}

View File

@@ -0,0 +1,8 @@
// ANSI 转义码生成网站 https://patorjk.com/software/taag/#p=display&f=Big&t=ABB%0A
const asciiArt = `
\x1b[32m欢迎使用 SaiAdmin 6.x
\x1b[0m
\x1b[36mSaiAdmin 官网: https://saithink.top
\x1b[0m
`
console.log(asciiArt)

View File

@@ -0,0 +1,102 @@
/**
* 全局错误处理模块
*
* 提供统一的错误捕获和处理机制
*
* ## 主要功能
*
* - Vue 运行时错误捕获(组件错误、生命周期错误等)
* - 全局脚本错误捕获(语法错误、运行时错误等)
* - Promise 未捕获错误处理unhandledrejection
* - 静态资源加载错误监控(图片、脚本、样式等)
* - 错误日志记录和上报
* - 统一的错误处理入口
*
* ## 使用场景
* - 应用启动时安装全局错误处理器
* - 捕获和记录所有类型的错误
* - 错误上报到监控平台
* - 提升应用稳定性和可维护性
* - 问题排查和调试
*
* ## 错误类型
*
* - VueError: Vue 组件相关错误
* - ScriptError: JavaScript 脚本错误
* - PromiseError: Promise 未捕获的 rejection
* - ResourceError: 静态资源加载失败
*
* @module utils/sys/error-handle
* @author Art Design Pro Team
*/
import type { App } from 'vue'
/**
* Vue 运行时错误处理
*/
export function vueErrorHandler(err: unknown, instance: any, info: string) {
console.error('[VueError]', err, info, instance)
// 这里可以上报到服务端,比如:
// reportError({ type: 'vue', err, info })
}
/**
* 全局脚本错误处理
*/
export function scriptErrorHandler(
message: Event | string,
source?: string,
lineno?: number,
colno?: number,
error?: Error
): boolean {
console.error('[ScriptError]', { message, source, lineno, colno, error })
// reportError({ type: 'script', message, source, lineno, colno, error })
return true // 阻止默认控制台报错,可根据需求改
}
/**
* Promise 未捕获错误处理
*/
export function registerPromiseErrorHandler() {
window.addEventListener('unhandledrejection', (event) => {
console.error('[PromiseError]', event.reason)
// reportError({ type: 'promise', reason: event.reason })
})
}
/**
* 资源加载错误处理 (img, script, css...)
*/
export function registerResourceErrorHandler() {
window.addEventListener(
'error',
(event: Event) => {
const target = event.target as HTMLElement
if (
target &&
(target.tagName === 'IMG' || target.tagName === 'SCRIPT' || target.tagName === 'LINK')
) {
console.error('[ResourceError]', {
tagName: target.tagName,
src:
(target as HTMLImageElement).src ||
(target as HTMLScriptElement).src ||
(target as HTMLLinkElement).href
})
// reportError({ type: 'resource', target })
}
},
true // 捕获阶段才能监听到资源错误
)
}
/**
* 安装统一错误处理
*/
export function setupErrorHandle(app: App) {
app.config.errorHandler = vueErrorHandler
window.onerror = scriptErrorHandler
registerPromiseErrorHandler()
registerResourceErrorHandler()
}

View File

@@ -0,0 +1,6 @@
/**
* 系统管理相关工具函数统一导出
*/
export * from './upgrade'
export { default as mittBus } from './mittBus'

View File

@@ -0,0 +1,63 @@
/**
* 全局事件总线模块
*
* 基于 mitt 库实现的类型安全的事件总线
*
* ## 主要功能
*
* - 跨组件通信(发布/订阅模式)
* - 类型安全的事件定义和调用
* - 全局事件管理(烟花效果、设置面板、搜索对话框等)
* - 解耦组件间的直接依赖
*
* ## 使用场景
*
* - 跨层级组件通信
* - 全局功能触发(设置、搜索、聊天、锁屏等)
* - 特效触发(烟花效果)
* - 避免 props 层层传递
*
* ## 用法示例
*
* ```typescript
* // 订阅事件
* mittBus.on('openSetting', () => { ... })
*
* // 发布事件
* mittBus.emit('openSetting')
*
* // 带参数的事件
* mittBus.emit('triggerFireworks', 'image-url')
* ```
*
* ## 已定义的事件
*
* - triggerFireworks: 触发烟花效果可选图片URL
* - openSetting: 打开设置面板
* - openSearchDialog: 打开搜索对话框
* - openChat: 打开聊天窗口
* - openLockScreen: 打开锁屏
*
* @module utils/sys/mittBus
* @author Art Design Pro Team
*/
import mitt, { type Emitter } from 'mitt'
// 定义事件类型映射
type Events = {
// 烟花效果事件 - 可选的图片URL参数
triggerFireworks: string | undefined
// 打开设置面板事件 - 无参数
openSetting: void
// 打开搜索对话框事件 - 无参数
openSearchDialog: void
// 打开聊天窗口事件 - 无参数
openChat: void
// 打开锁屏事件 - 无参数
openLockScreen: void
}
// 创建类型安全的事件总线实例
const mittBus: Emitter<Events> = mitt<Events>()
export default mittBus

View File

@@ -0,0 +1,277 @@
/**
* 系统版本升级管理模块
*
* 提供完整的应用版本升级检测和处理功能
*
* ## 主要功能
*
* - 版本号比较和升级检测
* - 首次访问识别和处理
* - 旧版本数据自动清理
* - 升级日志展示和通知
* - 强制重新登录控制(根据升级日志配置)
* - 版本号规范化处理
* - 旧存储结构迁移和清理
* - 升级流程延迟执行(确保应用完全加载)
*
* ## 使用场景
*
* - 应用启动时自动检测版本升级
* - 版本更新后清理旧数据
* - 向用户展示版本更新内容
* - 重大更新时要求用户重新登录
* - 防止旧版本数据污染新版本
*
* ## 工作流程
*
* 1. 检查本地存储的版本号
* 2. 与当前应用版本对比
* 3. 查找并清理旧版本数据
* 4. 展示升级通知(包含更新日志)
* 5. 根据配置决定是否强制重新登录
* 6. 更新本地版本号
*
* @module utils/sys/upgrade
* @author Art Design Pro Team
*/
import { upgradeLogList } from '@/mock/upgrade/changeLog'
import { ElNotification } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
import { StorageConfig } from '@/utils/storage/storage-config'
/**
* 版本管理器
* 负责处理版本比较、升级检测和数据清理
*/
class VersionManager {
/**
* 规范化版本号字符串,移除前缀 'v'
*/
private normalizeVersion(version: string): string {
return version.replace(/^v/, '')
}
/**
* 获取存储的版本号
*/
private getStoredVersion(): string | null {
return localStorage.getItem(StorageConfig.VERSION_KEY)
}
/**
* 设置版本号到存储
*/
private setStoredVersion(version: string): void {
localStorage.setItem(StorageConfig.VERSION_KEY, version)
}
/**
* 检查是否应该跳过升级处理
*/
private shouldSkipUpgrade(): boolean {
return StorageConfig.CURRENT_VERSION === StorageConfig.SKIP_UPGRADE_VERSION
}
/**
* 检查是否为首次访问
*/
private isFirstVisit(storedVersion: string | null): boolean {
return !storedVersion
}
/**
* 检查版本是否相同
*/
private isSameVersion(storedVersion: string): boolean {
return storedVersion === StorageConfig.CURRENT_VERSION
}
/**
* 查找旧的存储结构
*/
private findLegacyStorage(): { oldSysKey: string | null; oldVersionKeys: string[] } {
const storageKeys = Object.keys(localStorage)
const currentVersionPrefix = StorageConfig.generateStorageKey('').slice(0, -1) // 移除末尾的 '-'
// 查找旧的单一存储结构
const oldSysKey =
storageKeys.find(
(key) =>
StorageConfig.isVersionedKey(key) && key !== currentVersionPrefix && !key.includes('-')
) || null
// 查找旧版本的分离存储键
const oldVersionKeys = storageKeys.filter(
(key) =>
StorageConfig.isVersionedKey(key) &&
!StorageConfig.isCurrentVersionKey(key) &&
key.includes('-')
)
return { oldSysKey, oldVersionKeys }
}
/**
* 检查是否需要重新登录
*/
private shouldRequireReLogin(storedVersion: string): boolean {
const normalizedCurrent = this.normalizeVersion(StorageConfig.CURRENT_VERSION)
const normalizedStored = this.normalizeVersion(storedVersion)
return upgradeLogList.value.some((item) => {
const itemVersion = this.normalizeVersion(item.version)
return (
item.requireReLogin && itemVersion > normalizedStored && itemVersion <= normalizedCurrent
)
})
}
/**
* 构建升级通知消息
*/
private buildUpgradeMessage(requireReLogin: boolean): string {
const { title: content } = upgradeLogList.value[0]
const messageParts = [
`<p style="color: var(--art-gray-800) !important; padding-bottom: 5px;">`,
`系统已升级到 ${StorageConfig.CURRENT_VERSION} 版本,此次更新带来了以下改进:`,
`</p>`,
content
]
if (requireReLogin) {
messageParts.push(
`<p style="color: var(--theme-color); padding-top: 5px;">升级完成,请重新登录后继续使用。</p>`
)
}
return messageParts.join('')
}
/**
* 显示升级通知
*/
private showUpgradeNotification(message: string): void {
ElNotification({
title: '系统升级公告',
message,
duration: 0,
type: 'success',
dangerouslyUseHTMLString: true
})
}
/**
* 清理旧版本数据
*/
private cleanupLegacyData(oldSysKey: string | null, oldVersionKeys: string[]): void {
// 清理旧的单一存储结构
if (oldSysKey) {
localStorage.removeItem(oldSysKey)
console.info(`[Upgrade] 已清理旧存储: ${oldSysKey}`)
}
// 清理旧版本的分离存储
oldVersionKeys.forEach((key) => {
localStorage.removeItem(key)
console.info(`[Upgrade] 已清理旧存储: ${key}`)
})
}
/**
* 执行升级后的登出操作
*/
private performLogout(): void {
try {
useUserStore().logOut()
console.info('[Upgrade] 已执行升级后登出')
} catch (error) {
console.error('[Upgrade] 升级后登出失败:', error)
}
}
/**
* 执行升级流程
*/
private async executeUpgrade(
storedVersion: string,
legacyStorage: ReturnType<typeof this.findLegacyStorage>
): Promise<void> {
try {
if (!upgradeLogList.value.length) {
console.warn('[Upgrade] 升级日志列表为空')
return
}
const requireReLogin = this.shouldRequireReLogin(storedVersion)
const message = this.buildUpgradeMessage(requireReLogin)
// 显示升级通知
this.showUpgradeNotification(message)
// 更新版本号
this.setStoredVersion(StorageConfig.CURRENT_VERSION)
// 清理旧数据
this.cleanupLegacyData(legacyStorage.oldSysKey, legacyStorage.oldVersionKeys)
// 执行登出(如果需要)
if (requireReLogin) {
this.performLogout()
}
console.info(`[Upgrade] 升级完成: ${storedVersion}${StorageConfig.CURRENT_VERSION}`)
} catch (error) {
console.error('[Upgrade] 系统升级处理失败:', error)
}
}
/**
* 系统升级处理主流程
*/
async processUpgrade(): Promise<void> {
// 跳过特定版本
if (this.shouldSkipUpgrade()) {
console.debug('[Upgrade] 跳过版本升级检查')
return
}
const storedVersion = this.getStoredVersion()
// 首次访问处理
if (this.isFirstVisit(storedVersion)) {
this.setStoredVersion(StorageConfig.CURRENT_VERSION)
// console.info('[Upgrade] 首次访问,已设置当前版本')
return
}
// 版本相同,无需升级
if (this.isSameVersion(storedVersion!)) {
// console.debug('[Upgrade] 版本相同,无需升级')
return
}
// 检查是否有需要升级的旧数据
const legacyStorage = this.findLegacyStorage()
if (!legacyStorage.oldSysKey && legacyStorage.oldVersionKeys.length === 0) {
this.setStoredVersion(StorageConfig.CURRENT_VERSION)
console.info('[Upgrade] 无旧数据,已更新版本号')
return
}
// 延迟执行升级流程,确保应用已完全加载
setTimeout(() => {
this.executeUpgrade(storedVersion!, legacyStorage)
}, StorageConfig.UPGRADE_DELAY)
}
}
// 创建版本管理器实例
const versionManager = new VersionManager()
/**
* 系统升级处理入口函数
*/
export async function systemUpgrade(): Promise<void> {
await versionManager.processUpgrade()
}

View File

@@ -0,0 +1,266 @@
/**
* 表格缓存管理模块
*
* 提供高性能的表格数据缓存机制
*
* ## 主要功能
*
* - 基于参数的智能缓存键生成(使用 ohash
* - LRU最近最少使用缓存淘汰策略
* - 缓存过期时间管理
* - 缓存大小限制和自动清理
* - 基于标签的缓存分组管理
* - 多种缓存失效策略(清空所有、清空当前、清空分页等)
* - 缓存访问统计和命中率分析
* - 缓存大小估算
*
* ## 使用场景
*
* - 表格数据的分页缓存
* - 减少重复的 API 请求
* - 提升表格切换和返回的响应速度
* - 搜索条件变化时的智能缓存管理
* - 数据更新后的缓存失效处理
*
* ## 缓存策略
*
* - CLEAR_ALL: 清空所有缓存(适用于全局数据更新)
* - CLEAR_CURRENT: 仅清空当前查询条件的缓存(适用于单条数据更新)
* - CLEAR_PAGINATION: 清空所有分页缓存但保留不同搜索条件(适用于批量操作)
* - KEEP_ALL: 不清除缓存(适用于只读操作)
*
* @module utils/table/tableCache
* @author Art Design Pro Team
*/
import { hash } from 'ohash'
// 缓存失效策略枚举
export enum CacheInvalidationStrategy {
/** 清空所有缓存 */
CLEAR_ALL = 'clear_all',
/** 仅清空当前查询条件的缓存 */
CLEAR_CURRENT = 'clear_current',
/** 清空所有分页缓存(保留不同搜索条件的缓存) */
CLEAR_PAGINATION = 'clear_pagination',
/** 不清除缓存 */
KEEP_ALL = 'keep_all'
}
// 通用 API 响应接口(兼容不同的后端响应格式)
export interface ApiResponse<T = unknown> {
records?: T[]
data?: T[]
total?: number
current?: number
size?: number
[key: string]: unknown
}
// 缓存存储接口
export interface CacheItem<T> {
data: T[]
response: ApiResponse<T>
timestamp: number
params: string
// 缓存标签,用于分组管理
tags: Set<string>
// 访问次数(用于 LRU 算法)
accessCount: number
// 最后访问时间
lastAccessTime: number
}
// 增强的缓存管理类
export class TableCache<T> {
private cache = new Map<string, CacheItem<T>>()
private cacheTime: number
private maxSize: number
private enableLog: boolean
constructor(cacheTime = 5 * 60 * 1000, maxSize = 50, enableLog = false) {
// 默认5分钟最多50条缓存
this.cacheTime = cacheTime
this.maxSize = maxSize
this.enableLog = enableLog
}
// 内部日志工具
private log(message: string, ...args: any[]) {
if (this.enableLog) {
console.log(`[TableCache] ${message}`, ...args)
}
}
// 生成稳定的缓存键
private generateKey(params: unknown): string {
return hash(params)
}
// 🔧 优化:增强类型安全性
private generateTags(params: Record<string, unknown>): Set<string> {
const tags = new Set<string>()
// 添加搜索条件标签
const searchKeys = Object.keys(params).filter(
(key) =>
!['current', 'size', 'total'].includes(key) &&
params[key] !== undefined &&
params[key] !== '' &&
params[key] !== null
)
if (searchKeys.length > 0) {
const searchTag = searchKeys.map((key) => `${key}:${String(params[key])}`).join('|')
tags.add(`search:${searchTag}`)
} else {
tags.add('search:default')
}
// 添加分页标签
tags.add(`pagination:${params.size || 10}`)
// 添加通用分页标签,用于清理所有分页缓存
tags.add('pagination')
return tags
}
// 🔧 优化LRU 缓存清理
private evictLRU(): void {
if (this.cache.size <= this.maxSize) return
// 找到最少使用的缓存项
let lruKey = ''
let minAccessCount = Infinity
let oldestTime = Infinity
for (const [key, item] of this.cache.entries()) {
if (
item.accessCount < minAccessCount ||
(item.accessCount === minAccessCount && item.lastAccessTime < oldestTime)
) {
lruKey = key
minAccessCount = item.accessCount
oldestTime = item.lastAccessTime
}
}
if (lruKey) {
this.cache.delete(lruKey)
this.log(`LRU 清理缓存: ${lruKey}`)
}
}
// 设置缓存
set(params: unknown, data: T[], response: ApiResponse<T>): void {
const key = this.generateKey(params)
const tags = this.generateTags(params as Record<string, unknown>)
const now = Date.now()
// 检查是否需要清理
this.evictLRU()
this.cache.set(key, {
data,
response,
timestamp: now,
params: key,
tags,
accessCount: 1,
lastAccessTime: now
})
}
// 获取缓存
get(params: unknown): CacheItem<T> | null {
const key = this.generateKey(params)
const item = this.cache.get(key)
if (!item) return null
// 检查是否过期
if (Date.now() - item.timestamp > this.cacheTime) {
this.cache.delete(key)
return null
}
// 更新访问统计
item.accessCount++
item.lastAccessTime = Date.now()
return item
}
// 根据标签清除缓存
clearByTags(tags: string[]): number {
let clearedCount = 0
for (const [key, item] of this.cache.entries()) {
// 检查是否包含任意一个标签
const hasMatchingTag = tags.some((tag) =>
Array.from(item.tags).some((itemTag) => itemTag.includes(tag))
)
if (hasMatchingTag) {
this.cache.delete(key)
clearedCount++
}
}
return clearedCount
}
// 清除当前搜索条件的缓存
clearCurrentSearch(params: unknown): number {
const key = this.generateKey(params)
const deleted = this.cache.delete(key)
return deleted ? 1 : 0
}
// 清除分页缓存
clearPagination(): number {
return this.clearByTags(['pagination'])
}
// 清空所有缓存
clear(): void {
this.cache.clear()
}
// 获取缓存统计信息
getStats(): { total: number; size: string; hitRate: string } {
const total = this.cache.size
let totalSize = 0
let totalAccess = 0
for (const item of this.cache.values()) {
// 粗略估算大小JSON字符串长度
totalSize += JSON.stringify(item.data).length
totalAccess += item.accessCount
}
// 转换为人类可读的大小
const sizeInKB = (totalSize / 1024).toFixed(2)
const avgHits = total > 0 ? (totalAccess / total).toFixed(1) : '0'
return {
total,
size: `${sizeInKB}KB`,
hitRate: `${avgHits} avg hits`
}
}
// 清理过期缓存
cleanupExpired(): number {
let cleanedCount = 0
const now = Date.now()
for (const [key, item] of this.cache.entries()) {
if (now - item.timestamp > this.cacheTime) {
this.cache.delete(key)
cleanedCount++
}
}
return cleanedCount
}
}

View File

@@ -0,0 +1,59 @@
/**
* 表格全局配置模块
*
* 提供表格与后端接口的字段映射配置
*
* ## 主要功能
*
* - 响应数据字段自动识别和映射
* - 支持多种常见的后端响应格式
* - 请求参数字段映射配置
* - 可扩展的字段配置机制
*
* ## 使用场景
*
* - 适配不同后端的分页接口格式
* - 统一前端表格组件的数据处理
* - 减少重复的数据转换代码
* - 支持多个后端服务的接口对接
*
* ## 配置说明
*
* - recordFields: 列表数据字段名(按优先级顺序查找)
* - totalFields: 总条数字段名
* - currentFields: 当前页码字段名
* - sizeFields: 每页大小字段名
* - paginationKey: 前端发送请求时使用的分页参数名
*
* ## 扩展方式
*
* 如果后端使用其他字段名,可以在对应数组中添加新的字段名
* 例如recordFields: ['list', 'data', 'records', 'items', 'yourCustomField']
*
* @module utils/table/tableConfig
* @author Art Design Pro Team
*/
export const tableConfig = {
// 响应数据字段映射配置,系统会从接口返回数据中按顺序查找这些字段
// 列表数据
recordFields: ['list', 'data', 'records', 'items', 'result', 'rows'],
// 总条数
totalFields: ['total', 'count'],
// 当前页码
currentFields: ['current', 'page', 'pageNum'],
// 每页大小
sizeFields: ['size', 'pageSize', 'limit'],
// 请求参数映射配置,前端发送请求时使用的分页参数名
// useTable 组合式函数传递分页参数的时候 用 current 跟 size
paginationKey: {
// 当前页码
current: 'page',
// 每页大小
size: 'limit',
// 排序字段
orderField: 'orderField',
// 排序类型
orderType: 'orderType'
}
}

View File

@@ -0,0 +1,297 @@
/**
* 表格工具函数模块
*
* 提供表格数据处理和请求管理的核心工具函数
*
* ## 主要功能
*
* - 多格式 API 响应自动适配和标准化
* - 表格数据提取和转换
* - 分页信息自动更新和校验
* - 智能防抖函数(支持取消和立即执行)
* - 统一的错误处理机制
* - 嵌套数据结构解析
*
* ## 使用场景
*
* - useTable 组合式函数的底层工具
* - 适配各种后端接口响应格式
* - 表格数据的标准化处理
* - 请求防抖和性能优化
* - 错误统一处理和日志记录
*
* ## 支持的响应格式
*
* 1. 直接数组: [item1, item2, ...]
* 2. 标准对象: { records: [], total: 100 }
* 3. 嵌套data: { data: { list: [], total: 100 } }
* 4. 多种字段名: list/data/records/items/result/rows
*
* ## 核心功能
*
* - defaultResponseAdapter: 智能识别和转换响应格式
* - extractTableData: 提取表格数据数组
* - updatePaginationFromResponse: 更新分页信息
* - createSmartDebounce: 创建可控的防抖函数
* - createErrorHandler: 生成错误处理器
*
* @module utils/table/tableUtils
* @author Art Design Pro Team
*/
import type { ApiResponse } from './tableCache'
import { tableConfig } from './tableConfig'
// 请求参数基础接口,扩展分页参数
export interface BaseRequestParams extends Api.Common.PaginationParams {
[key: string]: unknown
}
// 错误处理接口
export interface TableError {
code: string
message: string
details?: unknown
}
// 辅助函数:从对象中提取记录数组
function extractRecords<T>(obj: Record<string, unknown>, fields: string[]): T[] {
for (const field of fields) {
if (field in obj && Array.isArray(obj[field])) {
return obj[field] as T[]
}
}
return []
}
// 辅助函数:从对象中提取总数
function extractTotal(obj: Record<string, unknown>, records: unknown[], fields: string[]): number {
for (const field of fields) {
if (field in obj && typeof obj[field] === 'number') {
return obj[field] as number
}
}
return records.length
}
// 辅助函数:提取分页参数
function extractPagination(
obj: Record<string, unknown>,
data?: Record<string, unknown>
): Pick<ApiResponse<unknown>, 'current' | 'size'> | undefined {
const result: Partial<Pick<ApiResponse<unknown>, 'current' | 'size'>> = {}
const sources = [obj, data ?? {}]
const currentFields = tableConfig.currentFields
for (const src of sources) {
for (const field of currentFields) {
if (field in src && typeof src[field] === 'number') {
result.current = src[field] as number
break
}
}
if (result.current !== undefined) break
}
const sizeFields = tableConfig.sizeFields
for (const src of sources) {
for (const field of sizeFields) {
if (field in src && typeof src[field] === 'number') {
result.size = src[field] as number
break
}
}
if (result.size !== undefined) break
}
if (result.current === undefined && result.size === undefined) return undefined
return result
}
/**
* 默认响应适配器 - 支持多种常见的API响应格式
*/
export const defaultResponseAdapter = <T>(response: unknown): ApiResponse<T> => {
// 定义支持的字段
const recordFields = tableConfig.recordFields
if (!response) {
return { records: [], total: 0 }
}
if (Array.isArray(response)) {
return { records: response, total: response.length }
}
if (typeof response !== 'object') {
console.warn(
'[tableUtils] 无法识别的响应格式,支持的格式包括: 数组、包含' +
recordFields.join('/') +
'字段的对象、嵌套data对象。当前格式:',
response
)
return { records: [], total: 0 }
}
const res = response as Record<string, unknown>
let records: T[] = []
let total = 0
let pagination: Pick<ApiResponse<unknown>, 'current' | 'size'> | undefined
// 处理标准格式或直接列表
records = extractRecords(res, recordFields)
total = extractTotal(res, records, tableConfig.totalFields)
pagination = extractPagination(res)
// 如果没有找到检查嵌套data
if (records.length === 0 && 'data' in res && typeof res.data === 'object') {
const data = res.data as Record<string, unknown>
records = extractRecords(data, ['list', 'records', 'items'])
total = extractTotal(data, records, tableConfig.totalFields)
pagination = extractPagination(res, data)
if (Array.isArray(res.data)) {
records = res.data as T[]
total = records.length
}
}
if (!recordFields.some((field) => field in res) && records.length === 0) {
console.warn('[tableUtils] 无法识别的响应格式')
console.warn('支持的字段包括: ' + recordFields.join('、'), response)
console.warn('扩展字段请到 utils/table/tableConfig 文件配置')
}
const result: ApiResponse<T> = { records, total }
if (pagination) {
Object.assign(result, pagination)
}
return result
}
/**
* 从标准化的API响应中提取表格数据
*/
export const extractTableData = <T>(response: ApiResponse<T>): T[] => {
const data = response.records || response.data || []
return Array.isArray(data) ? data : []
}
/**
* 根据API响应更新分页信息
*/
export const updatePaginationFromResponse = <T>(
pagination: Api.Common.PaginationParams,
response: ApiResponse<T>
): void => {
pagination.total = response.total ?? pagination.total ?? 0
if (response.current !== undefined) {
pagination.current = response.current
}
const maxPage = Math.max(1, Math.ceil(pagination.total / (pagination.size || 1)))
if (pagination.current > maxPage) {
pagination.current = maxPage
}
}
/**
* 创建智能防抖函数 - 支持取消和立即执行
*/
export const createSmartDebounce = <T extends (...args: any[]) => Promise<any>>(
fn: T,
delay: number
): T & { cancel: () => void; flush: () => Promise<any> } => {
let timeoutId: NodeJS.Timeout | null = null
let lastArgs: Parameters<T> | null = null
let lastResolve: ((value: any) => void) | null = null
let lastReject: ((reason: any) => void) | null = null
const debouncedFn = (...args: Parameters<T>): Promise<any> => {
return new Promise((resolve, reject) => {
if (timeoutId) clearTimeout(timeoutId)
lastArgs = args
lastResolve = resolve
lastReject = reject
timeoutId = setTimeout(async () => {
try {
const result = await fn(...args)
resolve(result)
} catch (error) {
reject(error)
} finally {
timeoutId = null
lastArgs = null
lastResolve = null
lastReject = null
}
}, delay)
})
}
debouncedFn.cancel = () => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = null
lastArgs = null
lastResolve = null
lastReject = null
}
debouncedFn.flush = async () => {
if (timeoutId && lastArgs && lastResolve && lastReject) {
clearTimeout(timeoutId)
timeoutId = null
const args = lastArgs
const resolve = lastResolve
const reject = lastReject
lastArgs = null
lastResolve = null
lastReject = null
try {
const result = await fn(...args)
resolve(result)
return result
} catch (error) {
reject(error)
throw error
}
}
return Promise.resolve()
}
return debouncedFn as any
}
/**
* 生成错误处理函数
*/
export const createErrorHandler = (
onError?: (error: TableError) => void,
enableLog: boolean = false
) => {
const logger = {
error: (message: string, ...args: any[]) => {
if (enableLog) console.error(`[useTable] ${message}`, ...args)
}
}
return (err: unknown, context: string): TableError => {
const tableError: TableError = {
code: 'UNKNOWN_ERROR',
message: '未知错误',
details: err
}
if (err instanceof Error) {
tableError.message = err.message
tableError.code = err.name
} else if (typeof err === 'string') {
tableError.message = err
}
logger.error(`${context}:`, err)
onError?.(tableError)
return tableError
}
}

View File

@@ -0,0 +1,60 @@
import { useUserStore } from '@/store/modules/user'
/**
* 检查权限
* @param permission
* @returns
*/
export function checkAuth(permission: string) {
const userStore = useUserStore()
const userButtons = userStore.getUserInfo.buttons
// 超级管理员
if (userButtons?.includes('*')) {
return true
}
// 如果按钮为空或未定义,移除元素
if (!userButtons?.length) {
return false
}
const hasPermission = userButtons.some((item) => item === permission)
// 如果没有权限,移除元素
if (hasPermission) {
return true
}
return false
}
/**
* 下载文件
* @param res 响应数据
* @param downName 下载文件名
*/
export function downloadFile(res: any, downName: string = '') {
const aLink = document.createElement('a')
let fileName = downName
let blob = res //第三方请求返回blob对象
//通过后端接口返回
if (res.headers && res.data) {
blob = new Blob([res.data], {
type: res.headers['content-type'].replace(';charset=utf8', '')
})
if (!downName) {
const contentDisposition = decodeURI(res.headers['content-disposition'])
const result = contentDisposition.match(/filename="(.+)/gi)
fileName = result?.[0].replace(/filename="(.+)/gi, '') || ''
fileName = fileName.replace('"', '')
}
}
aLink.href = URL.createObjectURL(blob)
// 设置下载文件名称
aLink.setAttribute('download', fileName)
document.body.appendChild(aLink)
aLink.click()
document.body.removeChild(aLink)
URL.revokeObjectURL(aLink.href)
}

View File

@@ -0,0 +1,80 @@
/**
* 主题动画工具模块
*
* 提供主题切换的视觉动画效果
*
* ## 主要功能
*
* - 基于鼠标点击位置的圆形扩散动画
* - View Transition API 支持(现代浏览器)
* - 降级处理(不支持动画的浏览器)
* - 暗黑主题切换过渡效果
* - 页面刷新时的主题过渡优化
*
* ## 使用场景
*
* - 明暗主题切换
* - 提升用户体验的视觉反馈
* - 页面刷新时的平滑过渡
*
* ## 技术实现
*
* - 使用 CSS 变量存储点击位置和半径
* - 利用 View Transition API 实现流畅动画
* - 通过 CSS class 控制过渡效果
* - 自动计算最大扩散半径
*
* @module utils/theme/animation
* @author Art Design Pro Team
*/
import { useCommon } from '@/hooks/core/useCommon'
import { useTheme } from '@/hooks/core/useTheme'
import { SystemThemeEnum } from '@/enums/appEnum'
import { useSettingStore } from '@/store/modules/setting'
const { LIGHT, DARK } = SystemThemeEnum
/**
* 主题切换动画
* @param e 鼠标点击事件
*/
export const themeAnimation = (e: any) => {
const x = e.clientX
const y = e.clientY
// 计算鼠标点击位置距离视窗的最大圆半径
const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y))
// 设置CSS变量
document.documentElement.style.setProperty('--x', x + 'px')
document.documentElement.style.setProperty('--y', y + 'px')
document.documentElement.style.setProperty('--r', endRadius + 'px')
if (document.startViewTransition) {
document.startViewTransition(() => toggleTheme())
} else {
toggleTheme()
}
}
/**
* 切换主题
*/
const toggleTheme = () => {
useTheme().switchThemeStyles(useSettingStore().systemThemeType === LIGHT ? DARK : LIGHT)
useCommon().refresh()
}
/**
* 切换主题过渡效果
* @param enable 是否启用过渡效果
*/
export const toggleTransition = (enable: boolean) => {
const body = document.body
if (enable) {
body.classList.add('theme-change')
} else {
setTimeout(() => {
body.classList.remove('theme-change')
}, 300)
}
}

View File

@@ -0,0 +1,273 @@
/**
* 颜色处理工具模块
*
* 提供完整的颜色格式转换和处理功能
*
* ## 主要功能
*
* - Hex 与 RGB/RGBA 格式互转
* - 颜色混合计算
* - 颜色变浅/变深处理
* - Element Plus 主题色自动生成
* - 颜色格式验证
* - CSS 变量读取
* - 暗黑模式颜色适配
*
* ## 使用场景
*
* - 主题色动态切换
* - Element Plus 组件主题定制
* - 颜色渐变生成
* - 明暗主题颜色计算
* - 颜色格式标准化
*
* ## 核心功能
*
* - hexToRgba: Hex 转 RGBA支持透明度
* - hexToRgb: Hex 转 RGB 数组
* - rgbToHex: RGB 转 Hex
* - colourBlend: 两种颜色混合
* - getLightColor: 生成变浅的颜色
* - getDarkColor: 生成变深的颜色
* - handleElementThemeColor: 处理 Element Plus 主题色
* - setElementThemeColor: 设置完整的主题色系统
*
* ## 支持格式
*
* - Hex: #FFF, #FFFFFF
* - RGB: rgb(255, 255, 255)
* - RGBA: rgba(255, 255, 255, 0.5)
*
* @module utils/ui/colors
* @author Art Design Pro Team
*/
import { useSettingStore } from '@/store/modules/setting'
/**
* 颜色转换结果接口
*/
interface RgbaResult {
red: number
green: number
blue: number
rgba: string
}
/**
* 获取CSS变量值别名函数
* @param name CSS变量名
* @returns CSS变量值
*/
export function getCssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name)
}
/**
* 验证hex颜色格式
* @param hex hex颜色值
* @returns 是否为有效的hex颜色
*/
function isValidHexColor(hex: string): boolean {
const cleanHex = hex.trim().replace(/^#/, '')
return /^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(cleanHex)
}
/**
* 验证RGB颜色值
* @param r 红色值
* @param g 绿色值
* @param b 蓝色值
* @returns 是否为有效的RGB值
*/
function isValidRgbValue(r: number, g: number, b: number): boolean {
const isValid = (value: number) => Number.isInteger(value) && value >= 0 && value <= 255
return isValid(r) && isValid(g) && isValid(b)
}
/**
* 将hex颜色转换为RGBA
* @param hex hex颜色值 (支持 #FFF 或 #FFFFFF 格式)
* @param opacity 透明度 (0-1)
* @returns 包含RGB值和RGBA字符串的对象
*/
export function hexToRgba(hex: string, opacity: number): RgbaResult {
if (!isValidHexColor(hex)) {
throw new Error('Invalid hex color format')
}
// 移除可能存在的 # 前缀并转换为大写
let cleanHex = hex.trim().replace(/^#/, '').toUpperCase()
// 如果是缩写形式(如 FFF转换为完整形式
if (cleanHex.length === 3) {
cleanHex = cleanHex
.split('')
.map((char) => char.repeat(2))
.join('')
}
// 解析 RGB 值
const [red, green, blue] = cleanHex.match(/\w\w/g)!.map((x) => parseInt(x, 16))
// 确保 opacity 在有效范围内
const validOpacity = Math.max(0, Math.min(1, opacity))
// 构建 RGBA 字符串
const rgba = `rgba(${red}, ${green}, ${blue}, ${validOpacity.toFixed(2)})`
return { red, green, blue, rgba }
}
/**
* 将hex颜色转换为RGB数组
* @param hexColor hex颜色值
* @returns RGB数组 [r, g, b]
*/
export function hexToRgb(hexColor: string): number[] {
if (!isValidHexColor(hexColor)) {
ElMessage.warning('输入错误的hex颜色值')
throw new Error('Invalid hex color format')
}
const cleanHex = hexColor.replace(/^#/, '')
let hex = cleanHex
// 处理缩写形式
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char.repeat(2))
.join('')
}
const hexPairs = hex.match(/../g)
if (!hexPairs) {
throw new Error('Invalid hex color format')
}
return hexPairs.map((hexPair) => parseInt(hexPair, 16))
}
/**
* 将RGB颜色转换为hex
* @param r 红色值 (0-255)
* @param g 绿色值 (0-255)
* @param b 蓝色值 (0-255)
* @returns hex颜色值
*/
export function rgbToHex(r: number, g: number, b: number): string {
if (!isValidRgbValue(r, g, b)) {
ElMessage.warning('输入错误的RGB颜色值')
throw new Error('Invalid RGB color values')
}
const toHex = (value: number) => {
const hex = value.toString(16)
return hex.length === 1 ? `0${hex}` : hex
}
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
}
/**
* 颜色混合
* @param color1 第一个颜色
* @param color2 第二个颜色
* @param ratio 混合比例 (0-1)
* @returns 混合后的颜色
*/
export function colourBlend(color1: string, color2: string, ratio: number): string {
const validRatio = Math.max(0, Math.min(1, Number(ratio)))
const rgb1 = hexToRgb(color1)
const rgb2 = hexToRgb(color2)
const blendedRgb = rgb1.map((value1, index) => {
const value2 = rgb2[index]
return Math.round(value1 * (1 - validRatio) + value2 * validRatio)
})
return rgbToHex(blendedRgb[0], blendedRgb[1], blendedRgb[2])
}
/**
* 获取变浅的颜色
* @param color 原始颜色
* @param level 变浅程度 (0-1)
* @param isDark 是否为暗色主题
* @returns 变浅后的颜色
*/
export function getLightColor(color: string, level: number, isDark: boolean = false): string {
if (!isValidHexColor(color)) {
ElMessage.warning('输入错误的hex颜色值')
throw new Error('Invalid hex color format')
}
if (isDark) {
return getDarkColor(color, level)
}
const rgb = hexToRgb(color)
const lightRgb = rgb.map((value) => Math.floor((255 - value) * level + value))
return rgbToHex(lightRgb[0], lightRgb[1], lightRgb[2])
}
/**
* 获取变深的颜色
* @param color 原始颜色
* @param level 变深程度 (0-1)
* @returns 变深后的颜色
*/
export function getDarkColor(color: string, level: number): string {
if (!isValidHexColor(color)) {
ElMessage.warning('输入错误的hex颜色值')
throw new Error('Invalid hex color format')
}
const rgb = hexToRgb(color)
const darkRgb = rgb.map((value) => Math.floor(value * (1 - level)))
return rgbToHex(darkRgb[0], darkRgb[1], darkRgb[2])
}
/**
* 处理 Element Plus 主题颜色
* @param theme 主题颜色
* @param isDark 是否为暗色主题
*/
export function handleElementThemeColor(theme: string, isDark: boolean = false): void {
document.documentElement.style.setProperty('--el-color-primary', theme)
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-light-${i}`,
getLightColor(theme, i / 10, isDark)
)
}
for (let i = 1; i <= 9; i++) {
document.documentElement.style.setProperty(
`--el-color-primary-dark-${i}`,
getDarkColor(theme, i / 10)
)
}
}
/**
* 设置 Element Plus 主题颜色
* @param color 主题颜色
*/
export function setElementThemeColor(color: string): void {
const mixColor = '#ffffff'
const elStyle = document.documentElement.style
elStyle.setProperty('--el-color-primary', color)
handleElementThemeColor(color, useSettingStore().isDark)
// 生成更淡一点的颜色
for (let i = 1; i < 16; i++) {
const itemColor = colourBlend(color, mixColor, i / 16)
elStyle.setProperty(`--el-color-primary-custom-${i}`, itemColor)
}
}

View File

@@ -0,0 +1,24 @@
/**
* 表情
* 用于在消息提示的时候显示对应的表情
*
* 用法
* ElMessage.success(`${EmojiText[200]} 图片上传成功`)
* ElMessage.error(`${EmojiText[400]} 图片上传失败`)
* ElMessage.error(`${EmojiText[500]} 图片上传失败`)
*
* @module utils/ui/emojo
* @author Art Design Pro Team
*/
// macos 用户 按 shift + 6 可以唤出更多表情……
const EmojiText: { [key: string]: string } = {
'0': 'O_O', // 空
'200': '^_^', // 成功
'400': 'T_T', // 错误请求
'500': 'X_X' // 服务器内部错误,无法完成请求
}
// const EmojiIcon = ['🟢', '🔴', '🟡 ', '🚀', '✨', '💡', '🛠️', '🔥', '🎉', '🌟', '🌈']
export default EmojiText

View File

@@ -0,0 +1,31 @@
/**
* 离线图标加载器
*
* 用于在内网环境下支持 Iconify 图标的离线加载。
* 通过预加载图标集数据,避免运行时从 CDN 获取图标。
*
* 使用方式:
* 1. 安装所需图标集pnpm add -D @iconify-json/[icon-set-name]
* 2. 在此文件中导入并注册图标集
* 3. 在组件中使用:<ArtSvgIcon icon="ri:home-line" />
*
* @module utils/ui/iconify-loader
* @author Art Design Pro Team
*/
// import { addCollection } from '@iconify/vue'
// // 导入离线图标数据
// // 系统必要图标库
// import riIcons from '@iconify-json/ri/icons.json'
// // 演示图标库(可选,生产环境可移除)
// import svgSpinners from '@iconify-json/svg-spinners/icons.json'
// import lineMd from '@iconify-json/line-md/icons.json'
// // 注册离线图标集
// addCollection(riIcons)
// addCollection(svgSpinners)
// addCollection(lineMd)

View File

@@ -0,0 +1,11 @@
/**
* UI 相关工具函数统一导出
*
* @module utils/ui/index
* @author Art Design Pro Team
*/
export * from './colors'
export * from './loading'
export * from './tabs'
export * from './emojo'

View File

@@ -0,0 +1,84 @@
/**
* 全局 Loading 加载管理模块
*
* 提供统一的全屏加载动画管理
*
* ## 主要功能
*
* - 全屏 Loading 显示和隐藏
* - 自动适配明暗主题背景色
* - 自定义 SVG 加载动画
* - 单例模式防止重复创建
* - 锁定页面交互
*
* ## 使用场景
*
* - 页面初始化加载
* - 大量数据请求
* - 路由切换过渡
* - 异步操作等待
*
* ## 特性
*
* - 自动检测当前主题并应用对应背景色
* - 使用自定义 SVG 动画(四点旋转)
* - 单例模式确保同时只有一个 Loading
* - 提供便捷的显示/隐藏方法
*
* @module utils/ui/loading
* @author Art Design Pro Team
*/
import { fourDotsSpinnerSvg } from '@/assets/svg/loading'
/**
* 获取当前主题对应的loading背景色
* @returns 背景色字符串
*/
const getLoadingBackground = (): string => {
const isDark = document.documentElement.classList.contains('dark')
return isDark ? 'rgba(7, 7, 7, 0.85)' : '#fff'
}
const DEFAULT_LOADING_CONFIG = {
lock: true,
get background() {
return getLoadingBackground()
},
svg: fourDotsSpinnerSvg,
svgViewBox: '0 0 40 40',
customClass: 'art-loading-fix'
} as const
interface LoadingInstance {
close: () => void
}
let loadingInstance: LoadingInstance | null = null
export const loadingService = {
/**
* 显示 loading
* @returns 关闭 loading 的函数
*/
showLoading(): () => void {
if (!loadingInstance) {
// 每次显示时获取最新的配置,确保背景色与当前主题同步
const config = {
...DEFAULT_LOADING_CONFIG,
background: getLoadingBackground()
}
loadingInstance = ElLoading.service(config)
}
return () => this.hideLoading()
},
/**
* 隐藏 loading
*/
hideLoading(): void {
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
}

View File

@@ -0,0 +1,60 @@
/**
* 标签页布局配置模块
*
* 提供不同标签页样式的高度和间距配置
*
* ## 主要功能
*
* - 多种标签页样式配置(默认、卡片、谷歌风格)
* - 标签页打开/关闭状态的高度管理
* - 顶部间距自动计算
* - 配置获取和默认值处理
*
* ## 使用场景
*
* - 工作标签页Worktab布局计算
* - 页面内容区域高度调整
* - 标签页显示/隐藏时的动画
* - 响应式布局适配
*
* ## 配置项说明
*
* - openTop: 标签页显示时,内容区域距离顶部的距离
* - closeTop: 标签页隐藏时,内容区域距离顶部的距离
* - openHeight: 标签页显示时的总高度(包含标签栏)
* - closeHeight: 标签页隐藏时的总高度(仅头部)
*
* ## 支持的样式
*
* - tab-default: 默认标签页样式
* - tab-card: 卡片式标签页
* - tab-google: 谷歌浏览器风格标签页
*
* @module utils/ui/tabs
* @author Art Design Pro Team
*/
export const TAB_CONFIG = {
'tab-default': {
openTop: 106,
closeTop: 60,
openHeight: 121,
closeHeight: 75
},
'tab-card': {
openTop: 122,
closeTop: 78,
openHeight: 139,
closeHeight: 95
},
'tab-google': {
openTop: 122,
closeTop: 78,
openHeight: 139,
closeHeight: 95
}
}
// 获取当前 tab 样式配置,设置默认值
export const getTabConfig = (style: string) => {
return TAB_CONFIG[style as keyof typeof TAB_CONFIG] || TAB_CONFIG['tab-card'] // 默认使用 tab-card 配置
}

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('forgetPassword.title') }}</h3>
<p class="sub-title">{{ $t('forgetPassword.subTitle') }}</p>
<div class="mt-5">
<span class="input-label" v-if="showInputLabel">账号</span>
<ElInput
class="custom-height"
:placeholder="$t('forgetPassword.placeholder')"
v-model.trim="username"
/>
</div>
<div style="margin-top: 15px">
<ElButton
class="w-full custom-height"
type="primary"
@click="register"
:loading="loading"
v-ripple
>
{{ $t('forgetPassword.submitBtnText') }}
</ElButton>
</div>
<div style="margin-top: 15px">
<ElButton class="w-full custom-height" plain @click="toLogin">
{{ $t('forgetPassword.backBtnText') }}
</ElButton>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'ForgetPassword' })
const router = useRouter()
const showInputLabel = ref(false)
const username = ref('')
const loading = ref(false)
const register = async () => {}
const toLogin = () => {
router.push({ name: 'Login' })
}
</script>
<style scoped>
@import '../login/style.css';
</style>

View File

@@ -0,0 +1,216 @@
<!-- 登录页面 -->
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('login.title') }}</h3>
<p class="sub-title">{{ $t('login.subTitle') }}</p>
<ElForm
ref="formRef"
:model="formData"
:rules="rules"
:key="formKey"
@keyup.enter="handleSubmit"
style="margin-top: 25px"
>
<ElFormItem prop="username">
<ElInput
class="custom-height"
:placeholder="$t('login.placeholder.username')"
v-model.trim="formData.username"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
class="custom-height"
:placeholder="$t('login.placeholder.password')"
v-model.trim="formData.password"
type="password"
autocomplete="off"
show-password
/>
</ElFormItem>
<ElFormItem prop="code">
<ElInput
class="custom-height"
:placeholder="$t('login.placeholder.code')"
v-model.trim="formData.code"
type="text"
autocomplete="off"
>
<template #append>
<img
:src="captcha"
style="height: 36px; cursor: pointer"
@click="refreshCaptcha"
/>
</template>
</ElInput>
</ElFormItem>
<div class="flex-cb mt-2 text-sm">
<ElCheckbox v-model="formData.rememberPassword">{{
$t('login.rememberPwd')
}}</ElCheckbox>
<!-- <RouterLink class="text-theme" :to="{ name: 'ForgetPassword' }">{{
$t('login.forgetPwd')
}}</RouterLink> -->
</div>
<div style="margin-top: 30px">
<ElButton
class="w-full custom-height"
type="primary"
@click="handleSubmit"
:loading="loading"
v-ripple
>
{{ $t('login.btnText') }}
</ElButton>
</div>
<!-- <div class="mt-5 text-sm text-gray-600">
<span>{{ $t('login.noAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Register' }">{{
$t('login.register')
}}</RouterLink>
</div> -->
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
import { useUserStore } from '@/store/modules/user'
import { useI18n } from 'vue-i18n'
import { HttpError } from '@/utils/http/error'
import { fetchCaptcha, fetchLogin, fetchGetUserInfo } from '@/api/auth'
import { ElNotification, type FormInstance, type FormRules } from 'element-plus'
defineOptions({ name: 'Login' })
const { t, locale } = useI18n()
const formKey = ref(0)
// 监听语言切换,重置表单
watch(locale, () => {
formKey.value++
})
const userStore = useUserStore()
const router = useRouter()
const captcha = ref(
'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
)
const systemName = AppConfig.systemInfo.name
const formRef = ref<FormInstance>()
const formData = reactive({
username: '',
password: '',
code: '',
uuid: '',
rememberPassword: true
})
const rules = computed<FormRules>(() => ({
username: [{ required: true, message: t('login.placeholder.username'), trigger: 'blur' }],
password: [{ required: true, message: t('login.placeholder.password'), trigger: 'blur' }],
code: [{ required: true, message: t('login.placeholder.code'), trigger: 'blur' }]
}))
const loading = ref(false)
onMounted(() => {
refreshCaptcha()
})
// 登录
const handleSubmit = async () => {
if (!formRef.value) return
try {
// 表单验证
const valid = await formRef.value.validate()
if (!valid) return
loading.value = true
// 登录请求
const { access_token, refresh_token } = await fetchLogin({
username: formData.username,
password: formData.password,
code: formData.code,
uuid: formData.uuid
})
// 验证token
if (!access_token) {
throw new Error('Login failed - no token received')
}
// 存储token和用户信息
userStore.setToken(access_token, refresh_token)
const userInfo = await fetchGetUserInfo()
userStore.setUserInfo(userInfo)
userStore.setLoginStatus(true)
// 登录成功处理
showLoginSuccessNotice()
router.push('/')
} catch (error) {
// 处理 HttpError
if (error instanceof HttpError) {
// console.log(error.code)
} else {
// 处理非 HttpError
// ElMessage.error('登录失败,请稍后重试')
console.error('[Login] Unexpected error:', error)
}
} finally {
refreshCaptcha()
loading.value = false
}
}
// 获取验证码
const refreshCaptcha = async () => {
fetchCaptcha().then((res) => {
formData.uuid = res.uuid
captcha.value = res.image
})
}
// 登录成功提示
const showLoginSuccessNotice = () => {
setTimeout(() => {
ElNotification({
title: t('login.success.title'),
type: 'success',
duration: 2500,
zIndex: 10000,
message: `${t('login.success.message')}, ${systemName}!`
})
}, 150)
}
</script>
<style scoped>
@import './style.css';
</style>
<style lang="scss" scoped>
:deep(.el-select__wrapper) {
height: 40px !important;
}
</style>

View File

@@ -0,0 +1,38 @@
@reference '@styles/core/tailwind.css';
/* 授权页右侧区域 */
.auth-right-wrap {
@apply absolute inset-0 w-[440px] h-[650px] py-[5px] m-auto overflow-hidden
max-sm:px-7 max-sm:w-full
animate-[slideInRight_0.6s_cubic-bezier(0.25,0.46,0.45,0.94)_forwards]
max-md:animate-none;
.form {
@apply h-full py-[40px];
}
.title {
@apply text-g-900 text-4xl font-semibold max-md:text-3xl max-sm:pt-10;
}
.sub-title {
@apply mt-[10px] text-g-600 text-sm;
}
.custom-height {
@apply !h-[40px];
}
}
/* 滑入动画 */
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(30px);
}
to {
opacity: 1;
transform: translateX(0);
}
}

View File

@@ -0,0 +1,240 @@
<!-- 注册页面 -->
<template>
<div class="flex w-full h-screen">
<LoginLeftView />
<div class="relative flex-1">
<AuthTopBar />
<div class="auth-right-wrap">
<div class="form">
<h3 class="title">{{ $t('register.title') }}</h3>
<p class="sub-title">{{ $t('register.subTitle') }}</p>
<ElForm
class="mt-7.5"
ref="formRef"
:model="formData"
:rules="rules"
label-position="top"
:key="formKey"
>
<ElFormItem prop="username">
<ElInput
class="custom-height"
v-model.trim="formData.username"
:placeholder="$t('register.placeholder.username')"
/>
</ElFormItem>
<ElFormItem prop="password">
<ElInput
class="custom-height"
v-model.trim="formData.password"
:placeholder="$t('register.placeholder.password')"
type="password"
autocomplete="off"
show-password
/>
</ElFormItem>
<ElFormItem prop="confirmPassword">
<ElInput
class="custom-height"
v-model.trim="formData.confirmPassword"
:placeholder="$t('register.placeholder.confirmPassword')"
type="password"
autocomplete="off"
@keyup.enter="register"
show-password
/>
</ElFormItem>
<ElFormItem prop="agreement">
<ElCheckbox v-model="formData.agreement">
{{ $t('register.agreeText') }}
<RouterLink
style="color: var(--theme-color); text-decoration: none"
to="/privacy-policy"
>{{ $t('register.privacyPolicy') }}</RouterLink
>
</ElCheckbox>
</ElFormItem>
<div style="margin-top: 15px">
<ElButton
class="w-full custom-height"
type="primary"
@click="register"
:loading="loading"
v-ripple
>
{{ $t('register.submitBtnText') }}
</ElButton>
</div>
<div class="mt-5 text-sm text-g-600">
<span>{{ $t('register.hasAccount') }}</span>
<RouterLink class="text-theme" :to="{ name: 'Login' }">{{
$t('register.toLogin')
}}</RouterLink>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'Register' })
interface RegisterForm {
username: string
password: string
confirmPassword: string
agreement: boolean
}
const USERNAME_MIN_LENGTH = 3
const USERNAME_MAX_LENGTH = 20
const PASSWORD_MIN_LENGTH = 6
const REDIRECT_DELAY = 1000
const { t, locale } = useI18n()
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const formKey = ref(0)
// 监听语言切换,重置表单
watch(locale, () => {
formKey.value++
})
const formData = reactive<RegisterForm>({
username: '',
password: '',
confirmPassword: '',
agreement: false
})
/**
* 验证密码
* 当密码输入后,如果确认密码已填写,则触发确认密码的验证
*/
const validatePassword = (_rule: any, value: string, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error(t('register.placeholder.password')))
return
}
if (formData.confirmPassword) {
formRef.value?.validateField('confirmPassword')
}
callback()
}
/**
* 验证确认密码
* 检查确认密码是否与密码一致
*/
const validateConfirmPassword = (
_rule: any,
value: string,
callback: (error?: Error) => void
) => {
if (!value) {
callback(new Error(t('register.rule.confirmPasswordRequired')))
return
}
if (value !== formData.password) {
callback(new Error(t('register.rule.passwordMismatch')))
return
}
callback()
}
/**
* 验证用户协议
* 确保用户已勾选同意协议
*/
const validateAgreement = (_rule: any, value: boolean, callback: (error?: Error) => void) => {
if (!value) {
callback(new Error(t('register.rule.agreementRequired')))
return
}
callback()
}
const rules = computed<FormRules<RegisterForm>>(() => ({
username: [
{ required: true, message: t('register.placeholder.username'), trigger: 'blur' },
{
min: USERNAME_MIN_LENGTH,
max: USERNAME_MAX_LENGTH,
message: t('register.rule.usernameLength'),
trigger: 'blur'
}
],
password: [
{ required: true, validator: validatePassword, trigger: 'blur' },
{ min: PASSWORD_MIN_LENGTH, message: t('register.rule.passwordLength'), trigger: 'blur' }
],
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: 'blur' }],
agreement: [{ validator: validateAgreement, trigger: 'change' }]
}))
/**
* 注册用户
* 验证表单后提交注册请求
*/
const register = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
loading.value = true
// TODO: 替换为真实 API 调用
// const params = {
// username: formData.username,
// password: formData.password
// }
// const res = await AuthService.register(params)
// if (res.code === ApiStatus.success) {
// ElMessage.success('注册成功')
// toLogin()
// }
// 模拟注册请求
setTimeout(() => {
loading.value = false
ElMessage.success('注册成功')
toLogin()
}, REDIRECT_DELAY)
} catch (error) {
console.error('表单验证失败:', error)
loading.value = false
}
}
/**
* 跳转到登录页面
*/
const toLogin = () => {
setTimeout(() => {
router.push({ name: 'Login' })
}, REDIRECT_DELAY)
}
</script>
<style scoped>
@import '../login/style.css';
</style>

View File

@@ -0,0 +1,53 @@
<!-- 工作台页面 -->
<template>
<div>
<template v-if="userInfo.dashboard === 'statistics'">
<CardList></CardList>
<ElRow :gutter="20">
<ElCol :sm="24" :md="12" :lg="10">
<ActiveUser />
</ElCol>
<ElCol :sm="24" :md="12" :lg="14">
<SalesOverview />
</ElCol>
</ElRow>
</template>
<template v-if="userInfo.dashboard === 'work'">
<ElRow :gutter="20">
<ElCol :sm="24" :md="24" :lg="12">
<NewUser />
</ElCol>
<ElCol :sm="24" :md="12" :lg="6">
<Dynamic />
</ElCol>
<ElCol :sm="24" :md="12" :lg="6">
<TodoList />
</ElCol>
</ElRow>
</template>
<AboutProject />
</div>
</template>
<script setup lang="ts">
import CardList from './modules/card-list.vue'
import ActiveUser from './modules/active-user.vue'
import SalesOverview from './modules/sales-overview.vue'
import AboutProject from './modules/about-project.vue'
import NewUser from './modules/new-user.vue'
import Dynamic from './modules/dynamic-stats.vue'
import TodoList from './modules/todo-list.vue'
import { useCommon } from '@/hooks/core/useCommon'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'Console' })
const userStore = useUserStore()
const userInfo = userStore.getUserInfo
const { scrollToTop } = useCommon()
scrollToTop()
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div class="art-card p-5 flex-b mb-5 max-sm:mb-4">
<div>
<h2 class="text-2xl font-medium">关于项目</h2>
<p class="text-g-700 mt-1">{{ systemName }} 是一款兼具设计美学与高效开发的后台系统</p>
<p class="text-g-700 mt-1">使用了 webman + Vue3 + Element Plus 高性能高颜值技术栈</p>
<div class="flex flex-wrap gap-3.5 max-w-150 mt-9">
<div
class="w-60 flex-cb h-12.5 px-3.5 border border-g-300 c-p rounded-lg text-sm bg-g-100 duration-300 hover:-translate-y-1 max-sm:w-full"
v-for="link in linkList"
:key="link.label"
@click="goPage(link.url)"
>
<span class="text-g-700">{{ link.label }}</span>
<ArtSvgIcon icon="ri:arrow-right-s-line" class="text-lg text-g-600" />
</div>
</div>
</div>
<img class="w-75 max-md:!hidden" src="@imgs/draw/draw1.png" alt="draw1" />
</div>
</template>
<script setup lang="ts">
import AppConfig from '@/config'
const systemName = AppConfig.systemInfo.name
const linkList = [
{ label: '项目官网', url: 'https://saithink.top/' },
{ label: '文档', url: 'https://saithink.top/documents/' },
{ label: 'Github', url: 'https://github.com/saithink/saiadmin' },
{ label: '插件市场', url: 'https://saas.saithink.top/' }
]
/**
* 在新标签页中打开指定 URL
* @param url 要打开的网页地址
*/
const goPage = (url: string): void => {
window.open(url, '_blank', 'noopener,noreferrer')
}
</script>

View File

@@ -0,0 +1,38 @@
<template>
<div class="art-card h-105 p-4 box-border mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>月度登录汇总</h4>
</div>
</div>
<ArtBarChart
class="box-border p-2"
barWidth="50%"
height="calc(100% - 40px)"
:showAxisLine="false"
:data="yData"
:xAxisData="xData"
/>
</div>
</template>
<script setup lang="ts">
import { fetchLoginBarChart } from '@/api/dashboard'
/**
* 登录数据
*/
const yData = ref<number[]>([])
/**
* 时间数据
*/
const xData = ref<string[]>([])
onMounted(async () => {
fetchLoginBarChart().then((data) => {
yData.value = data.login_count
xData.value = data.login_month
})
})
</script>

View File

@@ -0,0 +1,93 @@
<template>
<ElRow :gutter="20" class="flex">
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<span class="text-g-700 text-sm">用户统计</span>
<ArtCountTo class="text-[26px] font-medium mt-2" :target="statData.user" :duration="1300" />
<div class="flex-c mt-1">
<span class="text-xs text-g-600">较上周</span>
<span class="ml-1 text-xs font-semibold text-success">+10%</span>
</div>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
<ArtSvgIcon icon="ri:group-line" class="text-xl text-theme" />
</div>
</div>
</ElCol>
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<span class="text-g-700 text-sm">附件统计</span>
<ArtCountTo
class="text-[26px] font-medium mt-2"
:target="statData.attach"
:duration="1300"
/>
<div class="flex-c mt-1">
<span class="text-xs text-g-600">较上周</span>
<span class="ml-1 text-xs font-semibold text-success">+10%</span>
</div>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
<ArtSvgIcon icon="ri:attachment-line" class="text-xl text-theme" />
</div>
</div>
</ElCol>
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<span class="text-g-700 text-sm">登录统计</span>
<ArtCountTo
class="text-[26px] font-medium mt-2"
:target="statData.login"
:duration="1300"
/>
<div class="flex-c mt-1">
<span class="text-xs text-g-600">较上周</span>
<span class="ml-1 text-xs font-semibold text-success">+12%</span>
</div>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
<ArtSvgIcon icon="ri:fire-line" class="text-xl text-theme" />
</div>
</div>
</ElCol>
<ElCol :sm="12" :md="6" :lg="6">
<div class="art-card relative flex flex-col justify-center h-35 px-5 mb-5 max-sm:mb-4">
<span class="text-g-700 text-sm">操作统计</span>
<ArtCountTo
class="text-[26px] font-medium mt-2"
:target="statData.operate"
:duration="1300"
/>
<div class="flex-c mt-1">
<span class="text-xs text-g-600">较上周</span>
<span class="ml-1 text-xs font-semibold text-danger">-5%</span>
</div>
<div
class="absolute top-0 bottom-0 right-5 m-auto size-12.5 rounded-xl flex-cc bg-theme/10"
>
<ArtSvgIcon icon="ri:pie-chart-line" class="text-xl text-theme" />
</div>
</div>
</ElCol>
</ElRow>
</template>
<script setup lang="ts">
import { fetchStatistics } from '@/api/dashboard'
const statData = ref({
user: 0,
attach: 0,
login: 0,
operate: 0
})
onMounted(() => {
fetchStatistics().then((data) => {
statData.value = data
})
})
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="art-card h-128 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>动态</h4>
<p>新增<span class="text-success">+6</span></p>
</div>
</div>
<div class="h-9/10 mt-2 overflow-hidden">
<ElScrollbar>
<div
class="h-17.5 leading-17.5 border-b border-g-300 text-sm overflow-hidden last:border-b-0"
v-for="(item, index) in list"
:key="index"
>
<span class="text-g-800 font-medium">{{ item.username }}</span>
<span class="mx-2 text-g-600">{{ item.type }}</span>
<span class="text-theme">{{ item.target }}</span>
</div>
</ElScrollbar>
</div>
</div>
</template>
<script setup lang="ts">
interface DynamicItem {
username: string
type: string
target: string
}
/**
* 用户动态列表
* 记录用户的关注、发文、提问、兑换等各类活动
*/
const list = reactive<DynamicItem[]>([
{
username: '中小鱼',
type: '关注了',
target: '誶誶淰'
},
{
username: '何小荷',
type: '发表文章',
target: 'Vue3 + Typescript + Vite 项目实战笔记'
},
{
username: '中小鱼',
type: '关注了',
target: '誶誶淰'
},
{
username: '何小荷',
type: '发表文章',
target: 'Vue3 + Typescript + Vite 项目实战笔记'
},
{
username: '誶誶淰',
type: '提出问题',
target: '主题可以配置吗'
},
{
username: '发呆草',
type: '兑换了物品',
target: '《奇特的一生》'
},
{
username: '甜筒',
type: '关闭了问题',
target: '发呆草'
},
{
username: '冷月呆呆',
type: '兑换了物品',
target: '《高效人士的七个习惯》'
}
])
</script>

View File

@@ -0,0 +1,169 @@
<template>
<div class="art-card p-5 h-128 overflow-hidden mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>新用户</h4>
<p>这个月增长<span class="text-success">+20%</span></p>
</div>
<ElRadioGroup v-model="radio2">
<ElRadioButton value="本月" label="本月"></ElRadioButton>
<ElRadioButton value="上月" label="上月"></ElRadioButton>
<ElRadioButton value="今年" label="今年"></ElRadioButton>
</ElRadioGroup>
</div>
<ArtTable
class="w-full"
:data="tableData"
style="width: 100%"
size="large"
:border="false"
:stripe="false"
:header-cell-style="{ background: 'transparent' }"
>
<template #default>
<ElTableColumn label="头像" prop="avatar" width="150px">
<template #default="scope">
<div style="display: flex; align-items: center">
<img class="size-9 rounded-lg" :src="scope.row.avatar" alt="avatar" />
<span class="ml-2">{{ scope.row.username }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="地区" prop="province" />
<ElTableColumn label="性别" prop="avatar">
<template #default="scope">
<div style="display: flex; align-items: center">
<span style="margin-left: 10px">{{ scope.row.sex === 1 ? '男' : '女' }}</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="进度" width="240">
<template #default="scope">
<ElProgress
:percentage="scope.row.pro"
:color="scope.row.color"
:stroke-width="4"
:aria-label="`${scope.row.username}的完成进度: ${scope.row.pro}%`"
/>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
</template>
<script setup lang="ts">
import avatar1 from '@/assets/images/avatar/avatar1.webp'
import avatar2 from '@/assets/images/avatar/avatar2.webp'
import avatar3 from '@/assets/images/avatar/avatar3.webp'
import avatar4 from '@/assets/images/avatar/avatar4.webp'
import avatar5 from '@/assets/images/avatar/avatar5.webp'
import avatar6 from '@/assets/images/avatar/avatar6.webp'
interface UserTableItem {
username: string
province: string
sex: 0 | 1
age: number
percentage: number
pro: number
color: string
avatar: string
}
const ANIMATION_DELAY = 100
const radio2 = ref('本月')
/**
* 新用户表格数据
* 包含用户基本信息和完成进度
*/
const tableData = reactive<UserTableItem[]>([
{
username: '中小鱼',
province: '北京',
sex: 0,
age: 22,
percentage: 60,
pro: 0,
color: 'var(--art-primary)',
avatar: avatar1
},
{
username: '何小荷',
province: '深圳',
sex: 1,
age: 21,
percentage: 20,
pro: 0,
color: 'var(--art-secondary)',
avatar: avatar2
},
{
username: '誶誶淰',
province: '上海',
sex: 1,
age: 23,
percentage: 60,
pro: 0,
color: 'var(--art-warning)',
avatar: avatar3
},
{
username: '发呆草',
province: '长沙',
sex: 0,
age: 28,
percentage: 50,
pro: 0,
color: 'var(--art-info)',
avatar: avatar4
},
{
username: '甜筒',
province: '浙江',
sex: 1,
age: 26,
percentage: 70,
pro: 0,
color: 'var(--art-error)',
avatar: avatar5
},
{
username: '冷月呆呆',
province: '湖北',
sex: 1,
age: 25,
percentage: 90,
pro: 0,
color: 'var(--art-success)',
avatar: avatar6
}
])
/**
* 添加进度条动画效果
* 延迟后将进度值从 0 更新到目标百分比,触发动画
*/
const addAnimation = (): void => {
setTimeout(() => {
tableData.forEach((item) => {
item.pro = item.percentage
})
}, ANIMATION_DELAY)
}
onMounted(() => {
addAnimation()
})
</script>
<style lang="scss" scoped>
.art-card {
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
color: var(--el-color-primary) !important;
background: transparent !important;
}
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="art-card h-105 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>近期登录统计</h4>
</div>
</div>
<ArtLineChart
height="calc(100% - 40px)"
:data="yData"
:xAxisData="xData"
:showAreaColor="true"
:showAxisLine="false"
/>
</div>
</template>
<script setup lang="ts">
import { fetchLoginChart } from '@/api/dashboard'
/**
* 登录数据
*/
const yData = ref<number[]>([])
/**
* 时间数据
*/
const xData = ref<string[]>([])
onMounted(async () => {
fetchLoginChart().then((data) => {
yData.value = data.login_count
xData.value = data.login_date
})
})
</script>

View File

@@ -0,0 +1,71 @@
<template>
<div class="art-card h-128 p-5 mb-5 max-sm:mb-4">
<div class="art-card-header">
<div class="title">
<h4>代办事项</h4>
<p>待处理<span class="text-danger">3</span></p>
</div>
</div>
<div class="h-[calc(100%-40px)] overflow-auto">
<ElScrollbar>
<div
class="flex-cb h-17.5 border-b border-g-300 text-sm last:border-b-0"
v-for="(item, index) in list"
:key="index"
>
<div>
<p class="text-sm">{{ item.username }}</p>
<p class="text-g-500 mt-1">{{ item.date }}</p>
</div>
<ElCheckbox v-model="item.complate" />
</div>
</ElScrollbar>
</div>
</div>
</template>
<script setup lang="ts">
interface TodoItem {
username: string
date: string
complate: boolean
}
/**
* 待办事项列表
* 记录每日工作任务及完成状态
*/
const list = reactive<TodoItem[]>([
{
username: '查看今天工作内容',
date: '上午 09:30',
complate: true
},
{
username: '回复邮件',
date: '上午 10:30',
complate: true
},
{
username: '工作汇报整理',
date: '上午 11:00',
complate: true
},
{
username: '产品需求会议',
date: '下午 02:00',
complate: false
},
{
username: '整理会议内容',
date: '下午 03:30',
complate: false
},
{
username: '明天工作计划',
date: '下午 06:30',
complate: false
}
])
</script>

View File

@@ -0,0 +1,395 @@
<!-- 个人中心页面 -->
<template>
<div class="w-full h-full p-0 bg-transparent border-none shadow-none">
<div class="relative flex-b mt-2.5 max-md:block max-md:mt-1">
<div class="w-112 mr-5 max-md:w-full max-md:mr-0">
<div class="art-card-sm relative p-9 pb-6 overflow-hidden text-center">
<img
class="absolute top-0 left-0 w-full h-50 object-cover"
src="@imgs/user/user-bg.jpg"
/>
<SaImageUpload
class="w-20 h-20 mt-30 mx-auto"
:width="80"
:height="80"
:showTips="false"
v-model="avatar"
@change="handleAvatarChange"
round
/>
<h2 class="mt-5 text-xl font-normal">{{ userInfo.username }}</h2>
<div class="w-75 mx-auto mt-2.5 text-left">
<div class="mt-2.5">
<ArtSvgIcon icon="ri:user-line" class="text-g-700" />
<span class="ml-2 text-sm">{{ userInfo.realname }}</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon
:icon="userInfo.gender === '1' ? 'ri:men-line' : 'ri:women-line'"
class="text-g-700"
/>
<span class="ml-2 text-sm">{{ userInfo.gender === '1' ? '男' : '女' }}</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:mail-line" class="text-g-700" />
<span class="ml-2 text-sm">{{ userInfo.email }}</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:phone-line" class="text-g-700" />
<span class="ml-2 text-sm">{{ userInfo.phone }}</span>
</div>
<div class="mt-2.5">
<ArtSvgIcon icon="ri:dribbble-fill" class="text-g-700" />
<span class="ml-2 text-sm">{{ userInfo.department?.name }}</span>
</div>
</div>
</div>
<div class="art-card-sm py-5 h-128 my-5">
<div class="art-card-header border-b border-g-300">
<h1 class="p-4 text-xl font-normal">日志信息</h1>
<ElRadioGroup v-model="logType">
<ElRadioButton :value="1" label="登录日志"></ElRadioButton>
<ElRadioButton :value="2" label="操作日志"></ElRadioButton>
</ElRadioGroup>
</div>
<div class="mt-7.5">
<el-timeline class="pl-5 mt-3" v-if="logType === 1 && loginLogList.length > 0">
<el-timeline-item
v-for="(item, idx) in loginLogList"
:key="idx"
:timestamp="`您于 ${item.login_time} 登录系统`"
placement="top"
>
<div class="py-2 text-xs">
<span>地理位置{{ item.ip_location || '未知' }}</span>
<span class="ml-2">操作系统{{ item.os }}</span>
</div>
</el-timeline-item>
</el-timeline>
<el-timeline class="pl-5 mt-3" v-if="logType === 2 && operationLogList.length > 0">
<el-timeline-item
v-for="(item, idx) in operationLogList"
:key="idx"
:timestamp="`您于 ${item.create_time} 执行了 ${item.service_name}`"
placement="top"
>
<div class="py-2 text-xs">
<span>地理位置{{ item.ip_location || '未知' }}</span>
<span class="ml-2">路由{{ item.router }}</span>
</div>
</el-timeline-item>
</el-timeline>
</div>
</div>
</div>
<div class="flex-1 overflow-hidden max-md:w-full max-md:mt-3.5">
<div class="art-card-sm">
<h1 class="p-4 text-xl font-normal border-b border-g-300">基本设置</h1>
<ElForm
:model="form"
class="box-border p-5 [&>.el-row_.el-form-item]:w-[calc(50%-10px)] [&>.el-row_.el-input]:w-full [&>.el-row_.el-select]:w-full"
ref="ruleFormRef"
:rules="rules"
label-width="86px"
label-position="top"
>
<ElRow>
<ElFormItem label="姓名" prop="realname">
<ElInput v-model="form.realname" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="性别" prop="gender" class="ml-5">
<SaSelect
v-model="form.gender"
placeholder="请选择性别"
dict="gender"
valueType="string"
:disabled="!isEdit"
/>
</ElFormItem>
</ElRow>
<ElRow>
<ElFormItem label="邮箱" prop="email">
<ElInput v-model="form.email" :disabled="!isEdit" />
</ElFormItem>
<ElFormItem label="手机" prop="phone" class="ml-5">
<ElInput v-model="form.phone" :disabled="!isEdit" />
</ElFormItem>
</ElRow>
<ElFormItem label="个人介绍" prop="signed" class="h-32">
<ElInput type="textarea" :rows="4" v-model="form.signed" :disabled="!isEdit" />
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
<ElButton type="primary" class="w-22.5" v-ripple @click="edit">
{{ isEdit ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
<div class="art-card-sm h-128 my-5">
<h1 class="p-4 text-xl font-normal border-b border-g-300">更改密码</h1>
<ElForm
:model="pwdForm"
:rules="pwdRules"
ref="pwdFormRef"
class="box-border p-5"
label-width="86px"
label-position="top"
>
<ElFormItem label="当前密码" prop="oldPassword">
<ElInput
v-model="pwdForm.oldPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<ElFormItem label="新密码" prop="newPassword">
<ElInput
v-model="pwdForm.newPassword"
type="password"
:disabled="!isEditPwd"
show-password
@input="checkSafe"
/>
</ElFormItem>
<ElFormItem label="密码安全度" prop="passwordSafePercent">
<ElProgress
:percentage="passwordSafePercent"
:show-text="false"
class="w-full"
status="success"
:stroke-width="12"
/>
</ElFormItem>
<ElFormItem label="确认新密码" prop="confirmPassword">
<ElInput
v-model="pwdForm.confirmPassword"
type="password"
:disabled="!isEditPwd"
show-password
/>
</ElFormItem>
<div class="flex-c justify-end [&_.el-button]:!w-27.5">
<ElButton type="primary" class="w-22.5" v-ripple @click="editPwd">
{{ isEditPwd ? '保存' : '编辑' }}
</ElButton>
</div>
</ElForm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from '@/store/modules/user'
import { fetchGetLogin, fetchGetOperate, updateUserInfo, modifyPassword } from '@/api/auth'
import type { FormInstance, FormRules } from 'element-plus'
defineOptions({ name: 'UserCenter' })
const userStore = useUserStore()
const userInfo = computed(() => userStore.getUserInfo)
const isEdit = ref(false)
const isEditPwd = ref(false)
const date = ref('')
/**
* 用户信息表单
*/
const form = toReactive(userStore.info)
const ruleFormRef = ref<FormInstance>()
const pwdFormRef = ref<FormInstance>()
const passwordSafePercent = ref(0)
const avatar = ref('')
/**
* 密码修改表单
*/
const pwdForm = reactive({
oldPassword: '',
newPassword: '',
confirmPassword: ''
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
realname: [
{ required: true, message: '请输入姓名', trigger: 'blur' },
{ min: 2, max: 50, message: '长度在 2 到 50 个字符', trigger: 'blur' }
],
email: [{ required: true, message: '请输入邮箱', trigger: 'blur' }],
phone: [{ required: true, message: '请输入手机号码', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'blur' }]
})
const pwdRules = reactive<FormRules>({
oldPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
newPassword: [
{ required: true, message: '请输入新密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
],
confirmPassword: [{ required: true, message: '请确认新密码', trigger: 'blur' }]
})
const loginLogList = ref<Api.Common.ApiData[]>([])
const operationLogList = ref<Api.Common.ApiData[]>([])
const logType = ref(1) // 1: 登录日志, 2: 操作日志
// 监听radio切换
watch(logType, (newVal) => {
if (newVal === 1 && loginLogList.value.length === 0) {
loadLogin()
} else if (newVal === 2 && operationLogList.value.length === 0) {
loadOperate()
}
})
const loadLogin = async () => {
try {
const data = await fetchGetLogin({
page: 1,
limit: 5,
orderType: 'desc'
})
loginLogList.value = data.data || []
} catch (error) {
console.error('加载登录日志失败:', error)
}
}
const loadOperate = async () => {
try {
const data = await fetchGetOperate({
page: 1,
limit: 5,
orderType: 'desc'
})
operationLogList.value = data.data || []
} catch (error) {
console.error('加载操作日志失败:', error)
}
}
onMounted(() => {
avatar.value = userInfo.value.avatar || ''
getDate()
loadLogin()
loadOperate()
})
/**
* 根据当前时间获取问候语
*/
const getDate = () => {
const h = new Date().getHours()
if (h >= 6 && h < 9) date.value = '早上好'
else if (h >= 9 && h < 11) date.value = '上午好'
else if (h >= 11 && h < 13) date.value = '中午好'
else if (h >= 13 && h < 18) date.value = '下午好'
else if (h >= 18 && h < 24) date.value = '晚上好'
else date.value = '很晚了,早点睡'
}
const handleAvatarChange = async (val: string | string[]) => {
if (!val) {
return
}
try {
await updateUserInfo({
id: form.id,
avatar: val
})
userStore.setAvatar(val as string)
ElMessage.success('修改头像成功')
} catch (error) {
console.log('表单验证失败:', error)
}
}
/**
* 切换用户信息编辑状态
*/
const edit = async () => {
if (isEdit.value) {
try {
await ruleFormRef.value?.validate()
await updateUserInfo(form)
ElMessage.success('修改成功')
isEdit.value = !isEdit.value
} catch (error) {
console.log('表单验证失败:', error)
}
} else {
isEdit.value = !isEdit.value
}
}
/**
* 切换密码编辑状态
*/
const editPwd = async () => {
if (isEditPwd.value) {
try {
await pwdFormRef.value?.validate()
if (pwdForm.newPassword !== pwdForm.confirmPassword) {
ElMessage.error('确认密码与新密码不一致')
return
}
await modifyPassword(pwdForm)
ElMessage.success('修改成功')
Object.assign(pwdForm, { oldPassword: '', newPassword: '', confirmPassword: '' })
isEditPwd.value = !isEditPwd.value
} catch (error) {
console.log('表单验证失败:', error)
}
} else {
isEditPwd.value = !isEditPwd.value
}
}
/**
* 检查密码安全程度
* @param password 密码
*/
const checkSafe = (password: string) => {
if (password.length < 1) {
passwordSafePercent.value = 0
return
}
if (!(password.length >= 6)) {
passwordSafePercent.value = 0
return
}
passwordSafePercent.value = 10
if (/\d/.test(password)) {
passwordSafePercent.value += 10
}
if (/[a-z]/.test(password)) {
passwordSafePercent.value += 10
}
if (/[A-Z]/.test(password)) {
passwordSafePercent.value += 30
}
if (/[`~!@#$%^&*()_+<>?:"{},./;'[\]]/.test(password)) {
passwordSafePercent.value += 40
}
}
</script>

View File

@@ -0,0 +1,16 @@
<!-- 403页面 -->
<template>
<ArtException
:data="{
title: '403',
desc: $t('exceptionPage.403'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/svg/403.svg'
defineOptions({ name: 'Exception403' })
</script>

View File

@@ -0,0 +1,16 @@
<!-- 404页面 -->
<template>
<ArtException
:data="{
title: '404',
desc: $t('exceptionPage.404'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/svg/404.svg'
defineOptions({ name: 'Exception404' })
</script>

View File

@@ -0,0 +1,16 @@
<!-- 500页面 -->
<template>
<ArtException
:data="{
title: '500',
desc: $t('exceptionPage.500'),
btnText: $t('exceptionPage.gohome'),
imgUrl
}"
/>
</template>
<script setup lang="ts">
import imgUrl from '@imgs/svg/500.svg'
defineOptions({ name: 'Exception500' })
</script>

View File

@@ -0,0 +1,29 @@
<!-- 布局容器 -->
<template>
<div class="app-layout">
<aside id="app-sidebar">
<ArtSidebarMenu />
</aside>
<main id="app-main">
<div id="app-header">
<ArtHeaderBar />
</div>
<div id="app-content">
<ArtPageContent />
</div>
</main>
<div id="app-global">
<ArtGlobalComponent />
</div>
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'AppLayout' })
</script>
<style lang="scss" scoped>
@use './style';
</style>

View File

@@ -0,0 +1,93 @@
.app-layout {
display: flex;
width: 100%;
min-height: 100vh;
background: var(--default-bg-color);
#app-sidebar {
flex-shrink: 0;
}
#app-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
height: 100vh;
overflow: auto;
#app-header {
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
width: 100%;
}
#app-content {
flex: 1;
:deep(.layout-content) {
box-sizing: border-box;
width: calc(100% - 40px);
margin: auto;
// 子页面默认 style
.page-content {
position: relative;
box-sizing: border-box;
padding: 20px;
overflow: hidden;
background: var(--default-box-color);
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
}
}
}
}
}
@media only screen and (width <= 1180px) {
.app-layout {
#app-main {
height: 100dvh;
}
}
}
@media only screen and (width <= 800px) {
.app-layout {
position: relative;
#app-sidebar {
position: fixed;
top: 0;
left: 0;
z-index: 300;
height: 100vh;
}
#app-main {
width: 100%;
height: auto;
overflow: visible;
#app-content {
:deep(.layout-content) {
width: calc(100% - 40px);
}
}
}
}
}
@media only screen and (width <= 640px) {
.app-layout {
#app-main {
#app-content {
:deep(.layout-content) {
width: calc(100% - 30px);
}
}
}
}
}

View File

@@ -0,0 +1,42 @@
<template>
<div class="box-border w-full h-full" v-loading="isLoading">
<iframe
ref="iframeRef"
:src="iframeUrl"
frameborder="0"
class="w-full h-full min-h-[calc(100vh-120px)] border-none"
@load="handleIframeLoad"
></iframe>
</div>
</template>
<script setup lang="ts">
import { IframeRouteManager } from '@/router/core'
defineOptions({ name: 'IframeView' })
const route = useRoute()
const isLoading = ref(true)
const iframeUrl = ref('')
const iframeRef = ref<HTMLIFrameElement | null>(null)
/**
* 初始化 iframe URL
* 从路由配置中获取对应的外部链接地址
*/
onMounted(() => {
const iframeRoute = IframeRouteManager.getInstance().findByPath(route.path)
if (iframeRoute?.meta) {
iframeUrl.value = iframeRoute.meta.link || ''
}
})
/**
* 处理 iframe 加载完成事件
* 隐藏加载状态
*/
const handleIframeLoad = (): void => {
isLoading.value = false
}
</script>

View File

@@ -0,0 +1,188 @@
/**
* 插件管理 API
*
* 提供插件安装、卸载、上传等功能接口
*
* @module api/tool/saipackage
*/
import request from '@/utils/http'
export interface AppInfo {
app: string
title: string
about: string
author: string
version: string
support?: string
website?: string
state: number
npm_dependent_wait_install?: number
composer_dependent_wait_install?: number
}
export interface VersionInfo {
saiadmin_version?: {
describe: string
notes: string
state: string
}
saipackage_version?: {
describe: string
notes: string
state: string
}
}
export interface AppListResponse {
data: AppInfo[]
version: VersionInfo
}
export interface StoreApp {
id: number
title: string
about: string
logo: string
version: string
price: string
avatar?: string
username: string
sales_num: number
content?: string
screenshots?: string[]
}
export interface StoreUser {
nickname?: string
username: string
avatar?: string
}
export interface PurchasedApp {
id: number
app_id: number
title: string
logo: string
version: string
developer: string
about: string
}
export interface AppVersion {
id: number
version: string
create_time: string
remark: string
}
export default {
/**
* 获取已安装的插件列表
*/
getAppList() {
return request.get<AppListResponse>({ url: '/app/saipackage/install/index' })
},
/**
* 上传插件包
*/
uploadApp(data: FormData) {
return request.post<AppInfo>({ url: '/app/saipackage/install/upload', data })
},
/**
* 安装插件
*/
installApp(data: { appName: string }) {
return request.post<any>({ url: '/app/saipackage/install/install', data })
},
/**
* 卸载插件
*/
uninstallApp(data: { appName: string }) {
return request.post<any>({ url: '/app/saipackage/install/uninstall', data })
},
/**
* 重载后端
*/
reloadBackend() {
return request.post<any>({ url: '/app/saipackage/install/reload' })
},
/**
* 获取在线商店应用列表
*/
getOnlineAppList(params: {
page?: number
limit?: number
price?: string
type?: string | number
keywords?: string
}) {
return request.get<{ data: StoreApp[]; total: number }>({
url: '/tool/install/online/appList',
params
})
},
/**
* 获取验证码
*/
getStoreCaptcha() {
return request.get<{ image: string; uuid: string }>({
url: '/tool/install/online/storeCaptcha'
})
},
/**
* 商店登录
*/
storeLogin(data: { username: string; password: string; code: string; uuid: string }) {
return request.post<{ access_token: string }>({
url: '/tool/install/online/storeLogin',
data
})
},
/**
* 获取商店用户信息
*/
getStoreUserInfo(token: string) {
return request.get<StoreUser>({
url: '/tool/install/online/storeUserInfo',
params: { token }
})
},
/**
* 获取已购应用列表
*/
getPurchasedApps(token: string) {
return request.get<PurchasedApp[]>({
url: '/tool/install/online/storePurchasedApps',
params: { token }
})
},
/**
* 获取应用版本列表
*/
getAppVersions(token: string, app_id: number) {
return request.get<AppVersion[]>({
url: '/tool/install/online/storeAppVersions',
params: { token, app_id }
})
},
/**
* 下载应用
*/
downloadApp(data: { token: string; id: number }) {
return request.post<any>({
url: '/tool/install/online/storeDownloadApp',
data
})
}
}

View File

@@ -0,0 +1,988 @@
<template>
<div class="art-full-height">
<ElCard class="flex flex-col flex-1 min-h-0 art-table-card" shadow="never">
<!-- 提示警告 -->
<ElAlert type="warning" :closable="false">
仅支持上传由插件市场下载的zip压缩包进行安装请您务必确认插件包文件来自官方渠道或经由官方认证的插件作者
</ElAlert>
<!-- 工具栏 -->
<div class="flex flex-wrap items-center my-2">
<ElButton @click="getList" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
</ElButton>
<ElButton @click="handleUpload" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:upload-line" />
</template>
上传插件包
</ElButton>
<ElButton type="danger" @click="handleTerminal" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:terminal-box-line" />
</template>
</ElButton>
<div class="flex items-center gap-1 ml-auto">
<div class="version-title">saiadmin版本</div>
<div class="version-value">{{ version?.saiadmin_version?.describe }}</div>
<div class="version-title">状态</div>
<div
class="version-value"
:class="[
version?.saiadmin_version?.notes === '正常' ? 'text-green-500' : 'text-red-500'
]"
>
{{ version?.saiadmin_version?.notes }}
</div>
<div class="version-title">saipackage安装器</div>
<div class="version-value">{{ version?.saipackage_version?.describe }}</div>
<div class="version-title">状态</div>
<div
class="version-value"
:class="[
version?.saipackage_version?.notes === '正常' ? 'text-green-500' : 'text-red-500'
]"
>
{{ version?.saipackage_version?.notes }}
</div>
</div>
</div>
<!-- Tab切换 -->
<ElTabs v-model="activeTab" type="border-card">
<!-- 本地安装 Tab -->
<ElTabPane label="本地安装" name="local">
<ArtTable
:loading="loading"
:data="installList"
:columns="columns"
:show-table-header="false"
>
<!-- 插件标识列 -->
<template #app="{ row }">
<ElLink :href="row.website" target="_blank" type="primary">{{ row.app }}</ElLink>
</template>
<!-- 状态列 -->
<template #state="{ row }">
<ElTag v-if="row.state === 0" type="danger">已卸载</ElTag>
<ElTag v-else-if="row.state === 1" type="success">已安装</ElTag>
<ElTag v-else-if="row.state === 2" type="primary">等待安装</ElTag>
<ElTag v-else-if="row.state === 4" type="warning">等待安装依赖</ElTag>
</template>
<!-- 前端依赖列 -->
<template #npm="{ row }">
<ElLink
v-if="row.npm_dependent_wait_install === 1"
type="primary"
@click="handleExecFront(row)"
>
<ArtSvgIcon icon="ri:download-line" class="mr-1" />点击安装
</ElLink>
<ElTag v-else-if="row.state === 2" type="info">-</ElTag>
<ElTag v-else type="success">已安装</ElTag>
</template>
<!-- 后端依赖列 -->
<template #composer="{ row }">
<ElLink
v-if="row.composer_dependent_wait_install === 1"
type="primary"
@click="handleExecBackend(row)"
>
<ArtSvgIcon icon="ri:download-line" class="mr-1" />点击安装
</ElLink>
<ElTag v-else-if="row.state === 2" type="info">-</ElTag>
<ElTag v-else type="success">已安装</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<ElSpace>
<ElPopconfirm
title="确定要安装当前插件吗?"
@confirm="handleInstall(row)"
confirm-button-text="确定"
cancel-button-text="取消"
>
<template #reference>
<ElLink type="warning">
<ArtSvgIcon icon="ri:apps-2-add-line" class="mr-1" />安装
</ElLink>
</template>
</ElPopconfirm>
<ElPopconfirm
title="确定要卸载当前插件吗?"
@confirm="handleUninstall(row)"
confirm-button-text="确定"
cancel-button-text="取消"
>
<template #reference>
<ElLink type="danger">
<ArtSvgIcon icon="ri:delete-bin-5-line" class="mr-1" />卸载
</ElLink>
</template>
</ElPopconfirm>
</ElSpace>
</template>
</ArtTable>
</ElTabPane>
<!-- 在线商店 Tab -->
<ElTabPane label="在线商店" name="online">
<!-- 搜索栏 -->
<div class="flex flex-wrap items-center gap-4 mb-4">
<ElInput
v-model="searchForm.keywords"
placeholder="请输入关键词"
clearable
class="!w-48"
@keyup.enter="fetchOnlineApps"
>
<template #prefix>
<ArtSvgIcon icon="ri:search-line" />
</template>
</ElInput>
<ElSelect v-model="searchForm.type" placeholder="类型" clearable class="!w-32">
<ElOption label="全部" value="" />
<ElOption label="插件" :value="1" />
<ElOption label="系统" :value="2" />
<ElOption label="组件" :value="3" />
<ElOption label="项目" :value="4" />
</ElSelect>
<ElSelect v-model="searchForm.price" placeholder="价格" class="!w-32">
<ElOption label="全部" value="all" />
<ElOption label="免费" value="free" />
<ElOption label="付费" value="paid" />
</ElSelect>
<ElButton type="primary" @click="fetchOnlineApps">搜索</ElButton>
<!-- 商店账号 -->
<div class="ml-auto flex items-center gap-2">
<template v-if="storeUser">
<ElAvatar :size="24">
<img v-if="storeUser.avatar" :src="storeUser.avatar" />
<ArtSvgIcon v-else icon="ri:user-line" />
</ElAvatar>
<span class="font-medium">{{ storeUser.nickname || storeUser.username }}</span>
<ElButton size="small" @click="showPurchasedApps">已购应用</ElButton>
<ElButton size="small" @click="handleLogout">退出</ElButton>
</template>
<template v-else>
<ElButton size="small" @click="handleLogin">登录</ElButton>
<ElButton size="small" @click="handleRegister">注册</ElButton>
<span class="text-sm text-gray-400">来管理已购插件</span>
</template>
</div>
</div>
<!-- 应用网格 -->
<div class="app-grid">
<div
v-for="item in onlineApps"
:key="item.id"
class="app-card"
@click="showDetail(item)"
>
<div class="app-card-header">
<img :src="item.logo" :alt="item.title" class="app-logo" />
<div class="app-info">
<div class="app-title">{{ item.title }}</div>
<div class="app-version">v{{ item.version }}</div>
</div>
<div class="app-price" :class="{ free: item.price === '0.00' }">
{{ item.price === '0.00' ? '免费' : '¥' + item.price }}
</div>
</div>
<div class="app-about">{{ item.about }}</div>
<div class="app-footer">
<div class="app-author">
<img
:src="item.avatar || 'https://via.placeholder.com/24'"
class="author-avatar"
/>
<span>{{ item.username }}</span>
</div>
<div class="app-sales">
<ArtSvgIcon icon="ri:user-line" class="mr-1" />
{{ item.sales_num }} 销量
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="flex justify-center mt-4">
<ElPagination
v-model:current-page="onlinePagination.current"
v-model:page-size="onlinePagination.size"
:total="onlinePagination.total"
:page-sizes="[12, 24, 48]"
layout="total, prev, pager, next, sizes"
@size-change="fetchOnlineApps"
@current-change="fetchOnlineApps"
/>
</div>
</ElTabPane>
</ElTabs>
</ElCard>
<!-- 上传插件弹窗 -->
<InstallForm ref="installFormRef" @success="getList" />
<!-- 终端弹窗 -->
<TerminalBox ref="terminalRef" @success="getList" />
<!-- 详情抽屉 -->
<ElDrawer v-model="detailVisible" :size="600" :with-header="true">
<template #header>
<div class="flex items-center gap-3">
<img :src="currentApp?.logo" class="w-9 h-9 rounded-lg" />
<div>
<div class="text-lg font-semibold">{{ currentApp?.title }}</div>
<div class="text-xs text-gray-400">
v{{ currentApp?.version }} · {{ currentApp?.username }}
</div>
</div>
</div>
</template>
<div class="detail-content">
<div class="detail-price" :class="{ free: currentApp?.price === '0.00' }">
{{ currentApp?.price === '0.00' ? '免费' : '¥' + currentApp?.price }}
</div>
<div class="detail-about">{{ currentApp?.about }}</div>
<!-- 截图预览 -->
<div v-if="currentApp?.screenshots?.length" class="mb-6">
<div class="text-base font-semibold mb-3">截图预览</div>
<ElSpace wrap :size="12">
<ElImage
v-for="(img, idx) in currentApp?.screenshots"
:key="idx"
:src="img"
:preview-src-list="currentApp?.screenshots"
:preview-teleported="true"
fit="cover"
class="w-36 h-24 rounded-lg cursor-pointer"
/>
</ElSpace>
</div>
<!-- 详情描述 -->
<div class="detail-desc">
<div class="text-base font-semibold mb-3">详细介绍</div>
<div class="desc-content" v-html="renderMarkdown(currentApp?.content)"></div>
</div>
<!-- 购买按钮 -->
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<ElButton type="primary" size="large" class="w-full" @click="handleBuy">
<template #icon>
<ArtSvgIcon icon="ri:shopping-cart-line" />
</template>
前往购买
</ElButton>
</div>
</div>
</ElDrawer>
<!-- 登录弹窗 -->
<ElDialog v-model="loginVisible" title="登录应用商店" width="400" :close-on-click-modal="false">
<ElForm :model="loginForm" @submit.prevent="submitLogin" label-position="top">
<ElFormItem label="用户名/邮箱" required>
<ElInput v-model="loginForm.username" placeholder="请输入用户名或邮箱" clearable>
<template #prefix>
<ArtSvgIcon icon="ri:user-line" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="密码" required>
<ElInput
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
clearable
>
<template #prefix>
<ArtSvgIcon icon="ri:lock-line" />
</template>
</ElInput>
</ElFormItem>
<ElFormItem label="验证码" required>
<div class="flex gap-2 w-full">
<ElInput v-model="loginForm.code" placeholder="请输入验证码" clearable class="flex-1">
<template #prefix>
<ArtSvgIcon icon="ri:shield-check-line" />
</template>
</ElInput>
<img
:src="captchaImage"
@click="getCaptcha"
class="h-8 w-24 cursor-pointer rounded"
title="点击刷新"
/>
</div>
</ElFormItem>
<ElFormItem>
<ElButton type="primary" native-type="submit" class="w-full" :loading="loginLoading">
登录
</ElButton>
</ElFormItem>
<div class="text-center text-sm text-gray-400">
还没有账号
<ElLink type="primary" @click="handleRegister">立即注册</ElLink>
</div>
</ElForm>
</ElDialog>
<!-- 已购应用抽屉 -->
<ElDrawer v-model="purchasedVisible" title="已购应用" :size="720">
<div v-loading="purchasedLoading" class="purchased-list">
<div v-for="app in purchasedApps" :key="app.id" class="purchased-card">
<img :src="app.logo" class="purchased-logo" />
<div class="purchased-info">
<div class="purchased-title">{{ app.title }}</div>
<div class="purchased-version">v{{ app.version }} · {{ app.developer }}</div>
<div class="purchased-about">{{ app.about }}</div>
</div>
<div class="gap-2">
<ElButton size="small" @click="viewDocs(app)">
<template #icon>
<ArtSvgIcon icon="ri:book-line" />
</template>
文档
</ElButton>
<ElButton type="primary" size="small" @click="showVersions(app)">
<template #icon>
<ArtSvgIcon icon="ri:download-line" />
</template>
下载
</ElButton>
</div>
</div>
<ElEmpty
v-if="!purchasedLoading && purchasedApps.length === 0"
description="暂无已购应用"
/>
</div>
</ElDrawer>
<!-- 版本选择对话框 -->
<ElDialog
v-model="versionVisible"
:title="'选择版本 - ' + (currentPurchasedApp?.title || '')"
width="500"
>
<div v-loading="versionLoading" class="version-list">
<div v-for="ver in versionList" :key="ver.id" class="version-item">
<div>
<div class="version-info-row">
<span class="version-name">v{{ ver.version }}</span>
<span class="version-date">{{ ver.create_time }}</span>
</div>
<div class="version-remark">{{ ver.remark }}</div>
</div>
<ElButton
type="primary"
size="small"
:loading="downloadingId === ver.id"
@click="downloadVersion(ver)"
>
下载安装
</ElButton>
</div>
<ElEmpty v-if="!versionLoading && versionList.length === 0" description="暂无可用版本" />
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import type { ColumnOption } from '@/types'
import saipackageApi, {
type AppInfo,
type VersionInfo,
type StoreApp,
type StoreUser,
type PurchasedApp,
type AppVersion
} from '../api/index'
import InstallForm from './install-box.vue'
import TerminalBox from './terminal.vue'
// ========== 基础状态 ==========
const activeTab = ref('local')
const version = ref<VersionInfo>({})
const loading = ref(false)
const installFormRef = ref()
const terminalRef = ref()
const installList = ref<AppInfo[]>([])
// ========== 本地安装相关 ==========
const handleUpload = () => {
installFormRef.value?.open()
}
// 检查版本兼容性
const checkVersionCompatibility = (support: string | undefined): boolean => {
if (!support || !version.value?.saiadmin_version?.describe) {
return false // 如果没有兼容性信息,默认不允许安装
}
const currentVersion = version.value.saiadmin_version.describe
const currentMatch = currentVersion.match(/^(\d+)\./)
if (!currentMatch) {
return true
}
const currentMajor = currentMatch[1]
// support 格式为 "5.x" 或 "5.x|6.x",用 | 分隔多个支持的版本
const supportVersions = support.split('|').map((v) => v.trim())
// 检查当前版本是否匹配任意一个支持的版本
return supportVersions.some((ver) => {
const supportMatch = ver.match(/^(\d+)\.x$/i)
return supportMatch && supportMatch[1] === currentMajor
})
}
const handleInstall = async (record: AppInfo) => {
// 检查
if (version.value?.saipackage_version?.state === 'fail') {
ElMessage.error('插件市场saipackage版本检测失败')
return
}
// 检查版本兼容性
if (!checkVersionCompatibility(record.support)) {
ElMessage.error(
`此插件仅支持 ${record.support} 版本框架,当前框架版本为 ${version.value?.saiadmin_version?.describe},不兼容无法安装`
)
return
}
try {
await saipackageApi.installApp({ appName: record.app })
ElMessage.success('安装成功')
getList()
saipackageApi.reloadBackend()
} catch {
// Error already handled by http utility
}
}
const handleUninstall = async (record: AppInfo) => {
await saipackageApi.uninstallApp({ appName: record.app })
ElMessage.success('卸载成功')
getList()
}
const handleExecFront = (record: AppInfo) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.frontInstall(extend)
}, 500)
}
const handleExecBackend = (record: AppInfo) => {
const extend = 'module-install:' + record.app
terminalRef.value?.open()
setTimeout(() => {
terminalRef.value?.backendInstall(extend)
}, 500)
}
const handleTerminal = () => {
terminalRef.value?.open()
}
const columns: ColumnOption[] = [
{ prop: 'app', label: '插件标识', width: 120, useSlot: true },
{ prop: 'title', label: '插件名称', width: 150 },
{ prop: 'about', label: '插件描述', showOverflowTooltip: true },
{ prop: 'author', label: '作者', width: 120 },
{ prop: 'version', label: '版本', width: 100 },
{ prop: 'support', label: '框架兼容', width: 120, align: 'center' },
{ prop: 'state', label: '插件状态', width: 100, useSlot: true },
{ prop: 'npm', label: '前端依赖', width: 100, useSlot: true },
{ prop: 'composer', label: '后端依赖', width: 100, useSlot: true },
{ prop: 'operation', label: '操作', width: 140, fixed: 'right', useSlot: true }
]
const getList = async () => {
loading.value = true
try {
const resp = await saipackageApi.getAppList()
installList.value = resp?.data || []
version.value = resp?.version || {}
} catch {
// Error already handled by http utility
} finally {
loading.value = false
}
}
// ========== 在线商店相关 ==========
const detailVisible = ref(false)
const currentApp = ref<StoreApp | null>(null)
const storeUser = ref<StoreUser | null>(null)
const storeToken = ref(localStorage.getItem('storeToken') || '')
const onlineApps = ref<StoreApp[]>([])
const onlineLoading = ref(false)
const onlinePagination = reactive({
current: 1,
size: 12,
total: 0
})
// 登录相关
const loginVisible = ref(false)
const loginLoading = ref(false)
const captchaImage = ref('')
const captchaUuid = ref('')
const loginForm = reactive({
username: '',
password: '',
code: ''
})
// 搜索表单
const searchForm = reactive({
keywords: '',
type: '' as string | number,
price: 'all'
})
// 已购应用相关
const purchasedVisible = ref(false)
const purchasedLoading = ref(false)
const purchasedApps = ref<PurchasedApp[]>([])
const versionVisible = ref(false)
const versionLoading = ref(false)
const versionList = ref<AppVersion[]>([])
const currentPurchasedApp = ref<PurchasedApp | null>(null)
const downloadingId = ref<number | null>(null)
const handleLogin = () => {
loginVisible.value = true
getCaptcha()
}
const handleRegister = () => {
window.open('https://saas.saithink.top/register', '_blank')
}
const handleLogout = () => {
storeUser.value = null
storeToken.value = ''
localStorage.removeItem('storeToken')
}
const getCaptcha = async () => {
try {
const response = await saipackageApi.getStoreCaptcha()
captchaImage.value = response?.image || ''
captchaUuid.value = response?.uuid || ''
} catch {
// Error already handled by http utility
}
}
const submitLogin = async () => {
if (!loginForm.username || !loginForm.password || !loginForm.code) {
ElMessage.warning('请填写完整信息')
return
}
loginLoading.value = true
try {
const response = await saipackageApi.storeLogin({
username: loginForm.username,
password: loginForm.password,
code: loginForm.code,
uuid: captchaUuid.value
})
storeToken.value = response?.access_token || ''
localStorage.setItem('storeToken', response?.access_token || '')
loginVisible.value = false
loginForm.username = ''
loginForm.password = ''
loginForm.code = ''
await fetchStoreUser()
ElMessage.success('登录成功')
} catch {
getCaptcha()
// Error already handled by http utility
} finally {
loginLoading.value = false
}
}
const fetchStoreUser = async () => {
if (!storeToken.value) return
try {
const response = await saipackageApi.getStoreUserInfo(storeToken.value)
storeUser.value = response || null
} catch {
handleLogout()
}
}
const fetchOnlineApps = async () => {
onlineLoading.value = true
try {
const response = await saipackageApi.getOnlineAppList({
page: onlinePagination.current,
limit: onlinePagination.size,
price: searchForm.price,
type: searchForm.type,
keywords: searchForm.keywords
})
onlineApps.value = response?.data || []
onlinePagination.total = response?.total || 0
} catch {
// Error already handled by http utility
} finally {
onlineLoading.value = false
}
}
const showDetail = (item: StoreApp) => {
currentApp.value = item
detailVisible.value = true
}
const renderMarkdown = (content?: string) => {
if (!content) return ''
return content
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/^- (.+)$/gm, '<li>$1</li>')
.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>')
.replace(/\n/g, '<br/>')
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>')
}
const handleBuy = () => {
window.open('https://saas.saithink.top/store', '_blank')
}
const showPurchasedApps = async () => {
purchasedVisible.value = true
purchasedLoading.value = true
try {
const response = await saipackageApi.getPurchasedApps(storeToken.value)
purchasedApps.value = response || []
} catch {
// Error already handled by http utility
}
purchasedLoading.value = false
}
const viewDocs = (app: PurchasedApp) => {
window.open(`https://saas.saithink.top/store/docs-${app.app_id}`, '_blank')
}
const showVersions = async (app: PurchasedApp) => {
currentPurchasedApp.value = app
versionVisible.value = true
versionLoading.value = true
try {
const response = await saipackageApi.getAppVersions(storeToken.value, app.app_id)
versionList.value = response || []
} catch {
// Error already handled by http utility
}
versionLoading.value = false
}
const downloadVersion = async (ver: AppVersion) => {
downloadingId.value = ver.id
try {
await saipackageApi.downloadApp({
token: storeToken.value,
id: ver.id
})
ElMessage.success('下载成功,即将刷新插件列表...')
versionVisible.value = false
purchasedVisible.value = false
activeTab.value = 'local'
getList()
} catch {
// Error already handled by http utility
}
downloadingId.value = null
}
// 监听 tab 切换
watch(activeTab, (val) => {
if (val === 'online') {
fetchOnlineApps()
fetchStoreUser()
}
})
onMounted(() => {
getList()
})
</script>
<style lang="scss" scoped>
.version-title {
padding: 5px 10px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color);
font-size: 12px;
}
.version-value {
padding: 5px 10px;
border: 1px solid var(--el-border-color);
font-size: 12px;
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
max-height: calc(100vh - 380px);
overflow-y: auto;
}
.app-card {
background: var(--el-bg-color);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.3s ease;
border: 1px solid var(--el-border-color);
&:hover {
box-shadow: var(--el-box-shadow-light);
transform: translateY(-2px);
}
}
.app-card-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.app-logo {
width: 48px;
height: 48px;
border-radius: 8px;
object-fit: cover;
}
.app-info {
flex: 1;
}
.app-title {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.app-version {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-price {
font-size: 16px;
font-weight: 600;
color: var(--el-color-danger);
&.free {
color: var(--el-color-success);
}
}
.app-about {
font-size: 13px;
color: var(--el-text-color-regular);
line-height: 1.5;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.app-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-author {
display: flex;
align-items: center;
gap: 6px;
}
.author-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.app-sales {
display: flex;
align-items: center;
}
.detail-content {
padding: 16px 0;
}
.detail-price {
font-size: 24px;
font-weight: 600;
color: var(--el-color-danger);
margin-bottom: 16px;
&.free {
color: var(--el-color-success);
}
}
.detail-about {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.6;
margin-bottom: 24px;
}
.desc-content {
font-size: 14px;
color: var(--el-text-color-regular);
line-height: 1.8;
:deep(code) {
background: var(--el-fill-color);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
:deep(a) {
color: var(--el-color-primary);
}
}
.purchased-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.purchased-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--el-bg-color);
border-radius: 8px;
border: 1px solid var(--el-border-color);
}
.purchased-logo {
width: 56px;
height: 56px;
border-radius: 8px;
object-fit: cover;
}
.purchased-info {
flex: 1;
min-width: 0;
}
.purchased-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 4px;
}
.purchased-version {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 6px;
}
.purchased-about {
font-size: 13px;
color: var(--el-text-color-regular);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.version-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.version-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px;
background: var(--el-fill-color-light);
border-radius: 6px;
}
.version-info-row {
display: flex;
align-items: center;
gap: 12px;
}
.version-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
.version-date {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.version-remark {
flex: 1;
font-size: 13px;
color: var(--el-text-color-regular);
}
</style>

View File

@@ -0,0 +1,105 @@
<template>
<ElDialog v-model="visible" title="上传插件包-安装插件" width="800" :close-on-click-modal="false">
<div class="flex flex-col items-center mb-6">
<div class="w-[400px]">
<div class="text-lg text-red-500 font-bold mb-2">
请您务必确认模块包文件来自官方渠道或经由官方认证的模块作者否则系统可能被破坏因为
</div>
<div class="text-red-500">1. 模块可以修改和新增系统文件</div>
<div class="text-red-500">2. 模块可以执行sql命令和代码</div>
<div class="text-red-500">3. 模块可以安装新的前后端依赖</div>
</div>
<!-- 已上传的应用信息 -->
<div v-if="appInfo && appInfo.app" class="mt-10 w-[600px]">
<ElDescriptions :column="1" border>
<ElDescriptionsItem label="应用标识">{{ appInfo?.app }}</ElDescriptionsItem>
<ElDescriptionsItem label="应用名称">{{ appInfo?.title }}</ElDescriptionsItem>
<ElDescriptionsItem label="应用描述">{{ appInfo?.about }}</ElDescriptionsItem>
<ElDescriptionsItem label="作者">{{ appInfo?.author }}</ElDescriptionsItem>
<ElDescriptionsItem label="版本">{{ appInfo?.version }}</ElDescriptionsItem>
</ElDescriptions>
</div>
<!-- 上传区域 -->
<div v-else class="mt-10 w-[600px]">
<ElUpload
drag
:http-request="uploadFileHandler"
:show-file-list="false"
accept=".zip,.rar"
class="w-full"
>
<div class="flex flex-col items-center justify-center py-8">
<ArtSvgIcon icon="ri:upload-cloud-line" class="text-4xl text-gray-400 mb-2" />
<div class="text-gray-500">
将插件包文件拖到此处
<span class="text-primary ml-2">点击上传</span>
</div>
</div>
</ElUpload>
</div>
</div>
</ElDialog>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import type { UploadRequestOptions } from 'element-plus'
import saipackageApi, { type AppInfo } from '../api/index'
const emit = defineEmits<{
(e: 'success'): void
}>()
const visible = ref(false)
const loading = ref(false)
const uploadSize = 8 * 1024 * 1024
const initialApp: AppInfo = {
app: '',
title: '',
about: '',
author: '',
version: '',
state: 0
}
const appInfo = reactive<AppInfo>({ ...initialApp })
const uploadFileHandler = async (options: UploadRequestOptions) => {
const file = options.file
if (!file) return
if (file.size > uploadSize) {
ElMessage.warning(file.name + '超出文件大小限制(8MB)')
return
}
loading.value = true
try {
const dataForm = new FormData()
dataForm.append('file', file)
const res = await saipackageApi.uploadApp(dataForm)
if (res) {
Object.assign(appInfo, res)
ElMessage.success('上传成功')
emit('success')
}
} catch {
// Error already handled by http utility
} finally {
loading.value = false
}
}
const open = () => {
visible.value = true
Object.assign(appInfo, initialApp)
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,390 @@
<template>
<div>
<!-- 终端执行面板 -->
<ElDialog v-model="visible" title="终端执行面板" width="960">
<div>
<ElEmpty v-if="terminal.taskList.length === 0" description="暂无任务" />
<div v-else>
<ElTimeline>
<ElTimelineItem
v-for="(item, idx) in terminal.taskList"
:key="idx"
:timestamp="item.createTime"
placement="top"
>
<ElCollapse :model-value="terminal.taskList.map((_, i) => i)">
<ElCollapseItem :name="idx">
<template #title>
<div class="flex items-center gap-3">
<span class="font-bold text-base">{{ item.command }}</span>
<ElTag :type="getTagType(item.status)" size="small">
{{ getTagText(item.status) }}
</ElTag>
</div>
</template>
<template #icon>
<div class="flex gap-1">
<ElButton
type="warning"
size="small"
circle
@click.stop="terminal.retryTask(idx)"
>
<ArtSvgIcon icon="ri:refresh-line" />
</ElButton>
<ElButton
type="danger"
size="small"
circle
@click.stop="terminal.delTask(idx)"
>
<ArtSvgIcon icon="ri:delete-bin-line" />
</ElButton>
</div>
</template>
<div
v-if="
item.status === 2 ||
item.status === 3 ||
(item.status > 3 && item.showMessage)
"
class="exec-message"
>
<pre
v-for="(msg, index) in item.message"
:key="index"
v-html="ansiToHtml(msg)"
></pre>
</div>
</ElCollapseItem>
</ElCollapse>
</ElTimelineItem>
</ElTimeline>
</div>
<ElDivider />
<div class="flex justify-center flex-wrap gap-2">
<ElButton type="success" @click="testTerminal">
<template #icon>
<ArtSvgIcon icon="ri:play-line" />
</template>
测试命令
</ElButton>
<ElButton @click="handleFronted">
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
前端依赖更新
</ElButton>
<ElButton @click="handleBackend">
<template #icon>
<ArtSvgIcon icon="ri:refresh-line" />
</template>
后端依赖更新
</ElButton>
<ElButton type="warning" @click="webBuild">
<template #icon>
<ArtSvgIcon icon="ri:rocket-line" />
</template>
一键发布
</ElButton>
<ElButton @click="openConfig">
<template #icon>
<ArtSvgIcon icon="ri:settings-line" />
</template>
终端设置
</ElButton>
<ElButton type="danger" @click="terminal.cleanTaskList()">
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-line" />
</template>
清理任务
</ElButton>
</div>
</div>
</ElDialog>
<!-- 终端设置弹窗 -->
<ElDialog v-model="configVisible" title="终端设置" width="500">
<ElForm label-width="120px">
<ElFormItem label="NPM源">
<ElSelect v-model="terminal.npmRegistry" class="w-80" @change="npmRegistryChange">
<ElOption value="npm" label="npm官源" />
<ElOption value="taobao" label="taobao" />
<ElOption value="tencent" label="tencent" />
</ElSelect>
</ElFormItem>
<ElFormItem label="NPM包管理器">
<ElSelect v-model="terminal.packageManager" class="w-80">
<ElOption value="npm" label="npm" />
<ElOption value="yarn" label="yarn" />
<ElOption value="pnpm" label="pnpm" />
</ElSelect>
</ElFormItem>
<ElFormItem label="Composer源">
<ElSelect
v-model="terminal.composerRegistry"
class="w-80"
@change="composerRegistryChange"
>
<ElOption value="composer" label="composer官源" />
<ElOption value="tencent" label="tencent" />
<ElOption value="huawei" label="huawei" />
<ElOption value="kkame" label="kkame" />
</ElSelect>
</ElFormItem>
</ElForm>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTerminalStore, TaskStatus } from '../store/terminal'
const emit = defineEmits<{
(e: 'success'): void
}>()
const terminal = useTerminalStore()
const visible = ref(false)
const configVisible = ref(false)
const testTerminal = () => {
terminal.addNodeTask('test', '', () => {})
}
const webBuild = () => {
ElMessageBox.confirm('确认重新打包前端并发布项目吗?', '前端打包发布', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
terminal.addNodeTask('web-build', '', () => {
ElMessage.success('前端打包发布成功')
})
})
}
const handleFronted = () => {
ElMessageBox.confirm('确认更新前端Node依赖吗', '前端依赖更新', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
terminal.addNodeTask('web-install', '', () => {
ElMessage.success('前端依赖更新成功')
})
})
}
const handleBackend = () => {
ElMessageBox.confirm('确认更新后端composer包吗', 'composer包更新', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
terminal.addTask('composer.update', '', () => {
ElMessage.success('composer包更新成功')
})
})
}
const frontInstall = (extend = '') => {
terminal.addNodeTask('web-install', extend, () => {
ElMessage.success('前端依赖更新成功')
emit('success')
})
}
const backendInstall = (extend = '') => {
terminal.addTask('composer.update', extend, () => {
ElMessage.success('composer包更新成功')
setTimeout(() => {
emit('success')
}, 500)
})
}
const npmRegistryChange = (val: string) => {
const command = 'set-npm-registry.' + val
configVisible.value = false
terminal.addTask(command, '', () => {
ElMessage.success('NPM源设置成功')
})
}
const composerRegistryChange = (val: string) => {
const command = 'set-composer-registry.' + val
configVisible.value = false
terminal.addTask(command, '', () => {
ElMessage.success('Composer源设置成功')
})
}
const getTagType = (
status: TaskStatus
): 'success' | 'warning' | 'info' | 'danger' | 'primary' => {
switch (status) {
case TaskStatus.WAITING:
return 'info'
case TaskStatus.CONNECTING:
return 'primary'
case TaskStatus.RUNNING:
return 'warning'
case TaskStatus.SUCCESS:
return 'success'
case TaskStatus.FAILED:
return 'danger'
default:
return 'info'
}
}
const getTagText = (status: TaskStatus) => {
switch (status) {
case TaskStatus.WAITING:
return '等待执行'
case TaskStatus.CONNECTING:
return '连接中'
case TaskStatus.RUNNING:
return '执行中'
case TaskStatus.SUCCESS:
return '执行成功'
case TaskStatus.FAILED:
return '执行失败'
default:
return '未知'
}
}
// ESC 字符,用于 ANSI 转义序列
const ESC = String.fromCharCode(0x1b)
const ansiToHtml = (text: string) => {
// 先处理 ANSI 颜色代码
const colorPattern = new RegExp(`${ESC}\\[([0-9;]+)m`, 'g')
let result = text.replace(colorPattern, function (match, codes) {
const codeList = codes.split(';').map((c: string) => parseInt(c, 10))
// 如果是重置代码 (0 或空),返回闭标签
if (codeList.length === 1 && (codeList[0] === 0 || isNaN(codeList[0]))) {
return '</span>'
}
const styles: string[] = []
codeList.forEach((c: number) => {
switch (c) {
case 0:
// 重置 - 不添加样式,在上面已处理
break
case 1:
styles.push('font-weight:bold')
break
case 3:
styles.push('font-style:italic')
break
case 4:
styles.push('text-decoration:underline')
break
case 30:
styles.push('color:black')
break
case 31:
styles.push('color:red')
break
case 32:
styles.push('color:green')
break
case 33:
styles.push('color:yellow')
break
case 34:
styles.push('color:blue')
break
case 35:
styles.push('color:magenta')
break
case 36:
styles.push('color:cyan')
break
case 37:
styles.push('color:white')
break
// 亮色/高亮色
case 90:
styles.push('color:#888')
break
case 91:
styles.push('color:#f55')
break
case 92:
styles.push('color:#5f5')
break
case 93:
styles.push('color:#ff5')
break
case 94:
styles.push('color:#55f')
break
case 95:
styles.push('color:#f5f')
break
case 96:
styles.push('color:#5ff')
break
case 97:
styles.push('color:#fff')
break
}
})
return styles.length ? `<span style="${styles.join(';')}">` : ''
})
// 清理可能残留的其他 ANSI 转义序列 (如光标移动等)
const cleanupPattern = new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, 'g')
result = result.replace(cleanupPattern, '')
return result
}
const openConfig = () => {
configVisible.value = true
}
const open = () => {
visible.value = true
}
const close = () => {
visible.value = false
}
defineExpose({ open, close, frontInstall, backendInstall })
</script>
<style lang="scss" scoped>
.exec-message {
font-size: 12px;
line-height: 1.5em;
min-height: 30px;
max-height: 200px;
overflow: auto;
background-color: #000;
color: #c0c0c0;
padding: 8px;
border-radius: 4px;
&::-webkit-scrollbar {
width: 5px;
height: 5px;
}
&::-webkit-scrollbar-thumb {
background: #c8c9cc;
border-radius: 4px;
}
}
</style>

View File

@@ -0,0 +1,345 @@
/**
* 终端状态管理模块 - saipackage插件
*
* 提供终端命令执行任务队列的状态管理
*
* @module store/terminal
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/store/modules/user'
/** 任务状态枚举 */
export enum TaskStatus {
/** 等待执行 */
WAITING = 1,
/** 连接中 */
CONNECTING = 2,
/** 执行中 */
RUNNING = 3,
/** 执行成功 */
SUCCESS = 4,
/** 执行失败 */
FAILED = 5,
/** 未知 */
UNKNOWN = 6
}
/** 终端任务接口 */
export interface TerminalTask {
/** 任务唯一标识 */
uuid: string
/** 命令名称 */
command: string
/** 任务状态 */
status: TaskStatus
/** 执行消息 */
message: string[]
/** 创建时间 */
createTime: string
/** 是否显示消息 */
showMessage: boolean
/** 回调函数 */
callback?: (status: number) => void
/** 扩展参数 */
extend: string
}
// 扩展 window 类型
declare global {
interface Window {
eventSource?: EventSource
}
}
/**
* 生成UUID
*/
const generateUUID = (): string => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}
/**
* 格式化日期时间
*/
const formatDateTime = (): string => {
const now = new Date()
return now.toLocaleString('zh-CN', {
hour12: false,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
/**
* 构建终端 WebSocket URL
*/
const buildTerminalUrl = (commandKey: string, uuid: string, extend: string): string => {
const env = import.meta.env
const baseURL = env.VITE_API_URL || ''
const userStore = useUserStore()
const token = userStore.accessToken
const terminalUrl = '/app/saipackage/index/terminal'
return `${baseURL}${terminalUrl}?command=${commandKey}&uuid=${uuid}&extend=${extend}&token=${token}`
}
export const useTerminalStore = defineStore(
'saipackageTerminal',
() => {
// 状态
const show = ref(false)
const taskList = ref<TerminalTask[]>([])
const npmRegistry = ref('npm')
const packageManager = ref('pnpm')
const composerRegistry = ref('composer')
/**
* 设置任务状态
*/
const setTaskStatus = (idx: number, status: TaskStatus) => {
if (taskList.value[idx]) {
taskList.value[idx].status = status
taskList.value[idx].showMessage = true
}
}
/**
* 添加任务消息
*/
const addTaskMessage = (idx: number, message: string) => {
if (taskList.value[idx]) {
taskList.value[idx].message = taskList.value[idx].message.concat(message)
}
}
/**
* 切换任务消息显示
*/
const setTaskShowMessage = (idx: number, val?: boolean) => {
if (taskList.value[idx]) {
taskList.value[idx].showMessage = val ?? !taskList.value[idx].showMessage
}
}
/**
* 清空任务列表
*/
const cleanTaskList = () => {
taskList.value = []
}
/**
* 任务完成回调
*/
const taskCompleted = (idx: number) => {
const task = taskList.value[idx]
if (!task || typeof task.callback !== 'function') return
const status = task.status
if (status === TaskStatus.FAILED || status === TaskStatus.UNKNOWN) {
task.callback(TaskStatus.FAILED)
} else if (status === TaskStatus.SUCCESS) {
task.callback(TaskStatus.SUCCESS)
}
}
/**
* 根据UUID查找任务索引
*/
const findTaskIdxFromUuid = (uuid: string): number | false => {
for (let i = 0; i < taskList.value.length; i++) {
if (taskList.value[i].uuid === uuid) {
return i
}
}
return false
}
/**
* 根据猜测查找任务索引
*/
const findTaskIdxFromGuess = (idx: number): number | false => {
if (!taskList.value[idx]) {
let taskKey = -1
for (let i = 0; i < taskList.value.length; i++) {
if (
taskList.value[i].status === TaskStatus.CONNECTING ||
taskList.value[i].status === TaskStatus.RUNNING
) {
taskKey = i
}
}
return taskKey === -1 ? false : taskKey
}
return idx
}
/**
* 启动EventSource连接
*/
const startEventSource = (taskKey: number) => {
const task = taskList.value[taskKey]
if (!task) return
window.eventSource = new EventSource(buildTerminalUrl(task.command, task.uuid, task.extend))
window.eventSource.onmessage = (e: MessageEvent) => {
try {
const data = JSON.parse(e.data)
if (!data || !data.data) return
const taskIdx = findTaskIdxFromUuid(data.uuid)
if (taskIdx === false) return
if (data.data === 'exec-error') {
setTaskStatus(taskIdx, TaskStatus.FAILED)
window.eventSource?.close()
taskCompleted(taskIdx)
startTask()
} else if (data.data === 'exec-completed') {
window.eventSource?.close()
if (taskList.value[taskIdx].status !== TaskStatus.SUCCESS) {
setTaskStatus(taskIdx, TaskStatus.FAILED)
}
taskCompleted(taskIdx)
startTask()
} else if (data.data === 'connection-success') {
setTaskStatus(taskIdx, TaskStatus.RUNNING)
} else if (data.data === 'exec-success') {
setTaskStatus(taskIdx, TaskStatus.SUCCESS)
} else {
addTaskMessage(taskIdx, data.data)
}
} catch {
// JSON parse error
}
}
window.eventSource.onerror = () => {
window.eventSource?.close()
const taskIdx = findTaskIdxFromGuess(taskKey)
if (taskIdx === false) return
setTaskStatus(taskIdx, TaskStatus.FAILED)
taskCompleted(taskIdx)
}
}
/**
* 添加 Node 相关任务
*/
const addNodeTask = (command: string, extend: string = '', callback?: () => void) => {
const manager = packageManager.value === 'unknown' ? 'npm' : packageManager.value
const fullCommand = `${command}.${manager}`
addTask(fullCommand, extend, callback)
}
/**
* 添加任务
*/
const addTask = (command: string, extend: string = '', callback?: () => void) => {
const task: TerminalTask = {
uuid: generateUUID(),
createTime: formatDateTime(),
status: TaskStatus.WAITING,
command,
message: [],
showMessage: false,
extend,
callback: callback ? () => callback() : undefined
}
taskList.value.push(task)
// 检查是否有已经失败的任务
if (show.value === false) {
for (const t of taskList.value) {
if (t.status === TaskStatus.FAILED || t.status === TaskStatus.UNKNOWN) {
ElMessage.warning('任务列表中存在失败的任务')
break
}
}
}
startTask()
}
/**
* 开始执行任务
*/
const startTask = () => {
let taskKey: number | null = null
// 寻找可以开始执行的命令
for (let i = 0; i < taskList.value.length; i++) {
const task = taskList.value[i]
if (task.status === TaskStatus.WAITING) {
taskKey = i
break
}
if (task.status === TaskStatus.CONNECTING || task.status === TaskStatus.RUNNING) {
break
}
}
if (taskKey !== null) {
setTaskStatus(taskKey, TaskStatus.CONNECTING)
startEventSource(taskKey)
}
}
/**
* 重试任务
*/
const retryTask = (idx: number) => {
if (taskList.value[idx]) {
taskList.value[idx].message = []
setTaskStatus(idx, TaskStatus.WAITING)
startTask()
}
}
/**
* 删除任务
*/
const delTask = (idx: number) => {
const task = taskList.value[idx]
if (task && task.status !== TaskStatus.CONNECTING && task.status !== TaskStatus.RUNNING) {
taskList.value.splice(idx, 1)
}
}
return {
show,
taskList,
npmRegistry,
packageManager,
composerRegistry,
setTaskStatus,
addTaskMessage,
setTaskShowMessage,
cleanTaskList,
addNodeTask,
addTask,
retryTask,
delTask,
startTask
}
},
{
persist: {
key: 'saipackageTerminal',
storage: localStorage,
pick: ['npmRegistry', 'composerRegistry', 'packageManager']
}
}
)
export default useTerminalStore

View File

@@ -0,0 +1,28 @@
<template>
<ArtResultPage
type="fail"
title="提交失败"
message="请核对并修改以下信息后,再重新提交。"
iconCode="ri:close-fill"
>
<template #content>
<p>您提交的内容有如下错误</p>
<p>
<ArtSvgIcon icon="ri:close-circle-line" class="text-red-500 mr-1" />
<span>您的账户已被冻结</span>
</p>
<p>
<ArtSvgIcon icon="ri:close-circle-line" class="text-red-500 mr-1" />
<span>您的账户还不具备申请资格</span>
</p>
</template>
<template #buttons>
<ElButton type="primary" v-ripple>返回修改</ElButton>
<ElButton v-ripple>查看</ElButton>
</template>
</ArtResultPage>
</template>
<script setup lang="ts">
defineOptions({ name: 'ResultFail' })
</script>

View File

@@ -0,0 +1,21 @@
<template>
<ArtResultPage
type="success"
title="提交成功"
message="提交结果页用于反馈一系列操作任务的处理结果,如果仅是简单操作,使用 Message 全局提示反馈即可。灰色区域可以显示一些补充的信息。"
iconCode="ri:check-fill"
>
<template #content>
<p>已提交申请等待部门审核</p>
</template>
<template #buttons>
<ElButton type="primary" v-ripple>返回修改</ElButton>
<ElButton v-ripple>查看</ElButton>
<ElButton v-ripple>打印</ElButton>
</template>
</ArtResultPage>
</template>
<script setup lang="ts">
defineOptions({ name: 'ResultSuccess' })
</script>

View File

@@ -0,0 +1,321 @@
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 w-64 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="tree-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<div class="flex justify-between items-center">
<b>附件分类</b>
<SaButton
v-permission="'core:attachment:edit'"
type="primary"
@click="categoryShowDialog('add')"
/>
</div>
</template>
<ElScrollbar>
<ElTree
:data="treeData"
:props="{ children: 'children', label: 'label' }"
node-key="id"
default-expand-all
highlight-current
:expand-on-click-node="false"
@node-click="handleNodeClick"
>
<template #default="{ node, data }">
<div class="flex items-center justify-between w-full" v-if="data.id > 1">
<span>{{ node.label }}</span>
<div class="tree-node-actions">
<SaButton
v-permission="'core:attachment:edit'"
type="secondary"
@click="categoryShowDialog('edit', data)"
/>
<SaButton
v-permission="'core:attachment:edit'"
type="error"
@click="categoryDeleteRow(data, categoryApi.delete, getCategoryList)"
/>
</div>
</div>
</template>
</ElTree>
</ElScrollbar>
</ElCard>
</div>
<div class="flex flex-col flex-grow min-w-0">
<ElCard class="art-table-card !mt-0" shadow="never">
<!-- 表格头部 -->
<div class="flex justify-between items-center mb-4">
<ElSpace wrap>
<ElUpload
v-permission="'core:system:uploadImage'"
class="upload-btn"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept="image/*"
>
<ElButton :icon="UploadFilled">上传图片</ElButton>
</ElUpload>
<ElButton
v-permission="'core:attachment:edit'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
<ElButton
v-permission="'core:attachment:edit'"
:disabled="selectedRows.length === 0"
@click="moveDialogVisible = true"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:swap-box-line" />
</template>
移动
</ElButton>
</ElSpace>
<ElSpace wrap>
<SaSelect
v-model="searchForm.storage_mode"
placeholder="请选择存储模式"
dict="upload_mode"
@change="handleSearch"
clearable
style="width: 160px"
/>
<ElInput
v-model="searchForm.origin_name"
placeholder="请输入文件名称"
:suffix-icon="Search"
@keyup.enter="handleSearch"
@clear="handleSearch"
clearable
style="width: 240px"
/>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:attachment:edit'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:attachment:edit'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</div>
<!-- 分类弹窗 -->
<CategoryDialog
v-model="categoryDialogVisible"
:dialog-type="categoryDialogType"
:data="categoryDialogData"
@success="getCategoryList"
/>
<!-- 移动弹窗 -->
<MoveDialog v-model="moveDialogVisible" :selected-rows="selectedRows" @success="refreshData" />
<!-- 表单弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/attachment'
import categoryApi from '@/api/safeguard/category'
import { uploadImage } from '@/api/auth'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { Search, UploadFilled } from '@element-plus/icons-vue'
import type { UploadRequestOptions, UploadProps } from 'element-plus'
import EditDialog from './modules/edit-dialog.vue'
import CategoryDialog from './modules/category-dialog.vue'
import MoveDialog from './modules/move-dialog.vue'
/** 附件分类数据 */
const treeData = ref([])
/** 获取附件分类数据 */
const getCategoryList = () => {
categoryApi.list({ tree: true }).then((data: any) => {
treeData.value = data
})
}
/**
* 切换附件分类
* @param data
*/
const handleNodeClick = (data: any) => {
if (data.id === 1) {
searchParams.category_id = undefined
} else {
searchParams.category_id = data.id
}
getData()
}
/** 附件分类弹窗相关 */
const {
dialogType: categoryDialogType,
dialogVisible: categoryDialogVisible,
dialogData: categoryDialogData,
showDialog: categoryShowDialog,
deleteRow: categoryDeleteRow
} = useSaiAdmin()
/** 移动弹窗相关 */
const moveDialogVisible = ref(false)
/** 附件弹窗相关 */
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
selectedRows,
handleSelectionChange,
deleteRow,
deleteSelectedRows
} = useSaiAdmin()
/** 附件搜索表单 */
const searchForm = ref({
origin_name: undefined,
storage_mode: undefined,
category_id: undefined,
orderField: 'create_time',
orderType: 'desc'
})
/** 附件表格相关 */
const {
columns,
data,
loading,
pagination,
getData,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'url', label: '预览', saiType: 'image', width: 80 },
{ prop: 'origin_name', label: '文件名称', minWidth: 160, showOverflowTooltip: true },
{
prop: 'storage_mode',
label: '存储模式',
width: 100,
saiType: 'dict',
saiDict: 'upload_mode'
},
{ prop: 'mime_type', label: '文件类型', width: 160, showOverflowTooltip: true },
{ prop: 'size_info', label: '文件大小', width: 100 },
{ prop: 'create_time', label: '上传时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
/** 附件搜索 */
const handleSearch = () => {
Object.assign(searchParams, searchForm.value)
getData()
}
/** 附件上传前验证 */
const beforeUpload: UploadProps['beforeUpload'] = (file) => {
const isImage = file.type.startsWith('image/')
if (!isImage) {
ElMessage.error('只能上传图片文件!')
return false
}
const isLt5M = file.size / 1024 / 1024 < 5
if (!isLt5M) {
ElMessage.error('图片大小不能超过 5MB!')
return false
}
return true
}
/** 附件处理上传 */
const handleUpload = async (options: UploadRequestOptions) => {
const { file } = options
try {
const formData = new FormData()
formData.append('file', file)
await uploadImage(formData)
ElMessage.success('上传成功')
refreshData()
} catch (error: any) {
console.error('上传失败:', error)
ElMessage.error(error.message || '上传失败')
}
}
/** 初始化附件分类数据 */
onMounted(() => {
getCategoryList()
})
</script>
<style lang="scss" scoped>
.tree-node-actions {
opacity: 0;
transition: opacity 0.2s;
display: flex;
gap: 4px;
}
.el-tree-node__content:hover .tree-node-actions {
opacity: 1;
}
:deep(.el-tree-node__content) {
height: 32px;
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增分类' : '编辑分类'"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="上级分类" prop="parent_id">
<el-tree-select
v-model="formData.parent_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
/>
</el-form-item>
<el-form-item label="分类名称" prop="category_name">
<el-input v-model="formData.category_name" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/category'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
parent_id: [{ required: true, message: '请选择上级分类', trigger: 'change' }],
category_name: [{ required: true, message: '请输入分类名称', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
parent_id: null,
level: '',
category_name: '',
sort: 100
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
const data = await api.list({ tree: true })
optionData.treeData = data
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,134 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增文件' : '编辑文件'"
width="800px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="文件名称" prop="origin_name">
<el-input v-model="formData.origin_name" placeholder="请输入文件名称" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import api from '@/api/safeguard/attachment'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 表单验证规则
const rules: FormRules = {
origin_name: [{ required: true, message: '请输入文件名称', trigger: 'blur' }]
}
// 初始表单数据
const initialFormData = {
id: '',
origin_name: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm(props.data)
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'edit') {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,147 @@
<template>
<el-dialog
v-model="visible"
title="移动到分类"
width="500px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item>
<div class="text-gray-600 mb-2">
已选择 <span class="text-primary font-medium">{{ selectedCount }}</span> 个文件
</div>
</el-form-item>
<el-form-item label="目标分类" prop="category_id">
<el-tree-select
v-model="formData.category_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
placeholder="请选择目标分类"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定移动</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/attachment'
import categoryApi from '@/api/safeguard/category'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
selectedRows: any[]
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
selectedRows: () => []
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 选中数量
*/
const selectedCount = computed(() => props.selectedRows.length)
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
category_id: [{ required: true, message: '请选择目标分类', trigger: 'change' }]
})
/**
* 初始数据
*/
const initialFormData = {
category_id: null
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 重置为初始值
Object.assign(formData, initialFormData)
const data = await categoryApi.list({ tree: true })
optionData.treeData = data
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
const ids = props.selectedRows.map((row) => row.id)
await api.move({
ids: ids,
category_id: formData.category_id
})
ElMessage.success(`成功移动 ${ids.length} 个文件`)
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
:label-width="'70px'"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,249 @@
<template>
<div class="page-content mb-5">
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 字典缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">数据字典-缓存信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.dict_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.dict_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理缓存
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.dict_cache?.expire }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 配置缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">系统配置-缓存信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.config_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.config_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理缓存
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.config_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="缓存前缀">
{{ cacheInfo.config_cache?.prefix }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 菜单缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">菜单数据-缓存信息</span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.menu_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.menu_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.menu_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="缓存前缀">
{{ cacheInfo.menu_cache?.prefix }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 权限缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">权限按钮-缓存信息</span>
<span class="text-sm text-gray-500"> 缓存权限按钮的数据 </span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.button_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.button_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.button_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="缓存前缀">
{{ cacheInfo.button_cache?.prefix }}
</el-descriptions-item>
<el-descriptions-item label="角色前缀">
{{ cacheInfo.button_cache?.role }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="24" class="mb-4">
<!-- 反射文件缓存 信息 -->
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">反射文件-缓存信息</span>
<span class="text-sm text-gray-500"> 缓存反射文件的反射属性的方法名称和权限参数 </span>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="缓存TAG">
<div class="flex-c">
<span>{{ cacheInfo.reflection_cache?.tag }}</span>
<ElButton
v-permission="'core:server:clear'"
class="ml-2"
v-ripple
@click="handleClearCache(cacheInfo.reflection_cache?.tag)"
>
<template #icon>
<ArtSvgIcon icon="ri:eraser-line" />
</template>
清理缓存
</ElButton>
</div>
</el-descriptions-item>
<el-descriptions-item label="有效期">
{{ cacheInfo.reflection_cache?.expire }}
</el-descriptions-item>
<el-descriptions-item label="非验证方法缓存前缀">
{{ cacheInfo.reflection_cache?.no_need }}
</el-descriptions-item>
<el-descriptions-item label="方法名称和权限缓存参数">
{{ cacheInfo.reflection_cache?.attr }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/server'
import { onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const cacheInfo = reactive({
menu_cache: {} as any,
button_cache: {} as any,
config_cache: {} as any,
dict_cache: {} as any,
reflection_cache: {} as any
})
/**
* 更新缓存信息
*/
const updateCacheInfo = async () => {
loading.value = true
try {
const data = await api.cache({})
cacheInfo.menu_cache = data.menu_cache
cacheInfo.button_cache = data.button_cache
cacheInfo.config_cache = data.config_cache
cacheInfo.dict_cache = data.dict_cache
cacheInfo.reflection_cache = data.reflection_cache
} finally {
loading.value = false
}
}
/**
* 清理缓存
*/
const handleClearCache = (tag: string): void => {
if (!tag) {
ElMessage.warning('请选择要清理的缓存')
return
}
ElMessageBox.confirm(`确定要清理标签:【${tag}】的缓存吗?`, '清理选中缓存', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(() => {
api.clear({ tag }).then(() => {
ElMessage.success('操作成功')
updateCacheInfo()
})
})
}
onMounted(() => {
updateCacheInfo()
})
</script>
<style lang="scss" scoped>
:deep(.el-descriptions__label) {
width: 200px;
}
:deep(.el-descriptions__content) {
width: 400px;
}
</style>

View File

@@ -0,0 +1,214 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:database:edit'"
:disabled="selectedRows.length === 0"
@click="handleOptimizeRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:tools-fill" />
</template>
优化表
</ElButton>
<ElButton
v-permission="'core:database:edit'"
:disabled="selectedRows.length === 0"
@click="handleFragmentRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:wrench-line" />
</template>
清理碎片
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="name"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:database:index'"
type="primary"
icon="ri:node-tree"
tool-tip="表结构"
@click="handleTableDialog(row)"
/>
<SaButton
v-permission="'core:recycle:index'"
type="success"
icon="ri:recycle-line"
tool-tip="回收站"
@click="handleRecycleDialog(row)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 表结构信息 -->
<TableDialog v-model="dialogVisible" :data="dialogData" />
<!-- 回收站 -->
<RecycleList v-model="recycleVisible" :data="recycleData" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { ElMessageBox } from 'element-plus'
import api from '@/api/safeguard/database'
import TableSearch from './modules/table-search.vue'
import TableDialog from './modules/table-dialog.vue'
import RecycleList from './modules/recycle-list.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
orderField: 'create_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'name', label: '表名称', minWidth: 200 },
{ prop: 'comment', label: '表注释', minWidth: 150, showOverflowTooltip: true },
{ prop: 'engine', label: '表引擎', width: 120 },
{ prop: 'update_time', label: '更新时间', width: 180, sortable: true },
{ prop: 'rows', label: '总行数', width: 120 },
{ prop: 'data_free', label: '碎片大小', width: 120 },
{ prop: 'data_length', label: '数据大小', width: 120 },
{ prop: 'collation', label: '字符集', width: 180 },
{ prop: 'create_time', label: '创建时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { dialogVisible, dialogData, selectedRows, handleSelectionChange } = useSaiAdmin()
const recycleVisible = ref(false)
const recycleData = ref({})
/**
* 表结构
* @param row
*/
const handleTableDialog = (row: Record<string, any>): void => {
dialogVisible.value = true
dialogData.value = row
}
/**
* 回收站
* @param row
*/
const handleRecycleDialog = (row: Record<string, any>): void => {
recycleVisible.value = true
recycleData.value = row
}
/**
* 优化表
*/
const handleOptimizeRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要优化的行')
return
}
ElMessageBox.confirm(
`确定要优化选中的 ${selectedRows.value.length} 条数据吗?`,
'优化选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api.optimize({ tables: selectedRows.value.map((row) => row.name) }).then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
/**
* 清理表碎片
*/
const handleFragmentRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要清理碎片的行')
return
}
ElMessageBox.confirm(
`确定要清理选中的 ${selectedRows.value.length} 条数据吗?`,
'清理碎片操作',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api.fragment({ tables: selectedRows.value.map((row) => row.name) }).then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
</script>

View File

@@ -0,0 +1,212 @@
<template>
<el-drawer
v-model="visible"
:title="`回收站 - ${props.data?.name}`"
size="70%"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<div class="art-full-height">
<!-- 表格头部 -->
<div>
<ElSpace wrap>
<ElButton
v-permission="'core:recycle:edit'"
:disabled="selectedRows.length === 0"
@click="handleDestroyRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
销毁
</ElButton>
<ElButton
v-permission="'core:recycle:edit'"
:disabled="selectedRows.length === 0"
@click="handleRestoreRows()"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:restart-line" />
</template>
恢复
</ElButton>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="tableData"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 数据详情插槽 -->
<template #json_data="{ row }">
{{ JSON.stringify(row) }}
</template>
</ArtTable>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/database'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
refreshData()
}
}
const refreshData = () => {
searchForm.value.table = props.data?.name
Object.assign(searchParams, searchForm.value)
getData()
}
const searchForm = ref({
table: null
})
const {
loading,
data: tableData,
columns,
getData,
pagination,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.getRecycle,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'delete_time', label: '删除时间', width: 180 },
{ prop: 'json_data', label: '数据详情', useSlot: true, showOverflowTooltip: true }
]
}
})
// 编辑配置
const { handleSelectionChange, selectedRows } = useSaiAdmin()
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
}
/**
* 销毁选中数据
*/
const handleDestroyRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要销毁的行')
return
}
ElMessageBox.confirm(
`确定要销毁选中的 ${selectedRows.value.length} 条数据吗?`,
'销毁选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api
.delete({ table: searchForm.value.table, ids: selectedRows.value.map((row) => row.id) })
.then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
/**
* 恢复选中数据
*/
const handleRestoreRows = (): void => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请选择要恢复的行')
return
}
ElMessageBox.confirm(
`确定要恢复选中的 ${selectedRows.value.length} 条数据吗?`,
'恢复选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api
.recovery({ table: searchForm.value.table, ids: selectedRows.value.map((row) => row.id) })
.then(() => {
ElMessage.success('操作成功')
refreshData()
selectedRows.value = []
})
})
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<el-dialog v-model="visible" title="表结构信息" width="800px" align-center @close="handleClose">
<div>
<el-table :data="tableData" style="width: 100%">
<el-table-column prop="column_name" label="字段名称" width="180"> </el-table-column>
<el-table-column prop="column_type" label="字段类型" width="120"> </el-table-column>
<el-table-column prop="column_key" label="字段索引" width="100"> </el-table-column>
<el-table-column prop="column_default" label="默认值" width="100"> </el-table-column>
<el-table-column prop="column_comment" label="字段注释" min-width="200" showOverflowTooltip>
</el-table-column>
</el-table>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/database'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
const tableData = ref<Api.Common.ApiData[]>([])
/**
* 初始化页面数据
*/
const initPage = async () => {
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
if (props.data.name) {
const data = await api.getDetailed({ table: props.data.name })
tableData.value = data
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
}
</script>

View File

@@ -0,0 +1,67 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="表名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入表名称" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,402 @@
<!-- 左右页面 -->
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="left-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<b>数据字典</b>
</template>
<ElSpace wrap>
<SaButton type="primary" icon="ri:refresh-line" @click="refreshTypeData" />
<SaButton
v-permission="'core:dict:edit'"
type="primary"
@click="typeShowDialog('add')"
/>
<SaButton v-permission="'core:dict:edit'" type="secondary" @click="updateTypeDialog" />
<SaButton v-permission="'core:dict:edit'" type="error" @click="deleteTypeDialog" />
</ElSpace>
<ArtTable
rowKey="id"
:loading="loading"
:data="typeData"
:columns="typeColumns"
:pagination="typePagination"
highlight-current-row
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 基础列 -->
<template #name-header="{ column }">
<ElPopover placement="bottom" :width="200" trigger="hover">
<template #reference>
<div class="flex items-center gap-2 text-theme c-p custom-header">
<span>{{ column.label }}</span>
<ElIcon>
<Search />
</ElIcon>
</div>
</template>
<ElInput
v-model="typeSearch.name"
placeholder="搜索字典名称"
size="small"
clearable
@input="handleTypeSearch"
>
<template #prefix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
</ElPopover>
</template>
<template #code-header="{ column }">
<ElPopover placement="bottom" :width="200" trigger="hover">
<template #reference>
<div class="flex items-center gap-2 text-theme c-p custom-header">
<span>{{ column.label }}</span>
<ElIcon>
<Search />
</ElIcon>
</div>
</template>
<ElInput
v-model="typeSearch.code"
placeholder="搜索字典标识"
size="small"
clearable
@input="handleTypeSearch"
>
<template #prefix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
</ElPopover>
</template>
<template #id="{ row }">
<ElRadio
v-model="selectedId"
:value="row.id"
@update:modelValue="handleTypeChange(row.id, row)"
/>
</template>
</ArtTable>
</ElCard>
</div>
<div class="flex flex-col flex-1 min-w-0" v-if="selectedId === 0">
<ElCard class="flex flex-col flex-5 min-h-0 !mt-0" shadow="never">
<el-empty description="请先选择左侧字典类型配置" />
</ElCard>
</div>
<div class="flex flex-col flex-1 min-w-0" v-if="selectedId > 0">
<DictSearch v-model="searchForm" @search="handleSearch" @reset="handleReset" />
<ElCard class="flex flex-col flex-5 min-h-0 art-table-card" shadow="never">
<ElSpace wrap>
<ElButton
v-permission="'core:dict:edit'"
@click="showDataDialog('add', { type_id: selectedId })"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'core:dict:edit'"
@click="deleteSelectedRows(api.dataDelete, getDictData)"
:disabled="selectedRows.length === 0"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
<ArtTable
rowKey="id"
:loading="loading"
:data="dictData"
:columns="dictColumns"
:pagination="dictPagination"
highlight-current-row
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 基础列 -->
<template #label="{ row }">
<ElTag
:style="{
backgroundColor: getColor(row.color, 'bg'),
borderColor: getColor(row.color, 'border'),
color: getColor(row.color, 'text')
}"
>
{{ row.label }}
</ElTag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:dict:edit'"
type="secondary"
@click="showDataDialog('edit', row)"
/>
<SaButton
v-permission="'core:dict:edit'"
type="error"
@click="deleteRow(row, api.dataDelete, getDictData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</div>
<!-- 字典编辑弹窗 -->
<TypeEditDialog
v-model="typeVisible"
:dialog-type="typeDialogType"
:data="currentTypeData"
@success="getTypeData()"
/>
<!-- 字典项编辑弹窗 -->
<DictEditDialog
v-model="dictVisible"
:dialog-type="dictDialogType"
:data="currentDictData"
@success="getDictData()"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import api from '@/api/safeguard/dict'
import DictSearch from '@/views/safeguard/dict/modules/dict-search.vue'
import DictEditDialog from './modules/dict-edit-dialog.vue'
import TypeEditDialog from './modules/type-edit-dialog.vue'
// 字典类型数据
const {
dialogType: typeDialogType,
dialogVisible: typeVisible,
dialogData: currentTypeData,
showDialog: typeShowDialog,
deleteRow: typeDeleteRow
} = useSaiAdmin()
// 字典类型
const selectedId = ref(0)
const selectedRow = ref({})
const typeSearch = ref({
name: '',
code: ''
})
/** 修改字典类型 */
const updateTypeDialog = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要修改的数据')
return
}
typeShowDialog('edit', { ...selectedRow.value })
}
/** 删除字典类型 */
const deleteTypeDialog = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要删除的数据')
return
}
typeDeleteRow({ ...selectedRow.value }, api.delete, refreshTypeData)
}
/** 字典类型搜索 */
const handleTypeSearch = () => {
Object.assign(searchTypeParams, typeSearch.value)
getTypeData()
}
/** 字典类型切换 */
const handleTypeChange = (val: any, row?: any) => {
selectedId.value = val
selectedRow.value = row
searchForm.value.type_id = val
Object.assign(searchParams, searchForm.value)
getDictData()
}
/** 刷新数据 */
const refreshTypeData = () => {
selectedId.value = 0
selectedRow.value = {}
getTypeData()
getDictData()
}
// 字典类型数据
const {
data: typeData,
columns: typeColumns,
getData: getTypeData,
searchParams: searchTypeParams,
loading,
pagination: typePagination,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.typeList,
apiParams: {
...typeSearch.value
},
columnsFactory: () => [
{ prop: 'id', label: '选中', width: 80, align: 'center', useSlot: true },
{ prop: 'name', label: '字典名称', useHeaderSlot: true, width: 150 },
{ prop: 'code', label: '字典标识', useHeaderSlot: true, width: 150 },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status', width: 100 }
]
}
})
// 字典项数据
const {
dialogType: dictDialogType,
dialogVisible: dictVisible,
dialogData: currentDictData,
showDialog: showDataDialog,
deleteRow,
handleSelectionChange,
selectedRows,
deleteSelectedRows
} = useSaiAdmin()
/** 字典项搜索 */
const searchForm = ref({
label: '',
value: '',
status: '',
type_id: null
})
// 字典项数据
const {
data: dictData,
columns: dictColumns,
getData: getDictData,
pagination: dictPagination,
searchParams
} = useTable({
core: {
apiFn: api.dataList,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'label', label: '字典标签', useSlot: true },
{ prop: 'value', label: '字典键值' },
{ prop: 'color', label: '颜色' },
{ prop: 'sort', label: '排序' },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status' },
{ prop: 'operation', label: '操作', useSlot: true, width: 120 }
]
}
})
// 字典项搜索
const handleSearch = (params: Record<string, any>) => {
if (selectedId.value) {
Object.assign(searchParams, params)
getDictData()
}
}
// 字典项重置搜索
const handleReset = () => {
if (!selectedId.value) {
ElMessage.warning('请选择字典类型')
return
}
Object.assign(searchParams, {
label: '',
value: '',
status: '',
type_id: selectedId.value
})
getDictData()
}
const getColor = (color: string | undefined, type: 'bg' | 'border' | 'text') => {
// 如果没有指定颜色,使用默认主色调
if (!color) {
const colors = {
bg: 'var(--el-color-primary-light-9)',
border: 'var(--el-color-primary-light-8)',
text: 'var(--el-color-primary)'
}
return colors[type]
}
// 如果是 hex 颜色,转换为 RGB
let r, g, b
if (color.startsWith('#')) {
const hex = color.slice(1)
r = parseInt(hex.slice(0, 2), 16)
g = parseInt(hex.slice(2, 4), 16)
b = parseInt(hex.slice(4, 6), 16)
} else if (color.startsWith('rgb')) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
if (match) {
r = parseInt(match[1])
g = parseInt(match[2])
b = parseInt(match[3])
} else {
return color
}
} else {
return color
}
// 根据类型返回不同的颜色变体
switch (type) {
case 'bg':
// 背景色 - 更浅的版本
return `rgba(${r}, ${g}, ${b}, 0.1)`
case 'border':
// 边框色 - 中等亮度
return `rgba(${r}, ${g}, ${b}, 0.3)`
case 'text':
// 文字色 - 原始颜色
return `rgb(${r}, ${g}, ${b})`
default:
return color
}
}
</script>
<style scoped>
.left-card :deep(.el-card__body) {
flex: 1;
min-height: 0;
padding: 10px 2px 10px 10px;
}
</style>

View File

@@ -0,0 +1,184 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增字典项数据' : '编辑字典项数据'"
width="600px"
align-center
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="字典标签" prop="label">
<el-input v-model="formData.label" placeholder="请输入字典标签" />
</el-form-item>
<el-form-item label="字典键值" prop="value">
<el-input v-model="formData.value" placeholder="请输入字典键值" />
</el-form-item>
<el-form-item label="颜色选择" prop="color">
<el-color-picker v-model="formData.color" color-format="hex" :predefine="predefineColors" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="状态" prop="status">
<SaiRadio v-model="formData.status" dict="data_status" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/dict'
import { ElMessage } from 'element-plus'
import { useDictStore } from '@/store/modules/dict'
import SaiRadio from '@/components/sai/sa-radio/index.vue'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const dictStore = useDictStore()
const formRef = ref<FormInstance>()
const predefineColors = ref([
'#ff4500',
'#ff8c00',
'#ffd700',
'#90ee90',
'#00ced1',
'#1e90ff',
'#c71585',
'#5d87ff',
'#b48df3',
'#1d84ff',
'#60c041',
'#38c0fc',
'#f9901f',
'#ff80c8',
'#909399'
])
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
label: [{ required: true, message: '请输入字典标签', trigger: 'blur' }],
value: [{ required: true, message: '请输入字典键值', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
type_id: '',
code: '',
label: '',
color: '#5d87ff',
value: '',
remark: '',
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm(props.data)
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.dataSave(formData)
ElMessage.success('新增成功')
} else {
await api.dataUpdate(formData)
ElMessage.success('修改成功')
}
dictStore.refresh()
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="80px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="字典标签" prop="label">
<el-input v-model="formData.label" placeholder="请输入字典标签" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="字典键值" prop="value">
<el-input v-model="formData.value" placeholder="请输入字典键值" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,153 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增字典数据' : '编辑字典数据'"
width="600px"
align-center
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="字典名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入字典名称" />
</el-form-item>
<el-form-item label="字典标识" prop="code">
<el-input v-model="formData.code" placeholder="请输入字典标识" />
</el-form-item>
<el-form-item label="状态" prop="status">
<SaiRadio v-model="formData.status" dict="data_status" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/dict'
import { ElMessage } from 'element-plus'
import SaiRadio from '@/components/sai/sa-radio/index.vue'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入字典名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入字典标识', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
name: '',
code: '',
remark: '',
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm(props.data)
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:email:destroy'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #status="{ row }">
<ElTag v-if="row.status == 'success'" type="success">成功</ElTag>
<ElTag v-else type="danger">失败</ElTag>
</template>
<template #operation="{ row }">
<div class="flex">
<SaButton
v-permission="'core:email:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/safeguard/emailLog'
import TableSearch from './modules/table-search.vue'
// 搜索表单
const searchForm = ref({
from: undefined,
email: undefined,
status: undefined,
create_time: undefined,
orderField: 'create_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'gateway', label: '服务Host' },
{ prop: 'from', label: '发件人', minWidth: 150, showOverflowTooltip: true },
{ prop: 'email', label: '收件人', minWidth: 150, showOverflowTooltip: true },
{ prop: 'code', label: '验证码' },
{ prop: 'status', label: '发送状态', useSlot: true },
{ prop: 'response', label: '发送结果', minWidth: 150, showOverflowTooltip: true },
{ prop: 'create_time', label: '发送时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 80, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { deleteRow, deleteSelectedRows, selectedRows, handleSelectionChange } = useSaiAdmin()
</script>

View File

@@ -0,0 +1,93 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="true"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="发件人" prop="from">
<el-input v-model="formData.from" placeholder="请输入发件人" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="收件人" prop="email">
<el-input v-model="formData.email" placeholder="请输入收件人" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="发送状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择发送状态" clearable>
<el-option label="成功" value="success" />
<el-option label="失败" value="failure" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(12)" v-show="isExpanded">
<el-form-item label="发送时间" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,122 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:logs:deleteLogin'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #status="{ row }">
<ElTag v-if="row.status == 1" type="success">成功</ElTag>
<ElTag v-else type="danger">失败</ElTag>
</template>
<template #operation="{ row }">
<div class="flex">
<SaButton
v-permission="'core:logs:deleteLogin'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/safeguard/loginLog'
import TableSearch from './modules/table-search.vue'
// 搜索表单
const searchForm = ref({
username: undefined,
ip: undefined,
status: undefined,
login_time: undefined,
orderField: 'login_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'username', label: '登录用户' },
{ prop: 'status', label: '登录状态', useSlot: true },
{ prop: 'ip', label: '登录IP' },
{ prop: 'ip_location', label: '登录地点' },
{ prop: 'os', label: '操作系统' },
{ prop: 'browser', label: '浏览器' },
{ prop: 'message', label: '登录信息', showOverflowTooltip: true },
{ prop: 'login_time', label: '登录时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 80, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { deleteRow, deleteSelectedRows, selectedRows, handleSelectionChange } = useSaiAdmin()
</script>

View File

@@ -0,0 +1,93 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="true"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="登录用户" prop="username">
<el-input v-model="formData.username" placeholder="请输入登录用户" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="登录IP" prop="ip">
<el-input v-model="formData.ip" placeholder="请输入登录IP" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="登录状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择登录状态" clearable>
<el-option label="成功" value="1" />
<el-option label="失败" value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col v-bind="setSpan(12)" v-show="isExpanded">
<el-form-item label="登录时间" prop="login_time">
<el-date-picker
v-model="formData.login_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton
v-permission="'core:logs:deleteOper'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton type="success" @click="handleParams(row)" />
<SaButton
v-permission="'core:logs:deleteOper'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { ElMessageBox } from 'element-plus'
import api from '@/api/safeguard/operLog'
import TableSearch from './modules/table-search.vue'
// 搜索表单
const searchForm = ref({
username: undefined,
ip: undefined,
service_name: undefined,
router: undefined,
create_time: undefined,
orderField: 'create_time',
orderType: 'desc'
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'username', label: '操作用户' },
{ prop: 'service_name', label: '业务名称' },
{ prop: 'router', label: '路由', minWidth: 180, showOverflowTooltip: true },
{ prop: 'ip', label: '操作IP' },
{ prop: 'ip_location', label: '操作地点' },
{ prop: 'create_time', label: '操作时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { deleteRow, deleteSelectedRows, selectedRows, handleSelectionChange } = useSaiAdmin()
// 预览参数
const handleParams = (row: any) => {
let formattedData = row.request_data
// 尝试格式化JSON数据
if (row.request_data) {
try {
// 如果已经是对象,直接格式化;如果是字符串,先解析再格式化
const parsedData =
typeof row.request_data === 'string' ? JSON.parse(row.request_data) : row.request_data
formattedData = JSON.stringify(parsedData, null, 2)
} catch (error) {
// 如果解析失败,保持原样显示
formattedData = row.request_data
console.log('Error parsing JSON:', error)
}
}
ElMessageBox({
title: '请求参数',
message: h(
'div',
{
style: {
maxHeight: '400px',
minWidth: '380px',
overflow: 'auto',
backgroundColor: '#f5f5f5',
padding: '16px',
borderRadius: '4px'
}
},
[
h(
'pre',
{
style: {
margin: 0,
fontFamily: 'Consolas, Monaco, "Courier New", monospace',
fontSize: '14px',
lineHeight: '1.5',
color: '#333'
}
},
formattedData
)
]
),
callback: () => {}
})
}
</script>

View File

@@ -0,0 +1,90 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="true"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="操作用户" prop="username">
<el-input v-model="formData.username" placeholder="请输入操作用户" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="操作路由" prop="router">
<el-input v-model="formData.router" placeholder="请输入操作路由" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="操作IP" prop="ip">
<el-input v-model="formData.ip" placeholder="请输入操作IP" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(12)" v-show="isExpanded">
<el-form-item label="操作时间" prop="create_time">
<el-date-picker
v-model="formData.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,213 @@
<template>
<div class="page-content mb-5">
<el-row :gutter="20">
<!-- 内存 信息 -->
<el-col :span="24" class="mb-4">
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">内存信息</span>
</template>
<div class="flex justify-between">
<div class="flex-1">
<el-descriptions :column="1" border>
<el-descriptions-item label="总内存">
{{ serverInfo.memory.total }}
</el-descriptions-item>
<el-descriptions-item label="已使用内存">
{{ serverInfo.memory.used }}
</el-descriptions-item>
<el-descriptions-item label="PHP使用内存">
{{ serverInfo.memory.php }}
</el-descriptions-item>
<el-descriptions-item label="空闲内存">
{{ serverInfo.memory.free }}
</el-descriptions-item>
<el-descriptions-item label="使用率">
{{ serverInfo.memory.rate }}
</el-descriptions-item>
</el-descriptions>
</div>
<div class="w-80 p-4 text-center">
<div class="pb-3.5">
<span class="text-base font-medium">内存使用率</span>
</div>
<el-progress
type="dashboard"
:percentage="Number.parseFloat(serverInfo.memory.rate)"
/>
</div>
</div>
</el-card>
</el-col>
<!-- PHP 信息 -->
<el-col :span="24" class="mb-4">
<el-card class="art-table-card" shadow="never">
<template #header>
<span class="text-lg font-medium">PHP及环境信息</span>
</template>
<div class="py-2">
<el-descriptions :column="2" border class="php-config" v-if="serverInfo.phpEnv">
<el-descriptions-item
label="PHP版本"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.php_version }}
</el-descriptions-item>
<el-descriptions-item
label="操作系统"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.os }}
</el-descriptions-item>
<el-descriptions-item
label="项目路径"
label-class-name="php-label"
content-class-name="php-content"
>
<div class="project-path">{{ serverInfo.phpEnv?.project_path }}</div>
</el-descriptions-item>
<el-descriptions-item
label="内存限制"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.memory_limit }}
</el-descriptions-item>
<el-descriptions-item
label="最大执行时间"
label-class-name="php-label"
content-class-name="php-content"
>
{{
serverInfo.phpEnv?.max_execution_time === '0'
? '无限制'
: `${serverInfo.phpEnv?.max_execution_time}`
}}
</el-descriptions-item>
<el-descriptions-item
label="错误报告"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.error_reporting }}
</el-descriptions-item>
<el-descriptions-item
label="显示错误"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.display_errors }}
</el-descriptions-item>
<el-descriptions-item
label="上传限制"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.upload_max_filesize }}
</el-descriptions-item>
<el-descriptions-item
label="POST大小"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.post_max_size }}
</el-descriptions-item>
<el-descriptions-item
label="扩展目录"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.extension_dir }}
</el-descriptions-item>
<el-descriptions-item
label="扩展目录"
label-class-name="php-label"
content-class-name="php-content"
>
{{ serverInfo.phpEnv?.loaded_extensions }}
</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
</el-col>
<!-- 磁盘 信息 -->
<el-col :span="24" class="mb-4">
<el-card class="art-table-card" shadow="never">
<template #header>
<div class="card-header">
<span><i class="el-icon-disk"></i> 磁盘监控</span>
</div>
</template>
<el-table :data="serverInfo.disk" style="width: 100%">
<el-table-column prop="filesystem" label="文件系统" />
<el-table-column prop="size" label="总大小" />
<el-table-column prop="used" label="已用空间" />
<el-table-column prop="available" label="可用空间" />
<el-table-column prop="use_percentage" label="使用率">
<template #default="{ row }">
<el-progress
:percentage="parseInt(row.use_percentage.replace('%', ''))"
:stroke-width="12"
:show-text="true"
/>
</template>
</el-table-column>
<el-table-column prop="mounted_on" label="挂载点" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import api from '@/api/safeguard/server'
import { onMounted } from 'vue'
const loading = ref(false)
const serverInfo = reactive({
memory: {
total: '',
used: '',
rate: '',
php: '',
free: ''
},
disk: [] as any[],
phpEnv: {} as any
})
/**
* 更新服务器信息
*/
const updateServer = async () => {
loading.value = true
try {
const data = await api.monitor({})
serverInfo.memory = data.memory
serverInfo.phpEnv = data.phpEnv
serverInfo.disk = data.disk
} finally {
loading.value = false
}
}
onMounted(() => {
updateServer()
})
</script>
<style lang="scss" scoped>
:deep(.el-descriptions__label) {
width: 200px;
}
:deep(.el-descriptions__content) {
width: 400px;
}
</style>

View File

@@ -0,0 +1,429 @@
<!-- 左右页面 -->
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="left-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<b>系统设置</b>
</template>
<ElSpace wrap>
<SaButton type="primary" icon="ri:refresh-line" @click="reloadConfigData" />
<SaButton v-permission="'core:config:edit'" type="primary" @click="showDialog('add')" />
<SaButton
v-permission="'core:config:edit'"
type="secondary"
@click="updateConfigDialog"
/>
<SaButton v-permission="'core:config:edit'" type="error" @click="deleteConfigData" />
</ElSpace>
<ArtTable
rowKey="id"
:loading="loading"
:data="groupData"
:columns="groupColumns"
:pagination="groupPagination"
highlight-current-row
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 基础列 -->
<template #name-header="{ column }">
<ElPopover placement="bottom" :width="200" trigger="hover">
<template #reference>
<div class="flex items-center gap-2 text-theme c-p custom-header">
<span>{{ column.label }}</span>
<ElIcon>
<Search />
</ElIcon>
</div>
</template>
<ElInput
v-model="configSearch.name"
placeholder="搜索配置名称"
size="small"
clearable
@input="handleConfigSearch"
>
<template #prefix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
</ElPopover>
</template>
<template #code-header="{ column }">
<ElPopover placement="bottom" :width="200" trigger="hover">
<template #reference>
<div class="flex items-center gap-2 text-theme c-p custom-header">
<span>{{ column.label }}</span>
<ElIcon>
<Search />
</ElIcon>
</div>
</template>
<ElInput
v-model="configSearch.code"
placeholder="搜索配置标识"
size="small"
clearable
@input="handleConfigSearch"
>
<template #prefix>
<ElIcon>
<Search />
</ElIcon>
</template>
</ElInput>
</ElPopover>
</template>
<template #id="{ row }">
<ElRadio
v-model="selectedId"
:value="row.id"
@update:modelValue="handleGroupChange(row.id, row)"
/>
</template>
</ArtTable>
</ElCard>
</div>
<div class="flex flex-col flex-1 min-w-0">
<ElCard class="art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<div class="flex justify-between">
<b>{{ selectedRow.name || '未选择配置' }}</b>
<SaButton
v-permission="'core:config:edit'"
type="primary"
icon="ri:settings-4-line"
@click="handleConfigManage"
/>
</div>
</template>
<div class="max-h-[calc(100vh-250px)] overflow-y-auto">
<ElForm ref="formRef" :model="formData" label-width="140px">
<template v-for="(item, index) in formArray" :key="index">
<ElFormItem :label="item.name" :prop="item.key" v-show="item.display">
<template v-if="item.input_type === 'select'">
<el-select
v-model="item.value"
:options="item.config_select_data"
@change="handleSelect($event, item)"
:placeholder="'请选择' + item.name"
/>
</template>
<template v-if="item.input_type === 'input'">
<el-input v-model="item.value" :placeholder="'请输入' + item.name" />
</template>
<template v-if="item.input_type === 'radio'">
<el-radio-group v-model="item.value" :options="item.config_select_data" />
</template>
<template v-if="item.input_type === 'textarea'">
<el-input
type="textarea"
v-model="item.value"
:placeholder="'请输入' + item.name"
/>
</template>
<template v-if="item.input_type === 'uploadImage'">
<sa-image-picker v-model="item.value" />
</template>
<template v-if="item.input_type === 'uploadFile'">
<sa-file-upload v-model="item.value" />
</template>
<template v-if="item.input_type === 'wangEditor'">
<sa-editor v-model="item.value" />
</template>
<div class="text-gray-400 text-xs py-2">{{ item.remark }}</div>
</ElFormItem>
</template>
<ElFormItem v-permission="'core:config:update'" v-if="formArray.length > 0">
<ElButton type="primary" @click="submit(formArray)">保存修改</ElButton>
</ElFormItem>
<ElFormItem
v-permission="'core:config:update'"
label="测试邮件"
v-if="selectedRow.code === 'email_config'"
>
<div class="flex items-center gap-2">
<ElInput
v-model="email"
style="width: 300px"
placeholder="请输入正确的邮箱接收地址"
/>
<ElButton @click="sendMail()">
<template #icon>
<ArtSvgIcon icon="ri:mail-line" />
</template>
发送
</ElButton>
</div>
</ElFormItem>
<el-empty v-if="selectedId === 0" description="请先选择左侧配置" />
</ElForm>
</div>
</ElCard>
</div>
</div>
<!-- 配置编辑弹窗 -->
<GroupEditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="reloadConfigData()"
/>
<!-- 配置项管理 -->
<ConfigList v-model="configVisible" :data="selectedRow" @success="getConfigData()" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import api from '@/api/system/config'
import GroupEditDialog from './modules/group-edit-dialog.vue'
import ConfigList from './modules/config-list.vue'
defineOptions({ name: 'TreeTable' })
// 刷新配置数据
const reloadConfigData = () => {
selectedId.value = 0
selectedRow.value = {}
formArray.value = []
getGroupData()
}
// 修改配置
const updateConfigDialog = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要修改的数据')
return
}
showDialog('edit', selectedRow.value)
}
// 删除配置
const deleteConfigData = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要修改的数据')
return
}
deleteRow({ ...selectedRow.value }, api.delete, reloadConfigData)
}
// 配置数据
const formData = ref({})
const formArray = ref<any[]>([])
const email = ref('')
const configVisible = ref(false)
// 配置选中行
const selectedId = ref(0)
const selectedRow = ref<any>({})
const configSearch = ref({
name: '',
code: ''
})
// 配置搜索
const handleConfigSearch = () => {
Object.assign(searchConfigParams, configSearch.value)
getGroupData()
}
const searchForm = ref({
label: '',
value: '',
status: '',
group_id: null
})
/**
* 配置分组改变时,获取配置数据
*/
const handleGroupChange = (val: any, row?: any) => {
selectedId.value = val
selectedRow.value = row
searchForm.value.group_id = val
getConfigData()
}
const getConfigData = () => {
api.configList({ group_id: selectedId.value, saiType: 'all' }).then((data) => {
formArray.value = data.map((item: any) => {
if (
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
} else {
item.display = true
}
return item
})
if (selectedId.value === 2) {
formArray.value.map((item) => {
if (item.key === 'upload_mode') {
handleSelect(item.value, item)
}
})
}
})
}
// 配置名称
const {
data: groupData,
columns: groupColumns,
getData: getGroupData,
searchParams: searchConfigParams,
loading,
pagination: groupPagination,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.groupList,
apiParams: {
...configSearch.value
},
columnsFactory: () => [
{ prop: 'id', label: '选中', width: 80, align: 'center', useSlot: true },
{ prop: 'name', label: '配置名称', useHeaderSlot: true, width: 150 },
{ prop: 'code', label: '配置标识', useHeaderSlot: true, width: 150 }
]
}
})
// 编辑配置
const { dialogType, dialogVisible, dialogData, showDialog, deleteRow } = useSaiAdmin()
const handleConfigManage = () => {
if (selectedId.value === 0) {
ElMessage.error('请选择要管理的配置')
return
}
configVisible.value = true
}
// 发送测试邮件
const sendMail = async () => {
const reg = /^[a-z0-9]+([._\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$/
if (!reg.test(email.value)) {
ElMessage.warning('请输入正确的邮箱地址')
return
}
await api.emailTest({ email: email.value })
ElMessage.success('发送成功')
}
// 自定义处理切换显示
const handleSelect = async (val: any, ele: any) => {
if (ele.key === 'upload_mode') {
if (val == 1) {
formArray.value.map((item) => {
if (item.key.indexOf('local_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 2) {
formArray.value.map((item) => {
if (item.key.indexOf('oss_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 3) {
formArray.value.map((item) => {
if (item.key.indexOf('qiniu_') !== -1) {
item.display = true
}
if (
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 4) {
formArray.value.map((item) => {
if (item.key.indexOf('cos_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('oss_') !== -1 ||
item.key.indexOf('s3_') !== -1
) {
item.display = false
}
})
}
if (val == 5) {
formArray.value.map((item) => {
if (item.key.indexOf('s3_') !== -1) {
item.display = true
}
if (
item.key.indexOf('qiniu_') !== -1 ||
item.key.indexOf('cos_') !== -1 ||
item.key.indexOf('local_') !== -1 ||
item.key.indexOf('oss_') !== -1
) {
item.display = false
}
})
}
}
}
const submit = async (params: any) => {
const data = {
group_id: selectedId.value,
config: params
}
await api.batchUpdate(data)
ElMessage.success('保存成功')
}
</script>
<style scoped>
.left-card :deep(.el-card__body) {
flex: 1;
min-height: 0;
padding: 10px 2px 10px 10px;
}
</style>

View File

@@ -0,0 +1,228 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增配置' : '编辑配置'"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="配置标识" prop="key">
<el-input v-model="formData.key" placeholder="请输入配置标识" />
</el-form-item>
<el-form-item label="配置标题" prop="name">
<el-input v-model="formData.name" placeholder="请输入配置标题" />
</el-form-item>
<el-form-item label="组件类型" prop="input_type">
<el-select v-model="formData.input_type" :options="inputComponent" />
</el-form-item>
<el-form-item
label="组件数据"
prop="config_select_data"
v-if="['select', 'radio'].includes(formData.input_type)"
>
<el-row
:gutter="10"
class="mb-2"
v-for="(item, index) in formData.config_select_data"
:key="index"
>
<el-col :span="10">
<el-input v-model="item.label" placeholder="请输入label"></el-input>
</el-col>
<el-col :span="10">
<el-input v-model="item.value" placeholder="请输入value"></el-input>
</el-col>
<el-col :span="4">
<el-button type="danger" @click="removeConfigSelectData(index)">
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
</el-button>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-button type="primary" @click="addConfigSelectData">
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
</el-button>
</el-col>
</el-row>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="描述" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入配置描述"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/config'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
interface ConfigSelectData {
label: string
value: string
}
const inputComponent = [
{ label: '文本框', value: 'input' },
{ label: '文本域', value: 'textarea' },
{ label: '下拉选择框', value: 'select' },
{ label: '单选框', value: 'radio' },
{ label: '图片上传', value: 'uploadImage' },
{ label: '文件上传', value: 'uploadFile' },
{ label: '富文本编辑器', value: 'wangEditor' }
]
const addConfigSelectData = () => {
formData.config_select_data.push({ label: '', value: '' })
}
const removeConfigSelectData = (index: number) => {
formData.config_select_data.splice(index, 1)
}
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
key: [{ required: true, message: '请输入配置标识', trigger: 'blur' }],
name: [{ required: true, message: '请输入配置标题', trigger: 'blur' }],
input_type: [{ required: true, message: '请输入组件类型', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
group_id: null,
key: '',
value: '',
name: '',
input_type: 'input',
config_select_data: [] as ConfigSelectData[],
sort: 100,
remark: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
formData.config_select_data = []
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.configSave(formData)
ElMessage.success('新增成功')
} else {
await api.configUpdate(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,192 @@
<template>
<el-drawer
v-model="visible"
title="配置管理"
size="70%"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<div class="art-full-height">
<!-- 表格头部 -->
<div>
<ElSpace wrap>
<ElButton v-permission="'core:config:edit'" @click="handleAddClick" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'core:config:edit'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.configDelete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="tableData"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:config:edit'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:config:edit'"
type="error"
@click="deleteRow(row, api.configDelete, refreshData)"
/>
</div>
</template>
</ArtTable>
<ConfigEditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import api from '@/api/system/config'
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import ConfigEditDialog from './config-edit-dialog.vue'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
refreshData()
}
}
const refreshData = () => {
searchForm.value.group_id = props.data?.id
Object.assign(searchParams, searchForm.value)
getData()
}
const handleAddClick = () => {
showDialog('add', { group_id: searchForm.value.group_id })
}
const searchForm = ref({
label: '',
value: '',
status: '',
group_id: null
})
const {
loading,
data: tableData,
columns,
getData,
pagination,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.configList,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'key', label: '配置标识' },
{ prop: 'name', label: '配置标题' },
{ prop: 'input_type', label: '组件类型', width: 100 },
{ prop: 'sort', label: '排序', width: 100, sortable: true },
{ prop: 'remark', label: '备注' },
{ prop: 'operation', label: '操作', useSlot: true, width: 100 }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
emit('success')
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="80px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="字典标签" prop="label">
<el-input v-model="formData.label" placeholder="请输入字典标签" />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="字典键值" prop="value">
<el-input v-model="formData.value" placeholder="请输入字典键值" />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,151 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增配置分组' : '编辑配置分组'"
width="600px"
align-center
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="配置名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入配置名称" />
</el-form-item>
<el-form-item label="配置标识" prop="code">
<el-input v-model="formData.code" placeholder="请输入配置标识" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/config'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入配置名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入配置标识', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
name: '',
code: '',
remark: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:dept:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton @click="toggleExpand" v-ripple>
<template #icon>
<ArtSvgIcon v-if="isExpanded" icon="ri:collapse-diagonal-line" />
<ArtSvgIcon v-else icon="ri:expand-diagonal-line" />
</template>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:default-expand-all="true"
@sort-change="handleSortChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:dept:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:dept:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/system/dept'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 状态管理
const isExpanded = ref(true)
const tableRef = ref()
// 搜索表单
const searchForm = ref({
name: undefined,
code: undefined,
status: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ prop: 'name', label: '部门名称', minWidth: 200 },
{ prop: 'code', label: '部门编码', minWidth: 120 },
{ prop: 'leader.username', label: '部门领导', minWidth: 120 },
{ prop: 'remark', label: '描述', minWidth: 150, showOverflowTooltip: true },
{ prop: 'sort', label: '排序', width: 100 },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status', width: 100 },
{ prop: 'create_time', label: '创建日期', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { dialogType, dialogVisible, dialogData, showDialog, deleteRow } = useSaiAdmin()
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value?.elTableRef && data.value) {
const processRows = (rows: any[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
processRows(row.children)
}
})
}
processRows(data.value)
}
})
}
</script>

View File

@@ -0,0 +1,194 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增部门' : '编辑部门'"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="上级部门" prop="parent_id">
<el-tree-select
v-model="formData.parent_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
/>
</el-form-item>
<el-form-item label="部门名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入部门名称" />
</el-form-item>
<el-form-item label="部门编码" prop="code">
<el-input v-model="formData.code" placeholder="请输入部门编码" />
</el-form-item>
<el-form-item label="部门领导">
<sa-user v-model="formData.leader_id" />
</el-form-item>
<el-form-item label="描述" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入部门描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="启用" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/dept'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
parent_id: [{ required: true, message: '请选择上级部门', trigger: 'change' }],
name: [{ required: true, message: '请输入部门名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入部门编码', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
parent_id: null,
level: '',
name: '',
code: '',
leader_id: null,
remark: '',
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
const data = await api.list({ tree: true })
optionData.treeData = [
{
id: 0,
value: 0,
label: '无上级部门',
children: data
}
]
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="部门名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入部门名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="部门编码" prop="code">
<el-input v-model="formData.code" placeholder="请输入部门编码" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,223 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:menu:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'core:menu:destroy'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
<ElButton @click="toggleExpand" v-ripple>
<template #icon>
<ArtSvgIcon v-if="isExpanded" icon="ri:collapse-diagonal-line" />
<ArtSvgIcon v-else icon="ri:expand-diagonal-line" />
</template>
{{ isExpanded ? '收起' : '展开' }}
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:default-expand-all="false"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex justify-end gap-2">
<SaButton
v-permission="'core:menu:save'"
v-if="row.type < 3"
type="primary"
@click="handleAdd(row)"
/>
<SaButton
v-permission="'core:menu:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:menu:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/system/menu'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import { h } from 'vue'
import ArtSvgIcon from '@/components/core/base/art-svg-icon/index.vue'
// 状态管理
const isExpanded = ref(false)
const tableRef = ref()
// 搜索表单
const searchForm = ref({
name: undefined,
path: undefined,
status: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
resetSearchParams,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'name', label: '菜单名称', minWidth: 150 },
{
prop: 'type',
label: '菜单类型',
align: 'center',
saiType: 'dict',
saiDict: 'menu_type',
width: 100
},
{
prop: 'icon',
label: '图标',
align: 'center',
width: 80,
formatter: (row: any) => {
return h(ArtSvgIcon, { icon: row.icon })
}
},
{ prop: 'code', label: '组件名称' },
{
prop: 'path',
label: '路由',
formatter: (row: any) => {
if (row.type === 3) return ''
return row.path || ''
}
},
{
prop: 'slug',
label: '权限标识',
minWidth: 160,
formatter: (row: any) => {
if (row.type === 2) {
return row.children?.length ? row.children.length + '个权限标识' : ''
}
if (row.type === 3) {
return row.slug || ''
}
return ''
}
},
{ prop: 'sort', label: '排序', width: 100 },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status', width: 100 },
{ prop: 'operation', label: '操作', width: 140, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
selectedRows,
deleteRow,
deleteSelectedRows,
handleSelectionChange
} = useSaiAdmin()
/**
* 切换展开/收起所有菜单
*/
const toggleExpand = (): void => {
isExpanded.value = !isExpanded.value
nextTick(() => {
if (tableRef.value?.elTableRef && data.value) {
const processRows = (rows: any[]) => {
rows.forEach((row) => {
if (row.children?.length) {
tableRef.value.elTableRef.toggleRowExpansion(row, isExpanded.value)
processRows(row.children)
}
})
}
processRows(data.value)
}
})
}
/**
* 添加子项
* @param row
*/
const handleAdd = (row: any) => {
let data = { parent_id: row.id, type: 1 }
if (row.type === 2) {
data.type = 3
}
showDialog('add', data)
}
</script>

View File

@@ -0,0 +1,322 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增菜单' : '编辑菜单'"
width="820px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="菜单类型" prop="type">
<sa-radio v-model="formData.type" type="button" dict="menu_type"></sa-radio>
</el-form-item>
<el-form-item label="上级菜单" prop="parent_id">
<el-tree-select
v-model="formData.parent_id"
:data="optionData.treeData"
:render-after-expand="false"
check-strictly
clearable
/>
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="菜单名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入菜单名称" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type < 3">
<el-form-item prop="path">
<template #label>
<sa-label
label="路由地址"
tooltip="一级菜单:以 / 开头的绝对路径(如 /dashboard 二级及以下:相对路径(如 console、user"
/>
</template>
<el-input v-model="formData.path" placeholder="如:/dashboard 或 console" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type != 3">
<el-form-item label="组件名称" prop="code">
<el-input v-model="formData.code" placeholder="如: User" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 2">
<el-form-item prop="component">
<template #label>
<sa-label label="组件路径" tooltip="填写组件路径views目录下 目录菜单:留空" />
</template>
<el-autocomplete
class="w-full"
v-model="formData.component"
:fetch-suggestions="querySearch"
clearable
placeholder="如:/system/user 或留空"
/>
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type != 3">
<el-form-item label="菜单图标" prop="icon">
<sa-icon-picker v-model="formData.icon" />
</el-form-item>
</el-col>
<el-col :span="12" v-if="formData.type === 3">
<el-form-item label="权限标识" prop="slug">
<el-input v-model="formData.slug" placeholder="请输入权限标识" />
</el-form-item>
</el-col>
<el-col :span="24" v-if="formData.type === 4">
<el-form-item label="外链地址" prop="link_url">
<el-input v-model="formData.link_url" placeholder="如https://saithink.top" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="sort">
<template #label>
<sa-label label="排序" tooltip="数字越大越靠前" />
</template>
<el-input-number
v-model="formData.sort"
placeholder="请输入排序"
controls-position="right"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="status">
<template #label>
<sa-label label="状态" tooltip="禁用后,该菜单项将不可用" />
</template>
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="is_iframe">
<template #label>
<sa-label label="是否内嵌" tooltip="外链模式下有效" />
</template>
<sa-switch v-model="formData.is_iframe" dict="yes_or_no" :showText="false" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="is_keep_alive">
<template #label>
<sa-label label="是否缓存" tooltip="切换tabs不刷新" />
</template>
<sa-switch v-model="formData.is_keep_alive" dict="yes_or_no" :showText="false" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="is_hidden">
<template #label>
<sa-label label="是否隐藏" tooltip="不在菜单栏显示,但是可以通过路由访问" />
</template>
<sa-switch v-model="formData.is_hidden" dict="yes_or_no" :showText="false" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="is_fixed_tab">
<template #label>
<sa-label label="是否固定" tooltip="固定在tabs导航栏" />
</template>
<sa-switch v-model="formData.is_fixed_tab" dict="yes_or_no" :showText="false" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item prop="is_full_page">
<template #label>
<sa-label label="是否全屏" tooltip="不继承左侧菜单和顶部导航栏" />
</template>
<sa-switch v-model="formData.is_full_page" dict="yes_or_no" :showText="false" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/menu'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
treeData: <any[]>[]
})
/**
* 处理自动查找视图文件
*/
const modules = import.meta.glob('/src/views/**/*.vue')
const getModulesKey = () => {
return Object.keys(modules).map((item) => item.replace('/src/views/', '/').replace('.vue', ''))
}
const componentsOptions = ref(getModulesKey())
const querySearch = (queryString: string, cb: any) => {
const results = queryString
? componentsOptions.value.filter((item) =>
item.toLowerCase().includes(queryString.toLowerCase())
)
: componentsOptions.value
cb(results.map((item) => ({ value: item })))
}
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
parent_id: [{ required: true, message: '请选择上级菜单', trigger: 'change' }],
name: [{ required: true, message: '请输入菜单名称', trigger: 'blur' }],
path: [{ required: true, message: '请输入路由地址', trigger: 'blur' }],
code: [{ required: true, message: '请输入组件名称', trigger: 'blur' }],
slug: [{ required: true, message: '请输入权限标识', trigger: 'blur' }],
link_url: [{ required: true, message: '请输入外链地址', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
parent_id: null,
type: 1,
component: '',
name: '',
slug: '',
path: '',
icon: '',
code: '',
remark: '',
link_url: '',
is_iframe: 2,
is_keep_alive: 2,
is_hidden: 2,
is_fixed_tab: 2,
is_full_page: 2,
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
const data = await api.list({ tree: true })
optionData.treeData = [
{
id: 0,
value: 0,
label: '无上级菜单',
children: data
}
]
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>
<style lang="scss" scoped>
:deep(.el-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,77 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="菜单名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入菜单名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="菜单路由" prop="path">
<el-input v-model="formData.path" placeholder="请输入菜单路由" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:post:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
<ElButton
v-permission="'core:post:destroy'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
<SaImport
v-permission="'core:post:import'"
download-url="/core/post/downloadTemplate"
upload-url="/core/post/import"
@success="refreshData"
/>
<SaExport v-permission="'core:post:export'" url="/core/post/export" />
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'core:post:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:post:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/system/post'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
code: undefined,
status: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'name', label: '岗位名称', minWidth: 120 },
{ prop: 'code', label: '岗位编码', minWidth: 120 },
{ prop: 'remark', label: '描述', minWidth: 150, showOverflowTooltip: true },
{ prop: 'sort', label: '排序', width: 100 },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status', width: 100 },
{ prop: 'create_time', label: '创建日期', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 100, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
deleteRow,
deleteSelectedRows,
handleSelectionChange,
selectedRows
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,164 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增岗位' : '编辑岗位'"
width="600px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="岗位名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入岗位名称" />
</el-form-item>
<el-form-item label="岗位编码" prop="code">
<el-input v-model="formData.code" placeholder="请输入岗位编码" />
</el-form-item>
<el-form-item label="描述" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入岗位描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="启用" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/post'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入岗位名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入岗位编码', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
name: '',
code: '',
remark: '',
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="岗位名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入岗位名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="岗位编码" prop="code">
<el-input v-model="formData.code" placeholder="请输入岗位编码" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,152 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch
v-model="searchForm"
@search="handleSearch"
@reset="resetSearchParams"
></TableSearch>
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:role:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<template #operation="{ row }">
<div class="flex gap-2" v-if="row.id !== 1">
<SaButton
v-permission="'core:role:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:role:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
<ElDropdown>
<ArtIconButton
icon="ri:more-2-fill"
class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm"
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-permission="'core:role:menu'"
@click="showPermissionDialog('edit', row)"
>
<div class="flex-c gap-2">
<ArtSvgIcon icon="ri:user-3-line" />
<span>菜单权限</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
<!-- 菜单权限弹窗 -->
<PermissionDialog
v-model="permissionDialogVisible"
:dialog-type="permissionDialogType"
:data="permissionDialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import api from '@/api/system/role'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import PermissionDialog from './modules/permission-dialog.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
code: undefined,
status: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
pagination,
searchParams,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ prop: 'id', label: '编号', minWidth: 60, align: 'center' },
{ prop: 'name', label: '角色名称', minWidth: 120 },
{ prop: 'code', label: '角色编码', minWidth: 120 },
{ prop: 'level', label: '角色级别', minWidth: 100, sortable: true },
{ prop: 'remark', label: '角色描述', minWidth: 150, showOverflowTooltip: true },
{ prop: 'sort', label: '排序', minWidth: 100 },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status' },
{ prop: 'create_time', label: '创建日期', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 140, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { dialogType, dialogVisible, dialogData, showDialog, deleteRow } = useSaiAdmin()
// 权限配置
const {
dialogType: permissionDialogType,
dialogVisible: permissionDialogVisible,
dialogData: permissionDialogData,
showDialog: showPermissionDialog
} = useSaiAdmin()
</script>

View File

@@ -0,0 +1,171 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增角色' : '编辑角色'"
width="600px"
align-center
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="角色名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入角色名称" />
</el-form-item>
<el-form-item label="角色标识" prop="code">
<el-input v-model="formData.code" placeholder="请输入角色编码" />
</el-form-item>
<el-form-item label="角色级别" prop="level">
<el-input-number v-model="formData.level" placeholder="角色级别" :max="99" :min="1" />
</el-form-item>
<div class="text-xs text-gray-400 pl-32 pb-4"
>控制角色的权限层级, 不能操作职级高于自己的角色</div
>
<el-form-item label="描述" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入角色描述"
/>
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="formData.sort" placeholder="请输入排序" />
</el-form-item>
<el-form-item label="启用" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/role'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
level: [{ required: true, message: '请输入角色级别', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
level: 1,
name: '',
code: '',
remark: '',
sort: 100,
status: 1
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<ElDialog
v-model="visible"
title="菜单权限"
width="600px"
align-center
class="el-dialog-border"
@close="handleClose"
>
<ElScrollbar height="70vh">
<ElTree
ref="treeRef"
:data="menuList"
show-checkbox
node-key="id"
:default-expand-all="false"
:check-strictly="checkStrictly"
>
<template #default="{ data }">
<div class="flex-c gap-2">
<span>{{ data.label }}</span>
<ElTag :type="getMenuTypeTag(data)" size="small">{{ getMenuTypeText(data) }}</ElTag>
</div>
</template>
</ElTree>
</ElScrollbar>
<template #footer>
<ElButton @click="toggleExpandAll" v-ripple>
<template #icon>
<ArtSvgIcon v-if="isExpandAll" icon="ri:collapse-diagonal-line" />
<ArtSvgIcon v-else icon="ri:expand-diagonal-line" />
</template>
{{ isExpandAll ? '收起' : '展开' }}
</ElButton>
<ElButton @click="toggleCheck" style="margin-left: 8px">{{
checkStrictly ? '非关联选择模式' : '关联选择模式'
}}</ElButton>
<ElButton type="primary" @click="savePermission">保存</ElButton>
</template>
</ElDialog>
</template>
<script setup lang="ts">
import api from '@/api/system/role'
import menuApi from '@/api/system/menu'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const menuList = ref<Api.Common.ApiData[]>([])
const treeRef = ref()
const isExpandAll = ref(true)
const checkStrictly = ref(true)
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 菜单列表
menuList.value = await menuApi.accessMenu({ tree: true })
// 角色数据
const data = await api.menuByRole({ id: props.data?.id })
treeRef.value.setCheckedKeys(data.menus?.map((item: any) => item.id))
}
/**
* 获取菜单类型标签颜色
* @param row 菜单行数据
* @returns 标签颜色类型
*/
const getMenuTypeTag = (row: any): 'primary' | 'success' | 'warning' | 'info' | 'danger' => {
if (row.type == 1) return 'info'
if (row.type == 2) return 'primary'
if (row.type == 3) return 'danger'
if (row.type == 4) return 'success'
return 'info'
}
/**
* 获取菜单类型文本
* @param row 菜单行数据
* @returns 菜单类型文本
*/
const getMenuTypeText = (row: any): string => {
if (row.type == 1) return '目录'
if (row.type == 2) return '菜单'
if (row.type == 3) return '按钮'
if (row.type == 4) return '外链'
return '未知'
}
/**
* 关闭弹窗并清空选中状态
*/
const handleClose = () => {
visible.value = false
treeRef.value?.setCheckedKeys([])
}
/**
* 保存权限配置
*/
const savePermission = async () => {
// TODO: 调用保存权限接口
const checkedKeys = treeRef.value.getCheckedKeys()
try {
await api.menuPermission({
id: props.data?.id,
menu_ids: checkedKeys
})
ElMessage.success('保存成功')
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
/**
* 切换全部展开/收起状态
*/
const toggleExpandAll = () => {
const tree = treeRef.value
if (!tree) return
const nodes = tree.store.nodesMap
// 这里保留 any因为 Element Plus 的内部节点类型较复杂
Object.values(nodes).forEach((node: any) => {
node.expanded = !isExpandAll.value
})
isExpandAll.value = !isExpandAll.value
}
/**
* 切换关联选择/非关联选择状态
*/
const toggleCheck = () => {
const tree = treeRef.value
if (!tree) return
checkStrictly.value = !checkStrictly.value
}
</script>

View File

@@ -0,0 +1,77 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="角色名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入角色名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="角色编码" prop="code">
<el-input v-model="formData.code" placeholder="请输入角色编码" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,280 @@
<template>
<div class="art-full-height">
<div class="box-border flex gap-4 h-full max-md:block max-md:gap-0 max-md:h-auto">
<div class="flex-shrink-0 w-64 h-full max-md:w-full max-md:h-auto max-md:mb-5">
<ElCard class="tree-card art-card-xs flex flex-col h-full mt-0" shadow="never">
<template #header>
<b>部门列表</b>
</template>
<ElScrollbar>
<ElTree
:data="treeData"
:props="{ children: 'children', label: 'label' }"
node-key="id"
default-expand-all
highlight-current
@node-click="handleNodeClick"
/>
</ElScrollbar>
</ElCard>
</div>
<div class="flex flex-col flex-grow min-w-0">
<!-- 搜索栏 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="handleReset" />
<ElCard class="flex flex-col flex-1 min-h-0 art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:user:save'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2" v-if="row.id !== 1 && userStore.info.id !== row.id">
<SaButton
v-permission="'core:user:update'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'core:user:destroy'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
<ElDropdown>
<ArtIconButton
icon="ri:more-2-fill"
class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm"
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>
<div
class="flex-c gap-2"
v-permission="'core:user:home'"
@click="showWorkDialog('edit', row)"
>
<ArtSvgIcon icon="ri:home-office-line" />
<span>设置首页</span>
</div>
</ElDropdownItem>
<ElDropdownItem>
<div
class="flex-c gap-2"
v-permission="'core:user:password'"
@click="handlePassword(row)"
>
<ArtSvgIcon icon="ri:key-line" />
<span>修改密码</span>
</div>
</ElDropdownItem>
<ElDropdownItem>
<div
class="flex-c gap-2"
v-permission="'core:user:cache'"
@click="handleCache(row)"
>
<ArtSvgIcon icon="ri:eraser-line" />
<span>清理缓存</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ArtTable>
</ElCard>
</div>
</div>
<!-- 表单弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
<!-- 工作台弹窗 -->
<WorkDialog
v-model="workDialogVisible"
:dialog-type="workDialogType"
:data="workDialogData"
@success="refreshData"
/>
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { useUserStore } from '@/store/modules/user'
import { ElMessageBox } from 'element-plus'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import WorkDialog from './modules/work-dialog.vue'
import api from '@/api/system/user'
import deptApi from '@/api/system/dept'
const userStore = useUserStore()
const treeData = ref([])
// 编辑框
const { dialogType, dialogVisible, dialogData, showDialog, handleSelectionChange, deleteRow } =
useSaiAdmin()
const {
dialogType: workDialogType,
dialogVisible: workDialogVisible,
dialogData: workDialogData,
showDialog: showWorkDialog
} = useSaiAdmin()
// 搜索表单
const searchForm = ref({
username: undefined,
phone: undefined,
email: undefined,
dept_id: undefined,
status: ''
})
const {
columns,
columnChecks,
data,
loading,
pagination,
getData,
searchParams,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'index', width: 60, label: '序号' },
{
prop: 'avatar',
label: '用户名',
minWidth: 200,
saiType: 'imageAndText',
saiFirst: 'username',
saiSecond: 'email'
},
{ prop: 'phone', label: '手机号', width: 120 },
{ prop: 'depts.name', label: '部门', minWidth: 150 },
{ prop: 'status', label: '状态', width: 80, saiType: 'dict', saiDict: 'data_status' },
{ prop: 'dashboard', label: '首页', width: 100, saiType: 'dict', saiDict: 'dashboard' },
{ prop: 'login_time', label: '上次登录', width: 170, sortable: true },
{
prop: 'operation',
label: '操作',
width: 140,
fixed: 'right',
useSlot: true
}
]
}
})
/**
* 搜索
* @param params
*/
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
/**
* 重置
*/
const handleReset = () => {
searchForm.value.dept_id = undefined
resetSearchParams()
}
/**
* 切换部门
* @param data
*/
const handleNodeClick = (data: any) => {
searchParams.dept_id = data.id
getData()
}
/**
* 获取部门数据
*/
const getDeptList = () => {
deptApi.accessDept().then((data: any) => {
treeData.value = data
})
}
/**
* 修改密码
* @param row
*/
const handlePassword = (row: any) => {
ElMessageBox.prompt('请输入新密码', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /^.{6,16}$/,
inputErrorMessage: '密码长度在6到16之间',
type: 'warning'
}).then(({ value }) => {
api.changePassword({ id: row.id, password: value }).then(() => {
ElMessage.success('修改密码成功')
})
})
}
/**
* 清理缓存
* @param row
*/
const handleCache = (row: any) => {
ElMessageBox.confirm('确定要清理缓存吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
api.clearCache({ id: row.id }).then(() => {
ElMessage.success('清理缓存成功')
})
})
}
onMounted(() => {
getDeptList()
})
</script>

View File

@@ -0,0 +1,301 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增用户' : '编辑用户'"
width="800px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="100px">
<el-form-item label="头像" prop="avatar">
<sa-image-picker v-model="formData.avatar" round />
</el-form-item>
<el-row>
<el-col :span="12">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="真实姓名" prop="realname">
<el-input v-model="formData.realname" />
</el-form-item>
</el-col>
</el-row>
<el-row v-if="dialogType === 'add'">
<el-col :span="12">
<el-form-item label="密码" prop="password">
<el-input type="password" v-model="formData.password" show-password />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="确认密码" prop="password_confirm">
<el-input type="password" v-model="formData.password_confirm" show-password />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="formData.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="部门" prop="dept_id">
<el-tree-select
v-model="formData.dept_id"
:data="optionData.deptData"
:render-after-expand="false"
check-strictly
clearable
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="角色" prop="role_ids">
<el-select v-model="formData.role_ids" multiple clearable>
<el-option
v-for="role in optionData.roleList"
:key="(role as any)?.id"
:value="(role as any)?.id"
:label="(role as any)?.name"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="岗位" prop="post_ids">
<el-select v-model="formData.post_ids" multiple clearable>
<el-option
v-for="post in optionData.postList"
:key="(post as any)?.id"
:value="(post as any)?.id"
:label="(post as any)?.name"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<sa-radio v-model="formData.gender" dict="gender" valueType="string" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="状态" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input
v-model="formData.remark"
type="textarea"
:rows="3"
placeholder="请输入备注"
/>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import type { FormInstance, FormRules } from 'element-plus'
import api from '@/api/system/user'
import deptApi from '@/api/system/dept'
import roleApi from '@/api/system/role'
import postApi from '@/api/system/post'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
const optionData = reactive({
deptData: <any>[],
roleList: <any>[],
postList: <any>[]
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
const validatePasswordConfirm = (rule: any, value: any, callback: any) => {
if (value !== formData.password) {
callback(new Error('两次输入的密码不一致'))
} else {
callback()
}
}
// 表单验证规则
const rules: FormRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' }
],
password_confirm: [
{ required: true, message: '请输入确认密码', trigger: 'blur' },
{ min: 6, max: 20, message: '长度在 6 到 20 个字符', trigger: 'blur' },
{ validator: validatePasswordConfirm, trigger: 'blur' }
],
dept_id: [{ required: true, message: '请选择部门', trigger: 'change' }],
role_ids: [{ required: true, message: '请选择角色', trigger: 'blur' }]
}
// 初始表单数据
const initialFormData = {
id: null,
avatar: '',
username: '',
password: '',
password_confirm: '',
realname: '',
dept_id: '',
phone: '',
email: '',
role_ids: [],
post_ids: [],
status: 1,
gender: '',
remark: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
// 初始化页面数据
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 部门数据
const deptData = await deptApi.accessDept()
optionData.deptData = deptData
// 角色数据
const roleData = await roleApi.accessRole()
optionData.roleList = roleData
// 岗位数据
const postData = await postApi.accessPost()
optionData.postList = postData
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
if (props.data.id) {
let data = await api.read(props.data.id)
if (data.postList) {
const post = (data.postList as any[])?.map((item: any) => item.id)
data.post_ids = post
}
const role = (data.roleList as any[])?.map((item: any) => item.id)
data.role_ids = role
data.password = ''
initForm(data)
}
}
}
/**
* 初始化表单数据
*/
const initForm = (data: any) => {
if (data) {
for (const key in formData) {
if (data[key] != null && data[key] != undefined) {
;(formData as any)[key] = data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,76 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
:label-width="'70px'"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="用户名" prop="username">
<el-input v-model="formData.username" placeholder="请输入用户名" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="手机号" prop="phone">
<el-input v-model="formData.phone" placeholder="请输入手机号" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,132 @@
<template>
<el-dialog v-model="visible" title="设置工作台" width="600px" align-center @close="handleClose">
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="工作台" prop="dashboard">
<sa-select v-model="formData.dashboard" dict="dashboard" placeholder="请选择工作台" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/system/user'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
dashboard: [{ required: true, message: '请选择工作台', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
dashboard: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'edit') {
await api.setHomePage(formData)
ElMessage.success('操作成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,805 @@
<template>
<el-drawer
v-model="visible"
:title="`编辑生成信息 - ${record?.table_comment}`"
size="100%"
destroy-on-close
@close="handleClose"
>
<div v-loading="loading" element-loading-text="加载数据中...">
<el-form ref="formRef" :model="form">
<el-tabs v-model="activeTab">
<!-- 配置信息 Tab -->
<el-tab-pane label="配置信息" name="base_config">
<el-divider content-position="left">基础信息</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="表名称" prop="table_name" label-width="100px">
<el-input v-model="form.table_name" disabled />
<div class="text-xs text-gray-400 mt-1">
数据库表的名称自动读取数据库表名称
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item
label="表描述"
prop="table_comment"
label-width="100px"
:rules="[{ required: true, message: '表描述必填' }]"
>
<el-input v-model="form.table_comment" />
<div class="text-xs text-gray-400 mt-1"> 表的描述自动读取数据库表注释 </div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item
label="实体类"
prop="class_name"
label-width="100px"
:rules="[{ required: true, message: '实体类必填' }]"
>
<el-input v-model="form.class_name" />
<div class="text-xs text-gray-400 mt-1"> 生成的实体类名称可以修改去掉前缀 </div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item
label="业务名称"
prop="business_name"
label-width="100px"
:rules="[{ required: true, message: '业务名称必填' }]"
>
<el-input v-model="form.business_name" />
<div class="text-xs text-gray-400 mt-1"> 英文业务名称同一个分组包下唯一 </div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="数据源" prop="source" label-width="100px">
<el-select v-model="form.source" placeholder="请选择数据源" style="width: 100%">
<el-option
v-for="item in dataSourceList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<div class="text-xs text-gray-400 mt-1"> 数据库配置文件中配置的数据源 </div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="备注信息" prop="remark" label-width="100px">
<el-input v-model="form.remark" />
</el-form-item>
</el-col>
</el-row>
<el-divider content-position="left">生成信息</el-divider>
<el-row :gutter="24">
<el-col :xs="24" :md="8" :xl="8">
<el-form-item
label="应用类型"
prop="template"
label-width="100px"
:rules="[{ required: true, message: '应用类型必选' }]"
>
<el-select
v-model="form.template"
placeholder="请选择生成模板"
style="width: 100%"
clearable
>
<el-option label="webman应用[app]" value="app" />
<el-option label="webman插件[plugin]" value="plugin" />
</el-select>
<div class="text-xs text-gray-400 mt-1"
>默认app模板,生成文件放app目录下plugin应用需要先手动初始化</div
>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item
label="应用名称"
prop="namespace"
label-width="100px"
:rules="[{ required: true, message: '应用名称必填' }]"
>
<el-input v-model="form.namespace" />
<div class="text-xs text-gray-400 mt-1">
plugin插件名称, 或者app下应用名称, 禁止使用saiadmin
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item
label="分组包名"
prop="package_name"
label-width="100px"
:rules="[{ required: true, message: '分组包名必填' }]"
>
<el-input v-model="form.package_name" placeholder="请输入分组包名" clearable />
<div class="text-xs text-gray-400 mt-1">
生成的文件放在分组包名目录下功能模块分组
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :xs="24" :md="8" :xl="8">
<el-form-item
label="生成类型"
prop="tpl_category"
label-width="100px"
:rules="[{ required: true, message: '生成类型必填' }]"
>
<el-select
v-model="form.tpl_category"
placeholder="请选择所属模块"
style="width: 100%"
clearable
>
<el-option label="单表CRUD" value="single" />
<el-option label="树表CRUD" value="tree" />
</el-select>
<div class="text-xs text-gray-400 mt-1">
单表须有主键树表须指定idparent_idname等字段
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item
label="生成路径"
prop="generate_path"
label-width="100px"
:rules="[{ required: true, message: '生成路径必填' }]"
>
<el-input v-model="form.generate_path" />
<div class="text-xs text-gray-400 mt-1">
前端根目录文件夹名称必须与后端根目录同级
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item label="模型类型" prop="stub" label-width="100px">
<div class="flex-col">
<el-radio-group v-model="form.stub">
<el-radio value="think">ThinkOrm</el-radio>
<el-radio value="eloquent">EloquentORM</el-radio>
</el-radio-group>
<div class="text-xs text-gray-400 mt-1">生成不同驱动模型的代码</div>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :xs="24" :md="8" :xl="8">
<el-form-item label="所属菜单" prop="belong_menu_id" label-width="100px">
<el-cascader
v-model="form.belong_menu_id"
:options="menus"
:props="{
expandTrigger: 'hover',
checkStrictly: true,
value: 'id',
label: 'label'
}"
style="width: 100%"
placeholder="生成功能所属菜单"
clearable
/>
<div class="text-xs text-gray-400 mt-1">
默认为工具菜单栏目下的子菜单不选择则为顶级菜单栏目
</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item
label="菜单名称"
prop="menu_name"
label-width="100px"
:rules="[{ required: true, message: '菜单名称必选' }]"
>
<el-input v-model="form.menu_name" placeholder="请输入菜单名称" clearable />
<div class="text-xs text-gray-400 mt-1">
显示在菜单栏目上的菜单名称以及代码中的业务功能名称
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="表单效果" prop="component_type" label-width="100px">
<div class="flex-col">
<el-radio-group v-model="form.component_type">
<el-radio-button :value="1">弹出框</el-radio-button>
<el-radio-button :value="2">抽屉</el-radio-button>
</el-radio-group>
<div class="text-xs text-gray-400 mt-1">表单显示方式</div>
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="表单宽度" prop="form_width" label-width="100px">
<div class="flex-col">
<el-input-number v-model="form.form_width" :min="200" :max="10000" />
<div class="text-xs text-gray-400 mt-1">表单组件的宽度单位为px</div>
</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="表单全屏" prop="is_full" label-width="100px">
<div class="flex-col">
<el-radio-group v-model="form.is_full">
<el-radio :value="1"></el-radio>
<el-radio :value="2"></el-radio>
</el-radio-group>
<div class="text-xs text-gray-400 mt-1">编辑表单是否全屏</div>
</div>
</el-form-item>
</el-col>
</el-row>
<!-- 树表配置 -->
<template v-if="form.tpl_category === 'tree'">
<el-divider content-position="left">树表配置</el-divider>
<el-row :gutter="24">
<el-col :xs="24" :md="8" :xl="8">
<el-form-item label="树主ID" prop="tree_id" label-width="100px">
<el-select
v-model="formOptions.tree_id"
placeholder="请选择树表的主ID"
style="width: 100%"
clearable
filterable
>
<el-option
v-for="(item, index) in form.columns"
:key="index"
:label="`${item.column_name} - ${item.column_comment}`"
:value="item.column_name"
/>
</el-select>
<div class="text-xs text-gray-400 mt-1">指定树表的主要ID一般为主键</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item label="树父ID" prop="tree_parent_id" label-width="100px">
<el-select
v-model="formOptions.tree_parent_id"
placeholder="请选择树表的父ID"
style="width: 100%"
clearable
filterable
>
<el-option
v-for="(item, index) in form.columns"
:key="index"
:label="`${item.column_name} - ${item.column_comment}`"
:value="item.column_name"
/>
</el-select>
<div class="text-xs text-gray-400 mt-1">指定树表的父ID比如parent_id</div>
</el-form-item>
</el-col>
<el-col :xs="24" :md="8" :xl="8">
<el-form-item label="树名称" prop="tree_name" label-width="100px">
<el-select
v-model="formOptions.tree_name"
placeholder="请选择树表的名称字段"
style="width: 100%"
clearable
filterable
>
<el-option
v-for="(item, index) in form.columns"
:key="index"
:label="`${item.column_name} - ${item.column_comment}`"
:value="item.column_name"
/>
</el-select>
<div class="text-xs text-gray-400 mt-1">指定树显示的名称字段比如name</div>
</el-form-item>
</el-col>
</el-row>
</template>
</el-tab-pane>
<!-- 字段配置 Tab -->
<el-tab-pane label="字段配置" name="field_config">
<el-table :data="form.columns" max-height="750">
<el-table-column prop="sort" label="排序" width="150">
<template #default="{ row }">
<el-input-number
v-model="row.sort"
style="width: 100px"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column
prop="column_name"
label="字段名称"
width="160"
show-overflow-tooltip
/>
<el-table-column prop="column_comment" label="字段描述" width="160">
<template #default="{ row }">
<el-input v-model="row.column_comment" clearable />
</template>
</el-table-column>
<el-table-column prop="column_type" label="物理类型" width="100" />
<el-table-column prop="is_required" label="必填" width="80" align="center">
<template #header>
<div class="flex-c justify-center items-center gap-1">
<span>必填</span>
<el-checkbox @change="(val) => handlerAll(val, 'required')" />
</div>
</template>
<template #default="{ row }">
<el-checkbox v-model="row.is_required" />
</template>
</el-table-column>
<el-table-column prop="is_insert" label="表单" width="80" align="center">
<template #header>
<div class="flex-c justify-center items-center gap-1">
<span>表单</span>
<el-checkbox @change="(val) => handlerAll(val, 'insert')" />
</div>
</template>
<template #default="{ row }">
<el-checkbox v-model="row.is_insert" />
</template>
</el-table-column>
<el-table-column prop="is_list" label="列表" width="80" align="center">
<template #header>
<div class="flex-c justify-center items-center gap-1">
<span>列表</span>
<el-checkbox @change="(val) => handlerAll(val, 'list')" />
</div>
</template>
<template #default="{ row }">
<el-checkbox v-model="row.is_list" />
</template>
</el-table-column>
<el-table-column prop="is_query" label="查询" width="80" align="center">
<template #header>
<div class="flex-c justify-center items-center gap-1">
<span>查询</span>
<el-checkbox @change="(val) => handlerAll(val, 'query')" />
</div>
</template>
<template #default="{ row }">
<el-checkbox v-model="row.is_query" />
</template>
</el-table-column>
<el-table-column prop="is_sort" label="排序" width="80" align="center">
<template #header>
<div class="flex-c justify-center items-center gap-1">
<span>排序</span>
<el-checkbox @change="(val) => handlerAll(val, 'sort')" />
</div>
</template>
<template #default="{ row }">
<el-checkbox v-model="row.is_sort" />
</template>
</el-table-column>
<el-table-column prop="query_type" label="查询方式" width="150">
<template #default="{ row }">
<el-select v-model="row.query_type" clearable>
<el-option
v-for="item in queryType"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column prop="view_type" label="页面控件">
<template #default="{ row }">
<div class="flex items-center gap-2">
<el-select
v-model="row.view_type"
style="width: 140px"
@change="changeViewType(row)"
clearable
>
<el-option
v-for="item in viewComponent"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-link
v-if="notNeedSettingComponents.includes(row.view_type)"
@click="settingComponentRef.open(row)"
>
设置
</el-link>
</div>
</template>
</el-table-column>
<el-table-column prop="dict_type" label="数据字典">
<template #default="{ row }">
<el-select
v-model="row.dict_type"
clearable
placeholder="选择数据字典"
:disabled="!['saSelect', 'radio', 'checkbox'].includes(row.view_type)"
>
<el-option
v-for="(item, key) in dictStore.dictList"
:key="key"
:label="key"
:value="key"
/>
</el-select>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<!-- 关联配置 Tab -->
<el-tab-pane label="关联配置" name="relation_config">
<el-alert type="info" :closable="false">
模型关联支持一对一一对多一对一反向多对多
</el-alert>
<el-button type="primary" class="mt-4 mb-4" @click="addRelation">
<template #icon>
<ArtSvgIcon icon="ri:add-line" />
</template>
新增关联
</el-button>
<div v-for="(item, index) in formOptions.relations" :key="index">
<el-divider content-position="left">
{{ item.name ? item.name : '定义新关联' }}
<el-link type="danger" class="ml-5" @click="delRelation(index)">
<ArtSvgIcon icon="ri:delete-bin-line" class="mr-1" />
删除定义
</el-link>
</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="关联类型" label-width="100px">
<el-select
v-model="item.type"
placeholder="请选择关联类型"
clearable
filterable
>
<el-option
v-for="types in relationsType"
:key="types.value"
:label="types.name"
:value="types.value"
/>
</el-select>
<div class="text-xs text-gray-400 mt-1">指定关联类型</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="关联名称" label-width="100px">
<el-input v-model="item.name" placeholder="设置关联名称" clearable />
<div class="text-xs text-gray-400 mt-1">属性名称代码中with调用的名称</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="关联模型" label-width="100px">
<el-input v-model="item.model" placeholder="设置关联模型" clearable />
<div class="text-xs text-gray-400 mt-1">选择要关联的实体模型</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item
:label="
item.type === 'belongsTo'
? '外键'
: item.type === 'belongsToMany'
? '外键'
: '当前模型主键'
"
label-width="100px"
>
<el-input v-model="item.localKey" placeholder="设置键名" clearable />
<div class="text-xs text-gray-400 mt-1">
{{
item.type === 'belongsTo'
? '关联模型_id'
: item.type === 'belongsToMany'
? '关联模型_id'
: '当前模型主键'
}}
</div>
</el-form-item>
</el-col>
<el-col v-show="item.type === 'belongsToMany'" :span="8">
<el-form-item label="中间模型" label-width="100px">
<el-input v-model="item.table" placeholder="请输入中间模型" clearable />
<div class="text-xs text-gray-400 mt-1">多对多关联的中间模型</div>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item
:label="item.type === 'belongsTo' ? '关联主键' : '外键'"
label-width="100px"
>
<el-input v-model="item.foreignKey" placeholder="设置键名" clearable />
<div class="text-xs text-gray-400 mt-1">
{{ item.type === 'belongsTo' ? '关联模型主键' : '当前模型_id' }}
</div>
</el-form-item>
</el-col>
</el-row>
</div>
</el-tab-pane>
</el-tabs>
</el-form>
</div>
<!-- 设置组件弹窗 -->
<SettingComponent ref="settingComponentRef" @confirm="confirmSetting" />
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="save">保存</el-button>
</template>
</el-drawer>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { CheckboxValueType, FormInstance } from 'element-plus'
import { useDictStore } from '@/store/modules/dict'
// 接口导入
import generate from '@/api/tool/generate'
import database from '@/api/safeguard/database'
import menuApi from '@/api/system/menu'
import SettingComponent from './settingComponent.vue'
// 导入变量
import { relationsType, queryType, viewComponent } from '../js/vars'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
const dictStore = useDictStore()
const record = ref<any>({})
const loading = ref(true)
const submitLoading = ref(false)
const activeTab = ref('base_config')
const formRef = ref<FormInstance>()
const settingComponentRef = ref()
const notNeedSettingComponents = ref([
'uploadFile',
'uploadImage',
'imagePicker',
'chunkUpload',
'editor',
'date',
'userSelect'
])
const form = ref<any>({
generate_menus: ['index', 'save', 'update', 'read', 'destroy'],
columns: []
})
// form扩展组
const formOptions = ref<any>({
relations: []
})
// 菜单列表
const menus = ref<any[]>([])
// 数据源
const dataSourceList = ref<{ label: string; value: string }[]>([])
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
loading.value = true
// 获取数据源
const data = await database.getDataSource()
dataSourceList.value = data.map((item: any) => ({
label: item,
value: item
}))
const response = await generate.read({ id: props.data?.id })
record.value = response
initForm()
loading.value = false
}
/**
* 设置组件确认
*/
const confirmSetting = (name: string, value: any) => {
form.value.columns.find((item: any, idx: number) => {
if (item.column_name === name) {
form.value.columns[idx].options = value
}
})
ElMessage.success('组件设置成功')
}
/**
* 切换页面控件类型
*/
const changeViewType = (record: any) => {
if (
record.view_type === 'uploadImage' ||
record.view_type === 'imagePicker' ||
record.view_type === 'uploadFile' ||
record.view_type === 'chunkUpload'
) {
record.options = { multiple: false, limit: 1 }
} else if (record.view_type === 'editor') {
record.options = { height: 400 }
} else if (record.view_type === 'date') {
record.options = { mode: 'date' }
} else if (record.view_type === 'userSelect') {
record.options = { multiple: false }
} else {
record.options = {}
}
}
/**
* 保存
*/
const save = async () => {
if (form.value.namespace === 'saiadmin') {
ElMessage.error('应用名称不能为saiadmin')
return
}
const validResult = await formRef.value?.validate().catch((err) => err)
if (validResult !== true) {
return
}
submitLoading.value = true
try {
form.value.options = formOptions.value
await generate.update({ ...form.value })
ElMessage.success('更新成功')
emit('success')
handleClose()
} finally {
submitLoading.value = false
}
}
/**
* 全选 / 全不选
*/
const handlerAll = (value: CheckboxValueType, type: string) => {
form.value.columns.forEach((item: any) => {
item['is_' + type] = value
})
}
/**
* 新增关联定义
*/
const addRelation = () => {
formOptions.value.relations.push({
name: '',
type: 'hasOne',
model: '',
foreignKey: '',
localKey: '',
table: ''
})
}
/**
* 删除关联定义
*/
const delRelation = (idx: number | string) => {
formOptions.value.relations.splice(idx, 1)
}
/**
* 初始化数据
*/
const initForm = () => {
// 设置form数据
for (const name in record.value) {
if (name === 'generate_menus') {
form.value[name] = record.value[name] ? record.value[name].split(',') : []
} else {
form.value[name] = record.value[name]
}
}
if (record.value.options && record.value.options.relations) {
formOptions.value.relations = record.value.options.relations
} else {
formOptions.value.relations = []
}
if (record.value.tpl_category === 'tree') {
formOptions.value.tree_id = record.value.options.tree_id
formOptions.value.tree_name = record.value.options.tree_name
formOptions.value.tree_parent_id = record.value.options.tree_parent_id
}
// 请求表字段
generate.getTableColumns({ table_id: record.value.id }).then((data: any) => {
form.value.columns = []
data.forEach((item: any) => {
item.is_required = item.is_required === 2 ? true : false
item.is_insert = item.is_insert === 2 ? true : false
item.is_edit = item.is_edit === 2 ? true : false
item.is_list = item.is_list === 2 ? true : false
item.is_query = item.is_query === 2 ? true : false
item.is_sort = item.is_sort === 2 ? true : false
form.value.columns.push(item)
})
})
// 请求菜单列表
menuApi.list({ tree: true, menu: true }).then((data: any) => {
menus.value = data
menus.value.unshift({ id: 0, value: 0, label: '顶级菜单' })
})
}
/**
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
</script>

View File

@@ -0,0 +1,228 @@
<template>
<el-drawer
v-model="visible"
title="装载数据表"
size="70%"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<div class="art-full-height">
<el-alert type="info" :closable="false">
<template #title>
<div>1支持配置多数据源</div>
<div>
2载入表[sa_shop_category]会自动处理为[SaShopCategory]可以编辑对类名进行修改[ShopCategory]
</div>
</template>
</el-alert>
<div class="flex justify-between items-center mt-4">
<ElSpace wrap>
<el-select v-model="searchForm.source" placeholder="切换数据源" style="width: 200px">
<el-option
v-for="item in dataSourceList"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<el-input
v-model="searchForm.name"
placeholder="请输入数据表名称"
style="width: 300px"
clearable
/>
</ElSpace>
<ElSpace wrap>
<ElButton class="reset-button" @click="handleReset" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:reset-right-line" />
</template>
重置
</ElButton>
<ElButton type="primary" class="search-button" @click="handleSearch" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:search-line" />
</template>
查询
</ElButton>
</ElSpace>
</div>
<ElCard class="art-table-card" shadow="never">
<div>
<ElSpace wrap>
<ElButton :disabled="selectedRows.length === 0" @click="handleLoadTable" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:check-fill" />
</template>
确认选择
</ElButton>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="name"
:loading="loading"
:data="tableData"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
/>
</ElCard>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import api from '@/api/safeguard/database'
import { useTable } from '@/hooks/core/useTable'
import generate from '@/api/tool/generate'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
const selectedRows = ref<Record<string, any>[]>([])
const dataSourceList = ref<{ label: string; value: string }[]>([])
const searchForm = ref({
name: '',
source: ''
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
const response = await api.getDataSource()
dataSourceList.value = response.map((item: any) => ({
label: item,
value: item
}))
searchForm.value.source = dataSourceList.value[0]?.value || ''
refreshData()
}
/**
* 获取表格数据
*/
const refreshData = () => {
Object.assign(searchParams, searchForm.value)
getData()
}
/**
* 搜索
*/
const handleSearch = () => {
refreshData()
}
/**
* 重置
*/
const handleReset = () => {
searchForm.value.name = ''
refreshData()
}
// 表格行选择变化
const handleSelectionChange = (selection: Record<string, any>[]): void => {
selectedRows.value = selection
}
// 确认选择装载数据表
const handleLoadTable = async () => {
if (selectedRows.value.length < 1) {
ElMessage.info('至少要选择一条数据')
return
}
const names = selectedRows.value.map((item) => ({
name: item.name,
comment: item.comment,
sourceName: item.name
}))
await generate.loadTable({
source: searchForm.value.source,
names
})
ElMessage.success('装载成功')
emit('success')
handleClose()
}
/**
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
selectedRows.value = []
}
const {
loading,
data: tableData,
columns,
getData,
pagination,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.list,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'name', label: '表名称' },
{ prop: 'comment', label: '表注释' },
{ prop: 'engine', label: '引擎' },
{ prop: 'collation', label: '编码' },
{ prop: 'create_time', label: '创建时间' }
]
}
})
</script>

View File

@@ -0,0 +1,111 @@
<template>
<el-drawer v-model="visible" title="预览代码" size="100%" destroy-on-close @close="handleClose">
<el-tabs v-model="activeTab" type="card">
<el-tab-pane
v-for="item in previewCode"
:key="item.name"
:label="item.tab_name"
:name="item.name"
>
<div class="relative">
<SaCode :code="item.code" :language="item.lang" />
<el-button class="copy-button" type="primary" @click="handleCopy(item.code)">
<template #icon>
<ArtSvgIcon icon="ri:file-copy-line" />
</template>
复制
</el-button>
</div>
</el-tab-pane>
</el-tabs>
</el-drawer>
</template>
<script setup lang="ts">
import { useClipboard } from '@vueuse/core'
import { ElMessage } from 'element-plus'
import generate from '@/api/tool/generate'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
const activeTab = ref('controller')
const previewCode = ref<any[]>([])
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 打开弹窗
*/
const initPage = async () => {
try {
const response = await generate.preview({ id: props.data?.id })
previewCode.value = response
activeTab.value = previewCode.value[0]?.name || 'controller'
} catch (error) {
console.error(error)
handleClose()
}
}
/**
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
}
/**
* 复制代码到剪贴板
*/
const { copy } = useClipboard()
const handleCopy = async (code: string) => {
try {
await copy(code)
ElMessage.success('代码已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动复制')
}
}
</script>
<style lang="scss" scoped>
.copy-button {
position: absolute;
right: 15px;
top: 0px;
z-index: 999;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<el-dialog
v-model="visible"
:title="`设置组件 - ${row?.column_comment}`"
width="600px"
draggable
destroy-on-close
@close="handleClose"
>
<el-form :model="form" label-width="120px">
<!-- 编辑器相关 -->
<template v-if="row.view_type === 'editor'">
<el-form-item label="编辑器高度" prop="height">
<el-input-number v-model="form.height" :max="1000" :min="100" />
</el-form-item>
</template>
<!-- 上传资源选择器相关 -->
<template
v-if="['uploadImage', 'imagePicker', 'uploadFile', 'chunkUpload'].includes(row.view_type)"
>
<el-form-item label="是否多选" prop="multiple">
<el-radio-group v-model="form.multiple">
<el-radio :value="true"></el-radio>
<el-radio :value="false"></el-radio>
</el-radio-group>
<div class="text-xs text-gray-400 ml-2">多个文件必须选是字段自动处理为数组</div>
</el-form-item>
<el-form-item label="数量限制" prop="limit">
<el-input-number v-model="form.limit" :max="10" :min="1" />
<div class="text-xs text-gray-400 ml-2">限制上传数量</div>
</el-form-item>
</template>
<!-- 用户选择器 -->
<template v-if="row.view_type === 'userSelect'">
<el-form-item label="是否多选" prop="multiple">
<el-radio-group v-model="form.multiple">
<el-radio :value="true"></el-radio>
<el-radio :value="false"></el-radio>
</el-radio-group>
<div class="text-xs text-gray-400 ml-2">多个用户字段自动处理为数组</div>
</el-form-item>
</template>
<!-- 日期时间选择器 -->
<template v-if="['date'].includes(row.view_type)">
<el-form-item label="选择器类型" prop="mode">
<el-select v-model="form.mode" clearable>
<el-option label="日期选择器" value="date" />
<el-option label="日期时间择器" value="datetime" />
</el-select>
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="save">确定</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
const emit = defineEmits<{
(e: 'confirm', name: string, value: any): void
}>()
const visible = ref(false)
const row = ref<any>({})
const form = ref<any>({})
/**
* 打开弹窗
*/
const open = (record: any) => {
row.value = record
if (
record.view_type === 'uploadImage' ||
record.view_type === 'imagePicker' ||
record.view_type === 'uploadFile' ||
record.view_type === 'chunkUpload'
) {
form.value = record.options ? { ...record.options } : { multiple: false }
} else if (record.view_type === 'editor') {
form.value = record.options ? { ...record.options } : { height: 400 }
} else if (record.view_type === 'date' || record.view_type === 'datetime') {
form.value = record.options ? { ...record.options } : { mode: record.view_type }
} else if (record.view_type === 'userSelect') {
form.value = record.options ? { ...record.options } : { multiple: false }
} else {
form.value = record.options ? { ...record.options } : {}
}
visible.value = true
}
/**
* 保存
*/
const save = () => {
emit('confirm', row.value.column_name, form.value)
handleClose()
}
/**
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
}
defineExpose({ open })
</script>

View File

@@ -0,0 +1,272 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'core:database:index'" @click="showTableDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:upload-2-line" />
</template>
装载
</ElButton>
<ElButton
v-permission="'tool:code:edit'"
:disabled="selectedRows.length === 0"
@click="batchGenerate"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:download-2-line" />
</template>
生成
</ElButton>
<ElButton
v-permission="'tool:code:edit'"
:disabled="selectedRows.length === 0"
@click="deleteSelectedRows(api.delete, refreshData)"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 生成类型列 -->
<template #tpl_category="{ row }">
<el-tag v-if="row.tpl_category === 'single'" type="success">单表CRUD</el-tag>
<el-tag v-else type="danger">树表CRUD</el-tag>
</template>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'tool:code:edit'"
type="secondary"
icon="ri:eye-line"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'tool:code:edit'"
type="primary"
icon="ri:refresh-line"
@click="syncTable(row.id)"
/>
<SaButton
v-permission="'tool:code:edit'"
type="secondary"
@click="showEditDialog('edit', row)"
/>
<SaButton
v-permission="'tool:code:edit'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
<ElDropdown>
<ArtIconButton
icon="ri:more-2-fill"
class="!size-8 bg-g-200 dark:bg-g-300/45 text-sm"
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem>
<div
v-permission="'tool:code:edit'"
class="flex-c gap-2"
@click="generateFile(row.id)"
>
<ArtSvgIcon icon="ri:folder-add-line" />
<span>生成到项目</span>
</div>
</ElDropdownItem>
<ElDropdownItem>
<div
v-permission="'tool:code:edit'"
class="flex-c gap-2"
@click="generateCode(row.id)"
>
<ArtSvgIcon icon="ri:download-line" />
<span>代码下载</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 装载数据表 -->
<LoadTable v-model="tableVisible" :dialog-type="dialogType" @success="refreshData" />
<!-- 预览代码 -->
<Preview v-model="dialogVisible" :data="dialogData" />
<!-- 编辑弹窗 -->
<EditInfo v-model="editVisible" :data="editDialogData" @success="refreshData" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/tool/generate'
import { downloadFile } from '@/utils/tool'
import TableSearch from './modules/table-search.vue'
import LoadTable from './components/loadTable.vue'
import Preview from './components/preview.vue'
import EditInfo from './components/editInfo.vue'
// 编辑弹窗
const {
dialogType,
dialogVisible,
dialogData,
showDialog,
handleSelectionChange,
deleteRow,
deleteSelectedRows,
selectedRows
} = useSaiAdmin()
const { dialogVisible: tableVisible, showDialog: showTableDialog } = useSaiAdmin()
const {
dialogVisible: editVisible,
dialogData: editDialogData,
showDialog: showEditDialog
} = useSaiAdmin()
// 搜索表单
const searchForm = ref({
table_name: undefined,
source: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
pagination,
searchParams,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection', width: 50 },
{ prop: 'table_name', label: '表名称', minWidth: 180, align: 'left' },
{ prop: 'table_comment', label: '表描述', minWidth: 150, align: 'left' },
{ prop: 'template', label: '应用类型', minWidth: 120 },
{ prop: 'namespace', label: '应用名称', minWidth: 120 },
{ prop: 'stub', label: '模板类型', minWidth: 120 },
{ prop: 'tpl_category', label: '生成类型', minWidth: 120, useSlot: true },
{ prop: 'update_time', label: '更新时间', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 220, fixed: 'right', useSlot: true }
]
}
})
/**
* 生成代码下载
*/
const generateCode = async (ids: number | string) => {
ElMessage.info('代码生成下载中,请稍后')
const response = await api.generateCode({
ids: ids.toString().split(',')
})
if (response) {
downloadFile(response, 'code.zip')
ElMessage.success('代码生成成功,开始下载')
} else {
ElMessage.error('文件下载失败')
}
}
/**
* 同步表结构
*/
const syncTable = async (id: number) => {
ElMessageBox.confirm('执行同步操作将会覆盖已经设置的表结构,确定要同步吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
api.async({ id }).then(() => {
ElMessage.success('同步成功')
})
})
}
/**
* 生成到项目
*/
const generateFile = async (id: number) => {
ElMessageBox.confirm('生成到项目将会覆盖原有文件,确定要生成吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
api.generateFile({ id }).then(() => {
ElMessage.success('生成到项目成功')
})
})
}
/**
* 批量生成代码
*/
const batchGenerate = () => {
if (selectedRows.value.length === 0) {
ElMessage.error('至少要选择一条数据')
return
}
generateCode(selectedRows.value.map((item: any) => item.id).join(','))
}
</script>
<style lang="scss" scoped>
:deep(.el-drawer__header) {
margin-bottom: 10px !important;
}
</style>

View File

@@ -0,0 +1,45 @@
export const relationsType: { name: string; value: string }[] = [
{ name: '一对一[hasOne]', value: 'hasOne' },
{ name: '一对多[hasMany]', value: 'hasMany' },
{ name: '一对一(反向)[belongsTo]', value: 'belongsTo' },
{ name: '多对多[belongsToMany]', value: 'belongsToMany' }
]
export const queryType: { label: string; value: string }[] = [
{ label: '=', value: 'eq' },
{ label: '!=', value: 'neq' },
{ label: '>', value: 'gt' },
{ label: '>=', value: 'gte' },
{ label: '<', value: 'lt' },
{ label: '<=', value: 'lte' },
{ label: 'LIKE', value: 'like' },
{ label: 'IN', value: 'in' },
{ label: 'NOT IN', value: 'notin' },
{ label: 'BETWEEN', value: 'between' }
]
// 页面控件
export const viewComponent: { label: string; value: string }[] = [
{ label: '输入框', value: 'input' },
{ label: '密码框', value: 'password' },
{ label: '文本域', value: 'textarea' },
{ label: '数字输入框', value: 'inputNumber' },
{ label: '标签输入框', value: 'inputTag' },
{ label: '开关', value: 'switch' },
{ label: '滑块', value: 'slider' },
{ label: '数据下拉框', value: 'select' },
{ label: '字典下拉框', value: 'saSelect' },
{ label: '树形下拉框', value: 'treeSelect' },
{ label: '字典单选框', value: 'radio' },
{ label: '字典复选框', value: 'checkbox' },
{ label: '日期选择器', value: 'date' },
{ label: '时间选择器', value: 'time' },
{ label: '评分器', value: 'rate' },
{ label: '级联选择器', value: 'cascader' },
{ label: '用户选择器', value: 'userSelect' },
{ label: '图片上传', value: 'uploadImage' },
{ label: '图片选择', value: 'imagePicker' },
{ label: '文件上传', value: 'uploadFile' },
{ label: '大文件切片', value: 'chunkUpload' },
{ label: '富文本编辑器', value: 'editor' }
]

View File

@@ -0,0 +1,66 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="表名称" prop="table_name">
<el-input v-model="formData.table_name" placeholder="请输入数据表名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="数据源" prop="source">
<el-input v-model="formData.source" placeholder="请输入数据源名称" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,160 @@
<template>
<div class="art-full-height">
<!-- 搜索面板 -->
<TableSearch v-model="searchForm" @search="handleSearch" @reset="resetSearchParams" />
<ElCard class="art-table-card" shadow="never">
<!-- 表格头部 -->
<ArtTableHeader v-model:columns="columnChecks" :loading="loading" @refresh="refreshData">
<template #left>
<ElSpace wrap>
<ElButton v-permission="'tool:crontab:edit'" @click="showDialog('add')" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:add-fill" />
</template>
新增
</ElButton>
</ElSpace>
</template>
</ArtTableHeader>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="data"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<!-- 操作列 -->
<template #operation="{ row }">
<div class="flex gap-2">
<SaButton
v-permission="'tool:crontab:run'"
type="primary"
icon="ri:play-fill"
toolTip="运行任务"
@click="handleRun(row)"
/>
<SaButton
type="primary"
icon="ri:history-line"
toolTip="运行日志"
@click="showTableDialog('edit', row)"
/>
<SaButton
v-permission="'tool:crontab:edit'"
type="secondary"
@click="showDialog('edit', row)"
/>
<SaButton
v-permission="'tool:crontab:edit'"
type="error"
@click="deleteRow(row, api.delete, refreshData)"
/>
</div>
</template>
</ArtTable>
</ElCard>
<!-- 编辑弹窗 -->
<EditDialog
v-model="dialogVisible"
:dialog-type="dialogType"
:data="dialogData"
@success="refreshData"
/>
<!-- 日志弹窗 -->
<LogListDialog v-model="tableVisible" :dialog-type="tableDialogType" :data="tableData" />
</div>
</template>
<script setup lang="ts">
import { useTable } from '@/hooks/core/useTable'
import { useSaiAdmin } from '@/composables/useSaiAdmin'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/tool/crontab'
import TableSearch from './modules/table-search.vue'
import EditDialog from './modules/edit-dialog.vue'
import LogListDialog from './modules/log-list.vue'
// 搜索表单
const searchForm = ref({
name: undefined,
type: undefined,
status: undefined
})
// 搜索处理
const handleSearch = (params: Record<string, any>) => {
Object.assign(searchParams, params)
getData()
}
// 表格配置
const {
columns,
columnChecks,
data,
loading,
getData,
searchParams,
pagination,
resetSearchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange,
refreshData
} = useTable({
core: {
apiFn: api.list,
columnsFactory: () => [
{ prop: 'id', label: '编号', width: 100, align: 'center' },
{ prop: 'name', label: '任务名称', minWidth: 120 },
{
prop: 'type',
label: '任务类型',
saiType: 'dict',
saiDict: 'crontab_task_type',
minWidth: 120
},
{ prop: 'rule', label: '定时规则', minWidth: 140 },
{ prop: 'target', label: '调用目标', minWidth: 200, showOverflowTooltip: true },
{ prop: 'status', label: '状态', saiType: 'dict', saiDict: 'data_status', width: 100 },
{ prop: 'update_time', label: '更新日期', width: 180, sortable: true },
{ prop: 'operation', label: '操作', width: 180, fixed: 'right', useSlot: true }
]
}
})
// 编辑配置
const { dialogType, dialogVisible, dialogData, showDialog, deleteRow, handleSelectionChange } =
useSaiAdmin()
const {
dialogVisible: tableVisible,
dialogType: tableDialogType,
dialogData: tableData,
showDialog: showTableDialog
} = useSaiAdmin()
// 运行任务
const handleRun = (row: any) => {
ElMessageBox.confirm(`确定要运行任务【${row.name}】吗?`, '运行任务', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
api.run({ id: row.id }).then(() => {
ElMessage.success('任务运行成功')
refreshData()
})
})
}
</script>

View File

@@ -0,0 +1,278 @@
<template>
<el-dialog
v-model="visible"
:title="dialogType === 'add' ? '新增定时任务' : '编辑定时任务'"
width="800px"
align-center
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="formRef" :model="formData" :rules="rules" label-width="120px">
<el-form-item label="任务名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入任务名称" />
</el-form-item>
<el-form-item label="任务类型" prop="type">
<sa-select v-model="formData.type" dict="crontab_task_type" />
</el-form-item>
<el-form-item label="定时规则" prop="task_style">
<el-space>
<el-select v-model="formData.task_style" :style="{ width: '100px' }">
<el-option :value="1" label="每天" />
<el-option :value="2" label="每小时" />
<el-option :value="3" label="N小时" />
<el-option :value="4" label="N分钟" />
<el-option :value="5" label="N秒" />
<el-option :value="6" label="每周" />
<el-option :value="7" label="每月" />
<el-option :value="8" label="每年" />
</el-select>
<template v-if="formData.task_style == 8">
<el-input-number
v-model="formData.month"
:precision="0"
:min="1"
:max="12"
controls-position="right"
:style="{ width: '100px' }"
/>
<span></span>
</template>
<template v-if="formData.task_style > 6">
<el-input-number
v-model="formData.day"
:precision="0"
:min="1"
:max="31"
controls-position="right"
:style="{ width: '100px' }"
/>
<span></span>
</template>
<el-select
v-if="formData.task_style == 6"
v-model="formData.week"
:style="{ width: '100px' }"
>
<el-option :value="1" label="周一" />
<el-option :value="2" label="周二" />
<el-option :value="3" label="周三" />
<el-option :value="4" label="周四" />
<el-option :value="5" label="周五" />
<el-option :value="6" label="周六" />
<el-option :value="0" label="周日" />
</el-select>
<template v-if="[1, 3, 6, 7, 8].includes(formData.task_style)">
<el-input-number
v-model="formData.hour"
:precision="0"
:min="0"
:max="23"
controls-position="right"
:style="{ width: '100px' }"
/>
<span></span>
</template>
<template v-if="formData.task_style != 5">
<el-input-number
v-model="formData.minute"
:precision="0"
:min="0"
:max="59"
controls-position="right"
:style="{ width: '100px' }"
/>
<span></span>
</template>
<template v-if="formData.task_style == 5">
<el-input-number
v-model="formData.second"
:precision="0"
:min="0"
:max="59"
controls-position="right"
:style="{ width: '100px' }"
/>
<span></span>
</template>
</el-space>
</el-form-item>
<el-form-item label="调用目标" prop="target">
<el-input
v-model="formData.target"
type="textarea"
:rows="3"
placeholder="请输入调用目标"
/>
</el-form-item>
<el-form-item label="任务参数" prop="params">
<el-input
v-model="formData.parameter"
type="textarea"
:rows="3"
placeholder="请输入任务参数"
/>
</el-form-item>
<el-form-item label="状态" prop="status">
<sa-radio v-model="formData.status" dict="data_status" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="formData.remark" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit">提交</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import api from '@/api/tool/crontab'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
interface Props {
modelValue: boolean
dialogType: string
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
dialogType: 'add',
data: undefined
})
const emit = defineEmits<Emits>()
const formRef = ref<FormInstance>()
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 表单验证规则
*/
const rules = reactive<FormRules>({
name: [{ required: true, message: '任务名称不能为空', trigger: 'blur' }],
type: [{ required: true, message: '任务类型不能为空', trigger: 'blur' }],
task_style: [{ required: true, message: '定时规则不能为空', trigger: 'blur' }],
target: [{ required: true, message: '调用目标不能为空', trigger: 'blur' }]
})
/**
* 初始数据
*/
const initialFormData = {
id: null,
name: '',
type: '',
rule: '',
task_style: 1,
month: 1,
day: 1,
week: 1,
hour: 1,
minute: 1,
second: 1,
target: '',
parameter: '',
status: 1,
remark: ''
}
/**
* 表单数据
*/
const formData = reactive({ ...initialFormData })
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
// 先重置为初始值
Object.assign(formData, initialFormData)
// 如果有数据,则填充数据
if (props.data) {
await nextTick()
initForm()
}
}
// 提取数字
const extractNumber = (str: string) => {
const match = str.match(/\d+/)
return match ? Number.parseInt(match[0]) : 0
}
/**
* 初始化表单数据
*/
const initForm = () => {
if (props.data) {
for (const key in formData) {
if (props.data[key] != null && props.data[key] != undefined) {
;(formData as any)[key] = props.data[key]
}
}
const words = formData['rule'].split(' ')
formData['second'] = extractNumber(words[0])
formData['minute'] = extractNumber(words[1])
formData['hour'] = extractNumber(words[2])
formData['day'] = extractNumber(words[3])
formData['month'] = extractNumber(words[4])
formData['week'] = extractNumber(words[5])
}
}
/**
* 关闭弹窗并重置表单
*/
const handleClose = () => {
visible.value = false
formRef.value?.resetFields()
}
/**
* 提交表单
*/
const handleSubmit = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
if (props.dialogType === 'add') {
await api.save(formData)
ElMessage.success('新增成功')
} else {
await api.update(formData)
ElMessage.success('修改成功')
}
emit('success')
handleClose()
} catch (error) {
console.log('表单验证失败:', error)
}
}
</script>

View File

@@ -0,0 +1,222 @@
<template>
<el-drawer
v-model="visible"
title="任务执行日志"
size="70%"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<div class="art-full-height">
<div class="flex justify-between items-center">
<ElSpace wrap>
<el-date-picker
v-model="searchForm.create_time"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
clearable
/>
</ElSpace>
<ElSpace wrap>
<ElButton class="reset-button" @click="handleReset" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:reset-right-line" />
</template>
重置
</ElButton>
<ElButton type="primary" class="search-button" @click="handleSearch" v-ripple>
<template #icon>
<ArtSvgIcon icon="ri:search-line" />
</template>
查询
</ElButton>
</ElSpace>
</div>
<ElCard class="art-table-card" shadow="never">
<div>
<ElSpace wrap>
<ElButton
v-permission="'tool:crontab:edit'"
:disabled="selectedRows.length === 0"
@click="handleLoadTable"
v-ripple
>
<template #icon>
<ArtSvgIcon icon="ri:delete-bin-5-line" />
</template>
删除
</ElButton>
</ElSpace>
</div>
<!-- 表格 -->
<ArtTable
ref="tableRef"
rowKey="id"
:loading="loading"
:data="tableData"
:columns="columns"
:pagination="pagination"
@sort-change="handleSortChange"
@selection-change="handleSelectionChange"
@pagination:size-change="handleSizeChange"
@pagination:current-change="handleCurrentChange"
>
<template #status="{ row }">
<ElTag v-if="row.status == 1" type="success">成功</ElTag>
<ElTag v-else type="danger">失败</ElTag>
</template>
</ArtTable>
</ElCard>
</div>
</el-drawer>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/tool/crontab'
import { useTable } from '@/hooks/core/useTable'
interface Props {
modelValue: boolean
data?: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: boolean): void
(e: 'success'): void
}
const props = withDefaults(defineProps<Props>(), {
modelValue: false,
data: undefined
})
const emit = defineEmits<Emits>()
const selectedRows = ref<Record<string, any>[]>([])
const searchForm = ref({
crontab_id: '',
orderType: 'desc',
create_time: []
})
/**
* 弹窗显示状态双向绑定
*/
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
/**
* 监听弹窗打开,初始化表单数据
*/
watch(
() => props.modelValue,
(newVal) => {
if (newVal) {
initPage()
}
}
)
/**
* 初始化页面数据
*/
const initPage = async () => {
if (!props.data?.id) {
ElMessage.error('请先选择一个任务')
return
}
searchForm.value.crontab_id = props.data.id
refreshData()
}
/**
* 获取表格数据
*/
const refreshData = () => {
Object.assign(searchParams, searchForm.value)
getData()
}
/**
* 搜索
*/
const handleSearch = () => {
refreshData()
}
/**
* 重置
*/
const handleReset = () => {
searchForm.value.create_time = []
refreshData()
}
// 表格行选择变化
const handleSelectionChange = (selection: Record<string, any>[]): void => {
selectedRows.value = selection
}
// 确认选择装载数据表
const handleLoadTable = async () => {
if (selectedRows.value.length < 1) {
ElMessage.info('至少要选择一条数据')
return
}
ElMessageBox.confirm(
`确定要删除选中的 ${selectedRows.value.length} 条数据吗?`,
'删除选中数据',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}
).then(() => {
api.deleteCrontabLog({ ids: selectedRows.value.map((row) => row.id) }).then(() => {
ElMessage.success('删除成功')
refreshData()
})
})
}
/**
* 关闭弹窗
*/
const handleClose = () => {
visible.value = false
selectedRows.value = []
}
const {
loading,
data: tableData,
columns,
getData,
pagination,
searchParams,
handleSortChange,
handleSizeChange,
handleCurrentChange
} = useTable({
core: {
apiFn: api.logPageList,
immediate: false,
apiParams: {
...searchForm.value
},
columnsFactory: () => [
{ type: 'selection' },
{ prop: 'create_time', label: '执行时间', sortable: true },
{ prop: 'target', label: '调用目标' },
{ prop: 'parameter', label: '任务参数' },
{ prop: 'status', label: '执行状态', useSlot: true, width: 100 }
]
}
})
</script>

View File

@@ -0,0 +1,77 @@
<template>
<sa-search-bar
ref="searchBarRef"
v-model="formData"
label-width="100px"
:showExpand="false"
@reset="handleReset"
@search="handleSearch"
@expand="handleExpand"
>
<el-col v-bind="setSpan(6)">
<el-form-item label="任务名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入任务名称" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="任务类型" prop="type">
<sa-select v-model="formData.type" dict="crontab_task_type" clearable />
</el-form-item>
</el-col>
<el-col v-bind="setSpan(6)">
<el-form-item label="状态" prop="status">
<sa-select v-model="formData.status" dict="data_status" clearable />
</el-form-item>
</el-col>
</sa-search-bar>
</template>
<script setup lang="ts">
interface Props {
modelValue: Record<string, any>
}
interface Emits {
(e: 'update:modelValue', value: Record<string, any>): void
(e: 'search', params: Record<string, any>): void
(e: 'reset'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// 展开/收起
const isExpanded = ref<boolean>(false)
// 表单数据双向绑定
const searchBarRef = ref()
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
// 重置
function handleReset() {
searchBarRef.value?.ref.resetFields()
emit('reset')
}
// 搜索
async function handleSearch() {
emit('search', formData.value)
}
// 展开/收起
function handleExpand(expanded: boolean) {
isExpanded.value = expanded
}
// 栅格占据的列数
const setSpan = (span: number) => {
return {
span: span,
xs: 24, // 手机:满宽显示
sm: span >= 12 ? span : 12, // 平板大于等于12保持否则用半宽
md: span >= 8 ? span : 8, // 中等屏幕大于等于8保持否则用三分之一宽
lg: span,
xl: span
}
}
</script>

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": ["esnext", "dom"],
"types": ["vite/client", "node", "element-plus/global"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"],
"@views/*": ["src/views/*"],
"@imgs/*": ["src/assets/images/*"],
"@icons/*": ["src/assets/icons/*"],
"@utils/*": ["src/utils/*"],
"@stores/*": ["src/store/*"],
"@plugins/*": ["src/plugins/*"],
"@styles/*": ["src/assets/styles/*"]
}
},
"include": ["src/**/*", "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"exclude": ["node_modules", "dist", "**/*.js"]
}

View File

@@ -0,0 +1,157 @@
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import { fileURLToPath } from 'url'
import vueDevTools from 'vite-plugin-vue-devtools'
import viteCompression from 'vite-plugin-compression'
import Components from 'unplugin-vue-components/vite'
import AutoImport from 'unplugin-auto-import/vite'
import ElementPlus from 'unplugin-element-plus/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import tailwindcss from '@tailwindcss/vite'
// import { visualizer } from 'rollup-plugin-visualizer'
export default ({ mode }: { mode: string }) => {
const root = process.cwd()
const env = loadEnv(mode, root)
const { VITE_VERSION, VITE_PORT, VITE_BASE_URL, VITE_API_URL, VITE_API_PROXY_URL } = env
console.log(`🚀 API_URL = ${VITE_API_URL}`)
console.log(`🚀 VERSION = ${VITE_VERSION}`)
return defineConfig({
define: {
__APP_VERSION__: JSON.stringify(VITE_VERSION)
},
base: VITE_BASE_URL,
server: {
port: Number(VITE_PORT),
proxy: {
'/api': {
target: VITE_API_PROXY_URL,
changeOrigin: true,
rewrite: (path) => path.replace(new RegExp('^' + VITE_API_URL), '')
}
},
host: true
},
// 路径别名
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
'@views': resolvePath('src/views'),
'@imgs': resolvePath('src/assets/images'),
'@icons': resolvePath('src/assets/icons'),
'@utils': resolvePath('src/utils'),
'@stores': resolvePath('src/store'),
'@styles': resolvePath('src/assets/styles')
}
},
build: {
target: 'es2015',
outDir: 'dist',
chunkSizeWarningLimit: 2000,
minify: 'terser',
terserOptions: {
compress: {
// 生产环境去除 console
drop_console: true,
// 生产环境去除 debugger
drop_debugger: true
}
},
dynamicImportVarsOptions: {
warnOnError: true,
exclude: [],
include: ['src/views/**/*.vue']
}
},
plugins: [
vue(),
tailwindcss(),
// 自动按需导入 API
AutoImport({
imports: ['vue', 'vue-router', 'pinia', '@vueuse/core'],
dts: 'src/types/import/auto-imports.d.ts',
resolvers: [ElementPlusResolver()],
eslintrc: {
enabled: true,
filepath: './.auto-import.json',
globalsPropValue: true
}
}),
// 自动按需导入组件
Components({
dts: 'src/types/import/components.d.ts',
resolvers: [ElementPlusResolver()]
}),
// 按需定制主题配置
ElementPlus({
useSource: true
}),
// 压缩
viteCompression({
verbose: false, // 是否在控制台输出压缩结果
disable: false, // 是否禁用
algorithm: 'gzip', // 压缩算法
ext: '.gz', // 压缩后的文件名后缀
threshold: 10240, // 只有大小大于该值的资源会被处理 10240B = 10KB
deleteOriginFile: false // 压缩后是否删除原文件
}),
vueDevTools()
// 打包分析
// visualizer({
// open: true,
// gzipSize: true,
// brotliSize: true,
// filename: 'dist/stats.html' // 分析图生成的文件名及路径
// }),
],
// 依赖预构建:避免运行时重复请求与转换,提升首次加载速度
optimizeDeps: {
include: [
'echarts/core',
'echarts/charts',
'echarts/components',
'echarts/renderers',
'xlsx',
'xgplayer',
'crypto-js',
'file-saver',
'vue-img-cutter',
'element-plus/es',
'element-plus/es/components/*/style/css',
'element-plus/es/components/*/style/index'
]
},
css: {
preprocessorOptions: {
// sass variable and mixin
scss: {
additionalData: `
@use "@styles/core/el-light.scss" as *;
@use "@styles/core/mixin.scss" as *;
`
}
},
postcss: {
plugins: [
{
postcssPlugin: 'internal:charset-removal',
AtRule: {
charset: (atRule) => {
if (atRule.name === 'charset') {
atRule.remove()
}
}
}
}
]
}
}
})
}
function resolvePath(paths: string) {
return path.resolve(__dirname, paths)
}

23
server/.env.example Normal file
View File

@@ -0,0 +1,23 @@
# 数据库配置
DB_TYPE = mysql
DB_HOST = 127.0.0.1
DB_PORT = 3306
DB_NAME = saiadmin
DB_USER = root
DB_PASSWORD = 123456
DB_PREFIX =
# 缓存方式,支持file|redis
CACHE_MODE = file
# Redis配置
REDIS_HOST = 127.0.0.1
REDIS_PORT = 6379
REDIS_PASSWORD = ''
REDIS_DB = 0
# 验证码配置,支持cache|session
CAPTCHA_MODE = cache
#前端目录
FRONTEND_DIR = saiadmin-vue

8
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
/runtime
/.idea
/.vscode
/vendor
*.log
.env
/tests/tmp
/tests/.phpunit.result.cache

21
server/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 walkor<walkor@workerman.net> and contributors (see https://github.com/walkor/webman/contributors)
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.

70
server/README.md Normal file
View File

@@ -0,0 +1,70 @@
<div style="padding:18px;max-width: 1024px;margin:0 auto;background-color:#fff;color:#333">
<h1>webman</h1>
基于<a href="https://www.workerman.net" target="__blank">workerman</a>开发的超高性能PHP框架
<h1>学习</h1>
<ul>
<li>
<a href="https://www.workerman.net/webman" target="__blank">主页 / Home page</a>
</li>
<li>
<a href="https://webman.workerman.net" target="__blank">文档 / Document</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/install.html" target="__blank">安装 / Install</a>
</li>
<li>
<a href="https://www.workerman.net/questions" target="__blank">问答 / Questions</a>
</li>
<li>
<a href="https://www.workerman.net/apps" target="__blank">市场 / Apps</a>
</li>
<li>
<a href="https://www.workerman.net/sponsor" target="__blank">赞助 / Sponsors</a>
</li>
<li>
<a href="https://www.workerman.net/doc/webman/thanks.html" target="__blank">致谢 / Thanks</a>
</li>
</ul>
<div style="float:left;padding-bottom:30px;">
<h1>赞助商</h1>
<h4>特别赞助</h4>
<a href="https://www.crmeb.com/?form=workerman" target="__blank">
<img src="https://www.workerman.net/img/sponsors/6429/20230719111500.svg" width="200">
</a>
<h4>铂金赞助</h4>
<a href="https://www.fadetask.com/?from=workerman" target="__blank"><img src="https://www.workerman.net/img/sponsors/1/20230719084316.png" width="200"></a>
<a href="https://www.yilianyun.net/?from=workerman" target="__blank" style="margin-left:20px;"><img src="https://www.workerman.net/img/sponsors/6218/20230720114049.png" width="200"></a>
</div>
<div style="float:left;padding-bottom:30px;clear:both">
<h1>请作者喝咖啡</h1>
<img src="https://www.workerman.net/img/wx_donate.png" width="200">
<img src="https://www.workerman.net/img/ali_donate.png" width="200">
<br>
<b>如果您觉得webman对您有所帮助欢迎捐赠。</b>
</div>
<div style="clear: both">
<h1>LICENSE</h1>
The webman is open-sourced software licensed under the MIT.
</div>
</div>

View File

@@ -0,0 +1,42 @@
<?php
namespace app\controller;
use support\Request;
class IndexController
{
public function index(Request $request)
{
return <<<EOF
<style>
* {
padding: 0;
margin: 0;
}
iframe {
border: none;
overflow: scroll;
}
</style>
<iframe
src="https://www.workerman.net/wellcome"
width="100%"
height="100%"
allow="clipboard-write"
sandbox="allow-scripts allow-same-origin allow-popups"
></iframe>
EOF;
}
public function view(Request $request)
{
return view('index/view', ['name' => 'webman']);
}
public function json(Request $request)
{
return json(['code' => 0, 'msg' => 'ok']);
}
}

4
server/app/functions.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
/**
* Here is your custom functions.
*/

View File

@@ -0,0 +1,42 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\middleware;
use Webman\MiddlewareInterface;
use Webman\Http\Response;
use Webman\Http\Request;
/**
* Class StaticFile
* @package app\middleware
*/
class StaticFile implements MiddlewareInterface
{
public function process(Request $request, callable $handler): Response
{
// Access to files beginning with. Is prohibited
if (strpos($request->path(), '/.') !== false) {
return response('<h1>403 forbidden</h1>', 403);
}
/** @var Response $response */
$response = $handler($request);
// Add cross domain HTTP header
/*$response->withHeaders([
'Access-Control-Allow-Origin' => '*',
'Access-Control-Allow-Credentials' => 'true',
]);*/
return $response;
}
}

29
server/app/model/Test.php Normal file
View File

@@ -0,0 +1,29 @@
<?php
namespace app\model;
use support\Model;
class Test extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'test';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = false;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace app\process;
use Webman\App;
class Http extends App
{
}

View File

@@ -0,0 +1,305 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace app\process;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
use Workerman\Timer;
use Workerman\Worker;
/**
* Class FileMonitor
* @package process
*/
class Monitor
{
/**
* @var array
*/
protected array $paths = [];
/**
* @var array
*/
protected array $extensions = [];
/**
* @var array
*/
protected array $loadedFiles = [];
/**
* @var int
*/
protected int $ppid = 0;
/**
* Pause monitor
* @return void
*/
public static function pause(): void
{
file_put_contents(static::lockFile(), time());
}
/**
* Resume monitor
* @return void
*/
public static function resume(): void
{
clearstatcache();
if (is_file(static::lockFile())) {
unlink(static::lockFile());
}
}
/**
* Whether monitor is paused
* @return bool
*/
public static function isPaused(): bool
{
clearstatcache();
return file_exists(static::lockFile());
}
/**
* Lock file
* @return string
*/
protected static function lockFile(): string
{
return runtime_path('monitor.lock');
}
/**
* FileMonitor constructor.
* @param $monitorDir
* @param $monitorExtensions
* @param array $options
*/
public function __construct($monitorDir, $monitorExtensions, array $options = [])
{
$this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0;
static::resume();
$this->paths = (array)$monitorDir;
$this->extensions = $monitorExtensions;
foreach (get_included_files() as $index => $file) {
$this->loadedFiles[$file] = $index;
if (strpos($file, 'webman-framework/src/support/App.php')) {
break;
}
}
if (!Worker::getAllWorkers()) {
return;
}
$disableFunctions = explode(',', ini_get('disable_functions'));
if (in_array('exec', $disableFunctions, true)) {
echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n";
} else {
if ($options['enable_file_monitor'] ?? true) {
Timer::add(1, function () {
$this->checkAllFilesChange();
});
}
}
$memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null);
if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) {
Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]);
}
}
/**
* @param $monitorDir
* @return bool
*/
public function checkFilesChange($monitorDir): bool
{
static $lastMtime, $tooManyFilesCheck;
if (!$lastMtime) {
$lastMtime = time();
}
clearstatcache();
if (!is_dir($monitorDir)) {
if (!is_file($monitorDir)) {
return false;
}
$iterator = [new SplFileInfo($monitorDir)];
} else {
// recursive traversal directory
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
$iterator = new RecursiveIteratorIterator($dirIterator);
}
$count = 0;
foreach ($iterator as $file) {
$count ++;
/** var SplFileInfo $file */
if (is_dir($file->getRealPath())) {
continue;
}
// check mtime
if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) {
$lastMtime = $file->getMTime();
if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) {
echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n";
continue;
}
$var = 0;
exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var);
if ($var) {
continue;
}
// send SIGUSR1 signal to master process for reload
if (DIRECTORY_SEPARATOR === '/') {
if ($masterPid = $this->getMasterPid()) {
echo $file . " updated and reload\n";
posix_kill($masterPid, SIGUSR1);
} else {
echo "Master process has gone away and can not reload\n";
}
return true;
}
echo $file . " updated and reload\n";
return true;
}
}
if (!$tooManyFilesCheck && $count > 1000) {
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
$tooManyFilesCheck = 1;
}
return false;
}
/**
* @return int
*/
public function getMasterPid(): int
{
if ($this->ppid === 0) {
return 0;
}
if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) {
echo "Master process has gone away\n";
return $this->ppid = 0;
}
if (PHP_OS_FAMILY !== 'Linux') {
return $this->ppid;
}
$cmdline = "/proc/$this->ppid/cmdline";
if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) {
// Process not exist
$this->ppid = 0;
}
return $this->ppid;
}
/**
* @return bool
*/
public function checkAllFilesChange(): bool
{
if (static::isPaused()) {
return false;
}
foreach ($this->paths as $path) {
if ($this->checkFilesChange($path)) {
return true;
}
}
return false;
}
/**
* @param $memoryLimit
* @return void
*/
public function checkMemory($memoryLimit): void
{
if (static::isPaused() || $memoryLimit <= 0) {
return;
}
$masterPid = $this->getMasterPid();
if ($masterPid <= 0) {
echo "Master process has gone away\n";
return;
}
$childrenFile = "/proc/$masterPid/task/$masterPid/children";
if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) {
return;
}
foreach (explode(' ', $children) as $pid) {
$pid = (int)$pid;
$statusFile = "/proc/$pid/status";
if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) {
continue;
}
$mem = 0;
if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) {
$mem = $match[1];
}
$mem = (int)($mem / 1024);
if ($mem >= $memoryLimit) {
posix_kill($pid, SIGINT);
}
}
}
/**
* Get memory limit
* @param $memoryLimit
* @return int
*/
protected function getMemoryLimit($memoryLimit): int
{
if ($memoryLimit === 0) {
return 0;
}
$usePhpIni = false;
if (!$memoryLimit) {
$memoryLimit = ini_get('memory_limit');
$usePhpIni = true;
}
if ($memoryLimit == -1) {
return 0;
}
$unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]);
$memoryLimit = (int)$memoryLimit;
if ($unit === 'g') {
$memoryLimit = 1024 * $memoryLimit;
} else if ($unit === 'k') {
$memoryLimit = ($memoryLimit / 1024);
} else if ($unit === 'm') {
$memoryLimit = (int)($memoryLimit);
} else if ($unit === 't') {
$memoryLimit = (1024 * 1024 * $memoryLimit);
} else {
$memoryLimit = ($memoryLimit / (1024 * 1024));
}
if ($memoryLimit < 50) {
$memoryLimit = 50;
}
if ($usePhpIni) {
$memoryLimit = (0.8 * $memoryLimit);
}
return (int)$memoryLimit;
}
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="shortcut icon" href="/favicon.ico"/>
<title>webman</title>
</head>
<body>
hello <?=htmlspecialchars($name)?>
</body>
</html>

57
server/composer.json Normal file
View File

@@ -0,0 +1,57 @@
{
"name": "workerman/webman",
"type": "project",
"keywords": [
"high performance",
"http service"
],
"homepage": "https://www.workerman.net",
"license": "MIT",
"description": "High performance HTTP Service Framework.",
"authors": [
{
"name": "walkor",
"email": "walkor@workerman.net",
"homepage": "https://www.workerman.net",
"role": "Developer"
}
],
"support": {
"email": "walkor@workerman.net",
"issues": "https://github.com/walkor/webman/issues",
"forum": "https://wenda.workerman.net/",
"wiki": "https://workerman.net/doc/webman",
"source": "https://github.com/walkor/webman"
},
"require": {
"php": ">=8.1",
"workerman/webman-framework": "^2.1",
"monolog/monolog": "^2.0",
"saithink/saiadmin": "^6.0",
"saithink/saipackage": "^6.0"
},
"suggest": {
"ext-event": "For better performance. "
},
"autoload": {
"psr-4": {
"": "./",
"app\\": "./app",
"App\\": "./app",
"app\\View\\Components\\": "./app/view/components"
}
},
"scripts": {
"post-package-install": [
"support\\Plugin::install"
],
"post-package-update": [
"support\\Plugin::install"
],
"pre-package-uninstall": [
"support\\Plugin::uninstall"
]
},
"minimum-stability": "dev",
"prefer-stable": true
}

26
server/config/app.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Request;
return [
'debug' => true,
'error_reporting' => E_ALL,
'default_timezone' => 'Asia/Shanghai',
'request_class' => Request::class,
'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public',
'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime',
'controller_suffix' => 'Controller',
'controller_reuse' => false,
];

View File

@@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'files' => [
base_path() . '/app/functions.php',
base_path() . '/support/Request.php',
base_path() . '/support/Response.php',
]
];

View File

@@ -0,0 +1,17 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
support\bootstrap\Session::class,
];

18
server/config/cache.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
return [
'default' => env('CACHE_MODE', 'file'),
'stores' => [
'file' => [
'driver' => 'file',
'path' => runtime_path('cache')
],
'redis' => [
'driver' => 'redis',
'connection' => 'default'
],
'array' => [
'driver' => 'array'
]
]
];

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return new Webman\Container;

View File

@@ -0,0 +1,29 @@
<?php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
'driver' => env('DB_TYPE', 'mysql'),
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', 3306),
'database' => env('DB_NAME', 'saiadmin'),
'username' => env('DB_USER', 'root'),
'password' => env('DB_PASSWORD', '123456'),
'charset' => env('DB_CHARSET', 'utf8mb4'),
'collation' => env('DB_COLLATION', 'utf8mb4_general_ci'),
'prefix' => env('DB_PREFIX', ''),
'strict' => true,
'engine' => null,
'options' => [
PDO::ATTR_EMULATE_PREPARES => false, // Must be false for Swoole and Swow drivers.
],
'pool' => [
'max_connections' => 5,
'min_connections' => 1,
'wait_timeout' => 3,
'idle_timeout' => 60,
'heartbeat_interval' => 50,
],
],
],
];

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [];

View File

@@ -0,0 +1,17 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'' => support\exception\Handler::class,
];

32
server/config/log.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'default' => [
'handlers' => [
[
'class' => Monolog\Handler\RotatingFileHandler::class,
'constructor' => [
runtime_path() . '/logs/webman.log',
7, //$maxFiles
Monolog\Logger::DEBUG,
],
'formatter' => [
'class' => Monolog\Formatter\LineFormatter::class,
'constructor' => [null, 'Y-m-d H:i:s', true],
],
]
],
],
];

View File

@@ -0,0 +1,15 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [];

62
server/config/process.php Normal file
View File

@@ -0,0 +1,62 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\Log;
use support\Request;
use app\process\Http;
global $argv;
return [
'webman' => [
'handler' => Http::class,
'listen' => 'http://0.0.0.0:8787',
'count' => cpu_count() * 4,
'user' => '',
'group' => '',
'reusePort' => false,
'eventLoop' => '',
'context' => [],
'constructor' => [
'requestClass' => Request::class,
'logger' => Log::channel('default'),
'appPath' => app_path(),
'publicPath' => public_path()
]
],
// File update detection and automatic reload
'monitor' => [
'handler' => app\process\Monitor::class,
'reloadable' => false,
'constructor' => [
// Monitor these directories
'monitorDir' => array_merge([
app_path(),
config_path(),
base_path() . '/process',
base_path() . '/support',
base_path() . '/resource',
base_path() . '/.env',
], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')),
// Files with these suffixes will be monitored
'monitorExtensions' => [
'php', 'html', 'htm', 'env'
],
'options' => [
'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/',
'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/',
]
]
]
];

17
server/config/redis.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
return [
'default' => [
'password' => env('REDIS_PASSWORD', ''),
'host' => env('REDIS_HOST', '127.0.0.1'),
'port' => env('REDIS_PORT', 6379),
'database' => env('REDIS_DB', 0),
'pool' => [
'max_connections' => 5,
'min_connections' => 1,
'wait_timeout' => 3,
'idle_timeout' => 60,
'heartbeat_interval' => 50,
],
]
];

21
server/config/route.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Route;

23
server/config/server.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
return [
'event_loop' => '',
'stop_timeout' => 2,
'pid_file' => runtime_path() . '/webman.pid',
'status_file' => runtime_path() . '/webman.status',
'stdout_file' => runtime_path() . '/logs/stdout.log',
'log_file' => runtime_path() . '/logs/workerman.log',
'max_package_size' => 10 * 1024 * 1024
];

65
server/config/session.php Normal file
View File

@@ -0,0 +1,65 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Session\FileSessionHandler;
use Webman\Session\RedisSessionHandler;
use Webman\Session\RedisClusterSessionHandler;
return [
'type' => 'file', // or redis or redis_cluster
'handler' => FileSessionHandler::class,
'config' => [
'file' => [
'save_path' => runtime_path() . '/sessions',
],
'redis' => [
'host' => '127.0.0.1',
'port' => 6379,
'auth' => '',
'timeout' => 2,
'database' => '',
'prefix' => 'redis_session_',
],
'redis_cluster' => [
'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'],
'timeout' => 2,
'auth' => '',
'prefix' => 'redis_session_',
]
],
'session_name' => 'PHPSID',
'auto_update_timestamp' => false,
'lifetime' => 7*24*60*60,
'cookie_lifetime' => 365*24*60*60,
'cookie_path' => '/',
'domain' => '',
'http_only' => true,
'secure' => false,
'same_site' => '',
'gc_probability' => [1, 1000],
];

23
server/config/static.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Static file settings
*/
return [
'enable' => true,
'middleware' => [ // Static file Middleware
//app\middleware\StaticFile::class,
],
];

View File

@@ -0,0 +1,44 @@
<?php
return [
// 默认缓存驱动
'default' => env('CACHE_MODE', 'file'),
// 缓存连接方式配置
'stores' => [
// redis缓存
'redis' => [
// 驱动方式
'type' => 'redis',
// 服务器地址
'host' => env('REDIS_HOST', '127.0.0.1'),
// 服务器端口
'port' => env('REDIS_PORT', 6379),
// 服务器密码
'password' => env('REDIS_PASSWORD', ''),
// 数据库
'select' => env('REDIS_DB', 0),
// 缓存前缀
'prefix' => 'cache:',
// 默认缓存有效期 0表示永久缓存
'expire' => 0,
// Thinkphp官方没有这个参数由于生成的tag键默认不过期如果tag键数量很大避免长时间占用内存可以设置一个超过其他缓存的过期时间0为不设置
'tag_expire' => 86400 * 30,
// 缓存标签前缀
'tag_prefix' => 'tag:',
// 连接池配置
'pool' => [
'max_connections' => 5, // 最大连接数
'min_connections' => 1, // 最小连接数
'wait_timeout' => 3, // 从连接池获取连接等待超时时间
'idle_timeout' => 60, // 连接最大空闲时间,超过该时间会被回收
'heartbeat_interval' => 50, // 心跳检测间隔需要小于60秒
],
],
// 文件缓存
'file' => [
// 驱动方式
'type' => 'file',
// 设置不同的缓存保存目录
'path' => runtime_path() . '/file/',
],
],
];

View File

@@ -0,0 +1,42 @@
<?php
return [
'default' => 'mysql',
'connections' => [
'mysql' => [
// 数据库类型
'type' => env('DB_TYPE', 'mysql'),
// 服务器地址
'hostname' => env('DB_HOST', '127.0.0.1'),
// 数据库名
'database' => env('DB_NAME', 'saiadmin'),
// 数据库用户名
'username' => env('DB_USER', 'root'),
// 数据库密码
'password' => env('DB_PASSWORD', '123456'),
// 数据库连接端口
'hostport' => env('DB_PORT', 3306),
// 数据库连接参数
'params' => [
// 连接超时3秒
\PDO::ATTR_TIMEOUT => 3,
],
// 数据库编码默认采用utf8
'charset' => 'utf8',
// 数据库表前缀
'prefix' => env('DB_PREFIX', ''),
// 断线重连
'break_reconnect' => true,
// 自定义分页类
'bootstrap' => '',
// 连接池配置
'pool' => [
'max_connections' => 5, // 最大连接数
'min_connections' => 1, // 最小连接数
'wait_timeout' => 3, // 从连接池获取连接等待超时时间
'idle_timeout' => 60, // 连接最大空闲时间,超过该时间会被回收
'heartbeat_interval' => 50, // 心跳检测间隔需要小于60秒
],
],
],
];

View File

@@ -0,0 +1,25 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
/**
* Multilingual configuration
*/
return [
// Default language
'locale' => 'zh_CN',
// Fallback language
'fallback_locale' => ['zh_CN', 'en'],
// Folder where language files are stored
'path' => base_path() . '/resource/translations',
];

22
server/config/view.php Normal file
View File

@@ -0,0 +1,22 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use support\view\Raw;
use support\view\Twig;
use support\view\Blade;
use support\view\ThinkPHP;
return [
'handler' => Raw::class
];

BIN
server/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1,16 @@
{
"name": "npm-install-test",
"admin_name": "saiadmin",
"version": "1.0.0",
"type": "module",
"license": "MIT",
"scripts": {
"dev": "vite serve --mode development",
"build": "vite build",
"preview": "vite preview",
"tailwind": "tailwind-config-viewer -o -c tailwind.config.cjs"
},
"dependencies": {
"vue": "^3.4.19"
}
}

5
server/start.php Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env php
<?php
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
support\App::run();

View File

@@ -0,0 +1,50 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace support;
/**
* Class Request
* @package support
*/
class Request extends \Webman\Http\Request
{
/**
* 获取参数增强方法
* @param array $params
* @return array
*/
public function more(array $params): array
{
$p = [];
foreach ($params as $param) {
if (!is_array($param)) {
$p[$param] = $this->input($param);
} else {
if (!isset($param[1])) $param[1] = '';
if (is_array($param[0])) {
$name = $param[0][0] . '/' . $param[0][1];
$keyName = $param[0][0];
} else {
$name = $param[0];
$keyName = $param[0];
}
$p[$keyName] = $this->input($name, $param[1]);
}
}
return $p;
}
}

View File

@@ -0,0 +1,24 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
namespace support;
/**
* Class Response
* @package support
*/
class Response extends \Webman\Http\Response
{
}

View File

@@ -0,0 +1,139 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Dotenv\Dotenv;
use support\Log;
use Webman\Bootstrap;
use Webman\Config;
use Webman\Middleware;
use Webman\Route;
use Webman\Util;
use Workerman\Events\Select;
use Workerman\Worker;
$worker = $worker ?? null;
if (empty(Worker::$eventLoopClass)) {
Worker::$eventLoopClass = Select::class;
}
set_error_handler(function ($level, $message, $file = '', $line = 0) {
if (error_reporting() & $level) {
throw new ErrorException($message, 0, $level, $file, $line);
}
});
if ($worker) {
register_shutdown_function(function ($startTime) {
if (time() - $startTime <= 0.1) {
sleep(1);
}
}, time());
}
if (class_exists('Dotenv\Dotenv') && file_exists(base_path(false) . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeMutable')) {
Dotenv::createUnsafeMutable(base_path(false))->load();
} else {
Dotenv::createMutable(base_path(false))->load();
}
}
Config::clear();
support\App::loadAllConfig(['route']);
if ($timezone = config('app.default_timezone')) {
date_default_timezone_set($timezone);
}
foreach (config('autoload.files', []) as $file) {
include_once $file;
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
foreach ($projects['autoload']['files'] ?? [] as $file) {
include_once $file;
}
}
Middleware::load(config('middleware', []));
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project) || $name === 'static') {
continue;
}
Middleware::load($project['middleware'] ?? []);
}
Middleware::load($projects['middleware'] ?? [], $firm);
if ($staticMiddlewares = config("plugin.$firm.static.middleware")) {
Middleware::load(['__static__' => $staticMiddlewares], $firm);
}
}
Middleware::load(['__static__' => config('static.middleware', [])]);
foreach (config('bootstrap', []) as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['bootstrap'] ?? [] as $className) {
if (!class_exists($className)) {
$log = "Warning: Class $className setting in config/plugin/$firm/$name/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
foreach ($projects['bootstrap'] ?? [] as $className) {
/** @var string $className */
if (!class_exists($className)) {
$log = "Warning: Class $className setting in plugin/$firm/config/bootstrap.php not found\r\n";
echo $log;
Log::error($log);
continue;
}
/** @var Bootstrap $className */
$className::start($worker);
}
}
$directory = base_path() . '/plugin';
$paths = [config_path()];
foreach (Util::scanDir($directory) as $path) {
if (is_dir($path = "$path/config")) {
$paths[] = $path;
}
}
Route::load($paths);

3
server/windows.bat Normal file
View File

@@ -0,0 +1,3 @@
CHCP 65001
php windows.php
pause

136
server/windows.php Normal file
View File

@@ -0,0 +1,136 @@
<?php
/**
* Start file for windows
*/
chdir(__DIR__);
require_once __DIR__ . '/vendor/autoload.php';
use Dotenv\Dotenv;
use support\App;
use Workerman\Worker;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (class_exists('Dotenv\Dotenv') && file_exists(base_path() . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv::createUnsafeImmutable(base_path())->load();
} else {
Dotenv::createMutable(base_path())->load();
}
}
App::loadAllConfig(['route']);
$errorReporting = config('app.error_reporting');
if (isset($errorReporting)) {
error_reporting($errorReporting);
}
$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows';
$paths = [
$runtimeProcessPath,
runtime_path('logs'),
runtime_path('views')
];
foreach ($paths as $path) {
if (!is_dir($path)) {
mkdir($path, 0777, true);
}
}
$processFiles = [];
if (config('server.listen')) {
$processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php';
}
foreach (config('process', []) as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, '');
}
foreach (config('plugin', []) as $firm => $projects) {
foreach ($projects as $name => $project) {
if (!is_array($project)) {
continue;
}
foreach ($project['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name");
}
}
foreach ($projects['process'] ?? [] as $processName => $config) {
$processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm);
}
}
function write_process_file($runtimeProcessPath, $processName, $firm): string
{
$processParam = $firm ? "plugin.$firm.$processName" : $processName;
$configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']";
$fileContent = <<<EOF
<?php
require_once __DIR__ . '/../../vendor/autoload.php';
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
use Webman\Config;
use support\App;
ini_set('display_errors', 'on');
error_reporting(E_ALL);
if (is_callable('opcache_reset')) {
opcache_reset();
}
if (!\$appConfigFile = config_path('app.php')) {
throw new RuntimeException('Config file not found: app.php');
}
\$appConfig = require \$appConfigFile;
if (\$timezone = \$appConfig['default_timezone'] ?? '') {
date_default_timezone_set(\$timezone);
}
App::loadAllConfig(['route']);
worker_start('$processParam', $configParam);
if (DIRECTORY_SEPARATOR != "/") {
Worker::\$logFile = config('server')['log_file'] ?? Worker::\$logFile;
TcpConnection::\$defaultMaxPackageSize = config('server')['max_package_size'] ?? 10*1024*1024;
}
Worker::runAll();
EOF;
$processFile = $runtimeProcessPath . DIRECTORY_SEPARATOR . "start_$processParam.php";
file_put_contents($processFile, $fileContent);
return $processFile;
}
if ($monitorConfig = config('process.monitor.constructor')) {
$monitorHandler = config('process.monitor.handler');
$monitor = new $monitorHandler(...array_values($monitorConfig));
}
function popen_processes($processFiles)
{
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
$descriptorspec = [STDIN, STDOUT, STDOUT];
$resource = proc_open($cmd, $descriptorspec, $pipes, null, null, ['bypass_shell' => true]);
if (!$resource) {
exit("Can not execute $cmd\r\n");
}
return $resource;
}
$resource = popen_processes($processFiles);
echo "\r\n";
while (1) {
sleep(1);
if (!empty($monitor) && $monitor->checkAllFilesChange()) {
$status = proc_get_status($resource);
$pid = $status['pid'];
shell_exec("taskkill /F /T /PID $pid");
proc_close($resource);
$resource = popen_processes($processFiles);
}
}