初始化
21
LICENSE
Normal 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
@@ -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
@@ -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
|
||||
13
saiadmin-artd/.env.development
Normal 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
|
||||
10
saiadmin-artd/.env.production
Normal 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
@@ -0,0 +1,2 @@
|
||||
*.html linguist-detectable=false
|
||||
*.vue linguist-detectable=true
|
||||
11
saiadmin-artd/.gitignore
vendored
Normal 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
|
||||
1
saiadmin-artd/.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
pnpm dlx commitlint --edit $1
|
||||
1
saiadmin-artd/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
pnpm run lint:lint-staged
|
||||
3
saiadmin-artd/.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
/node_modules/*
|
||||
/dist/*
|
||||
/src/main.ts
|
||||
20
saiadmin-artd/.prettierrc
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"singleQuote": true,
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSpacing": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": false,
|
||||
"arrowParens": "always",
|
||||
"insertPragma": false,
|
||||
"requirePragma": false,
|
||||
"proseWrap": "never",
|
||||
"htmlWhitespaceSensitivity": "strict",
|
||||
"endOfLine": "auto",
|
||||
"rangeStart": 0
|
||||
}
|
||||
9
saiadmin-artd/.stylelintignore
Normal file
@@ -0,0 +1,9 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
.husky
|
||||
.vscode
|
||||
|
||||
src/components/Layout/MenuLeft/index.vue
|
||||
src/assets
|
||||
stats.html
|
||||
82
saiadmin-artd/.stylelintrc.cjs
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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` | 编辑弹窗组件,用于新增/编辑数据 |
|
||||
97
saiadmin-artd/commitlint.config.cjs
Normal 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: ''
|
||||
}
|
||||
}
|
||||
83
saiadmin-artd/eslint.config.mjs
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
BIN
saiadmin-artd/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
838
saiadmin-artd/scripts/clean-dev.ts
Normal 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
@@ -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>
|
||||
191
saiadmin-artd/src/api/auth.ts
Normal 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
|
||||
})
|
||||
}
|
||||
46
saiadmin-artd/src/api/common.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
31
saiadmin-artd/src/api/dashboard.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
54
saiadmin-artd/src/api/safeguard/attachment.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
65
saiadmin-artd/src/api/safeguard/category.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
95
saiadmin-artd/src/api/safeguard/database.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
102
saiadmin-artd/src/api/safeguard/dict.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
30
saiadmin-artd/src/api/safeguard/emailLog.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
30
saiadmin-artd/src/api/safeguard/loginLog.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
30
saiadmin-artd/src/api/safeguard/operLog.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
42
saiadmin-artd/src/api/safeguard/server.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
126
saiadmin-artd/src/api/system/config.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
75
saiadmin-artd/src/api/system/dept.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
76
saiadmin-artd/src/api/system/menu.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
75
saiadmin-artd/src/api/system/post.ts
Normal 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'
|
||||
})
|
||||
}
|
||||
}
|
||||
123
saiadmin-artd/src/api/system/role.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
101
saiadmin-artd/src/api/system/user.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
101
saiadmin-artd/src/api/tool/crontab.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
126
saiadmin-artd/src/api/tool/generate.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
BIN
saiadmin-artd/src/assets/images/avatar/avatar.webp
Normal file
|
After Width: | Height: | Size: 954 B |
BIN
saiadmin-artd/src/assets/images/avatar/avatar1.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
saiadmin-artd/src/assets/images/avatar/avatar10.webp
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
saiadmin-artd/src/assets/images/avatar/avatar2.webp
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
saiadmin-artd/src/assets/images/avatar/avatar3.webp
Normal file
|
After Width: | Height: | Size: 726 B |
BIN
saiadmin-artd/src/assets/images/avatar/avatar4.webp
Normal file
|
After Width: | Height: | Size: 944 B |
BIN
saiadmin-artd/src/assets/images/avatar/avatar5.webp
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
saiadmin-artd/src/assets/images/avatar/avatar6.webp
Normal file
|
After Width: | Height: | Size: 810 B |
BIN
saiadmin-artd/src/assets/images/avatar/avatar7.webp
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
saiadmin-artd/src/assets/images/avatar/avatar8.webp
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
saiadmin-artd/src/assets/images/avatar/avatar9.webp
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
saiadmin-artd/src/assets/images/ceremony/hb.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
saiadmin-artd/src/assets/images/ceremony/sd.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
saiadmin-artd/src/assets/images/ceremony/xc.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
saiadmin-artd/src/assets/images/ceremony/yd.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
saiadmin-artd/src/assets/images/common/logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
saiadmin-artd/src/assets/images/common/logo.webp
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
saiadmin-artd/src/assets/images/common/server.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
saiadmin-artd/src/assets/images/draw/draw1.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
saiadmin-artd/src/assets/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
saiadmin-artd/src/assets/images/lock/bg_dark.webp
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
saiadmin-artd/src/assets/images/lock/bg_light.webp
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
saiadmin-artd/src/assets/images/login/lf_icon2.webp
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 409 B |
BIN
saiadmin-artd/src/assets/images/settings/menu_layouts/mixed.png
Normal file
|
After Width: | Height: | Size: 431 B |
|
After Width: | Height: | Size: 439 B |
BIN
saiadmin-artd/src/assets/images/settings/menu_styles/dark.png
Normal file
|
After Width: | Height: | Size: 292 B |
BIN
saiadmin-artd/src/assets/images/settings/menu_styles/design.png
Normal file
|
After Width: | Height: | Size: 286 B |
BIN
saiadmin-artd/src/assets/images/settings/menu_styles/light.png
Normal file
|
After Width: | Height: | Size: 293 B |
BIN
saiadmin-artd/src/assets/images/settings/theme_styles/dark.png
Normal file
|
After Width: | Height: | Size: 448 B |
BIN
saiadmin-artd/src/assets/images/settings/theme_styles/light.png
Normal file
|
After Width: | Height: | Size: 416 B |
BIN
saiadmin-artd/src/assets/images/settings/theme_styles/system.png
Normal file
|
After Width: | Height: | Size: 509 B |
1
saiadmin-artd/src/assets/images/svg/403.svg
Normal 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 |
1
saiadmin-artd/src/assets/images/svg/404.svg
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
5
saiadmin-artd/src/assets/images/svg/500.svg
Normal 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 |
1
saiadmin-artd/src/assets/images/svg/login_icon.svg
Normal 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 |
BIN
saiadmin-artd/src/assets/images/user/avatar.webp
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
saiadmin-artd/src/assets/images/user/user-bg.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
292
saiadmin-artd/src/assets/styles/core/app.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
93
saiadmin-artd/src/assets/styles/core/dark.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
saiadmin-artd/src/assets/styles/core/el-dark.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
// 导入暗黑主题
|
||||
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
|
||||
34
saiadmin-artd/src/assets/styles/core/el-light.scss
Normal 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'
|
||||
)
|
||||
);
|
||||
519
saiadmin-artd/src/assets/styles/core/el-ui.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1036
saiadmin-artd/src/assets/styles/core/md.scss
Normal 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;
|
||||
}
|
||||
157
saiadmin-artd/src/assets/styles/core/mixin.scss
Normal 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);
|
||||
}
|
||||
41
saiadmin-artd/src/assets/styles/core/reset.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
104
saiadmin-artd/src/assets/styles/core/router-transition.scss
Normal 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');
|
||||
}
|
||||
208
saiadmin-artd/src/assets/styles/core/tailwind.css
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
saiadmin-artd/src/assets/styles/core/theme-animation.scss
Normal 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);
|
||||
}
|
||||
11
saiadmin-artd/src/assets/styles/core/theme-change.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
// 主题切换过渡优化,优化除视觉上的不适感
|
||||
.theme-change {
|
||||
* {
|
||||
transition: 0s !important;
|
||||
}
|
||||
|
||||
.el-switch__core,
|
||||
.el-switch__action {
|
||||
transition: all 0.3s !important;
|
||||
}
|
||||
}
|
||||
98
saiadmin-artd/src/assets/styles/custom/one-dark-pro.scss
Normal 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;
|
||||
}
|
||||
23
saiadmin-artd/src/assets/styles/index.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
// 重置默认样式
|
||||
@use './core/reset.scss';
|
||||
|
||||
// 应用全局样式
|
||||
@use './core/app.scss';
|
||||
|
||||
// Element Plus 样式优化
|
||||
@use './core/el-ui.scss';
|
||||
|
||||
// Element Plus 暗黑主题
|
||||
@use './core/el-dark.scss';
|
||||
|
||||
// 暗黑主题样式优化
|
||||
@use './core/dark.scss';
|
||||
|
||||
// 路由切换动画
|
||||
@use './core/router-transition';
|
||||
|
||||
// 主题切换过渡优化
|
||||
@use './core/theme-change.scss';
|
||||
|
||||
// 主题切换圆形扩散动画
|
||||
@use './core/theme-animation.scss';
|
||||
32
saiadmin-artd/src/assets/svg/loading.ts
Normal 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>
|
||||
`
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
21
saiadmin-artd/src/components/core/base/art-logo/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
203
saiadmin-artd/src/components/core/charts/art-bar-chart/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
311
saiadmin-artd/src/components/core/forms/art-form/index.vue
Normal 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>
|
||||
437
saiadmin-artd/src/components/core/forms/art-search-bar/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
584
saiadmin-artd/src/components/core/layouts/art-work-tab/index.vue
Normal 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>
|
||||
350
saiadmin-artd/src/components/core/media/art-cutter-img/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
410
saiadmin-artd/src/components/core/tables/art-table/index.vue
Normal 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 {
|
||||
// 如果找不到表格头部,设置为 undefined,useElementSize 会返回 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
100
saiadmin-artd/src/components/core/theme/theme-svg/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
149
saiadmin-artd/src/components/core/views/login/AuthTopBar.vue
Normal 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>
|
||||
602
saiadmin-artd/src/components/core/views/login/LoginLeftView.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
71
saiadmin-artd/src/components/sai/sa-button/index.vue
Normal 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>
|
||||
108
saiadmin-artd/src/components/sai/sa-checkbox/index.vue
Normal 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>
|
||||
216
saiadmin-artd/src/components/sai/sa-chunk-upload/README.md
Normal 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="MP4、AVI、MOV"
|
||||
: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="ZIP、RAR、7Z"
|
||||
: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
|
||||
```
|
||||
623
saiadmin-artd/src/components/sai/sa-chunk-upload/index.vue
Normal 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>
|
||||
51
saiadmin-artd/src/components/sai/sa-code/index.vue
Normal 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>
|
||||
113
saiadmin-artd/src/components/sai/sa-dict/index.vue
Normal 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>
|
||||
297
saiadmin-artd/src/components/sai/sa-editor/index.vue
Normal 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>
|
||||
210
saiadmin-artd/src/components/sai/sa-editor/style.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
127
saiadmin-artd/src/components/sai/sa-export/index.vue
Normal 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>
|
||||
119
saiadmin-artd/src/components/sai/sa-file-upload/README.MD
Normal 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>
|
||||
269
saiadmin-artd/src/components/sai/sa-file-upload/index.vue
Normal 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>
|
||||
186
saiadmin-artd/src/components/sai/sa-icon-picker/index.vue
Normal 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>
|
||||
3175
saiadmin-artd/src/components/sai/sa-icon-picker/lib/RemixIcon.json
Normal 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"
|
||||
]
|
||||
}
|
||||
389
saiadmin-artd/src/components/sai/sa-image-dialog/index.vue
Normal 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>
|
||||
321
saiadmin-artd/src/components/sai/sa-image-picker/index.vue
Normal 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>
|
||||
310
saiadmin-artd/src/components/sai/sa-image-upload/index.vue
Normal 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>
|
||||
223
saiadmin-artd/src/components/sai/sa-import/index.vue
Normal 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>
|
||||
30
saiadmin-artd/src/components/sai/sa-label/index.vue
Normal 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>
|
||||
113
saiadmin-artd/src/components/sai/sa-md-editor/index.vue
Normal 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) => ``).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>
|
||||
126
saiadmin-artd/src/components/sai/sa-radio/index.vue
Normal 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>
|
||||
236
saiadmin-artd/src/components/sai/sa-search-bar/index.vue
Normal 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>
|
||||
125
saiadmin-artd/src/components/sai/sa-select/index.vue
Normal 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>
|
||||
45
saiadmin-artd/src/components/sai/sa-switch/index.vue
Normal 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>
|
||||
402
saiadmin-artd/src/components/sai/sa-user/index.vue
Normal 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>
|
||||
91
saiadmin-artd/src/composables/useSaiAdmin.ts
Normal 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
|
||||
}
|
||||
}
|
||||
61
saiadmin-artd/src/config/assets/images.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
saiadmin-artd/src/config/fastEnter.ts
Normal 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)
|
||||
135
saiadmin-artd/src/config/index.ts
Normal 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)
|
||||
105
saiadmin-artd/src/config/modules/component.ts
Normal 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)
|
||||
}
|
||||
127
saiadmin-artd/src/config/modules/fastEnter.ts
Normal 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)
|
||||
51
saiadmin-artd/src/config/modules/festival.ts
Normal 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 Christmas!Art Design Pro 祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!',
|
||||
// }
|
||||
]
|
||||
63
saiadmin-artd/src/config/modules/headerBar.ts
Normal 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
|
||||
109
saiadmin-artd/src/config/setting.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 系统设置默认值配置
|
||||
*
|
||||
* 统一管理系统设置的所有默认值
|
||||
*
|
||||
* ## 主要功能
|
||||
*
|
||||
* - 菜单相关默认配置
|
||||
* - 主题相关默认配置
|
||||
* - 界面显示默认配置
|
||||
* - 功能开关默认配置
|
||||
* - 样式相关默认配置
|
||||
*
|
||||
* ## 注意事项
|
||||
*
|
||||
* 1. 修改此文件的配置项时,需要同步更新以下文件:
|
||||
* - src/components/core/layouts/art-settings-panel/widget/SettingActions.vue(复制配置和重置配置逻辑)
|
||||
* - src/store/modules/setting.ts(Store 状态定义)
|
||||
* 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]
|
||||
}
|
||||
})
|
||||
}
|
||||
248
saiadmin-artd/src/directives/business/highlight.ts
Normal 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)
|
||||
}
|
||||
114
saiadmin-artd/src/directives/business/ripple.ts
Normal 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)
|
||||
}
|
||||
78
saiadmin-artd/src/directives/core/auth.ts
Normal 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)
|
||||
}
|
||||
89
saiadmin-artd/src/directives/core/roles.ts
Normal 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)
|
||||
}
|
||||
14
saiadmin-artd/src/directives/index.ts
Normal 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) // 权限指令
|
||||
}
|
||||
78
saiadmin-artd/src/directives/sai/permission.ts
Normal 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)
|
||||
}
|
||||
81
saiadmin-artd/src/enums/appEnum.ts
Normal 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'
|
||||
}
|
||||
24
saiadmin-artd/src/enums/formEnum.ts
Normal 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
@@ -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 // 版本号
|
||||
45
saiadmin-artd/src/hooks/core/useAppMode.ts
Normal 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
|
||||
}
|
||||
}
|
||||
74
saiadmin-artd/src/hooks/core/useAuth.ts
Normal 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
|
||||
}
|
||||
}
|
||||
184
saiadmin-artd/src/hooks/core/useCeremony.ts
Normal 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
|
||||
}
|
||||
}
|
||||
745
saiadmin-artd/src/hooks/core/useChart.ts
Normal 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
|
||||
}
|
||||
}
|
||||
87
saiadmin-artd/src/hooks/core/useCommon.ts
Normal 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
|
||||
}
|
||||
}
|
||||
55
saiadmin-artd/src/hooks/core/useFastEnter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
201
saiadmin-artd/src/hooks/core/useHeaderBar.ts
Normal 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 // 获取所有禁用的功能(别名)
|
||||
}
|
||||
}
|
||||
148
saiadmin-artd/src/hooks/core/useLayoutHeight.ts
Normal 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
|
||||
}
|
||||
}
|
||||
767
saiadmin-artd/src/hooks/core/useTable.ts
Normal 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'
|
||||
312
saiadmin-artd/src/hooks/core/useTableColumns.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
105
saiadmin-artd/src/hooks/core/useTableHeight.ts
Normal 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
|
||||
}
|
||||
}
|
||||
174
saiadmin-artd/src/hooks/core/useTheme.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
32
saiadmin-artd/src/hooks/index.ts
Normal 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'
|
||||
123
saiadmin-artd/src/locales/index.ts
Normal 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
|
||||
297
saiadmin-artd/src/locales/langs/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
297
saiadmin-artd/src/locales/langs/zh.json
Normal 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
@@ -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')
|
||||
273
saiadmin-artd/src/mock/temp/formData.ts
Normal 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
|
||||
}
|
||||
]
|
||||
12
saiadmin-artd/src/mock/upgrade/changeLog.ts
Normal 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[]>([])
|
||||
76
saiadmin-artd/src/plugins/echarts.ts
Normal 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
|
||||
6
saiadmin-artd/src/plugins/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 插件统一导出
|
||||
* 集中管理第三方库的封装和配置
|
||||
*/
|
||||
|
||||
export * from './echarts'
|
||||
82
saiadmin-artd/src/router/core/ComponentLoader.ts
Normal 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}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
78
saiadmin-artd/src/router/core/IframeRouteManager.ts
Normal 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 = []
|
||||
}
|
||||
}
|
||||
}
|
||||
173
saiadmin-artd/src/router/core/MenuProcessor.ts
Normal 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}`
|
||||
}
|
||||
}
|
||||
119
saiadmin-artd/src/router/core/RoutePermissionValidator.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
90
saiadmin-artd/src/router/core/RouteRegistry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
144
saiadmin-artd/src/router/core/RouteTransformer.ts
Normal 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]}` : '/'
|
||||
}
|
||||
}
|
||||
187
saiadmin-artd/src/router/core/RouteValidator.ts
Normal 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('/')
|
||||
}
|
||||
}
|
||||
14
saiadmin-artd/src/router/core/index.ts
Normal 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'
|
||||
34
saiadmin-artd/src/router/guards/afterEach.ts
Normal 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()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
413
saiadmin-artd/src/router/guards/beforeEach.ts
Normal 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
|
||||
}
|
||||
23
saiadmin-artd/src/router/index.ts
Normal 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 = ''
|
||||
29
saiadmin-artd/src/router/modules/dashboard.ts
Normal 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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
46
saiadmin-artd/src/router/modules/exception.ts
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
15
saiadmin-artd/src/router/modules/index.ts
Normal 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
|
||||
]
|
||||
33
saiadmin-artd/src/router/modules/result.ts
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
60
saiadmin-artd/src/router/modules/system.ts
Normal 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' }
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
9
saiadmin-artd/src/router/routes/asyncRoutes.ts
Normal 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
|
||||
72
saiadmin-artd/src/router/routes/staticRoutes.ts
Normal 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' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
8
saiadmin-artd/src/router/routesAlias.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 公共路由别名
|
||||
# 存放系统级公共路由路径,如布局容器、登录页等
|
||||
*/
|
||||
export enum RoutesAlias {
|
||||
Layout = '/index/index', // 布局容器
|
||||
Login = '/auth/login' // 登录页
|
||||
}
|
||||
52
saiadmin-artd/src/store/index.ts
Normal 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)
|
||||
}
|
||||
80
saiadmin-artd/src/store/modules/dict.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
109
saiadmin-artd/src/store/modules/menu.ts
Normal 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
|
||||
}
|
||||
})
|
||||
450
saiadmin-artd/src/store/modules/setting.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
97
saiadmin-artd/src/store/modules/table.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
253
saiadmin-artd/src/store/modules/user.ts
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
568
saiadmin-artd/src/store/modules/worktab.ts
Normal 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
@@ -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[]
|
||||
}
|
||||
}
|
||||
}
|
||||
95
saiadmin-artd/src/types/common/index.ts
Normal 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'
|
||||
30
saiadmin-artd/src/types/common/response.ts
Normal 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
|
||||
}
|
||||
324
saiadmin-artd/src/types/component/chart.ts
Normal 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
|
||||
}
|
||||
145
saiadmin-artd/src/types/component/index.ts
Normal 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
|
||||
}
|
||||
211
saiadmin-artd/src/types/config/index.ts
Normal 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
|
||||
}
|
||||
22
saiadmin-artd/src/types/index.ts
Normal 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'
|
||||
80
saiadmin-artd/src/types/router/index.ts
Normal 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>)
|
||||
}
|
||||
23
saiadmin-artd/src/types/sai/index.ts
Normal 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
|
||||
}
|
||||
157
saiadmin-artd/src/types/store/index.ts
Normal 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
|
||||
}
|
||||
8
saiadmin-artd/src/utils/constants/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 常量定义相关工具函数统一导出
|
||||
*
|
||||
* @module utils/constants/index
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
export * from './links'
|
||||
35
saiadmin-artd/src/utils/constants/links.ts
Normal 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'
|
||||
}
|
||||
12
saiadmin-artd/src/utils/form/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 表单工具函数统一导出
|
||||
*
|
||||
* @module utils/form
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
// 表单验证器
|
||||
export * from './validator'
|
||||
|
||||
// 响应式布局
|
||||
export * from './responsive'
|
||||
122
saiadmin-artd/src/utils/form/responsive.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
316
saiadmin-artd/src/utils/form/validator.ts
Normal 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
|
||||
}
|
||||
182
saiadmin-artd/src/utils/http/error.ts
Normal 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
|
||||
}
|
||||
217
saiadmin-artd/src/utils/http/index.ts
Normal 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
|
||||
18
saiadmin-artd/src/utils/http/status.ts
Normal 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版本不支持
|
||||
}
|
||||
34
saiadmin-artd/src/utils/index.ts
Normal 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'
|
||||
10
saiadmin-artd/src/utils/navigation/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 路由和导航相关工具函数统一导出
|
||||
*
|
||||
* @module utils/navigation/index
|
||||
* @author Art Design Pro Team
|
||||
*/
|
||||
|
||||
export * from './jump'
|
||||
export * from './worktab'
|
||||
export * from './route'
|
||||
62
saiadmin-artd/src/utils/navigation/jump.ts
Normal 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)
|
||||
}
|
||||
78
saiadmin-artd/src/utils/navigation/route.ts
Normal 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 ''
|
||||
}
|
||||
67
saiadmin-artd/src/utils/navigation/worktab.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
61
saiadmin-artd/src/utils/router.ts
Normal 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 ''
|
||||
}
|
||||
388
saiadmin-artd/src/utils/socket/index.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
7
saiadmin-artd/src/utils/storage/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 存储相关工具函数统一导出
|
||||
*/
|
||||
|
||||
export * from './storage'
|
||||
export * from './storage-config'
|
||||
export * from './storage-key-manager'
|
||||
122
saiadmin-artd/src/utils/storage/storage-config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
97
saiadmin-artd/src/utils/storage/storage-key-manager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
250
saiadmin-artd/src/utils/storage/storage.ts
Normal 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)
|
||||
}
|
||||
8
saiadmin-artd/src/utils/sys/console.ts
Normal 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)
|
||||
102
saiadmin-artd/src/utils/sys/error-handle.ts
Normal 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()
|
||||
}
|
||||
6
saiadmin-artd/src/utils/sys/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* 系统管理相关工具函数统一导出
|
||||
*/
|
||||
|
||||
export * from './upgrade'
|
||||
export { default as mittBus } from './mittBus'
|
||||
63
saiadmin-artd/src/utils/sys/mittBus.ts
Normal 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
|
||||
277
saiadmin-artd/src/utils/sys/upgrade.ts
Normal 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()
|
||||
}
|
||||
266
saiadmin-artd/src/utils/table/tableCache.ts
Normal 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
|
||||
}
|
||||
}
|
||||
59
saiadmin-artd/src/utils/table/tableConfig.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
297
saiadmin-artd/src/utils/table/tableUtils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
60
saiadmin-artd/src/utils/tool.ts
Normal 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)
|
||||
}
|
||||
80
saiadmin-artd/src/utils/ui/animation.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
273
saiadmin-artd/src/utils/ui/colors.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
24
saiadmin-artd/src/utils/ui/emojo.ts
Normal 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
|
||||
31
saiadmin-artd/src/utils/ui/iconify-loader.ts
Normal 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)
|
||||
11
saiadmin-artd/src/utils/ui/index.ts
Normal 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'
|
||||
84
saiadmin-artd/src/utils/ui/loading.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
60
saiadmin-artd/src/utils/ui/tabs.ts
Normal 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 配置
|
||||
}
|
||||
62
saiadmin-artd/src/views/auth/forget-password/index.vue
Normal 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>
|
||||
216
saiadmin-artd/src/views/auth/login/index.vue
Normal 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>
|
||||
38
saiadmin-artd/src/views/auth/login/style.css
Normal 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);
|
||||
}
|
||||
}
|
||||
240
saiadmin-artd/src/views/auth/register/index.vue
Normal 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>
|
||||
53
saiadmin-artd/src/views/dashboard/console/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
169
saiadmin-artd/src/views/dashboard/console/modules/new-user.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
395
saiadmin-artd/src/views/dashboard/user-center/index.vue
Normal 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>
|
||||
16
saiadmin-artd/src/views/exception/403/index.vue
Normal 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>
|
||||
16
saiadmin-artd/src/views/exception/404/index.vue
Normal 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>
|
||||
16
saiadmin-artd/src/views/exception/500/index.vue
Normal 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>
|
||||
29
saiadmin-artd/src/views/index/index.vue
Normal 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>
|
||||
93
saiadmin-artd/src/views/index/style.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
saiadmin-artd/src/views/outside/Iframe.vue
Normal 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>
|
||||
188
saiadmin-artd/src/views/plugin/saipackage/api/index.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
988
saiadmin-artd/src/views/plugin/saipackage/install/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
390
saiadmin-artd/src/views/plugin/saipackage/install/terminal.vue
Normal 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>
|
||||
345
saiadmin-artd/src/views/plugin/saipackage/store/terminal.ts
Normal 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
|
||||
28
saiadmin-artd/src/views/result/fail/index.vue
Normal 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>
|
||||
21
saiadmin-artd/src/views/result/success/index.vue
Normal 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>
|
||||
321
saiadmin-artd/src/views/safeguard/attachment/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
249
saiadmin-artd/src/views/safeguard/cache/index.vue
vendored
Normal 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>
|
||||
214
saiadmin-artd/src/views/safeguard/database/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
402
saiadmin-artd/src/views/safeguard/dict/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
121
saiadmin-artd/src/views/safeguard/email-log/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
122
saiadmin-artd/src/views/safeguard/login-log/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
170
saiadmin-artd/src/views/safeguard/oper-log/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
213
saiadmin-artd/src/views/safeguard/server/index.vue
Normal 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>
|
||||
429
saiadmin-artd/src/views/system/config/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
192
saiadmin-artd/src/views/system/config/modules/config-list.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
143
saiadmin-artd/src/views/system/dept/index.vue
Normal 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>
|
||||
194
saiadmin-artd/src/views/system/dept/modules/edit-dialog.vue
Normal 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>
|
||||
77
saiadmin-artd/src/views/system/dept/modules/table-search.vue
Normal 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>
|
||||
223
saiadmin-artd/src/views/system/menu/index.vue
Normal 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>
|
||||
322
saiadmin-artd/src/views/system/menu/modules/edit-dialog.vue
Normal 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>
|
||||
77
saiadmin-artd/src/views/system/menu/modules/table-search.vue
Normal 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>
|
||||
142
saiadmin-artd/src/views/system/post/index.vue
Normal 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>
|
||||
164
saiadmin-artd/src/views/system/post/modules/edit-dialog.vue
Normal 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>
|
||||
77
saiadmin-artd/src/views/system/post/modules/table-search.vue
Normal 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>
|
||||
152
saiadmin-artd/src/views/system/role/index.vue
Normal 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>
|
||||
171
saiadmin-artd/src/views/system/role/modules/edit-dialog.vue
Normal 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>
|
||||
@@ -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>
|
||||
77
saiadmin-artd/src/views/system/role/modules/table-search.vue
Normal 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>
|
||||
280
saiadmin-artd/src/views/system/user/index.vue
Normal 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>
|
||||
301
saiadmin-artd/src/views/system/user/modules/edit-dialog.vue
Normal 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>
|
||||
76
saiadmin-artd/src/views/system/user/modules/table-search.vue
Normal 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>
|
||||
132
saiadmin-artd/src/views/system/user/modules/work-dialog.vue
Normal 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>
|
||||
805
saiadmin-artd/src/views/tool/code/components/editInfo.vue
Normal 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">
|
||||
单表须有主键,树表须指定id、parent_id、name等字段
|
||||
</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>
|
||||
228
saiadmin-artd/src/views/tool/code/components/loadTable.vue
Normal 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>
|
||||
111
saiadmin-artd/src/views/tool/code/components/preview.vue
Normal 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>
|
||||
@@ -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>
|
||||
272
saiadmin-artd/src/views/tool/code/index.vue
Normal 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>
|
||||
45
saiadmin-artd/src/views/tool/code/js/vars.ts
Normal 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' }
|
||||
]
|
||||
66
saiadmin-artd/src/views/tool/code/modules/table-search.vue
Normal 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>
|
||||
160
saiadmin-artd/src/views/tool/crontab/index.vue
Normal 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>
|
||||
278
saiadmin-artd/src/views/tool/crontab/modules/edit-dialog.vue
Normal 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>
|
||||
222
saiadmin-artd/src/views/tool/crontab/modules/log-list.vue
Normal 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>
|
||||
@@ -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>
|
||||
28
saiadmin-artd/tsconfig.json
Normal 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"]
|
||||
}
|
||||
157
saiadmin-artd/vite.config.ts
Normal 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
@@ -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
@@ -0,0 +1,8 @@
|
||||
/runtime
|
||||
/.idea
|
||||
/.vscode
|
||||
/vendor
|
||||
*.log
|
||||
.env
|
||||
/tests/tmp
|
||||
/tests/.phpunit.result.cache
|
||||
21
server/LICENSE
Normal 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
@@ -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>
|
||||
|
||||
|
||||
42
server/app/controller/IndexController.php
Normal 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
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
42
server/app/middleware/StaticFile.php
Normal 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
@@ -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;
|
||||
}
|
||||
10
server/app/process/Http.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use Webman\App;
|
||||
|
||||
class Http extends App
|
||||
{
|
||||
|
||||
}
|
||||
305
server/app/process/Monitor.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
14
server/app/view/index/view.html
Normal 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
@@ -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
@@ -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,
|
||||
];
|
||||
21
server/config/autoload.php
Normal 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',
|
||||
]
|
||||
];
|
||||
17
server/config/bootstrap.php
Normal 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
@@ -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'
|
||||
]
|
||||
]
|
||||
];
|
||||
15
server/config/container.php
Normal 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;
|
||||
29
server/config/database.php
Normal 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,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
15
server/config/dependence.php
Normal 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 [];
|
||||
17
server/config/exception.php
Normal 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
@@ -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],
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
];
|
||||
15
server/config/middleware.php
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
],
|
||||
];
|
||||
44
server/config/think-cache.php
Normal 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/',
|
||||
],
|
||||
],
|
||||
];
|
||||
42
server/config/think-orm.php
Normal 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秒
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
25
server/config/translation.php
Normal 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
@@ -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
|
After Width: | Height: | Size: 4.2 KiB |
16
server/public/npm-install-test/package.json
Normal 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
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
chdir(__DIR__);
|
||||
require_once __DIR__ . '/vendor/autoload.php';
|
||||
support\App::run();
|
||||
50
server/support/Request.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
24
server/support/Response.php
Normal 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
|
||||
{
|
||||
|
||||
}
|
||||
139
server/support/bootstrap.php
Normal 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
@@ -0,0 +1,3 @@
|
||||
CHCP 65001
|
||||
php windows.php
|
||||
pause
|
||||
136
server/windows.php
Normal 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);
|
||||
}
|
||||
}
|
||||