diff --git a/CLAUDE.md b/CLAUDE.md index 8058a00..1a89804 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,10 +87,10 @@ 8. **400(预检撞车)vs 409(竞态 IntegrityError)并存**——API 冻结前统一。 9. **limiter 全量清扫**(token/登录限流的内存键永不回访,长跑进程缓慢累积;收口 = 定期清扫)。 10. **lead_push_service 的 settings re-export 锚**(app.engine.config 的 settings 在 lead_push_service 顶部 re-export,22 处测试 monkeypatch 依赖此锚——改动前先迁测试)。 -11. **密钥掩码未实现**——GET config/settings 返回明文 api_key/smtp 密码;Phase 3 前端对接前必须定掩码哨兵方案(GET 吐 `__SET__` + PUT 收哨兵保留旧值)(出处:终审审查 I-2)。 -12. **站点管理权限现为仅超管**(spec §7.3 写租户管理员同左)——Phase 3 对接时决策放开或改 spec(出处:终审审查 M-2)。 +11. ~~密钥掩码未实现~~(Phase 3 收口,commit 0c25287:GET config/settings 四处响应密钥值掩码为哨兵 `__SET__`,PUT 收哨兵保留库值;前端 `admin/src/api/mask.ts` secretToSubmit/secretPlaceholder 同款契约)。 +12. ~~站点管理权限现为仅超管~~(Phase 3 收口,commit 0c25287:放开租户管理员同权,与 spec §7.3 原文一致)。 -**Phase 3 清理单**(Phase 1/2 审查挂账滚动合并;~~删除线~~ 项已在 Phase 2 完成): +**Phase 3 清理单**(Phase 1/2 审查挂账滚动合并;~~删除线~~ 项已结,结项时点见各条注记;未结项滚入 Phase 4 清理单增补): 1. ~~FakeAI 三份拷贝收敛进 conftest~~(Task 10 完成:conftest.py 单一定义,tests/ 零拷贝)。 2. ~~rate_limit 与 ws_conn_limit 的 0 语义统一 + 配置类型校验~~(Task 10 完成)。 @@ -98,19 +98,41 @@ 4. ~~avatar 从 seed_profiles 迁 uploads 卷~~(Task 7 完成:上传 + 三级解析链)。 5. **AIService.from_runtime 类方法**——组装正典化(出处:引擎改造审查)。 6. **boundary_filter 视图常量退役**——admin 重写后旧测试迁移时(出处:引擎改造审查)。 -7. **push enabled 布尔空槽语义**——admin 配置显式化时解决(出处:push 审查)。 +7. ~~push enabled 布尔空槽语义~~(Phase 3 收口:凭据页显式布尔开关 ElSwitch,normalize `enabled === true` 空槽归 false,提交永为显式布尔)。 8. **contact.template_languages 运行期接线**——后台批次(出处:配置 schema 审查)。 9. **话术含引号/换行的转义**——编辑器批次(`_format_deflection_pool` docstring 有注)。 10. **产品决策项**:国名纠正 e164 变化时是否补发更正邮件(上游行为更优);REST/WS 服务端 IP 口径是否统一(出处:上游吸收审查)。 11. **/health 分级健康**——Phase 4 部署批次(进程存活之外按依赖分级上报;出处:终审审查 M-3)。 12. **PromptProvider.from_dir 的 FileNotFoundError 带路径上下文**——提示词目录缺失时异常信息含实际路径(出处:终审审查 M-4)。 -## Phase 3 计划编写注意 +## Phase 3 完成状态(2026-10-13 收尾核对) -- conftest 三坑纪律延续:`async_db` 夹具用 NullPool;TestClient 必须走 portal 桥接事件循环;禁止 `asyncio.run` 直开租户 session_factory(三个坑都在 tests/conftest.py 有既成解法,照抄勿发明)。 -- `build_app` / `app_client` / `TenantTestClient` 夹具已就绪,新测试优先复用而非自建 app 实例;**平台层测试用 `db_source_app` / `super_client` 夹具**(conftest.py:DB 源装配 + 超管登录态客户端,Phase 2 全部平台路由测试的既成姿势)。 -- 引擎侧读配置一律经 **config_views 单入口**(app/platform/config_views.py:resolve_email/push/ai_settings、resolve_contact_flags、context_seed_map、name_to_region_extras)——新消费点不再自写 resolver。 -- 统计前端照 **stats.py docstring 的指标口径表**展示(会话数不要求有消息、线索=三选一非空、推送成功率分母口径、北京日界趋势等,歧义处已定死)。 +**测试基线:后端 pytest 595 passed / 前端 vitest 156 passed**(Phase 3 起步 139,Task 9 平台设置页 +14、终审修复通用 JSON 组 +3;两端数字均已实跑核对,后端唯一 warning 仍为 starlette TestClient 弃用提示,无害)。`pnpm build`(vue-tsc --noEmit && vite build)零错误。后续回归以此为准。 + +**交付形态**:`admin/` 子目录的 Vue3 + TS + Vite + Element Plus SPA(art-design-pro 模板,pnpm 管理),与后端同仓。本地起法: + +- 后端:`PYTHONUTF8=1 .venv/Scripts/python -m uvicorn app.main:app --port 8018 --workers 1`(不变)。 +- 前端:`cd admin && pnpm install && pnpm dev`(默认 3006,被占自动顺延);Vite 代理 `/api` → 8018,`/api/public` 与 `/static` 走 Host 改写代理(`VITE_TENANT_PREVIEW_HOST`,见 admin/.env.development 注释)。`pnpm test` 跑 vitest;`pnpm build` 出 dist。 +- 登录:超管 / 租户管理员同一登录页(滑块验证为纯前端交互校验);must_change_password 由后端 409 门强制,前端跳 `#/auth/force-change-password`。 + +**admin 能力一览**(Phase 2 平台 API 的全量对接;菜单按角色过滤,后端 require_super / _tenant_guard 兜底): + +- 认证流:登录 / 强制改密 / 退出;pinia + 内存 token,401 统一跳登录,改密 409 契约锁定。 +- 仪表盘:平台总览 + 租户卡片墙(超管)/ 我的指标(租户管理员),时间窗切换。 +- 租户管理(仅超管):列表 / 创建向导(消费出厂档案)/ 详情编辑 / 站点与域名子资源 / 停用复启。 +- 会话与线索:列表(筛选 / 分页 / 租户切换)/ 详情消息流 / 推送日志 / 删除。 +- 配置中心:品牌(表单 / 本地简化预览 / 嵌入代码 / 头像上传)、提示词四件套与结构化词表、试金石面板、凭据(邮件 / 推送 / AI 表单 + SMTP 测试 + 推送探测)、contact/engine/misc 三组通用 JSON 编辑(终审 I-1 补齐 spec §7.3 全组覆盖)、版本历史(两版对比 / 回滚 / 组级重置出厂)。 +- 统计分析:卡片 / 转化漏斗 / 按日趋势 / 意向·来源·站点·国家分布(ECharts)/ 推送健康表;时间与租户筛选(口径同 stats.py docstring 口径表)。 +- 平台设置(仅超管):自由 KV 表的推荐键表单(ai.* / smtp.*,键名是前端约定)+ 存储总览表格;保存 = 逐键 PUT(只提交脏键)。**当前仅存储与展示——resolver 的平台默认回退链未接线(页头注记),引擎不读取这些值**;密钥键掩码哨兵交互与凭据页同款(清除 / 恢复保持)。 + +**前端实现约定**(后续维护须知): + +- 视图逻辑沉淀在各 `views/**/model.ts` 纯函数(掩码判定 / 归一 / 脏检测 / 提交值构造 / 校验),vitest 直接覆盖;.vue 只做装配。auto-import 生效(ref/reactive/ElMessage 等无需手写 import)。 +- 密钥键判定前端镜像后端 `_SETTING_SECRET_MARKERS` 子串匹配('monkey' 含 'key' 也命中——与后端一致是契约,不"修正")。 +- PUT config 整组替换语义:前端封装读-改-写(GET 全组 → 改 → PUT);平台设置不同,为逐键 upsert。 +- `components.d.ts` 构建漂移:`pnpm build`/dev 会重写入库的 dts(仅行尾差异)——`git diff --ignore-cr-at-eol` 为空即 `git checkout --` 还原入库版(超集原则,勿提交行尾噪音)。 + +**滚动纪律**(自 Phase 3 计划注意延续):conftest 三坑(NullPool / portal 桥接 / 禁 asyncio.run)、平台层测试用 `db_source_app` / `super_client` 夹具、引擎读配置走 config_views 单入口、统计展示照 stats.py 口径表——均照抄勿发明。 ## Phase 4 部署记档(提前锚定,部署批次展开成 docs/deploy-production.md) @@ -119,10 +141,21 @@ - **TENANT_SOURCE=db 默认**(生产装配形态);`file` 为冷备/本地调试回退。 - **admin 域名反代区分**:`/api/platform/*` 前缀豁免租户 Host 解析——平台后台 API 可与租户聊天域名同源反代(按路径分流)或独立域名,两形态皆可;租户聊天面(`/`、`/static`、WS、`/api/public`、`/api/chat*`)仍严格按 Host 解析租户,未知 Host 404。 - **ADMIN_PASSWORD 默认值警示**(终审 I-4):lifespan 启动检测到 ADMIN_PASSWORD 仍是出厂默认(admin123)时打 WARNING 日志——生产部署必须设 ADMIN_PASSWORD 环境变量换掉,且超管首登立即改密(must_change_password 门强制)。 +- **Phase 4 清理单增补**(Phase 3 冒烟沉淀 + 终审记档;Phase 3 清理单未结项 5/6/8/9/10 一并滚入): + 1. `components.d.ts` 构建行尾漂移(长期收口 = 生成物出库或 EOL 规范钩子,现阶段靠手工还原)。 + 2. 推送探测 env 回退真址:租户 push URL 空槽时 `resolve_push_settings` 回退 env `PUSH_API_URL`(默认即真 UniService 生产址)——后台点「探测」会对真实服务发空批量 POST(`{"messages": []}`,按契约文档应为无操作,但属真实出站)。收口 = 部署设 stub 域或探测端点拒绝 env 回退 URL。 + 3. 生产反代需规划 admin 域的头像/品牌公开端点:`/api/public/*` 按租户 Host 解析,admin 域直挂必 404——开发期靠 Vite Host 改写代理,生产要么同源反代改 Host(单租户形态)要么前端改用租户域名绝对 URL。 + 4. `VITE_TENANT_PREVIEW_HOST` 单一预览域名:开发代理只指向一个租户 Host,切换租户时头像等公开资源预览不准(.env.development 已注记)。 + 5. **resolver 平台默认回退链接入 platform_settings**(终审 I-3 记档):平台设置当前仅存储与展示(admin 页头有注记),引擎不读取——回退链接线是 Phase 4 的功能项。 + 6. **email/test 与 push/test 速率限制**(终审 I-3 记档):两测试端点无限流,复用登录限流器模式收口。 + 7. **锁屏功能已摘除入口**(终审 M-1):用户菜单项删除 + 全局组件 enabled:false 不再挂载(AES 存 localStorage + 公开模板默认密钥,防御价值为零);代码待 Phase 4 决定删否。 + 8. **租户切换器 composable 收拢**(终审 M-3 不做记档):仪表盘/会话/配置三页各自内联实现同一模式,收拢归后续。 + 9. **ECharts 按需收窄**(终审 M-4 不做记档):stats 的 plugins/echarts.ts 全量注册,bundle 体积优化归后续。 ## 文档索引 - 设计文档(spec):`docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md` - Phase 1 实施计划:`docs/superpowers/plans/2026-10-12-phase1-engine-merge-tenantization.md` - Phase 2 实施计划:`docs/superpowers/plans/2026-10-13-phase2-platform-layer.md` +- Phase 3 实施计划:`docs/superpowers/plans/2026-10-13-phase3-admin-spa.md` - 部署坑手册:Phase 4 补 `docs/deploy-production.md`(尚未存在;Phase 4 部署记档段已锚定要点) diff --git a/README.md b/README.md index 15183dd..e29375e 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,16 @@ # all-kefu -统一多租户 AI 客服平台:一套引擎 + 租户配置包,消除此前"每客户复制一套代码"的分叉模式。项目由 ai-kefu / ai-kefu-jc 两个复制分叉项目合并而来(jc 为主线),Phase 1 引擎合并与租户化改造、Phase 2 平台层(platform.db + admin API)已完成。 +统一多租户 AI 客服平台:一套引擎 + 租户配置包,消除此前"每客户复制一套代码"的分叉模式。项目由 ai-kefu / ai-kefu-jc 两个复制分叉项目合并而来(jc 为主线),Phase 1 引擎合并与租户化改造、Phase 2 平台层(platform.db + admin API)、Phase 3 管理后台 SPA 均已完成。 -**现状能力**(Phase 2 收尾时点): +**现状能力**(Phase 3 收尾时点): - 多租户引擎:TenantRuntime 隔离边界,engine 包不感知平台层,可整体拎走独立部署。 - 站点模型:一租户多站点(品牌/主题/站点级覆盖),词表、提示词、品牌全部配置化;配置存 platform.db 版本快照(分组校验 / 回滚 / 热加载),seed_profiles 为出厂档案与测试形态。 - Host 路由:按域名解析租户,未知 Host 404;WS 通道租户化客户端 key 防串库,另有限流与每 IP 连接上限。 - 引擎行为:cc 区号补全判定树、多语言墙(中/西/葡借道/俄/阿)、dual-contact 分步双要、CC 强制墙、lead 邮件推送。 -- 平台层(Phase 2):scrypt 认证 + 内存 token + 登录限流;租户管理(创建向导 / 站点 / 域名 / 停用);配置版本管线(整组替换 / 回滚 / 重置出厂 / 试金石);头像上传(魔数校验 + uuid 重命名);数据视图(会话 / 推送日志 / 删除,租户 scope 强制);统计(卡片 / 漏斗 / 趋势 / 分布 / 推送健康,逐库聚合禁 ATTACH);DB 源生产装配(TENANT_SOURCE=db 默认 + bootstrap_platform.py 引导)。admin API 全在 `/api/platform` 前缀(Host 豁免、路由依赖守护),端点清单见 [CLAUDE.md](CLAUDE.md)。 +- 平台层:scrypt 认证 + 内存 token + 登录限流;租户管理(创建向导 / 站点 / 域名 / 停用);配置版本管线(整组替换 / 回滚 / 重置出厂 / 试金石);头像上传(魔数校验 + uuid 重命名);数据视图(会话 / 推送日志 / 删除,租户 scope 强制);统计(卡片 / 漏斗 / 趋势 / 分布 / 推送健康,逐库聚合禁 ATTACH);密钥掩码哨兵(`__SET__` 往返);DB 源生产装配(TENANT_SOURCE=db 默认 + bootstrap_platform.py 引导)。admin API 全在 `/api/platform` 前缀(Host 豁免、路由依赖守护),端点清单见 [CLAUDE.md](CLAUDE.md)。 +- 管理后台 SPA(Phase 3,`admin/` 目录,Vue3 + Element Plus + pnpm):登录 / 强制改密 / 角色化菜单(超管 vs 租户管理员);仪表盘(平台总览 + 租户卡片墙 / 我的指标);租户管理与站点域名;会话与线索(筛选 / 详情消息流 / 推送日志 / 删除);配置中心(品牌 + 本地预览 + 嵌入代码 + 头像上传 / 提示词词表 / 试金石 / 凭据掩码编辑 + SMTP 测试 + 推送探测 / contact·engine·misc 通用 JSON 编辑 / 版本对比回滚重置);统计分析(ECharts 漏斗 / 趋势 / 分布 / 推送健康);平台设置(ai.* / smtp.* 推荐键 KV 表单 + 存储总览,现阶段仅存储不接引擎回退链)。 -**本地跑法与工作约定**:见 [CLAUDE.md](CLAUDE.md)(含 uvicorn 启动命令、Host 访问方式、Windows PYTHONUTF8 要求、全量回归基线 566)。 +**本地跑法**:后端 `PYTHONUTF8=1 .venv/Scripts/python -m uvicorn app.main:app --port 8018 --workers 1`;管理后台 `cd admin && pnpm install && pnpm dev`(Vite 代理 /api → 8018)。工作约定与测试基线(后端 pytest 595 / 前端 vitest 156)见 [CLAUDE.md](CLAUDE.md)。 设计文档(spec):[docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md](docs/superpowers/specs/2026-10-12-unified-kefu-platform-design.md) diff --git a/admin/.env.development b/admin/.env.development new file mode 100644 index 0000000..49c0be7 --- /dev/null +++ b/admin/.env.development @@ -0,0 +1,42 @@ +# 【开发】环境变量 +# +# 说明:模板原把通用变量放 .env(全模式加载),但根 .gitignore 的 +# `.env` 规则会误伤 admin/.env——入库文件只有 .env.development / +# .env.production 两份(Phase 3 终审 I-2 收口)。通用变量因此按 +# "两份都写"纪律维护:改通用项时两文件同步改。 + +# 版本号(vite.config.ts 的 __APP_VERSION__ define) +VITE_VERSION = 0.1.0 + +# dev server 端口号(被占自动顺延) +VITE_PORT = 3006 + +# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) +VITE_BASE_URL = / + +# 权限模式【 frontend 前端模式 / backend 后端模式 】 +VITE_ACCESS_MODE = frontend + +# 跨域请求时是否携带 Cookie(开启前需确保后端支持) +VITE_WITH_CREDENTIALS = false + +# 是否打开路由信息 +VITE_OPEN_ROUTE_INFO = false + +# 锁屏加密密钥(锁屏入口已摘除——Phase 3 终审 M-1;密钥保留防遗留代码异常) +VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro + +# API 请求基础路径(开发环境使用 / 走 Vite 代理,见 vite.config.ts 的 server.proxy) +VITE_API_URL = / + +# 代理目标地址(后端 FastAPI 本地服务) +VITE_API_PROXY_URL = http://127.0.0.1:8018 + +# 开发期租户预览域名——/api/public 与 /static 的代理 Host 改写目标。 +# 后端 TenantHostMiddleware 只豁免 /health 与 /api/platform/ 前缀, +# 其余路径按 Host 解析租户;经代理直连 127.0.0.1:8018 会因未知 Host 404, +# 故这两条代理用 headers.Host 指向种子租户(jiancai)的域名。 +VITE_TENANT_PREVIEW_HOST = chat2.agent.hnxinyouhe.com + +# Delete console +VITE_DROP_CONSOLE = false diff --git a/admin/.env.production b/admin/.env.production new file mode 100644 index 0000000..cdc67d4 --- /dev/null +++ b/admin/.env.production @@ -0,0 +1,30 @@ +# 【生产】环境变量 +# +# 说明:模板原把通用变量放 .env(全模式加载),但根 .gitignore 的 +# `.env` 规则会误伤 admin/.env——入库文件只有 .env.development / +# .env.production 两份(Phase 3 终审 I-2 收口)。通用变量因此按 +# "两份都写"纪律维护:改通用项时两文件同步改。 + +# 版本号(vite.config.ts 的 __APP_VERSION__ define) +VITE_VERSION = 0.1.0 + +# 应用部署基础路径(如部署在子目录 /admin,则设置为 /admin/) +VITE_BASE_URL = / + +# 权限模式【 frontend 前端模式 / backend 后端模式 】 +VITE_ACCESS_MODE = frontend + +# 跨域请求时是否携带 Cookie(开启前需确保后端支持) +VITE_WITH_CREDENTIALS = false + +# 是否打开路由信息 +VITE_OPEN_ROUTE_INFO = false + +# 锁屏加密密钥(锁屏入口已摘除——Phase 3 终审 M-1;密钥保留防遗留代码异常) +VITE_LOCK_ENCRYPT_KEY = s3cur3k3y4adpro + +# API 地址前缀(生产由反代把 /api 与 /static 指向后端,前端同源相对路径) +VITE_API_URL = / + +# Delete console +VITE_DROP_CONSOLE = true diff --git a/admin/.gitattributes b/admin/.gitattributes new file mode 100644 index 0000000..866e8ee --- /dev/null +++ b/admin/.gitattributes @@ -0,0 +1,2 @@ +*.html linguist-detectable=false +*.vue linguist-detectable=true diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..08f9677 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,12 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local +.cursorrules + +# Auto-generated files +# 注:auto-imports.d.ts / components.d.ts 由 unplugin 生成但已入库, +# 保证新克隆/CI 首跑 pnpm build 直接可用(vue-tsc 需要它们); +# 本地重新构建会自动更新这两个文件。 +.auto-import.json diff --git a/admin/.prettierignore b/admin/.prettierignore new file mode 100644 index 0000000..9e96efc --- /dev/null +++ b/admin/.prettierignore @@ -0,0 +1,3 @@ +/node_modules/* +/dist/* +/src/main.ts \ No newline at end of file diff --git a/admin/.prettierrc b/admin/.prettierrc new file mode 100644 index 0000000..f3d6ad5 --- /dev/null +++ b/admin/.prettierrc @@ -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 +} diff --git a/admin/.stylelintignore b/admin/.stylelintignore new file mode 100644 index 0000000..476ea45 --- /dev/null +++ b/admin/.stylelintignore @@ -0,0 +1,9 @@ +dist +node_modules +public +.husky +.vscode + +src/components/Layout/MenuLeft/index.vue +src/assets +stats.html \ No newline at end of file diff --git a/admin/.stylelintrc.cjs b/admin/.stylelintrc.cjs new file mode 100644 index 0000000..9dbea0b --- /dev/null +++ b/admin/.stylelintrc.cjs @@ -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' + ] + } + ] + } +} diff --git a/admin/.vscode/extensions.json b/admin/.vscode/extensions.json new file mode 100644 index 0000000..d7766fa --- /dev/null +++ b/admin/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "vue.volar", + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "stylelint.vscode-stylelint", + "lokalise.i18n-ally", + "bradlc.vscode-tailwindcss" + ] +} diff --git a/admin/.vscode/settings.json b/admin/.vscode/settings.json new file mode 100644 index 0000000..5268fc2 --- /dev/null +++ b/admin/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "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 +} diff --git a/admin/LICENSE b/admin/LICENSE new file mode 100644 index 0000000..68322de --- /dev/null +++ b/admin/LICENSE @@ -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. diff --git a/admin/README.md b/admin/README.md new file mode 100644 index 0000000..8f58fc6 --- /dev/null +++ b/admin/README.md @@ -0,0 +1,3 @@ +# all-kefu 管理后台(admin SPA) + +Vue3 + TS + Vite + Element Plus(art-design-pro 模板)。本地起法与工作约定见根目录 [CLAUDE.md](../CLAUDE.md) 的「Phase 3 完成状态」段(后端 8018 + `pnpm install && pnpm dev`)。 diff --git a/admin/eslint.config.mjs b/admin/eslint.config.mjs new file mode 100644 index 0000000..ed2d4ba --- /dev/null +++ b/admin/eslint.config.mjs @@ -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,tsx,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,tsx,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 +] diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..6b72611 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,44 @@ + + + + all-kefu 管理后台 + + + + + + + + + + + +
+ + + diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..a46c6c0 --- /dev/null +++ b/admin/package.json @@ -0,0 +1,87 @@ +{ + "name": "all-kefu-admin", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20.19.0", + "pnpm": ">=8.8.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "@parcel/watcher", + "@tailwindcss/oxide", + "core-js", + "esbuild", + "es5-ext", + "vue-demi" + ] + }, + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "serve": "vite preview", + "test": "vitest run", + "lint": "eslint", + "fix": "eslint --fix", + "lint:prettier": "prettier --write \"**/*.{js,cjs,ts,json,tsx,vue,html,md}\"", + "lint:stylelint": "stylelint \"**/*.{css,scss,vue}\" --fix", + "clean:dev": "tsx scripts/clean-dev.ts" + }, + "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", + "axios": "^1.12.2", + "crypto-js": "^4.2.0", + "echarts": "^6.0.0", + "element-plus": "^2.11.2", + "mitt": "^3.0.1", + "nprogress": "^0.2.0", + "ohash": "^2.0.11", + "pinia": "^3.0.3", + "pinia-plugin-persistedstate": "^4.3.0", + "tailwindcss": "^4.1.14", + "vue": "^3.5.21", + "vue-draggable-plus": "^0.6.0", + "vue-i18n": "^9.14.0", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@eslint/js": "^9.9.1", + "@types/node": "^24.0.5", + "@typescript-eslint/eslint-plugin": "^8.3.0", + "@typescript-eslint/parser": "^8.3.0", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/compiler-sfc": "^3.0.5", + "eslint": "^9.9.1", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-vue": "^9.27.0", + "globals": "^15.9.0", + "happy-dom": "^21.0.1", + "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", + "vitest": "^3.2.4", + "vue-tsc": "~2.1.6" + } +} diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml new file mode 100644 index 0000000..71e1870 --- /dev/null +++ b/admin/pnpm-lock.yaml @@ -0,0 +1,5855 @@ +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)) + 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)) + 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))) + 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)) + devDependencies: + '@eslint/js': + specifier: ^9.9.1 + version: 9.36.0 + '@types/node': + specifier: ^24.0.5 + version: 24.8.1 + '@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 + 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 + happy-dom: + specifier: ^21.0.1 + version: 21.0.1 + 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)) + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.8.1)(happy-dom@21.0.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-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/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==} + + '@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 + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@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/web-bluetooth@0.0.16': + resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.2': + resolution: {integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==} + + '@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} + + '@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 + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@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==} + + 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 + + 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-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + 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-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + 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==} + + 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==} + + 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==} + + 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-image-size@0.6.4: + resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} + engines: {node: '>=4.0'} + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacheable@2.0.2: + resolution: {integrity: sha512-dWjhLx8RWnPsAWVKwW/wI6OJpQ/hSVb1qS0NUif8TR9vRiSwci7Gey8x04kRU9iAF+Rnbtex5Kjjfg/aB5w8Pg==} + + 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==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + 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'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + 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==} + + 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'} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: 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==} + + 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 + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + 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'} + + 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'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + 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==} + + 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==} + + 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@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'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + 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'} + + 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@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 + + 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'} + + execa@9.6.0: + resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} + engines: {node: ^18.19.0 || >=20.5.0} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + exsolve@1.0.7: + resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + + 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@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'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + 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'} + + 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'} + + 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-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@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@4.10.1: + resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + + 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'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + 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==} + + happy-dom@21.0.1: + resolution: {integrity: sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==} + engines: {node: '>=20.0.0'} + + 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 + + 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'} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + 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'} + + immutable@5.1.3: + resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + 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-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + 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@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + 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==} + + 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'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + 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'} + + 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.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + 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@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + 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-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + 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'} + + 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==} + + 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==} + + 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@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==} + + 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'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + 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'} + + 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-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==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + 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'} + + 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} + + 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'} + + 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'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + 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-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==} + + 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-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.93.2: + resolution: {integrity: sha512-t+YPtOQHpGW1QWsh1CHQ5cPIr9lbbGZLZnbihP/D/qZj/yuV68m8qarcV17nvkOX81BCrvzAlq2klCQFZghyTg==} + engines: {node: '>=14.0.0'} + hasBin: true + + 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'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + 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'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + 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'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + 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@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 + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.6: + resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} + engines: {node: '>=14.0.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==} + + 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'} + + 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.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-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + 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 + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + 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-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 + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + 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 + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + wsl-utils@0.1.0: + resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} + engines: {node: '>=18'} + + 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'} + + 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/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': {} + + '@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) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@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/web-bluetooth@0.0.16': {} + + '@types/web-bluetooth@0.0.21': {} + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 24.8.1 + + '@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 + + '@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) + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.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))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.19 + optionalDependencies: + 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) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.19 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.6 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@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 + + acorn-jsx@5.3.2(acorn@8.15.0): + dependencies: + acorn: 8.15.0 + + acorn@8.15.0: {} + + 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-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + astral-regex@2.0.0: {} + + async-validator@4.2.5: {} + + asynckit@0.4.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: {} + + baseline-browser-mapping@2.8.8: {} + + binary-extensions@2.3.0: {} + + birpc@2.6.1: {} + + 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-image-size@0.6.4: + dependencies: + '@types/node': 24.8.1 + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + cac@6.7.14: {} + + 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 + + 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: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + 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: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@2.20.3: {} + + concat-map@0.0.1: {} + + confbox@0.1.8: {} + + confbox@0.2.2: {} + + convert-source-map@2.0.0: {} + + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + + 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 + + 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: {} + + dayjs@1.11.18: {} + + de-indent@1.0.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + 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 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + defu@6.1.4: {} + + delayed-stream@1.0.0: {} + + destr@2.0.5: {} + + 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 + + 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 + + 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@8.0.0: {} + + enhanced-resolve@5.18.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + entities@4.5.0: {} + + entities@7.0.1: {} + + env-paths@2.2.1: {} + + 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 + + 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@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 + + 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: {} + + 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 + + expect-type@1.4.0: {} + + exsolve@1.0.7: {} + + 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@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 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + 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 + + 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 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + 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@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 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + 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: {} + + happy-dom@21.0.1: + dependencies: + '@types/node': 24.8.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + 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: {} + + hookable@5.5.3: {} + + hookified@1.12.1: {} + + html-tags@3.3.1: {} + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + + human-signals@8.0.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.3: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + 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-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + + is-what@4.1.16: {} + + 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 + + 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 + + lines-and-columns@1.2.4: {} + + 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 + + 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.merge@4.6.2: {} + + lodash.truncate@4.4.2: {} + + lodash@4.17.21: {} + + loupe@3.2.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@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + minimatch@3.1.2: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + 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: {} + + nanoid@3.3.11: {} + + nanoid@5.1.6: {} + + natural-compare@1.4.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@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: {} + + 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 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.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: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-type@4.0.0: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + 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 + + 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 + + proxy-from-env@1.1.0: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + 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-from@4.0.0: {} + + resolve-from@5.0.0: {} + + resolve-pkg-maps@1.0.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-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + 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 + + 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: {} + + siginfo@2.0.0: {} + + 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: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + 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: {} + + speakingurl@14.0.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + 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@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 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.6: {} + + 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: {} + + 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: {} + + 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.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-node@3.2.4(@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: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.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) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + 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 + + vitest@3.2.7(@types/node@24.8.1)(happy-dom@21.0.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: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.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)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.19 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.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) + vite-node: 3.2.4(@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) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.8.1 + happy-dom: 21.0.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + 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-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 + + webpack-virtual-modules@0.6.2: {} + + whatwg-mimetype@3.0.0: {} + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + ws@8.22.0: {} + + wsl-utils@0.1.0: + dependencies: + is-wsl: 3.1.0 + + xml-name-validator@4.0.0: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@5.0.0: {} + + yaml@2.8.1: + optional: true + + 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: {} + + yoctocolors@2.1.2: {} + + zrender@6.0.0: + dependencies: + tslib: 2.3.0 diff --git a/admin/pnpm-workspace.yaml b/admin/pnpm-workspace.yaml new file mode 100644 index 0000000..d77c0f0 --- /dev/null +++ b/admin/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +onlyBuiltDependencies: + - "@parcel/watcher" + - "@tailwindcss/oxide" + - core-js + - esbuild + - es5-ext + - vue-demi diff --git a/admin/public/favicon.png b/admin/public/favicon.png new file mode 100644 index 0000000..11cba74 Binary files /dev/null and b/admin/public/favicon.png differ diff --git a/admin/scripts/clean-dev.ts b/admin/scripts/clean-dev.ts new file mode 100644 index 0000000..cc0b9bc --- /dev/null +++ b/admin/scripts/clean-dev.ts @@ -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 { + 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 { + 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([]) +` + + 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 { + 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) +}) diff --git a/admin/src/App.vue b/admin/src/App.vue new file mode 100644 index 0000000..23745c9 --- /dev/null +++ b/admin/src/App.vue @@ -0,0 +1,39 @@ + + + diff --git a/admin/src/api/__tests__/auth-store.test.ts b/admin/src/api/__tests__/auth-store.test.ts new file mode 100644 index 0000000..8bb2a38 --- /dev/null +++ b/admin/src/api/__tests__/auth-store.test.ts @@ -0,0 +1,211 @@ +/** + * auth store 单元测试(Phase 3 Task 3) + * + * 覆盖: + * - login 成功存 token/role/tenant_slug/must_change_password/username(含模板侧同步) + * - logout:调 API + 清空状态 + 模板侧 logOut(API 失败也照常本地清) + * - localStorage 持久化(all-kefu 前缀键;新建 pinia 实例模拟刷新恢复) + * - handleSessionExpired / handleMustChangePassword / changePassword + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import { createApp, defineComponent } from 'vue' +import { createPersistedState } from 'pinia-plugin-persistedstate' + +const mocks = vi.hoisted(() => ({ + fetchLogin: vi.fn(), + fetchLogout: vi.fn(), + fetchChangePassword: vi.fn(), + userStore: { + logOut: vi.fn(), + setLoginStatus: vi.fn(), + setToken: vi.fn(), + setUserInfo: vi.fn(), + checkAndClearWorktabs: vi.fn(), + info: {} + }, + routerPush: vi.fn(), + routerReplace: vi.fn(), + currentRoute: { value: { path: '/' } } +})) + +vi.mock('@/api/auth', () => ({ + fetchLogin: mocks.fetchLogin, + fetchLogout: mocks.fetchLogout, + fetchChangePassword: mocks.fetchChangePassword +})) + +vi.mock('@/store/modules/user', () => ({ + useUserStore: () => mocks.userStore +})) + +vi.mock('@/router', () => ({ + router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute } +})) + +import { useAuthStore } from '@/store/modules/auth' + +const LOGIN_RESPONSE = { + token: 'tok-abc', + role: 'super', + tenant_slug: null, + must_change_password: true +} as const + +/** + * 等价应用的持久化 pinia: + * pinia.use() 在未安装 app 时(_a 为空)插件只进 toBeInstalled 队列, + * 必须 app.use(pinia) 才真正生效——真实应用经 initStore(main.ts)如此; + * 测试里用一次性 app 复刻同一装配,否则持久化插件静默不运行。 + */ +function freshPersistedPinia() { + const pinia = createPinia() + pinia.use(createPersistedState()) + createApp(defineComponent(() => () => null)).use(pinia) + setActivePinia(pinia) + return pinia +} + +describe('auth store(src/store/modules/auth.ts)', () => { + beforeEach(() => { + localStorage.clear() + vi.clearAllMocks() + mocks.currentRoute.value.path = '/' + }) + + it('login 成功:存 token/role/tenantSlug/mustChangePassword/username 并同步模板侧', async () => { + freshPersistedPinia() + mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE }) + + const store = useAuthStore() + await store.login({ username: 'admin', password: 'admin123' }) + + expect(store.token).toBe('tok-abc') + expect(store.role).toBe('super') + expect(store.tenantSlug).toBeNull() + expect(store.mustChangePassword).toBe(true) + expect(store.username).toBe('admin') + expect(store.isAuthenticated).toBe(true) + + // 模板侧(user store)同步:token / 登录标记 / 用户信息(菜单过滤用 roles) + expect(mocks.userStore.setToken).toHaveBeenCalledWith('tok-abc', '') + expect(mocks.userStore.setLoginStatus).toHaveBeenCalledWith(true) + expect(mocks.userStore.setUserInfo).toHaveBeenCalled() + const info = mocks.userStore.setUserInfo.mock.calls[0][0] + expect(info.roles).toEqual(['super']) + expect(info.userName).toBe('admin') + }) + + it('login 失败:状态保持空、错误向上抛', async () => { + freshPersistedPinia() + mocks.fetchLogin.mockRejectedValue(new Error('用户名或密码错误')) + + const store = useAuthStore() + await expect(store.login({ username: 'a', password: 'b' })).rejects.toThrow('用户名或密码错误') + + expect(store.token).toBe('') + expect(store.isAuthenticated).toBe(false) + expect(mocks.userStore.setLoginStatus).not.toHaveBeenCalled() + }) + + it('logout:调登出 API + 清空状态 + 模板侧 logOut', async () => { + freshPersistedPinia() + mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE }) + mocks.fetchLogout.mockResolvedValue({ ok: true }) + + const store = useAuthStore() + await store.login({ username: 'admin', password: 'x' }) + + await store.logout() + + expect(mocks.fetchLogout).toHaveBeenCalledTimes(1) + expect(store.token).toBe('') + expect(store.role).toBe('') + expect(store.tenantSlug).toBeNull() + expect(store.username).toBe('') + expect(store.mustChangePassword).toBe(false) + expect(store.isAuthenticated).toBe(false) + expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1) + }) + + it('logout 时后端已不可达:仍完成本地清理(best effort)', async () => { + freshPersistedPinia() + mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE }) + mocks.fetchLogout.mockRejectedValue(new Error('network down')) + + const store = useAuthStore() + await store.login({ username: 'admin', password: 'x' }) + + await expect(store.logout()).resolves.toBeUndefined() + + expect(store.token).toBe('') + expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1) + }) + + it('localStorage 持久化:all-kefu 前缀键;新 pinia 实例(模拟刷新)恢复会话', async () => { + freshPersistedPinia() + mocks.fetchLogin.mockResolvedValue({ ...LOGIN_RESPONSE, role: 'tenant', tenant_slug: 'jiancai', must_change_password: false }) + + await useAuthStore().login({ username: 'jcadmin', password: 'x' }) + + // 持久化键带 all-kefu 前缀 + expect(localStorage.getItem('all-kefu-auth')).toBeTruthy() + + // 模拟页面刷新:全新 pinia + 插件,从 localStorage 恢复 + freshPersistedPinia() + const restored = useAuthStore() + expect(restored.token).toBe('tok-abc') + expect(restored.role).toBe('tenant') + expect(restored.tenantSlug).toBe('jiancai') + expect(restored.username).toBe('jcadmin') + expect(restored.mustChangePassword).toBe(false) + expect(restored.isAuthenticated).toBe(true) + }) + + it('handleSessionExpired:清空会话并委托模板侧 logOut(401 全局处理路径)', () => { + freshPersistedPinia() + const store = useAuthStore() + store.token = 'tok' + store.role = 'super' + store.username = 'admin' + + store.handleSessionExpired() + + expect(store.token).toBe('') + expect(store.isAuthenticated).toBe(false) + expect(mocks.userStore.logOut).toHaveBeenCalledTimes(1) + }) + + it('handleMustChangePassword:置标志并跳强制改密页;已在改密页不重复跳', async () => { + freshPersistedPinia() + const store = useAuthStore() + + await store.handleMustChangePassword() + + expect(store.mustChangePassword).toBe(true) + await vi.waitFor(() => { + expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password') + }) + + // 已在改密页:不再跳 + mocks.routerReplace.mockClear() + mocks.currentRoute.value.path = '/auth/force-change-password' + await store.handleMustChangePassword() + expect(mocks.routerReplace).not.toHaveBeenCalled() + }) + + it('changePassword 成功:清 must_change 标志', async () => { + freshPersistedPinia() + mocks.fetchChangePassword.mockResolvedValue({ ok: true, must_change_password: false }) + const store = useAuthStore() + store.mustChangePassword = true + + await store.changePassword({ old_password: 'old', new_password: 'new-pass-123' }) + + expect(mocks.fetchChangePassword).toHaveBeenCalledWith({ + old_password: 'old', + new_password: 'new-pass-123' + }) + expect(store.mustChangePassword).toBe(false) + }) +}) diff --git a/admin/src/api/__tests__/client.test.ts b/admin/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..262c80e --- /dev/null +++ b/admin/src/api/__tests__/client.test.ts @@ -0,0 +1,238 @@ +/** + * HTTP client 单元测试(Phase 3 Task 3) + * + * 契约对齐后端 app/platform/auth.py 与 auth_routes.py: + * - Authorization: Bearer 注入 + * - 401(非登录请求)→ 清会话 + 跳登录(登录请求自身的 401 = 凭据错误,只提示不跳转) + * - 409 detail === "必须先修改初始密码" → 跳强制改密页; + * 其余 409(并发冲突等)为普通业务错误,不跳转 + * - 其余错误 / 网络错误 → ElMessage 出 detail / 统一文案 + * - 成功透传 response.data + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from 'axios' +import { createPinia, setActivePinia } from 'pinia' + +const mocks = vi.hoisted(() => ({ + userStore: { + logOut: vi.fn(), + setLoginStatus: vi.fn(), + setToken: vi.fn(), + setUserInfo: vi.fn(), + checkAndClearWorktabs: vi.fn(), + info: {} + }, + routerPush: vi.fn(), + routerReplace: vi.fn(), + currentRoute: { value: { path: '/' } }, + elMessageError: vi.fn() +})) + +vi.mock('element-plus', () => ({ + ElMessage: { error: mocks.elMessageError, success: vi.fn() } +})) + +vi.mock('@/store/modules/user', () => ({ + useUserStore: () => mocks.userStore +})) + +vi.mock('@/router', () => ({ + router: { push: mocks.routerPush, replace: mocks.routerReplace, currentRoute: mocks.currentRoute } +})) + +import { ElMessage } from 'element-plus' +import { http } from '@/api/client' +import { useAuthStore } from '@/store/modules/auth' + +/** 自定义 axios adapter:按计划响应回放,并捕获请求头 */ +function makeAdapter(plan: { status: number; body: unknown } | { networkError: true }) { + let captured: InternalAxiosRequestConfig | null = null + const adapter = (config: InternalAxiosRequestConfig): Promise => { + captured = config + if ('networkError' in plan) { + // 模拟断网:无 response 的 AxiosError + return Promise.reject( + new AxiosError('Network Error', 'ECONNABORTED', config, undefined, undefined) + ) + } + const response: AxiosResponse = { + status: plan.status, + statusText: '', + headers: {}, + config, + data: plan.body + } + if (plan.status >= 200 && plan.status < 300) return Promise.resolve(response) + return Promise.reject( + new AxiosError( + `Request failed with status code ${plan.status}`, + String(plan.status), + config, + undefined, + response + ) + ) + } + return { + adapter, + capturedHeaders: () => (captured as InternalAxiosRequestConfig | null)?.headers + } +} + +describe('HTTP client(src/api/client.ts)', () => { + beforeEach(() => { + setActivePinia(createPinia()) + localStorage.clear() + vi.clearAllMocks() + mocks.currentRoute.value.path = '/' + }) + + describe('token 注入', () => { + it('有 token 时自动注入 Authorization: Bearer ', async () => { + const authStore = useAuthStore() + authStore.token = 'tok-123' + + const { adapter, capturedHeaders } = makeAdapter({ status: 200, body: { ok: true } }) + await http.post<{ ok: boolean }>('/api/platform/auth/logout', null, { adapter }) + + expect(capturedHeaders()?.get('Authorization')).toBe('Bearer tok-123') + }) + + it('无 token 时不注入 Authorization 头', async () => { + const { adapter, capturedHeaders } = makeAdapter({ status: 200, body: { ok: true } }) + await http.post('/api/platform/auth/logout', null, { adapter }) + + expect(capturedHeaders()?.get('Authorization')).toBeFalsy() + }) + }) + + describe('401 处理', () => { + it('非登录请求 401:清空会话并触发登出清理(跳登录)', async () => { + const authStore = useAuthStore() + authStore.token = 'expired-token' + + const { adapter } = makeAdapter({ + status: 401, + body: { detail: '未认证或凭证已失效' } + }) + await expect(http.get('/api/platform/tenants', { adapter })).rejects.toThrow( + '未认证或凭证已失效' + ) + + expect(authStore.token).toBe('') + expect(authStore.isAuthenticated).toBe(false) + // 模板侧全套清理(含跳登录页)由 userStore.logOut 承担 + expect(mocks.userStore.logOut).toHaveBeenCalled() + expect(ElMessage.error).toHaveBeenCalledWith('未认证或凭证已失效') + }) + + it('登录请求自身 401(凭据错误):只提示 detail,不触发登出/跳转', async () => { + const { adapter } = makeAdapter({ + status: 401, + body: { detail: '用户名或密码错误' } + }) + await expect( + http.post('/api/platform/auth/login', { username: 'a', password: 'b' }, { adapter }) + ).rejects.toThrow('用户名或密码错误') + + expect(mocks.userStore.logOut).not.toHaveBeenCalled() + expect(mocks.routerReplace).not.toHaveBeenCalledWith('/auth/force-change-password') + expect(ElMessage.error).toHaveBeenCalledWith('用户名或密码错误') + }) + }) + + describe('409 处理', () => { + it('409 detail 为必须先修改初始密码:置 must_change 并跳强制改密页', async () => { + const authStore = useAuthStore() + authStore.token = 'tok' + authStore.mustChangePassword = false + + const { adapter } = makeAdapter({ + status: 409, + body: { detail: '必须先修改初始密码' } + }) + await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow( + '必须先修改初始密码' + ) + + await vi.waitFor(() => { + expect(mocks.routerReplace).toHaveBeenCalledWith('/auth/force-change-password') + }) + expect(authStore.mustChangePassword).toBe(true) + }) + + it('已在强制改密页时收到 409:不再重复跳转', async () => { + mocks.currentRoute.value.path = '/auth/force-change-password' + const { adapter } = makeAdapter({ + status: 409, + body: { detail: '必须先修改初始密码' } + }) + await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow() + + await vi.waitFor(() => { + expect(ElMessage.error).toHaveBeenCalled() + }) + expect(mocks.routerReplace).not.toHaveBeenCalled() + }) + + it('其他 409(并发冲突):普通业务错误,不跳改密页', async () => { + const { adapter } = makeAdapter({ + status: 409, + body: { detail: '并发冲突: 域名已被占用' } + }) + await expect(http.post('/api/platform/tenants', {}, { adapter })).rejects.toThrow( + '并发冲突: 域名已被占用' + ) + + expect(mocks.routerReplace).not.toHaveBeenCalled() + expect(ElMessage.error).toHaveBeenCalledWith('并发冲突: 域名已被占用') + }) + }) + + describe('其他错误与成功', () => { + it('登录 429 限流:弹出后端 detail 提示,不触发跳转', async () => { + const { adapter } = makeAdapter({ + status: 429, + body: { detail: '登录尝试过于频繁,请15分钟后再试' } + }) + await expect( + http.post('/api/platform/auth/login', { username: 'a', password: 'b' }, { adapter }) + ).rejects.toThrow('登录尝试过于频繁,请15分钟后再试') + + expect(ElMessage.error).toHaveBeenCalledWith('登录尝试过于频繁,请15分钟后再试') + expect(mocks.userStore.logOut).not.toHaveBeenCalled() + expect(mocks.routerReplace).not.toHaveBeenCalled() + }) + + it('网络错误(无响应):统一文案提示', async () => { + const { adapter } = makeAdapter({ networkError: true }) + await expect(http.get('/api/platform/stats', { adapter })).rejects.toThrow() + + expect(ElMessage.error).toHaveBeenCalledTimes(1) + const message = vi.mocked(ElMessage.error).mock.calls[0][0] as string + expect(message).toContain('网络') + }) + + it('showErrorMessage: false 时不弹全局错误提示', async () => { + const { adapter } = makeAdapter({ status: 400, body: { detail: '原密码不正确' } }) + await expect( + http.post('/api/platform/auth/change-password', {}, { adapter, showErrorMessage: false }) + ).rejects.toThrow('原密码不正确') + + expect(ElMessage.error).not.toHaveBeenCalled() + }) + + it('成功:直接透传 response.data(非 axios 包装)', async () => { + const body = { + token: 't', + role: 'super', + tenant_slug: null, + must_change_password: true + } + const { adapter } = makeAdapter({ status: 200, body }) + const res = await http.post('/api/platform/auth/login', {}, { adapter }) + + expect(res).toEqual(body) + }) + }) +}) diff --git a/admin/src/api/__tests__/config.test.ts b/admin/src/api/__tests__/config.test.ts new file mode 100644 index 0000000..50f05a4 --- /dev/null +++ b/admin/src/api/__tests__/config.test.ts @@ -0,0 +1,439 @@ +/** + * 配置中心 API 单元测试(Phase 3 Task 7a——品牌相关;Task 7b——试金石; + * Task 7c——凭据测试端点;Task 7d——版本历史/回滚/组级重置) + * + * 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 与 + * upload_routes.py);HTTP 层行为已在 client.test.ts 覆盖,此处 mock http + * 只断言调用形态: + * - GET config:快照 + version(原样组——缺键防御读取靠 normalizeBrandGroup) + * - PUT config/brand:请求体是**组值本身**(整组替换语义,非 {note,value} + * 包装),备注走 comment 查询参数,无备注时不带 params + * - POST wall/test(Task 7b):句子列表 + wall 覆盖(当前表单值预演, + * 不落库)+ has_contact/source 可选项——保存前自动验证复用本函数 + * - POST email/test · push/test(Task 7c):前者 body 只带 {to}(用已 + * 保存配置真发一封);后者无请求体(探测读已保存配置,空批量零线索 + * 数据)——两者 showErrorMessage: false 内联展示 + * - GET versions / versions/{n}(Task 7d):列表不含 config 本体;详情含 + * 整份快照(密钥 '__SET__' 掩码视图) + * - POST rollback(Task 7d):body 只带 {version}——目标版本复制为新版本 + * (历史不可变),响应含新版本号 + 新快照 + * - POST config/{group}/reset(Task 7d):无请求体(后端 reset 不读 + * body),响应与 PUT 分组保存同形 + * - 头像上传:multipart file 字段(FormData) + * - normalizeBrandGroup:缺键补空串 / 类型漂移防御 / topics 只留字符串项 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn(), post: vi.fn(), put: vi.fn() } +})) + +import { http } from '@/api/client' +import { + CONFIG_ENDPOINTS, + fetchConfigVersionDetail, + fetchConfigVersions, + fetchTenantConfig, + normalizeBrandGroup, + putConfigGroup, + resetConfigGroup, + rollbackConfig, + testEmail, + testPush, + testWall, + uploadBrandAvatar +} from '@/api/config' + +describe('配置中心 API(src/api/config.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchTenantConfig:GET /api/platform/tenants/{slug}/config', async () => { + const body = { + slug: 'jiancai', + version: 3, + config: { + brand: { title: 'HESUPIPE Support', color: '#066fc0', topics: ['Get Quotation'] } + } + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchTenantConfig('jiancai') + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/config', undefined) + }) + + it('putConfigGroup:PUT 请求体为组值本身 + comment 查询参数(整组替换契约)', async () => { + const brand = { + title: 'HESUPIPE Support', + color: '#ff6600', + avatar: 'abc123.png', + welcome: 'Hello', + placeholder: 'Ask me', + whatsapp: '+8615937108317', + email: 'hesu@hesuu.com', + privacy_url: 'https://hesupipe.com/privacy-policy', + topics: ['Get Quotation', 'Delivery & Shipping'] + } + const response = { slug: 'jiancai', group: 'brand', version: 4, config: brand } + vi.mocked(http.put).mockResolvedValue(response) + + const res = await putConfigGroup('jiancai', 'brand', { ...brand }, '改主题色') + + expect(res).toEqual(response) + expect(http.put).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/brand', + brand, // 组值直传——绝不能包成 { note, value }(后端 _json_body 整组校验) + { params: { comment: '改主题色' } } + ) + }) + + it('putConfigGroup:无备注时不带 params(comment 是可选项)', async () => { + vi.mocked(http.put).mockResolvedValue({ + slug: 'jiancai', + group: 'brand', + version: 5, + config: {} + }) + + await putConfigGroup('jiancai', 'brand', { title: 'T' }) + + expect(http.put).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/brand', + { title: 'T' }, + { params: undefined } + ) + }) + + it('uploadBrandAvatar:POST multipart(FormData file 字段)', async () => { + const response = { slug: 'jiancai', avatar: '7f3c9d.png', version: 6 } + vi.mocked(http.post).mockResolvedValue(response) + const file = new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], 'logo.png', { + type: 'image/png' + }) + + const res = await uploadBrandAvatar('jiancai', file, { showErrorMessage: false }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + const [url, body, config] = vi.mocked(http.post).mock.calls[0] + expect(url).toBe('/api/platform/tenants/jiancai/brand/avatar') + expect(body).toBeInstanceOf(FormData) + expect((body as FormData).get('file')).toBe(file) + expect(config).toEqual({ showErrorMessage: false }) + }) + + it('CONFIG_ENDPOINTS.avatarUpload:端点路径与后端 upload_routes 一致', () => { + expect(CONFIG_ENDPOINTS.avatarUpload('jiancai')).toBe( + '/api/platform/tenants/jiancai/brand/avatar' + ) + expect(CONFIG_ENDPOINTS.putGroup('jiancai', 'brand')).toBe( + '/api/platform/tenants/jiancai/config/brand' + ) + }) +}) + +describe('试金石 API(testWall · Phase 3 Task 7b)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('POST wall/test:句子 + wall 覆盖(当前表单值预演)+ 判定选项', async () => { + const response = { + slug: 'jiancai', + wall_source: 'override', + results: [ + { + sentence: 'The price is 500 USD per ton.', + action: 'blocked', + reason: 'boundary', + replacement: 'Prices vary a lot by config...' + }, + { sentence: 'PPR pipes in stock?', action: 'pass', reason: null, replacement: null } + ] + } + vi.mocked(http.post).mockResolvedValue(response) + const wallOverride = { + price_patterns: ['\\$\\s?\\d'], + deflections: { en: ['...'], zh: ['...'] } + } + + const res = await testWall( + 'jiancai', + { + sentences: ['The price is 500 USD per ton.', 'PPR pipes in stock?'], + wall: wallOverride, + has_contact: true, + source: 'app' + }, + { showErrorMessage: false } + ) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/wall/test', + { + sentences: ['The price is 500 USD per ton.', 'PPR pipes in stock?'], + wall: wallOverride, // 覆盖墙原样直传——后端 compile_wall 校验后纯内存判定 + has_contact: true, + source: 'app' + }, + { showErrorMessage: false } // 坏墙 400 字段路径由调用方内联展示 + ) + }) + + it('POST wall/test:最小 body(仅句子——缺省用当前生效墙)', async () => { + const response = { + slug: 'jiancai', + wall_source: 'current', + results: [{ sentence: 'Hello', action: 'pass', reason: null, replacement: null }] + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testWall('jiancai', { sentences: ['Hello'] }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/wall/test', + { sentences: ['Hello'] }, + undefined + ) + }) + + it('CONFIG_ENDPOINTS.wallTest:端点路径与后端 config_routes 一致', () => { + expect(CONFIG_ENDPOINTS.wallTest('jiancai')).toBe( + '/api/platform/tenants/jiancai/wall/test' + ) + }) +}) + +describe('凭据测试 API(testEmail / testPush · Phase 3 Task 7c)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('testEmail:POST email/test,body 只带 {to}(收件人)', async () => { + const response = { slug: 'jiancai', sent: true, error: null } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testEmail('jiancai', { to: 'you@example.com' }, { + showErrorMessage: false + }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/email/test', + { to: 'you@example.com' }, + { showErrorMessage: false } // 400「SMTP 未配置」等内联展示,不走全局提示 + ) + }) + + it('testEmail:发送失败也走成功响应通道(sent=false + error 串,非异常)', async () => { + const response = { slug: 'jiancai', sent: false, error: 'SMTPRecipientsRefused(...)' } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testEmail('jiancai', { to: 'you@example.com' }) + + expect(res.sent).toBe(false) + expect(res.error).toContain('SMTP') + }) + + it('testPush:POST push/test,无请求体(探测读已保存配置)', async () => { + const response = { + slug: 'jiancai', + reachable: false, + auth_ok: null, + status: null, + reason: 'dns', + error: 'Failed to resolve.' + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await testPush('jiancai', { showErrorMessage: false }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + const [url, body, config] = vi.mocked(http.post).mock.calls[0] + expect(url).toBe('/api/platform/tenants/jiancai/push/test') + expect(body).toBeUndefined() // 端点不读 body——探测参数全来自已保存配置 + expect(config).toEqual({ showErrorMessage: false }) + }) + + it('CONFIG_ENDPOINTS.emailTest / pushTest:端点路径与后端 config_routes 一致', () => { + expect(CONFIG_ENDPOINTS.emailTest('jiancai')).toBe( + '/api/platform/tenants/jiancai/email/test' + ) + expect(CONFIG_ENDPOINTS.pushTest('jiancai')).toBe( + '/api/platform/tenants/jiancai/push/test' + ) + }) +}) + +describe('版本历史 / 回滚 / 重置 API(Task 7d · 事实源 config_routes)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchConfigVersions:GET versions 列表(升序元数据,不含 config 本体)', async () => { + const response = { + slug: 'jiancai', + items: [ + { + version: 1, + created_at: '2026-10-12T09:00:00', + updated_by: 'super#1', + comment: '创建向导初始版本' + }, + { + version: 2, + created_at: '2026-10-13T10:30:00', + updated_by: 'tenant#3', + comment: '改主题色' + } + ] + } + vi.mocked(http.get).mockResolvedValue(response) + + const res = await fetchConfigVersions('jiancai', { showErrorMessage: false }) + + expect(res).toEqual(response) + expect(res.items[0]).not.toHaveProperty('config') // 列表行绝无 config 本体 + expect(http.get).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/versions', + { showErrorMessage: false } + ) + }) + + it('fetchConfigVersionDetail:GET versions/{n} 详情(整份快照 + 掩码)', async () => { + const response = { + slug: 'jiancai', + version: 2, + created_at: '2026-10-13T10:30:00', + updated_by: 'tenant#3', + comment: '改主题色', + config: { + brand: { title: 'HESUPIPE Support' }, + push: { api_key: '__SET__' } + } + } + vi.mocked(http.get).mockResolvedValue(response) + + const res = await fetchConfigVersionDetail('jiancai', 2) + + expect(res).toEqual(response) + expect(http.get).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/versions/2', + undefined + ) + }) + + it('rollbackConfig:POST rollback,body 只带 {version}(note 不被后端读取)', async () => { + const response = { + slug: 'jiancai', + rollback_to: 2, + version: 11, + config: { brand: { title: 'HESUPIPE Support' } } + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await rollbackConfig('jiancai', { version: 2 }, { + showErrorMessage: false + }) + + expect(res).toEqual(response) + expect(http.post).toHaveBeenCalledTimes(1) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/config/rollback', + { version: 2 }, // 回滚请求体只认 version——多余字段是死代码 + { showErrorMessage: false } + ) + }) + + it('resetConfigGroup:POST config/{group}/reset,无请求体(后端不读 body)', async () => { + const response = { + slug: 'jiancai', + group: 'brand', + version: 12, + config: { title: 'AI Support', color: '#066fc0' } + } + vi.mocked(http.post).mockResolvedValue(response) + + const res = await resetConfigGroup('jiancai', 'brand', { showErrorMessage: false }) + + expect(res).toEqual(response) // 响应与 PUT 分组保存同形(新版本 + 组掩码视图) + expect(http.post).toHaveBeenCalledTimes(1) + const [url, body, config] = vi.mocked(http.post).mock.calls[0] + expect(url).toBe('/api/platform/tenants/jiancai/config/brand/reset') + expect(body).toBeUndefined() + expect(config).toEqual({ showErrorMessage: false }) + }) + + it('CONFIG_ENDPOINTS.versions / versionDetail / rollback / resetGroup:路径与后端一致', () => { + expect(CONFIG_ENDPOINTS.versions('jiancai')).toBe( + '/api/platform/tenants/jiancai/config/versions' + ) + expect(CONFIG_ENDPOINTS.versionDetail('jiancai', 7)).toBe( + '/api/platform/tenants/jiancai/config/versions/7' + ) + expect(CONFIG_ENDPOINTS.rollback('jiancai')).toBe( + '/api/platform/tenants/jiancai/config/rollback' + ) + expect(CONFIG_ENDPOINTS.resetGroup('jiancai', 'push')).toBe( + '/api/platform/tenants/jiancai/config/push/reset' + ) + }) +}) + +describe('normalizeBrandGroup(GET 原样组 → 表单完整值)', () => { + it('缺键补空串、topics 缺省为空数组(快照是原样组,可能缺键)', () => { + const out = normalizeBrandGroup({ title: 'HESUPIPE Support' }) + expect(out).toEqual({ + title: 'HESUPIPE Support', + color: '', + avatar: '', + welcome: '', + placeholder: '', + whatsapp: '', + email: '', + privacy_url: '', + topics: [] + }) + }) + + it('类型漂移防御:非字符串字段读作空串、topics 只留字符串项', () => { + const out = normalizeBrandGroup({ + title: 123, + color: null, + topics: ['Get Quotation', 42, null, 'Delivery'], + welcome: true + }) + expect(out.title).toBe('') + expect(out.color).toBe('') + expect(out.welcome).toBe('') + expect(out.topics).toEqual(['Get Quotation', 'Delivery']) + }) + + it('完整组原样保留(正常路径)', () => { + const full = { + title: 'HESUPIPE Support', + color: '#066fc0', + avatar: '/static/kefu-small.png', + welcome: 'What kind of piping project?', + placeholder: 'Ask about pipes...', + whatsapp: '+8615937108317', + email: 'hesu@hesuu.com', + privacy_url: 'https://hesupipe.com/privacy-policy', + topics: ['Get Quotation'] + } + expect(normalizeBrandGroup(full)).toEqual(full) + }) + + it('null / undefined 组值 → 全空表单值', () => { + expect(normalizeBrandGroup(null).title).toBe('') + expect(normalizeBrandGroup(undefined).topics).toEqual([]) + }) +}) diff --git a/admin/src/api/__tests__/conversations.test.ts b/admin/src/api/__tests__/conversations.test.ts new file mode 100644 index 0000000..d96eb9d --- /dev/null +++ b/admin/src/api/__tests__/conversations.test.ts @@ -0,0 +1,162 @@ +/** + * 会话与线索 API 单元测试(Phase 3 Task 5) + * + * 钉住端点 URL 与查询参数组装(事实源 app/platform/routes/data_routes.py: + * intent 白名单 interested/not_interested/pending、push status 白名单 + * pending/success/failed/skipped、from/to 闭区间、分页 page/page_size ≤100); + * HTTP 层行为已在 client.test.ts 覆盖,此处 mock http 只断言 API 层契约。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn(), del: vi.fn() } +})) + +import { http } from '@/api/client' +import { + CONVERSATION_ENDPOINTS, + INTENT_OPTIONS, + PUSH_STATUS_OPTIONS, + conversationSourceLabel, + conversationStatusLabel, + deleteConversation, + fetchConversationDetail, + fetchConversations, + fetchPushLogs, + intentLabel, + pushStatusLabel, + pushTypeLabel +} from '@/api/conversations' + +describe('会话与线索 API(src/api/conversations.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(http.get).mockResolvedValue({ total: 0, page: 1, page_size: 20, items: [] }) + vi.mocked(http.del).mockResolvedValue({ ok: true }) + }) + + it('fetchConversations:GET /api/platform/tenants/{slug}/conversations 带筛选+分页参数', async () => { + await fetchConversations('jiancai', { + intent: 'interested', + site: 'es', + from: '2026-10-01', + to: '2026-10-13', + page: 2, + page_size: 50 + }) + + expect(http.get).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/conversations', + { + params: { + intent: 'interested', + site: 'es', + from: '2026-10-01', + to: '2026-10-13', + page: 2, + page_size: 50 + } + } + ) + }) + + it('fetchConversations 无参数:params 为 undefined(后端缺省 page=1/page_size=20)', async () => { + await fetchConversations('jiancai') + + expect(http.get).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/conversations', + { params: undefined } + ) + }) + + it('fetchConversationDetail:GET .../conversations/{convId}', async () => { + const detail = { + id: 'c1', + session_id: 'sess-c1', + status: 'completed', + source: 'widget', + client_info: { ip: '8.8.8.8', site: 'es' }, + site: 'es', + ip: '8.8.8.8', + device: '', + country: '美国', + created_at: '2026-10-01T12:00:00', + updated_at: '2026-10-01T12:00:00', + evaluation: null, + profile: { chips: { 姓名: 'John' }, summary: null }, + messages: [{ role: 'user', content: '您好', time: '2026-10-01T12:00:00' }] + } + vi.mocked(http.get).mockResolvedValue(detail) + + const res = await fetchConversationDetail('jiancai', 'c1') + + expect(res).toEqual(detail) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/conversations/c1') + }) + + it('deleteConversation:DELETE .../conversations/{convId},响应 {ok}', async () => { + const res = await deleteConversation('jiancai', 'c1') + + expect(res).toEqual({ ok: true }) + expect(http.del).toHaveBeenCalledWith('/api/platform/tenants/jiancai/conversations/c1') + }) + + it('fetchPushLogs:GET .../push-logs 带 status/conversation_id/分页参数', async () => { + await fetchPushLogs('jiancai', { + status: 'failed', + conversation_id: 'c1', + page: 2, + page_size: 10 + }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/push-logs', { + params: { status: 'failed', conversation_id: 'c1', page: 2, page_size: 10 } + }) + }) + + it('fetchPushLogs 无参数:params 为 undefined', async () => { + await fetchPushLogs('jiancai') + + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/push-logs', { + params: undefined + }) + }) + + it('CONVERSATION_ENDPOINTS:端点形态与 data_routes.py 路由一致', () => { + expect(CONVERSATION_ENDPOINTS.list('a')).toBe('/api/platform/tenants/a/conversations') + expect(CONVERSATION_ENDPOINTS.detail('a', 'c1')).toBe( + '/api/platform/tenants/a/conversations/c1' + ) + expect(CONVERSATION_ENDPOINTS.remove('a', 'c1')).toBe( + '/api/platform/tenants/a/conversations/c1' + ) + expect(CONVERSATION_ENDPOINTS.pushLogs('a')).toBe('/api/platform/tenants/a/push-logs') + }) + + it('筛选白名单选项:intent 三值 / push status 四值(与后端 INTENTS/PUSH_STATUSES 对齐)', () => { + expect(INTENT_OPTIONS.map((item) => item.value)).toEqual([ + 'interested', + 'not_interested', + 'pending' + ]) + expect(PUSH_STATUS_OPTIONS.map((item) => item.value)).toEqual([ + 'pending', + 'success', + 'failed', + 'skipped' + ]) + }) + + it('展示映射:未知枚举值回退原样,空值有占底文案', () => { + expect(intentLabel('interested')).toBe('有意向') + expect(intentLabel(null)).toBe('未评估') + expect(intentLabel('bogus')).toBe('bogus') + expect(pushStatusLabel('skipped')).toBe('跳过') + expect(pushStatusLabel('bogus')).toBe('bogus') + expect(pushTypeLabel('correction')).toBe('更正') + expect(pushTypeLabel('unknown')).toBe('unknown') + expect(conversationStatusLabel('completed')).toBe('已完成') + expect(conversationSourceLabel('widget')).toBe('挂件') + expect(conversationSourceLabel('weird')).toBe('weird') + }) +}) diff --git a/admin/src/api/__tests__/mask.test.ts b/admin/src/api/__tests__/mask.test.ts new file mode 100644 index 0000000..5564d5c --- /dev/null +++ b/admin/src/api/__tests__/mask.test.ts @@ -0,0 +1,61 @@ +/** + * 掩码哨兵辅助纯函数测试(Phase 3 Task 3) + * + * 契约对齐后端 app/platform/routes/config_routes.py: + * 密钥字段 GET 响应非空时回 `__SET__`(MASKED 哨兵),PUT 提交 `__SET__` + * 表示"保持库值"。本模块负责 `__SET__` ↔ 表单"已设置"语义的相互转换, + * 配置中心 / 平台设置页(Task 7/9)消费。 + */ +import { describe, expect, it } from 'vitest' +import { + MASKED_DISPLAY, + MASKED_SENTINEL, + isMaskedSentinel, + secretPlaceholder, + secretToSubmit +} from '@/api/mask' + +describe('掩码哨兵辅助(src/api/mask.ts)', () => { + it('哨兵识别:__SET__ 为哨兵,其他值不是', () => { + expect(isMaskedSentinel('__SET__')).toBe(true) + expect(isMaskedSentinel('sk-real-key')).toBe(false) + expect(isMaskedSentinel('')).toBe(false) + expect(isMaskedSentinel(null)).toBe(false) + expect(isMaskedSentinel(undefined)).toBe(false) + }) + + it('GET 掩码值 → 表单占位语义:已设置提示可覆盖,未设置提示待输入', () => { + expect(MASKED_SENTINEL).toBe('__SET__') + expect(MASKED_DISPLAY).toBe('已设置') + // 后端下发的密钥字段只可能是哨兵(已设置)或空串/缺省(未设置) + expect(secretPlaceholder('__SET__')).toBe('已设置,输入新值覆盖') + expect(secretPlaceholder('')).toBe('未设置,输入新值') + expect(secretPlaceholder(null)).toBe('未设置,输入新值') + expect(secretPlaceholder(undefined)).toBe('未设置,输入新值') + }) + + it('表单值 → 提交值:未改动(空输入 + 原本已设置)回传哨兵保持库值', () => { + // 用户没有输入新值,且库里有值 → 回 __SET__,后端保持原值不落哨兵字面量 + expect(secretToSubmit('', '__SET__')).toBe('__SET__') + }) + + it('表单值 → 提交值:输入了新值则提交新值(覆盖)', () => { + expect(secretToSubmit('sk-new-key', '__SET__')).toBe('sk-new-key') + expect(secretToSubmit('sk-first-key', '')).toBe('sk-first-key') + expect(secretToSubmit('sk-first-key', null)).toBe('sk-first-key') + }) + + it('表单值 → 提交值:未设置且留空 → 提交空串(保持未设置)', () => { + expect(secretToSubmit('', '')).toBe('') + expect(secretToSubmit('', null)).toBe('') + }) + + it('往返一致性:掩码字段未改动时,提交值仍是哨兵(后端可识别为"保持")', () => { + const fromServer = '__SET__' + const displayed = secretPlaceholder(fromServer) + expect(displayed).toContain('已设置') + + const submitted = secretToSubmit('', fromServer) + expect(isMaskedSentinel(submitted)).toBe(true) + }) +}) diff --git a/admin/src/api/__tests__/settings.test.ts b/admin/src/api/__tests__/settings.test.ts new file mode 100644 index 0000000..c322fac --- /dev/null +++ b/admin/src/api/__tests__/settings.test.ts @@ -0,0 +1,78 @@ +/** + * 平台设置 API 单元测试(Phase 3 Task 9) + * + * 钉住 API 层契约(事实源 app/platform/routes/config_routes.py 的 + * settings_router):GET 列表 {items:[{key,value}]} / PUT 单键 upsert + * {key,value}——与 config 组的"整组替换"不同,KV 表逐键写。 + * HTTP 层行为已在 client.test.ts 覆盖,此处 mock http 只断言调用形态。 + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn(), put: vi.fn() } +})) + +import { http } from '@/api/client' +import { + SETTINGS_ENDPOINTS, + fetchPlatformSettings, + putPlatformSetting +} from '@/api/settings' + +describe('平台设置 API(src/api/settings.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchPlatformSettings:GET /api/platform/settings(超管端点)', async () => { + const body = { + items: [ + { key: 'ai.api_key', value: '__SET__' }, + { key: 'ai.model', value: 'glm-4-flash' } + ] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchPlatformSettings() + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith(SETTINGS_ENDPOINTS.list, undefined) + }) + + it('fetchPlatformSettings:透传 RequestOptions(内联错误展示)', async () => { + vi.mocked(http.get).mockResolvedValue({ items: [] }) + await fetchPlatformSettings({ showErrorMessage: false }) + expect(http.get).toHaveBeenCalledWith(SETTINGS_ENDPOINTS.list, { + showErrorMessage: false + }) + }) + + it('putPlatformSetting:PUT 单键 upsert,请求体 {key, value}', async () => { + const response = { key: 'smtp.host', value: 'smtp.exmail.qq.com' } + vi.mocked(http.put).mockResolvedValue(response) + + const res = await putPlatformSetting( + { key: 'smtp.host', value: 'smtp.exmail.qq.com' }, + { showErrorMessage: false } + ) + + expect(res).toEqual(response) + expect(http.put).toHaveBeenCalledWith( + SETTINGS_ENDPOINTS.put, + { key: 'smtp.host', value: 'smtp.exmail.qq.com' }, + { showErrorMessage: false } + ) + }) + + it('putPlatformSetting:密钥哨兵原样提交(后端还原库值)', async () => { + vi.mocked(http.put).mockResolvedValue({ key: 'ai.api_key', value: '__SET__' }) + + await putPlatformSetting({ key: 'ai.api_key', value: '__SET__' }) + + expect(http.put).toHaveBeenCalledWith( + SETTINGS_ENDPOINTS.put, + { key: 'ai.api_key', value: '__SET__' }, + undefined + ) + }) +}) diff --git a/admin/src/api/__tests__/stats.test.ts b/admin/src/api/__tests__/stats.test.ts new file mode 100644 index 0000000..3269ff9 --- /dev/null +++ b/admin/src/api/__tests__/stats.test.ts @@ -0,0 +1,89 @@ +/** + * 统计 API 单元测试(Phase 3 Task 4) + * + * 钉住端点 URL 与查询参数组装(?from/&to/&tenant= 事实源 + * app/platform/routes/stats_routes.py);HTTP 层行为(拦截/token/错误 + * 提示)已在 client.test.ts 覆盖,此处 mock http 只断言 API 层契约: + * - fetchPlatformStats:/api/platform/stats 合并视图;tenant= 下钻; + * showErrorMessage: false 透传(卡片墙逐卡内联错误防 N 连弹) + * - fetchTenantStats:/api/platform/tenants/{slug}/stats 单租户 + * - fetchTenantList:/api/platform/tenants(卡片墙枚举租户) + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn() } +})) + +import { http } from '@/api/client' +import { fetchPlatformStats, fetchTenantStats } from '@/api/stats' +import { fetchTenantList } from '@/api/tenants' + +/** 零数据统计响应形态(与后端 stats._empty_stats 同构) */ +const EMPTY_STATS = { + cards: { conversations: 0, leads: 0, high_intent: 0, push_success_rate: null }, + funnel: { conversations: 0, leads: 0, evaluated: 0, push_succeeded: 0 }, + trend: [], + distributions: { intent: {}, source: {}, site: {}, country: [] }, + push_health: { pending: 0, success: 0, failed: 0, skipped: 0, duplicated: 0 } +} + +describe('统计 API(src/api/stats.ts + tenants.ts 列表函数)', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(http.get).mockResolvedValue(EMPTY_STATS) + }) + + it('fetchPlatformStats 无参数:GET /api/platform/stats,无查询参数', async () => { + const res = await fetchPlatformStats() + + expect(res).toEqual(EMPTY_STATS) + expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { params: undefined }) + }) + + it('fetchPlatformStats 带 from/to/tenant:全部进查询参数(卡片墙下钻形态)', async () => { + await fetchPlatformStats({ tenant: 'jiancai', from: '2026-10-13', to: '2026-10-13' }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { + params: { tenant: 'jiancai', from: '2026-10-13', to: '2026-10-13' } + }) + }) + + it('fetchPlatformStats 透传 showErrorMessage: false(卡片墙防 N 连弹)', async () => { + await fetchPlatformStats({ from: '2026-10-13' }, { showErrorMessage: false }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/stats', { + params: { from: '2026-10-13' }, + showErrorMessage: false + }) + }) + + it('fetchTenantStats:GET /api/platform/tenants/{slug}/stats 带 from/to', async () => { + await fetchTenantStats('jiancai', { from: '2026-10-07', to: '2026-10-13' }) + + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai/stats', { + params: { from: '2026-10-07', to: '2026-10-13' } + }) + }) + + it('fetchTenantList:GET /api/platform/tenants(卡片墙枚举)', async () => { + const body = { + items: [ + { + slug: 'jiancai', + name: '建材租户', + status: 'active', + domain_count: 1, + current_version: 1, + admins: [{ username: 'jc-admin', status: 'active' }] + } + ] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchTenantList() + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants') + }) +}) diff --git a/admin/src/api/__tests__/tenants.test.ts b/admin/src/api/__tests__/tenants.test.ts new file mode 100644 index 0000000..3928084 --- /dev/null +++ b/admin/src/api/__tests__/tenants.test.ts @@ -0,0 +1,218 @@ +/** + * 租户/站点/域名 API 单元测试(Phase 3 Task 6) + * + * 钉住 API 层契约:端点 URL / 方法 / 请求体形状(事实源 + * app/platform/routes/tenant_routes.py);HTTP 层行为已在 client.test.ts + * 覆盖,此处 mock http 只断言调用形态: + * - 列表/详情:GET /api/platform/tenants[/{slug}] + * - 创建向导:POST 全量六字段(domains 进 main 站点) + * - PATCH 租户:只发 name 或 status(slug 键绝不出现在 body——后端 400) + * - 站点:POST sites(brand_overlay 只含显式覆盖字段)/ PATCH sites/{site} + * - 域名:POST domains {domain} / DELETE domains/{domain}(路径段直拼) + * - RequestOptions 透传(向导/绑域名内联错误关全局 toast) + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/api/client', () => ({ + http: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), del: vi.fn() } +})) + +import { http } from '@/api/client' +import { + addSiteDomain, + createSite, + createTenant, + deleteSiteDomain, + fetchTenantDetail, + fetchTenantList, + patchSite, + patchTenant +} from '@/api/tenants' + +describe('租户与站点管理 API(src/api/tenants.ts)', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchTenantList:GET /api/platform/tenants', async () => { + const body = { + items: [ + { + slug: 'jiancai', + name: '建材租户', + status: 'active', + domain_count: 1, + current_version: 1, + admins: [{ username: 'jc-admin', status: 'active' }] + } + ] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchTenantList() + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants') + }) + + it('fetchTenantDetail:GET /api/platform/tenants/{slug}', async () => { + const body = { + slug: 'jiancai', + name: '建材租户', + status: 'active', + current_version: 1, + sites: [ + { + slug: 'main', + name: 'main', + status: 'active', + domains: ['chat2.agent.hnxinyouhe.com'], + brand_overlay: {} + } + ], + admins: [{ username: 'jc-admin', status: 'active' }] + } + vi.mocked(http.get).mockResolvedValue(body) + + const res = await fetchTenantDetail('jiancai') + + expect(res).toEqual(body) + expect(http.get).toHaveBeenCalledWith('/api/platform/tenants/jiancai', undefined) + }) + + it('createTenant:POST 全量六字段(向导提交形态)', async () => { + const body = { + name: '新租户', + slug: 'new-tenant', + profile: 'blank', + admin_username: 'nt-admin', + admin_password: 'initial-pass', + domains: ['a.example.com', 'b.example.com'] + } + const created = { + slug: 'new-tenant', + name: '新租户', + status: 'active', + profile: 'blank', + version: 1, + site: { slug: 'main', domains: ['a.example.com', 'b.example.com'] }, + admin_username: 'nt-admin' + } + vi.mocked(http.post).mockResolvedValue(created) + + const res = await createTenant(body, { showErrorMessage: false }) + + expect(res).toEqual(created) + expect(http.post).toHaveBeenCalledWith('/api/platform/tenants', body, { + showErrorMessage: false + }) + }) + + it('patchTenant 改名:PATCH {slug} 只发 name(slug 键不可出现)', async () => { + vi.mocked(http.patch).mockResolvedValue({ + slug: 'jiancai', + name: '建材租户改', + status: 'active' + }) + + await patchTenant('jiancai', { name: '建材租户改' }) + + expect(http.patch).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai', + { name: '建材租户改' }, + undefined + ) + }) + + it('patchTenant 停用:PATCH {slug} 只发 status=disabled', async () => { + vi.mocked(http.patch).mockResolvedValue({ + slug: 'jiancai', + name: '建材租户', + status: 'disabled' + }) + + await patchTenant('jiancai', { status: 'disabled' }) + + expect(http.patch).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai', + { status: 'disabled' }, + undefined + ) + }) + + it('createSite:POST sites 带 brand_overlay 覆盖子集(留空字段不进 overlay)', async () => { + const body = { + slug: 'shop1', + name: '西班牙站', + brand_overlay: { title: 'HESUPIPE ES', color: '#ff6600' } + } + const created = { + slug: 'shop1', + name: '西班牙站', + status: 'active', + domains: [], + brand_overlay: body.brand_overlay + } + vi.mocked(http.post).mockResolvedValue(created) + + const res = await createSite('jiancai', body) + + expect(res).toEqual(created) + expect(http.post).toHaveBeenCalledWith('/api/platform/tenants/jiancai/sites', body, undefined) + }) + + it('patchSite 停用:PATCH sites/{siteSlug} 只发 status', async () => { + vi.mocked(http.patch).mockResolvedValue({ + slug: 'shop1', + name: '西班牙站', + status: 'disabled', + domains: [], + brand_overlay: {} + }) + + await patchSite('jiancai', 'shop1', { status: 'disabled' }) + + expect(http.patch).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/sites/shop1', + { status: 'disabled' }, + undefined + ) + }) + + it('addSiteDomain:POST sites/{siteSlug}/domains {domain}', async () => { + vi.mocked(http.post).mockResolvedValue({ + domain: 'es.hesupipe.com', + site: 'shop1' + }) + + const res = await addSiteDomain( + 'jiancai', + 'shop1', + { domain: 'es.hesupipe.com' }, + { showErrorMessage: false } + ) + + expect(res).toEqual({ domain: 'es.hesupipe.com', site: 'shop1' }) + expect(http.post).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/sites/shop1/domains', + { domain: 'es.hesupipe.com' }, + { showErrorMessage: false } + ) + }) + + it('deleteSiteDomain:DELETE sites/{siteSlug}/domains/{domain}(路径直拼)', async () => { + vi.mocked(http.del).mockResolvedValue({ + ok: true, + domain: 'es.hesupipe.com', + site: 'shop1' + }) + + const res = await deleteSiteDomain('jiancai', 'shop1', 'es.hesupipe.com') + + expect(res).toEqual({ ok: true, domain: 'es.hesupipe.com', site: 'shop1' }) + expect(http.del).toHaveBeenCalledWith( + '/api/platform/tenants/jiancai/sites/shop1/domains/es.hesupipe.com', + undefined + ) + }) +}) diff --git a/admin/src/api/auth.ts b/admin/src/api/auth.ts new file mode 100644 index 0000000..3c08637 --- /dev/null +++ b/admin/src/api/auth.ts @@ -0,0 +1,38 @@ +/** + * 认证 API(Phase 3 Task 3 实装,替换 Task 2 开发桩) + * + * 对接后端 app/platform/routes/auth_routes.py: + * - POST /api/platform/auth/login {username, password} + * → {token, role: "super"|"tenant", tenant_slug, must_change_password} + * - POST /api/platform/auth/logout(Bearer;撤销当前 token) + * - POST /api/platform/auth/change-password {old_password, new_password} + * → {ok, must_change_password} + * + * 错误语义(HTTP client 统一处理,见 src/api/client.ts): + * - 401 凭据错误/会话过期(登录请求自身 401 只提示不跳转) + * - 429 登录限流(5 次/15 分钟) + * - 400 原密码不正确 + */ +import { http } from './client' +import type { LoginParams, LoginResponse, ChangePasswordParams } from '@/store/modules/auth' + +/** 登录 */ +export function fetchLogin(params: LoginParams): Promise { + return http.post('/api/platform/auth/login', params) +} + +/** 登出(撤销当前 token;需 Bearer) */ +export function fetchLogout(): Promise<{ ok: boolean }> { + return http.post<{ ok: boolean }>('/api/platform/auth/logout') +} + +/** 修改密码(验旧密 → 写新 hash;改完当前会话保持有效) */ +export function fetchChangePassword(params: ChangePasswordParams): Promise<{ + ok: boolean + must_change_password: boolean +}> { + return http.post<{ ok: boolean; must_change_password: boolean }>( + '/api/platform/auth/change-password', + params + ) +} diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..f0a497a --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,143 @@ +/** + * 平台 API HTTP client(Phase 3 Task 3,自建薄封装) + * + * ## 为什么不用模板的 src/utils/http + * + * 模板封装与后端约定对不上,改造成本高于自建: + * - 响应:模板要求 `{code, msg, data}` 业务信封;后端(FastAPI)业务语义 + * 直用 HTTP 状态码,成功响应就是数据本身 + * - 错误:模板读 `data.msg`;FastAPI 错误体是 `{detail: 中文}` + * - 凭据:模板裸塞 `Authorization: `;后端要求 `Bearer ` + * - 模板无 409 强制改密语义 + * + * ## 本 client 约定 + * + * - 请求拦截:auth store 有 token 时自动注入 `Authorization: Bearer ` + * - 401(非登录请求):清会话 + 跳登录(登录请求自身的 401 = 凭据错误, + * 只弹 detail,不触发登出/跳转——否则登录页死循环) + * - 409 且 detail 为强制改密文案:跳强制改密页;其余 409(并发冲突等) + * 是普通业务错误,只弹 detail(后端 409 双语义:enforce_password_changed + * 与租户/域名并发冲突,见 app/platform/routes/) + * - 其余错误/网络错误:ElMessage 出后端 detail / 统一网络文案 + * - 成功:直接透传 response.data + * - 请求配置 showErrorMessage: false 可关闭全局错误提示(调用方自行处理) + */ +import axios, { + AxiosError, + type AxiosRequestConfig, + type AxiosResponse, + type InternalAxiosRequestConfig +} from 'axios' +import { ElMessage } from 'element-plus' +import { useAuthStore } from '@/store/modules/auth' + +/** + * 强制改密 409 的 detail 契约(app/platform/auth.py enforce_password_changed + * 的静态文案)。只有命中该文案的 409 才触发改密跳转。 + */ +export const MUST_CHANGE_DETAIL = '必须先修改初始密码' + +/** 网络错误统一文案(无响应时后端不可能给 detail) */ +export const NETWORK_ERROR_MESSAGE = '网络连接异常,请检查网络后重试' + +/** 登录端点路径(其 401/429 是凭据/限流业务错误,不走会话过期处理) */ +const LOGIN_URL = '/api/platform/auth/login' + +/** 统一 API 错误(供调用方按需识别状态码) */ +export class ApiError extends Error { + readonly status?: number + readonly url?: string + + constructor(message: string, status?: number, url?: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.url = url + } +} + +/** 后端错误体形态(FastAPI HTTPException:{detail: string}) */ +interface ApiErrorBody { + detail?: string +} + +/** 扩展请求配置:允许关闭全局错误提示 */ +export interface RequestOptions extends AxiosRequestConfig { + showErrorMessage?: boolean +} + +const instance = axios.create({ + timeout: 15000, + // 开发走 vite 代理(/),生产同源反代;两环境 VITE_API_URL 均为 '/' + baseURL: import.meta.env.VITE_API_URL || '/', + // 后端业务语义直用 HTTP 状态码,非 2xx 一律进错误拦截器 + validateStatus: (status) => status >= 200 && status < 300 +}) + +/** 请求拦截:Bearer token 注入 */ +instance.interceptors.request.use((config: InternalAxiosRequestConfig) => { + const { token } = useAuthStore() + if (token) { + config.headers.set('Authorization', `Bearer ${token}`) + } + return config +}) + +/** 提取错误提示文案:优先后端 detail,其次统一网络文案 */ +function extractDetail(error: AxiosError): string { + if (error.response?.data?.detail) { + return error.response.data.detail + } + return error.response ? `请求失败(HTTP ${error.response.status})` : NETWORK_ERROR_MESSAGE +} + +/** 响应拦截:401/409 全局处理 + 统一错误提示 */ +instance.interceptors.response.use( + (response: AxiosResponse) => response, + async (error: AxiosError) => { + const status = error.response?.status + const detail = extractDetail(error) + const url = error.config?.url || '' + const config = error.config as (RequestOptions & InternalAxiosRequestConfig) | undefined + const isLoginRequest = url.includes(LOGIN_URL) + + if (status === 401 && !isLoginRequest) { + // 会话过期:清空状态 + 模板侧全套清理(含跳登录,登录页不重复跳) + await useAuthStore().handleSessionExpired() + } else if (status === 409 && detail === MUST_CHANGE_DETAIL) { + // 强制改密:置标志 + 跳改密页(已在改密页不重复跳) + await useAuthStore().handleMustChangePassword() + } + + if (config?.showErrorMessage !== false) { + ElMessage.error(detail) + } + + throw new ApiError(detail, status, url) + } +) + +/** 发起请求,成功透传 response.data */ +async function request(config: RequestOptions): Promise { + const response = await instance.request(config) + return response.data +} + +/** API 方法集合 */ +export const http = { + get(url: string, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'GET' }) + }, + post(url: string, data?: unknown, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'POST', data }) + }, + put(url: string, data?: unknown, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'PUT', data }) + }, + patch(url: string, data?: unknown, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'PATCH', data }) + }, + del(url: string, config?: RequestOptions): Promise { + return request({ ...config, url, method: 'DELETE' }) + } +} diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts new file mode 100644 index 0000000..d11dd52 --- /dev/null +++ b/admin/src/api/config.ts @@ -0,0 +1,402 @@ +/** + * 租户配置中心 API(Phase 3 Task 3 骨架;Task 7a 实装品牌相关调用函数; + * Task 7b 补词表试金石调用) + * + * 端点事实源:app/platform/routes/config_routes.py + * (配置快照 / 分组编辑 / 版本历史 / 回滚 / 重置 / 试金石) + * 与 app/platform/routes/upload_routes.py(头像上传)。 + * + * 密钥掩码哨兵:GET 的密钥字段非空时为 '__SET__'(见 src/api/mask.ts + * 的表单语义转换);PUT 提交 '__SET__' = 保持库值。品牌组无密钥字段, + * 无哨兵处理需求。 + * + * ## Task 7a 契约修正(对齐后端事实源) + * + * - PUT /config/{group} 的请求体是**组值本身**(整组替换语义),保存备注 + * 走查询参数 comment(config_routes.put_config_group 的 comment 形参); + * - 版本行字段是 updated_by / comment(非骨架期误写的 created_by / note); + * - 回滚请求体只认 version(note 不被后端读取); + * - 试金石响应逐句为 {sentence, action, reason, replacement}。 + * + * ## Task 7b 试金石契约(config_routes.test_wall) + * + * - body:sentences 必填;wall 可选覆盖(编辑器当前表单值的**预演**—— + * 后端先 compile_wall 校验后纯内存判定,不落库不 bump 版本),坏墙 + * 400 带字段路径;has_contact / source 可选(默认 false / web); + * - 判定优先级与 conversation.py 执行侧一致:价格墙(blocked)→ + * 零号码墙(replaced)→ 再索要墙(仅 has_contact,blocked 无替换)。 + * + * ## Task 7c 凭据测试契约(config_routes.test_email / test_push) + * + * - POST email/test:body {to}——后端用**已保存**的当前邮件配置真发一封 + * 测试邮件;SMTP 未配置 → 400;成败如实返回(sent=false 时带 error); + * - POST push/test:连通性探测——只验 URL 可达 + 鉴权,**不发任何线索 + * 数据**(后端发空 messages 批量);不可达时 reason 分类 + * dns/timeout/connect,auth_ok 三态(true/false/null=不定)。 + * + * ## Task 7d 版本历史 / 回滚 / 重置契约(config_routes) + * + * - GET versions:升序 items(version/created_at/updated_by/comment), + * **不含 config 本体**(列表几十 KB × N 版本会白白撑爆响应);展示侧 + * 自行倒序(最新在前); + * - GET versions/{n}:含整份快照(掩码视图——密钥字段 '__SET__',历史行 + * 本身永远存原值);created_at 是 naive isoformat(无时区后缀); + * - POST rollback {version}:目标版本整份复制为新版本(历史不可变), + * 响应 {rollback_to, version(新), config(新快照掩码视图)}; + * - POST config/{group}/reset:该组恢复出厂档案值(代码库当前模板,非 + * 创建时快照),响应形态与 PUT 相同({group, version, config}); + * 合法组 = config_validation.CONFIG_GROUPS(RESET_GROUP_LABELS 镜像); + * push 组重置可能因出厂 prefix 被其他租户占用而 400(跨租户查重)。 + */ + +import { http, type RequestOptions } from './client' + +/** 配置组名(快照顶层键;与 config_validation.CONFIG_GROUPS 对齐) */ +export type ConfigGroupKey = + | 'brand' + | 'prompts' + | 'wall' + | 'contact' + | 'email' + | 'push' + | 'ai' + | 'engine' + | 'misc' + +/** 品牌组值(整组替换语义:字段清单固定,topics 为字符串数组) */ +export interface BrandGroupValue { + title: string + color: string + avatar: string + welcome: string + topics: string[] + placeholder: string + whatsapp: string + email: string + privacy_url: string +} + +/** 当前生效配置响应(GET /api/platform/tenants/{slug}/config) */ +export interface TenantConfigResponse { + slug: string + version: number + /** 掩码视图的整份快照(密钥字段可能为 '__SET__') */ + config: Record> +} + +/** 分组保存响应(PUT .../config/{group};config 为该组掩码视图) */ +export interface PutConfigGroupResponse { + slug: string + group: string + version: number + config: Record +} + +/** + * 组级重置响应(POST .../config/{group}/reset):与 PUT 分组保存同形 + * (新版本号 + 该组出厂值的掩码视图——密钥字段 '__SET__')。 + */ +export type ResetConfigGroupResponse = PutConfigGroupResponse + +/** 配置版本行(GET .../config/versions;字段名与后端一致) */ +export interface ConfigVersionItem { + version: number + created_at: string + updated_by: string + comment: string +} + +/** 版本列表响应 */ +export interface ConfigVersionListResponse { + slug: string + items: ConfigVersionItem[] +} + +/** 版本详情响应(含整份快照) */ +export interface ConfigVersionDetailResponse extends ConfigVersionItem { + slug: string + config: Record> +} + +/** 回滚请求(POST .../config/rollback,产生新版本;后端只读 version) */ +export interface ConfigRollbackRequest { + version: number +} + +/** 回滚响应 */ +export interface ConfigRollbackResponse { + slug: string + rollback_to: number + version: number + config: Record> +} + +/** 词表试金石请求(POST .../wall/test) + * wall 覆盖 = 编辑器当前表单值的预演(后端 compile_wall 校验 + 纯内存 + * 判定,不落库);缺省用当前生效墙。 */ +export interface WallTestRequest { + sentences: string[] + wall?: Record + has_contact?: boolean + source?: 'web' | 'app' +} + +/** 词表试金石响应(逐句判定;action: blocked/replaced/pass) */ +export interface WallTestResponse { + slug: string + wall_source: 'current' | 'override' + results: Array<{ + sentence: string + action: 'blocked' | 'replaced' | 'pass' + reason: 'boundary' | 'no_contact' | 'reask' | null + replacement: string | null + }> +} + +/** 头像上传响应(POST .../brand/avatar;avatar 为落盘文件名) */ +export interface AvatarUploadResponse { + slug: string + avatar: string + version: number +} + +/** SMTP 测试邮件请求(POST .../email/test;用**已保存**配置真发一封到 to) */ +export interface EmailTestRequest { + to: string +} + +/** SMTP 测试邮件响应(发送失败不 5xx——sent=false 时 error 带原因) */ +export interface EmailTestResponse { + slug: string + sent: boolean + error: string | null +} + +/** 推送探测不可达原因分类(前端据此给修复提示文案) */ +export type PushProbeReason = 'dns' | 'timeout' | 'connect' + +/** 推送连通性探测响应(POST .../push/test;空批量预检,零线索数据) */ +export interface PushTestResponse { + slug: string + reachable: boolean + /** true=2xx / false=401·403 / null=其他状态(到达但鉴权不定)或不可达 */ + auth_ok: boolean | null + status: number | null + reason: PushProbeReason | null + error: string | null +} + +/** 配置中心端点 */ +export const CONFIG_ENDPOINTS = { + current: (slug: string) => `/api/platform/tenants/${slug}/config`, + putGroup: (slug: string, group: ConfigGroupKey | string) => + `/api/platform/tenants/${slug}/config/${group}`, + resetGroup: (slug: string, group: ConfigGroupKey | string) => + `/api/platform/tenants/${slug}/config/${group}/reset`, + versions: (slug: string) => `/api/platform/tenants/${slug}/config/versions`, + versionDetail: (slug: string, version: number) => + `/api/platform/tenants/${slug}/config/versions/${version}`, + rollback: (slug: string) => `/api/platform/tenants/${slug}/config/rollback`, + wallTest: (slug: string) => `/api/platform/tenants/${slug}/wall/test`, + avatarUpload: (slug: string) => `/api/platform/tenants/${slug}/brand/avatar`, + emailTest: (slug: string) => `/api/platform/tenants/${slug}/email/test`, + pushTest: (slug: string) => `/api/platform/tenants/${slug}/push/test` +} as const + +/** + * 当前生效配置(MAX 版本快照 + version)。 + * 注意快照是"原样组"——组内缺省键由消费侧深合并出厂默认, + * 品牌表单消费 config.brand 时用 normalizeBrandGroup 防御读取。 + */ +export function fetchTenantConfig( + slug: string, + config?: RequestOptions +): Promise { + return http.get(CONFIG_ENDPOINTS.current(slug), config) +} + +/** + * 分组保存(整组替换语义):请求体 = 组值本身,备注走 comment 查询参数。 + * 调用方必须先 GET 全组、改字段后整组提交——部分提交会丢掉未携带的键 + * (后端 deep merge 出厂默认兜底,但租户既有定制会被覆盖丢失)。 + */ +export function putConfigGroup( + slug: string, + group: ConfigGroupKey | string, + value: Record, + comment?: string, + config?: RequestOptions +): Promise { + return http.put( + CONFIG_ENDPOINTS.putGroup(slug, group), + value, + { + ...config, + params: comment ? { comment } : undefined + } + ) +} + +/** + * 版本列表(GET .../config/versions):升序 items(版本/时间/作者/备注), + * 不含 config 本体。展示侧自行倒序(最新在前)——后端列表只回元数据, + * 详情走 fetchConfigVersionDetail 按需取整份。 + */ +export function fetchConfigVersions( + slug: string, + config?: RequestOptions +): Promise { + return http.get( + CONFIG_ENDPOINTS.versions(slug), + config + ) +} + +/** + * 单版本详情(GET .../config/versions/{n}):含整份快照(密钥字段为 + * '__SET__' 掩码视图——历史行存原值但绝不回传)。版本不存在 → 404。 + */ +export function fetchConfigVersionDetail( + slug: string, + version: number, + config?: RequestOptions +): Promise { + return http.get( + CONFIG_ENDPOINTS.versionDetail(slug, version), + config + ) +} + +/** + * 回滚(POST .../config/rollback):目标版本整份复制为新版本(历史不可变 + * ——旧版本行永不改写),reload 热生效。响应含新版本号 + 新快照掩码视图 + * (调用方据此回写当前配置态,无需再 GET config)。 + */ +export function rollbackConfig( + slug: string, + body: ConfigRollbackRequest, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.rollback(slug), + body, + config + ) +} + +/** + * 组级重置出厂(POST .../config/{group}/reset,无请求体):该组恢复出厂 + * 档案值(代码库当前模板,随平台升级演进——非创建时快照),产新版本 + + * reload 热生效。合法组见 model.ts 的 RESET_GROUP_LABELS(后端 + * CONFIG_GROUPS 镜像,未知组 400);push 组重置可能因出厂 prefix 被其他 + * 租户占用而 400(跨租户查重)——调用方 showErrorMessage: false 内联展示。 + */ +export function resetConfigGroup( + slug: string, + group: ConfigGroupKey | string, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.resetGroup(slug, group), + undefined, + config + ) +} + +/** + * 词表试金石(POST .../wall/test):逐句墙判定预览。传 wall 覆盖 = + * 编辑器当前表单值的预演(不落库不 bump 版本)——保存词表前的自动 + * 验证复用本函数;坏墙(正则编译失败等)400 带字段路径,调用方需 + * showErrorMessage: false 自行内联展示。 + */ +export function testWall( + slug: string, + body: WallTestRequest, + config?: RequestOptions +): Promise { + return http.post(CONFIG_ENDPOINTS.wallTest(slug), body, config) +} + +/** + * 头像上传(multipart file 字段):魔数校验 / 2MB 上限在后端 + * (upload_routes.py),前端做扩展名与大小预检提前拦截。 + * 成功后 brand.avatar 更新为落盘文件名,版本 +1 并热生效。 + */ +export function uploadBrandAvatar( + slug: string, + file: File, + config?: RequestOptions +): Promise { + const form = new FormData() + form.append('file', file) + return http.post( + CONFIG_ENDPOINTS.avatarUpload(slug), + form, + config + ) +} + +/** + * SMTP 测试邮件(POST .../email/test):用**已保存**的当前邮件配置真发一封 + * 到 to——表单里未保存的编辑不生效(界面需提示"先保存再测试")。SMTP 未 + * 配置 → 400(调用方 showErrorMessage: false 自行内联展示);发送失败 + * 不抛错——sent=false 时 error 带原因串。 + */ +export function testEmail( + slug: string, + body: EmailTestRequest, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.emailTest(slug), + body, + config + ) +} + +/** + * 推送连通性探测(POST .../push/test):只验 URL 可达 + 鉴权,后端发空 + * messages 批量(零线索数据)。URL 未配置 → 400;结果三态由调用方按 + * reachable/auth_ok 展示(不可达 reason 分类 dns/timeout/connect)。 + * 同样用**已保存**配置——改完 URL 先保存再探测。 + */ +export function testPush( + slug: string, + config?: RequestOptions +): Promise { + return http.post( + CONFIG_ENDPOINTS.pushTest(slug), + undefined, + config + ) +} + +/** + * GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。 + * 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂 + * title/color/avatar 本非空——"AI Support"/"#066fc0"/内置头像),快照 + * 缺省键则由消费侧深合并回落出厂;UI 所见即所得。topics 只留字符串项。 + */ +export function normalizeBrandGroup( + value: Record | null | undefined +): BrandGroupValue { + const source = value ?? {} + const str = (key: string): string => { + const v = source[key] + return typeof v === 'string' ? v : '' + } + const topics = source.topics + return { + title: str('title'), + color: str('color'), + avatar: str('avatar'), + welcome: str('welcome'), + placeholder: str('placeholder'), + whatsapp: str('whatsapp'), + email: str('email'), + privacy_url: str('privacy_url'), + topics: Array.isArray(topics) + ? topics.filter((t): t is string => typeof t === 'string') + : [] + } +} diff --git a/admin/src/api/conversations.ts b/admin/src/api/conversations.ts new file mode 100644 index 0000000..0e386ec --- /dev/null +++ b/admin/src/api/conversations.ts @@ -0,0 +1,239 @@ +/** + * 会话与线索 API(Phase 3 Task 3 骨架;Task 5 实装调用函数并按实际响应修正类型) + * + * 端点与响应事实源:app/platform/routes/data_routes.py + * - 列表:{total, page, page_size, items},行含 preview/intent_level/contact_info/ + * email_sent/message_count/country/site 等;筛选 intent 白名单(interested/ + * not_interested/pending,非法 400)/site 精确/client_info["site"]/from-to 闭区间 + * (日期-only 的 to 含当日全天) + * - 详情:扁平结构(元数据 + evaluation/profile/messages 三块),消息行字段为 + * time(非 created_at);profile.chips 为 raw_info 去 _ 前缀键 + * - 删除:{ok: true};不存在 404(级联消息/画像/评估/推送日志) + * - push-logs:列表不回大 payload(load_only 排除 request_payload),带 + * payload_size 服务端长度标记;status 白名单 pending/success/failed/skipped + */ +import { http } from './client' + +/** 意向层级(Evaluation.intent_level 枚举;列表筛选白名单同值) */ +export type IntentLevel = 'interested' | 'not_interested' | 'pending' + +/** 推送状态(LeadPushLog.status;筛选白名单同值) */ +export type PushLogStatus = 'pending' | 'success' | 'failed' | 'skipped' + +/** 联系方式(评估产出,留资三选一) */ +export interface ContactInfo { + email?: string | null + whatsapp?: string | null + phone?: string | null +} + +/** 会话列表行(GET /api/platform/tenants/{slug}/conversations) */ +export interface ConversationListItem { + id: string + session_id: string + status: string + source: string + site: string | null + ip: string + device: string + country: string | null + intent_level: IntentLevel | null + reasoning: string | null + contact_info: ContactInfo | null + email_sent: boolean + message_count: number + preview: string + created_at: string | null +} + +/** 会话列表查询参数(intent 白名单校验在后端;from/to 为闭区间日期 YYYY-MM-DD) */ +export interface ConversationListParams { + intent?: IntentLevel + site?: string + from?: string + to?: string + page?: number + page_size?: number +} + +/** 会话列表响应 */ +export interface ConversationListResponse { + total: number + page: number + page_size: number + items: ConversationListItem[] +} + +/** 会话详情消息行(时间升序;字段为 time) */ +export interface ConversationMessage { + role: 'user' | 'assistant' | 'system' | string + content: string + time: string | null +} + +/** 评估块(详情内嵌,未评估为 null) */ +export interface ConversationEvaluation { + intent_level: IntentLevel | null + reasoning: string | null + contact_info: ContactInfo | null + email_sent: boolean + correction_count: number | null + evaluated_at: string | null +} + +/** 会话详情响应(GET .../conversations/{convId}——扁平,与列表行不同构) */ +export interface ConversationDetail { + id: string + session_id: string + status: string + source: string + client_info: Record + site: string | null + ip: string + device: string + country: string | null + created_at: string | null + updated_at: string | null + evaluation: ConversationEvaluation | null + profile: { + chips: Record + summary: string | null + } | null + messages: ConversationMessage[] +} + +/** 推送日志行(GET .../push-logs——无 request_payload,仅 payload_size 长度标记) */ +export interface PushLogItem { + id: string + evaluation_id: string + conversation_id: string + push_type: string + status: PushLogStatus | string + attempts: number + outcome: string | null + response_status: number | null + error_message: string | null + payload_size: number | null + created_at: string | null + updated_at: string | null +} + +/** 推送日志查询参数(status 白名单校验在后端) */ +export interface PushLogParams { + status?: PushLogStatus + conversation_id?: string + page?: number + page_size?: number +} + +/** 推送日志响应 */ +export interface PushLogResponse { + total: number + page: number + page_size: number + items: PushLogItem[] +} + +/** 会话与线索端点 */ +export const CONVERSATION_ENDPOINTS = { + list: (slug: string) => `/api/platform/tenants/${slug}/conversations`, + detail: (slug: string, convId: string) => + `/api/platform/tenants/${slug}/conversations/${convId}`, + remove: (slug: string, convId: string) => + `/api/platform/tenants/${slug}/conversations/${convId}`, + pushLogs: (slug: string) => `/api/platform/tenants/${slug}/push-logs` +} as const + +// ---- 展示映射(枚举值事实源 data_routes.py INTENTS/PUSH_STATUSES 与 models.py) ---- + +/** 意向筛选/展示选项 */ +export const INTENT_OPTIONS: Array<{ value: IntentLevel; label: string }> = [ + { value: 'interested', label: '有意向' }, + { value: 'not_interested', label: '无意向' }, + { value: 'pending', label: '待定' } +] + +/** 意向展示文案(未知值回退原样) */ +export function intentLabel(level: string | null | undefined): string { + if (!level) return '未评估' + return INTENT_OPTIONS.find((item) => item.value === level)?.label ?? level +} + +/** 推送状态筛选/展示选项 */ +export const PUSH_STATUS_OPTIONS: Array<{ value: PushLogStatus; label: string }> = [ + { value: 'pending', label: '待推送' }, + { value: 'success', label: '成功' }, + { value: 'failed', label: '失败' }, + { value: 'skipped', label: '跳过' } +] + +/** 推送状态展示文案 */ +export function pushStatusLabel(status: string | null | undefined): string { + if (!status) return '—' + return PUSH_STATUS_OPTIONS.find((item) => item.value === status)?.label ?? status +} + +/** 推送类型展示文案(LeadPushLog.push_type:lead/returning/correction) */ +export function pushTypeLabel(type: string | null | undefined): string { + const labels: Record = { + lead: '新线索', + returning: '回访', + correction: '更正' + } + if (!type) return '—' + return labels[type] ?? type +} + +/** 会话状态展示文案(ConversationStatus) */ +export function conversationStatusLabel(status: string | null | undefined): string { + const labels: Record = { + active: '进行中', + completed: '已完成', + abandoned: '已放弃' + } + if (!status) return '—' + return labels[status] ?? status +} + +/** 会话来源展示文案(ConversationSource) */ +export function conversationSourceLabel(source: string | null | undefined): string { + const labels: Record = { + standalone: '独立站', + embedded: '内嵌页', + widget: '挂件', + app: '应用' + } + if (!source) return '—' + return labels[source] ?? source +} + +// ---- 调用函数 ------------------------------------------------------------------- + +/** 会话列表(分页 + intent/site/日期区间筛选) */ +export function fetchConversations( + slug: string, + params?: ConversationListParams +): Promise { + return http.get(CONVERSATION_ENDPOINTS.list(slug), { params }) +} + +/** 会话详情(元数据 + 画像 chips + 评估 + 消息全量) */ +export function fetchConversationDetail( + slug: string, + convId: string +): Promise { + return http.get(CONVERSATION_ENDPOINTS.detail(slug, convId)) +} + +/** 删除会话(级联消息/画像/评估/推送日志;不存在 404) */ +export function deleteConversation( + slug: string, + convId: string +): Promise<{ ok: boolean }> { + return http.del<{ ok: boolean }>(CONVERSATION_ENDPOINTS.remove(slug, convId)) +} + +/** 推送日志列表(status/conversation_id 筛选 + 分页;不回大 payload) */ +export function fetchPushLogs(slug: string, params?: PushLogParams): Promise { + return http.get(CONVERSATION_ENDPOINTS.pushLogs(slug), { params }) +} diff --git a/admin/src/api/mask.ts b/admin/src/api/mask.ts new file mode 100644 index 0000000..60efdfc --- /dev/null +++ b/admin/src/api/mask.ts @@ -0,0 +1,44 @@ +/** + * 掩码哨兵辅助(Phase 3 Task 3) + * + * 契约对齐后端 app/platform/routes/config_routes.py: + * - 密钥字段 GET 响应:库中非空 → `__SET__`(MASKED 哨兵);空/缺省 → 原样 + * - PUT 提交:密钥字段传 `__SET__` = 保持库值(后端还原,哨兵字面量绝不落库) + * + * 本模块提供哨兵 ↔ 表单语义的纯函数转换,配置中心(Task 7)与 + * 平台设置(Task 9)的凭据表单消费: + * - secretPlaceholder:GET 掩码值 → 输入框占位文案("已设置,输入新值覆盖") + * - secretToSubmit:用户输入 + 原掩码状态 → 提交值(未改动回哨兵保持库值) + */ + +/** 后端密钥掩码哨兵(config_routes.MASKED) */ +export const MASKED_SENTINEL = '__SET__' + +/** 表单侧"已设置"展示语义 */ +export const MASKED_DISPLAY = '已设置' + +/** 值是否为掩码哨兵 */ +export function isMaskedSentinel(value: unknown): value is typeof MASKED_SENTINEL { + return value === MASKED_SENTINEL +} + +/** + * GET 掩码值 → 表单占位文案 + * + * @param maskedValue 后端下发的密钥字段值(哨兵=已设置;空串/null/undefined=未设置) + */ +export function secretPlaceholder(maskedValue: string | null | undefined): string { + return isMaskedSentinel(maskedValue) ? '已设置,输入新值覆盖' : '未设置,输入新值' +} + +/** + * 表单输入 → 提交值 + * + * @param input 用户输入(空串=未改动/清空) + * @param originalMasked GET 时的掩码值(哨兵=库中已有值;否则=未设置) + * @returns 提交值:输入非空 → 新值;输入空且已设置 → 哨兵(保持库值);输入空且未设置 → 空串 + */ +export function secretToSubmit(input: string, originalMasked: string | null | undefined): string { + if (input !== '') return input + return isMaskedSentinel(originalMasked) ? MASKED_SENTINEL : '' +} diff --git a/admin/src/api/settings.ts b/admin/src/api/settings.ts new file mode 100644 index 0000000..625705b --- /dev/null +++ b/admin/src/api/settings.ts @@ -0,0 +1,70 @@ +/** + * 平台设置 API(Phase 3 Task 3 骨架;Task 9 实装调用函数) + * + * 端点事实源:app/platform/routes/config_routes.py 的 settings_router + * (/api/platform/settings,仅超管:集团 AI 凭据 / 默认 SMTP 等)。 + * + * 形态(GET 列表 / PUT 单键 upsert——与 config 组的"整组替换"不同, + * 平台设置是 KV 表,逐键写): + * - GET → {items: [{key, value}]}(按 key 升序;value 任意 JSON—— + * 后端 Text 列 json.dumps 直存,读回还原类型) + * - PUT → body {key, value}:同 key 再写 = 整值替换;响应 {key, value} + * (掩码视图) + * + * 密钥掩码:key 名含 key/password/secret 的非空字符串值在响应中 + * 掩码为 '__SET__'(嵌套 dict 递归);PUT 提交 '__SET__' = 保持库值。 + * 表单语义转换见 src/api/mask.ts。 + * + * 注意(CLAUDE.md Phase 3 取舍):resolver 的平台默认回退链**尚未**接入 + * platform_settings——本端点现阶段只做存储与展示,引擎不读取这些值。 + */ + +import { http, type RequestOptions } from './client' + +/** 平台设置项(列表响应行;value 可能是掩码哨兵或任意 JSON) */ +export interface PlatformSettingItem { + key: string + value: unknown +} + +/** 平台设置列表响应 */ +export interface PlatformSettingsResponse { + items: PlatformSettingItem[] +} + +/** upsert 单个设置项请求(同 key 再写 = 整值替换) */ +export interface PutPlatformSettingRequest { + key: string + value: unknown +} + +/** 平台设置端点 */ +export const SETTINGS_ENDPOINTS = { + list: '/api/platform/settings', + put: '/api/platform/settings' +} as const + +/** + * 平台设置列表(GET /api/platform/settings,仅超管):按 key 升序的 + * 全量 KV。value 为掩码视图——密钥键(key 名含 key/password/secret) + * 的非空字符串值已是 '__SET__' 哨兵,调用方经 model 层的 normalize + * 转表单态、经 formatSettingValue 做展示,绝不久存原文。 + */ +export function fetchPlatformSettings( + config?: RequestOptions +): Promise { + return http.get(SETTINGS_ENDPOINTS.list, config) +} + +/** + * upsert 单个设置项(PUT /api/platform/settings):同 key 再写 = 整值 + * 替换(无部分合并)。密钥键提交 '__SET__' 哨兵 = 保持库值(后端还原, + * 哨兵字面量绝不落库)。响应 {key, value} 为掩码视图。 + * 本页按"仅提交脏键"调用——未改动 的键不发请求。 + */ +export function putPlatformSetting( + body: PutPlatformSettingRequest, + config?: RequestOptions +): Promise { + return http.put(SETTINGS_ENDPOINTS.put, body, config) +} diff --git a/admin/src/api/stats.ts b/admin/src/api/stats.ts new file mode 100644 index 0000000..43aeb63 --- /dev/null +++ b/admin/src/api/stats.ts @@ -0,0 +1,99 @@ +/** + * 统计分析 API(Phase 3 Task 4 实装调用函数;类型与端点 Task 3 建立) + * + * 端点事实源:app/platform/routes/stats_routes.py + app/platform/stats.py + * (平台合并统计 / 单租户统计,?from/&to= 闭区间)。 + * + * 指标口径事实源:app/platform/stats.py 模块 docstring 的口径表—— + * 会话数不要求有消息 / 线索=留资三选一(phone/whatsapp/email)任一非空 / + * 高意向=评估 interested / 推送成功率 success/(success+failed)(分母 0 → + * null,前端展示"—")。Task 8(统计分析页)复用本组函数。 + */ +import { http } from './client' + +/** 指标卡(None 项为 null——如无推送时成功率无定义) */ +export interface StatsCards { + conversations: number + leads: number + high_intent: number + push_success_rate: number | null +} + +/** 漏斗计数 */ +export interface StatsFunnel { + conversations: number + leads: number + evaluated: number + push_succeeded: number +} + +/** 按日趋势行 */ +export interface StatsTrendPoint { + date: string + conversations: number + leads: number +} + +/** 分布视图(intent 按枚举键、source/site 计数表、country Top 数组) */ +export interface StatsDistributions { + intent: Record + source: Record + site: Record + country: Array<{ country: string; count: number }> +} + +/** 推送健康计数 */ +export interface PushHealth { + pending: number + success: number + failed: number + skipped: number + duplicated: number +} + +/** 统计响应(平台合并与单租户同构) */ +export interface StatsResponse { + cards: StatsCards + funnel: StatsFunnel + trend: StatsTrendPoint[] + distributions: StatsDistributions + push_health: PushHealth +} + +/** 统计查询参数(from/to 闭区间,ISO 日期) */ +export interface StatsParams { + from?: string + to?: string +} + +/** 统计端点(platform 合并版仅超管;单租户版超管或本租户管理员) */ +export const STATS_ENDPOINTS = { + platform: '/api/platform/stats', + tenant: (slug: string) => `/api/platform/tenants/${slug}/stats` +} as const + +/** 平台合并统计查询参数(?tenant= 单租户下钻,未知 slug 404) */ +export interface PlatformStatsParams extends StatsParams { + tenant?: string +} + +/** + * 平台统计(仅超管):缺省全部租户合并(逐库查询应用层合并); + * tenant=slug 单租户下钻——仪表盘租户卡片墙逐租户调用即此形态。 + * options.showErrorMessage: false 关全局错误提示(卡片墙 N 连发的失败 + * toast 改由调用方逐卡内联展示) + */ +export function fetchPlatformStats( + params?: PlatformStatsParams, + options?: { showErrorMessage?: boolean } +): Promise { + return http.get(STATS_ENDPOINTS.platform, { params, ...options }) +} + +/** + * 单租户统计(本租户管理员或超管):租户管理员的"自己指标卡"走这里, + * slug 取登录态 tenant_slug(auth store) + */ +export function fetchTenantStats(slug: string, params?: StatsParams): Promise { + return http.get(STATS_ENDPOINTS.tenant(slug), { params }) +} diff --git a/admin/src/api/tenants.ts b/admin/src/api/tenants.ts new file mode 100644 index 0000000..e034dbc --- /dev/null +++ b/admin/src/api/tenants.ts @@ -0,0 +1,218 @@ +/** + * 租户与站点管理 API(Phase 3 Task 3 骨架;Task 4 列表函数;Task 6 实装全部调用函数) + * + * 端点事实源:app/platform/routes/tenant_routes.py + * (/api/platform/tenants,require_super / require_tenant(slug) 鉴权)。 + * + * 错误语义(页面交互消费): + * - 400 预检撞车带中文 detail:slug 已存在 / 域名已绑定(含归属租户与站点)/ + * 出厂 push.prefix 被占(引导改 blank 或先改占用方)——向导与绑域名 + * 弹窗用 showErrorMessage:false 关全局 toast、改内联常驻展示; + * - 409 并发竞态(提交瞬间被抢先); + * - PATCH 带 slug 键一律 400(slug 不可变——界面不提供改 slug 入口)。 + */ +import { http, type RequestOptions } from './client' + +/** 租户状态(Tenant.status) */ +export type TenantStatus = 'active' | 'disabled' + +/** 站点概要(_site_summary:slug/name/status/domains/brand_overlay) */ +export interface SiteSummary { + slug: string + name: string + status: string + domains: string[] + brand_overlay: Record +} + +/** 管理员概要(_admin_summary:不含任何凭据字段) */ +export interface TenantAdminSummary { + username: string + status: string +} + +/** 租户列表行(GET /api/platform/tenants) */ +export interface TenantListItem { + slug: string + name: string + status: TenantStatus + domain_count: number + current_version: number | null + admins: TenantAdminSummary[] +} + +/** 租户列表响应 */ +export interface TenantListResponse { + items: TenantListItem[] +} + +/** 租户详情响应(GET /api/platform/tenants/{slug}) */ +export interface TenantDetailResponse { + slug: string + name: string + status: TenantStatus + sites: SiteSummary[] + admins: TenantAdminSummary[] + current_version: number | null +} + +/** 创建租户请求(POST /api/platform/tenants,201) */ +export interface CreateTenantRequest { + name: string + slug: string + /** 出厂档案:auto | jiancai | blank */ + profile: string + admin_username: string + admin_password: string + domains: string[] +} + +/** 改名/改状态请求(PATCH /api/platform/tenants/{slug}) */ +export interface PatchTenantRequest { + name?: string + status?: TenantStatus +} + +/** 创建站点请求(POST /api/platform/tenants/{slug}/sites,201) */ +export interface CreateSiteRequest { + slug: string + name?: string + brand_overlay?: Record + domains?: string[] +} + +/** 创建租户响应(create_tenant:201 返回概要;管理员凭据绝不回传) */ +export interface TenantCreatedResponse { + slug: string + name: string + status: TenantStatus + profile: string + version: number + site: { slug: string; domains: string[] } + admin_username: string +} + +/** 改名/改状态响应(patch_tenant / patch_site 返回形状) */ +export interface TenantPatchResponse { + slug: string + name: string + status: TenantStatus +} + +/** 改站点请求(PATCH .../sites/{siteSlug};slug 不可变——后端 400) */ +export interface PatchSiteRequest { + name?: string + status?: TenantStatus + brand_overlay?: Record +} + +/** 绑定域名请求(POST .../sites/{siteSlug}/domains,201;400=撞车带归属) */ +export interface AddDomainRequest { + domain: string +} + +/** 绑定域名响应 */ +export interface DomainBindResponse { + domain: string + site: string +} + +/** 解绑域名响应 */ +export interface DomainUnbindResponse { + ok: boolean + domain: string + site: string +} + +/** 租户管理端点(鉴权与 409 并发冲突语义见 tenant_routes.py) */ +export const TENANT_ENDPOINTS = { + list: '/api/platform/tenants', + create: '/api/platform/tenants', + detail: (slug: string) => `/api/platform/tenants/${slug}`, + patch: (slug: string) => `/api/platform/tenants/${slug}`, + createSite: (slug: string) => `/api/platform/tenants/${slug}/sites`, + patchSite: (slug: string, siteSlug: string) => `/api/platform/tenants/${slug}/sites/${siteSlug}`, + addDomain: (slug: string, siteSlug: string) => + `/api/platform/tenants/${slug}/sites/${siteSlug}/domains`, + deleteDomain: (slug: string, siteSlug: string, domain: string) => + `/api/platform/tenants/${slug}/sites/${siteSlug}/domains/${domain}` +} as const + +/** + * 租户列表(仅超管)——Task 4 仪表盘的租户卡片墙需要枚举租户; + * 列表行的 slug/name/status 供卡片展示与逐租户统计下钻 + */ +export function fetchTenantList(): Promise { + return http.get(TENANT_ENDPOINTS.list) +} + +/** 租户详情(含 sites/domains/brand_overlay/admins;本租户管理员亦可读) */ +export function fetchTenantDetail( + slug: string, + config?: RequestOptions +): Promise { + return http.get(TENANT_ENDPOINTS.detail(slug), config) +} + +/** + * 创建租户(创建向导提交;201)。向导调用建议传 + * `{ showErrorMessage: false }`——400 预检撞车 detail(slug 已存在 / + * 出厂 push.prefix 被占的改 blank 引导)在向导内联常驻展示。 + */ +export function createTenant( + body: CreateTenantRequest, + config?: RequestOptions +): Promise { + return http.post(TENANT_ENDPOINTS.create, body, config) +} + +/** 改名 / 停用 / 复启用(PATCH;slug 不可变,body 不含 slug 键) */ +export function patchTenant( + slug: string, + body: PatchTenantRequest, + config?: RequestOptions +): Promise { + return http.patch(TENANT_ENDPOINTS.patch(slug), body, config) +} + +/** 新建站点(brand_overlay 只放显式覆盖的字段——留空=继承租户默认) */ +export function createSite( + slug: string, + body: CreateSiteRequest, + config?: RequestOptions +): Promise { + return http.post(TENANT_ENDPOINTS.createSite(slug), body, config) +} + +/** 改站点(停用/复启用/改名/改覆盖层;整字典赋值——旧键不残留) */ +export function patchSite( + slug: string, + siteSlug: string, + body: PatchSiteRequest, + config?: RequestOptions +): Promise { + return http.patch(TENANT_ENDPOINTS.patchSite(slug, siteSlug), body, config) +} + +/** 绑定域名(全局唯一;400 detail 带现有归属"租户 x 的站点 y") */ +export function addSiteDomain( + slug: string, + siteSlug: string, + body: AddDomainRequest, + config?: RequestOptions +): Promise { + return http.post(TENANT_ENDPOINTS.addDomain(slug, siteSlug), body, config) +} + +/** 解绑域名(只解本站点的该域名;不存在 404) */ +export function deleteSiteDomain( + slug: string, + siteSlug: string, + domain: string, + config?: RequestOptions +): Promise { + return http.del( + TENANT_ENDPOINTS.deleteDomain(slug, siteSlug, domain), + config + ) +} diff --git a/admin/src/assets/images/3d/icon1.webp b/admin/src/assets/images/3d/icon1.webp new file mode 100644 index 0000000..631094c Binary files /dev/null and b/admin/src/assets/images/3d/icon1.webp differ diff --git a/admin/src/assets/images/3d/icon2.webp b/admin/src/assets/images/3d/icon2.webp new file mode 100644 index 0000000..a9e4742 Binary files /dev/null and b/admin/src/assets/images/3d/icon2.webp differ diff --git a/admin/src/assets/images/3d/icon3.webp b/admin/src/assets/images/3d/icon3.webp new file mode 100644 index 0000000..5e5994d Binary files /dev/null and b/admin/src/assets/images/3d/icon3.webp differ diff --git a/admin/src/assets/images/3d/icon4.webp b/admin/src/assets/images/3d/icon4.webp new file mode 100644 index 0000000..522108f Binary files /dev/null and b/admin/src/assets/images/3d/icon4.webp differ diff --git a/admin/src/assets/images/3d/icon5.webp b/admin/src/assets/images/3d/icon5.webp new file mode 100644 index 0000000..b8f794d Binary files /dev/null and b/admin/src/assets/images/3d/icon5.webp differ diff --git a/admin/src/assets/images/3d/icon6.webp b/admin/src/assets/images/3d/icon6.webp new file mode 100644 index 0000000..c955494 Binary files /dev/null and b/admin/src/assets/images/3d/icon6.webp differ diff --git a/admin/src/assets/images/3d/icon7.webp b/admin/src/assets/images/3d/icon7.webp new file mode 100644 index 0000000..8aa26ee Binary files /dev/null and b/admin/src/assets/images/3d/icon7.webp differ diff --git a/admin/src/assets/images/3d/icon8.webp b/admin/src/assets/images/3d/icon8.webp new file mode 100644 index 0000000..609c4ee Binary files /dev/null and b/admin/src/assets/images/3d/icon8.webp differ diff --git a/admin/src/assets/images/avatar/avatar.webp b/admin/src/assets/images/avatar/avatar.webp new file mode 100644 index 0000000..bea307b Binary files /dev/null and b/admin/src/assets/images/avatar/avatar.webp differ diff --git a/admin/src/assets/images/avatar/avatar1.webp b/admin/src/assets/images/avatar/avatar1.webp new file mode 100644 index 0000000..68e256c Binary files /dev/null and b/admin/src/assets/images/avatar/avatar1.webp differ diff --git a/admin/src/assets/images/avatar/avatar10.webp b/admin/src/assets/images/avatar/avatar10.webp new file mode 100644 index 0000000..a813d4c Binary files /dev/null and b/admin/src/assets/images/avatar/avatar10.webp differ diff --git a/admin/src/assets/images/avatar/avatar2.webp b/admin/src/assets/images/avatar/avatar2.webp new file mode 100644 index 0000000..6716e3f Binary files /dev/null and b/admin/src/assets/images/avatar/avatar2.webp differ diff --git a/admin/src/assets/images/avatar/avatar3.webp b/admin/src/assets/images/avatar/avatar3.webp new file mode 100644 index 0000000..7355ad4 Binary files /dev/null and b/admin/src/assets/images/avatar/avatar3.webp differ diff --git a/admin/src/assets/images/avatar/avatar4.webp b/admin/src/assets/images/avatar/avatar4.webp new file mode 100644 index 0000000..56a9549 Binary files /dev/null and b/admin/src/assets/images/avatar/avatar4.webp differ diff --git a/admin/src/assets/images/avatar/avatar5.webp b/admin/src/assets/images/avatar/avatar5.webp new file mode 100644 index 0000000..f78400c Binary files /dev/null and b/admin/src/assets/images/avatar/avatar5.webp differ diff --git a/admin/src/assets/images/avatar/avatar6.webp b/admin/src/assets/images/avatar/avatar6.webp new file mode 100644 index 0000000..9771b78 Binary files /dev/null and b/admin/src/assets/images/avatar/avatar6.webp differ diff --git a/admin/src/assets/images/avatar/avatar7.webp b/admin/src/assets/images/avatar/avatar7.webp new file mode 100644 index 0000000..e5ef6fe Binary files /dev/null and b/admin/src/assets/images/avatar/avatar7.webp differ diff --git a/admin/src/assets/images/avatar/avatar8.webp b/admin/src/assets/images/avatar/avatar8.webp new file mode 100644 index 0000000..b66e48f Binary files /dev/null and b/admin/src/assets/images/avatar/avatar8.webp differ diff --git a/admin/src/assets/images/avatar/avatar9.webp b/admin/src/assets/images/avatar/avatar9.webp new file mode 100644 index 0000000..7974139 Binary files /dev/null and b/admin/src/assets/images/avatar/avatar9.webp differ diff --git a/admin/src/assets/images/ceremony/hb.png b/admin/src/assets/images/ceremony/hb.png new file mode 100644 index 0000000..4103324 Binary files /dev/null and b/admin/src/assets/images/ceremony/hb.png differ diff --git a/admin/src/assets/images/ceremony/sd.png b/admin/src/assets/images/ceremony/sd.png new file mode 100644 index 0000000..75ec838 Binary files /dev/null and b/admin/src/assets/images/ceremony/sd.png differ diff --git a/admin/src/assets/images/ceremony/xc.png b/admin/src/assets/images/ceremony/xc.png new file mode 100644 index 0000000..9c7ab67 Binary files /dev/null and b/admin/src/assets/images/ceremony/xc.png differ diff --git a/admin/src/assets/images/ceremony/yd.png b/admin/src/assets/images/ceremony/yd.png new file mode 100644 index 0000000..426912d Binary files /dev/null and b/admin/src/assets/images/ceremony/yd.png differ diff --git a/admin/src/assets/images/common/logo-large.png b/admin/src/assets/images/common/logo-large.png new file mode 100644 index 0000000..29a6a42 Binary files /dev/null and b/admin/src/assets/images/common/logo-large.png differ diff --git a/admin/src/assets/images/common/logo.png b/admin/src/assets/images/common/logo.png new file mode 100644 index 0000000..11cba74 Binary files /dev/null and b/admin/src/assets/images/common/logo.png differ diff --git a/admin/src/assets/images/cover/img1.webp b/admin/src/assets/images/cover/img1.webp new file mode 100644 index 0000000..ebcd0c5 Binary files /dev/null and b/admin/src/assets/images/cover/img1.webp differ diff --git a/admin/src/assets/images/cover/img10.webp b/admin/src/assets/images/cover/img10.webp new file mode 100644 index 0000000..c6f53e3 Binary files /dev/null and b/admin/src/assets/images/cover/img10.webp differ diff --git a/admin/src/assets/images/cover/img2.webp b/admin/src/assets/images/cover/img2.webp new file mode 100644 index 0000000..747580f Binary files /dev/null and b/admin/src/assets/images/cover/img2.webp differ diff --git a/admin/src/assets/images/cover/img3.webp b/admin/src/assets/images/cover/img3.webp new file mode 100644 index 0000000..4fbe638 Binary files /dev/null and b/admin/src/assets/images/cover/img3.webp differ diff --git a/admin/src/assets/images/cover/img4.webp b/admin/src/assets/images/cover/img4.webp new file mode 100644 index 0000000..2dd1139 Binary files /dev/null and b/admin/src/assets/images/cover/img4.webp differ diff --git a/admin/src/assets/images/cover/img5.webp b/admin/src/assets/images/cover/img5.webp new file mode 100644 index 0000000..392f0bd Binary files /dev/null and b/admin/src/assets/images/cover/img5.webp differ diff --git a/admin/src/assets/images/cover/img6.webp b/admin/src/assets/images/cover/img6.webp new file mode 100644 index 0000000..bf3537a Binary files /dev/null and b/admin/src/assets/images/cover/img6.webp differ diff --git a/admin/src/assets/images/cover/img7.webp b/admin/src/assets/images/cover/img7.webp new file mode 100644 index 0000000..bebbade Binary files /dev/null and b/admin/src/assets/images/cover/img7.webp differ diff --git a/admin/src/assets/images/cover/img8.webp b/admin/src/assets/images/cover/img8.webp new file mode 100644 index 0000000..0b38bfc Binary files /dev/null and b/admin/src/assets/images/cover/img8.webp differ diff --git a/admin/src/assets/images/cover/img9.webp b/admin/src/assets/images/cover/img9.webp new file mode 100644 index 0000000..b87cca6 Binary files /dev/null and b/admin/src/assets/images/cover/img9.webp differ diff --git a/admin/src/assets/images/draw/draw1.png b/admin/src/assets/images/draw/draw1.png new file mode 100644 index 0000000..da5d87a Binary files /dev/null and b/admin/src/assets/images/draw/draw1.png differ diff --git a/admin/src/assets/images/lock/bg_dark.webp b/admin/src/assets/images/lock/bg_dark.webp new file mode 100644 index 0000000..1c33435 Binary files /dev/null and b/admin/src/assets/images/lock/bg_dark.webp differ diff --git a/admin/src/assets/images/lock/bg_light.webp b/admin/src/assets/images/lock/bg_light.webp new file mode 100644 index 0000000..00efbd9 Binary files /dev/null and b/admin/src/assets/images/lock/bg_light.webp differ diff --git a/admin/src/assets/images/login/lf_icon2.webp b/admin/src/assets/images/login/lf_icon2.webp new file mode 100644 index 0000000..5e4f3fd Binary files /dev/null and b/admin/src/assets/images/login/lf_icon2.webp differ diff --git a/admin/src/assets/images/safeguard/server.png b/admin/src/assets/images/safeguard/server.png new file mode 100644 index 0000000..d7eee9d Binary files /dev/null and b/admin/src/assets/images/safeguard/server.png differ diff --git a/admin/src/assets/images/settings/menu_layouts/dual_column.png b/admin/src/assets/images/settings/menu_layouts/dual_column.png new file mode 100644 index 0000000..9b868ca Binary files /dev/null and b/admin/src/assets/images/settings/menu_layouts/dual_column.png differ diff --git a/admin/src/assets/images/settings/menu_layouts/horizontal.png b/admin/src/assets/images/settings/menu_layouts/horizontal.png new file mode 100644 index 0000000..ca779bc Binary files /dev/null and b/admin/src/assets/images/settings/menu_layouts/horizontal.png differ diff --git a/admin/src/assets/images/settings/menu_layouts/mixed.png b/admin/src/assets/images/settings/menu_layouts/mixed.png new file mode 100644 index 0000000..c82b580 Binary files /dev/null and b/admin/src/assets/images/settings/menu_layouts/mixed.png differ diff --git a/admin/src/assets/images/settings/menu_layouts/vertical.png b/admin/src/assets/images/settings/menu_layouts/vertical.png new file mode 100644 index 0000000..16e942b Binary files /dev/null and b/admin/src/assets/images/settings/menu_layouts/vertical.png differ diff --git a/admin/src/assets/images/settings/menu_styles/dark.png b/admin/src/assets/images/settings/menu_styles/dark.png new file mode 100644 index 0000000..e1653b7 Binary files /dev/null and b/admin/src/assets/images/settings/menu_styles/dark.png differ diff --git a/admin/src/assets/images/settings/menu_styles/design.png b/admin/src/assets/images/settings/menu_styles/design.png new file mode 100644 index 0000000..7681aa8 Binary files /dev/null and b/admin/src/assets/images/settings/menu_styles/design.png differ diff --git a/admin/src/assets/images/settings/menu_styles/light.png b/admin/src/assets/images/settings/menu_styles/light.png new file mode 100644 index 0000000..3007b99 Binary files /dev/null and b/admin/src/assets/images/settings/menu_styles/light.png differ diff --git a/admin/src/assets/images/settings/theme_styles/dark.png b/admin/src/assets/images/settings/theme_styles/dark.png new file mode 100644 index 0000000..e8c6e44 Binary files /dev/null and b/admin/src/assets/images/settings/theme_styles/dark.png differ diff --git a/admin/src/assets/images/settings/theme_styles/light.png b/admin/src/assets/images/settings/theme_styles/light.png new file mode 100644 index 0000000..6754238 Binary files /dev/null and b/admin/src/assets/images/settings/theme_styles/light.png differ diff --git a/admin/src/assets/images/settings/theme_styles/system.png b/admin/src/assets/images/settings/theme_styles/system.png new file mode 100644 index 0000000..6a6baa9 Binary files /dev/null and b/admin/src/assets/images/settings/theme_styles/system.png differ diff --git a/admin/src/assets/images/svg/403.svg b/admin/src/assets/images/svg/403.svg new file mode 100644 index 0000000..68790ad --- /dev/null +++ b/admin/src/assets/images/svg/403.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/src/assets/images/svg/404.svg b/admin/src/assets/images/svg/404.svg new file mode 100644 index 0000000..48e1ca3 --- /dev/null +++ b/admin/src/assets/images/svg/404.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/src/assets/images/svg/500.svg b/admin/src/assets/images/svg/500.svg new file mode 100644 index 0000000..512429f --- /dev/null +++ b/admin/src/assets/images/svg/500.svg @@ -0,0 +1,5 @@ + diff --git a/admin/src/assets/images/svg/login_icon.svg b/admin/src/assets/images/svg/login_icon.svg new file mode 100644 index 0000000..4beb3ab --- /dev/null +++ b/admin/src/assets/images/svg/login_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin/src/assets/images/user/avatar.webp b/admin/src/assets/images/user/avatar.webp new file mode 100644 index 0000000..6d7234b Binary files /dev/null and b/admin/src/assets/images/user/avatar.webp differ diff --git a/admin/src/assets/images/user/bg.webp b/admin/src/assets/images/user/bg.webp new file mode 100644 index 0000000..762b22d Binary files /dev/null and b/admin/src/assets/images/user/bg.webp differ diff --git a/admin/src/assets/styles/core/app.scss b/admin/src/assets/styles/core/app.scss new file mode 100644 index 0000000..c0efeed --- /dev/null +++ b/admin/src/assets/styles/core/app.scss @@ -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; + } +} diff --git a/admin/src/assets/styles/core/dark.scss b/admin/src/assets/styles/core/dark.scss new file mode 100644 index 0000000..c52abc3 --- /dev/null +++ b/admin/src/assets/styles/core/dark.scss @@ -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); + } + } +} diff --git a/admin/src/assets/styles/core/el-dark.scss b/admin/src/assets/styles/core/el-dark.scss new file mode 100644 index 0000000..8f81cdf --- /dev/null +++ b/admin/src/assets/styles/core/el-dark.scss @@ -0,0 +1,2 @@ +// 导入暗黑主题 +@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *; diff --git a/admin/src/assets/styles/core/el-light.scss b/admin/src/assets/styles/core/el-light.scss new file mode 100644 index 0000000..ddf2bc5 --- /dev/null +++ b/admin/src/assets/styles/core/el-light.scss @@ -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' + ) +); diff --git a/admin/src/assets/styles/core/el-ui.scss b/admin/src/assets/styles/core/el-ui.scss new file mode 100644 index 0000000..44429a1 --- /dev/null +++ b/admin/src/assets/styles/core/el-ui.scss @@ -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; + } +} diff --git a/admin/src/assets/styles/core/md.scss b/admin/src/assets/styles/core/md.scss new file mode 100644 index 0000000..b22fdc2 --- /dev/null +++ b/admin/src/assets/styles/core/md.scss @@ -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; +} diff --git a/admin/src/assets/styles/core/mixin.scss b/admin/src/assets/styles/core/mixin.scss new file mode 100644 index 0000000..db36888 --- /dev/null +++ b/admin/src/assets/styles/core/mixin.scss @@ -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); +} diff --git a/admin/src/assets/styles/core/reset.scss b/admin/src/assets/styles/core/reset.scss new file mode 100644 index 0000000..17a3bcf --- /dev/null +++ b/admin/src/assets/styles/core/reset.scss @@ -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; + } +} diff --git a/admin/src/assets/styles/core/router-transition.scss b/admin/src/assets/styles/core/router-transition.scss new file mode 100644 index 0000000..f47c741 --- /dev/null +++ b/admin/src/assets/styles/core/router-transition.scss @@ -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'); +} diff --git a/admin/src/assets/styles/core/tailwind.css b/admin/src/assets/styles/core/tailwind.css new file mode 100644 index 0000000..1a9e22c --- /dev/null +++ b/admin/src/assets/styles/core/tailwind.css @@ -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; + } + } + } + } +} diff --git a/admin/src/assets/styles/core/theme-animation.scss b/admin/src/assets/styles/core/theme-animation.scss new file mode 100644 index 0000000..377b945 --- /dev/null +++ b/admin/src/assets/styles/core/theme-animation.scss @@ -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); +} diff --git a/admin/src/assets/styles/core/theme-change.scss b/admin/src/assets/styles/core/theme-change.scss new file mode 100644 index 0000000..5b640d2 --- /dev/null +++ b/admin/src/assets/styles/core/theme-change.scss @@ -0,0 +1,11 @@ +// 主题切换过渡优化,优化除视觉上的不适感 +.theme-change { + * { + transition: 0s !important; + } + + .el-switch__core, + .el-switch__action { + transition: all 0.3s !important; + } +} diff --git a/admin/src/assets/styles/custom/one-dark-pro.scss b/admin/src/assets/styles/custom/one-dark-pro.scss new file mode 100644 index 0000000..36bdf63 --- /dev/null +++ b/admin/src/assets/styles/custom/one-dark-pro.scss @@ -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; +} diff --git a/admin/src/assets/styles/index.scss b/admin/src/assets/styles/index.scss new file mode 100644 index 0000000..cdc2ddc --- /dev/null +++ b/admin/src/assets/styles/index.scss @@ -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'; diff --git a/admin/src/assets/svg/loading.ts b/admin/src/assets/svg/loading.ts new file mode 100644 index 0000000..fdfb078 --- /dev/null +++ b/admin/src/assets/svg/loading.ts @@ -0,0 +1,32 @@ +// 自定义四点旋转SVG +export const fourDotsSpinnerSvg = ` + + + + + + + + + +` diff --git a/admin/src/components/business/SiteOverlayFields/index.vue b/admin/src/components/business/SiteOverlayFields/index.vue new file mode 100644 index 0000000..20a9d00 --- /dev/null +++ b/admin/src/components/business/SiteOverlayFields/index.vue @@ -0,0 +1,76 @@ + + + + + + diff --git a/admin/src/components/business/SiteOverlayFields/model.ts b/admin/src/components/business/SiteOverlayFields/model.ts new file mode 100644 index 0000000..e7350c1 --- /dev/null +++ b/admin/src/components/business/SiteOverlayFields/model.ts @@ -0,0 +1,89 @@ +/** + * 站点门面覆盖层字段模型(Task 6 建立;Task 7a 提取共用) + * + * 消费方:站点管理页(tenants/list)的「新建站点」与「编辑门面」弹窗—— + * 两处共用同一字段清单 / 空表单 / 提交收集逻辑。 + * + * 语义(与后端 tenant_routes.patch_site 的整字典赋值对齐): + * - buildOverlay 只收集非空字段——留空 = 剔除该键的覆盖 = 继承租户默认 + * 品牌(brand 组);旧键不残留(整字典替换,M-2a)。 + * - overlay 值是 brand 组子集(站点级覆盖,public.py 的 site.brand 深合并)。 + */ + +/** 门面覆盖层字符串字段(brand 组子集) */ +export const OVERLAY_FIELDS = [ + { key: 'title', label: '标题 title' }, + { key: 'color', label: '主题色 color' }, + { key: 'avatar', label: '头像路径 avatar' }, + { key: 'welcome', label: '欢迎语 welcome' }, + { key: 'placeholder', label: '输入框占位 placeholder' }, + { key: 'whatsapp', label: 'WhatsApp' }, + { key: 'email', label: '邮箱 email' }, + { key: 'privacy_url', label: '隐私政策 URL' } +] as const + +/** 覆盖层字段 key 联合类型 */ +export type OverlayFieldKey = (typeof OVERLAY_FIELDS)[number]['key'] + +/** 覆盖层表单状态(字符串字段全空串语义 + topics 数组) */ +export interface OverlayFormState extends Record { + topics: string[] +} + +/** 覆盖层字段中文标签(站点表 tooltip 用;topics 单列) */ +export const OVERLAY_LABELS: Record = Object.fromEntries( + OVERLAY_FIELDS.map((f) => [f.key, f.label] as [string, string]).concat([ + ['topics', '快捷话题 topics'] + ]) +) + +/** 空表单(全留空 = 完全继承租户默认) */ +export function blankOverlayForm(): OverlayFormState { + return { + title: '', + color: '', + avatar: '', + welcome: '', + placeholder: '', + whatsapp: '', + email: '', + privacy_url: '', + topics: [] + } +} + +/** + * 表单 → brand_overlay 提交字典:只收集非空字段(留空 = 不覆盖)。 + * 全空表单产出 {} = 清空覆盖层(整字典赋值语义下回到完全继承)。 + */ +export function buildOverlay(form: OverlayFormState): Record { + const overlay: Record = {} + for (const field of OVERLAY_FIELDS) { + const value = form[field.key].trim() + if (value) overlay[field.key] = value + } + const topics = form.topics.map((t) => t.trim()).filter(Boolean) + if (topics.length > 0) overlay.topics = topics + return overlay +} + +/** + * overlay 落库值 → 表单预填(编辑门面用):只认已知键的字符串值; + * 返回表单之外未能识别的键(保存时会被整字典替换丢弃,调用方提示用户)。 + */ +export function overlayToForm( + overlay: Record | null | undefined +): { form: OverlayFormState; unknownKeys: string[] } { + const form = blankOverlayForm() + const source = overlay ?? {} + for (const field of OVERLAY_FIELDS) { + const value = source[field.key] + if (typeof value === 'string') form[field.key] = value + } + if (Array.isArray(source.topics)) { + form.topics = source.topics.filter((t): t is string => typeof t === 'string') + } + const known = new Set([...OVERLAY_FIELDS.map((f) => f.key), 'topics']) + const unknownKeys = Object.keys(source).filter((k) => !known.has(k)) + return { form, unknownKeys } +} diff --git a/admin/src/components/core/banners/art-basic-banner/index.vue b/admin/src/components/core/banners/art-basic-banner/index.vue new file mode 100644 index 0000000..65b47e4 --- /dev/null +++ b/admin/src/components/core/banners/art-basic-banner/index.vue @@ -0,0 +1,343 @@ + + + + + + diff --git a/admin/src/components/core/banners/art-card-banner/index.vue b/admin/src/components/core/banners/art-card-banner/index.vue new file mode 100644 index 0000000..8a5f9d4 --- /dev/null +++ b/admin/src/components/core/banners/art-card-banner/index.vue @@ -0,0 +1,114 @@ + + + + diff --git a/admin/src/components/core/base/art-back-to-top/index.vue b/admin/src/components/core/base/art-back-to-top/index.vue new file mode 100644 index 0000000..6f8da61 --- /dev/null +++ b/admin/src/components/core/base/art-back-to-top/index.vue @@ -0,0 +1,40 @@ + + + + diff --git a/admin/src/components/core/base/art-logo/index.vue b/admin/src/components/core/base/art-logo/index.vue new file mode 100644 index 0000000..d5a4375 --- /dev/null +++ b/admin/src/components/core/base/art-logo/index.vue @@ -0,0 +1,52 @@ + + + + + + diff --git a/admin/src/components/core/base/art-svg-icon/index.vue b/admin/src/components/core/base/art-svg-icon/index.vue new file mode 100644 index 0000000..0bfcd0c --- /dev/null +++ b/admin/src/components/core/base/art-svg-icon/index.vue @@ -0,0 +1,24 @@ + + + + diff --git a/admin/src/components/core/cards/art-bar-chart-card/index.vue b/admin/src/components/core/cards/art-bar-chart-card/index.vue new file mode 100644 index 0000000..6815c2b --- /dev/null +++ b/admin/src/components/core/cards/art-bar-chart-card/index.vue @@ -0,0 +1,103 @@ + + + + diff --git a/admin/src/components/core/cards/art-data-list-card/index.vue b/admin/src/components/core/cards/art-data-list-card/index.vue new file mode 100644 index 0000000..fc43323 --- /dev/null +++ b/admin/src/components/core/cards/art-data-list-card/index.vue @@ -0,0 +1,74 @@ + + + + diff --git a/admin/src/components/core/cards/art-donut-chart-card/index.vue b/admin/src/components/core/cards/art-donut-chart-card/index.vue new file mode 100644 index 0000000..df2dcbb --- /dev/null +++ b/admin/src/components/core/cards/art-donut-chart-card/index.vue @@ -0,0 +1,124 @@ + + + + diff --git a/admin/src/components/core/cards/art-image-card/index.vue b/admin/src/components/core/cards/art-image-card/index.vue new file mode 100644 index 0000000..d27fe00 --- /dev/null +++ b/admin/src/components/core/cards/art-image-card/index.vue @@ -0,0 +1,89 @@ + + + + diff --git a/admin/src/components/core/cards/art-line-chart-card/index.vue b/admin/src/components/core/cards/art-line-chart-card/index.vue new file mode 100644 index 0000000..e58c9b2 --- /dev/null +++ b/admin/src/components/core/cards/art-line-chart-card/index.vue @@ -0,0 +1,126 @@ + + + + diff --git a/admin/src/components/core/cards/art-progress-card/index.vue b/admin/src/components/core/cards/art-progress-card/index.vue new file mode 100644 index 0000000..048a836 --- /dev/null +++ b/admin/src/components/core/cards/art-progress-card/index.vue @@ -0,0 +1,86 @@ + + + + diff --git a/admin/src/components/core/cards/art-stats-card/index.vue b/admin/src/components/core/cards/art-stats-card/index.vue new file mode 100644 index 0000000..8e0341b --- /dev/null +++ b/admin/src/components/core/cards/art-stats-card/index.vue @@ -0,0 +1,67 @@ + + + + diff --git a/admin/src/components/core/cards/art-timeline-list-card/index.vue b/admin/src/components/core/cards/art-timeline-list-card/index.vue new file mode 100644 index 0000000..fbb2c78 --- /dev/null +++ b/admin/src/components/core/cards/art-timeline-list-card/index.vue @@ -0,0 +1,69 @@ + + + + diff --git a/admin/src/components/core/charts/art-bar-chart/index.vue b/admin/src/components/core/charts/art-bar-chart/index.vue new file mode 100644 index 0000000..d677196 --- /dev/null +++ b/admin/src/components/core/charts/art-bar-chart/index.vue @@ -0,0 +1,203 @@ + + + + diff --git a/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue b/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue new file mode 100644 index 0000000..32aa60f --- /dev/null +++ b/admin/src/components/core/charts/art-dual-bar-compare-chart/index.vue @@ -0,0 +1,195 @@ + + + + diff --git a/admin/src/components/core/charts/art-h-bar-chart/index.vue b/admin/src/components/core/charts/art-h-bar-chart/index.vue new file mode 100644 index 0000000..2e34759 --- /dev/null +++ b/admin/src/components/core/charts/art-h-bar-chart/index.vue @@ -0,0 +1,208 @@ + + + + diff --git a/admin/src/components/core/charts/art-k-line-chart/index.vue b/admin/src/components/core/charts/art-k-line-chart/index.vue new file mode 100644 index 0000000..0061b51 --- /dev/null +++ b/admin/src/components/core/charts/art-k-line-chart/index.vue @@ -0,0 +1,152 @@ + + + + diff --git a/admin/src/components/core/charts/art-line-chart/index.vue b/admin/src/components/core/charts/art-line-chart/index.vue new file mode 100644 index 0000000..b70c2c3 --- /dev/null +++ b/admin/src/components/core/charts/art-line-chart/index.vue @@ -0,0 +1,371 @@ + + + + diff --git a/admin/src/components/core/charts/art-radar-chart/index.vue b/admin/src/components/core/charts/art-radar-chart/index.vue new file mode 100644 index 0000000..e99fff6 --- /dev/null +++ b/admin/src/components/core/charts/art-radar-chart/index.vue @@ -0,0 +1,105 @@ + + + + diff --git a/admin/src/components/core/charts/art-ring-chart/index.vue b/admin/src/components/core/charts/art-ring-chart/index.vue new file mode 100644 index 0000000..79115f7 --- /dev/null +++ b/admin/src/components/core/charts/art-ring-chart/index.vue @@ -0,0 +1,133 @@ + + + + diff --git a/admin/src/components/core/charts/art-scatter-chart/index.vue b/admin/src/components/core/charts/art-scatter-chart/index.vue new file mode 100644 index 0000000..995b56a --- /dev/null +++ b/admin/src/components/core/charts/art-scatter-chart/index.vue @@ -0,0 +1,115 @@ + + + + diff --git a/admin/src/components/core/forms/art-button-more/index.vue b/admin/src/components/core/forms/art-button-more/index.vue new file mode 100644 index 0000000..858d305 --- /dev/null +++ b/admin/src/components/core/forms/art-button-more/index.vue @@ -0,0 +1,71 @@ + + + + diff --git a/admin/src/components/core/forms/art-button-table/index.vue b/admin/src/components/core/forms/art-button-table/index.vue new file mode 100644 index 0000000..c849901 --- /dev/null +++ b/admin/src/components/core/forms/art-button-table/index.vue @@ -0,0 +1,59 @@ + + + + diff --git a/admin/src/components/core/forms/art-drag-verify/index.vue b/admin/src/components/core/forms/art-drag-verify/index.vue new file mode 100644 index 0000000..5306e04 --- /dev/null +++ b/admin/src/components/core/forms/art-drag-verify/index.vue @@ -0,0 +1,430 @@ + + + + + + + + diff --git a/admin/src/components/core/forms/art-form/index.vue b/admin/src/components/core/forms/art-form/index.vue new file mode 100644 index 0000000..561e878 --- /dev/null +++ b/admin/src/components/core/forms/art-form/index.vue @@ -0,0 +1,507 @@ + + + + + + diff --git a/admin/src/components/core/forms/art-search-bar/index.vue b/admin/src/components/core/forms/art-search-bar/index.vue new file mode 100644 index 0000000..6786dca --- /dev/null +++ b/admin/src/components/core/forms/art-search-bar/index.vue @@ -0,0 +1,581 @@ + + + + + + + + diff --git a/admin/src/components/core/layouts/art-breadcrumb/index.vue b/admin/src/components/core/layouts/art-breadcrumb/index.vue new file mode 100644 index 0000000..4b54859 --- /dev/null +++ b/admin/src/components/core/layouts/art-breadcrumb/index.vue @@ -0,0 +1,142 @@ + + + + diff --git a/admin/src/components/core/layouts/art-chat-window/index.vue b/admin/src/components/core/layouts/art-chat-window/index.vue new file mode 100644 index 0000000..f3d9471 --- /dev/null +++ b/admin/src/components/core/layouts/art-chat-window/index.vue @@ -0,0 +1,262 @@ + + + + diff --git a/admin/src/components/core/layouts/art-fast-enter/index.vue b/admin/src/components/core/layouts/art-fast-enter/index.vue new file mode 100644 index 0000000..fdde222 --- /dev/null +++ b/admin/src/components/core/layouts/art-fast-enter/index.vue @@ -0,0 +1,113 @@ + + + + diff --git a/admin/src/components/core/layouts/art-fireworks-effect/index.vue b/admin/src/components/core/layouts/art-fireworks-effect/index.vue new file mode 100644 index 0000000..be85274 --- /dev/null +++ b/admin/src/components/core/layouts/art-fireworks-effect/index.vue @@ -0,0 +1,633 @@ + + + + diff --git a/admin/src/components/core/layouts/art-global-component/index.vue b/admin/src/components/core/layouts/art-global-component/index.vue new file mode 100644 index 0000000..6908f94 --- /dev/null +++ b/admin/src/components/core/layouts/art-global-component/index.vue @@ -0,0 +1,14 @@ + + + + diff --git a/admin/src/components/core/layouts/art-global-search/index.vue b/admin/src/components/core/layouts/art-global-search/index.vue new file mode 100644 index 0000000..f2c0b88 --- /dev/null +++ b/admin/src/components/core/layouts/art-global-search/index.vue @@ -0,0 +1,431 @@ + + + + + + + diff --git a/admin/src/components/core/layouts/art-header-bar/index.vue b/admin/src/components/core/layouts/art-header-bar/index.vue new file mode 100644 index 0000000..4e3c8f9 --- /dev/null +++ b/admin/src/components/core/layouts/art-header-bar/index.vue @@ -0,0 +1,485 @@ + + + + + + diff --git a/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue b/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue new file mode 100644 index 0000000..40fdc39 --- /dev/null +++ b/admin/src/components/core/layouts/art-header-bar/widget/ArtUserMenu.vue @@ -0,0 +1,137 @@ + + + + + + diff --git a/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue b/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue new file mode 100644 index 0000000..edd1473 --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-horizontal-menu/index.vue @@ -0,0 +1,110 @@ + + + + + + diff --git a/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue b/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue new file mode 100644 index 0000000..0f3a817 --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue b/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue new file mode 100644 index 0000000..4e98246 --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-mixed-menu/index.vue @@ -0,0 +1,279 @@ + + + + + + + + diff --git a/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue new file mode 100644 index 0000000..c9eb1fa --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/index.vue @@ -0,0 +1,369 @@ + + + + + + + + diff --git a/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss new file mode 100644 index 0000000..8cccdd9 --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/style.scss @@ -0,0 +1,251 @@ +.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; + // 防止菜单内的滚动影响整个页面滚动 + 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; + } + } +} diff --git a/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss new file mode 100644 index 0000000..7626c42 --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/theme.scss @@ -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; + } + } +} diff --git a/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue new file mode 100644 index 0000000..9afa262 --- /dev/null +++ b/admin/src/components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue @@ -0,0 +1,199 @@ + + + diff --git a/admin/src/components/core/layouts/art-notification/index.vue b/admin/src/components/core/layouts/art-notification/index.vue new file mode 100644 index 0000000..a58853c --- /dev/null +++ b/admin/src/components/core/layouts/art-notification/index.vue @@ -0,0 +1,456 @@ + + + + + + diff --git a/admin/src/components/core/layouts/art-page-content/index.vue b/admin/src/components/core/layouts/art-page-content/index.vue new file mode 100644 index 0000000..a862df1 --- /dev/null +++ b/admin/src/components/core/layouts/art-page-content/index.vue @@ -0,0 +1,136 @@ + + + diff --git a/admin/src/components/core/layouts/art-screen-lock/index.vue b/admin/src/components/core/layouts/art-screen-lock/index.vue new file mode 100644 index 0000000..d7002a9 --- /dev/null +++ b/admin/src/components/core/layouts/art-screen-lock/index.vue @@ -0,0 +1,522 @@ + + + + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts new file mode 100644 index 0000000..35e8066 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsConfig.ts @@ -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 + } +} diff --git a/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts new file mode 100644 index 0000000..392c690 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsHandlers.ts @@ -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 = ( + 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((width: number) => + settingStore.setMenuOpenWidth(width) + ), + + // 标签页风格 + tabStyle: createValueHandler((style: string) => settingStore.setTabStyle(style)), + + // 页面切换动画 + pageTransition: createValueHandler((transition: string) => + settingStore.setPageTransition(transition) + ), + + // 圆角大小 + customRadius: createValueHandler((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 + } +} diff --git a/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts new file mode 100644 index 0000000..f5e4e56 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsPanel.ts @@ -0,0 +1,233 @@ +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 { StorageConfig } from '@/utils' +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 getStoredDesktopMenuType = (): MenuTypeEnum | undefined => { + const storedMenuType = localStorage.getItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY) + return Object.values(MenuTypeEnum).includes(storedMenuType as MenuTypeEnum) + ? (storedMenuType as MenuTypeEnum) + : undefined + } + + const setStoredDesktopMenuType = (type: MenuTypeEnum) => { + localStorage.setItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY, type) + } + + const clearStoredDesktopMenuType = () => { + localStorage.removeItem(StorageConfig.RESPONSIVE_MENU_TYPE_KEY) + } + + const storedDesktopMenuType = getStoredDesktopMenuType() + const beforeMenuType = ref(storedDesktopMenuType) + const hasChangedMenu = ref(Boolean(storedDesktopMenuType)) + + // 计算属性 + 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 + if (menuType.value !== MenuTypeEnum.LEFT) { + setStoredDesktopMenuType(menuType.value) + useSettingsState().switchMenuLayouts(MenuTypeEnum.LEFT) + hasChangedMenu.value = true + } + } + + settingStore.setMenuOpen(false) + } else { + // 恢复桌面端布局 + if (hasChangedMenu.value && beforeMenuType.value) { + if (menuType.value === MenuTypeEnum.LEFT) { + useSettingsState().switchMenuLayouts(beforeMenuType.value) + } + + clearStoredDesktopMenuType() + hasChangedMenu.value = false + } + + settingStore.setMenuOpen(true) + } + }, + { immediate: true } + ) + + return { stopWatch } + } + + // 抽屉控制 + const useDrawerControl = () => { + // 用于存储 setTimeout 的 ID,以便在需要时清除 + let themeChangeTimer: ReturnType | 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 + } +} diff --git a/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts new file mode 100644 index 0000000..65352d2 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/composables/useSettingsState.ts @@ -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 + } +} diff --git a/admin/src/components/core/layouts/art-settings-panel/index.vue b/admin/src/components/core/layouts/art-settings-panel/index.vue new file mode 100644 index 0000000..0cbf344 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/index.vue @@ -0,0 +1,72 @@ + + + + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/style.scss b/admin/src/components/core/layouts/art-settings-panel/style.scss new file mode 100644 index 0000000..e863074 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/style.scss @@ -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; + } +} diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue new file mode 100644 index 0000000..b6dc9d3 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/BasicSettings.vue @@ -0,0 +1,77 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue new file mode 100644 index 0000000..86c7a9e --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue @@ -0,0 +1,38 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue new file mode 100644 index 0000000..05a4b41 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/ColorSettings.vue @@ -0,0 +1,35 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue new file mode 100644 index 0000000..1f5be72 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/ContainerSettings.vue @@ -0,0 +1,33 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue new file mode 100644 index 0000000..dbcae46 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue @@ -0,0 +1,31 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue new file mode 100644 index 0000000..61237eb --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue @@ -0,0 +1,44 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue b/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue new file mode 100644 index 0000000..31ef00c --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/SectionTitle.vue @@ -0,0 +1,17 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue b/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue new file mode 100644 index 0000000..7b47d1a --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/SettingActions.vue @@ -0,0 +1,235 @@ + + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue b/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue new file mode 100644 index 0000000..85372be --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/SettingDrawer.vue @@ -0,0 +1,51 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue b/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue new file mode 100644 index 0000000..e3ead9e --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/SettingHeader.vue @@ -0,0 +1,18 @@ + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue b/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue new file mode 100644 index 0000000..5721027 --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/SettingItem.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue b/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue new file mode 100644 index 0000000..4b46fcd --- /dev/null +++ b/admin/src/components/core/layouts/art-settings-panel/widget/ThemeSettings.vue @@ -0,0 +1,28 @@ + + + diff --git a/admin/src/components/core/layouts/art-work-tab/index.vue b/admin/src/components/core/layouts/art-work-tab/index.vue new file mode 100644 index 0000000..152ff63 --- /dev/null +++ b/admin/src/components/core/layouts/art-work-tab/index.vue @@ -0,0 +1,584 @@ + + + + + + diff --git a/admin/src/components/core/others/art-menu-right/index.vue b/admin/src/components/core/others/art-menu-right/index.vue new file mode 100644 index 0000000..1cc92ab --- /dev/null +++ b/admin/src/components/core/others/art-menu-right/index.vue @@ -0,0 +1,415 @@ + + + + + + diff --git a/admin/src/components/core/others/art-watermark/index.vue b/admin/src/components/core/others/art-watermark/index.vue new file mode 100644 index 0000000..1d7f06b --- /dev/null +++ b/admin/src/components/core/others/art-watermark/index.vue @@ -0,0 +1,64 @@ + + + + diff --git a/admin/src/components/core/tables/art-table-header/index.vue b/admin/src/components/core/tables/art-table-header/index.vue new file mode 100644 index 0000000..788c2b7 --- /dev/null +++ b/admin/src/components/core/tables/art-table-header/index.vue @@ -0,0 +1,339 @@ + + + + + + diff --git a/admin/src/components/core/tables/art-table/index.vue b/admin/src/components/core/tables/art-table/index.vue new file mode 100644 index 0000000..be99e70 --- /dev/null +++ b/admin/src/components/core/tables/art-table/index.vue @@ -0,0 +1,368 @@ + + + + + + + + + diff --git a/admin/src/components/core/tables/art-table/style.scss b/admin/src/components/core/tables/art-table/style.scss new file mode 100644 index 0000000..67459e8 --- /dev/null +++ b/admin/src/components/core/tables/art-table/style.scss @@ -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; + } +} diff --git a/admin/src/components/core/text-effect/art-count-to/index.vue b/admin/src/components/core/text-effect/art-count-to/index.vue new file mode 100644 index 0000000..7fb104b --- /dev/null +++ b/admin/src/components/core/text-effect/art-count-to/index.vue @@ -0,0 +1,310 @@ + + + + diff --git a/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue b/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue new file mode 100644 index 0000000..770b457 --- /dev/null +++ b/admin/src/components/core/text-effect/art-festival-text-scroll/index.vue @@ -0,0 +1,32 @@ + + + + diff --git a/admin/src/components/core/text-effect/art-text-scroll/index.vue b/admin/src/components/core/text-effect/art-text-scroll/index.vue new file mode 100644 index 0000000..90be30f --- /dev/null +++ b/admin/src/components/core/text-effect/art-text-scroll/index.vue @@ -0,0 +1,285 @@ + + + + diff --git a/admin/src/components/core/theme/theme-svg/index.vue b/admin/src/components/core/theme/theme-svg/index.vue new file mode 100644 index 0000000..0b565a9 --- /dev/null +++ b/admin/src/components/core/theme/theme-svg/index.vue @@ -0,0 +1,100 @@ + + + + + + + diff --git a/admin/src/components/core/views/exception/ArtException.vue b/admin/src/components/core/views/exception/ArtException.vue new file mode 100644 index 0000000..4be59ae --- /dev/null +++ b/admin/src/components/core/views/exception/ArtException.vue @@ -0,0 +1,55 @@ + + + diff --git a/admin/src/components/core/views/login/AuthTopBar.vue b/admin/src/components/core/views/login/AuthTopBar.vue new file mode 100644 index 0000000..9455253 --- /dev/null +++ b/admin/src/components/core/views/login/AuthTopBar.vue @@ -0,0 +1,149 @@ + + + + + + diff --git a/admin/src/components/core/views/login/LoginLeftView.vue b/admin/src/components/core/views/login/LoginLeftView.vue new file mode 100644 index 0000000..af6a904 --- /dev/null +++ b/admin/src/components/core/views/login/LoginLeftView.vue @@ -0,0 +1,602 @@ + + + + + + diff --git a/admin/src/components/core/views/result/ArtResultPage.vue b/admin/src/components/core/views/result/ArtResultPage.vue new file mode 100644 index 0000000..b2eca48 --- /dev/null +++ b/admin/src/components/core/views/result/ArtResultPage.vue @@ -0,0 +1,43 @@ + + + diff --git a/admin/src/components/core/widget/art-icon-button/index.vue b/admin/src/components/core/widget/art-icon-button/index.vue new file mode 100644 index 0000000..760888b --- /dev/null +++ b/admin/src/components/core/widget/art-icon-button/index.vue @@ -0,0 +1,23 @@ + + + + diff --git a/admin/src/config/assets/images.ts b/admin/src/config/assets/images.ts new file mode 100644 index 0000000..f3e89dd --- /dev/null +++ b/admin/src/config/assets/images.ts @@ -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 + } +} diff --git a/admin/src/config/index.ts b/admin/src/config/index.ts new file mode 100644 index 0000000..ddbc958 --- /dev/null +++ b/admin/src/config/index.ts @@ -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: 'all-kefu 管理后台' // 系统名称 + }, + // 系统主题 + 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) diff --git a/admin/src/config/modules/component.ts b/admin/src/config/modules/component.ts new file mode 100644 index 0000000..dc0e244 --- /dev/null +++ b/admin/src/config/modules/component.ts @@ -0,0 +1,108 @@ +/** + * 全局组件配置 + * + * 统一管理系统级全局组件的注册。 + * 这些组件会在应用启动时全局注册,可在任何地方使用。 + * + * ## 主要功能 + * + * - 组件配置 - 集中管理全局组件的配置信息 + * - 异步加载 - 使用 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') + ), + // 入口已摘除(Phase 3 终审 M-1):锁屏把真实密码 AES 存 localStorage + // 且密钥是公开模板默认值,防御价值为零——enabled: false 连全局挂载 + // 也不注册;组件代码保留,Phase 4 决定删否。 + enabled: false + }, + { + 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) +} diff --git a/admin/src/config/modules/fastEnter.ts b/admin/src/config/modules/fastEnter.ts new file mode 100644 index 0000000..60c37b3 --- /dev/null +++ b/admin/src/config/modules/fastEnter.ts @@ -0,0 +1,60 @@ +/** + * 快速入口配置 + * 包含:应用列表、快速链接等 + */ +import type { FastEnterConfig } from '@/types/config' + +const fastEnterConfig: FastEnterConfig = { + // 显示条件(屏幕宽度) + minWidth: 1200, + // 应用列表 + applications: [ + { + name: '仪表盘', + description: '平台概览与数据统计', + icon: 'ri:dashboard-line', + iconColor: '#377dff', + enabled: true, + order: 1, + routeName: 'Console' + }, + { + name: '会话与线索', + description: '会话列表与询盘推送', + icon: 'ri:chat-3-line', + iconColor: '#13DEB9', + enabled: true, + order: 2, + routeName: 'ConversationList' + }, + { + name: '配置中心', + description: '租户配置与品牌管理', + icon: 'ri:settings-4-line', + iconColor: '#ffb100', + enabled: true, + order: 3, + routeName: 'ConfigCenter' + }, + { + name: '统计分析', + description: '卡片/漏斗/趋势/分布', + icon: 'ri:bar-chart-2-line', + iconColor: '#ff6b6b', + enabled: true, + order: 4, + routeName: 'StatsOverview' + } + ], + // 快速链接(登录项已删——终审 M-8:已登录态下无意义) + quickLinks: [ + { + name: '修改密码', + enabled: true, + order: 1, + routeName: 'ForceChangePassword' + } + ] +} + +export default Object.freeze(fastEnterConfig) diff --git a/admin/src/config/modules/festival.ts b/admin/src/config/modules/festival.ts new file mode 100644 index 0000000..39cd790 --- /dev/null +++ b/admin/src/config/modules/festival.ts @@ -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 祝您圣诞快乐,愿节日的欢乐与祝福如雪花般纷至沓来!', + // } +] diff --git a/admin/src/config/modules/headerBar.ts b/admin/src/config/modules/headerBar.ts new file mode 100644 index 0000000..d1c908b --- /dev/null +++ b/admin/src/config/modules/headerBar.ts @@ -0,0 +1,65 @@ +/** + * 顶部栏功能配置 + * + * 统一管理顶部栏各个功能模块的启用状态。 + * 通过修改此配置文件可以快速启用或禁用顶部栏的功能按钮。 + * + * @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: { + // 入口已摘除(Phase 3 终审 M-2):模板通知中心是硬编码演示数据, + // 无真实数据源——enabled: false 隐藏铃铛;组件保留,接真数据源时再开。 + enabled: false, + description: '通知中心,显示系统通知和消息' + }, + chat: { + enabled: true, + description: '聊天功能,提供实时沟通' + }, + language: { + enabled: true, + description: '多语言切换功能' + }, + settings: { + enabled: true, + description: '系统设置面板' + }, + themeToggle: { + enabled: true, + description: '主题切换功能(明暗主题)' + } +} + +export default headerBarConfig diff --git a/admin/src/config/setting.ts b/admin/src/config/setting.ts new file mode 100644 index 0000000..94f2d2c --- /dev/null +++ b/admin/src/config/setting.ts @@ -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) { + const defaults = getSettingDefaults() + Object.keys(defaults).forEach((key) => { + if (key in currentSettings) { + currentSettings[key] = defaults[key as keyof typeof defaults] + } + }) +} diff --git a/admin/src/directives/business/ripple.ts b/admin/src/directives/business/ripple.ts new file mode 100644 index 0000000..e65470e --- /dev/null +++ b/admin/src/directives/business/ripple.ts @@ -0,0 +1,116 @@ +/** + * v-ripple 水波纹效果指令 + * + * 为元素添加 Material Design 风格的水波纹点击效果。 + * 点击时从点击位置扩散出圆形水波纹动画,提升交互体验。 + * + * ## 主要功能 + * + * - 水波纹动画 - 点击时从点击位置扩散圆形波纹 + * - 自适应大小 - 根据元素尺寸自动调整波纹大小和动画时长 + * - 智能配色 - 自动识别按钮类型,使用合适的波纹颜色 + * - 自定义颜色 - 支持通过参数自定义波纹颜色 + * - 性能优化 - 使用 requestAnimationFrame 和自动清理机制 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 颜色规则 + * + * - 有色按钮(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 type RippleDirective = Directive + +export const vRipple: RippleDirective = { + 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) +} diff --git a/admin/src/directives/core/auth.ts b/admin/src/directives/core/auth.ts new file mode 100644 index 0000000..3d244c7 --- /dev/null +++ b/admin/src/directives/core/auth.ts @@ -0,0 +1,66 @@ +/** + * v-auth 权限指令 + * + * 适用于后端权限控制模式,基于权限标识控制 DOM 元素的显示和隐藏。 + * 如果用户没有对应权限,元素将从 DOM 中移除。 + * + * ## 主要功能 + * + * - 权限验证 - 根据路由 meta 中的权限列表验证用户权限 + * - DOM 控制 - 无权限时自动移除元素,而非隐藏 + * - 响应式更新 - 权限变化时自动更新元素状态 + * + * ## 使用示例 + * + * ```vue + * + * 新增 + * + * + * 编辑 + * + * + * 删除 + * ``` + * + * ## 注意事项 + * + * - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏 + * - 权限列表从当前路由的 meta.authList 中获取 + * + * @module directives/auth + * @author Art Design Pro Team + */ + +import { router } from '@/router' +import { App, Directive, DirectiveBinding } from 'vue' + +export type AuthDirective = Directive + +function checkAuthPermission(el: HTMLElement, binding: DirectiveBinding): void { + // 获取当前路由的权限列表 + const authList = (router.currentRoute.value.meta.authList as Array<{ authMark: string }>) || [] + + // 检查是否有对应的权限标识 + const hasPermission = authList.some((item) => item.authMark === binding.value) + + // 如果没有权限,移除元素 + if (!hasPermission) { + removeElement(el) + } +} + +function removeElement(el: HTMLElement): void { + if (el.parentNode) { + el.parentNode.removeChild(el) + } +} + +const authDirective: AuthDirective = { + mounted: checkAuthPermission, + updated: checkAuthPermission +} + +export function setupAuthDirective(app: App): void { + app.directive('auth', authDirective) +} diff --git a/admin/src/directives/core/roles.ts b/admin/src/directives/core/roles.ts new file mode 100644 index 0000000..f1db35b --- /dev/null +++ b/admin/src/directives/core/roles.ts @@ -0,0 +1,87 @@ +/** + * v-roles 角色权限指令 + * + * 基于用户角色控制 DOM 元素的显示和隐藏。 + * 只要用户拥有指定角色中的任意一个,元素就会显示,否则从 DOM 中移除。 + * + * ## 主要功能 + * + * - 角色验证 - 检查用户是否拥有指定角色 + * - 多角色支持 - 支持单个角色或多个角色(满足其一即可) + * - DOM 控制 - 无权限时自动移除元素,而非隐藏 + * - 响应式更新 - 角色变化时自动更新元素状态 + * + * ## 使用示例 + * + * ```vue + * + * ``` + * + * ## 权限逻辑 + * + * - 用户角色从 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' + +export type RolesDirective = Directive + +function checkRolePermission(el: HTMLElement, binding: DirectiveBinding): 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: RolesDirective = { + mounted: checkRolePermission, + updated: checkRolePermission +} + +export function setupRolesDirective(app: App): void { + app.directive('roles', rolesDirective) +} diff --git a/admin/src/directives/index.ts b/admin/src/directives/index.ts new file mode 100644 index 0000000..b396be0 --- /dev/null +++ b/admin/src/directives/index.ts @@ -0,0 +1,12 @@ +import type { App } from 'vue' +import { setupAuthDirective, type AuthDirective } from './core/auth' +import { setupRippleDirective, type RippleDirective } from './business/ripple' +import { setupRolesDirective, type RolesDirective } from './core/roles' + +export function setupGlobDirectives(app: App) { + setupAuthDirective(app) // 权限指令 + setupRolesDirective(app) // 角色权限指令 + setupRippleDirective(app) // 水波纹指令 +} + +export type { AuthDirective, RippleDirective, RolesDirective } diff --git a/admin/src/enums/appEnum.ts b/admin/src/enums/appEnum.ts new file mode 100644 index 0000000..a39c278 --- /dev/null +++ b/admin/src/enums/appEnum.ts @@ -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' +} diff --git a/admin/src/enums/formEnum.ts b/admin/src/enums/formEnum.ts new file mode 100644 index 0000000..8e9b3b4 --- /dev/null +++ b/admin/src/enums/formEnum.ts @@ -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' +} diff --git a/admin/src/env.d.ts b/admin/src/env.d.ts new file mode 100644 index 0000000..73846e2 --- /dev/null +++ b/admin/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module 'nprogress' + +declare module 'crypto-js' + +// 全局变量声明 +declare const __APP_VERSION__: string // 版本号 diff --git a/admin/src/hooks/core/useAppMode.ts b/admin/src/hooks/core/useAppMode.ts new file mode 100644 index 0000000..c39cd9e --- /dev/null +++ b/admin/src/hooks/core/useAppMode.ts @@ -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 + } +} diff --git a/admin/src/hooks/core/useAuth.ts b/admin/src/hooks/core/useAuth.ts new file mode 100644 index 0000000..283b859 --- /dev/null +++ b/admin/src/hooks/core/useAuth.ts @@ -0,0 +1,74 @@ +/** + * useAuth - 权限验证管理 + * + * 提供统一的权限验证功能,支持前端和后端两种权限模式。 + * 用于控制页面按钮、操作等功能的显示和访问权限。 + * + * ## 主要功能 + * + * 1. 权限检查 - 检查用户是否拥有指定的权限标识 + * 2. 双模式支持 - 自动适配前端模式和后端模式的权限验证 + * 3. 前端模式 - 从用户信息中获取按钮权限列表(如 ['add', 'edit', 'delete']) + * 4. 后端模式 - 从路由 meta 配置中获取权限列表(如 [{ authMark: 'add' }]) + * + * ## 使用示例 + * + * ```typescript + * const { hasAuth } = useAuth() + * + * // 检查是否有新增权限 + * if (hasAuth('add')) { + * // 显示新增按钮 + * } + * + * // 在模板中使用 + * 编辑 + * 删除 + * ``` + * + * @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[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 + } +} diff --git a/admin/src/hooks/core/useCeremony.ts b/admin/src/hooks/core/useCeremony.ts new file mode 100644 index 0000000..ead2630 --- /dev/null +++ b/admin/src/hooks/core/useCeremony.ts @@ -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 + } +} diff --git a/admin/src/hooks/core/useChart.ts b/admin/src/hooks/core/useChart.ts new file mode 100644 index 0000000..29ba1d1 --- /dev/null +++ b/admin/src/hooks/core/useChart.ts @@ -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() + 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 = `暂无数据` + + // 确保父容器有相对定位 + 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 { + /** Props响应式对象 */ + props: T + /** 图表配置生成函数 */ + generateOptions: () => EChartsOption + /** 空数据检查函数 */ + checkEmpty?: () => boolean + /** 自定义监听的响应式数据 */ + watchSources?: (() => any)[] + /** 自定义可视事件处理 */ + onVisible?: () => void + /** useChart选项 */ + chartOptions?: UseChartOptions +} + +export function useChartComponent(options: UseChartComponentOptions) { + 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 + } +} diff --git a/admin/src/hooks/core/useCommon.ts b/admin/src/hooks/core/useCommon.ts new file mode 100644 index 0000000..c936854 --- /dev/null +++ b/admin/src/hooks/core/useCommon.ts @@ -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 + } +} diff --git a/admin/src/hooks/core/useFastEnter.ts b/admin/src/hooks/core/useFastEnter.ts new file mode 100644 index 0000000..555eb65 --- /dev/null +++ b/admin/src/hooks/core/useFastEnter.ts @@ -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(() => { + 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(() => { + 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 + } +} diff --git a/admin/src/hooks/core/useHeaderBar.ts b/admin/src/hooks/core/useHeaderBar.ts new file mode 100644 index 0000000..be10712 --- /dev/null +++ b/admin/src/hooks/core/useHeaderBar.ts @@ -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(() => 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 // 获取所有禁用的功能(别名) + } +} diff --git a/admin/src/hooks/core/useLayoutHeight.ts b/admin/src/hooks/core/useLayoutHeight.ts new file mode 100644 index 0000000..4b1171a --- /dev/null +++ b/admin/src/hooks/core/useLayoutHeight.ts @@ -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() + const contentHeaderRef = ref() + + // 使用 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() + const contentHeaderRef = ref() + + // 使用 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 + } +} diff --git a/admin/src/hooks/core/useTable.ts b/admin/src/hooks/core/useTable.ts new file mode 100644 index 0000000..d9def4f --- /dev/null +++ b/admin/src/hooks/core/useTable.ts @@ -0,0 +1,762 @@ +/** + * 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 extends (params: infer P) => any ? P : never +type InferApiResponse = T extends (params: any) => Promise ? R : never +type InferRecordType = T extends Api.Common.PaginatedResponse ? U : never + +// 优化的配置接口 - 支持自动类型推导 +export interface UseTableConfig< + TApiFn extends (params: any) => Promise = (params: any) => Promise, + TRecord = InferRecordType>, + TParams = InferApiParams, + TResponse = InferApiResponse +> { + // 核心配置 + core: { + /** API 请求函数 */ + apiFn: TApiFn + /** 默认请求参数 */ + apiParams?: Partial + /** 排除 apiParams 中的属性 */ + excludeParams?: string[] + /** 是否立即加载数据 */ + immediate?: boolean + /** 列配置工厂函数 */ + columnsFactory?: () => ColumnOption[] + /** 自定义分页字段映射 */ + paginationKey?: { + /** 当前页码字段名,默认为 'current' */ + current?: string + /** 每页条数字段名,默认为 'size' */ + size?: string + } + } + + // 数据处理 + transform?: { + /** 数据转换函数 */ + dataTransformer?: (data: TRecord[]) => TRecord[] + /** 响应数据适配器 */ + responseAdapter?: (response: TResponse) => ApiResponse + } + + // 性能优化 + performance?: { + /** 是否启用缓存 */ + enableCache?: boolean + /** 缓存时间(毫秒) */ + cacheTime?: number + /** 防抖延迟时间(毫秒) */ + debounceTime?: number + /** 最大缓存条数限制 */ + maxCacheSize?: number + } + + // 生命周期钩子 + hooks?: { + /** 数据加载成功回调(仅网络请求成功时触发) */ + onSuccess?: (data: TRecord[], response: ApiResponse) => void + /** 错误处理回调 */ + onError?: (error: TableError) => void + /** 缓存命中回调(从缓存获取数据时触发) */ + onCacheHit?: (data: TRecord[], response: ApiResponse) => void + /** 加载状态变化回调 */ + onLoading?: (loading: boolean) => void + /** 重置表单回调函数 */ + resetFormCallback?: () => void + } + + // 调试配置 + debug?: { + /** 是否启用日志输出 */ + enableLog?: boolean + /** 日志级别 */ + logLevel?: 'info' | 'warn' | 'error' + } +} + +export function useTable Promise>( + config: UseTableConfig +) { + return useTableImpl(config) +} + +/** + * useTable 的核心实现 - 强大的表格数据管理 Hook + * + * 提供完整的表格解决方案,包括: + * - 数据获取与缓存 + * - 分页控制 + * - 搜索功能 + * - 智能刷新策略 + * - 错误处理 + * - 列配置管理 + */ +function useTableImpl Promise>( + config: UseTableConfig +) { + type TRecord = InferRecordType> + type TParams = InferApiParams + const { + core: { + apiFn, + apiParams = {} as Partial, + 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 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(cacheTime, maxCacheSize, enableLog) : null + + // 加载状态机 + type LoadingState = 'idle' | 'loading' | 'success' | 'error' + const loadingState = ref('idle') + const loading = computed(() => loadingState.value === 'loading') + + // 错误状态 + const error = ref(null) + + // 表格数据 + const data = ref([]) + + // 请求取消控制器 + 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({ + current: ((searchParams as Record)[pageKey] as number) || 1, + size: ((searchParams as Record)[sizeKey] as number) || 10, + total: 0 + }) + + // 移动端分页 (响应式) + const { width } = useWindowSize() + const mobilePagination = computed(() => ({ + ...pagination, + small: width.value < 768 + })) + + // 列配置 + const columnConfig = columnsFactory ? useTableColumns(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, + useCache = enableCache + ): Promise> => { + // 取消上一个请求 + 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)[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 + 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 + 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): Promise | void> => { + try { + return await fetchData(params) + } catch { + // 错误已在 fetchData 中处理 + return Promise.resolve() + } + } + + // 分页获取数据 (重置到第一页) - 专门用于搜索场景 + const getDataByPage = async (params?: Partial): Promise | void> => { + pagination.current = 1 + ;(searchParams as Record)[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 => { + // 取消防抖的搜索 + debouncedGetDataByPage.cancel() + + // 保存分页相关的默认值 + const paramsRecord = searchParams as Record + 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() + } + } + + // 替换搜索参数:适用于表单查询,避免旧字段残留 + const replaceSearchParams = (params?: Partial): void => { + const paramsRecord = searchParams as Record + const currentSize = pagination.size || ((paramsRecord[sizeKey] as number) ?? 10) + + Object.keys(searchParams).forEach((key) => { + if (key !== pageKey && key !== sizeKey) { + delete paramsRecord[key] + } + }) + + Object.assign( + searchParams, + { + [pageKey]: 1, + [sizeKey]: currentSize + }, + params || {} + ) + + pagination.current = 1 + pagination.size = currentSize + } + + // 防重复调用的标志 + let isCurrentChanging = false + + // 处理分页大小变化 + const handleSizeChange = async (newSize: number): Promise => { + if (newSize <= 0) return + + debouncedGetDataByPage.cancel() + + const paramsRecord = searchParams as Record + pagination.size = newSize + pagination.current = 1 + paramsRecord[sizeKey] = newSize + paramsRecord[pageKey] = 1 + + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '分页大小变化') + + await getData() + } + + // 处理当前页变化 + const handleCurrentChange = async (newCurrent: number): Promise => { + if (newCurrent <= 0) return + + // 修复:防止重复调用 + if (isCurrentChanging) { + return + } + + // 修复:如果当前页没有变化,不需要重新请求 + if (pagination.current === newCurrent) { + logger.log('分页页码未变化,跳过请求') + return + } + + try { + isCurrentChanging = true + + // 修复:只更新必要的状态 + const paramsRecord = searchParams as Record + pagination.current = newCurrent + // 只有当 searchParams 的分页字段与新值不同时才更新 + if (paramsRecord[pageKey] !== newCurrent) { + paramsRecord[pageKey] = newCurrent + } + + await getData() + } finally { + isCurrentChanging = false + } + } + + // 针对不同业务场景的刷新方法 + + // 新增后刷新:回到第一页并清空分页缓存(适用于新增数据后) + const refreshCreate = async (): Promise => { + debouncedGetDataByPage.cancel() + pagination.current = 1 + ;(searchParams as Record)[pageKey] = 1 + clearCache(CacheInvalidationStrategy.CLEAR_PAGINATION, '新增数据') + await getData() + } + + // 更新后刷新:保持当前页,仅清空当前搜索缓存(适用于更新数据后) + const refreshUpdate = async (): Promise => { + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '编辑数据') + await getData() + } + + // 删除后刷新:智能处理页码,避免空页面(适用于删除数据后) + const refreshRemove = async (): Promise => { + const { current } = pagination + + // 清除缓存并获取最新数据 + clearCache(CacheInvalidationStrategy.CLEAR_CURRENT, '删除数据') + await getData() + + // 如果当前页为空且不是第一页,回到上一页 + if (data.value.length === 0 && current > 1) { + pagination.current = current - 1 + ;(searchParams as Record)[pageKey] = current - 1 + await getData() + } + } + + // 全量刷新:清空所有缓存,重新获取数据(适用于手动刷新按钮) + const refreshData = async (): Promise => { + debouncedGetDataByPage.cancel() + clearCache(CacheInvalidationStrategy.CLEAR_ALL, '手动刷新') + await getData() + } + + // 轻量刷新:仅清空当前搜索条件的缓存,保持分页状态(适用于定时刷新) + const refreshSoft = async (): Promise => { + 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, + + // 搜索相关 - 统一前缀 + /** 搜索参数 */ + searchParams, + /** 替换搜索参数(适用于表单查询,避免旧字段残留) */ + replaceSearchParams, + /** 重置搜索参数 */ + 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' diff --git a/admin/src/hooks/core/useTableColumns.ts b/admin/src/hooks/core/useTableColumns.ts new file mode 100644 index 0000000..84b6e13 --- /dev/null +++ b/admin/src/hooks/core/useTableColumns.ts @@ -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 = { + 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 = (col: ColumnOption) => + SPECIAL_COLUMNS[col.type as keyof typeof SPECIAL_COLUMNS]?.prop ?? (col.prop as string) + +/** + * 获取列的显示状态 + * 优先使用 visible 字段,如果不存在则使用 checked 字段 + */ +export const getColumnVisibility = (col: ColumnOption): boolean => { + // visible 优先级高于 checked + if (col.visible !== undefined) { + return col.visible + } + // 如果 visible 未定义,使用 checked,默认为 true + return col.checked ?? true +} + +/** + * 获取列的检查状态 + */ +export const getColumnChecks = (columns: ColumnOption[]) => + 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 { + /** + * 新增列(支持单个或批量) + * @param column 列配置或列配置数组 + * @param index 可选的插入位置,默认末尾(批量时为第一个列的位置) + */ + addColumn: (column: ColumnOption | ColumnOption[], 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> }>, + updates?: Partial> + ) => void + /** + * 批量更新列(兼容旧版本,推荐使用 updateColumn 的数组模式) + * @param updates 列更新配置 + * @deprecated 推荐使用 updateColumn 的数组模式 + */ + batchUpdateColumns: (updates: Array<{ prop: string; updates: Partial> }>) => void + /** + * 重新排序列 + * @param fromIndex 源索引 + * @param toIndex 目标索引 + */ + reorderColumns: (fromIndex: number, toIndex: number) => void + /** + * 获取列配置 + * @param prop 列的唯一标识 + * @returns 列配置 + */ + getColumnConfig: (prop: string) => ColumnOption | undefined + /** + * 获取所有列配置 + * @returns 所有列配置 + */ + getAllColumns: () => ColumnOption[] + /** + * 重置所有列 + */ + resetColumns: () => void +} + +export function useTableColumns( + columnsFactory: () => ColumnOption[] +): { + columns: any + columnChecks: any +} & DynamicColumnConfig { + const dynamicColumns = ref[]>(columnsFactory()) + const columnChecks = ref[]>(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[] + }) + + // 支持 updater 返回新数组或直接在传入数组上 mutate + const setDynamicColumns = (updater: (cols: ColumnOption[]) => void | ColumnOption[]) => { + const copy = [...dynamicColumns.value] + const result = updater(copy) + dynamicColumns.value = Array.isArray(result) ? result : copy + } + + return { + columns, + columnChecks, + + /** + * 新增列(支持单个或批量) + */ + addColumn: (column: ColumnOption | ColumnOption[], 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> }>, + updates?: Partial> + ) => { + // 批量模式: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] + } +} diff --git a/admin/src/hooks/core/useTableHeight.ts b/admin/src/hooks/core/useTableHeight.ts new file mode 100644 index 0000000..8fdf6da --- /dev/null +++ b/admin/src/hooks/core/useTableHeight.ts @@ -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 + /** 分页器高度 */ + paginationHeight: Ref + /** 表格头部高度 */ + tableHeaderHeight: Ref + /** 分页器间距 */ + paginationSpacing: Ref +} + +/** + * 表格高度计算器类 + */ +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 + } +} diff --git a/admin/src/hooks/core/useTheme.ts b/admin/src/hooks/core/useTheme.ts new file mode 100644 index 0000000..187c3e0 --- /dev/null +++ b/admin/src/hooks/core/useTheme.ts @@ -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 } + ) + } +} diff --git a/admin/src/hooks/index.ts b/admin/src/hooks/index.ts new file mode 100644 index 0000000..472b09c --- /dev/null +++ b/admin/src/hooks/index.ts @@ -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' diff --git a/admin/src/locales/index.ts b/admin/src/locales/index.ts new file mode 100644 index 0000000..36c2648 --- /dev/null +++ b/admin/src/locales/index.ts @@ -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 diff --git a/admin/src/locales/langs/en.json b/admin/src/locales/langs/en.json new file mode 100644 index 0000000..b98b778 --- /dev/null +++ b/admin/src/locales/langs/en.json @@ -0,0 +1,257 @@ +{ + "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": { + "changePassword": "Change Password", + "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": "all-kefu Multi-tenant AI Support", + "subTitle": "Tenant config · Conversations · Statistics" + }, + "title": "Welcome Back", + "subTitle": "Sign in with your account and password", + "placeholder": { + "username": "Username", + "password": "Password", + "slider": "Drag to verify" + }, + "sliderText": "Drag to verify", + "sliderSuccessText": "Verified", + "rememberPwd": "Remember me", + "btnText": "Sign in", + "success": { + "title": "Success", + "message": "Welcome back" + } + }, + "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" + }, + "forceChangePassword": { + "title": "Change Password" + }, + "outside": { + "title": "Embedded" + }, + "dashboard": { + "title": "Dashboard", + "console": "Console" + }, + "tenants": { + "title": "Tenants", + "list": "Tenant List" + }, + "conversations": { + "title": "Conversations", + "list": "Conversation List" + }, + "config": { + "title": "Config Center", + "center": "Configuration" + }, + "stats": { + "title": "Statistics", + "overview": "Overview" + }, + "settings": { + "title": "Platform Settings", + "platform": "Platform" + } + }, + "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" + } +} diff --git a/admin/src/locales/langs/zh.json b/admin/src/locales/langs/zh.json new file mode 100644 index 0000000..cddf7ca --- /dev/null +++ b/admin/src/locales/langs/zh.json @@ -0,0 +1,257 @@ +{ + "httpMsg": { + "unauthorized": "未授权访问,请重新登录", + "forbidden": "禁止访问该资源", + "notFound": "请求的资源不存在", + "methodNotAllowed": "请求方法不允许", + "requestTimeout": "请求超时,请稍后重试", + "internalServerError": "服务器内部错误,请稍后重试", + "badGateway": "网关错误,请稍后重试", + "serviceUnavailable": "服务暂时不可用,请稍后重试", + "gatewayTimeout": "网关超时,请稍后重试", + "requestCancelled": "请求已取消", + "networkError": "网络连接异常,请检查网络连接", + "requestFailed": "请求失败", + "requestConfigError": "请求配置错误" + }, + "topBar": { + "search": { + "title": "搜索" + }, + "user": { + "changePassword": "修改密码", + "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": "all-kefu 多租户 AI 客服平台", + "subTitle": "租户配置 · 会话线索 · 数据统计,一站式管理" + }, + "title": "欢迎回来", + "subTitle": "输入您的账号和密码登录", + "placeholder": { + "username": "请输入账号", + "password": "请输入密码", + "slider": "请拖动滑块完成验证" + }, + "sliderText": "按住滑块拖动", + "sliderSuccessText": "验证成功", + "rememberPwd": "记住密码", + "btnText": "登录", + "success": { + "title": "登录成功", + "message": "欢迎回来" + } + }, + "lockScreen": { + "pwdError": "密码错误", + "lock": { + "inputPlaceholder": "请输入锁屏密码", + "btnText": "锁定" + }, + "unlock": { + "inputPlaceholder": "请输入解锁密码", + "btnText": "解锁", + "backBtnText": "返回登录" + } + }, + "greeting": { + "dawn": "凌晨了!", + "morning": "上午好!", + "afternoon": "下午好!", + "evening": "晚上好!" + }, + "exceptionPage": { + "403": "抱歉,您无权访问该页面", + "404": "抱歉,您访问的页面不存在", + "500": "抱歉,服务器出错了", + "gohome": "返回首页" + }, + "menus": { + "login": { + "title": "登录" + }, + "forceChangePassword": { + "title": "强制修改密码" + }, + "outside": { + "title": "内嵌页面" + }, + "dashboard": { + "title": "仪表盘", + "console": "工作台" + }, + "tenants": { + "title": "租户管理", + "list": "租户列表" + }, + "conversations": { + "title": "会话与线索", + "list": "会话列表" + }, + "config": { + "title": "配置中心", + "center": "配置管理" + }, + "stats": { + "title": "统计分析", + "overview": "数据总览" + }, + "settings": { + "title": "平台设置", + "platform": "平台参数" + } + }, + "table": { + "form": { + "reset": "重置", + "submit": "提交" + }, + "searchBar": { + "reset": "重置", + "search": "查询", + "expand": "展开", + "collapse": "收起", + "searchInputPlaceholder": "请输入", + "searchSelectPlaceholder": "请选择" + }, + "selection": "选择", + "sizeOptions": { + "small": "紧凑", + "default": "默认", + "large": "宽松" + }, + "column": { + "selection": "勾选", + "expand": "展开", + "index": "序号" + }, + "zebra": "斑马纹", + "border": "边框", + "headerBackground": "表头背景" + } +} diff --git a/admin/src/main.ts b/admin/src/main.ts new file mode 100644 index 0000000..7df948a --- /dev/null +++ b/admin/src/main.ts @@ -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') \ No newline at end of file diff --git a/admin/src/plugins/echarts.ts b/admin/src/plugins/echarts.ts new file mode 100644 index 0000000..4f56d89 --- /dev/null +++ b/admin/src/plugins/echarts.ts @@ -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 diff --git a/admin/src/plugins/index.ts b/admin/src/plugins/index.ts new file mode 100644 index 0000000..4536a86 --- /dev/null +++ b/admin/src/plugins/index.ts @@ -0,0 +1,6 @@ +/** + * 插件统一导出 + * 集中管理第三方库的封装和配置 + */ + +export * from './echarts' diff --git a/admin/src/router/core/ComponentLoader.ts b/admin/src/router/core/ComponentLoader.ts new file mode 100644 index 0000000..8af3ce3 --- /dev/null +++ b/admin/src/router/core/ComponentLoader.ts @@ -0,0 +1,82 @@ +/** + * 组件加载器 + * + * 负责动态加载 Vue 组件 + * + * @module router/core/ComponentLoader + * @author Art Design Pro Team + */ + +import { h } from 'vue' + +export class ComponentLoader { + private modules: Record Promise> + + constructor() { + // 动态导入 views 目录下所有 .vue 组件 + this.modules = import.meta.glob('../../views/**/*.vue') + } + + /** + * 加载组件 + */ + load(componentPath: string): () => Promise { + 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 { + return () => import('@/views/index/index.vue') + } + + /** + * 加载 iframe 组件 + */ + loadIframe(): () => Promise { + return () => import('@/views/outside/Iframe.vue') + } + + /** + * 创建空组件 + */ + private createEmptyComponent(): () => Promise { + return () => + Promise.resolve({ + render() { + return h('div', {}) + } + }) + } + + /** + * 创建错误提示组件 + */ + private createErrorComponent(componentPath: string): () => Promise { + return () => + Promise.resolve({ + render() { + return h('div', { class: 'route-error' }, `组件未找到: ${componentPath}`) + } + }) + } +} diff --git a/admin/src/router/core/IframeRouteManager.ts b/admin/src/router/core/IframeRouteManager.ts new file mode 100644 index 0000000..c054ca1 --- /dev/null +++ b/admin/src/router/core/IframeRouteManager.ts @@ -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 = [] + } + } +} diff --git a/admin/src/router/core/MenuProcessor.ts b/admin/src/router/core/MenuProcessor.ts new file mode 100644 index 0000000..c424867 --- /dev/null +++ b/admin/src/router/core/MenuProcessor.ts @@ -0,0 +1,264 @@ +/** + * 菜单处理器 + * + * 负责菜单数据的获取、过滤和处理 + * + * @module router/core/MenuProcessor + * @author Art Design Pro Team + */ + +import type { AppRouteRecord } from '@/types/router' +import { useUserStore } from '@/store/modules/user' +import { asyncRoutes } from '../routes/asyncRoutes' +import { RoutesAlias } from '../routesAlias' +import { formatMenuTitle } from '@/utils' + +export class MenuProcessor { + /** + * 获取菜单数据 + */ + async getMenuList(): Promise { + const menuList = await this.processFrontendMenu() + + // 在规范化路径之前,验证原始路径配置 + this.validateMenuPaths(menuList) + + // 规范化路径(将相对路径转换为完整路径) + return this.normalizeMenuPaths(menuList) + } + + /** + * 处理前端控制模式的菜单(本平台固定前端权限模式: + * 菜单来自本地路由模块,按登录角色 meta.roles 过滤) + */ + private async processFrontendMenu(): Promise { + 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 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 + + const redirect = item.redirect || this.resolveDefaultRedirect(children) + + return { + ...item, + path: fullPath, + redirect, + children + } + }) + } + + /** + * 为目录型菜单推导默认跳转地址 + */ + private resolveDefaultRedirect(children?: AppRouteRecord[]): string | undefined { + if (!children?.length) { + return undefined + } + + for (const child of children) { + if (this.isNavigableRoute(child)) { + return child.path + } + + const nestedRedirect = this.resolveDefaultRedirect(child.children) + if (nestedRedirect) { + return nestedRedirect + } + } + + return undefined + } + + /** + * 判断子路由是否可以作为默认落点 + */ + private isNavigableRoute(route: AppRouteRecord): boolean { + return Boolean( + route.path && + route.path !== '/' && + !route.meta?.link && + route.meta?.isIframe !== true && + route.component && + route.component !== '' + ) + } + + /** + * 验证菜单路径配置 + * 检测非一级菜单是否错误使用了 / 开头的路径 + */ + /** + * 验证菜单路径配置 + * 检测非一级菜单是否错误使用了 / 开头的路径 + */ + private validateMenuPaths(menuList: AppRouteRecord[], level = 1): void { + menuList.forEach((route) => { + if (!route.children?.length) return + + const parentName = String(route.name || route.path || '未知路由') + + route.children.forEach((child) => { + const childPath = child.path || '' + + // 跳过合法的绝对路径:外部链接和 iframe 路由 + if (this.isValidAbsolutePath(childPath)) return + + // 检测非法的绝对路径 + if (childPath.startsWith('/')) { + this.logPathError(child, childPath, parentName, level) + } + }) + + // 递归检查更深层级的子路由 + this.validateMenuPaths(route.children, level + 1) + }) + } + + /** + * 判断是否为合法的绝对路径 + */ + private isValidAbsolutePath(path: string): boolean { + return ( + path.startsWith('http://') || + path.startsWith('https://') || + path.startsWith('/outside/iframe/') + ) + } + + /** + * 输出路径配置错误日志 + */ + private logPathError( + route: AppRouteRecord, + path: string, + parentName: string, + level: number + ): void { + const routeName = String(route.name || path || '未知路由') + const menuTitle = route.meta?.title || routeName + const suggestedPath = path.split('/').pop() || path.slice(1) + + console.error( + `[路由配置错误] 菜单 "${formatMenuTitle(menuTitle)}" (name: ${routeName}, path: ${path}) 配置错误\n` + + ` 位置: ${parentName} > ${routeName}\n` + + ` 问题: ${level + 1}级菜单的 path 不能以 / 开头\n` + + ` 当前配置: path: '${path}'\n` + + ` 应该改为: path: '${suggestedPath}'` + ) + } + + /** + * 构建完整路径 + */ + 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}` + } +} diff --git a/admin/src/router/core/RoutePermissionValidator.ts b/admin/src/router/core/RoutePermissionValidator.ts new file mode 100644 index 0000000..1e607e0 --- /dev/null +++ b/admin/src/router/core/RoutePermissionValidator.ts @@ -0,0 +1,161 @@ +/** + * 路由权限验证模块 + * + * 提供路由权限验证和路径检查功能 + * + * ## 主要功能 + * + * - 验证路径是否在用户菜单权限中 + * - 构建菜单路径集合(扁平化处理) + * - 支持动态路由参数匹配 + * - 路径前缀匹配 + * + * ## 使用场景 + * + * - 路由守卫中验证用户权限 + * - 动态路由注册后的权限检查 + * - 防止用户访问无权限的页面 + * + * @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 + } + + return this.matchRoute(targetPath, menuList) + } + + /** + * 构建菜单路径集合(扁平化处理) + * @param menuList 菜单列表 + * @param pathSet 路径集合 + * @returns 路径集合 + */ + static buildMenuPathSet( + menuList: AppRouteRecord[], + pathSet: Set = new Set() + ): Set { + if (!Array.isArray(menuList) || menuList.length === 0) { + return pathSet + } + + for (const menuItem of menuList) { + if (!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): boolean { + // 遍历路径集合,检查是否有前缀匹配 + for (const menuPath of pathSet) { + if (targetPath.startsWith(`${menuPath}/`)) { + return true + } + } + return false + } + + /** + * 递归匹配路由配置,支持隐藏路由和动态参数路由 + */ + static matchRoute(targetPath: string, routes: AppRouteRecord[]): boolean { + if (!Array.isArray(routes) || routes.length === 0) { + return false + } + + for (const route of routes) { + if (!route.path) { + continue + } + + const routePath = route.path.startsWith('/') ? route.path : `/${route.path}` + + if ( + routePath === targetPath || + this.isDynamicRouteMatch(targetPath, routePath) || + targetPath.startsWith(`${routePath}/`) + ) { + return true + } + + if (route.children?.length && this.matchRoute(targetPath, route.children)) { + return true + } + } + + return false + } + + /** + * 检查目标路径是否匹配动态参数路由,如 /demo/123 匹配 /demo/:id + */ + static isDynamicRouteMatch(targetPath: string, routePath: string): boolean { + if (!routePath.includes(':')) { + return false + } + + const pattern = routePath + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/:([^/]+)/g, '[^/]+') + .replace(/\\\*/g, '.*') + + return new RegExp(`^${pattern}$`).test(targetPath) + } + + /** + * 验证并返回有效的路径 + * 如果目标路径无权限,返回首页路径 + * @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 } + } +} diff --git a/admin/src/router/core/RouteRegistry.ts b/admin/src/router/core/RouteRegistry.ts new file mode 100644 index 0000000..e1acb9e --- /dev/null +++ b/admin/src/router/core/RouteRegistry.ts @@ -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 + } +} diff --git a/admin/src/router/core/RouteTransformer.ts b/admin/src/router/core/RouteTransformer.ts new file mode 100644 index 0000000..0f6900c --- /dev/null +++ b/admin/src/router/core/RouteTransformer.ts @@ -0,0 +1,132 @@ +/** + * 路由转换器 + * + * 负责将菜单数据转换为 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 { + id?: number + children?: ConvertedRoute[] + component?: RouteRecordRaw['component'] | (() => Promise) +} + +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 (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) + } + + /** + * 处理 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) + } + } + + /** + * 提取路径的第一段 + */ + private extractFirstSegment(path: string): string { + const segments = path.split('/').filter(Boolean) + return segments.length > 0 ? `/${segments[0]}` : '/' + } +} diff --git a/admin/src/router/core/RouteValidator.ts b/admin/src/router/core/RouteValidator.ts new file mode 100644 index 0000000..f8e58fc --- /dev/null +++ b/admin/src/router/core/RouteValidator.ts @@ -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() + + /** + * 验证路由配置 + */ + 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() + const componentPathMap = new Map() + + 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('/') + } +} diff --git a/admin/src/router/core/index.ts b/admin/src/router/core/index.ts new file mode 100644 index 0000000..fcfecfc --- /dev/null +++ b/admin/src/router/core/index.ts @@ -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' diff --git a/admin/src/router/guards/afterEach.ts b/admin/src/router/guards/afterEach.ts new file mode 100644 index 0000000..d60572d --- /dev/null +++ b/admin/src/router/guards/afterEach.ts @@ -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() + }) + } + }) +} diff --git a/admin/src/router/guards/beforeEach.ts b/admin/src/router/guards/beforeEach.ts new file mode 100644 index 0000000..c0d4b0c --- /dev/null +++ b/admin/src/router/guards/beforeEach.ts @@ -0,0 +1,452 @@ +/** + * 路由全局前置守卫模块 + * + * 提供完整的路由导航守卫功能 + * + * ## 主要功能 + * + * - 登录状态验证和重定向 + * - 动态路由注册和权限控制 + * - 菜单数据获取和处理(前端/后端模式) + * - 用户信息获取和缓存 + * - 页面标题设置 + * - 工作标签页管理 + * - 进度条和加载动画控制 + * - 静态路由识别和处理 + * - 错误处理和异常跳转 + * + * ## 使用场景 + * + * - 路由跳转前的权限验证 + * - 动态菜单加载和路由注册 + * - 用户登录状态管理 + * - 页面访问控制 + * - 路由级别的加载状态管理 + * + * ## 工作流程 + * + * 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 { useAuthStore } from '@/store/modules/auth' +import { useMenuStore } from '@/store/modules/menu' +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 { 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 { + const settingStore = useSettingStore() + const userStore = useUserStore() + + // 启动进度条 + if (settingStore.showNprogress) { + NProgress.start() + } + + // 1. 检查登录状态(会话事实源 = auth store 的 token) + if (!handleLoginStatus(to, userStore, next)) { + return + } + + // 2. 强制改密门:会话有效但 must_change → 除改密页外一律重定向 + // (含登录页——已登录的引导流程不回登录;后端 409 语义的前端镜像) + if (!handleMustChangePassword(to, next)) { + return + } + + // 3. 检查路由初始化是否已失败(防止死循环) + if (routeInitFailed) { + // 已经失败过,直接放行到错误页面,不再重试 + if (to.matched.length > 0) { + next() + } else { + // 未匹配到路由,跳转到 500 页面 + next({ name: 'Exception500', replace: true }) + } + return + } + + // 4. 处理动态路由注册 + if (!routeRegistry?.isRegistered() && userStore.isLogin) { + // 防止并发请求(快速连续导航场景) + if (routeInitInProgress) { + // 正在初始化中,等待完成后重新导航 + next(false) + return + } + await handleDynamicRoutes(to, next, router) + return + } + + // 5. 处理根路径重定向 + if (handleRootPathRedirect(to, next)) { + return + } + + // 6. 处理已匹配的路由 + if (to.matched.length > 0) { + setWorktab(to) + setPageTitle(to) + next() + return + } + + // 7. 未匹配到路由,跳转到 404 + next({ name: 'Exception404' }) +} + +/** + * 强制改密门 + * + * 会话有效且 must_change_password=true 时,除强制改密页本身外 + * 一律重定向(含登录页与静态异常页——改完初始密码前不进后台)。 + * 后端同语义:受保护端点 409(HTTP client 全局跳转兜底)。 + * + * @returns true 表示可以继续,false 表示已处理跳转 + */ +function handleMustChangePassword( + to: RouteLocationNormalized, + next: NavigationGuardNext +): boolean { + const authStore = useAuthStore() + if (!authStore.isAuthenticated || !authStore.mustChangePassword) { + return true + } + if (to.path === RoutesAlias.ForceChangePassword) { + next() + return false + } + next({ path: RoutesAlias.ForceChangePassword, replace: true }) + return false +} + +/** + * 处理登录状态 + * + * 会话事实源为 auth store 的 token(localStorage 持久化,刷新恢复); + * 模板 user store 的 isLogin 由登录/登出同步维护(此处对遗留残留自愈)。 + * @returns true 表示可以继续,false 表示已处理跳转 + */ +function handleLoginStatus( + to: RouteLocationNormalized, + userStore: ReturnType, + next: NavigationGuardNext +): boolean { + const authStore = useAuthStore() + + // 已认证:放行(自愈模板侧登录标记——如 Task 2 桩时代的残留状态) + if (authStore.isAuthenticated) { + if (!userStore.isLogin) { + userStore.setLoginStatus(true) + } + return true + } + + // 未认证:登录页/静态路由放行,但强制改密页需要会话——回登录页 + if (to.path === RoutesAlias.Login || isStaticRoute(to.path)) { + if (to.path === RoutesAlias.ForceChangePassword) { + next({ name: 'Login', replace: true }) + return false + } + return true + } + + // 未登录且访问需要权限的页面,跳转到登录页并携带 redirect 参数 + 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) => { + // 404 catch-all 路由不应视为可匿名访问的静态页, + // 否则未登录时手动输入任意地址会直接落到 404,无法跳转登录页。 + if (route.name === 'Exception404') { + return false + } + + // 处理动态路由参数匹配 + 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 { + // 标记初始化进行中 + routeInitInProgress = true + + // 显示 loading + pendingLoading = true + loadingService.showLoading() + + try { + // 1. 派生用户信息(auth store 登录快照 → 模板 UserInfo) + deriveUserInfo() + + // 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. 静态路由不依赖菜单权限,初始化后直接恢复目标地址。 + if (isStaticRoute(to.path)) { + routeInitInProgress = false + next({ + path: to.path, + query: to.query, + hash: to.hash, + replace: true + }) + return + } + + // 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() + + // 标记初始化失败,防止死循环 + routeInitFailed = true + routeInitInProgress = false + + // 输出详细错误信息,便于排查 + if (isHttpError(error)) { + console.error(`[RouteGuard] 错误码: ${error.code}, 消息: ${error.message}`) + } + + // 跳转到 500 页面,使用 replace 避免产生历史记录 + next({ name: 'Exception500', replace: true }) + } +} + +/** + * 派生用户信息 + * + * 后端无用户信息端点——登录响应(role/username)已随 auth store 持久化, + * 此处映射为模板消费形态(roles 供菜单角色过滤,见 MenuProcessor)。 + */ +function deriveUserInfo(): void { + const authStore = useAuthStore() + const userStore = useUserStore() + userStore.setUserInfo(authStore.userInfo) + // 检查并清理工作台标签页(如果是不同用户登录) + userStore.checkAndClearWorktabs() +} + +/** + * 重置路由相关状态 + */ +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 +} diff --git a/admin/src/router/index.ts b/admin/src/router/index.ts new file mode 100644 index 0000000..286ae58 --- /dev/null +++ b/admin/src/router/index.ts @@ -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): void { + configureNProgress() // 顶部进度条 + setupBeforeEachGuard(router) // 路由前置守卫 + setupAfterEachGuard(router) // 路由后置守卫 + app.use(router) +} + +// 主页路径,默认使用菜单第一个有效路径,配置后使用此路径 +export const HOME_PAGE_PATH = '' diff --git a/admin/src/router/modules/config.ts b/admin/src/router/modules/config.ts new file mode 100644 index 0000000..7be14da --- /dev/null +++ b/admin/src/router/modules/config.ts @@ -0,0 +1,24 @@ +import { AppRouteRecord } from '@/types/router' + +/** 配置中心——超管与租户管理员均可见(租户配置组/站点/品牌) */ +export const configRoutes: AppRouteRecord = { + name: 'Config', + path: '/config', + component: '/index/index', + meta: { + title: 'menus.config.title', + icon: 'ri:settings-4-line' + }, + children: [ + { + path: 'center', + name: 'ConfigCenter', + component: '/config/center', + meta: { + title: 'menus.config.center', + icon: 'ri:equalizer-line', + keepAlive: false + } + } + ] +} diff --git a/admin/src/router/modules/conversations.ts b/admin/src/router/modules/conversations.ts new file mode 100644 index 0000000..ef96bc3 --- /dev/null +++ b/admin/src/router/modules/conversations.ts @@ -0,0 +1,24 @@ +import { AppRouteRecord } from '@/types/router' + +/** 会话与线索——超管与租户管理员均可见 */ +export const conversationsRoutes: AppRouteRecord = { + name: 'Conversations', + path: '/conversations', + component: '/index/index', + meta: { + title: 'menus.conversations.title', + icon: 'ri:chat-3-line' + }, + children: [ + { + path: 'list', + name: 'ConversationList', + component: '/conversations/list', + meta: { + title: 'menus.conversations.list', + icon: 'ri:chat-4-line', + keepAlive: false + } + } + ] +} diff --git a/admin/src/router/modules/dashboard.ts b/admin/src/router/modules/dashboard.ts new file mode 100644 index 0000000..b1d2455 --- /dev/null +++ b/admin/src/router/modules/dashboard.ts @@ -0,0 +1,24 @@ +import { AppRouteRecord } from '@/types/router' + +export const dashboardRoutes: AppRouteRecord = { + name: 'Dashboard', + path: '/dashboard', + component: '/index/index', + meta: { + title: 'menus.dashboard.title', + icon: 'ri:dashboard-line' + }, + children: [ + { + path: 'console', + name: 'Console', + component: '/dashboard/console', + meta: { + title: 'menus.dashboard.console', + icon: 'ri:home-smile-2-line', + keepAlive: false, + fixedTab: true + } + } + ] +} diff --git a/admin/src/router/modules/index.ts b/admin/src/router/modules/index.ts new file mode 100644 index 0000000..c7271fc --- /dev/null +++ b/admin/src/router/modules/index.ts @@ -0,0 +1,20 @@ +import { AppRouteRecord } from '@/types/router' +import { dashboardRoutes } from './dashboard' +import { tenantsRoutes } from './tenants' +import { conversationsRoutes } from './conversations' +import { configRoutes } from './config' +import { statsRoutes } from './stats' +import { settingsRoutes } from './settings' + +/** + * 导出所有模块化路由(前端权限模式:菜单按 meta.roles 过滤, + * 角色值与后端一致:super 全量;tenant 隐藏 租户管理/平台设置) + */ +export const routeModules: AppRouteRecord[] = [ + dashboardRoutes, + tenantsRoutes, + conversationsRoutes, + configRoutes, + statsRoutes, + settingsRoutes +] diff --git a/admin/src/router/modules/settings.ts b/admin/src/router/modules/settings.ts new file mode 100644 index 0000000..cd86376 --- /dev/null +++ b/admin/src/router/modules/settings.ts @@ -0,0 +1,25 @@ +import { AppRouteRecord } from '@/types/router' + +/** 平台设置——仅超管(super)可见,租户管理员整个菜单组隐藏 */ +export const settingsRoutes: AppRouteRecord = { + name: 'Settings', + path: '/settings', + component: '/index/index', + meta: { + title: 'menus.settings.title', + icon: 'ri:shield-keyhole-line', + roles: ['super'] + }, + children: [ + { + path: 'platform', + name: 'PlatformSettings', + component: '/settings/platform', + meta: { + title: 'menus.settings.platform', + icon: 'ri:shield-star-line', + keepAlive: false + } + } + ] +} diff --git a/admin/src/router/modules/stats.ts b/admin/src/router/modules/stats.ts new file mode 100644 index 0000000..63102f8 --- /dev/null +++ b/admin/src/router/modules/stats.ts @@ -0,0 +1,24 @@ +import { AppRouteRecord } from '@/types/router' + +/** 统计分析——超管与租户管理员均可见 */ +export const statsRoutes: AppRouteRecord = { + name: 'Stats', + path: '/stats', + component: '/index/index', + meta: { + title: 'menus.stats.title', + icon: 'ri:bar-chart-2-line' + }, + children: [ + { + path: 'overview', + name: 'StatsOverview', + component: '/stats/overview', + meta: { + title: 'menus.stats.overview', + icon: 'ri:line-chart-line', + keepAlive: false + } + } + ] +} diff --git a/admin/src/router/modules/tenants.ts b/admin/src/router/modules/tenants.ts new file mode 100644 index 0000000..afbe573 --- /dev/null +++ b/admin/src/router/modules/tenants.ts @@ -0,0 +1,25 @@ +import { AppRouteRecord } from '@/types/router' + +/** 租户管理——仅超管(super)可见,租户管理员整个菜单组隐藏 */ +export const tenantsRoutes: AppRouteRecord = { + name: 'Tenants', + path: '/tenants', + component: '/index/index', + meta: { + title: 'menus.tenants.title', + icon: 'ri:building-2-line', + roles: ['super'] + }, + children: [ + { + path: 'list', + name: 'TenantList', + component: '/tenants/list', + meta: { + title: 'menus.tenants.list', + icon: 'ri:building-line', + keepAlive: false + } + } + ] +} diff --git a/admin/src/router/routes/asyncRoutes.ts b/admin/src/router/routes/asyncRoutes.ts new file mode 100644 index 0000000..ccf1201 --- /dev/null +++ b/admin/src/router/routes/asyncRoutes.ts @@ -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 diff --git a/admin/src/router/routes/staticRoutes.ts b/admin/src/router/routes/staticRoutes.ts new file mode 100644 index 0000000..9326e47 --- /dev/null +++ b/admin/src/router/routes/staticRoutes.ts @@ -0,0 +1,66 @@ +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/force-change-password', + name: 'ForceChangePassword', + component: () => import('@views/auth/force-change-password/index.vue'), + meta: { title: 'menus.forceChangePassword.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' } + } + ] + } +] diff --git a/admin/src/router/routesAlias.ts b/admin/src/router/routesAlias.ts new file mode 100644 index 0000000..e66113b --- /dev/null +++ b/admin/src/router/routesAlias.ts @@ -0,0 +1,9 @@ +/** + * 公共路由别名 + * 存放系统级公共路由路径,如布局容器、登录页等 + */ +export enum RoutesAlias { + Layout = '/index/index', // 布局容器 + Login = '/auth/login', // 登录页 + ForceChangePassword = '/auth/force-change-password' // 强制修改密码页 +} diff --git a/admin/src/store/index.ts b/admin/src/store/index.ts new file mode 100644 index 0000000..b485999 --- /dev/null +++ b/admin/src/store/index.ts @@ -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): void { + app.use(store) +} diff --git a/admin/src/store/modules/auth.ts b/admin/src/store/modules/auth.ts new file mode 100644 index 0000000..9b2563c --- /dev/null +++ b/admin/src/store/modules/auth.ts @@ -0,0 +1,188 @@ +/** + * 认证状态管理(Phase 3 Task 3) + * + * 会话事实源:token / role / tenantSlug / username / mustChangePassword。 + * 持久化 localStorage(键 all-kefu-auth,all-kefu 前缀与模板 sys-v* 键区隔), + * 刷新页面恢复会话;路由守卫与 HTTP client(401/409 全局处理)消费。 + * + * 模板侧同步:登录成功后把 token / 登录标记 / 用户信息(roles 供菜单过滤) + * 同步进模板的 user store(isLogin/accessToken/info 是模板路由初始化与 + * 顶栏展示的既有依赖);登出委托 userStore.logOut() 完成模板全套清理 + * (动态路由注销、工作台标签、跳登录页)。 + * + * 与 @/api/client 的模块环(client → 本模块 → @/api/auth → client): + * 双方均只在函数体内访问对方导出(ESM 活绑定),模块求值期无交叉读取。 + */ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { fetchChangePassword, fetchLogin, fetchLogout } from '@/api/auth' +import { useUserStore } from '@/store/modules/user' +import { RoutesAlias } from '@/router/routesAlias' + +/** 平台角色(与后端 token 快照一致) */ +export type AdminRole = 'super' | 'tenant' + +/** 登录参数(POST /api/platform/auth/login) */ +export interface LoginParams { + username: string + password: string +} + +/** 改密参数(POST /api/platform/auth/change-password) */ +export interface ChangePasswordParams { + old_password: string + new_password: string +} + +/** 登录响应(后端 auth_routes.login) */ +export interface LoginResponse { + token: string + role: AdminRole + tenant_slug: string | null + must_change_password: boolean +} + +/** 持久化键(all-kefu 前缀;user store 等模板键为 sys-v* 体系) */ +const AUTH_STORAGE_KEY = 'all-kefu-auth' + +/** + * 用户名 → 稳定伪 ID(djb2 哈希) + * + * 后端不下发 user_id;模板 user store 的工作台跨用户清理 + * (checkAndClearWorktabs)按 userId 比对,用确定性哈希保持该语义。 + */ +function stableUserId(username: string): number { + let hash = 5381 + for (let i = 0; i < username.length; i++) { + hash = ((hash << 5) + hash + username.charCodeAt(i)) | 0 + } + return Math.abs(hash) || 1 +} + +export const useAuthStore = defineStore( + 'authStore', + () => { + // ---- 状态 ------------------------------------------------------------------ + const token = ref('') + const role = ref('') + const tenantSlug = ref(null) + const username = ref('') + const mustChangePassword = ref(false) + + // ---- 计算属性 ---------------------------------------------------------------- + const isAuthenticated = computed(() => token.value !== '') + + /** 模板 Api.Auth.UserInfo 映射(守卫初始化动态路由/菜单角色过滤用) */ + const userInfo = computed(() => ({ + buttons: [], + roles: role.value ? [role.value] : [], + userId: stableUserId(username.value), + userName: username.value, + email: '' + })) + + // ---- 内部工具 ---------------------------------------------------------------- + /** 登录成功后的状态落地(不调 API) */ + function applyLogin(loginUsername: string, res: LoginResponse): void { + token.value = res.token + role.value = res.role + tenantSlug.value = res.tenant_slug + mustChangePassword.value = res.must_change_password + username.value = loginUsername + syncUserStore() + } + + /** 同步模板 user store(模板路由初始化/顶栏依赖其状态) */ + function syncUserStore(): void { + const userStore = useUserStore() + userStore.setToken(token.value, '') + userStore.setLoginStatus(true) + userStore.setUserInfo(userInfo.value) + userStore.checkAndClearWorktabs() + } + + /** 清空本 store 全部会话状态 */ + function clearSession(): void { + token.value = '' + role.value = '' + tenantSlug.value = null + username.value = '' + mustChangePassword.value = false + } + + /** + * 页面跳转(动态 import 断开与 router 模块的静态环; + * 已在目标页则不重复跳——防 401/409 拦截死循环) + */ + async function goTo(path: string): Promise { + const { router } = await import('@/router') + if (router.currentRoute.value.path === path) return + await router.replace(path) + } + + // ---- Actions ----------------------------------------------------------------- + /** 登录:调 API 成功后落地会话状态(失败向上抛,由调用方/全局拦截提示) */ + async function login(params: LoginParams): Promise { + const res = await fetchLogin(params) + applyLogin(params.username, res) + return res + } + + /** + * 登出:调后端撤销 token(best effort,失败也照常本地清理), + * 清空会话后委托模板 userStore.logOut()(动态路由注销 + 跳登录页) + */ + async function logout(): Promise { + try { + await fetchLogout() + } catch { + // token 已失效/网络不可达:本地清理照常进行 + } + clearSession() + useUserStore().logOut() + } + + /** + * 改密:成功后清 must_change 标志(后端已就地清当前 token 的标志, + * 会话保持,改完无需重登) + */ + async function changePassword(params: ChangePasswordParams): Promise { + await fetchChangePassword(params) + mustChangePassword.value = false + } + + /** 401 全局处理:清会话 + 模板侧全套清理(userStore.logOut 含跳登录) */ + function handleSessionExpired(): void { + clearSession() + useUserStore().logOut() + } + + /** 409 强制改密全局处理:置标志 + 跳改密页(已在改密页不重复跳) */ + async function handleMustChangePassword(): Promise { + mustChangePassword.value = true + await goTo(RoutesAlias.ForceChangePassword) + } + + return { + token, + role, + tenantSlug, + username, + mustChangePassword, + isAuthenticated, + userInfo, + login, + logout, + changePassword, + clearSession, + handleSessionExpired, + handleMustChangePassword + } + }, + { + persist: { + key: AUTH_STORAGE_KEY, + storage: localStorage + } + } +) diff --git a/admin/src/store/modules/menu.ts b/admin/src/store/modules/menu.ts new file mode 100644 index 0000000..85d13da --- /dev/null +++ b/admin/src/store/modules/menu.ts @@ -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([]) + /** 菜单宽度 */ + 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 + } +}) diff --git a/admin/src/store/modules/setting.ts b/admin/src/store/modules/setting.ts new file mode 100644 index 0000000..2878259 --- /dev/null +++ b/admin/src/store/modules/setting.ts @@ -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 + } + } +) diff --git a/admin/src/store/modules/table.ts b/admin/src/store/modules/table.ts new file mode 100644 index 0000000..094c310 --- /dev/null +++ b/admin/src/store/modules/table.ts @@ -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(false) + + // 是否全屏 + 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 + } + } +) diff --git a/admin/src/store/modules/user.ts b/admin/src/store/modules/user.ts new file mode 100644 index 0000000..6ae2fff --- /dev/null +++ b/admin/src/store/modules/user.ts @@ -0,0 +1,237 @@ +/** + * 用户状态管理模块 + * + * 提供用户相关的状态管理 + * + * ## 主要功能 + * + * - 用户登录状态管理 + * - 用户信息存储 + * - 访问令牌和刷新令牌管理 + * - 语言设置 + * - 搜索历史记录 + * - 锁屏状态和密码管理 + * - 登出清理逻辑 + * + * ## 使用场景 + * + * - 用户登录和认证 + * - 权限验证 + * - 个人信息展示 + * - 多语言切换 + * - 锁屏功能 + * - 搜索历史管理 + * + * ## 持久化 + * + * - 使用 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 { RoutesAlias } from '@/router/routesAlias' +import { StorageConfig } from '@/utils/storage/storage-config' + +/** + * 用户状态管理 + * 管理用户登录状态、个人信息、语言设置、搜索历史、锁屏状态等 + */ +export const useUserStore = defineStore( + 'userStore', + () => { + // 语言设置 + const language = ref(LanguageEnum.ZH) + // 登录状态 + const isLogin = ref(false) + // 锁屏状态 + const isLock = ref(false) + // 锁屏密码 + const lockPassword = ref('') + // 用户信息 + const info = ref>({}) + // 搜索历史记录 + const searchHistory = ref([]) + // 访问令牌 + 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 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 logOut = () => { + // 保存当前用户 ID,用于下次登录时判断是否为同一用户 + const currentUserId = info.value.userId + 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 !== RoutesAlias.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.userId + + // 无法获取当前用户 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, + setLoginStatus, + setLanguage, + setSearchHistory, + setLockStatus, + setLockPassword, + setToken, + logOut, + checkAndClearWorktabs + } + }, + { + persist: { + key: 'user', + storage: localStorage + } + } +) diff --git a/admin/src/store/modules/worktab.ts b/admin/src/store/modules/worktab.ts new file mode 100644 index 0000000..caa0d90 --- /dev/null +++ b/admin/src/store/modules/worktab.ts @@ -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 + opened: WorkTab[] + keepAliveExclude: string[] +} + +/** + * 工作台标签页管理 Store + */ +export const useWorktabStore = defineStore( + 'worktabStore', + () => { + // 状态定义 + const current = ref>({}) + const opened = ref([]) + const keepAliveExclude = ref([]) + + // 计算属性 + 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): 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): 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 + } + } +) diff --git a/admin/src/types/api/api.d.ts b/admin/src/types/api/api.d.ts new file mode 100644 index 0000000..45e3a40 --- /dev/null +++ b/admin/src/types/api/api.d.ts @@ -0,0 +1,129 @@ +/** + * API 接口类型定义模块 + * + * 提供所有后端接口的类型定义 + * + * ## 主要功能 + * + * - 通用类型(分页参数、响应结构等) + * - 认证类型(登录、用户信息等) + * - 系统管理类型(用户、角色等) + * - 全局命名空间声明 + * + * ## 使用场景 + * + * - API 请求参数类型约束 + * - API 响应数据类型定义 + * - 接口文档类型同步 + * + * ## 注意事项 + * + * - 在 .vue 文件使用需要在 eslint.config.mjs 中配置 globals: { Api: 'readonly' } + * - 使用全局命名空间,无需导入即可使用 + * + * ## 使用方式 + * + * ```typescript + * const info: Api.Auth.UserInfo = { buttons: [], roles: ['super'], userId: 1, userName: 'admin', email: '' } + * ``` + * + * @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 + + /** 分页响应基础结构 */ + interface PaginatedResponse { + records: T[] + current: number + size: number + total: number + } + + /** 启用状态 */ + type EnableStatus = '1' | '2' + } + + /** 认证类型(登录/改密参数与响应见 src/store/modules/auth.ts,与后端字段一一对应) */ + namespace Auth { + /** + * 用户信息(模板消费形态:路由初始化/菜单角色过滤/顶栏展示)。 + * 后端不下发用户信息端点——由 auth store 从登录快照派生 + * (roles=[role]、userName=登录名、userId=用户名稳定哈希)。 + */ + interface UserInfo { + buttons: string[] + roles: string[] + userId: number + userName: string + email: string + avatar?: string + } + } + + /** 系统管理类型 */ + namespace SystemManage { + /** 用户列表 */ + type UserList = Api.Common.PaginatedResponse + + /** 用户列表项 */ + interface UserListItem { + id: number + avatar: string + status: string + userName: string + userGender: string + nickName: string + userPhone: string + userEmail: string + userRoles: string[] + createBy: string + createTime: string + updateBy: string + updateTime: string + } + + /** 用户搜索参数 */ + type UserSearchParams = Partial< + Pick & + Api.Common.CommonSearchParams + > + + /** 角色列表 */ + type RoleList = Api.Common.PaginatedResponse + + /** 角色列表项 */ + interface RoleListItem { + roleId: number + roleName: string + roleCode: string + description: string + enabled: boolean + createTime: string + } + + /** 角色搜索参数 */ + type RoleSearchParams = Partial< + Pick & + Api.Common.CommonSearchParams & { + startTime: string | null + endTime: string | null + } + > + } +} diff --git a/admin/src/types/common/index.ts b/admin/src/types/common/index.ts new file mode 100644 index 0000000..7e751d1 --- /dev/null +++ b/admin/src/types/common/index.ts @@ -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 = Record + +// 键值对类型 +export type KeyValue = { + 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' diff --git a/admin/src/types/common/response.ts b/admin/src/types/common/response.ts new file mode 100644 index 0000000..4a5fdab --- /dev/null +++ b/admin/src/types/common/response.ts @@ -0,0 +1,30 @@ +/** + * API 响应类型定义模块 + * + * 提供统一的 API 响应结构类型定义 + * + * ## 主要功能 + * + * - 基础响应结构定义 + * - 泛型支持(适配不同数据类型) + * - 统一的响应格式约束 + * + * ## 使用场景 + * + * - API 请求响应类型约束 + * - 接口数据类型定义 + * - 响应数据解析 + * + * @module types/common/response + * @author Art Design Pro Team + */ + +/** 基础 API 响应结构 */ +export interface BaseResponse { + /** 状态码 */ + code: number + /** 消息 */ + msg: string + /** 数据 */ + data: T +} diff --git a/admin/src/types/component/chart.ts b/admin/src/types/component/chart.ts new file mode 100644 index 0000000..c3225c9 --- /dev/null +++ b/admin/src/types/component/chart.ts @@ -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 +} diff --git a/admin/src/types/component/index.ts b/admin/src/types/component/index.ts new file mode 100644 index 0000000..b9e90c6 --- /dev/null +++ b/admin/src/types/component/index.ts @@ -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 { + // 列类型 + type?: 'selection' | 'expand' | 'index' | 'globalIndex' + // 列属性名 + prop?: string + // 列标题 + label?: string + // 列宽度 + width?: string | number + // 最小列宽度 + minWidth?: string | number + // 固定列 + fixed?: boolean | 'left' | 'right' + // 是否可排序 + sortable?: boolean | 'custom' + // 过滤器选项 + 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 +} diff --git a/admin/src/types/config/index.ts b/admin/src/types/config/index.ts new file mode 100644 index 0000000..dd144de --- /dev/null +++ b/admin/src/types/config/index.ts @@ -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 +} diff --git a/admin/src/types/directive/directive.d.ts b/admin/src/types/directive/directive.d.ts new file mode 100644 index 0000000..eb0154e --- /dev/null +++ b/admin/src/types/directive/directive.d.ts @@ -0,0 +1,15 @@ +import type { + AuthDirective, + RolesDirective, + RippleDirective, + HighlightDirective +} from '@/directives' + +declare module 'vue' { + export interface GlobalDirectives { + vAuth: AuthDirective + vRoles: RolesDirective + vRipple: RippleDirective + vHighlight: HighlightDirective + } +} diff --git a/admin/src/types/import/auto-imports.d.ts b/admin/src/types/import/auto-imports.d.ts new file mode 100644 index 0000000..a3d6466 --- /dev/null +++ b/admin/src/types/import/auto-imports.d.ts @@ -0,0 +1,319 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +// biome-ignore lint: disable +export {} +declare global { + const EffectScope: typeof import('vue')['EffectScope'] + const ElLoading: typeof import('element-plus/es')['ElLoading'] + const ElMessage: typeof import('element-plus/es')['ElMessage'] + const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] + const asyncComputed: typeof import('@vueuse/core')['asyncComputed'] + const autoResetRef: typeof import('@vueuse/core')['autoResetRef'] + const computed: typeof import('vue')['computed'] + const computedAsync: typeof import('@vueuse/core')['computedAsync'] + const computedEager: typeof import('@vueuse/core')['computedEager'] + const computedInject: typeof import('@vueuse/core')['computedInject'] + const computedWithControl: typeof import('@vueuse/core')['computedWithControl'] + const controlledComputed: typeof import('@vueuse/core')['controlledComputed'] + const controlledRef: typeof import('@vueuse/core')['controlledRef'] + const createApp: typeof import('vue')['createApp'] + const createEventHook: typeof import('@vueuse/core')['createEventHook'] + const createGlobalState: typeof import('@vueuse/core')['createGlobalState'] + const createInjectionState: typeof import('@vueuse/core')['createInjectionState'] + const createPinia: typeof import('pinia')['createPinia'] + const createReactiveFn: typeof import('@vueuse/core')['createReactiveFn'] + const createRef: typeof import('@vueuse/core')['createRef'] + const createReusableTemplate: typeof import('@vueuse/core')['createReusableTemplate'] + const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable'] + const createTemplatePromise: typeof import('@vueuse/core')['createTemplatePromise'] + const createUnrefFn: typeof import('@vueuse/core')['createUnrefFn'] + const customRef: typeof import('vue')['customRef'] + const debouncedRef: typeof import('@vueuse/core')['debouncedRef'] + const debouncedWatch: typeof import('@vueuse/core')['debouncedWatch'] + const defineAsyncComponent: typeof import('vue')['defineAsyncComponent'] + const defineComponent: typeof import('vue')['defineComponent'] + const defineStore: typeof import('pinia')['defineStore'] + const eagerComputed: typeof import('@vueuse/core')['eagerComputed'] + const effectScope: typeof import('vue')['effectScope'] + const extendRef: typeof import('@vueuse/core')['extendRef'] + const getActivePinia: typeof import('pinia')['getActivePinia'] + const getCurrentInstance: typeof import('vue')['getCurrentInstance'] + const getCurrentScope: typeof import('vue')['getCurrentScope'] + const getCurrentWatcher: typeof import('vue')['getCurrentWatcher'] + const h: typeof import('vue')['h'] + const ignorableWatch: typeof import('@vueuse/core')['ignorableWatch'] + const inject: typeof import('vue')['inject'] + const injectLocal: typeof import('@vueuse/core')['injectLocal'] + const isDefined: typeof import('@vueuse/core')['isDefined'] + const isProxy: typeof import('vue')['isProxy'] + const isReactive: typeof import('vue')['isReactive'] + const isReadonly: typeof import('vue')['isReadonly'] + const isRef: typeof import('vue')['isRef'] + const isShallow: typeof import('vue')['isShallow'] + const makeDestructurable: typeof import('@vueuse/core')['makeDestructurable'] + const mapActions: typeof import('pinia')['mapActions'] + const mapGetters: typeof import('pinia')['mapGetters'] + const mapState: typeof import('pinia')['mapState'] + const mapStores: typeof import('pinia')['mapStores'] + const mapWritableState: typeof import('pinia')['mapWritableState'] + const markRaw: typeof import('vue')['markRaw'] + const nextTick: typeof import('vue')['nextTick'] + const onActivated: typeof import('vue')['onActivated'] + const onBeforeMount: typeof import('vue')['onBeforeMount'] + const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave'] + const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate'] + const onBeforeUnmount: typeof import('vue')['onBeforeUnmount'] + const onBeforeUpdate: typeof import('vue')['onBeforeUpdate'] + const onClickOutside: typeof import('@vueuse/core')['onClickOutside'] + const onDeactivated: typeof import('vue')['onDeactivated'] + const onElementRemoval: typeof import('@vueuse/core')['onElementRemoval'] + const onErrorCaptured: typeof import('vue')['onErrorCaptured'] + const onKeyStroke: typeof import('@vueuse/core')['onKeyStroke'] + const onLongPress: typeof import('@vueuse/core')['onLongPress'] + const onMounted: typeof import('vue')['onMounted'] + const onRenderTracked: typeof import('vue')['onRenderTracked'] + const onRenderTriggered: typeof import('vue')['onRenderTriggered'] + const onScopeDispose: typeof import('vue')['onScopeDispose'] + const onServerPrefetch: typeof import('vue')['onServerPrefetch'] + const onStartTyping: typeof import('@vueuse/core')['onStartTyping'] + const onUnmounted: typeof import('vue')['onUnmounted'] + const onUpdated: typeof import('vue')['onUpdated'] + const onWatcherCleanup: typeof import('vue')['onWatcherCleanup'] + const pausableWatch: typeof import('@vueuse/core')['pausableWatch'] + const provide: typeof import('vue')['provide'] + const provideLocal: typeof import('@vueuse/core')['provideLocal'] + const reactify: typeof import('@vueuse/core')['reactify'] + const reactifyObject: typeof import('@vueuse/core')['reactifyObject'] + const reactive: typeof import('vue')['reactive'] + const reactiveComputed: typeof import('@vueuse/core')['reactiveComputed'] + const reactiveOmit: typeof import('@vueuse/core')['reactiveOmit'] + const reactivePick: typeof import('@vueuse/core')['reactivePick'] + const readonly: typeof import('vue')['readonly'] + const ref: typeof import('vue')['ref'] + const refAutoReset: typeof import('@vueuse/core')['refAutoReset'] + const refDebounced: typeof import('@vueuse/core')['refDebounced'] + const refDefault: typeof import('@vueuse/core')['refDefault'] + const refThrottled: typeof import('@vueuse/core')['refThrottled'] + const refWithControl: typeof import('@vueuse/core')['refWithControl'] + const resolveComponent: typeof import('vue')['resolveComponent'] + const resolveRef: typeof import('@vueuse/core')['resolveRef'] + const resolveUnref: typeof import('@vueuse/core')['resolveUnref'] + const setActivePinia: typeof import('pinia')['setActivePinia'] + const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const shallowReactive: typeof import('vue')['shallowReactive'] + const shallowReadonly: typeof import('vue')['shallowReadonly'] + const shallowRef: typeof import('vue')['shallowRef'] + const storeToRefs: typeof import('pinia')['storeToRefs'] + const syncRef: typeof import('@vueuse/core')['syncRef'] + const syncRefs: typeof import('@vueuse/core')['syncRefs'] + const templateRef: typeof import('@vueuse/core')['templateRef'] + const throttledRef: typeof import('@vueuse/core')['throttledRef'] + const throttledWatch: typeof import('@vueuse/core')['throttledWatch'] + const toRaw: typeof import('vue')['toRaw'] + const toReactive: typeof import('@vueuse/core')['toReactive'] + const toRef: typeof import('vue')['toRef'] + const toRefs: typeof import('vue')['toRefs'] + const toValue: typeof import('vue')['toValue'] + const triggerRef: typeof import('vue')['triggerRef'] + const tryOnBeforeMount: typeof import('@vueuse/core')['tryOnBeforeMount'] + const tryOnBeforeUnmount: typeof import('@vueuse/core')['tryOnBeforeUnmount'] + const tryOnMounted: typeof import('@vueuse/core')['tryOnMounted'] + const tryOnScopeDispose: typeof import('@vueuse/core')['tryOnScopeDispose'] + const tryOnUnmounted: typeof import('@vueuse/core')['tryOnUnmounted'] + const unref: typeof import('vue')['unref'] + const unrefElement: typeof import('@vueuse/core')['unrefElement'] + const until: typeof import('@vueuse/core')['until'] + const useActiveElement: typeof import('@vueuse/core')['useActiveElement'] + const useAnimate: typeof import('@vueuse/core')['useAnimate'] + const useArrayDifference: typeof import('@vueuse/core')['useArrayDifference'] + const useArrayEvery: typeof import('@vueuse/core')['useArrayEvery'] + const useArrayFilter: typeof import('@vueuse/core')['useArrayFilter'] + const useArrayFind: typeof import('@vueuse/core')['useArrayFind'] + const useArrayFindIndex: typeof import('@vueuse/core')['useArrayFindIndex'] + const useArrayFindLast: typeof import('@vueuse/core')['useArrayFindLast'] + const useArrayIncludes: typeof import('@vueuse/core')['useArrayIncludes'] + const useArrayJoin: typeof import('@vueuse/core')['useArrayJoin'] + const useArrayMap: typeof import('@vueuse/core')['useArrayMap'] + const useArrayReduce: typeof import('@vueuse/core')['useArrayReduce'] + const useArraySome: typeof import('@vueuse/core')['useArraySome'] + const useArrayUnique: typeof import('@vueuse/core')['useArrayUnique'] + const useAsyncQueue: typeof import('@vueuse/core')['useAsyncQueue'] + const useAsyncState: typeof import('@vueuse/core')['useAsyncState'] + const useAttrs: typeof import('vue')['useAttrs'] + const useBase64: typeof import('@vueuse/core')['useBase64'] + const useBattery: typeof import('@vueuse/core')['useBattery'] + const useBluetooth: typeof import('@vueuse/core')['useBluetooth'] + const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints'] + const useBroadcastChannel: typeof import('@vueuse/core')['useBroadcastChannel'] + const useBrowserLocation: typeof import('@vueuse/core')['useBrowserLocation'] + const useCached: typeof import('@vueuse/core')['useCached'] + const useClipboard: typeof import('@vueuse/core')['useClipboard'] + const useClipboardItems: typeof import('@vueuse/core')['useClipboardItems'] + const useCloned: typeof import('@vueuse/core')['useCloned'] + const useColorMode: typeof import('@vueuse/core')['useColorMode'] + const useConfirmDialog: typeof import('@vueuse/core')['useConfirmDialog'] + const useCountdown: typeof import('@vueuse/core')['useCountdown'] + const useCounter: typeof import('@vueuse/core')['useCounter'] + const useCssModule: typeof import('vue')['useCssModule'] + const useCssVar: typeof import('@vueuse/core')['useCssVar'] + const useCssVars: typeof import('vue')['useCssVars'] + const useCurrentElement: typeof import('@vueuse/core')['useCurrentElement'] + const useCycleList: typeof import('@vueuse/core')['useCycleList'] + const useDark: typeof import('@vueuse/core')['useDark'] + const useDateFormat: typeof import('@vueuse/core')['useDateFormat'] + const useDebounce: typeof import('@vueuse/core')['useDebounce'] + const useDebounceFn: typeof import('@vueuse/core')['useDebounceFn'] + const useDebouncedRefHistory: typeof import('@vueuse/core')['useDebouncedRefHistory'] + const useDeviceMotion: typeof import('@vueuse/core')['useDeviceMotion'] + const useDeviceOrientation: typeof import('@vueuse/core')['useDeviceOrientation'] + const useDevicePixelRatio: typeof import('@vueuse/core')['useDevicePixelRatio'] + const useDevicesList: typeof import('@vueuse/core')['useDevicesList'] + const useDisplayMedia: typeof import('@vueuse/core')['useDisplayMedia'] + const useDocumentVisibility: typeof import('@vueuse/core')['useDocumentVisibility'] + const useDraggable: typeof import('@vueuse/core')['useDraggable'] + const useDropZone: typeof import('@vueuse/core')['useDropZone'] + const useElementBounding: typeof import('@vueuse/core')['useElementBounding'] + const useElementByPoint: typeof import('@vueuse/core')['useElementByPoint'] + const useElementHover: typeof import('@vueuse/core')['useElementHover'] + const useElementSize: typeof import('@vueuse/core')['useElementSize'] + const useElementVisibility: typeof import('@vueuse/core')['useElementVisibility'] + const useEventBus: typeof import('@vueuse/core')['useEventBus'] + const useEventListener: typeof import('@vueuse/core')['useEventListener'] + const useEventSource: typeof import('@vueuse/core')['useEventSource'] + const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper'] + const useFavicon: typeof import('@vueuse/core')['useFavicon'] + const useFetch: typeof import('@vueuse/core')['useFetch'] + const useFileDialog: typeof import('@vueuse/core')['useFileDialog'] + const useFileSystemAccess: typeof import('@vueuse/core')['useFileSystemAccess'] + const useFocus: typeof import('@vueuse/core')['useFocus'] + const useFocusWithin: typeof import('@vueuse/core')['useFocusWithin'] + const useFps: typeof import('@vueuse/core')['useFps'] + const useFullscreen: typeof import('@vueuse/core')['useFullscreen'] + const useGamepad: typeof import('@vueuse/core')['useGamepad'] + const useGeolocation: typeof import('@vueuse/core')['useGeolocation'] + const useId: typeof import('vue')['useId'] + const useIdle: typeof import('@vueuse/core')['useIdle'] + const useImage: typeof import('@vueuse/core')['useImage'] + const useInfiniteScroll: typeof import('@vueuse/core')['useInfiniteScroll'] + const useIntersectionObserver: typeof import('@vueuse/core')['useIntersectionObserver'] + const useInterval: typeof import('@vueuse/core')['useInterval'] + const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn'] + const useKeyModifier: typeof import('@vueuse/core')['useKeyModifier'] + const useLastChanged: typeof import('@vueuse/core')['useLastChanged'] + const useLink: typeof import('vue-router')['useLink'] + const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage'] + const useMagicKeys: typeof import('@vueuse/core')['useMagicKeys'] + const useManualRefHistory: typeof import('@vueuse/core')['useManualRefHistory'] + const useMediaControls: typeof import('@vueuse/core')['useMediaControls'] + const useMediaQuery: typeof import('@vueuse/core')['useMediaQuery'] + const useMemoize: typeof import('@vueuse/core')['useMemoize'] + const useMemory: typeof import('@vueuse/core')['useMemory'] + const useModel: typeof import('vue')['useModel'] + const useMounted: typeof import('@vueuse/core')['useMounted'] + const useMouse: typeof import('@vueuse/core')['useMouse'] + const useMouseInElement: typeof import('@vueuse/core')['useMouseInElement'] + const useMousePressed: typeof import('@vueuse/core')['useMousePressed'] + const useMutationObserver: typeof import('@vueuse/core')['useMutationObserver'] + const useNavigatorLanguage: typeof import('@vueuse/core')['useNavigatorLanguage'] + const useNetwork: typeof import('@vueuse/core')['useNetwork'] + const useNow: typeof import('@vueuse/core')['useNow'] + const useObjectUrl: typeof import('@vueuse/core')['useObjectUrl'] + const useOffsetPagination: typeof import('@vueuse/core')['useOffsetPagination'] + const useOnline: typeof import('@vueuse/core')['useOnline'] + const usePageLeave: typeof import('@vueuse/core')['usePageLeave'] + const useParallax: typeof import('@vueuse/core')['useParallax'] + const useParentElement: typeof import('@vueuse/core')['useParentElement'] + const usePerformanceObserver: typeof import('@vueuse/core')['usePerformanceObserver'] + const usePermission: typeof import('@vueuse/core')['usePermission'] + const usePointer: typeof import('@vueuse/core')['usePointer'] + const usePointerLock: typeof import('@vueuse/core')['usePointerLock'] + const usePointerSwipe: typeof import('@vueuse/core')['usePointerSwipe'] + const usePreferredColorScheme: typeof import('@vueuse/core')['usePreferredColorScheme'] + const usePreferredContrast: typeof import('@vueuse/core')['usePreferredContrast'] + const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark'] + const usePreferredLanguages: typeof import('@vueuse/core')['usePreferredLanguages'] + const usePreferredReducedMotion: typeof import('@vueuse/core')['usePreferredReducedMotion'] + const usePreferredReducedTransparency: typeof import('@vueuse/core')['usePreferredReducedTransparency'] + const usePrevious: typeof import('@vueuse/core')['usePrevious'] + const useRafFn: typeof import('@vueuse/core')['useRafFn'] + const useRefHistory: typeof import('@vueuse/core')['useRefHistory'] + const useResizeObserver: typeof import('@vueuse/core')['useResizeObserver'] + const useRoute: typeof import('vue-router')['useRoute'] + const useRouter: typeof import('vue-router')['useRouter'] + const useSSRWidth: typeof import('@vueuse/core')['useSSRWidth'] + const useScreenOrientation: typeof import('@vueuse/core')['useScreenOrientation'] + const useScreenSafeArea: typeof import('@vueuse/core')['useScreenSafeArea'] + const useScriptTag: typeof import('@vueuse/core')['useScriptTag'] + const useScroll: typeof import('@vueuse/core')['useScroll'] + const useScrollLock: typeof import('@vueuse/core')['useScrollLock'] + const useSessionStorage: typeof import('@vueuse/core')['useSessionStorage'] + const useShare: typeof import('@vueuse/core')['useShare'] + const useSlots: typeof import('vue')['useSlots'] + const useSorted: typeof import('@vueuse/core')['useSorted'] + const useSpeechRecognition: typeof import('@vueuse/core')['useSpeechRecognition'] + const useSpeechSynthesis: typeof import('@vueuse/core')['useSpeechSynthesis'] + const useStepper: typeof import('@vueuse/core')['useStepper'] + const useStorage: typeof import('@vueuse/core')['useStorage'] + const useStorageAsync: typeof import('@vueuse/core')['useStorageAsync'] + const useStyleTag: typeof import('@vueuse/core')['useStyleTag'] + const useSupported: typeof import('@vueuse/core')['useSupported'] + const useSwipe: typeof import('@vueuse/core')['useSwipe'] + const useTemplateRef: typeof import('vue')['useTemplateRef'] + const useTemplateRefsList: typeof import('@vueuse/core')['useTemplateRefsList'] + const useTextDirection: typeof import('@vueuse/core')['useTextDirection'] + const useTextSelection: typeof import('@vueuse/core')['useTextSelection'] + const useTextareaAutosize: typeof import('@vueuse/core')['useTextareaAutosize'] + const useThrottle: typeof import('@vueuse/core')['useThrottle'] + const useThrottleFn: typeof import('@vueuse/core')['useThrottleFn'] + const useThrottledRefHistory: typeof import('@vueuse/core')['useThrottledRefHistory'] + const useTimeAgo: typeof import('@vueuse/core')['useTimeAgo'] + const useTimeAgoIntl: typeof import('@vueuse/core')['useTimeAgoIntl'] + const useTimeout: typeof import('@vueuse/core')['useTimeout'] + const useTimeoutFn: typeof import('@vueuse/core')['useTimeoutFn'] + const useTimeoutPoll: typeof import('@vueuse/core')['useTimeoutPoll'] + const useTimestamp: typeof import('@vueuse/core')['useTimestamp'] + const useTitle: typeof import('@vueuse/core')['useTitle'] + const useToNumber: typeof import('@vueuse/core')['useToNumber'] + const useToString: typeof import('@vueuse/core')['useToString'] + const useToggle: typeof import('@vueuse/core')['useToggle'] + const useTransition: typeof import('@vueuse/core')['useTransition'] + const useUrlSearchParams: typeof import('@vueuse/core')['useUrlSearchParams'] + const useUserMedia: typeof import('@vueuse/core')['useUserMedia'] + const useVModel: typeof import('@vueuse/core')['useVModel'] + const useVModels: typeof import('@vueuse/core')['useVModels'] + const useVibrate: typeof import('@vueuse/core')['useVibrate'] + const useVirtualList: typeof import('@vueuse/core')['useVirtualList'] + const useWakeLock: typeof import('@vueuse/core')['useWakeLock'] + const useWebNotification: typeof import('@vueuse/core')['useWebNotification'] + const useWebSocket: typeof import('@vueuse/core')['useWebSocket'] + const useWebWorker: typeof import('@vueuse/core')['useWebWorker'] + const useWebWorkerFn: typeof import('@vueuse/core')['useWebWorkerFn'] + const useWindowFocus: typeof import('@vueuse/core')['useWindowFocus'] + const useWindowScroll: typeof import('@vueuse/core')['useWindowScroll'] + const useWindowSize: typeof import('@vueuse/core')['useWindowSize'] + const watch: typeof import('vue')['watch'] + const watchArray: typeof import('@vueuse/core')['watchArray'] + const watchAtMost: typeof import('@vueuse/core')['watchAtMost'] + const watchDebounced: typeof import('@vueuse/core')['watchDebounced'] + const watchDeep: typeof import('@vueuse/core')['watchDeep'] + const watchEffect: typeof import('vue')['watchEffect'] + const watchIgnorable: typeof import('@vueuse/core')['watchIgnorable'] + const watchImmediate: typeof import('@vueuse/core')['watchImmediate'] + const watchOnce: typeof import('@vueuse/core')['watchOnce'] + const watchPausable: typeof import('@vueuse/core')['watchPausable'] + const watchPostEffect: typeof import('vue')['watchPostEffect'] + const watchSyncEffect: typeof import('vue')['watchSyncEffect'] + const watchThrottled: typeof import('@vueuse/core')['watchThrottled'] + const watchTriggerable: typeof import('@vueuse/core')['watchTriggerable'] + const watchWithFilter: typeof import('@vueuse/core')['watchWithFilter'] + const whenever: typeof import('@vueuse/core')['whenever'] +} +// for type re-export +declare global { + // @ts-ignore + export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue' + import('vue') +} diff --git a/admin/src/types/import/components.d.ts b/admin/src/types/import/components.d.ts new file mode 100644 index 0000000..b9177ab --- /dev/null +++ b/admin/src/types/import/components.d.ts @@ -0,0 +1,133 @@ +/* eslint-disable */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +// biome-ignore lint: disable +export {} + +/* prettier-ignore */ +declare module 'vue' { + export interface GlobalComponents { + ArtBackToTop: typeof import('./../../components/core/base/art-back-to-top/index.vue')['default'] + ArtBarChart: typeof import('./../../components/core/charts/art-bar-chart/index.vue')['default'] + ArtBarChartCard: typeof import('./../../components/core/cards/art-bar-chart-card/index.vue')['default'] + ArtBasicBanner: typeof import('./../../components/core/banners/art-basic-banner/index.vue')['default'] + ArtBreadcrumb: typeof import('./../../components/core/layouts/art-breadcrumb/index.vue')['default'] + ArtButtonMore: typeof import('./../../components/core/forms/art-button-more/index.vue')['default'] + ArtButtonTable: typeof import('./../../components/core/forms/art-button-table/index.vue')['default'] + ArtCardBanner: typeof import('./../../components/core/banners/art-card-banner/index.vue')['default'] + ArtChatWindow: typeof import('./../../components/core/layouts/art-chat-window/index.vue')['default'] + ArtCountTo: typeof import('./../../components/core/text-effect/art-count-to/index.vue')['default'] + ArtDataListCard: typeof import('./../../components/core/cards/art-data-list-card/index.vue')['default'] + ArtDonutChartCard: typeof import('./../../components/core/cards/art-donut-chart-card/index.vue')['default'] + ArtDragVerify: typeof import('./../../components/core/forms/art-drag-verify/index.vue')['default'] + ArtDualBarCompareChart: typeof import('./../../components/core/charts/art-dual-bar-compare-chart/index.vue')['default'] + ArtException: typeof import('./../../components/core/views/exception/ArtException.vue')['default'] + ArtFastEnter: typeof import('./../../components/core/layouts/art-fast-enter/index.vue')['default'] + ArtFestivalTextScroll: typeof import('./../../components/core/text-effect/art-festival-text-scroll/index.vue')['default'] + ArtFireworksEffect: typeof import('./../../components/core/layouts/art-fireworks-effect/index.vue')['default'] + ArtForm: typeof import('./../../components/core/forms/art-form/index.vue')['default'] + ArtGlobalComponent: typeof import('./../../components/core/layouts/art-global-component/index.vue')['default'] + ArtGlobalSearch: typeof import('./../../components/core/layouts/art-global-search/index.vue')['default'] + ArtHBarChart: typeof import('./../../components/core/charts/art-h-bar-chart/index.vue')['default'] + ArtHeaderBar: typeof import('./../../components/core/layouts/art-header-bar/index.vue')['default'] + ArtHorizontalMenu: typeof import('./../../components/core/layouts/art-menus/art-horizontal-menu/index.vue')['default'] + ArtIconButton: typeof import('./../../components/core/widget/art-icon-button/index.vue')['default'] + ArtImageCard: typeof import('./../../components/core/cards/art-image-card/index.vue')['default'] + ArtKLineChart: typeof import('./../../components/core/charts/art-k-line-chart/index.vue')['default'] + ArtLineChart: typeof import('./../../components/core/charts/art-line-chart/index.vue')['default'] + ArtLineChartCard: typeof import('./../../components/core/cards/art-line-chart-card/index.vue')['default'] + ArtLogo: typeof import('./../../components/core/base/art-logo/index.vue')['default'] + ArtMenuRight: typeof import('./../../components/core/others/art-menu-right/index.vue')['default'] + ArtMixedMenu: typeof import('./../../components/core/layouts/art-menus/art-mixed-menu/index.vue')['default'] + ArtNotification: typeof import('./../../components/core/layouts/art-notification/index.vue')['default'] + ArtPageContent: typeof import('./../../components/core/layouts/art-page-content/index.vue')['default'] + ArtProgressCard: typeof import('./../../components/core/cards/art-progress-card/index.vue')['default'] + ArtRadarChart: typeof import('./../../components/core/charts/art-radar-chart/index.vue')['default'] + ArtResultPage: typeof import('./../../components/core/views/result/ArtResultPage.vue')['default'] + ArtRingChart: typeof import('./../../components/core/charts/art-ring-chart/index.vue')['default'] + ArtScatterChart: typeof import('./../../components/core/charts/art-scatter-chart/index.vue')['default'] + ArtScreenLock: typeof import('./../../components/core/layouts/art-screen-lock/index.vue')['default'] + ArtSearchBar: typeof import('./../../components/core/forms/art-search-bar/index.vue')['default'] + ArtSettingsPanel: typeof import('./../../components/core/layouts/art-settings-panel/index.vue')['default'] + ArtSidebarMenu: typeof import('./../../components/core/layouts/art-menus/art-sidebar-menu/index.vue')['default'] + ArtStatsCard: typeof import('./../../components/core/cards/art-stats-card/index.vue')['default'] + ArtSvgIcon: typeof import('./../../components/core/base/art-svg-icon/index.vue')['default'] + ArtTable: typeof import('./../../components/core/tables/art-table/index.vue')['default'] + ArtTableHeader: typeof import('./../../components/core/tables/art-table-header/index.vue')['default'] + ArtTextScroll: typeof import('./../../components/core/text-effect/art-text-scroll/index.vue')['default'] + ArtTimelineListCard: typeof import('./../../components/core/cards/art-timeline-list-card/index.vue')['default'] + ArtUserMenu: typeof import('./../../components/core/layouts/art-header-bar/widget/ArtUserMenu.vue')['default'] + ArtWatermark: typeof import('./../../components/core/others/art-watermark/index.vue')['default'] + ArtWorkTab: typeof import('./../../components/core/layouts/art-work-tab/index.vue')['default'] + AuthTopBar: typeof import('./../../components/core/views/login/AuthTopBar.vue')['default'] + BasicSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/BasicSettings.vue')['default'] + BoxStyleSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/BoxStyleSettings.vue')['default'] + ColorSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ColorSettings.vue')['default'] + ContainerSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ContainerSettings.vue')['default'] + ElAlert: typeof import('element-plus/es')['ElAlert'] + ElAvatar: typeof import('element-plus/es')['ElAvatar'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] + ElCollapse: typeof import('element-plus/es')['ElCollapse'] + ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] + ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider'] + ElDatePicker: typeof import('element-plus/es')['ElDatePicker'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDivider: typeof import('element-plus/es')['ElDivider'] + ElDrawer: typeof import('element-plus/es')['ElDrawer'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElPopover: typeof import('element-plus/es')['ElPopover'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] + ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSkeleton: typeof import('element-plus/es')['ElSkeleton'] + ElStep: typeof import('element-plus/es')['ElStep'] + ElSteps: typeof import('element-plus/es')['ElSteps'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + ElTooltip: typeof import('element-plus/es')['ElTooltip'] + ElWatermark: typeof import('element-plus/es')['ElWatermark'] + HorizontalSubmenu: typeof import('./../../components/core/layouts/art-menus/art-horizontal-menu/widget/HorizontalSubmenu.vue')['default'] + LoginLeftView: typeof import('./../../components/core/views/login/LoginLeftView.vue')['default'] + MenuLayoutSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/MenuLayoutSettings.vue')['default'] + MenuStyleSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/MenuStyleSettings.vue')['default'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + SectionTitle: typeof import('./../../components/core/layouts/art-settings-panel/widget/SectionTitle.vue')['default'] + SettingActions: typeof import('./../../components/core/layouts/art-settings-panel/widget/SettingActions.vue')['default'] + SettingDrawer: typeof import('./../../components/core/layouts/art-settings-panel/widget/SettingDrawer.vue')['default'] + SettingHeader: typeof import('./../../components/core/layouts/art-settings-panel/widget/SettingHeader.vue')['default'] + SettingItem: typeof import('./../../components/core/layouts/art-settings-panel/widget/SettingItem.vue')['default'] + SidebarSubmenu: typeof import('./../../components/core/layouts/art-menus/art-sidebar-menu/widget/SidebarSubmenu.vue')['default'] + SiteOverlayFields: typeof import('./../../components/business/SiteOverlayFields/index.vue')['default'] + ThemeSettings: typeof import('./../../components/core/layouts/art-settings-panel/widget/ThemeSettings.vue')['default'] + ThemeSvg: typeof import('./../../components/core/theme/theme-svg/index.vue')['default'] + } + export interface GlobalDirectives { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/admin/src/types/index.ts b/admin/src/types/index.ts new file mode 100644 index 0000000..9032fd2 --- /dev/null +++ b/admin/src/types/index.ts @@ -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' diff --git a/admin/src/types/router/index.ts b/admin/src/types/router/index.ts new file mode 100644 index 0000000..d9ef012 --- /dev/null +++ b/admin/src/types/router/index.ts @@ -0,0 +1,80 @@ +/** + * 路由类型定义模块 + * + * 提供路由相关的类型定义 + * + * ## 主要功能 + * + * - 路由元数据类型(标题、图标、权限等) + * - 应用路由记录类型 + * - 路由配置扩展 + * + * ## 使用场景 + * + * - 路由配置类型约束 + * - 路由元数据定义 + * - 菜单生成 + * - 权限控制 + * + * @module types/router/index + * @author Art Design Pro Team + */ + +import { RouteRecordRaw } from 'vue-router' + +/** + * 路由元数据接口 + * 定义路由的各种配置属性 + */ +export interface RouteMeta extends Record { + /** 路由标题 */ + 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 { + id?: number + meta: RouteMeta + children?: AppRouteRecord[] + component?: string | (() => Promise) +} diff --git a/admin/src/types/store/index.ts b/admin/src/types/store/index.ts new file mode 100644 index 0000000..019801e --- /dev/null +++ b/admin/src/types/store/index.ts @@ -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]: 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 +} diff --git a/admin/src/utils/__tests__/statsRange.test.ts b/admin/src/utils/__tests__/statsRange.test.ts new file mode 100644 index 0000000..2d574cd --- /dev/null +++ b/admin/src/utils/__tests__/statsRange.test.ts @@ -0,0 +1,103 @@ +/** + * 统计时间快捷范围单测(Phase 3 Task 4;Task 8 补自定义区间 resolveStatsRange) + * + * 钉住口径:from/to 为本地日期串 YYYY-MM-DD;"近 N 天"含今天往回共 N 天 + * (今天→from==to;近7天→from=今天-6;近30天→今天-29)——后端把日期-only + * 的 to 视为含当日全天(stats_routes 的 _parse_dt),前端不传时间部分。 + * 自定义档:完整且起≤止 → 原样透传;缺半/起>止 → null(调用方不发请求)。 + */ +import { describe, expect, it } from 'vitest' +import { + STATS_QUICK_RANGES, + STATS_RANGE_OPTIONS, + rangeToParams, + resolveStatsRange +} from '@/utils/statsRange' + +/** 固定"现在":2026-10-13 09:30(本地时间)——跨月/跨年边界另测 */ +const NOW = new Date(2026, 9, 13, 9, 30) + +describe('统计时间快捷范围(src/utils/statsRange.ts)', () => { + it('今天:from 与 to 同为今天', () => { + expect(rangeToParams('today', NOW)).toEqual({ + from: '2026-10-13', + to: '2026-10-13' + }) + }) + + it('近7天:from = 今天往回 6 天(窗口含今天共 7 天)', () => { + expect(rangeToParams('last7', NOW)).toEqual({ + from: '2026-10-07', + to: '2026-10-13' + }) + }) + + it('近30天:from = 今天往回 29 天', () => { + expect(rangeToParams('last30', NOW)).toEqual({ + from: '2026-09-14', + to: '2026-10-13' + }) + }) + + it('月份/日期补零:单数月日出两位日期串', () => { + expect(rangeToParams('today', new Date(2026, 0, 5, 23, 59))).toEqual({ + from: '2026-01-05', + to: '2026-01-05' + }) + }) + + it('跨月回退由 Date 构造正确结算(10-01 的近7天跨回 9 月)', () => { + expect(rangeToParams('last7', new Date(2026, 9, 1, 12))).toEqual({ + from: '2026-09-25', + to: '2026-10-01' + }) + }) + + it('快捷范围清单与类型对齐:三个挡位、标签非空', () => { + expect(STATS_QUICK_RANGES.map((item) => item.value)).toEqual(['today', 'last7', 'last30']) + for (const item of STATS_QUICK_RANGES) { + expect(item.label).not.toBe('') + expect(item.days).toBeGreaterThan(0) + } + }) +}) + +describe('时间筛选档位解析(resolveStatsRange——Task 8 自定义区间)', () => { + it('快捷档等价 rangeToParams(custom 入参被忽略)', () => { + expect(resolveStatsRange('last7', null, NOW)).toEqual({ from: '2026-10-07', to: '2026-10-13' }) + }) + + it('自定义完整区间:原样透传(不做时区/时间部分加工)', () => { + expect(resolveStatsRange('custom', ['2026-09-01', '2026-10-13'], NOW)).toEqual({ + from: '2026-09-01', + to: '2026-10-13' + }) + }) + + it('自定义起=止:单日闭区间合法', () => { + expect(resolveStatsRange('custom', ['2026-10-13', '2026-10-13'], NOW)).toEqual({ + from: '2026-10-13', + to: '2026-10-13' + }) + }) + + it('自定义缺半(null/空串)→ null(不发请求)', () => { + expect(resolveStatsRange('custom', null, NOW)).toBeNull() + expect(resolveStatsRange('custom', undefined, NOW)).toBeNull() + expect(resolveStatsRange('custom', ['', '2026-10-13'], NOW)).toBeNull() + expect(resolveStatsRange('custom', ['2026-10-13', ''], NOW)).toBeNull() + }) + + it('自定义起>止 → null(ISO 日期串字典序即时间序)', () => { + expect(resolveStatsRange('custom', ['2026-10-14', '2026-10-13'], NOW)).toBeNull() + }) + + it('筛选档位清单:三快捷档 + 自定义殿后(切换器展示序)', () => { + expect(STATS_RANGE_OPTIONS.map((item) => item.value)).toEqual([ + 'today', + 'last7', + 'last30', + 'custom' + ]) + }) +}) diff --git a/admin/src/utils/form/index.ts b/admin/src/utils/form/index.ts new file mode 100644 index 0000000..ed23a46 --- /dev/null +++ b/admin/src/utils/form/index.ts @@ -0,0 +1,12 @@ +/** + * 表单工具函数统一导出 + * + * @module utils/form + * @author Art Design Pro Team + */ + +// 表单验证器 +export * from './validator' + +// 响应式布局 +export * from './responsive' diff --git a/admin/src/utils/form/responsive.ts b/admin/src/utils/form/responsive.ts new file mode 100644 index 0000000..c11df92 --- /dev/null +++ b/admin/src/utils/form/responsive.ts @@ -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 = { + 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) + } +} diff --git a/admin/src/utils/form/validator.ts b/admin/src/utils/form/validator.ts new file mode 100644 index 0000000..3670763 --- /dev/null +++ b/admin/src/utils/form/validator.ts @@ -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 +} diff --git a/admin/src/utils/http/error.ts b/admin/src/utils/http/error.ts new file mode 100644 index 0000000..0f2c1ae --- /dev/null +++ b/admin/src/utils/http/error.ts @@ -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 = { + [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): 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 +} diff --git a/admin/src/utils/http/index.ts b/admin/src/utils/http/index.ts new file mode 100644 index 0000000..0e0002a --- /dev/null +++ b/admin/src/utils/http/index.ts @@ -0,0 +1,214 @@ +/** + * 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', 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) => { + const { code, msg } = response.data + if (code === ApiStatus.success) return response + if (code === ApiStatus.unauthorized) handleUnauthorizedError(msg) + throw createHttpError(msg || $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( + config: ExtendedAxiosRequestConfig, + retries: number = MAX_RETRIES +): Promise { + try { + return await request(config) + } catch (error) { + if (retries > 0 && error instanceof HttpError && shouldRetry(error.code)) { + await delay(RETRY_DELAY) + return retryRequest(config, retries - 1) + } + throw error + } +} + +/** 延迟函数 */ +function delay(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** 请求函数 */ +async function request(config: ExtendedAxiosRequestConfig): Promise { + // 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>(config) + + // 显示成功消息 + if (config.showSuccessMessage && res.data.msg) { + showSuccess(res.data.msg) + } + + 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(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'GET' }) + }, + post(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'POST' }) + }, + put(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'PUT' }) + }, + del(config: ExtendedAxiosRequestConfig) { + return retryRequest({ ...config, method: 'DELETE' }) + }, + request(config: ExtendedAxiosRequestConfig) { + return retryRequest(config) + } +} + +export default api diff --git a/admin/src/utils/http/status.ts b/admin/src/utils/http/status.ts new file mode 100644 index 0000000..989bb37 --- /dev/null +++ b/admin/src/utils/http/status.ts @@ -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版本不支持 +} diff --git a/admin/src/utils/index.ts b/admin/src/utils/index.ts new file mode 100644 index 0000000..a89a6b0 --- /dev/null +++ b/admin/src/utils/index.ts @@ -0,0 +1,31 @@ +/** + * Utils 工具函数统一导出 + * 提供向后兼容性和便捷导入 + * + * @module utils/index + * @author Art Design Pro Team + */ + +// UI 相关 +export * from './ui' + +// 路由相关 +export * from './router' + +// 路由导航相关 +export * from './navigation' + +// 系统管理相关 +export * from './sys' + +// 存储相关 +export * from './storage' + +// HTTP 相关 +export * from './http' + +// 表单相关 +export * from './form' + +// socket 相关 +export * from './socket' diff --git a/admin/src/utils/navigation/index.ts b/admin/src/utils/navigation/index.ts new file mode 100644 index 0000000..0b84e78 --- /dev/null +++ b/admin/src/utils/navigation/index.ts @@ -0,0 +1,10 @@ +/** + * 路由和导航相关工具函数统一导出 + * + * @module utils/navigation/index + * @author Art Design Pro Team + */ + +export * from './jump' +export * from './worktab' +export * from './route' diff --git a/admin/src/utils/navigation/jump.ts b/admin/src/utils/navigation/jump.ts new file mode 100644 index 0000000..320edb5 --- /dev/null +++ b/admin/src/utils/navigation/jump.ts @@ -0,0 +1,68 @@ +/** + * 导航跳转工具模块 + * + * 提供统一的页面跳转和导航功能 + * + * ## 主要功能 + * + * - 外部链接打开(新窗口) + * - 菜单项跳转处理(支持内部路由和外部链接) + * - iframe 页面跳转支持 + * - 递归查找并跳转到第一个可见的子菜单 + * - 智能判断跳转目标类型(外部链接/内部路由) + * + * @module utils/navigation/jump + * @author Art Design Pro Team + */ +import { AppRouteRecord } from '@/types/router' +import { router } from '@/router' +import { isNavigableMenuItem } from './route' + +// 打开外部链接 +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 | undefined => { + for (const child of items) { + if (isNavigableMenuItem(child)) { + return child.children?.length ? findFirstLeafMenu(child.children) || child : child + } + } + return undefined + } + + const firstChild = findFirstLeafMenu(item.children) + + // 如果子菜单都不可见,则回退到父级页面自身。 + if (!firstChild) { + return router.push(item.path) + } + + // 如果第一个子菜单是外部链接则打开新窗口 + if (firstChild.meta?.link) { + return openExternalLink(firstChild.meta.link) + } + + // 跳转到子菜单路径 + router.push(firstChild.path) +} diff --git a/admin/src/utils/navigation/route.ts b/admin/src/utils/navigation/route.ts new file mode 100644 index 0000000..518bcb9 --- /dev/null +++ b/admin/src/utils/navigation/route.ts @@ -0,0 +1,85 @@ +/** + * 路由工具模块 + * + * 提供路由处理和菜单路径相关的工具函数 + * + * ## 主要功能 + * + * - 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/') +} + +/** + * 判断菜单项是否可作为默认导航落点 + * 隐藏的全屏页面虽然不展示在菜单中,但仍然可能是合法首页。 + */ +export const isNavigableMenuItem = (menuItem: AppRouteRecord): boolean => { + if (!menuItem.path || !menuItem.path.trim()) { + return false + } + + if (!menuItem.meta?.isHide) { + return true + } + + return menuItem.meta?.isFullPage === true +} + +/** + * 标准化路径格式 + * @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 (!isNavigableMenuItem(menuItem)) { + continue + } + + // 如果有子菜单,优先查找子菜单 + if (menuItem.children?.length) { + const childPath = getFirstMenuPath(menuItem.children) + if (childPath) { + return childPath + } + } + + // 返回当前菜单项的标准化路径 + return normalizePath(menuItem.path!) + } + + return '' +} diff --git a/admin/src/utils/navigation/worktab.ts b/admin/src/utils/navigation/worktab.ts new file mode 100644 index 0000000..6db6a77 --- /dev/null +++ b/admin/src/utils/navigation/worktab.ts @@ -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 + }) + } + } +} diff --git a/admin/src/utils/router.ts b/admin/src/utils/router.ts new file mode 100644 index 0000000..8c838ff --- /dev/null +++ b/admin/src/utils/router.ts @@ -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 '' +} diff --git a/admin/src/utils/socket/index.ts b/admin/src/utils/socket/index.ts new file mode 100644 index 0000000..ac80da1 --- /dev/null +++ b/admin/src/utils/socket/index.ts @@ -0,0 +1,423 @@ +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 = [] + + // 定时器 + 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 isReconnecting: 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 { + this.connect(true) + } + + private connect(resetReconnectAttempts: boolean = false): 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.stopReconnect = false + if (resetReconnectAttempts) { + this.reconnectAttempts = 0 + this.isReconnecting = false + this.clearTimer('reconnectTimer') + } + 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.isReconnecting = false + 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.isReconnecting = 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.clearConnectionTimers() + this.ws = null + + 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 closeCurrentSocketForReconnect(): void { + this.clearConnectionTimers() + this.isConnected = false + this.isConnecting = false + + if (this.ws) { + this.ws.onopen = null + this.ws.onmessage = null + this.ws.onclose = null + this.ws.onerror = null + + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.close(1001, 'Reconnect') + } + + this.ws = null + } + } + + // 转换连接状态为文本描述 + 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 || this.reconnectInterval <= 0) { + return + } + + // 检查是否超过最大重连次数 + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error(`已达到最大重连次数(${this.maxReconnectAttempts}),停止重连`) + this.close(true) + return + } + + this.reconnectAttempts++ + this.isReconnecting = true + this.closeCurrentSocketForReconnect() + + const delay = this.calculateReconnectDelay() + console.log( + `将在${delay / 1000}秒后尝试重新连接(第${this.reconnectAttempts}/${this.maxReconnectAttempts}次)` + ) + + this.clearTimer('reconnectTimer') + this.reconnectTimer = setTimeout(() => { + console.log(`尝试重新连接WebSocket(第${this.reconnectAttempts}次)`) + this.connect(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.clearConnectionTimers() + this.clearTimer('reconnectTimer') + } + + private clearConnectionTimers(): void { + this.clearTimer('detectionTimer') + this.clearTimer('timeoutTimer') + 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.isReconnecting && this.reconnectAttempts > 0) + return `重连中(${this.reconnectAttempts}/${this.maxReconnectAttempts})` + return '已断开' + } + + // 销毁实例 + static destroyInstance(): void { + if (WebSocketClient.instance) { + WebSocketClient.instance.close() + WebSocketClient.instance = null + } + } +} diff --git a/admin/src/utils/statsRange.ts b/admin/src/utils/statsRange.ts new file mode 100644 index 0000000..72baecc --- /dev/null +++ b/admin/src/utils/statsRange.ts @@ -0,0 +1,70 @@ +/** + * 统计时间快捷范围(Phase 3 Task 4 仪表盘;Task 8 统计分析复用) + * + * 口径事实源:app/platform/routes/stats_routes.py 的 from/to 解析 + * (data_routes._parse_dt)——前端只传本地日期串 YYYY-MM-DD: + * - 日期-only 的 to 后端视为含当日全天(23:59:59.999999,边界含); + * - 边界是 naive UTC(与库内 created_at 同一对比空间),前端不做时区换算; + * - "近 N 天" = 含今天往回共 N 天(今天 / 近7天 / 近30天)。 + */ +export type StatsQuickRange = 'today' | 'last7' | 'last30' + +/** 快捷范围清单(顺序即切换器展示序) */ +export const STATS_QUICK_RANGES: Array<{ + value: StatsQuickRange + label: string + /** 窗口天数(含今天) */ + days: number +}> = [ + { value: 'today', label: '今天', days: 1 }, + { value: 'last7', label: '近7天', days: 7 }, + { value: 'last30', label: '近30天', days: 30 } +] + +/** Date → 本地日期串 YYYY-MM-DD(月/日两位补零) */ +function toDateKey(date: Date): string { + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${date.getFullYear()}-${month}-${day}` +} + +/** 快捷范围 → {from, to} 查询参数(本地日期;now 可注入便于测试) */ +export function rangeToParams( + range: StatsQuickRange, + now: Date = new Date() +): { from: string; to: string } { + const days = STATS_QUICK_RANGES.find((item) => item.value === range)?.days ?? 1 + return { + from: toDateKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - (days - 1))), + to: toDateKey(now) + } +} + +// ---- 自定义区间扩展(Phase 3 Task 8 统计分析页) ---------------------------------- + +/** 时间筛选状态:快捷档 + 自定义(统计分析页;仪表盘只用快捷档) */ +export type StatsRangeFilter = StatsQuickRange | 'custom' + +/** 筛选档位清单(顺序即切换器展示序;自定义档配合日期区间选择器) */ +export const STATS_RANGE_OPTIONS: Array<{ value: StatsRangeFilter; label: string }> = [ + ...STATS_QUICK_RANGES.map(({ value, label }) => ({ value, label })), + { value: 'custom', label: '自定义' } +] + +/** + * 筛选状态 → {from, to} 查询参数(custom 需完整且起≤止的区间) + * + * 返回 null = 区间不完整/非法(自定义只选了一半、起>止)——调用方不发请求, + * 等 ElDatePicker 补全;快速档永不为 null。 + */ +export function resolveStatsRange( + filter: StatsRangeFilter, + custom: [string, string] | null | undefined, + now: Date = new Date() +): { from: string; to: string } | null { + if (filter !== 'custom') return rangeToParams(filter, now) + if (!custom || !custom[0] || !custom[1]) return null + const [from, to] = custom + if (from > to) return null // ISO 日期串字典序即时间序 + return { from, to } +} diff --git a/admin/src/utils/storage/index.ts b/admin/src/utils/storage/index.ts new file mode 100644 index 0000000..a4366f0 --- /dev/null +++ b/admin/src/utils/storage/index.ts @@ -0,0 +1,7 @@ +/** + * 存储相关工具函数统一导出 + */ + +export * from './storage' +export * from './storage-config' +export * from './storage-key-manager' diff --git a/admin/src/utils/storage/storage-config.ts b/admin/src/utils/storage/storage-config.ts new file mode 100644 index 0000000..c53b489 --- /dev/null +++ b/admin/src/utils/storage/storage-config.ts @@ -0,0 +1,125 @@ +/** + * 存储配置管理模块 + * + * 提供统一的本地存储配置和工具方法 + * + * ## 主要功能 + * + * - 版本化存储键管理,支持多版本数据隔离 + * - 存储键名生成和解析(带版本前缀) + * - 版本号提取和验证 + * - 存储键匹配的正则表达式生成 + * - 旧版本存储键兼容处理 + * - 升级和登出延迟配置 + * - 主题存储键配置 + * + * ## 使用场景 + * + * - 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 RESPONSIVE_MENU_TYPE_KEY = 'sys-responsive-menu-type' + + /** 跳过升级检查的版本 */ + 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 + } +} diff --git a/admin/src/utils/storage/storage-key-manager.ts b/admin/src/utils/storage/storage-key-manager.ts new file mode 100644 index 0000000..ba14f65 --- /dev/null +++ b/admin/src/utils/storage/storage-key-manager.ts @@ -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 + } +} diff --git a/admin/src/utils/storage/storage.ts b/admin/src/utils/storage/storage.ts new file mode 100644 index 0000000..67b9e9e --- /dev/null +++ b/admin/src/utils/storage/storage.ts @@ -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 { + 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) +} diff --git a/admin/src/utils/sys/console.ts b/admin/src/utils/sys/console.ts new file mode 100644 index 0000000..d2a1344 --- /dev/null +++ b/admin/src/utils/sys/console.ts @@ -0,0 +1,9 @@ +// 控制台欢迎信息(all-kefu 品牌位,替换模板自推广内容) +const asciiArt = ` +\x1b[32m欢迎使用 all-kefu 管理后台! +\x1b[0m +\x1b[36m多租户 AI 客服平台控制台(基于 art-design-pro 模板构建) +\x1b[0m +` + +console.log(asciiArt) diff --git a/admin/src/utils/sys/error-handle.ts b/admin/src/utils/sys/error-handle.ts new file mode 100644 index 0000000..bc966c9 --- /dev/null +++ b/admin/src/utils/sys/error-handle.ts @@ -0,0 +1,143 @@ +/** + * 全局错误处理模块 + * + * 提供统一的错误捕获和处理机制 + * + * ## 主要功能 + * + * - 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' + +const IGNORABLE_SCRIPT_ERRORS = [ + 'ResizeObserver loop completed with undelivered notifications.', + 'ResizeObserver loop limit exceeded' +] + +function normalizeErrorMessage(message: Event | string): string { + if (typeof message === 'string') { + return message + } + + if ('message' in message && typeof message.message === 'string') { + return message.message + } + + return '' +} + +function isIgnorableScriptError(message: Event | string, source?: string): boolean { + const normalizedMessage = normalizeErrorMessage(message) + + if (!normalizedMessage) { + return false + } + + if (IGNORABLE_SCRIPT_ERRORS.some((item) => normalizedMessage.includes(item))) { + // 浏览器/扩展在布局抖动时常见的 ResizeObserver 噪声,不作为真实异常处理 + return true + } + + // 浏览器扩展注入脚本偶发的跨域 Script error 也没有排查价值 + if (normalizedMessage === 'Script error.' && source === '') { + return true + } + + return false +} + +/** + * 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 { + if (isIgnorableScriptError(message, source)) { + return true + } + + 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() +} diff --git a/admin/src/utils/sys/index.ts b/admin/src/utils/sys/index.ts new file mode 100644 index 0000000..cc30e31 --- /dev/null +++ b/admin/src/utils/sys/index.ts @@ -0,0 +1,5 @@ +/** + * 系统管理相关工具函数统一导出 + */ + +export { default as mittBus } from './mittBus' diff --git a/admin/src/utils/sys/mittBus.ts b/admin/src/utils/sys/mittBus.ts new file mode 100644 index 0000000..22f0108 --- /dev/null +++ b/admin/src/utils/sys/mittBus.ts @@ -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 = mitt() + +export default mittBus diff --git a/admin/src/utils/table/tableCache.ts b/admin/src/utils/table/tableCache.ts new file mode 100644 index 0000000..045a7ce --- /dev/null +++ b/admin/src/utils/table/tableCache.ts @@ -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 { + records?: T[] + data?: T[] + total?: number + current?: number + size?: number + [key: string]: unknown +} + +// 缓存存储接口 +export interface CacheItem { + data: T[] + response: ApiResponse + timestamp: number + params: string + // 缓存标签,用于分组管理 + tags: Set + // 访问次数(用于 LRU 算法) + accessCount: number + // 最后访问时间 + lastAccessTime: number +} + +// 增强的缓存管理类 +export class TableCache { + private cache = new Map>() + 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): Set { + const tags = new Set() + + // 添加搜索条件标签 + 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): void { + const key = this.generateKey(params) + const tags = this.generateTags(params as Record) + 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 | 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 + } +} diff --git a/admin/src/utils/table/tableConfig.ts b/admin/src/utils/table/tableConfig.ts new file mode 100644 index 0000000..e464c89 --- /dev/null +++ b/admin/src/utils/table/tableConfig.ts @@ -0,0 +1,55 @@ +/** + * 表格全局配置模块 + * + * 提供表格与后端接口的字段映射配置 + * + * ## 主要功能 + * + * - 响应数据字段自动识别和映射 + * - 支持多种常见的后端响应格式 + * - 请求参数字段映射配置 + * - 可扩展的字段配置机制 + * + * ## 使用场景 + * + * - 适配不同后端的分页接口格式 + * - 统一前端表格组件的数据处理 + * - 减少重复的数据转换代码 + * - 支持多个后端服务的接口对接 + * + * ## 配置说明 + * + * - 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: 'current', + // 每页大小 + size: 'size' + } +} diff --git a/admin/src/utils/table/tableUtils.ts b/admin/src/utils/table/tableUtils.ts new file mode 100644 index 0000000..3ca9db1 --- /dev/null +++ b/admin/src/utils/table/tableUtils.ts @@ -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(obj: Record, 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, 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, + data?: Record +): Pick, 'current' | 'size'> | undefined { + const result: Partial, '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 = (response: unknown): ApiResponse => { + // 定义支持的字段 + 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 + let records: T[] = [] + let total = 0 + let pagination: Pick, '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 + 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 = { records, total } + if (pagination) { + Object.assign(result, pagination) + } + return result +} + +/** + * 从标准化的API响应中提取表格数据 + */ +export const extractTableData = (response: ApiResponse): T[] => { + const data = response.records || response.data || [] + return Array.isArray(data) ? data : [] +} + +/** + * 根据API响应更新分页信息 + */ +export const updatePaginationFromResponse = ( + pagination: Api.Common.PaginationParams, + response: ApiResponse +): 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 = Promise>( + fn: T, + delay: number +): T & { cancel: () => void; flush: () => Promise } => { + let timeoutId: NodeJS.Timeout | null = null + let lastArgs: Parameters | null = null + let lastResolve: ((value: any) => void) | null = null + let lastReject: ((reason: any) => void) | null = null + + const debouncedFn = (...args: Parameters): Promise => { + 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 + } +} diff --git a/admin/src/utils/ui/animation.ts b/admin/src/utils/ui/animation.ts new file mode 100644 index 0000000..5efd02a --- /dev/null +++ b/admin/src/utils/ui/animation.ts @@ -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) + } +} diff --git a/admin/src/utils/ui/colors.ts b/admin/src/utils/ui/colors.ts new file mode 100644 index 0000000..b4f6b77 --- /dev/null +++ b/admin/src/utils/ui/colors.ts @@ -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) + } +} diff --git a/admin/src/utils/ui/emojo.ts b/admin/src/utils/ui/emojo.ts new file mode 100644 index 0000000..cabad7d --- /dev/null +++ b/admin/src/utils/ui/emojo.ts @@ -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 diff --git a/admin/src/utils/ui/iconify-loader.ts b/admin/src/utils/ui/iconify-loader.ts new file mode 100644 index 0000000..035de16 --- /dev/null +++ b/admin/src/utils/ui/iconify-loader.ts @@ -0,0 +1,31 @@ +/** + * 离线图标加载器 + * + * 用于在内网环境下支持 Iconify 图标的离线加载。 + * 通过预加载图标集数据,避免运行时从 CDN 获取图标。 + * + * 使用方式: + * 1. 安装所需图标集:pnpm add -D @iconify-json/[icon-set-name] + * 2. 在此文件中导入并注册图标集 + * 3. 在组件中使用: + * + * @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) diff --git a/admin/src/utils/ui/index.ts b/admin/src/utils/ui/index.ts new file mode 100644 index 0000000..9ca1049 --- /dev/null +++ b/admin/src/utils/ui/index.ts @@ -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' diff --git a/admin/src/utils/ui/loading.ts b/admin/src/utils/ui/loading.ts new file mode 100644 index 0000000..6580e02 --- /dev/null +++ b/admin/src/utils/ui/loading.ts @@ -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 + } + } +} diff --git a/admin/src/utils/ui/tabs.ts b/admin/src/utils/ui/tabs.ts new file mode 100644 index 0000000..5f53ea5 --- /dev/null +++ b/admin/src/utils/ui/tabs.ts @@ -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 配置 +} diff --git a/admin/src/views/auth/force-change-password/index.vue b/admin/src/views/auth/force-change-password/index.vue new file mode 100644 index 0000000..c6b590c --- /dev/null +++ b/admin/src/views/auth/force-change-password/index.vue @@ -0,0 +1,122 @@ + + + + + + diff --git a/admin/src/views/auth/login/index.vue b/admin/src/views/auth/login/index.vue new file mode 100644 index 0000000..77f8bce --- /dev/null +++ b/admin/src/views/auth/login/index.vue @@ -0,0 +1,198 @@ + + + + + + + + diff --git a/admin/src/views/auth/login/style.css b/admin/src/views/auth/login/style.css new file mode 100644 index 0000000..bd8c3a9 --- /dev/null +++ b/admin/src/views/auth/login/style.css @@ -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); + } +} diff --git a/admin/src/views/config/center/CredsPane.vue b/admin/src/views/config/center/CredsPane.vue new file mode 100644 index 0000000..cdb6704 --- /dev/null +++ b/admin/src/views/config/center/CredsPane.vue @@ -0,0 +1,836 @@ + + + + + + diff --git a/admin/src/views/config/center/JsonGroupPane.vue b/admin/src/views/config/center/JsonGroupPane.vue new file mode 100644 index 0000000..ec4113d --- /dev/null +++ b/admin/src/views/config/center/JsonGroupPane.vue @@ -0,0 +1,254 @@ + + + + + + diff --git a/admin/src/views/config/center/PromptsPane.vue b/admin/src/views/config/center/PromptsPane.vue new file mode 100644 index 0000000..3b74408 --- /dev/null +++ b/admin/src/views/config/center/PromptsPane.vue @@ -0,0 +1,360 @@ + + + + + + diff --git a/admin/src/views/config/center/VersionsPane.vue b/admin/src/views/config/center/VersionsPane.vue new file mode 100644 index 0000000..ace6a96 --- /dev/null +++ b/admin/src/views/config/center/VersionsPane.vue @@ -0,0 +1,486 @@ + + + + + + diff --git a/admin/src/views/config/center/WallPane.vue b/admin/src/views/config/center/WallPane.vue new file mode 100644 index 0000000..55f49f1 --- /dev/null +++ b/admin/src/views/config/center/WallPane.vue @@ -0,0 +1,967 @@ + + + + + + diff --git a/admin/src/views/config/center/__tests__/model.test.ts b/admin/src/views/config/center/__tests__/model.test.ts new file mode 100644 index 0000000..a9845e9 --- /dev/null +++ b/admin/src/views/config/center/__tests__/model.test.ts @@ -0,0 +1,747 @@ +/** + * 配置中心提示词/词表/凭据编辑模型单元测试(Phase 3 Task 7b + 7c) + * + * 钉住 src/views/config/center/model.ts 的纯逻辑契约(事实源: + * app/platform/config_validation.py 的 PROMPTS_SIZE_CAPS 与 + * app/engine/wall_schema.py 的结构/双形态正则条目约定): + * - 提示词:UTF-8 字节口径(中文 3 字节/字)、50/100KB 上限判定、 + * 原样快照 → 表单值的防御归一; + * - 词表:正则条目双形态(纯字符串 | {pattern, flags, comment})的 + * 解析/序列化往返、空行剔除、语言池清洗、initWallEdit 对透传键的 + * 保留、buildWallPayload 的整组重组; + * - 变体键镜像:reminders 六类 / cc_templates 四组与后端常量同构; + * - 凭据三组(Task 7c):normalize(掩码哨兵态/数值转串/缺键防御)、 + * build(密钥哨兵往返——不动保存回 '__SET__' 保持库值;空串 = 清空; + * 新值优先;数值空串 → null)与 validate 前端预检(端口 1-65535 / + * 邮箱含 @ / push URL 协议与正数·1-10 整数边界,与后端校验同款)。 + */ +import { describe, expect, it } from 'vitest' +import { + CC_TEMPLATE_VARIANTS, + DEFAULT_TOUCHSTONE_SENTENCES, + NO_CONTACT_KEYS, + POOL_FIELDS, + PROMPTS_CAPS, + PROMPTS_KEYS, + PUSH_PROBE_REASON_LABELS, + REMINDER_VARIANTS, + RESET_GROUP_LABELS, + TRIGGER_KEYS, + ZERO_NUMBER_KEYS, + asSecretMasked, + buildAiCredsPayload, + buildEmailCredsPayload, + buildPushCredsPayload, + buildWallPayload, + COMPARE_SCOPE_ALL, + descendingVersions, + formatKB, + formatVersionTime, + initWallEdit, + JSON_GROUP_FIELDS, + jsonGroupDraft, + normalizeAiCredsGroup, + normalizeEmailCredsGroup, + normalizePromptsGroup, + normalizePushCredsGroup, + parsePatternEntry, + parseJsonGroupDraft, + promptByteInfo, + promptsOverCap, + rowToEntry, + rowsToEntries, + rowToSingleEntry, + scopedConfigJson, + snapshotGroupKeys, + touchstoneSentences, + utf8Bytes, + validateEmailCredsForm, + validatePushCredsForm, + type EmailCredsForm, + type PromptsDraft, + type PushCredsForm +} from '../model' + +// ---- 提示词四件套 ------------------------------------------------------------------- + +describe('utf8Bytes / formatKB(UTF-8 字节口径)', () => { + it('ASCII 1 字节/字符、中文 3 字节/字、空串为 0', () => { + expect(utf8Bytes('')).toBe(0) + expect(utf8Bytes('abc')).toBe(3) + expect(utf8Bytes('中文')).toBe(6) // 后端 len('中文'.encode('utf-8')) == 6 + expect(utf8Bytes('价格500')).toBe(3 * 2 + 3) + }) + + it('formatKB:KB 一位小数', () => { + expect(formatKB(0)).toBe('0.0KB') + expect(formatKB(50 * 1024)).toBe('50.0KB') + expect(formatKB(1536)).toBe('1.5KB') + }) +}) + +describe('提示词字节上限(config_validation.PROMPTS_SIZE_CAPS 镜像)', () => { + it('company_facts 50KB、其余 100KB', () => { + expect(PROMPTS_CAPS.company_facts).toBe(50 * 1024) + for (const key of PROMPTS_KEYS) { + if (key !== 'company_facts') expect(PROMPTS_CAPS[key]).toBe(100 * 1024) + } + }) + + it('边界:恰好等于上限不算超限,超 1 字节算', () => { + expect(promptByteInfo('company_facts', '').over).toBe(false) + expect(promptByteInfo('company_facts', '中'.repeat(50 * 1024 / 3)).over).toBe(false) + expect(promptByteInfo('company_facts', `a`.repeat(50 * 1024 + 1)).over).toBe(true) + expect(promptByteInfo('system', 'a'.repeat(100 * 1024)).over).toBe(false) + expect(promptByteInfo('evaluator', 'a'.repeat(100 * 1024 + 1)).over).toBe(true) + }) + + it('promptsOverCap:任一件超限即 true(禁存判定)', () => { + const draft: PromptsDraft = { + system: '', + company_facts: 'a'.repeat(50 * 1024 + 1), + extractor: '', + evaluator: '' + } + expect(promptsOverCap(draft)).toBe(true) + expect(promptsOverCap(normalizePromptsGroup({}))).toBe(false) + }) +}) + +describe('normalizePromptsGroup(原样快照 → 表单值)', () => { + it('缺键/非字符串补空串、字符串原样保留', () => { + expect( + normalizePromptsGroup({ system: 'S', company_facts: 123, extractor: null }) + ).toEqual({ system: 'S', company_facts: '', extractor: '', evaluator: '' }) + expect(normalizePromptsGroup(null)).toEqual({ + system: '', + company_facts: '', + extractor: '', + evaluator: '' + }) + }) +}) + +// ---- 正则条目双形态 ----------------------------------------------------------------- + +describe('parsePatternEntry(字符串/dict 双形态 → 编辑行)', () => { + it('纯字符串条目:无 flags 无备注', () => { + expect(parsePatternEntry('\\$\\s?\\d')).toEqual({ + pattern: '\\$\\s?\\d', + flags: '', + comment: '', + showComment: false + }) + }) + + it('dict 条目:pattern/flags/comment 各自归位', () => { + expect( + parsePatternEntry({ pattern: '\\d+', flags: 'i', comment: '# 时间承诺' }) + ).toEqual({ + pattern: '\\d+', + flags: 'i', + comment: '# 时间承诺', + showComment: false + }) + }) + + it('dict 条目无 comment 键(wall.json 真实形态)与异常值防御', () => { + expect(parsePatternEntry({ pattern: 'warranty', flags: 'i' }).comment).toBe('') + expect(parsePatternEntry({ pattern: 'x' }).flags).toBe('') + // 异常值:数字/null/缺 pattern → 归空行(存量脏数据防御) + expect(parsePatternEntry(42).pattern).toBe('') + expect(parsePatternEntry(null).pattern).toBe('') + expect(parsePatternEntry({ flags: 'i' }).pattern).toBe('') + expect(parsePatternEntry({ pattern: 1, flags: 'x', comment: 2 })).toEqual({ + pattern: '', + flags: '', + comment: '', + showComment: false + }) + }) +}) + +describe('rowToEntry / rowsToEntries(编辑行 → 条目序列化)', () => { + it('无 flags 无备注 → 纯字符串(与 wall.json 极简条目一致)', () => { + expect( + rowToEntry({ pattern: '\\$\\s?\\d', flags: '', comment: '', showComment: false }) + ).toBe('\\$\\s?\\d') + }) + + it('有 flags/备注 → dict 且只带非空键(comment-only 条目不带 flags 键)', () => { + expect( + rowToEntry({ pattern: '\\d+', flags: 'i', comment: '', showComment: false }) + ).toEqual({ pattern: '\\d+', flags: 'i' }) + expect( + rowToEntry({ pattern: 'x', flags: '', comment: '# 说明', showComment: false }) + ).toEqual({ pattern: 'x', comment: '# 说明' }) + expect( + rowToEntry({ pattern: 'x', flags: 'i', comment: '# 说明', showComment: false }) + ).toEqual({ pattern: 'x', flags: 'i', comment: '# 说明' }) + }) + + it('空 pattern 行剔除、纯空白备注视为无备注', () => { + expect( + rowToEntry({ pattern: '', flags: 'i', comment: 'x', showComment: false }) + ).toBeNull() + expect( + rowsToEntries([ + { pattern: 'a', flags: '', comment: ' ', showComment: false }, + { pattern: '', flags: '', comment: '', showComment: false }, + { pattern: 'b', flags: 'i', comment: '', showComment: false } + ]) + ).toEqual(['a', { pattern: 'b', flags: 'i' }]) + }) + + it('rowToSingleEntry(triggers/zero_number):无标志非空 → 纯字符串;空 pattern → dict 显性失败', () => { + expect( + rowToSingleEntry({ pattern: 'price', flags: 'i', comment: '', showComment: false }) + ).toEqual({ pattern: 'price', flags: 'i' }) + // wall.json zero_number 出厂是纯字符串——无标志无备注保持字符串(往返无损) + expect( + rowToSingleEntry({ pattern: 'budget|预算', flags: '', comment: '', showComment: false }) + ).toBe('budget|预算') + // 空 pattern 绝不能出字符串形态('' 会编成匹配一切的正则)——dict 让后端报路径 + expect( + rowToSingleEntry({ pattern: '', flags: '', comment: '', showComment: false }) + ).toEqual({ pattern: '' }) + }) +}) + +// ---- 词表整体模型 ------------------------------------------------------------------- + +/** 最小但结构完整的 wall 快照(对照 seed_profiles/jiancai/wall.json 形态) */ +function miniWall(): Record { + return { + price_patterns: [ + '\\$\\s?\\d', + { pattern: '\\d+\\s*天', comment: '# 中文时限' }, + { pattern: 'warranty', flags: 'i' } + ], + deflections: { en: ['A?'], zh: ['B?'], es: ['C?'] }, + deflections_contact: { en: ['A.'], zh: ['B.'] }, + pivots: { en: ['P'], zh: ['p'] }, + pivots_contact: { en: ['Q'], zh: ['q'] }, + no_contact: { web: 'W', app: 'A', already: 'D' }, + zero_number: { + long_number: '\\+?\\d[\\d\\s\\-().]{8,17}\\d', + any_email: '[a-z]+@[a-z]+\\.[a-z]{2,}' + }, + reask_patterns: [{ pattern: 'your\\s+whatsapp', flags: 'i' }], + triggers: { + boundary: { pattern: 'price', flags: 'i' }, + budget: { pattern: 'budget|预算', flags: 'i' }, + contact_ask: { pattern: 'your\\s+number', flags: 'i' } + }, + reminders: { + boundary: { budget: 'r-b', normal: 'r-n' }, + contact_ask: { app_already: 'ca-aa', app: 'ca-a', web: 'ca-w' }, + language_follow: { zh: 'lf-zh', es: 'lf-es' }, + lead_given: { + forbidden: 'lg-f', + respond_script: 'lg-r', + boundary_tail: 'lg-b', + suspect: 'lg-s' + }, + cc_guide: { echo: 'cg-e', ask: 'cg-a' }, + email_light: { en: 'el-en', zh: 'el-zh' } + }, + cc_templates: { + ask: { en: 't-a-en', zh: 't-a-zh', es: 't-a-es' }, + wall: { en: 't-w-en', zh: 't-w-zh', es: 't-w-es' }, + note: { en: 't-n-en', zh: 't-n-zh' }, + echo: { en: 't-e-en', zh: 't-e-zh', es: 't-e-es' } + }, + _meta: { source: 'extract_config.py' } + } +} + +describe('initWallEdit(快照 → 编辑模型)', () => { + it('缺失/空对象/非对象 → null(面板空态)', () => { + expect(initWallEdit(null)).toBeNull() + expect(initWallEdit(undefined)).toBeNull() + expect(initWallEdit({})).toBeNull() + expect(initWallEdit('x' as unknown as Record)).toBeNull() + }) + + it('双形态正则解析 + 透传键保留 + 六类/四组变体补齐', () => { + const model = initWallEdit(miniWall())! + expect(model.priceRows).toHaveLength(3) + expect(model.priceRows[0]).toMatchObject({ pattern: '\\$\\s?\\d', flags: '' }) + expect(model.priceRows[1]).toMatchObject({ + pattern: '\\d+\\s*天', + flags: '', + comment: '# 中文时限' + }) + expect(model.priceRows[2]).toMatchObject({ pattern: 'warranty', flags: 'i' }) + expect(model.reaskRows).toHaveLength(1) + expect(model.singles.boundary).toMatchObject({ pattern: 'price', flags: 'i' }) + expect(model.singles.long_number.pattern).toBe('\\+?\\d[\\d\\s\\-().]{8,17}\\d') + expect(model.pools.deflections).toEqual({ en: ['A?'], zh: ['B?'], es: ['C?'] }) + expect(model.noContact).toEqual({ web: 'W', app: 'A', already: 'D' }) + expect(model.reminders.boundary.budget).toBe('r-b') + expect(model.ccTemplates.note).toEqual({ en: 't-n-en', zh: 't-n-zh' }) + // 未接管键(含 "_" 前缀元信息)原样透传 + expect(model.passthrough).toEqual({ _meta: { source: 'extract_config.py' } }) + }) + + it('脏数据防御:非字符串池项剔除、非对象区补空、非列表正则区为空', () => { + const model = initWallEdit({ + price_patterns: 'oops', + deflections: { en: ['ok', 42, null], zh: 'oops', es: [] }, + triggers: 'oops', + no_contact: 'oops', + reminders: 'oops', + cc_templates: 'oops' + })! + expect(model.priceRows).toEqual([]) + expect(model.pools.deflections).toEqual({ en: ['ok'] }) + expect(model.singles.boundary.pattern).toBe('') + expect(model.noContact.web).toBe('') + expect(model.reminders.boundary.budget).toBe('') + expect(model.ccTemplates.ask.en).toBe('') + }) +}) + +describe('buildWallPayload(编辑模型 → PUT config/wall 整组载荷)', () => { + it('结构化区重组 + 透传键合入 + 序列化规则生效', () => { + const model = initWallEdit(miniWall())! + // 编辑:价格墙加一条(rich 形态)+ 留一条空行(应被剔除); + // deflections.en 加一条空句(应被剔除);en 池清空句子(整键应被丢弃) + model.priceRows.push({ pattern: '\\d+k\\b', flags: 'i', comment: '', showComment: false }) + model.priceRows.push({ pattern: '', flags: '', comment: '', showComment: false }) + model.pools.deflections.en.push('') + model.pools.deflections_contact.en = [''] + + const payload = buildWallPayload(model) + + expect(payload.price_patterns).toEqual([ + '\\$\\s?\\d', + { pattern: '\\d+\\s*天', comment: '# 中文时限' }, + { pattern: 'warranty', flags: 'i' }, + { pattern: '\\d+k\\b', flags: 'i' } + ]) + expect(payload.reask_patterns).toEqual([{ pattern: 'your\\s+whatsapp', flags: 'i' }]) + expect(payload.triggers).toEqual({ + boundary: { pattern: 'price', flags: 'i' }, + budget: { pattern: 'budget|预算', flags: 'i' }, + contact_ask: { pattern: 'your\\s+number', flags: 'i' } + }) + expect(payload.zero_number).toEqual({ + long_number: '\\+?\\d[\\d\\s\\-().]{8,17}\\d', + any_email: '[a-z]+@[a-z]+\\.[a-z]{2,}' + }) + expect(payload.deflections).toEqual({ en: ['A?'], zh: ['B?'], es: ['C?'] }) + // deflections_contact.en 清空后整键丢弃(zh 保留) + expect(payload.deflections_contact).toEqual({ zh: ['B.'] }) + expect(payload.no_contact).toEqual({ web: 'W', app: 'A', already: 'D' }) + expect(payload.reminders).toEqual(miniWall().reminders) + expect(payload.cc_templates).toEqual(miniWall().cc_templates) + expect(payload._meta).toEqual({ source: 'extract_config.py' }) + }) + + it('initWallEdit → buildWallPayload 无编辑时结构等价往返(序列化形态按约定归一)', () => { + const source = miniWall() + const payload = buildWallPayload(initWallEdit(source)!) + expect(payload.price_patterns).toEqual(source.price_patterns) + expect(payload.triggers).toEqual(source.triggers) + expect(payload.pivots).toEqual(source.pivots) + expect(payload.reminders).toEqual(source.reminders) + expect(payload.cc_templates).toEqual(source.cc_templates) + expect(payload._meta).toEqual(source._meta) + }) +}) + +// ---- 变体键镜像与试金石 ------------------------------------------------------------- + +describe('变体键镜像(wall_schema 常量同构)', () => { + it('reminders 六类 / cc_templates 四组 / 池与单正则键清单', () => { + expect(Object.keys(REMINDER_VARIANTS).sort()).toEqual( + ['boundary', 'cc_guide', 'contact_ask', 'email_light', 'language_follow', 'lead_given'] + ) + expect(Object.keys(CC_TEMPLATE_VARIANTS).sort()).toEqual(['ask', 'echo', 'note', 'wall']) + expect(POOL_FIELDS).toEqual([ + 'deflections', + 'deflections_contact', + 'pivots', + 'pivots_contact' + ]) + expect([...TRIGGER_KEYS]).toEqual(['boundary', 'budget', 'contact_ask']) + expect([...ZERO_NUMBER_KEYS]).toEqual(['long_number', 'any_email']) + expect([...NO_CONTACT_KEYS]).toEqual(['web', 'app', 'already']) + }) +}) + +describe('试金石句集', () => { + it('默认 3 示例句(覆盖 blocked/replaced/pass 三态)且非空', () => { + expect(DEFAULT_TOUCHSTONE_SENTENCES).toHaveLength(3) + for (const s of DEFAULT_TOUCHSTONE_SENTENCES) expect(s.trim()).not.toBe('') + }) + + it('touchstoneSentences:trim + 空行过滤', () => { + expect(touchstoneSentences([' a ', '', ' ', 'b'])).toEqual(['a', 'b']) + expect(touchstoneSentences([])).toEqual([]) + }) +}) + +// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) ----------------------------------- + +describe('asSecretMasked(GET 密钥值 → 掩码哨兵态)', () => { + it('哨兵原样 / 空串与 null·undefined → 空串(区分「配了」与「没配」)', () => { + expect(asSecretMasked('__SET__')).toBe('__SET__') + expect(asSecretMasked('')).toBe('') + expect(asSecretMasked(null)).toBe('') + expect(asSecretMasked(undefined)).toBe('') + }) + + it('掩码失效漏出的任意非空值也归哨兵(防原始密钥回传库外)', () => { + expect(asSecretMasked('sk-real-secret')).toBe('__SET__') + expect(asSecretMasked(123)).toBe('') + }) +}) + +describe('normalize 三组(GET 原样组 → 表单 + 密钥态)', () => { + it('email:smtp 完整组 → 表单值 + 密码哨兵;密钥输入恒空', () => { + const state = normalizeEmailCredsGroup({ + smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '__SET__' }, + sender_name: 'HESUPIPE', + notify_emails: ['a@x.com', 'b@x.com'] + }) + expect(state.form).toEqual({ + smtpHost: 'smtp.x.com', + smtpPort: '465', + smtpUser: 'bot@x.com', + smtpPassword: '', + senderName: 'HESUPIPE', + notifyEmails: ['a@x.com', 'b@x.com'] + }) + expect(state.passwordMasked).toBe('__SET__') + }) + + it('email:jiancai 档案形态(无 smtp 键)→ 全空槽 + 未设置密钥态', () => { + const state = normalizeEmailCredsGroup({ + sender_name: 'HESUPIPE', + notify_emails: ['hesu@hesuu.com'] + }) + expect(state.form.smtpHost).toBe('') + expect(state.form.smtpPort).toBe('') + expect(state.form.smtpPassword).toBe('') + expect(state.passwordMasked).toBe('') + }) + + it('email:smtp null / 类型漂移防御', () => { + const state = normalizeEmailCredsGroup({ smtp: null, notify_emails: [1, 'a@x.com'] }) + expect(state.form.smtpHost).toBe('') + expect(state.form.notifyEmails).toEqual(['a@x.com']) + expect(normalizeEmailCredsGroup(undefined).form.notifyEmails).toEqual([]) + }) + + it('push:完整组 → 表单(数值转串)+ api_key 哨兵', () => { + const state = normalizePushCredsGroup({ + enabled: true, + url: 'https://push.example/v1', + api_key: '__SET__', + prefix: 'ai-kefu-jc', + timeout: 5.5, + max_attempts: 3, + retry_backoff_base: 1 + }) + expect(state.form).toEqual({ + enabled: true, + url: 'https://push.example/v1', + apiKey: '', + prefix: 'ai-kefu-jc', + timeout: '5.5', + maxAttempts: '3', + backoffBase: '1' + }) + expect(state.apiKeyMasked).toBe('__SET__') + }) + + it('push:缺省/未启用 → enabled false + 数值空串(null → 空槽)', () => { + const state = normalizePushCredsGroup({ prefix: 'ai-kefu-jc' }) + expect(state.form.enabled).toBe(false) + expect(state.form.timeout).toBe('') + expect(state.form.maxAttempts).toBe('') + expect(state.form.backoffBase).toBe('') + expect(state.form.apiKey).toBe('') + expect(state.apiKeyMasked).toBe('') + }) + + it('ai:缺省组 → 空表单(出厂默认由后端深合并兜底)+ 未设置密钥态', () => { + const state = normalizeAiCredsGroup(null) + expect(state.form).toEqual({ provider: '', apiKey: '', baseUrl: '', model: '' }) + expect(state.apiKeyMasked).toBe('') + const set = normalizeAiCredsGroup({ + provider: 'glm', + api_key: '__SET__', + model: 'glm-4-flash' + }) + expect(set.form.provider).toBe('glm') + expect(set.form.apiKey).toBe('') + expect(set.apiKeyMasked).toBe('__SET__') + }) +}) + +describe('build 三组载荷(表单 → PUT 整组)+ 密钥哨兵往返', () => { + it('email:普通字段 trim、空行剔除、端口转整数', () => { + const payload = buildEmailCredsPayload( + { + smtpHost: ' smtp.x.com ', + smtpPort: ' 465 ', + smtpUser: ' bot@x.com ', + smtpPassword: '', + senderName: ' HESUPIPE ', + notifyEmails: [' a@x.com ', '', ' '] + }, + '' + ) + expect(payload).toEqual({ + smtp: { host: 'smtp.x.com', port: 465, user: 'bot@x.com', password: '' }, + sender_name: 'HESUPIPE', + notify_emails: ['a@x.com'] + }) + }) + + it('email 密钥三态:未设置不动 → 空串;已设置不动 → 哨兵;输入新值 → 新值', () => { + const form = (): EmailCredsForm => ({ + smtpHost: 'smtp.x.com', + smtpPort: '465', + smtpUser: 'bot@x.com', + smtpPassword: '', + senderName: '', + notifyEmails: [] + }) + // 已设置 + 未动 → 哨兵(后端还原库值,库值不变) + expect(buildEmailCredsPayload(form(), '__SET__').smtp).toMatchObject({ + password: '__SET__' + }) + // 未设置 + 未动 → 空串(「清除」意图同为空串 = 显式清空) + expect(buildEmailCredsPayload(form(), '').smtp).toMatchObject({ password: '' }) + // 输入新值 → 新值优先(无论原态) + const typed = { ...form(), smtpPassword: 'new-pw' } + expect(buildEmailCredsPayload(typed, '__SET__').smtp).toMatchObject({ + password: 'new-pw' + }) + }) + + it('push:数值空串 → null(未设置哨兵)、enabled 布尔原样、密钥哨兵往返', () => { + const payload = buildPushCredsPayload( + { + enabled: true, + url: ' https://push.example/v1 ', + apiKey: '', + prefix: 'demo-p', + timeout: '5.5', + maxAttempts: '3', + backoffBase: '' + }, + '__SET__' + ) + expect(payload).toEqual({ + enabled: true, + url: 'https://push.example/v1', + api_key: '__SET__', + prefix: 'demo-p', + timeout: 5.5, + max_attempts: 3, + retry_backoff_base: null + }) + }) + + it('ai:api_key 哨兵/新值/清空三态 + 字段 trim', () => { + const base = { provider: ' glm ', apiKey: '', baseUrl: ' https://x.com ', model: ' glm-4 ' } + expect(buildAiCredsPayload({ ...base }, '__SET__')).toEqual({ + provider: 'glm', + api_key: '__SET__', + base_url: 'https://x.com', + model: 'glm-4' + }) + expect(buildAiCredsPayload({ ...base, apiKey: 'sk-new' }, '').api_key).toBe('sk-new') + expect(buildAiCredsPayload({ ...base }, '').api_key).toBe('') + }) +}) + +describe('validate 前端预检(后端 400 的前置层)', () => { + it('email:端口非整数/越界报错;空串合法(= 平台默认)', () => { + const ok: EmailCredsForm = { + smtpHost: '', + smtpPort: '', + smtpUser: '', + smtpPassword: '', + senderName: '', + notifyEmails: [] + } + expect(validateEmailCredsForm(ok)).toEqual([]) + for (const bad of ['0', '65536', 'abc', '1.5', '-1']) { + expect(validateEmailCredsForm({ ...ok, smtpPort: bad })).toHaveLength(1) + } + expect(validateEmailCredsForm({ ...ok, smtpPort: '465' })).toEqual([]) + expect(validateEmailCredsForm({ ...ok, smtpPort: '65535' })).toEqual([]) + }) + + it('email:通知邮箱非空行须含 @(空行提交时剔除,不报错)', () => { + const base: EmailCredsForm = { + smtpHost: '', + smtpPort: '', + smtpUser: '', + smtpPassword: '', + senderName: '', + notifyEmails: [] + } + expect(validateEmailCredsForm({ ...base, notifyEmails: ['', ' '] })).toEqual([]) + expect( + validateEmailCredsForm({ ...base, notifyEmails: ['a@x.com', 'not-an-email'] }) + ).toHaveLength(1) + }) + + it('push:URL 协议 / 正数 / 1-10 整数边界(与后端 _check_push 同款)', () => { + const ok: PushCredsForm = { + enabled: false, + url: 'https://push.example/v1', + apiKey: '', + prefix: '', + timeout: '10', + maxAttempts: '3', + backoffBase: '1' + } + expect(validatePushCredsForm(ok)).toEqual([]) + // URL 空串合法(= 平台默认);非 http(s) 协议报错 + expect(validatePushCredsForm({ ...ok, url: '' })).toEqual([]) + expect(validatePushCredsForm({ ...ok, url: 'ftp://x' })).toHaveLength(1) + // timeout 正数 + expect(validatePushCredsForm({ ...ok, timeout: '0' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, timeout: '-1' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, timeout: 'abc' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, timeout: '0.5' })).toEqual([]) + // max_attempts 1-10 整数 + expect(validatePushCredsForm({ ...ok, maxAttempts: '0' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, maxAttempts: '11' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, maxAttempts: '2.5' })).toHaveLength(1) + expect(validatePushCredsForm({ ...ok, maxAttempts: '10' })).toEqual([]) + // backoff 正数;全部空串 = 平台默认,合法 + expect(validatePushCredsForm({ ...ok, backoffBase: '0' })).toHaveLength(1) + expect( + validatePushCredsForm({ ...ok, url: '', timeout: '', maxAttempts: '', backoffBase: '' }) + ).toEqual([]) + }) +}) + +describe('PUSH_PROBE_REASON_LABELS(不可达原因 → 修复提示)', () => { + it('覆盖后端 reason 全部分类(dns/timeout/connect)', () => { + expect(PUSH_PROBE_REASON_LABELS.dns).toContain('域名解析') + expect(PUSH_PROBE_REASON_LABELS.timeout).toContain('超时') + expect(PUSH_PROBE_REASON_LABELS.connect).toContain('拒绝') + }) +}) + +// ---- 版本历史 / 两版对比 / 组级重置(Phase 3 Task 7d) ------------------------------- + +describe('RESET_GROUP_LABELS(后端 CONFIG_GROUPS 九组镜像)', () => { + it('覆盖全部可重置组(config_validation.CONFIG_GROUPS 同构)', () => { + expect(Object.keys(RESET_GROUP_LABELS).sort()).toEqual([ + 'ai', + 'brand', + 'contact', + 'email', + 'engine', + 'misc', + 'prompts', + 'push', + 'wall' + ]) + }) + + it('每组有非空中文标签(确认框明示组名用)', () => { + for (const label of Object.values(RESET_GROUP_LABELS)) { + expect(label.trim()).not.toBe('') + } + }) +}) + +describe('formatVersionTime(naive isoformat → 展示串)', () => { + it('T 换空格、截到秒(不做 Date 解析——避免时区偏移)', () => { + expect(formatVersionTime('2026-10-13T10:30:05.123456')).toBe('2026-10-13 10:30:05') + expect(formatVersionTime('2026-10-12T09:00:00')).toBe('2026-10-12 09:00:00') + }) +}) + +describe('descendingVersions(升序取回 → 最新在前)', () => { + it('按版本号倒序;不改动入参数组(后端返回值保持原序)', () => { + const items = [ + { version: 1, comment: 'a' }, + { version: 3, comment: 'c' }, + { version: 2, comment: 'b' } + ] + const out = descendingVersions(items) + expect(out.map((i) => i.version)).toEqual([3, 2, 1]) + expect(items.map((i) => i.version)).toEqual([1, 3, 2]) // 入参不动 + }) +}) + +describe('scopedConfigJson / snapshotGroupKeys(两版对比的范围视图)', () => { + const snapshot = { + brand: { title: 'HESUPIPE Support', color: '#066fc0' }, + push: { api_key: '__SET__', prefix: 'jc' } + } + + it('整份范围:顶层全组、两空格缩进 JSON(掩码哨兵原样呈现)', () => { + const json = scopedConfigJson(snapshot, COMPARE_SCOPE_ALL) + expect(json).toContain('"brand"') + expect(json).toContain('"push"') + expect(json).toContain('"__SET__"') + expect(JSON.parse(json)).toEqual(snapshot) + }) + + it('单组范围:只留该组键(wall 等大组按组聚焦)', () => { + const json = scopedConfigJson(snapshot, 'brand') + expect(JSON.parse(json)).toEqual({ brand: snapshot.brand }) + }) + + it('缺组/空快照防御:快照无该组 → 空对象;空快照 → 空串', () => { + expect(scopedConfigJson(snapshot, 'misc')).toBe('{}') + expect(scopedConfigJson(null, COMPARE_SCOPE_ALL)).toBe('') + expect(scopedConfigJson(undefined, 'brand')).toBe('') + }) + + it('snapshotGroupKeys:顶层组键排序稳定;空值防御', () => { + expect(snapshotGroupKeys(snapshot)).toEqual(['brand', 'push']) + expect(snapshotGroupKeys(null)).toEqual([]) + }) +}) + +describe('通用 JSON 组编辑(终审 I-1:contact/engine/misc)', () => { + it('jsonGroupDraft:组值 → 2 空格缩进等宽稿;null/undefined → 空对象', () => { + expect(jsonGroupDraft({ cc_completion: false })).toBe( + '{\n "cc_completion": false\n}' + ) + expect(jsonGroupDraft(null)).toBe('{}') + expect(jsonGroupDraft(undefined)).toBe('{}') + }) + + it('parseJsonGroupDraft:语法错误 / 非对象顶层 → 带原因拒绝;对象通过', () => { + const bad = parseJsonGroupDraft('{ "a": ') + expect(bad.ok).toBe(false) + if (!bad.ok) expect(bad.error).toContain('JSON 语法错误') + const notObject = parseJsonGroupDraft('[1, 2]') + expect(notObject.ok).toBe(false) + if (!notObject.ok) expect(notObject.error).toContain('顶层须为 JSON 对象') + const ok = parseJsonGroupDraft('{"cc_completion": false}') + expect(ok.ok).toBe(true) + if (ok.ok) expect(ok.value).toEqual({ cc_completion: false }) + }) + + it('JSON_GROUP_FIELDS:三组字段说明齐备(键与 DEFAULT_CONFIG 同名)', () => { + expect(Object.keys(JSON_GROUP_FIELDS).sort()).toEqual(['contact', 'engine', 'misc']) + expect(JSON_GROUP_FIELDS.contact.map((f) => f.key)).toEqual([ + 'cc_completion', + 'email_light_ask', + 'template_languages' + ]) + expect(JSON_GROUP_FIELDS.engine.map((f) => f.key)).toEqual([ + 'rate_limit_per_hour', + 'ws_conn_limit', + 'context_seed_keys' + ]) + expect(JSON_GROUP_FIELDS.misc.map((f) => f.key)).toEqual(['name_to_region_extras']) + }) +}) diff --git a/admin/src/views/config/center/index.vue b/admin/src/views/config/center/index.vue new file mode 100644 index 0000000..3faacc6 --- /dev/null +++ b/admin/src/views/config/center/index.vue @@ -0,0 +1,1339 @@ + + + + + + diff --git a/admin/src/views/config/center/model.ts b/admin/src/views/config/center/model.ts new file mode 100644 index 0000000..6e0541a --- /dev/null +++ b/admin/src/views/config/center/model.ts @@ -0,0 +1,801 @@ +/** + * 配置中心提示词/词表/凭据编辑模型(Phase 3 Task 7b + 7c;Task 7d 补 + * 版本历史展示辅助) + * + * 消费方:config/center 的 PromptsPane.vue / WallPane.vue / CredsPane.vue / + * VersionsPane.vue。纯逻辑无 DOM 依赖,vitest 直接覆盖(__tests__/model.test.ts)。 + * + * 事实源对齐: + * - 提示词四件套键名/字节上限:app/engine/services/ai_service.PROMPTS_KEYS + * 与 app/platform/config_validation.PROMPTS_SIZE_CAPS(company_facts + * 50KB 软上限 spec §5.7,其余 100KB;UTF-8 字节口径——与后端 + * len(text.encode('utf-8')) 一致,用 TextEncoder 计数); + * - 词表结构:app/engine/wall_schema.py(seed_profiles/jiancai/wall.json + * 为真实形态样本)。正则条目双形态:纯字符串 | {pattern, flags?, + * comment?}(flags 仅支持 'i');PUT wall 整组提交,未结构化编辑的 + * 顶层键(含 "_" 前缀元信息键)原样透传; + * - 语言池必填 en/zh(wall_schema._REQUIRED_POOLS),reminders 六类 / + * cc_templates 四组的变体键为封闭集合(未知键编译期显性拒绝); + * - 凭据三组(Task 7c):DEFAULT_CONFIG 的 email/push/ai 组键 + + * config_validation 数值边界(push.timeout/backoff 正数、max_attempts + * 1-10 整数、notify_emails 项含 @)+ config_views 空槽回退语义 + * (空串/None = 未设置 → 回退 env,表单提示「留空 = 平台默认」)。 + * 密钥哨兵交互见 src/api/mask.ts(GET '__SET__' → 空输入 + 占位符; + * 不动保存回传哨兵保持库值;「清除」= 提交空串)。 + */ +import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask' +import type { ConfigGroupKey } from '@/api/config' +// ---- 提示词四件套 ----------------------------------------------------------------- + +/** 四件套键名(ai_service.PROMPTS_KEYS 镜像;prompts 是整组 PUT 的四键 dict) */ +export const PROMPTS_KEYS = [ + 'system', + 'company_facts', + 'extractor', + 'evaluator' +] as const + +export type PromptKey = (typeof PROMPTS_KEYS)[number] + +export type PromptsDraft = Record + +/** 各件字节上限(UTF-8 口径;config_validation.PROMPTS_SIZE_CAPS 镜像) */ +export const PROMPTS_CAPS: Record = { + system: 100 * 1024, + company_facts: 50 * 1024, + extractor: 100 * 1024, + evaluator: 100 * 1024 +} + +/** 四件套编辑器元数据(label/hint 为 UI 展示;deflectionPoolNote 仅 system) */ +export const PROMPTS_META: Array<{ + key: PromptKey + label: string + rows: number + hint: string +}> = [ + { + key: 'system', + label: '系统提示词 system.md', + rows: 14, + hint: 'AI 行为总纲(职能边界/语言跟随等)' + }, + { + key: 'company_facts', + label: '公司事实卡 company_facts.md', + rows: 10, + hint: '每轮整段拼入 system prompt——保持精炼(50KB 软上限)' + }, + { + key: 'extractor', + label: '线索提取器 extractor.md', + rows: 8, + hint: '$existing_info / $messages 占位符由运行时回填' + }, + { + key: 'evaluator', + label: '意向评估器 evaluator.md', + rows: 8, + hint: '$profile / $recent_messages 占位符由运行时回填' + } +] + +/** UTF-8 字节数(后端 len(text.encode('utf-8')) 同口径) */ +export function utf8Bytes(text: string): number { + return new TextEncoder().encode(text).length +} + +/** 字节数展示(KB 一位小数) */ +export function formatKB(bytes: number): string { + return `${(bytes / 1024).toFixed(1)}KB` +} + +/** 单件字节状态(实时提示用) */ +export interface PromptByteInfo { + bytes: number + cap: number + over: boolean + label: string +} + +export function promptByteInfo(key: PromptKey, text: string): PromptByteInfo { + const bytes = utf8Bytes(text) + const cap = PROMPTS_CAPS[key] + return { + bytes, + cap, + over: bytes > cap, + label: `${formatKB(bytes)} / ${formatKB(cap)}` + } +} + +/** GET 的 prompts 组(原样快照,可能缺键/类型漂移)→ 四键字符串表单值 */ +export function normalizePromptsGroup( + value: Record | null | undefined +): PromptsDraft { + const source = value ?? {} + const out = {} as PromptsDraft + for (const key of PROMPTS_KEYS) { + const v = source[key] + out[key] = typeof v === 'string' ? v : '' + } + return out +} + +/** 四件套是否有超限件(超限即禁存——后端同样硬拒,前移到按钮态) */ +export function promptsOverCap(draft: PromptsDraft): boolean { + return PROMPTS_KEYS.some((key) => utf8Bytes(draft[key]) > PROMPTS_CAPS[key]) +} + +// ---- 词表(wall 组)--------------------------------------------------------------- + +/** 正则条目双形态(wall_schema._compile_pattern):纯字符串 | dict */ +export type WallPatternEntry = + | string + | { pattern?: unknown; flags?: unknown; comment?: unknown } + +/** 正则条目编辑行(双形态归一;flags 仅 '' | 'i'——_FLAG_MAP 只认 'i') */ +export interface RegexRow { + pattern: string + flags: '' | 'i' + comment: string + /** 备注编辑展开态(瞬态 UI 状态,不参与序列化) */ + showComment: boolean +} + +function blankRow(): RegexRow { + return { pattern: '', flags: '', comment: '', showComment: false } +} + +/** 防御读取:一层对象或 null */ +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null +} + +/** 条目 → 编辑行(字符串/dict 双形态;异常值防御归空) */ +export function parsePatternEntry(entry: unknown): RegexRow { + if (typeof entry === 'string') { + return { pattern: entry, flags: '', comment: '', showComment: false } + } + const dict = asRecord(entry) + if (dict) { + return { + pattern: asStr(dict.pattern), + flags: dict.flags === 'i' ? 'i' : '', + comment: asStr(dict.comment), + showComment: false + } + } + return blankRow() +} + +/** 编辑行 → 条目:无 flags 无备注 → 纯字符串(两种形态对编译等价, + * 与 wall.json 的极简条目保持一致);否则 dict 且只带非空键。 + * 空 pattern 行返回 null(编译必 400「缺非空 pattern」,序列化期剔除)。 */ +export function rowToEntry( + row: RegexRow +): string | Record | null { + if (!row.pattern) return null + if (!row.flags && !row.comment.trim()) return row.pattern + const out: Record = { pattern: row.pattern } + if (row.flags) out.flags = row.flags + if (row.comment.trim()) out.comment = row.comment + return out +} + +/** 正则列表序列化(空 pattern 行剔除;列表可为空——由后端编译校验报路径) */ +export function rowsToEntries( + rows: RegexRow[] +): Array> { + return rows + .map(rowToEntry) + .filter((e): e is string | Record => e !== null) +} + +/** 单条目字段(triggers 三键 / zero_number 两键)编辑行 → 条目: + * 无标志无备注且 pattern 非空 → 纯字符串(wall.json zero_number 出厂 + * 形态,编译等价、往返无损);否则 dict 只带非空键——空 pattern 也走 + * dict 分支,后端报「缺非空 pattern」字段路径(裸 '' 会编成匹配一切的 + * 空正则,必须显性失败) */ +export function rowToSingleEntry( + row: RegexRow +): string | Record { + if (row.pattern && !row.flags && !row.comment.trim()) return row.pattern + const out: Record = { pattern: row.pattern } + if (row.flags) out.flags = row.flags + if (row.comment.trim()) out.comment = row.comment + return out +} + +// ---- 语言池 / 话术区结构(wall_schema 变体键镜像;封闭集合) ------------------------ + +/** 四个语言池字段(en/zh 必填——_REQUIRED_POOLS) */ +export const POOL_FIELDS = [ + 'deflections', + 'deflections_contact', + 'pivots', + 'pivots_contact' +] as const + +export type PoolField = (typeof POOL_FIELDS)[number] + +/** 池必填语言(删除时前端拦截;后端编译同样拒绝缺失) */ +export const POOL_REQUIRED_LANGS = ['en', 'zh'] as const + +/** reminders 六类 × 变体(wall_schema._REMINDER_VARIANTS 镜像) */ +export const REMINDER_VARIANTS: Record = { + boundary: ['budget', 'normal'], + contact_ask: ['app_already', 'app', 'web'], + language_follow: ['zh', 'es'], + lead_given: ['forbidden', 'respond_script', 'boundary_tail', 'suspect'], + cc_guide: ['echo', 'ask'], + email_light: ['en', 'zh'] +} + +/** cc_templates 四组 × 语言(wall_schema._CC_TEMPLATE_VARIANTS 镜像) */ +export const CC_TEMPLATE_VARIANTS: Record = { + ask: ['en', 'zh', 'es'], + wall: ['en', 'zh', 'es'], + note: ['en', 'zh'], + echo: ['en', 'zh', 'es'] +} + +export const NO_CONTACT_KEYS = ['web', 'app', 'already'] as const + +/** triggers 三键 / zero_number 两键(单条目正则字段) */ +export const TRIGGER_KEYS = ['boundary', 'budget', 'contact_ask'] as const +export const ZERO_NUMBER_KEYS = ['long_number', 'any_email'] as const + +// ---- 词表整体编辑模型 -------------------------------------------------------------- + +/** 词表编辑模型:结构化编辑区(全部归一为可 v-model 的形态)+ 透传区 */ +export interface WallEditModel { + /** 未提供结构化编辑的顶层键原样透传(含 "_" 前缀元信息键) */ + passthrough: Record + priceRows: RegexRow[] + reaskRows: RegexRow[] + /** triggers.boundary/budget/contact_ask + zero_number.long_number/any_email */ + singles: Record<(typeof TRIGGER_KEYS)[number] | (typeof ZERO_NUMBER_KEYS)[number], RegexRow> + /** 四池:语言 → 句子列表(可增删行;序列化时空行剔除) */ + pools: Record> + noContact: Record<(typeof NO_CONTACT_KEYS)[number], string> + /** 六类 × 变体 → 话术文本(变体键封闭集合,缺项补空串交后端报路径) */ + reminders: Record> + /** 四组 × 语言 → 模板文本({intl}/{cc_script} 等占位符白名单由后端校验) */ + ccTemplates: Record> +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +/** 防御读取:字符串或空串 */ +function asStr(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** 防御读取:语言池 → 语言到字符串数组的映射(非字符串项剔除) */ +function asPool(value: unknown): Record { + const source = asRecord(value) + if (!source) return {} + const out: Record = {} + for (const [lang, items] of Object.entries(source)) { + if (Array.isArray(items)) { + const rows = items.filter((t): t is string => typeof t === 'string') + if (rows.length > 0) out[lang] = rows + } + } + return out +} + +/** 防御读取:一层字符串映射 → 只保留 variant 集合内的键(缺项补空串) */ +function asVariantMap( + value: unknown, + variants: string[] +): Record { + const source = asRecord(value) ?? {} + const out: Record = {} + for (const key of variants) { + out[key] = asStr(source[key]) + } + return out +} + +/** 结构化编辑接管的顶层键(其余透传) */ +const KNOWN_WALL_EDIT_KEYS = new Set([ + 'price_patterns', + 'reask_patterns', + 'triggers', + 'zero_number', + ...POOL_FIELDS, + 'no_contact', + 'reminders', + 'cc_templates' +]) + +/** + * GET 的 wall 组 → 编辑模型。wall 缺失/非对象/空对象 → null(面板出空态 + * ——wall 组来自租户 seed 的 wall.json,缺失时无从结构化编辑)。结构上 + * 无法通过 PUT 校验的存量数据(理论不可达——PUT 前有编译校验)也归一为 + * 可编辑形态,保存时由后端 400 带字段路径显性兜底。 + */ +export function initWallEdit( + source: Record | null | undefined +): WallEditModel | null { + const record = asRecord(source) + if (!record || Object.keys(record).length === 0) return null + + const rest = Object.fromEntries( + Object.entries(record).filter(([k]) => !KNOWN_WALL_EDIT_KEYS.has(k)) + ) + const triggersSrc = asRecord(record.triggers) + const zeroSrc = asRecord(record.zero_number) + + const singles = {} as WallEditModel['singles'] + for (const key of TRIGGER_KEYS) { + singles[key] = parsePatternEntry(triggersSrc?.[key]) + } + for (const key of ZERO_NUMBER_KEYS) { + singles[key] = parsePatternEntry(zeroSrc?.[key]) + } + + const pools = {} as WallEditModel['pools'] + for (const field of POOL_FIELDS) { + pools[field] = asPool(record[field]) + } + + const reminders: Record> = {} + const remindersSrc = asRecord(record.reminders) + for (const [cat, variants] of Object.entries(REMINDER_VARIANTS)) { + reminders[cat] = asVariantMap(remindersSrc?.[cat], variants) + } + + const ccTemplates: Record> = {} + const ccSrc = asRecord(record.cc_templates) + for (const [group, langs] of Object.entries(CC_TEMPLATE_VARIANTS)) { + ccTemplates[group] = asVariantMap(ccSrc?.[group], langs) + } + + const noContact = asVariantMap(record.no_contact, [...NO_CONTACT_KEYS]) as WallEditModel['noContact'] + + return { + passthrough: clone(rest), + priceRows: parsePatternList(record.price_patterns), + reaskRows: parsePatternList(record.reask_patterns), + singles, + pools, + noContact, + reminders, + ccTemplates + } +} + +function parsePatternList(value: unknown): RegexRow[] { + return Array.isArray(value) ? value.map(parsePatternEntry) : [] +} + +/** 池序列化:空行剔除;剔除后为空的语言整键丢弃(空列表必被编译拒绝, + * 丢弃后缺 en/zh 由后端报「缺语言」路径——同为显性失败,语义更准) */ +function cleanPool(pool: Record): Record { + const out: Record = {} + for (const [lang, rows] of Object.entries(pool)) { + const cleaned = rows.filter((t) => t.trim() !== '') + if (cleaned.length > 0) out[lang] = cleaned + } + return out +} + +/** + * 编辑模型 → PUT config/wall 整组载荷:结构化区按编辑值重组,其余顶层键 + * 原样透传。正则编译/结构问题不在前端造校验器——后端 compile_wall 的 + * 400 自带字段路径(含试金石预演路径),内联展示即可。 + */ +export function buildWallPayload(model: WallEditModel): Record { + const triggers: Record = {} + for (const key of TRIGGER_KEYS) { + triggers[key] = rowToSingleEntry(model.singles[key]) + } + const zeroNumber: Record = {} + for (const key of ZERO_NUMBER_KEYS) { + zeroNumber[key] = rowToSingleEntry(model.singles[key]) + } + const pools: Record = {} + for (const field of POOL_FIELDS) { + pools[field] = cleanPool(model.pools[field]) + } + return { + ...model.passthrough, + price_patterns: rowsToEntries(model.priceRows), + reask_patterns: rowsToEntries(model.reaskRows), + triggers, + zero_number: zeroNumber, + ...pools, + no_contact: { ...model.noContact }, + reminders: clone(model.reminders), + cc_templates: clone(model.ccTemplates) + } +} + +// ---- 试金石 ------------------------------------------------------------------------ + +/** 试金石默认示例句:一句价格墙(blocked)/ 一句零号码墙(replaced)/ + * 一句干净(pass)——三色判定一屏可见 */ +export const DEFAULT_TOUCHSTONE_SENTENCES = [ + 'The price is 500 USD per ton.', + 'You can reach me at john.doe@example.com.', + 'What sizes of PPR pipes do you have in stock?' +] + +/** 试金石请求句集:去空白过滤空行(后端要求非空字符串列表) */ +export function touchstoneSentences(sentences: string[]): string[] { + return sentences.map((s) => s.trim()).filter(Boolean) +} + +// ---- 凭据三组(email / push / ai,Phase 3 Task 7c) -------------------------------- + +/** 邮件凭据表单(数值/密钥输入均为字符串形态——空串 = 未设置回退 env) */ +export interface EmailCredsForm { + smtpHost: string + smtpPort: string + smtpUser: string + /** 密钥输入:初始恒空,「已设置」态由占位符表达(见 mask.ts) */ + smtpPassword: string + senderName: string + notifyEmails: string[] +} + +/** 推送凭据表单(timeout/maxAttempts/backoffBase:'' = null = 平台默认) */ +export interface PushCredsForm { + enabled: boolean + url: string + apiKey: string + prefix: string + timeout: string + maxAttempts: string + backoffBase: string +} + +/** AI 凭据表单 */ +export interface AiCredsForm { + provider: string + apiKey: string + baseUrl: string + model: string +} + +/** GET 组 → 表单 + 密钥掩码状态(掩码值驱动占位符与 secretToSubmit) */ +export interface EmailCredsState { + form: EmailCredsForm + passwordMasked: string +} + +export interface PushCredsState { + form: PushCredsForm + apiKeyMasked: string +} + +export interface AiCredsState { + apiKeyMasked: string + form: AiCredsForm +} + +/** + * 任意非空字符串 → 掩码哨兵。比 isMaskedSentinel 更保守:即使后端掩码 + * 失效漏出原始密钥(理论不可达),也按「已设置」处理——不动保存回传 + * 哨兵由后端还原库值,绝不把原始值再提交回去;空串/null/undefined → '' + * (未设置)。 + */ +export function asSecretMasked(value: unknown): string { + return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : '' +} + +/** 防御读取:数值 → 表单串(非有限数值/缺省 → '') */ +function numberToForm(value: unknown): string { + return typeof value === 'number' && Number.isFinite(value) ? String(value) : '' +} + +/** 防御读取:字符串数组(非字符串项剔除) */ +function strList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => typeof v === 'string') + : [] +} + +/** 表单串 → 整数 | null(空串 = 未设置;非整数由 validate 前置拦截) */ +function intOrNull(value: string): number | null { + const n = Number(value.trim()) + return value.trim() !== '' && Number.isInteger(n) ? n : null +} + +/** 表单串 → 数值 | null(空串 = 未设置;非数值由 validate 前置拦截) */ +function numOrNull(value: string): number | null { + const n = Number(value.trim()) + return value.trim() !== '' && Number.isFinite(n) ? n : null +} + +/** GET 的 email 组 → 邮件表单(smtp 缺省/null → 全空槽,回退 env) */ +export function normalizeEmailCredsGroup( + value: Record | null | undefined +): EmailCredsState { + const source = value ?? {} + const smtp = asRecord(source.smtp) ?? {} + return { + form: { + smtpHost: asStr(smtp.host), + smtpPort: numberToForm(smtp.port), + smtpUser: asStr(smtp.user), + smtpPassword: '', + senderName: asStr(source.sender_name), + notifyEmails: strList(source.notify_emails) + }, + passwordMasked: asSecretMasked(smtp.password) + } +} + +/** GET 的 push 组 → 推送表单 */ +export function normalizePushCredsGroup( + value: Record | null | undefined +): PushCredsState { + const source = value ?? {} + return { + form: { + enabled: source.enabled === true, + url: asStr(source.url), + apiKey: '', + prefix: asStr(source.prefix), + timeout: numberToForm(source.timeout), + maxAttempts: numberToForm(source.max_attempts), + backoffBase: numberToForm(source.retry_backoff_base) + }, + apiKeyMasked: asSecretMasked(source.api_key) + } +} + +/** GET 的 ai 组 → AI 表单 */ +export function normalizeAiCredsGroup( + value: Record | null | undefined +): AiCredsState { + const source = value ?? {} + return { + form: { + provider: asStr(source.provider), + apiKey: '', + baseUrl: asStr(source.base_url), + model: asStr(source.model) + }, + apiKeyMasked: asSecretMasked(source.api_key) + } +} + +/** + * 邮件表单 → PUT config/email 整组载荷。密钥走 secretToSubmit(未动 → + * 哨兵保持库值;「清除」按钮已把 maskedState 置 '',提交空串 = 显式清空)。 + * 数值/邮箱格式由 validateEmailCredsForm 前置把关(后端 400 二层兜底)。 + */ +export function buildEmailCredsPayload( + form: EmailCredsForm, + passwordMasked: string +): Record { + return { + smtp: { + host: form.smtpHost.trim(), + port: intOrNull(form.smtpPort), + user: form.smtpUser.trim(), + password: secretToSubmit(form.smtpPassword, passwordMasked) + }, + sender_name: form.senderName.trim(), + notify_emails: form.notifyEmails + .map((e) => e.trim()) + .filter((e) => e !== '') + } +} + +/** 推送表单 → PUT config/push 整组载荷(prefix 冲突 400 由后端跨租户查重) */ +export function buildPushCredsPayload( + form: PushCredsForm, + apiKeyMasked: string +): Record { + return { + enabled: form.enabled, + url: form.url.trim(), + api_key: secretToSubmit(form.apiKey, apiKeyMasked), + prefix: form.prefix.trim(), + timeout: numOrNull(form.timeout), + max_attempts: intOrNull(form.maxAttempts), + retry_backoff_base: numOrNull(form.backoffBase) + } +} + +/** AI 表单 → PUT config/ai 整组载荷 */ +export function buildAiCredsPayload( + form: AiCredsForm, + apiKeyMasked: string +): Record { + return { + provider: form.provider.trim(), + api_key: secretToSubmit(form.apiKey, apiKeyMasked), + base_url: form.baseUrl.trim(), + model: form.model.trim() + } +} + +/** 邮件表单预检(空槽合法——回退 env;只查非空行的格式) */ +export function validateEmailCredsForm(form: EmailCredsForm): string[] { + const errors: string[] = [] + const port = form.smtpPort.trim() + if (port !== '') { + const n = Number(port) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + errors.push('SMTP 端口:须为 1-65535 的整数(留空 = 平台默认)') + } + } + form.notifyEmails.forEach((row, i) => { + const t = row.trim() + if (t !== '' && !t.includes('@')) { + errors.push(`通知邮箱第 ${i + 1} 行:须为含 @ 的邮箱地址`) + } + }) + return errors +} + +/** 推送表单预检(与后端 _check_push 的数值边界同款:正数 / 1-10 整数) */ +export function validatePushCredsForm(form: PushCredsForm): string[] { + const errors: string[] = [] + const url = form.url.trim() + if (url !== '' && !/^https?:\/\//.test(url)) { + errors.push('推送 URL:须以 http:// 或 https:// 开头') + } + const timeout = form.timeout.trim() + if (timeout !== '') { + const n = Number(timeout) + if (!Number.isFinite(n) || n <= 0) { + errors.push('推送超时(秒):须为正数(留空 = 平台默认)') + } + } + const attempts = form.maxAttempts.trim() + if (attempts !== '') { + const n = Number(attempts) + if (!Number.isInteger(n) || n < 1 || n > 10) { + errors.push('最大尝试次数:须为 1-10 的整数(留空 = 平台默认)') + } + } + const backoff = form.backoffBase.trim() + if (backoff !== '') { + const n = Number(backoff) + if (!Number.isFinite(n) || n <= 0) { + errors.push('重试退避基数(秒):须为正数(留空 = 平台默认)') + } + } + return errors +} + +/** 推送探测不可达原因 → 修复提示文案(后端 reason 分类的前端展示层) */ +export const PUSH_PROBE_REASON_LABELS: Record = { + dns: '域名解析失败(检查 URL 拼写)', + timeout: '连接超时', + connect: '连接被拒绝或网络不可达' +} + +// ---- 版本历史 / 两版对比 / 组级重置(Phase 3 Task 7d) ------------------------------ + +/** + * 可重置组镜像(后端 config_validation.CONFIG_GROUPS = DEFAULT_CONFIG 键 + + * prompts,共 9 组;reset 路由同款 allowlist——未知组 400)。标签用于 + * 确认框明示组名(「将把「品牌」组恢复为出厂档案值」)。 + */ +export const RESET_GROUP_LABELS: Record = { + brand: '品牌', + prompts: '提示词', + wall: '安全词表', + contact: '联系方式', + email: '邮件', + push: '推送', + ai: 'AI', + engine: '引擎', + misc: '杂项' +} + +/** + * 版本时间展示:后端 created_at 是 naive isoformat(无时区后缀)——直接 + * 字符串切形(T → 空格,截到秒),不做 Date 解析避免被浏览器当作 UTC + * 再偏移出错误时刻。 + */ +export function formatVersionTime(iso: string): string { + return iso.replace('T', ' ').slice(0, 19) +} + +/** 版本列表倒序(后端升序返回——展示最新在前) */ +export function descendingVersions( + items: T[] +): T[] { + return [...items].sort((a, b) => b.version - a.version) +} + +/** 对比范围哨兵:整份快照(其余值 = 单组键——wall 等大组 20KB+ 按组聚焦) */ +export const COMPARE_SCOPE_ALL = '__all__' + +/** 快照顶层组键(对比范围下拉选项;键序排序稳定) */ +export function snapshotGroupKeys( + config: Record | null | undefined +): string[] { + return config ? Object.keys(config).sort() : [] +} + +/** + * 范围视图 → 等宽面板 JSON 串(spec §7.3 最低形态:纯并排展示,不做差异 + * 高亮)。密钥字段显示为 '__SET__'——后端对所有携带 config 的响应统一 + * 掩码,历史行存原值但绝不回传。 + */ +export function scopedConfigJson( + config: Record | null | undefined, + scope: string +): string { + if (!config) return '' + const view = + scope === COMPARE_SCOPE_ALL ? config : { [scope]: config[scope] } + return JSON.stringify(view, null, 2) +} + +// ---- 通用 JSON 组编辑(Phase 3 终审 I-1:contact / engine / misc) ------------------- + +/** 走通用 JSON pane 的组(无密钥字段、结构低频变更——表单化收益低) */ +export type JsonGroupKey = 'contact' | 'engine' | 'misc' + +/** 组取值 → 编辑器初稿(null 组 = 空对象;2 空格缩进等宽展示) */ +export function jsonGroupDraft( + group: Record | null | undefined +): string { + return JSON.stringify(group ?? {}, null, 2) +} + +/** 编辑器解析结果:ok=false 时 error 带可直接展示的中文原因 */ +export type JsonDraftParse = + | { ok: true; value: Record } + | { ok: false; error: string } + +/** 编辑稿预检:JSON 语法 + 顶层须为对象(后端 _check_* 类型校验兜底) */ +export function parseJsonGroupDraft(text: string): JsonDraftParse { + let value: unknown + try { + value = JSON.parse(text) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + return { ok: false, error: `JSON 语法错误——${detail}` } + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return { ok: false, error: '顶层须为 JSON 对象({ ... })——整组替换语义' } + } + // typeof 收窄只到 object(无索引签名)——JSON.parse 产物必为普通对象,断言收形 + return { ok: true, value: value as Record } +} + +/** 三组的字段说明(pane 顶部展示;事实源 app/platform/defaults.py) */ +export const JSON_GROUP_FIELDS: Record< + JsonGroupKey, + Array<{ key: string; desc: string }> +> = { + contact: [ + { key: 'cc_completion', desc: 'bool——cc 区号补全判定树开关' }, + { key: 'email_light_ask', desc: 'bool——dual-contact 分步双要的邮箱轻补' }, + { + key: 'template_languages', + desc: 'string[]——邮件模板语言清单(运行期接线归 Phase 4,清理单 #8)' + } + ], + engine: [ + { key: 'rate_limit_per_hour', desc: '正整数——聊天限流(次/小时/IP)' }, + { key: 'ws_conn_limit', desc: '正整数——每 IP WebSocket 连接上限' }, + { + key: 'context_seed_keys', + desc: '映射——产品字段 → 会话上下文种子键(如 product_name → app_product_name)' + } + ], + misc: [ + { + key: 'name_to_region_extras', + desc: '映射——国名 → ISO 区域码扩展(resolve_region_code 消费)' + } + ] +} diff --git a/admin/src/views/config/center/reset.ts b/admin/src/views/config/center/reset.ts new file mode 100644 index 0000000..4dad5d3 --- /dev/null +++ b/admin/src/views/config/center/reset.ts @@ -0,0 +1,45 @@ +/** + * 组级重置出厂交互(Phase 3 Task 7d):各配置组编辑卡头部的「重置出厂」 + * 危险色小按钮共用入口——确认框(明示组名与「恢复出厂档案值」)→ + * POST config/{group}/reset。 + * + * 返回值三态(调用方据此刷新该组表单 + 版本): + * - null:用户取消确认(非请求失败); + * - resolve:重置成功——响应 {group, version(新), config(出厂值掩码视图)}, + * 调用方 emit 'saved' 回写父层快照组 → 组引用变化触发 pane 重置表单; + * - reject:请求失败(showErrorMessage: false,调用方内联展示 400 detail + * ——如 push 出厂前缀被其他租户占用的跨租户查重报错)。 + * + * 后端事实源 config_routes.reset_config_group:出厂值 = 代码库当前模板 + * (租户创建 profile 的组快照,缺的组取 DEFAULT_CONFIG 整组),产新版本 + * + registry_reload 热生效。 + */ +import { ElMessageBox } from 'element-plus' +import { + resetConfigGroup, + type ConfigGroupKey, + type ResetConfigGroupResponse +} from '@/api/config' +import { RESET_GROUP_LABELS } from './model' + +export async function confirmAndResetGroup( + slug: string, + group: ConfigGroupKey +): Promise { + const label = RESET_GROUP_LABELS[group] + const confirmed = await ElMessageBox.confirm( + `将把「${label}」组(${group})恢复为出厂档案值,覆盖该组当前全部定制。` + + '重置会产生一个新版本并立即热生效——历史版本保留不变,可随时在「版本」页回滚。', + `重置出厂:${label}`, + { + type: 'warning', + confirmButtonText: '重置出厂', + cancelButtonText: '取消' + } + ).then( + () => true, + () => false + ) + if (!confirmed) return null + return resetConfigGroup(slug, group, { showErrorMessage: false }) +} diff --git a/admin/src/views/conversations/list/index.vue b/admin/src/views/conversations/list/index.vue new file mode 100644 index 0000000..fcbeec4 --- /dev/null +++ b/admin/src/views/conversations/list/index.vue @@ -0,0 +1,874 @@ + + + + + + diff --git a/admin/src/views/dashboard/console/index.vue b/admin/src/views/dashboard/console/index.vue new file mode 100644 index 0000000..449548f --- /dev/null +++ b/admin/src/views/dashboard/console/index.vue @@ -0,0 +1,438 @@ + + + + + + diff --git a/admin/src/views/exception/403/index.vue b/admin/src/views/exception/403/index.vue new file mode 100644 index 0000000..2756c42 --- /dev/null +++ b/admin/src/views/exception/403/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/admin/src/views/exception/404/index.vue b/admin/src/views/exception/404/index.vue new file mode 100644 index 0000000..6b64f45 --- /dev/null +++ b/admin/src/views/exception/404/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/admin/src/views/exception/500/index.vue b/admin/src/views/exception/500/index.vue new file mode 100644 index 0000000..1b26377 --- /dev/null +++ b/admin/src/views/exception/500/index.vue @@ -0,0 +1,16 @@ + + + + diff --git a/admin/src/views/index/index.vue b/admin/src/views/index/index.vue new file mode 100644 index 0000000..415a436 --- /dev/null +++ b/admin/src/views/index/index.vue @@ -0,0 +1,29 @@ + + + + + + diff --git a/admin/src/views/index/style.scss b/admin/src/views/index/style.scss new file mode 100644 index 0000000..c89f354 --- /dev/null +++ b/admin/src/views/index/style.scss @@ -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); + } + } + } + } +} diff --git a/admin/src/views/outside/Iframe.vue b/admin/src/views/outside/Iframe.vue new file mode 100644 index 0000000..33ea0dc --- /dev/null +++ b/admin/src/views/outside/Iframe.vue @@ -0,0 +1,42 @@ + + + diff --git a/admin/src/views/settings/platform/__tests__/model.test.ts b/admin/src/views/settings/platform/__tests__/model.test.ts new file mode 100644 index 0000000..b1b14a1 --- /dev/null +++ b/admin/src/views/settings/platform/__tests__/model.test.ts @@ -0,0 +1,149 @@ +/** + * 平台设置页模型测试(Phase 3 Task 9) + * + * 钉住 model.ts 与后端 config_routes settings_router 的契约: + * - 密钥判定镜像 _SETTING_SECRET_MARKERS(子串匹配 + 大小写不敏感—— + * 'monkey' 命中 'key' 是与后端一致的行为,不"修正"); + * - normalize:GET items → 表单(密钥输入恒空 + 掩码态单列;数值 → + * 字符串;缺键补空); + * - 脏检测:密钥键输入新值 / 显式清除才脏(不动不发请求);普通键 + * trim 后比对; + * - 提交值:密钥走 secretToSubmit(新值 / 空串清除 / 哨兵保持);int + * 键空串 → null、合法整数 → 数值; + * - 展示:哨兵与密钥键非空串一律脱敏文案(后端掩码失效也不回显原文)。 + */ +import { describe, expect, it } from 'vitest' + +import { MASKED_SENTINEL } from '@/api/mask' +import { + AI_SETTING_FIELDS, + SMTP_SETTING_FIELDS, + ALL_SETTING_FIELDS, + buildSettingValue, + formatSettingValue, + isFieldDirty, + isRecommendedKey, + isSecretSettingKey, + normalizeSettingsItems, + validateSettingsForm +} from '../model' + +const AI_KEY = AI_SETTING_FIELDS.find((f) => f.key === 'ai.api_key')! +const SMTP_PASSWORD = SMTP_SETTING_FIELDS.find((f) => f.key === 'smtp.password')! +const SMTP_PORT = SMTP_SETTING_FIELDS.find((f) => f.key === 'smtp.port')! +const BASE_URL = AI_SETTING_FIELDS.find((f) => f.key === 'ai.base_url')! + +describe('平台设置模型(settings/platform/model.ts)', () => { + // ---- 密钥判定(后端镜像) ---------------------------------------------------------- + + it('isSecretSettingKey:key/password/secret 片段命中(子串 + 大小写不敏感)', () => { + expect(isSecretSettingKey('ai.api_key')).toBe(true) + expect(isSecretSettingKey('smtp.password')).toBe(true) + expect(isSecretSettingKey('client_secret')).toBe(true) + expect(isSecretSettingKey('API-KEY')).toBe(true) + // 子串匹配的后端同款行为:monkey 含 key 也命中(契约一致,不修正) + expect(isSecretSettingKey('monkey')).toBe(true) + expect(isSecretSettingKey('smtp.host')).toBe(false) + expect(isSecretSettingKey('ai.model')).toBe(false) + }) + + // ---- normalize --------------------------------------------------------------------- + + it('normalizeSettingsItems:密钥键输入恒空 + 掩码态单列;数值转字符串;缺键补空', () => { + const state = normalizeSettingsItems([ + { key: 'ai.api_key', value: MASKED_SENTINEL }, + { key: 'ai.base_url', value: 'https://open.bigmodel.cn/api/paas/v4' }, + { key: 'smtp.port', value: 465 }, + { key: 'smtp.password', value: '' }, + { key: 'other.custom', value: { nested: true } } + ]) + expect(state.form['ai.api_key']).toBe('') + expect(state.masked['ai.api_key']).toBe(MASKED_SENTINEL) + expect(state.form['ai.base_url']).toBe('https://open.bigmodel.cn/api/paas/v4') + expect(state.form['smtp.port']).toBe('465') + // 空串密码 = 未设置(后端哨兵只掩非空) + expect(state.masked['smtp.password']).toBe('') + expect(state.form['ai.model']).toBe('') + expect(state.form['smtp.host']).toBe('') + expect(state.items).toHaveLength(5) + }) + + it('normalizeSettingsItems:后端掩码失效漏出原文 → 仍按哨兵处理(保守策略)', () => { + const state = normalizeSettingsItems([ + { key: 'ai.api_key', value: 'sk-leaked-plaintext' } + ]) + expect(state.masked['ai.api_key']).toBe(MASKED_SENTINEL) + expect(state.form['ai.api_key']).toBe('') + }) + + // ---- 脏检测 ------------------------------------------------------------------------- + + it('isFieldDirty:密钥键——输入新值 / 显式清除才脏,不动不提交', () => { + const maskedSet = MASKED_SENTINEL + // 未动:输入空 + 有效掩码 = 原掩码 → 不脏 + expect(isFieldDirty(AI_KEY, '', maskedSet, maskedSet, '')).toBe(false) + // 输入新值 → 脏 + expect(isFieldDirty(AI_KEY, 'sk-new', maskedSet, maskedSet, '')).toBe(true) + // 显式清除(有效掩码被置 '',原已设置)→ 脏(提交空串) + expect(isFieldDirty(AI_KEY, '', '', maskedSet, '')).toBe(true) + // 库中未设置 + 输入空 → 不脏(不发无意义的空 PUT) + expect(isFieldDirty(AI_KEY, '', '', '', '')).toBe(false) + }) + + it('isFieldDirty:普通键 trim 后比对(两端空白视为未改)', () => { + expect(isFieldDirty(BASE_URL, 'https://x.cn', '', '', 'https://x.cn')).toBe(false) + expect(isFieldDirty(BASE_URL, ' https://x.cn ', '', '', 'https://x.cn')).toBe(false) + expect(isFieldDirty(BASE_URL, 'https://y.cn', '', '', 'https://x.cn')).toBe(true) + expect(isFieldDirty(BASE_URL, '', '', '', 'https://x.cn')).toBe(true) + }) + + // ---- 提交值 ------------------------------------------------------------------------- + + it('buildSettingValue:密钥键走 secretToSubmit(新值 / 清除空串 / 哨兵保持)', () => { + expect(buildSettingValue(AI_KEY, 'sk-new', MASKED_SENTINEL)).toBe('sk-new') + expect(buildSettingValue(AI_KEY, '', '')).toBe('') + // 未动但被强制提交 → 哨兵(后端还原库值) + expect(buildSettingValue(AI_KEY, '', MASKED_SENTINEL)).toBe(MASKED_SENTINEL) + expect(buildSettingValue(SMTP_PASSWORD, '', MASKED_SENTINEL)).toBe(MASKED_SENTINEL) + }) + + it('buildSettingValue:int 键空串 → null、合法整数 → 数值、其余 trim 字符串', () => { + expect(buildSettingValue(SMTP_PORT, '', '')).toBeNull() + expect(buildSettingValue(SMTP_PORT, ' 465 ', '')).toBe(465) + // 非整数被 validate 前置拦截;此处字符串透传兜底(后端 value 任意 JSON) + expect(buildSettingValue(SMTP_PORT, 'abc', '')).toBe('abc') + expect(buildSettingValue(BASE_URL, ' https://x.cn ', '')).toBe('https://x.cn') + }) + + // ---- 校验 --------------------------------------------------------------------------- + + it('validateSettingsForm:端口 1-65535 整数(空槽合法)', () => { + const form = { 'smtp.port': '465' } + expect(validateSettingsForm(SMTP_SETTING_FIELDS, form)).toEqual([]) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '' })).toEqual([]) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '0' })).toHaveLength(1) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '70000' })).toHaveLength(1) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': 'abc' })).toHaveLength(1) + expect(validateSettingsForm(SMTP_SETTING_FIELDS, { 'smtp.port': '4.5' })).toHaveLength(1) + }) + + // ---- 展示 --------------------------------------------------------------------------- + + it('formatSettingValue:哨兵 / 密钥键非空串 → 脱敏文案;其余原样或 JSON', () => { + expect(formatSettingValue('ai.api_key', MASKED_SENTINEL)).toBe('已设置(值已脱敏)') + expect(formatSettingValue('smtp.password', 'raw-secret')).toBe('已设置(值已脱敏)') + expect(formatSettingValue('smtp.host', 'smtp.qq.com')).toBe('smtp.qq.com') + expect(formatSettingValue('smtp.host', '')).toBe('""') + expect(formatSettingValue('x.flag', null)).toBe('null') + expect(formatSettingValue('x.flag', undefined)).toBe('—') + expect(formatSettingValue('x.flag', { a: 1 })).toBe('{"a":1}') + expect(formatSettingValue('x.list', [1, 2])).toBe('[1,2]') + }) + + it('推荐键集合:ai.* 三键 + smtp.* 四键', () => { + expect(ALL_SETTING_FIELDS).toHaveLength(7) + expect(isRecommendedKey('ai.api_key')).toBe(true) + expect(isRecommendedKey('smtp.port')).toBe(true) + expect(isRecommendedKey('other.custom')).toBe(false) + }) +}) diff --git a/admin/src/views/settings/platform/index.vue b/admin/src/views/settings/platform/index.vue new file mode 100644 index 0000000..73be125 --- /dev/null +++ b/admin/src/views/settings/platform/index.vue @@ -0,0 +1,377 @@ + + + + + + diff --git a/admin/src/views/settings/platform/model.ts b/admin/src/views/settings/platform/model.ts new file mode 100644 index 0000000..15afc41 --- /dev/null +++ b/admin/src/views/settings/platform/model.ts @@ -0,0 +1,265 @@ +/** + * 平台设置页模型(Phase 3 Task 9) + * + * 消费方:settings/platform/index.vue。纯逻辑无 DOM 依赖,vitest 直接 + * 覆盖(__tests__/model.test.ts)。 + * + * 事实源对齐: + * - 后端 app/platform/routes/config_routes.py 的 settings_router: + * PlatformSetting 是**自由 KV 表**(key 1-64 字符,value 任意 JSON), + * 本页的推荐键名集合(ai.api_key / ai.base_url / ai.model / smtp.*) + * 是前端约定,不是后端 schema; + * - 密钥判定镜像 _SETTING_SECRET_MARKERS:key 名不区分大小写含 + * key/password/secret 片段即视为密钥(子串匹配——'monkey' 也会命中, + * 与后端行为一致是契约,不"修正"); + * - GET 响应的密钥值已掩码为 '__SET__'(哨兵);PUT 提交哨兵 = 保持 + * 库值(secretToSubmit,见 src/api/mask.ts)。 + * + * 保存语义(与 config 组的"整组替换"不同):逐键 upsert——只 PUT 脏键 + * (改了值的普通键 / 输入了新值的密钥键 / 显式清除的密钥键),未改动 + * 的键不发请求。 + */ + +import { MASKED_SENTINEL, secretToSubmit } from '@/api/mask' +import type { PlatformSettingItem } from '@/api/settings' + +// ---- 密钥判定(后端 _is_secret_setting_key 镜像) ---------------------------------- + +const SECRET_MARKERS = ['key', 'password', 'secret'] as const + +/** key 名是否命中密钥片段(大小写不敏感子串匹配,与后端同款) */ +export function isSecretSettingKey(key: string): boolean { + const lowered = key.toLowerCase() + return SECRET_MARKERS.some((marker) => lowered.includes(marker)) +} + +/** + * 任意非空字符串 → 掩码哨兵。比 === MASKED_SENTINEL 更保守:即使后端 + * 掩码失效漏出原始密钥(理论不可达),也按「已设置」处理——展示层绝不 + * 回显原文,提交层靠"不动不提交"天然不回传。与 config/center/model.ts + * 的 asSecretMasked 同策略(跨视图不互相引,双胞胎注记)。 + */ +export function asSecretMasked(value: unknown): string { + return typeof value === 'string' && value !== '' ? MASKED_SENTINEL : '' +} + +// ---- 推荐键元数据 ------------------------------------------------------------------ + +/** 推荐键字段元数据(secret = 密钥哨兵交互;int = 整数规范化提交) */ +export interface SettingFieldMeta { + key: string + label: string + placeholder: string + secret?: boolean + kind?: 'int' +} + +/** 集团 AI 凭据(租户 ai 组空槽的回退源——回退链接线归后续版本) */ +export const AI_SETTING_FIELDS: SettingFieldMeta[] = [ + { + key: 'ai.api_key', + label: 'API Key', + placeholder: '集团统一密钥(如智谱 sk-…)', + secret: true + }, + { + key: 'ai.base_url', + label: 'Base URL', + placeholder: 'https://open.bigmodel.cn/api/paas/v4' + }, + { + key: 'ai.model', + label: '模型', + placeholder: '如 glm-4-flash' + } +] + +/** 默认 SMTP(租户 email 组空槽的回退源——回退链接线归后续版本) */ +export const SMTP_SETTING_FIELDS: SettingFieldMeta[] = [ + { + key: 'smtp.host', + label: 'SMTP 服务器', + placeholder: '如 smtp.exmail.qq.com' + }, + { + key: 'smtp.port', + label: 'SMTP 端口', + placeholder: '如 465(1-65535 整数)', + kind: 'int' + }, + { + key: 'smtp.user', + label: 'SMTP 账号', + placeholder: '发件邮箱账号' + }, + { + key: 'smtp.password', + label: 'SMTP 密码', + placeholder: '密码或授权码', + secret: true + } +] + +/** 表单分组(页卡顺序) */ +export const SETTING_FIELD_GROUPS: Array<{ + id: 'ai' | 'smtp' + title: string + description: string + fields: SettingFieldMeta[] +}> = [ + { + id: 'ai', + title: '集团 AI 凭据', + description: '租户 ai 组空槽的回退源(推荐键名 ai.*——键名自由,本页约定)', + fields: AI_SETTING_FIELDS + }, + { + id: 'smtp', + title: '默认 SMTP', + description: '租户 email 组空槽的回退源(推荐键名 smtp.*——键名自由,本页约定)', + fields: SMTP_SETTING_FIELDS + } +] + +/** 全部推荐键(顺序展开) */ +export const ALL_SETTING_FIELDS: SettingFieldMeta[] = + SETTING_FIELD_GROUPS.flatMap((group) => group.fields) + +// ---- 归一 / 脏检测 / 提交值 --------------------------------------------------------- + +/** 表单态:推荐键 → 字符串输入(密钥输入恒空——「已设置」由占位符表达) */ +export type PlatformSettingsForm = Record + +/** GET items → 表单 + 密钥掩码态 + 原始 items(表格展示) */ +export interface PlatformSettingsState { + form: PlatformSettingsForm + /** 密钥键 → GET 掩码态(哨兵 = 库中已设置;'' = 未设置) */ + masked: Record + items: PlatformSettingItem[] +} + +/** 存储值 → 表单串(字符串原样 / 有限数值 String / 缺省空 / 其余 JSON 展示) */ +function valueToForm(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + if (value === null || value === undefined) return '' + try { + return JSON.stringify(value) ?? '' + } catch { + return '' + } +} + +/** GET items → 表单态(缺键补空;密钥键输入恒空 + 掩码态单列) */ +export function normalizeSettingsItems( + items: PlatformSettingItem[] +): PlatformSettingsState { + const byKey = new Map(items.map((item) => [item.key, item.value])) + const form: PlatformSettingsForm = {} + const masked: Record = {} + for (const field of ALL_SETTING_FIELDS) { + const value = byKey.get(field.key) + if (field.secret) { + form[field.key] = '' + masked[field.key] = asSecretMasked(value) + } else { + form[field.key] = valueToForm(value) + } + } + return { form, masked, items } +} + +/** + * 字段是否需要提交(脏检测): + * - 密钥键:输入了新值 → 脏;库中已设置(原掩码态哨兵)且显式清除 + * (有效掩码态被置 '')→ 脏(提交空串 = 清空);其余不脏不发请求; + * - 普通键:trim 后与归一表单值不同 → 脏(trim 归一:' 465 ' → '465')。 + * + * @param field 字段元数据 + * @param input 当前输入 + * @param effectiveMasked 有效掩码态(清除按钮按下时为 '',否则原掩码态) + * @param originalMasked GET 时的原掩码态(密钥键脏检测用) + * @param originalForm GET 归一出的表单值(普通键脏检测用) + */ +export function isFieldDirty( + field: SettingFieldMeta, + input: string, + effectiveMasked: string, + originalMasked: string, + originalForm: string +): boolean { + if (field.secret) { + if (input.trim() !== '') return true + return originalMasked === MASKED_SENTINEL && effectiveMasked === '' + } + return input.trim() !== originalForm.trim() +} + +/** + * 脏字段的提交值: + * - 密钥键走 secretToSubmit(新值 → 新值;显式清除 → 空串;哨兵 → 保持 + * 库值——"仅提交脏键"下单键 PUT 通常用不到哨兵分支,保留以对齐凭据 + * 页往返契约); + * - int 键:空串 → null(未设置);合法整数 → 数值(后端 json.dumps 还原 + * 类型);非法输入被 validateSettingsForm 前置拦截,此处字符串透传兜底; + * - 其余:trim 后字符串。 + */ +export function buildSettingValue( + field: SettingFieldMeta, + input: string, + effectiveMasked: string +): unknown { + if (field.secret) { + return secretToSubmit(input.trim(), effectiveMasked) + } + const text = input.trim() + if (field.kind === 'int') { + if (text === '') return null + const n = Number(text) + return Number.isInteger(n) ? n : text + } + return text +} + +/** 表单预检(只查非空输入的格式;空槽 = 未设置合法) */ +export function validateSettingsForm( + fields: SettingFieldMeta[], + form: PlatformSettingsForm +): string[] { + const errors: string[] = [] + for (const field of fields) { + if (field.kind !== 'int') continue + const text = (form[field.key] ?? '').trim() + if (text === '') continue + const n = Number(text) + if (!Number.isInteger(n) || n < 1 || n > 65535) { + errors.push(`${field.label}(${field.key}):须为 1-65535 的整数(留空 = 未设置)`) + } + } + return errors +} + +// ---- 存储总览表格展示 --------------------------------------------------------------- + +/** 表格展示值:哨兵 / 密钥键非空串 → 脱敏文案;其余标量与 JSON 原样 */ +export function formatSettingValue(key: string, value: unknown): string { + if ( + value === MASKED_SENTINEL || + (isSecretSettingKey(key) && typeof value === 'string' && value !== '') + ) { + return '已设置(值已脱敏)' + } + if (value === null) return 'null' + if (value === undefined) return '—' + if (typeof value === 'string') return value === '' ? '""' : value + try { + return JSON.stringify(value) ?? String(value) + } catch { + return String(value) + } +} + +/** 键是否属于本页推荐键集合(表格区分"可编辑键 / 其他已存键") */ +export function isRecommendedKey(key: string): boolean { + return ALL_SETTING_FIELDS.some((field) => field.key === key) +} diff --git a/admin/src/views/stats/overview/__tests__/charts.test.ts b/admin/src/views/stats/overview/__tests__/charts.test.ts new file mode 100644 index 0000000..fc37643 --- /dev/null +++ b/admin/src/views/stats/overview/__tests__/charts.test.ts @@ -0,0 +1,157 @@ +/** + * 统计图表构建单测(Phase 3 Task 8) + * + * 钉住 charts.ts 纯函数层的口径与配色纪律: + * - fillTrendDates:窗口补零(含缺口日/边界日)、无界回退数据 min/max、空数据原样; + * - buildFunnelOption:y 类目倒序(会话在顶)、逐级转化标注、x 轴 max=会话数归一; + * - 意向环形:色随实体(零值过滤后幸存系列保留原槽位色,不因删行换色); + * - recordEntries:降序 + 同数按 key 字典序(与后端 _sorted_counts 同规)、滤零。 + */ +import { describe, expect, it } from 'vitest' +import type { StatsFunnel, StatsTrendPoint } from '@/api/stats' +import { + CHART_THEME_DARK, + CHART_THEME_LIGHT, + buildFunnelOption, + buildIntentPieOption, + fillTrendDates, + intentEntries, + recordEntries +} from '../charts' + +describe('fillTrendDates(趋势按窗口补零)', () => { + it('窗口内缺口日补 {0,0},输出按日升序', () => { + const trend: StatsTrendPoint[] = [ + { date: '2026-10-13', conversations: 3, leads: 1 }, + { date: '2026-10-10', conversations: 2, leads: 0 } + ] + const out = fillTrendDates(trend, '2026-10-10', '2026-10-13') + expect(out.map((p) => p.date)).toEqual([ + '2026-10-10', + '2026-10-11', + '2026-10-12', + '2026-10-13' + ]) + expect(out[1]).toEqual({ date: '2026-10-11', conversations: 0, leads: 0 }) + expect(out[3]).toEqual({ date: '2026-10-13', conversations: 3, leads: 1 }) + }) + + it('窗口恰好覆盖数据(from==to 单日):原样单点', () => { + const trend = [{ date: '2026-10-13', conversations: 5, leads: 2 }] + expect(fillTrendDates(trend, '2026-10-13', '2026-10-13')).toEqual(trend) + }) + + it('跨月窗口补零(09-30 → 10-01 的日界推进)', () => { + const out = fillTrendDates( + [{ date: '2026-09-30', conversations: 1, leads: 0 }], + '2026-09-30', + '2026-10-01' + ) + expect(out.map((p) => p.date)).toEqual(['2026-09-30', '2026-10-01']) + }) + + it('from/to 缺失或非法:回退数据自身 min/max', () => { + const trend: StatsTrendPoint[] = [ + { date: '2026-10-12', conversations: 1, leads: 1 }, + { date: '2026-10-10', conversations: 4, leads: 2 } + ] + const out = fillTrendDates(trend) + expect(out.map((p) => p.date)).toEqual(['2026-10-10', '2026-10-11', '2026-10-12']) + expect(fillTrendDates(trend, '2026-10-20', '2026-10-10')).toHaveLength(3) // 起>止回退 + }) + + it('空趋势原样返回(空数据态由图表层处理)', () => { + expect(fillTrendDates([], '2026-10-01', '2026-10-13')).toEqual([]) + }) +}) + +describe('buildFunnelOption(漏斗横向条形)', () => { + const funnel: StatsFunnel = { + conversations: 100, + leads: 40, + evaluated: 30, + push_succeeded: 12 + } + + it('y 类目倒序录入使「会话」在顶;x 轴按会话数归一', () => { + const option = buildFunnelOption(funnel, CHART_THEME_LIGHT) + const yAxis = option.yAxis as { data: string[] } + expect(yAxis.data).toEqual(['推送成功', '评估完成', '留资', '会话']) + const xAxis = option.xAxis as { max: number } + expect(xAxis.max).toBe(100) + }) + + it('逐级标注:首级纯数量,其余 数量 · 占上一级%(40%→75%→40%)', () => { + const option = buildFunnelOption(funnel, CHART_THEME_LIGHT) + const series = option.series as Array<{ + data: Array<{ value: number }> + label: { formatter: (params: { dataIndex: number }) => string } + }> + const labelAt = (index: number) => series[0].label.formatter({ dataIndex: index }) + // dataIndex 0 是数组首位 = 倒序后的「推送成功」 + expect(labelAt(0)).toBe('12 · 40%') + expect(labelAt(1)).toBe('30 · 75%') + expect(labelAt(2)).toBe('40 · 40%') + expect(labelAt(3)).toBe('100') // 首级无转化率 + }) + + it('上级为 0 时只标数量(除零防护);四级色取主题漏斗阶梯', () => { + const option = buildFunnelOption( + { conversations: 10, leads: 0, evaluated: 3, push_succeeded: 0 }, + CHART_THEME_DARK + ) + const series = option.series as Array<{ + data: Array<{ value: number; itemStyle: { color: string } }> + label: { formatter: (params: { dataIndex: number }) => string } + }> + expect(series[0].label.formatter({ dataIndex: 1 })).toBe('3') // 上级 leads=0(除零防护) + expect(series[0].data.map((item) => item.itemStyle.color)).toEqual([...CHART_THEME_DARK.funnel].reverse()) + }) +}) + +describe('意向环形(色随实体,零值过滤不重排槽位)', () => { + it('intentEntries:滤零、保 INTENT_PIE_ORDER 固定序与槽位', () => { + const entries = intentEntries({ interested: 3, not_interested: 0, pending: 7 }) + expect(entries).toEqual([ + { name: '有意向', value: 3, slot: 0 }, + { name: '待定', value: 7, slot: 2 } + ]) + // 全零 → 空数组(图表走空态) + expect(intentEntries({ interested: 0, not_interested: 0, pending: 0 })).toEqual([]) + }) + + it('buildIntentPieOption:系列色按固定槽位——首系列为零时幸存者不换色', () => { + const entries = intentEntries({ interested: 0, not_interested: 5, pending: 9 }) + const option = buildIntentPieOption(entries, CHART_THEME_LIGHT) + const series = option.series as Array<{ + data: Array<{ name: string; itemStyle: { color: string } }> + }> + // not_interested 恒为 slot 2 橙,不因 interested 缺席升位变蓝 + expect(series[0].data[0].itemStyle.color).toBe(CHART_THEME_LIGHT.series[1]) + expect(series[0].data[1].itemStyle.color).toBe(CHART_THEME_LIGHT.series[2]) + }) + + it('中心合计 = 各片之和(总评估数)', () => { + const option = buildIntentPieOption( + intentEntries({ interested: 2, not_interested: 3, pending: 5 }), + CHART_THEME_LIGHT + ) + expect((option.title as { text: string }).text).toBe('10') + }) +}) + +describe('recordEntries(名义类目降序条目)', () => { + it('按计数降序、同数按 key 字典序、滤零(与后端 _sorted_counts 同规)', () => { + const entries = recordEntries({ widget: 5, embedded: 9, standalone: 5, app: 0 }) + expect(entries).toEqual([ + { name: 'embedded', value: 9 }, + { name: 'standalone', value: 5 }, + { name: 'widget', value: 5 } + ]) + }) + + it('空对象/全零 → 空数组(图表走空态)', () => { + expect(recordEntries({})).toEqual([]) + expect(recordEntries({ a: 0 })).toEqual([]) + }) +}) diff --git a/admin/src/views/stats/overview/charts.ts b/admin/src/views/stats/overview/charts.ts new file mode 100644 index 0000000..d570674 --- /dev/null +++ b/admin/src/views/stats/overview/charts.ts @@ -0,0 +1,379 @@ +/** + * 统计分析图表配置(Phase 3 Task 8) + * + * 纯函数层:StatsResponse(api/stats.ts 类型,形态事实源 app/platform/stats.py) + * + 主题档 → EChartsOption;Vue 层只管取数与生命周期(useChart)。 + * 图表形态与配色方法(dataviz 规范): + * - 漏斗 = 有序量尺(stage 有序)→ 单色相明度阶梯(ordinal ramp),不用多色分类; + * - 来源/站点/国家 = 名义类目 → 全部同色(slot 1 单系列),禁止按数值上色; + * - 趋势双线 / 意向三片 = 系列即主体 → 分类色固定槽位(色随实体,筛选不重排); + * - 文字一律文字色(标签/数值/图例不用系列色);网格线实线细线;条形 ≤24px、 + * 数据端 4px 圆角;饼片间 2px 表面缝隙(borderWidth/surface 色)。 + * 配色经 validate_palette.js 双模式校验(明度带/色度/CVD 邻对 ΔE/对比度全过): + * - 分类三槽 light #2a78d6/#eb6834/#1baf7a · dark #3987e5/#d95926/#199e70 + * (意向饼语义红绿组 green↔red CVD ΔE 4.1 硬伤,弃用改分类槽); + * - 漏斗 ordinal light #1c5cab→#86b6ef · dark #b7d3f6→#184f95(首深末浅/镜像)。 + */ +import type { EChartsOption } from '@/plugins/echarts' +import type { StatsFunnel, StatsTrendPoint } from '@/api/stats' + +// ---- 主题档(两模式各一套;chrome 色同 dataviz 参考面板) -------------------------- + +export interface ChartTheme { + /** 分类系列槽位(固定序:随实体不随筛选) */ + series: [string, string, string] + /** 名义类目条形统一色(slot 1) */ + barHue: string + /** 漏斗四级明度阶梯(stage 1 → 4) */ + funnel: [string, string, string, string] + /** 图表表面色(饼片缝隙/标记环) */ + surface: string + /** 主文字色(直接标注数值) */ + ink: string + /** 次级文字色(轴标/图例/条端数值) */ + muted: string + /** 网格细实线 */ + grid: string + /** 基线(x 轴线) */ + axis: string + /** tooltip 底色(半透明表面) */ + tooltipBg: string + /** tooltip 文字 */ + tooltipInk: string +} + +export const CHART_THEME_LIGHT: ChartTheme = { + series: ['#2a78d6', '#eb6834', '#1baf7a'], + barHue: '#2a78d6', + funnel: ['#1c5cab', '#2a78d6', '#5598e7', '#86b6ef'], + surface: '#fcfcfb', + ink: '#0b0b0b', + muted: '#52514e', + grid: '#e1e0d9', + axis: '#c3c2b7', + tooltipBg: 'rgba(255, 255, 255, 0.94)', + tooltipInk: '#0b0b0b' +} + +export const CHART_THEME_DARK: ChartTheme = { + series: ['#3987e5', '#d95926', '#199e70'], + barHue: '#3987e5', + funnel: ['#b7d3f6', '#6da7ec', '#2a78d6', '#184f95'], + surface: '#1a1a19', + ink: '#f0efec', + muted: '#c3c2b7', + grid: '#2c2c2a', + axis: '#383835', + tooltipBg: 'rgba(0, 0, 0, 0.8)', + tooltipInk: '#f0efec' +} + +export function chartTheme(dark: boolean): ChartTheme { + return dark ? CHART_THEME_DARK : CHART_THEME_LIGHT +} + +/** tooltip 公共样式(item/axis 触发) */ +function tooltipStyle(theme: ChartTheme, trigger: 'item' | 'axis') { + return { + trigger, + backgroundColor: theme.tooltipBg, + borderColor: theme.axis, + borderWidth: 1, + textStyle: { color: theme.tooltipInk, fontSize: 12 }, + ...(trigger === 'item' ? {} : { axisPointer: { type: 'line' as const } }) + } +} + +// ---- 趋势:按日补零 ---------------------------------------------------------------- + +/** Date → UTC 日期串(date-only 串的加减日用 UTC,避免本地时区漂移) */ +function utcKey(date: Date): string { + return date.toISOString().slice(0, 10) +} + +/** + * 趋势补零(stats.py 口径:无数据的日期不产出行,前端补零) + * + * from/to 缺失或非法时回退数据自身 min/max 日期;趋势原始为空 → 原样返回 + * (空数据态由图表层处理)。输出按日升序,缺口日 {0,0}。 + */ +export function fillTrendDates( + trend: StatsTrendPoint[], + from?: string, + to?: string +): StatsTrendPoint[] { + if (trend.length === 0) return trend + const dates = trend.map((p) => p.date).sort() + const hasBounds = !!from && !!to && /^\d{4}-\d{2}-\d{2}$/.test(from) && /^\d{4}-\d{2}-\d{2}$/.test(to) && from! <= to! + const start = hasBounds ? from! : dates[0] + const end = hasBounds ? to! : dates[dates.length - 1] + const byDate = new Map(trend.map((p) => [p.date, p])) + const out: StatsTrendPoint[] = [] + for (let cur = new Date(`${start}T00:00:00Z`); utcKey(cur) <= end; cur.setUTCDate(cur.getUTCDate() + 1)) { + const key = utcKey(cur) + const point = byDate.get(key) + out.push({ date: key, conversations: point?.conversations ?? 0, leads: point?.leads ?? 0 }) + } + return out +} + +/** 趋势双线(会话 slot 1 / 线索 slot 2;x 轴显 MM-DD,tooltip 显全日期) */ +export function buildTrendOption(points: StatsTrendPoint[], theme: ChartTheme): EChartsOption { + return { + legend: { + bottom: 0, + icon: 'roundRect', + itemWidth: 12, + itemHeight: 12, + itemGap: 20, + textStyle: { color: theme.muted, fontSize: 12 } + }, + grid: { top: 16, right: 16, bottom: 40, left: 8, containLabel: true }, + tooltip: tooltipStyle(theme, 'axis'), + xAxis: { + type: 'category', + boundaryGap: false, + data: points.map((p) => p.date), + axisLine: { lineStyle: { color: theme.axis, width: 1 } }, + axisTick: { show: false }, + axisLabel: { + color: theme.muted, + fontSize: 12, + hideOverlap: true, + formatter: (value: string) => value.slice(5) // MM-DD(年份进 tooltip) + } + }, + yAxis: { + type: 'value', + minInterval: 1, // 计数轴取整 + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: theme.muted, fontSize: 12 }, + splitLine: { show: true, lineStyle: { color: theme.grid, width: 1, type: 'solid' } } + }, + series: [ + { + name: '会话', + type: 'line', + data: points.map((p) => p.conversations), + color: theme.series[0], + lineStyle: { width: 2 }, + symbol: 'circle', + symbolSize: 8, + showSymbol: false + }, + { + name: '线索', + type: 'line', + data: points.map((p) => p.leads), + color: theme.series[1], + lineStyle: { width: 2 }, + symbol: 'circle', + symbolSize: 8, + showSymbol: false + } + ] + } +} + +// ---- 漏斗:横向条形模拟(有序色阶) -------------------------------------------------- + +const FUNNEL_STAGES: Array<{ key: keyof StatsFunnel; label: string }> = [ + { key: 'conversations', label: '会话' }, + { key: 'leads', label: '留资' }, + { key: 'evaluated', label: '评估完成' }, + { key: 'push_succeeded', label: '推送成功' } +] + +/** + * 漏斗横向条形:宽度按会话数归一(x 轴 max=会话数),逐级标注 + * 「数量 · 占上一级 %」(首级无转化率;上级 0 时只标数量)。 + */ +export function buildFunnelOption(funnel: StatsFunnel, theme: ChartTheme): EChartsOption { + const counts = FUNNEL_STAGES.map((stage) => funnel[stage.key]) + // y 类目轴 index 0 在底部 → 倒序录入使「会话」在顶 + const labels = FUNNEL_STAGES.map((stage, index) => { + const count = counts[index] + if (index === 0) return String(count) + const prev = counts[index - 1] + if (prev <= 0) return String(count) + return `${count} · ${Math.round((count / prev) * 100)}%` + }).reverse() + return { + grid: { top: 8, right: 16, bottom: 8, left: 8, containLabel: true }, + tooltip: { + ...tooltipStyle(theme, 'item'), + formatter: (params) => { + const point = Array.isArray(params) ? params[0] : params + const stageIndex = FUNNEL_STAGES.length - 1 - (point?.dataIndex ?? 0) // 还原正序 + const stage = FUNNEL_STAGES[stageIndex] + const count = counts[stageIndex] + const base = counts[0] + const overall = base > 0 ? ` · 整体转化 ${Math.round((count / base) * 100)}%` : '' + return `${stage.label}:${count}${overall}` + } + }, + xAxis: { + type: 'value', + max: counts[0], // 各级宽度同比于会话数(漏斗比例语义) + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false } + }, + yAxis: { + type: 'category', + data: FUNNEL_STAGES.map((stage) => stage.label).reverse(), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: theme.muted, fontSize: 12 } + }, + series: [ + { + type: 'bar', + barWidth: 22, + data: counts + .map((value, index) => ({ + value, + itemStyle: { color: theme.funnel[index], borderRadius: [0, 4, 4, 0] } + })) + .reverse(), + label: { + show: true, + position: 'right', + color: theme.muted, + fontSize: 12, + formatter: (params: { dataIndex: number }) => labels[params.dataIndex] + } + } + ] + } +} + +// ---- 意向分布:环形图(分类三槽固定指派) ---------------------------------------------- + +/** 意向键固定序(色随实体:interested=slot1 / not_interested=slot2 / pending=slot3) */ +export const INTENT_PIE_ORDER: Array<{ key: string; label: string }> = [ + { key: 'interested', label: '有意向' }, + { key: 'not_interested', label: '无意向' }, + { key: 'pending', label: '待定' } +] + +/** 意向分布条目(过滤零值,保固定序;后端枚举三键,未知键不展示) */ +export interface IntentEntry { + name: string + value: number + /** 固定色槽位(interested=0 / not_interested=1 / pending=2——色随实体不随筛选) */ + slot: number +} + +export function intentEntries(intent: Record): IntentEntry[] { + return INTENT_PIE_ORDER.map((item, slot) => ({ + name: item.label, + value: intent[item.key] ?? 0, + slot + })).filter((entry) => entry.value > 0) +} + +/** 意向环形图:外标 name 数值(占比),图例兜底识别;片间 2px 表面缝隙 */ +export function buildIntentPieOption(entries: IntentEntry[], theme: ChartTheme): EChartsOption { + const total = entries.reduce((sum, item) => sum + item.value, 0) + return { + title: { + text: String(total), + subtext: '总评估', + left: 'center', + top: '38%', + textStyle: { color: theme.ink, fontSize: 22, fontWeight: 600 }, + subtextStyle: { color: theme.muted, fontSize: 12 } + }, + legend: { + bottom: 0, + icon: 'roundRect', + itemWidth: 12, + itemHeight: 12, + itemGap: 20, + textStyle: { color: theme.muted, fontSize: 12 } + }, + tooltip: tooltipStyle(theme, 'item'), + series: [ + { + type: 'pie', + radius: ['50%', '72%'], + center: ['50%', '44%'], + data: entries.map((entry) => ({ + name: entry.name, + value: entry.value, + itemStyle: { color: theme.series[entry.slot] } + })), + itemStyle: { borderWidth: 2, borderColor: theme.surface }, + label: { + color: theme.muted, + fontSize: 12, + formatter: '{b} {c}({d}%)' + }, + labelLine: { length: 10, length2: 12, lineStyle: { color: theme.axis } } + } + ] + } +} + +// ---- 名义类目横向条形(来源/站点/国家) ---------------------------------------------- + +export interface BarEntry { + name: string + value: number +} + +/** + * 名义类目横向条形(统一 slot 1 单色——类目无序,不按数值上色) + * + * entries 需预排序(多→少)与截断;调用方负责 name 展示文案(如来源枚举中文化)。 + */ +export function buildBarOption(entries: BarEntry[], theme: ChartTheme): EChartsOption { + return { + grid: { top: 8, right: 48, bottom: 8, left: 8, containLabel: true }, + tooltip: tooltipStyle(theme, 'item'), + xAxis: { + type: 'value', + minInterval: 1, + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + splitLine: { show: false } + }, + yAxis: { + type: 'category', + data: entries.map((entry) => entry.name).reverse(), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: theme.muted, + fontSize: 12, + width: 130, + overflow: 'truncate' + } + }, + series: [ + { + type: 'bar', + barWidth: 16, + data: entries.map((entry) => ({ + ...entry, + itemStyle: { color: theme.barHue, borderRadius: [0, 4, 4, 0] } + })), + label: { show: true, position: 'right', color: theme.muted, fontSize: 12 } + } + ] + } +} + +// ---- 计数表降序工具(source/site 的 Record 形态 → BarEntry[]) ----------------------- + +/** 计数 Record → 降序条目(value>0;同数按 key 字典序,与后端 _sorted_counts 同规) */ +export function recordEntries(record: Record): BarEntry[] { + return Object.entries(record) + .filter(([, value]) => value > 0) + .sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)) + .map(([name, value]) => ({ name, value })) +} diff --git a/admin/src/views/stats/overview/index.vue b/admin/src/views/stats/overview/index.vue new file mode 100644 index 0000000..a629f44 --- /dev/null +++ b/admin/src/views/stats/overview/index.vue @@ -0,0 +1,592 @@ + + + + + + diff --git a/admin/src/views/tenants/list/index.vue b/admin/src/views/tenants/list/index.vue new file mode 100644 index 0000000..52a87af --- /dev/null +++ b/admin/src/views/tenants/list/index.vue @@ -0,0 +1,1127 @@ + + + + + + diff --git a/admin/tsconfig.json b/admin/tsconfig.json new file mode 100644 index 0000000..4331962 --- /dev/null +++ b/admin/tsconfig.json @@ -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"] +} diff --git a/admin/vite.config.ts b/admin/vite.config.ts new file mode 100644 index 0000000..3a25ff6 --- /dev/null +++ b/admin/vite.config.ts @@ -0,0 +1,178 @@ +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, + VITE_TENANT_PREVIEW_HOST + } = env + + console.log(`🚀 API_URL = ${VITE_API_URL}`) + console.log(`🚀 VERSION = ${VITE_VERSION}`) + + // 代理策略(后端 TenantHostMiddleware 事实源 app/platform/host_middleware.py: + // 仅 /health 精确与 /api/platform/ 前缀豁免租户 Host 解析,其余按 Host 找租户): + // - /api/platform/*:豁免前缀,changeOrigin 直转即可(Host=127.0.0.1:8018 无碍); + // - /api/public/*:品牌/头像公开端点依赖租户 Host——用 headers.Host 改写成 + // VITE_TENANT_PREVIEW_HOST(种子租户域名),changeOrigin 必须为 false + // 否则 Host 会被改回目标地址使解析 404; + // - /static/*:静态资源同样过租户解析,与 /api/public 同策略。 + // 注意对象键序:更长的 /api/public 前缀必须排在 /api 之前(http-proxy 按序首配命中)。 + const tenantHostProxy = { + target: VITE_API_PROXY_URL, + changeOrigin: false, + headers: { Host: VITE_TENANT_PREVIEW_HOST } + } + + return defineConfig({ + define: { + __APP_VERSION__: JSON.stringify(VITE_VERSION) + }, + base: VITE_BASE_URL, + server: { + port: Number(VITE_PORT), + proxy: { + '/api/public': { ...tenantHostProxy }, + '/api': { + target: VITE_API_PROXY_URL, + changeOrigin: true + }, + '/static': { ...tenantHostProxy } + }, + 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: { + // Windows 本机 es2015 曾在 rendering chunks 阶段触发 esbuild 原生崩溃 + //(退出码 0xC0000409,多次复现),改 esnext 后连续构建稳定; + // 管理后台仅面向现代浏览器,不做低版本降级。 + target: 'esnext', + 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', + 'crypto-js', + '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) +} diff --git a/admin/vitest.config.ts b/admin/vitest.config.ts new file mode 100644 index 0000000..a590c71 --- /dev/null +++ b/admin/vitest.config.ts @@ -0,0 +1,32 @@ +/** + * Vitest 配置(Phase 3 Task 3 引入) + * + * 仅覆盖单元测试(src 下各 __tests__ 目录中的 *.test.ts)——与 + * vite.config.ts 分离,避免测试环境加载 devtools/compression 等构建插件。 + * 路径别名与 vite.config.ts / tsconfig.json 保持一致(@ @views @utils @stores 等)。 + * + * CI 语义:`pnpm test` = `vitest run`(一次性跑完退出,非 watch)。 + */ +import { defineConfig } from 'vitest/config' +import { fileURLToPath } from 'url' + +export default defineConfig({ + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + '@views': fileURLToPath(new URL('./src/views', import.meta.url)), + '@imgs': fileURLToPath(new URL('./src/assets/images', import.meta.url)), + '@icons': fileURLToPath(new URL('./src/assets/icons', import.meta.url)), + '@utils': fileURLToPath(new URL('./src/utils', import.meta.url)), + '@stores': fileURLToPath(new URL('./src/store', import.meta.url)), + '@plugins': fileURLToPath(new URL('./src/plugins', import.meta.url)), + '@styles': fileURLToPath(new URL('./src/assets/styles', import.meta.url)) + } + }, + test: { + environment: 'happy-dom', + include: ['src/**/__tests__/**/*.test.ts'], + // 测试文件显式 import { describe, it, expect, vi } from 'vitest',不开 globals + globals: false + } +}) diff --git a/app/engine/services/email_service.py b/app/engine/services/email_service.py index e0bbf1a..0d96a57 100644 --- a/app/engine/services/email_service.py +++ b/app/engine/services/email_service.py @@ -1,5 +1,6 @@ import html import logging +from datetime import datetime, timedelta, timezone from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText @@ -58,16 +59,23 @@ def _cc_views(contacts: dict, cc_info: dict | None) -> tuple[dict, dict]: return body, subj +async def _smtp_send(msg: MIMEMultipart, cfg: dict) -> None: + """底层连接发送(Phase 3 Task 7c 从 _send 抽出): 直调 aiosmtplib, + 失败抛原始异常——需要错误串的调用方(配置中心测试邮件 send_test_email) + 复用同一连接逻辑;_send 在此之上包"吞异常返 bool"契约,行为不变。""" + await aiosmtplib.send( + msg, hostname=cfg["host"], port=cfg["port"], + username=cfg["user"], password=cfg["password"], + use_tls=True, + ) + + async def _send(msg: MIMEMultipart, cfg: dict) -> bool: if not all([cfg["host"], cfg["user"], cfg["notify_emails"]]): logger.warning("SMTP not configured, skipping email") return False try: - await aiosmtplib.send( - msg, hostname=cfg["host"], port=cfg["port"], - username=cfg["user"], password=cfg["password"], - use_tls=True, - ) + await _smtp_send(msg, cfg) logger.info("Lead email sent to %s", cfg["notify_emails"]) return True except Exception: @@ -170,3 +178,35 @@ async def send_correction_email( f"{_contact_rows(body_contacts)}
" f"", "html", "utf-8")) return await _send(msg, cfg) + + +async def send_test_email( + to: str, tenant_name: str = "", + config_data: dict | None = None, +) -> tuple[bool, str | None]: + """配置中心测试邮件(Phase 3 Task 7c): 按当前生效邮件配置真发一封到 + 指定收件人——主题/正文钉契约("[all-kefu] SMTP 测试邮件"+租户名+时间, + 时间与 lead_push_service 同款 +08:00 口径)。 + + 与 _send 的吞异常 bool 契约解耦: 成败与错误串如实返回(探测端点直传 + 前端,凭据对不对让收件箱与错误串说话);连接逻辑复用 _smtp_send。 + "SMTP 未配置"的 400 判定在端点层(本函数假定 cfg 已可用)。""" + cfg = resolve_email_settings(config_data) + now = datetime.now(timezone(timedelta(hours=8))) + msg = MIMEMultipart("alternative") + msg["Subject"] = "[all-kefu] SMTP 测试邮件" + msg["From"] = f"【{cfg['sender_name']}】 <{cfg['user']}>" + msg["To"] = to + msg.attach(MIMEText( + f"" + f"

这是一封 SMTP 配置测试邮件——收到本邮件说明该租户的邮件配置" + f"可以正常发信。

" + f"

租户:{_esc(tenant_name)}

" + f"

时间:{now.strftime('%Y-%m-%d %H:%M:%S')}(UTC+8)

" + f"", "html", "utf-8")) + try: + await _smtp_send(msg, cfg) + return True, None + except Exception as exc: + logger.exception("Test email to %s failed", to) + return False, repr(exc) diff --git a/app/platform/routes/config_routes.py b/app/platform/routes/config_routes.py index 8f27eb0..d637740 100644 --- a/app/platform/routes/config_routes.py +++ b/app/platform/routes/config_routes.py @@ -19,23 +19,36 @@ 缺省用当前墙——逐句调 boundary_filter 纯函数给判定,**不落库不 bump 版本** (reset/编辑器的预览入口)。判定优先级与 conversation.py 执行侧一致: 价格墙 → 零号码墙 → 再索要墙(仅 has_contact); +- POST /email/test 与 /push/test(凭据测试,Phase 3 Task 7c):分别用 + resolve_email_settings/resolve_push_settings 的当前配置真发测试邮件 / + 探测推送连通性——**push 探测绝不携带线索数据**(空 messages 批量), + 两端点不落库不 bump 版本,成败如实返回(400 只用于"未配置"预检); - GET/PUT /api/platform/settings:PlatformSetting CRUD,仅超管。本阶段只落 存储与接口(value 以 JSON Text 直存,读回还原类型);resolver 的平台默认 - 回退链接入归 Phase 3。 + 回退链接线归 Phase 4(当前仅存储,admin 平台设置页页头有注记)。 鉴权矩阵(spec §3):config 系 = require_tenant(slug)(租户全开——本租户 管理员可自助改配置);settings = require_super。409 门经 platform_user 连锁。 +密钥掩码哨兵(Phase 3 Task 1):push.api_key / ai.api_key / +email.smtp.password 及平台设置的密钥名值,所有携带 config/value 的响应 +一律掩码为 "__SET__"(原值非空;空串/None 原样——前端能区分"配了"与 +"没配");PUT 收到 "__SET__" 还原为库值再写版本(前端"不改就原样传回" +的往返契约)。库行/版本历史永远存原值——掩码只作用响应层。 + 版本写入一律走 db.write_config_version(与 Task 5 创建向导同一 MAX+1 路径)。 """ import copy import json +import socket +import httpx from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.engine.services import boundary_filter +from app.engine.services.email_service import send_test_email from app.engine.wall_schema import WallConfigError, compile_wall from app.platform.auth import UserContext, require_super from app.platform.config_validation import ( @@ -43,6 +56,10 @@ from app.platform.config_validation import ( ValidationContext, validate_group, ) +from app.platform.config_views import ( + resolve_email_settings, + resolve_push_settings, +) from app.platform.db import ( PlatformSetting, Tenant, @@ -110,6 +127,111 @@ def _updated_by(user: UserContext) -> str: return f"{user.role}#{user.user_id}" +# ---- 密钥掩码 / __SET__ 哨兵(Phase 3 Task 1) -------------------------------------- + +MASKED = "__SET__" + +# 租户配置密钥字段清单(组 → 组内路径元组;多元素元组 = 嵌套键)。响应一律 +# 掩码;PUT 收到 MASKED 值还原为当前快照原值——前端"不改就原样传回"的往返 +# 契约。语义:原值非空 → MASKED;空串/None/缺键原样(前端能区分"配了"与 +# "没配")。空串传回仍 = 清空(既有语义不因掩码改变)。 +_SECRET_PATHS: dict[str, tuple[tuple[str, ...], ...]] = { + "push": (("api_key",),), + "ai": (("api_key",),), + "email": (("smtp", "password"),), +} + + +def _dig(container, path: tuple[str, ...]): + """按路径取嵌套 dict 值;任一层不是 dict → None。""" + node = container + for key in path: + node = node.get(key) if isinstance(node, dict) else None + return node + + +def _mask_group(group: str, value): + """单组掩码视图(深拷贝):密钥路径上非空字符串 → MASKED,空串/None/ + 缺键原样。非 dict 组值原样深拷贝返回。""" + out = copy.deepcopy(value) + if not isinstance(out, dict): + return out + for path in _SECRET_PATHS.get(group, ()): + leaf = _dig(out, path) + if isinstance(leaf, str) and leaf: + node = _dig(out, path[:-1]) + node[path[-1]] = MASKED + return out + + +def _mask_snapshot(snapshot: dict) -> dict: + """整份快照的掩码视图(逐组 _mask_group)。""" + return {group: _mask_group(group, value) + for group, value in snapshot.items()} + + +def _restore_sentinels(group: str, body: dict, current: dict) -> dict: + """PUT 哨兵还原:body 密钥路径上值恰为 MASKED → 替换为当前快照同路径 + 原值(无库存 → None,保持"未设置"——哨兵字面量绝不落库)。返回处理后 + 的深拷贝,不改入参;非密钥路径与传新值/空串的路径一律原样。""" + out = copy.deepcopy(body) + for path in _SECRET_PATHS.get(group, ()): + if _dig(out, path) == MASKED: + node = _dig(out, path[:-1]) + node[path[-1]] = _dig(current.get(group), path) + return out + + +# 平台设置密钥判定:key 名(不区分大小写)含以下片段即视为密钥。适用设置 +# 项自身的 key(标量密钥,如 glm_api_key = "sk-...")与 value 的嵌套 dict +# 递归两层。 +_SETTING_SECRET_MARKERS = ("key", "password", "secret") + + +def _is_secret_setting_key(name: str) -> bool: + lowered = name.lower() if isinstance(name, str) else "" + return any(marker in lowered for marker in _SETTING_SECRET_MARKERS) + + +def _mask_setting_value(value): + """递归掩码:密钥名下的非空字符串值 → MASKED;dict/list 递归,其余 + 标量原样。""" + if isinstance(value, dict): + return { + k: (MASKED if _is_secret_setting_key(k) + and isinstance(v, str) and v else _mask_setting_value(v)) + for k, v in value.items()} + if isinstance(value, list): + return [_mask_setting_value(v) for v in value] + return value + + +def _mask_setting_item(key: str, value): + """单个设置项的掩码视图:设置 key 本身命中密钥名且值为非空字符串 → + 掩码;否则递归掩码 value 内部。""" + if _is_secret_setting_key(key) and isinstance(value, str) and value: + return MASKED + return _mask_setting_value(value) + + +def _restore_setting_sentinels(value, stored): + """value 中密钥位置的 MASKED → stored 同路径值(递归结构与 + _mask_setting_value 对齐;stored 无该路径 → None)。不改入参。""" + if isinstance(value, dict): + src = stored if isinstance(stored, dict) else {} + return { + k: (copy.deepcopy(src.get(k)) + if _is_secret_setting_key(k) and v == MASKED + else _restore_setting_sentinels(v, src.get(k))) + for k, v in value.items()} + if isinstance(value, list): + src = stored if isinstance(stored, list) else [] + return [_restore_setting_sentinels( + v, src[i] if i < len(src) else None) + for i, v in enumerate(value)] + return value + + # ---- 配置快照 / 分组编辑 ---------------------------------------------------------- @@ -117,12 +239,14 @@ def _updated_by(user: UserContext) -> str: async def get_config(slug: str, request: Request, user=Depends(_tenant_guard)): """当前生效快照(MAX 版本)+ version。原样快照,非合并视图——缺省键 - 由消费侧(registry)深合并 DEFAULT_CONFIG。""" + 由消费侧(registry)深合并 DEFAULT_CONFIG。密钥字段掩码(Phase 3 + Task 1):非空 → MASKED,空串/None 原样。""" engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: tenant = await _get_tenant_or_404(session, slug) row, snapshot = await _load_current(session, tenant.id, slug) - return {"slug": slug, "version": row.version, "config": snapshot} + return {"slug": slug, "version": row.version, + "config": _mask_snapshot(snapshot)} @router.put("/{slug}/config/{group}") @@ -131,7 +255,12 @@ async def put_config_group( user=Depends(_tenant_guard)): """分组编辑:校验(config_validation 管线)→ 整组替换当前快照该组 → MAX+1 写新版本 → registry_reload 热生效。快照存用户配置**原样组** - (组内缺省键消费侧深合并出厂默认补)。""" + (组内缺省键消费侧深合并出厂默认补)。 + + 密钥哨兵(Phase 3 Task 1):密钥字段传 MASKED = 保持库值(校验按 + 字符串过,还原发生在校验后写入前);传新值 → 覆盖;空串传回 = 清空 + (既有语义不因掩码改变)。响应的密钥字段同样掩码——哨兵还原后的原值 + 不经响应回显。""" body = await _json_body(request) engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: @@ -142,6 +271,7 @@ async def put_config_group( if errors: raise HTTPException(status_code=400, detail="; ".join(errors)) _, current = await _load_current(session, tenant.id, slug) + body = _restore_sentinels(group, body, current) new_snapshot = dict(current) new_snapshot[group] = copy.deepcopy(body) version = await write_config_version( @@ -151,7 +281,7 @@ async def put_config_group( await request.app.state.registry.registry_reload(slug) return {"slug": slug, "group": group, "version": version, - "config": new_snapshot[group]} + "config": _mask_group(group, new_snapshot[group])} @router.post("/{slug}/config/{group}/reset") @@ -197,7 +327,8 @@ async def reset_config_group( await session.commit() await request.app.state.registry.registry_reload(slug) - return {"slug": slug, "group": group, "version": version, "config": value} + return {"slug": slug, "group": group, "version": version, + "config": _mask_group(group, value)} # ---- 版本历史 / 回滚 --------------------------------------------------------------- @@ -226,7 +357,8 @@ async def list_config_versions( @router.get("/{slug}/config/versions/{version}") async def get_config_version( slug: str, version: int, request: Request, user=Depends(_tenant_guard)): - """单版本详情(含整份 config)。不存在 → 404。""" + """单版本详情(含整份 config)。不存在 → 404。config 掩码视图 + (Phase 3 Task 1)——历史行本身永远存原值。""" engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: tenant = await _get_tenant_or_404(session, slug) @@ -242,7 +374,7 @@ async def get_config_version( "slug": slug, "version": row.version, "created_at": row.created_at.isoformat(), "updated_by": row.updated_by, "comment": row.comment, - "config": json.loads(row.config_json), + "config": _mask_snapshot(json.loads(row.config_json)), } @@ -250,7 +382,7 @@ async def get_config_version( async def rollback_config( slug: str, request: Request, user=Depends(_tenant_guard)): """回滚:目标版本整份复制为新版本(历史不可变——旧版本行永不改写), - reload 生效。""" + reload 生效。落库原值;响应为掩码视图(Phase 3 Task 1)。""" body = await _json_body(request) target = body.get("version") if not isinstance(target, int) or isinstance(target, bool) or target < 1: @@ -277,7 +409,7 @@ async def rollback_config( await request.app.state.registry.registry_reload(slug) return {"slug": slug, "rollback_to": target, "version": new_version, - "config": snapshot} + "config": _mask_snapshot(snapshot)} # ---- 试金石(wall/test) ----------------------------------------------------------- @@ -367,7 +499,110 @@ async def test_wall(slug: str, request: Request, user=Depends(_tenant_guard)): return {"slug": slug, "wall_source": wall_source, "results": results} -# ---- 平台设置(仅超管;resolver 回退链接入归 Phase 3) ----------------------------- +# ---- 凭据测试端点(email/push test,Phase 3 Task 7c) ------------------------------- + +_EMAIL_TEST_KEYS = frozenset({"to"}) + + +@router.post("/{slug}/email/test") +async def test_email(slug: str, request: Request, + user=Depends(_tenant_guard)): + """SMTP 测试邮件: 用当前生效邮件配置(resolve_email_settings,租户组 + 优先/空槽回退 env SMTP_*)真发一封到 body.to——主题/正文契约见 + email_service.send_test_email。SMTP 未配置(回退后 host/user 仍空) + → 400;发送失败不 5xx,{sent: false, error} 如实返回。不落库不 bump + 版本;密钥绝不进响应。""" + body = await _json_body(request) + unknown = set(body) - _EMAIL_TEST_KEYS + if unknown: + raise HTTPException( + status_code=400, + detail=f"未知字段: {', '.join(sorted(unknown))}(合法: to)") + to = body.get("to") + if not isinstance(to, str) or "@" not in to.strip(): + raise HTTPException( + status_code=400, + detail=f"to: 须为含 @ 的收件人邮箱(实际 {to!r})") + + engine = request.app.state.platform_engine + async with AsyncSession(engine, expire_on_commit=False) as session: + tenant = await _get_tenant_or_404(session, slug) + _, snapshot = await _load_current(session, tenant.id, slug) + cfg = resolve_email_settings(snapshot) + if not cfg["host"] or not cfg["user"]: + raise HTTPException( + status_code=400, + detail="SMTP 未配置(服务器/账号在租户配置与环境回退后均为空)" + "——请先在「配置中心 → 凭据」保存邮件 SMTP") + sent, error = await send_test_email(to.strip(), tenant.name, snapshot) + return {"slug": slug, "sent": sent, "error": error} + + +def _transport_reason(exc: Exception) -> str: + """传输异常 → 不可达原因分类(前端三态展示): dns(域名解析失败)/ + timeout(TimeoutException 族)/connect(拒绝·不可达·TLS 等其余)。 + + dns 判定双通道: 沿 __cause__/__context__ 链找 gaierror(httpx 把解析 + 错误包在 ConnectError 里),并对链上异常做文案匹配——Windows 实测 + (冒烟 Task 7c)httpx/anyio 会把 errno 11001 包成裸 ConnectError, + cause 链不带 gaierror,只能从 "[Errno 11001] getaddrinfo failed" + 文案识别;分类仅供修复提示,错误原文始终随响应返回兜底。""" + node: BaseException | None = exc + while node is not None: + if isinstance(node, socket.gaierror): + return "dns" + text = str(node).lower() + if "getaddrinfo" in text or "name or service not known" in text: + return "dns" + node = node.__cause__ or node.__context__ + if isinstance(exc, httpx.TimeoutException): + return "timeout" + return "connect" + + +async def _probe_push(cfg: dict) -> dict: + """单次连通性探测(无重试): 与 lead_push_service._do_post 同款连接形态 + (timeout/follow_redirects/X-Api-Key 头),body 恒为空 messages 列表—— + **绝不携带任何线索数据**(UniService 是批量结构,空批量对方案应为 + 无操作;契约文档不在库内,这是可用的最保守预检形态)。""" + try: + async with httpx.AsyncClient(timeout=cfg["timeout"], + follow_redirects=True) as client: + resp = await client.post( + cfg["url"], json={"messages": []}, + headers={"X-Api-Key": cfg["api_key"]}) + except httpx.HTTPError as exc: + return {"reachable": False, "auth_ok": None, "status": None, + "reason": _transport_reason(exc), + "error": str(exc) or repr(exc)} + auth_ok = (True if resp.status_code < 300 + else False if resp.status_code in (401, 403) + else None) + return {"reachable": True, "auth_ok": auth_ok, + "status": resp.status_code, "reason": None, "error": None} + + +@router.post("/{slug}/push/test") +async def test_push(slug: str, request: Request, + user=Depends(_tenant_guard)): + """推送连通性探测: 只验 URL 可达 + 鉴权通过,不发真数据(空 messages + 批量,见 _probe_push)。URL 回退后仍空 → 400;其余如实返回——不可达 + (含 dns/timeout/connect 原因分类)/可达但鉴权失败(401/403)/正常(2xx); + 其余状态码可达但鉴权不定(auth_ok=null)。不落库不 bump 版本。""" + engine = request.app.state.platform_engine + async with AsyncSession(engine, expire_on_commit=False) as session: + tenant = await _get_tenant_or_404(session, slug) + _, snapshot = await _load_current(session, tenant.id, slug) + cfg = resolve_push_settings(snapshot) + if not cfg["url"]: + raise HTTPException( + status_code=400, + detail="推送 URL 未配置(租户配置与环境回退后均为空)" + "——请先在「配置中心 → 凭据」保存推送 URL") + return {"slug": slug, **await _probe_push(cfg)} + + +# ---- 平台设置(仅超管;resolver 回退链接线归 Phase 4——当前仅存储) ------------------ def _decode_setting(raw: str): @@ -380,21 +615,28 @@ def _decode_setting(raw: str): @settings_router.get("") async def list_settings(request: Request, user=Depends(require_super)): - """平台级默认(集团 AI 凭据/默认 SMTP 等,spec §5.2)——本阶段只落 - 存储与接口;消费(resolver 平台默认回退链)归 Phase 3。""" + """平台级默认(集团 AI 凭据/默认 SMTP 等,spec §5.2)。值掩码视图 + (Phase 3 Task 1):key 名含 key/password/secret 的非空字符串值 → + MASKED(设置 key 本身与嵌套 dict 递归都算);库行永远存原值。""" engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: rows = (await session.execute( select(PlatformSetting).order_by(PlatformSetting.key)) ).scalars().all() - return {"items": [{"key": row.key, "value": _decode_setting(row.value)} + return {"items": [{"key": row.key, + "value": _mask_setting_item( + row.key, _decode_setting(row.value))} for row in rows]} @settings_router.put("") async def put_setting(request: Request, user=Depends(require_super)): """upsert 单个设置项:key 1-64 字符;value 任意 JSON(json.dumps 入 - Text,读回还原)。同 key 再写 = 整值替换。""" + Text,读回还原)。同 key 再写 = 整值替换。 + + 密钥哨兵(Phase 3 Task 1):key 命中密钥名且值恰为 MASKED,或嵌套 + dict 内密钥位置为 MASKED → 还原为现存同路径值(无库存 → None)。 + 非密钥位置的 MASKED 字面量 = 普通字符串原样落库。响应掩码。""" body = await _json_body(request) key = body.get("key") if not isinstance(key, str) or not 1 <= len(key) <= 64: @@ -402,16 +644,21 @@ async def put_setting(request: Request, user=Depends(require_super)): status_code=400, detail="key: 须为 1-64 字符字符串") if "value" not in body: raise HTTPException(status_code=400, detail="value: 缺少(必填)") - encoded = json.dumps(body["value"], ensure_ascii=False) engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: row = (await session.execute( select(PlatformSetting).where(PlatformSetting.key == key) )).scalar_one_or_none() + stored = _decode_setting(row.value) if row is not None else None + if _is_secret_setting_key(key) and body["value"] == MASKED: + value = copy.deepcopy(stored) + else: + value = _restore_setting_sentinels(body["value"], stored) + encoded = json.dumps(value, ensure_ascii=False) if row is None: session.add(PlatformSetting(key=key, value=encoded)) else: row.value = encoded await session.commit() - return {"key": key, "value": body["value"]} + return {"key": key, "value": _mask_setting_item(key, value)} diff --git a/app/platform/routes/tenant_routes.py b/app/platform/routes/tenant_routes.py index 5a9bef3..d6e0e07 100644 --- a/app/platform/routes/tenant_routes.py +++ b/app/platform/routes/tenant_routes.py @@ -13,8 +13,10 @@ DEFAULT_CONFIG 满足不了。 也直接插入,创建即可用);停用走 reload 路径顺带 dispose 旧 engine (引擎释放由 registry 负责,本层不碰)。 -鉴权:写操作与列表 require_super;GET /{slug} 详情放宽 require_tenant -(slug)(租户管理员看自己概要,403 边界有测试钉住)。 +鉴权:租户生命周期(POST 创建 / GET 列表 / PATCH 停用改名)require_super; +GET /{slug} 详情与站点·域名四端点(POST sites / PATCH sites / +POST domains / DELETE domains)放宽 require_tenant(slug)(Phase 3 Task 1: +本租户管理员自助管理站点与域名,403 边界有测试钉住)。 安全纪律: - 密码哈希/salt 绝不进响应——管理员序列化显式挑 {username, status} @@ -386,9 +388,10 @@ async def patch_tenant( @router.post("/{slug}/sites", status_code=201) async def create_site( slug: str, body: CreateSiteRequest, request: Request, - user=Depends(require_super)): + user=Depends(_tenant_guard)): """建站:slug/name/brand_overlay/domains 可选。brand_overlay 是 ORM - JSON 列——整字典赋值(M-2a)。""" + JSON 列——整字典赋值(M-2a)。鉴权 _tenant_guard(Phase 3 Task 1 + 放宽):本租户管理员可自助建站。""" _validate_slug(body.slug) domains = list(dict.fromkeys( _normalize_domain(d) for d in body.domains)) @@ -427,9 +430,9 @@ async def create_site( @router.patch("/{slug}/sites/{site_slug}") async def patch_site( slug: str, site_slug: str, body: dict, request: Request, - user=Depends(require_super)): + user=Depends(_tenant_guard)): """改站点:停用/改名/改 overlay(整字典赋值,旧键不残留)。站点 slug - 同样不可变(400)。""" + 同样不可变(400)。鉴权 _tenant_guard(Phase 3 Task 1 放宽)。""" fields = _patch_fields(body, allow=("name", "status", "brand_overlay")) if "name" in fields: _check_name(fields["name"]) @@ -462,9 +465,9 @@ async def patch_site( @router.post("/{slug}/sites/{site_slug}/domains", status_code=201) async def add_domain( slug: str, site_slug: str, body: AddDomainRequest, request: Request, - user=Depends(require_super)): + user=Depends(_tenant_guard)): """域名绑定:全局唯一(撞车 400 带现有归属);绑定后 registry reload - 生效。""" + 生效。鉴权 _tenant_guard(Phase 3 Task 1 放宽)。""" domain = _normalize_domain(body.domain) engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: @@ -486,8 +489,9 @@ async def add_domain( @router.delete("/{slug}/sites/{site_slug}/domains/{domain}") async def delete_domain( slug: str, site_slug: str, domain: str, request: Request, - user=Depends(require_super)): - """域名解绑:只解本站点的该域名(不存在 404);解绑后 reload 生效。""" + user=Depends(_tenant_guard)): + """域名解绑:只解本站点的该域名(不存在 404);解绑后 reload 生效。 + 鉴权 _tenant_guard(Phase 3 Task 1 放宽)。""" domain = _normalize_domain(domain) engine = request.app.state.platform_engine async with AsyncSession(engine, expire_on_commit=False) as session: diff --git a/app/platform/stats.py b/app/platform/stats.py index 254e927..47690f8 100644 --- a/app/platform/stats.py +++ b/app/platform/stats.py @@ -271,5 +271,5 @@ def merge_stats(parts: list[dict]) -> dict: "site": _sorted_counts(site), "country": [ {"country": name, "count": count} - for name, count in country.most_common(COUNTRY_TOP_N)]}, + for name, count in country.most_common(COUNTRY_TOP_N)]} return out diff --git a/tests/test_auth.py b/tests/test_auth.py index de97d0d..6699a58 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -126,10 +126,13 @@ def test_change_password_flow(app_client): def test_must_change_password_409_semantics(app_client): - """引导密码强制语义: must_change=True 时受保护端点(logout)409; - change-password 本身豁免(200);改完 409 消除。""" + """引导密码强制语义: must_change=True 时受保护端点(logout)409 + (detail 为固定文案——前端 client.ts 的 MUST_CHANGE_DETAIL 依赖此 + 契约,改动文案即红);change-password 本身豁免(200);改完 409 消除。""" token = _login(app_client).json()["token"] - assert app_client.post(LOGOUT, headers=_auth(token)).status_code == 409 + r = app_client.post(LOGOUT, headers=_auth(token)) + assert r.status_code == 409 + assert r.json()["detail"] == "必须先修改初始密码" r = app_client.post( CHANGE_PW, headers=_auth(token), json={"old_password": "admin123", "new_password": "n3w-pass-3"}) diff --git a/tests/test_config_api.py b/tests/test_config_api.py index 43dc30c..09a9e89 100644 --- a/tests/test_config_api.py +++ b/tests/test_config_api.py @@ -5,13 +5,24 @@ 鉴权矩阵(spec §3):config GET/PUT/reset/versions/rollback/wall-test = require_tenant(slug)(租户全开);settings = require_super。 + +Phase 3 Task 1:密钥掩码哨兵——push.api_key / ai.api_key / +email.smtp.password 及平台设置的密钥名值,响应一律 "__SET__"(原值非空), +PUT 收到 "__SET__" 还原为库值(前端"不改就原样传回"的往返契约)。 """ +import json + +from sqlalchemy import select + +from app.platform.db import PlatformSetting from app.platform.tenant_source import FileTenantSource from tests.conftest import SEED_DIR from tests.test_tenant_admin_api import ( _auth, _bearer, _create, + _current_snapshot, + _db_fetch, _tenant_admin_token, ) @@ -458,3 +469,159 @@ def test_wall_group_round_trip_and_body_hygiene(super_client): ).json()["token"] r = super_client.get(_cfg("t2"), headers=_bearer(token)) assert r.status_code == 409 + + +# ---- 密钥掩码 / __SET__ 哨兵(Phase 3 Task 1) --------------------------------------- + + +def test_get_config_masks_secrets(super_client): + """GET config:密钥字段非空 → "__SET__";空串/None 原样(区分"配了"与 + "没配");原值绝不出现在响应。""" + _create(super_client, slug="demo") + _put_group(super_client, "demo", "push", + {"prefix": "demo-p", "api_key": "push-s3cret"}) + _put_group(super_client, "demo", "ai", {"api_key": "ai-s3cret"}) + _put_group(super_client, "demo", "email", + {"smtp": {"host": "smtp.example.com", "port": 465, + "password": "smtp-s3cret"}}) + r = super_client.get(_cfg("demo"), headers=_auth(super_client)) + assert r.status_code == 200 + cfg = r.json()["config"] + assert cfg["push"]["api_key"] == "__SET__" + assert cfg["ai"]["api_key"] == "__SET__" + assert cfg["email"]["smtp"]["password"] == "__SET__" + for secret in ("push-s3cret", "ai-s3cret", "smtp-s3cret"): + assert secret not in r.text, f"响应泄露密钥: {secret}" + + # 未设语义不受掩码遮蔽: 空串 → "";None → null + _put_group(super_client, "demo", "push", + {"prefix": "demo-p", "api_key": ""}) + _put_group(super_client, "demo", "ai", {"api_key": None}) + _put_group(super_client, "demo", "email", {"smtp": None}) + cfg = _get_config(super_client, "demo")["config"] + assert cfg["push"]["api_key"] == "" + assert cfg["ai"]["api_key"] is None + assert cfg["email"]["smtp"] is None + + +def test_put_set_sentinel_keeps_stored_value(super_client, tmp_path): + """PUT 哨兵还原:密钥字段传 "__SET__" = 保持库值(版本+1 照常);传新值 + → 覆盖;空串传回仍 = 清空(既有语义不因掩码改变)。""" + _create(super_client, slug="demo") + _put_group(super_client, "demo", "push", + {"prefix": "demo-p", "api_key": "orig-s3cret"}) # v2 + r = _put_group(super_client, "demo", "push", + {"prefix": "demo-p", "api_key": "__SET__"}) # v3 + assert r.status_code == 200, r.text + assert r.json()["version"] == 3 + assert r.json()["config"]["api_key"] == "__SET__" # PUT 响应同样掩码 + snapshot = _current_snapshot(tmp_path, "demo") + assert snapshot["push"]["api_key"] == "orig-s3cret" # 库值原样 + assert snapshot["push"]["prefix"] == "demo-p" # 非密钥字段照常写 + + # 传新值 → 覆盖;空串 → 清空 + _put_group(super_client, "demo", "push", + {"prefix": "demo-p", "api_key": "new-s3cret"}) # v4 + assert _current_snapshot(tmp_path, "demo")["push"]["api_key"] == "new-s3cret" + _put_group(super_client, "demo", "push", + {"prefix": "demo-p", "api_key": ""}) # v5 + assert _current_snapshot(tmp_path, "demo")["push"]["api_key"] == "" + + # 嵌套路径(email.smtp.password)哨兵同款 + _put_group(super_client, "demo", "email", + {"smtp": {"host": "smtp.example.com", "port": 465, + "password": "smtp-orig"}}) # v6 + r = _put_group(super_client, "demo", "email", + {"smtp": {"host": "smtp2.example.com", "port": 587, + "password": "__SET__"}}) # v7 + assert r.status_code == 200 + assert r.json()["config"]["smtp"]["password"] == "__SET__" + assert _current_snapshot(tmp_path, "demo")["email"]["smtp"] == { + "host": "smtp2.example.com", "port": 587, "password": "smtp-orig"} + + # 库值未设时哨兵还原为 None(保持"未设置"),而非落哨兵字面量 + r = _put_group(super_client, "demo", "ai", {"api_key": "__SET__"}) + assert r.status_code == 200 + assert _current_snapshot(tmp_path, "demo")["ai"]["api_key"] is None + + +def test_version_detail_and_rollback_response_masked(super_client, tmp_path): + """GET versions/{n} 与 rollback 响应的密钥字段也是 "__SET__";写入不受 + 影响——rollback 落库仍是原值(掩码只作用响应,不改历史与回滚写入)。""" + _create(super_client, slug="demo") + _put_group(super_client, "demo", "ai", {"api_key": "v2-s3cret"}) # v2 + r = super_client.get(f"{_cfg('demo')}/versions/2", + headers=_auth(super_client)) + assert r.status_code == 200 + assert r.json()["config"]["ai"]["api_key"] == "__SET__" + assert "v2-s3cret" not in r.text + + _put_group(super_client, "demo", "ai", {"api_key": "v3-s3cret"}) # v3 + r = super_client.post(f"{_cfg('demo')}/rollback", headers=_auth(super_client), + json={"version": 2}) + assert r.status_code == 200 + assert r.json()["version"] == 4 + assert r.json()["config"]["ai"]["api_key"] == "__SET__" # 响应掩码 + assert "v2-s3cret" not in r.text + # 落库仍是原值(分离断言:响应掩码 ≠ 写入掩码) + assert _current_snapshot(tmp_path, "demo")["ai"]["api_key"] == "v2-s3cret" + # 当前生效视图(GET config)也掩码 + assert _get_config(super_client, "demo")["config"]["ai"]["api_key"] == "__SET__" + + +def test_platform_settings_masked(super_client, tmp_path): + """平台设置同款:key 名含 key/password/secret 的字符串值掩码(设置 key + 本身与嵌套 dict 递归都算);PUT 哨兵还原;非密钥值原样。""" + assert super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "glm_api_key", + "value": "sk-plat-1"}).status_code == 200 + assert super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "default_smtp", "value": { + "host": "smtp.example.com", "port": 465, + "password": "smtp-pw-1", "user": "bot"}} + ).status_code == 200 + assert super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "hmac_secret", + "value": "sec-plat-1"}).status_code == 200 + assert super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "glm_timeout", + "value": 30}).status_code == 200 + r = super_client.get(SETTINGS, headers=_auth(super_client)) + assert r.status_code == 200 + items = {i["key"]: i["value"] for i in r.json()["items"]} + assert items["glm_api_key"] == "__SET__" # 设置 key 本身命中 + assert items["default_smtp"] == { # 嵌套 dict 递归 + "host": "smtp.example.com", "port": 465, + "password": "__SET__", "user": "bot"} + assert items["hmac_secret"] == "__SET__" + assert items["glm_timeout"] == 30 # 非密钥原样 + for secret in ("sk-plat-1", "smtp-pw-1", "sec-plat-1"): + assert secret not in r.text, f"响应泄露密钥: {secret}" + + # PUT 哨兵还原: 嵌套密钥位置传 "__SET__" → 库值保持,其余字段更新 + r = super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "default_smtp", "value": { + "host": "smtp2.example.com", "port": 587, + "password": "__SET__", "user": "bot2"}}) + assert r.status_code == 200 + assert r.json()["value"]["password"] == "__SET__" # 响应掩码 + rows = _db_fetch(tmp_path, select(PlatformSetting) + .where(PlatformSetting.key == "default_smtp")) + assert json.loads(rows[0][0].value) == { + "host": "smtp2.example.com", "port": 587, + "password": "smtp-pw-1", "user": "bot2"} + # 标量密钥设置哨兵还原 + r = super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "glm_api_key", "value": "__SET__"}) + assert r.status_code == 200 + rows = _db_fetch(tmp_path, select(PlatformSetting) + .where(PlatformSetting.key == "glm_api_key")) + assert json.loads(rows[0][0].value) == "sk-plat-1" + # 非密钥 key 的 "__SET__" 字面量 = 普通字符串(不还原也不掩码) + r = super_client.put(SETTINGS, headers=_auth(super_client), + json={"key": "plain_note", "value": "__SET__"}) + assert r.status_code == 200 + assert r.json()["value"] == "__SET__" + items = {i["key"]: i["value"] for i in super_client.get( + SETTINGS, headers=_auth(super_client)).json()["items"]} + assert items["plain_note"] == "__SET__" diff --git a/tests/test_email_test_endpoint.py b/tests/test_email_test_endpoint.py new file mode 100644 index 0000000..f709dd5 --- /dev/null +++ b/tests/test_email_test_endpoint.py @@ -0,0 +1,177 @@ +"""POST /api/platform/tenants/{slug}/email/test(Phase 3 Task 7c)——SMTP 测试邮件。 + +端点契约(计划 Task 7c): +- 用当前生效配置(resolve_email_settings:租户 email 组优先,空槽回退 env + SMTP_*)**真发一封**:主题 "[all-kefu] SMTP 测试邮件",正文含租户名与 + 时间,From = sender_name/user,To = body.to; +- SMTP 未配置(回退后 host/user 仍空)→ 400; +- 发送成败如实返回 {sent, error}(失败不 5xx——凭据对不对让收件箱与 + 错误串说话);密钥绝不进响应。 + +测试纪律: monkeypatch email_service.aiosmtplib.send 捕获报文与连接参数, +绝不真发(同 test_email_rendering 的 _capture_send 姿势,但打在 +aiosmtplib.send 一层——端点经 send_test_email 走完整组包路径)。 +""" +from datetime import datetime, timedelta, timezone + +from app.engine.config import settings +from app.engine.services import email_service +from tests.test_tenant_admin_api import _auth, _bearer, _create, _tenant_admin_token + +CONFIG = "/api/platform/tenants/{slug}/config" +EMAIL_TEST = "/api/platform/tenants/{slug}/email/test" + + +def _capture_smtp(monkeypatch): + """aiosmtplib.send 桩: 捕获报文与连接参数,返回 True 语义(发送成功)。""" + captured = {} + + async def fake_send(msg, **kwargs): + captured["msg"] = msg + captured["kwargs"] = kwargs + return msg + + monkeypatch.setattr(email_service.aiosmtplib, "send", fake_send) + return captured + + +def _failing_smtp(monkeypatch, message: str): + async def failing_send(msg, **kwargs): + raise RuntimeError(message) + + monkeypatch.setattr(email_service.aiosmtplib, "send", failing_send) + + +def _clear_smtp_env(monkeypatch): + """env SMTP_* 清空(隔离本机 .env 联调值——同 test_push_settings_defaults + 的隔离教训);"回退后仍空"的 400 分支才可控。""" + for key in ("SMTP_HOST", "SMTP_USER", "SMTP_PASS", "NOTIFY_EMAIL"): + monkeypatch.setattr(settings, key, "") + monkeypatch.setattr(settings, "SMTP_PORT", 465) + + +def _put_email_group(client, slug, payload): + return client.put(f"{CONFIG.format(slug=slug)}/email", + headers=_auth(client), json=payload) + + +def _post_test(client, slug, body, headers=None): + return client.post(EMAIL_TEST.format(slug=slug), + headers=headers or _auth(client), json=body) + + +SMTP_GROUP = { + "smtp": {"host": "t.smtp.example.com", "port": 587, + "user": "bot@demo.com", "password": "s3cret-pw"}, + "sender_name": "Demo 发件人", + "notify_emails": ["notify@demo.com"], +} + + +def test_email_test_sends_with_tenant_config(super_client, monkeypatch): + """已配置 SMTP: 真发路径走通——主题/收件人/发件人/正文(租户名+时间) + 与连接参数(租户四字段)逐项钉死;密钥不进响应。""" + _create(super_client, slug="demo", name="Demo 建材") + assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200 + captured = _capture_smtp(monkeypatch) + + r = _post_test(super_client, "demo", {"to": "checker@example.com"}) + assert r.status_code == 200, r.text + assert r.json() == {"slug": "demo", "sent": True, "error": None} + + msg = captured["msg"] + assert str(msg["Subject"]) == "[all-kefu] SMTP 测试邮件" + assert str(msg["To"]) == "checker@example.com" + assert str(msg["From"]) == "【Demo 发件人】 " + body = msg.get_payload(0).get_payload(decode=True).decode("utf-8") + assert "Demo 建材" in body # 正文含租户名 + cst_today = datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d") + assert cst_today in body and "UTC+8" in body # 正文含时间(与推送同款 CST 口径) + # 连接参数 = 租户配置(与 email_service._send 同一连接逻辑) + assert captured["kwargs"] == { + "hostname": "t.smtp.example.com", "port": 587, + "username": "bot@demo.com", "password": "s3cret-pw", "use_tls": True} + assert "s3cret-pw" not in r.text # 密钥不进响应 + + +def test_email_test_env_fallback(super_client, monkeypatch): + """租户 smtp 未配置 → 回退 env SMTP_*(部署级平台默认同样可发)。""" + _create(super_client, slug="demo") # jiancai 档案: email 组无 smtp 键 + for key, value in (("SMTP_HOST", "env.smtp.example.com"), + ("SMTP_PORT", 465), ("SMTP_USER", "env-bot@x.com"), + ("SMTP_PASS", "env-pass")): + monkeypatch.setattr(settings, key, value) + captured = _capture_smtp(monkeypatch) + + r = _post_test(super_client, "demo", {"to": "checker@example.com"}) + assert r.status_code == 200, r.text + assert r.json()["sent"] is True + assert captured["kwargs"]["hostname"] == "env.smtp.example.com" + assert captured["kwargs"]["password"] == "env-pass" + # sender_name 无租户覆盖 → 平台默认;user 走 env + assert str(captured["msg"]["From"]) == "【HESUPIPE】 " + + +def test_email_test_smtp_unconfigured_400(super_client, monkeypatch): + """SMTP 未配置(租户组与 env 回退后仍空)→ 400 提示(不试探连接)。""" + _clear_smtp_env(monkeypatch) + _create(super_client, slug="demo") + + r = _post_test(super_client, "demo", {"to": "checker@example.com"}) + assert r.status_code == 400 + detail = r.json()["detail"] + assert "SMTP" in detail and "未配置" in detail + + +def test_email_test_send_failure_reports_error(super_client, monkeypatch): + """发送失败不 5xx: {sent: false, error 带原始错误串}如实返回。""" + _create(super_client, slug="demo") + assert _put_email_group(super_client, "demo", SMTP_GROUP).status_code == 200 + _failing_smtp(monkeypatch, "smtp auth rejected") + + r = _post_test(super_client, "demo", {"to": "checker@example.com"}) + assert r.status_code == 200, r.text + body = r.json() + assert body["sent"] is False + assert "smtp auth rejected" in body["error"] + + +def test_email_test_validates_to(super_client, monkeypatch): + """to 必填且须为含 @ 的字符串;未知字段 400(错误契约带字段路径)。""" + _clear_smtp_env(monkeypatch) + _create(super_client, slug="demo") + for bad in (None, 123, "", "no-at-sign", ["a@b.com"], {"to": "a@b.com"}): + r = _post_test(super_client, "demo", {"to": bad}) + assert r.status_code == 400, f"{bad!r}: {r.text}" + assert "to" in r.json()["detail"] + # 缺 to / 未知字段 + assert _post_test(super_client, "demo", {}).status_code == 400 + r = _post_test(super_client, "demo", {"to": "a@b.com", "extra": 1}) + assert r.status_code == 400 + assert "extra" in r.json()["detail"] + + +def test_email_test_unknown_tenant_404(super_client, monkeypatch): + _clear_smtp_env(monkeypatch) + r = _post_test(super_client, "ghost", {"to": "a@b.com"}) + assert r.status_code == 404 + + +def test_email_test_auth_matrix(super_client, monkeypatch): + """require_tenant(slug): 本租户管理员放行(400 是业务分支,鉴权先于 + 业务);跨租户 403;未认证 401。""" + _clear_smtp_env(monkeypatch) + _create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1") + _create(super_client, slug="au2", profile="auto", + admin="au2-admin", password="au2-pass-1") + token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1") + + # 本租户管理员: 鉴权过,落在"未配置"业务分支 400(而非 403) + r = _post_test(super_client, "jc2", {"to": "a@b.com"}, headers=_bearer(token)) + assert r.status_code == 400 + # 别的租户 → 403 + r = _post_test(super_client, "au2", {"to": "a@b.com"}, headers=_bearer(token)) + assert r.status_code == 403 + # 未认证 → 401 + r = super_client.post(EMAIL_TEST.format(slug="jc2"), json={"to": "a@b.com"}) + assert r.status_code == 401 diff --git a/tests/test_push_test_endpoint.py b/tests/test_push_test_endpoint.py new file mode 100644 index 0000000..2019a51 --- /dev/null +++ b/tests/test_push_test_endpoint.py @@ -0,0 +1,243 @@ +"""POST /api/platform/tenants/{slug}/push/test(Phase 3 Task 7c)——推送连通性探测。 + +端点红线: **不发任何线索数据**——探测 body = {"messages": []}(空批量, +与 lead_push_service._do_post 同端点同头;UniService 是批量结构,空列表 +对方案应为无操作)。测试断言钉死这一形态。 + +响应契约: {reachable, auth_ok(可空), status, reason, error}—— +- 传输层异常 → reachable=false,reason 分类 dns/timeout/connect; +- HTTP 有响应 → reachable=true + status;2xx → auth_ok=true, + 401/403 → false,其余(4xx/5xx)→ null(到达但状态异常,鉴权不定); +- 单次探测不重试(与 _run_push 的重试矩阵无关)。 + +测试纪律: monkeypatch config_routes.httpx.AsyncClient(假客户端),绝不真连。 +""" +import socket + +import httpx + +from app.engine.config import settings +from app.platform.routes import config_routes +from tests.test_tenant_admin_api import _auth, _bearer, _create, _tenant_admin_token + +CONFIG = "/api/platform/tenants/{slug}/config" +PUSH_TEST = "/api/platform/tenants/{slug}/push/test" + +PUSH_GROUP = { + "enabled": True, + "url": "https://push.example/open/v1/inquiry/push", + "api_key": "k-t1", + "prefix": "demo-p", + "timeout": 5.5, + "max_attempts": 3, + "retry_backoff_base": 1, +} + + +def _install_fake_client(monkeypatch, handler): + """config_routes.httpx.AsyncClient 桩: handler(call) → httpx.Response, + 抛异常照抛。返回调用记录(init 构造参数 + 每次 post 的 url/json/headers)。""" + calls = [] + + class FakeClient: + def __init__(self, **kwargs): + calls.append({"init": kwargs}) + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc_info): + return False + + async def post(self, url, json=None, headers=None): + call = {"url": url, "json": json, "headers": dict(headers or {})} + calls.append(call) + return handler(call) + + monkeypatch.setattr(config_routes.httpx, "AsyncClient", FakeClient) + return calls + + +def _ok(call): + return httpx.Response(200, json={"success": True, "data": { + "accepted": 0, "duplicated": 0, "failed": []}}) + + +def _put_push_group(client, slug, payload): + return client.put(f"{CONFIG.format(slug=slug)}/push", + headers=_auth(client), json=payload) + + +def _post_test(client, slug, headers=None): + return client.post(PUSH_TEST.format(slug=slug), headers=headers or _auth(client)) + + +def _post_calls(calls): + return [c for c in calls if "url" in c] + + +def test_push_test_probe_shape_and_ok(super_client, monkeypatch): + """正常路径: 200 → reachable+auth_ok;请求形态逐项钉死——同 _do_post 的 + 端点/X-Api-Key 头/超时/follow_redirects,body 恒为空 messages(红线)。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + calls = _install_fake_client(monkeypatch, _ok) + + r = _post_test(super_client, "demo") + assert r.status_code == 200, r.text + assert r.json() == {"slug": "demo", "reachable": True, "auth_ok": True, + "status": 200, "reason": None, "error": None} + assert calls[0]["init"] == {"timeout": 5.5, "follow_redirects": True} + post = _post_calls(calls)[0] + assert post["url"] == "https://push.example/open/v1/inquiry/push" + assert post["json"] == {"messages": []} # 红线: 空批量,零线索数据 + assert post["headers"] == {"X-Api-Key": "k-t1"} # 与 _do_post 同头 + + +def test_push_test_auth_rejected(super_client, monkeypatch): + """可达但鉴权失败: 401/403 → auth_ok=false(前端三态展示的第二态)。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + for status in (401, 403): + _install_fake_client( + monkeypatch, lambda call, s=status: httpx.Response(s)) + r = _post_test(super_client, "demo") + assert r.status_code == 200 + body = r.json() + assert body["reachable"] is True and body["status"] == status + assert body["auth_ok"] is False + + +def test_push_test_5xx_reachable_auth_unknown_single_shot(super_client, monkeypatch): + """5xx: 可达但状态异常(auth_ok=null);探测单次发起,不重试。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + calls = _install_fake_client( + monkeypatch, lambda call: httpx.Response(502)) + + r = _post_test(super_client, "demo") + assert r.status_code == 200 + body = r.json() + assert body["reachable"] is True and body["status"] == 502 + assert body["auth_ok"] is None + assert len(_post_calls(calls)) == 1 # 无重试矩阵 + + +def test_push_test_unreachable_dns(super_client, monkeypatch): + """DNS 失败(异常 cause 链含 gaierror)→ reachable=false + reason=dns。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + + def dns_fail(call): + raise httpx.ConnectError("Failed to resolve.") from socket.gaierror( + -2, "Name or service not known") + + _install_fake_client(monkeypatch, dns_fail) + r = _post_test(super_client, "demo") + assert r.status_code == 200 + body = r.json() + assert body["reachable"] is False and body["auth_ok"] is None + assert body["status"] is None + assert body["reason"] == "dns" and body["error"] + + +def test_push_test_unreachable_connect(super_client, monkeypatch): + """连接拒绝/不可达(无 gaierror cause 的 ConnectError)→ reason=connect。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + + def refused(call): + raise httpx.ConnectError("Connection refused") + + _install_fake_client(monkeypatch, refused) + r = _post_test(super_client, "demo") + body = r.json() + assert body["reachable"] is False + assert body["reason"] == "connect" + + +def test_push_test_unreachable_dns_windows_text(super_client, monkeypatch): + """Windows 实测形态(冒烟 Task 7c): getaddrinfo 失败被 httpx/anyio 包成 + 裸 ConnectError("[Errno 11001] getaddrinfo failed"),cause 链不带 + gaierror——文案匹配兜底,仍须归 dns 而非 connect。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + + def win_dns_fail(call): + raise httpx.ConnectError("[Errno 11001] getaddrinfo failed") + + _install_fake_client(monkeypatch, win_dns_fail) + r = _post_test(super_client, "demo") + body = r.json() + assert body["reachable"] is False + assert body["reason"] == "dns" + assert "getaddrinfo" in body["error"] + + +def test_push_test_unreachable_timeout(super_client, monkeypatch): + """连接超时(TimeoutException 族)→ reason=timeout。""" + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", PUSH_GROUP).status_code == 200 + + def timed_out(call): + raise httpx.ConnectTimeout("timed out") + + _install_fake_client(monkeypatch, timed_out) + r = _post_test(super_client, "demo") + body = r.json() + assert body["reachable"] is False + assert body["reason"] == "timeout" + + +def test_push_test_url_unconfigured_400(super_client, monkeypatch): + """URL 回退后仍空(租户 url 空槽 + env PUSH_API_URL 清空)→ 400。""" + monkeypatch.setattr(settings, "PUSH_API_URL", "") + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", {"prefix": "demo-p"}).status_code == 200 + + r = _post_test(super_client, "demo") + assert r.status_code == 400 + detail = r.json()["detail"] + assert "URL" in detail and "未配置" in detail + + +def test_push_test_env_fallback(super_client, monkeypatch): + """租户 push 组空槽 → env PUSH_* 回退(探测用的就是部署级平台默认)。""" + monkeypatch.setattr(settings, "PUSH_API_URL", "https://env-push.example/v1") + monkeypatch.setattr(settings, "PUSH_API_KEY", "env-key") + monkeypatch.setattr(settings, "PUSH_TIMEOUT_SECONDS", 8.0) + _create(super_client, slug="demo") + assert _put_push_group(super_client, "demo", {"prefix": "demo-p"}).status_code == 200 + calls = _install_fake_client(monkeypatch, _ok) + + r = _post_test(super_client, "demo") + assert r.status_code == 200 + assert r.json()["reachable"] is True + assert calls[0]["init"]["timeout"] == 8.0 + post = _post_calls(calls)[0] + assert post["url"] == "https://env-push.example/v1" + assert post["headers"] == {"X-Api-Key": "env-key"} + + +def test_push_test_unknown_tenant_404(super_client, monkeypatch): + _install_fake_client(monkeypatch, _ok) + r = _post_test(super_client, "ghost") + assert r.status_code == 404 + + +def test_push_test_auth_matrix(super_client, monkeypatch): + """require_tenant(slug): 本租户管理员放行;跨租户 403;未认证 401。""" + _create(super_client, slug="jc2", admin="jc2-admin", password="jc2-pass-1") + _create(super_client, slug="au2", profile="auto", + admin="au2-admin", password="au2-pass-1") + assert _put_push_group(super_client, "jc2", PUSH_GROUP).status_code == 200 + token = _tenant_admin_token(super_client, "jc2-admin", "jc2-pass-1") + _install_fake_client(monkeypatch, _ok) + + # 本租户管理员放行 + assert _post_test(super_client, "jc2", headers=_bearer(token)).status_code == 200 + # 别的租户 → 403 + assert _post_test(super_client, "au2", headers=_bearer(token)).status_code == 403 + # 未认证 → 401 + r = super_client.post(PUSH_TEST.format(slug="jc2")) + assert r.status_code == 401 diff --git a/tests/test_stats.py b/tests/test_stats.py index 110ee75..7c858ce 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -374,6 +374,10 @@ def test_cross_tenant_super_view(super_client, tmp_path): {"date": "2026-01-05", "conversations": 1, "leads": 1}, {"date": "2026-01-06", "conversations": 2, "leads": 1}, ] + # 回归钉子: merge_stats 的 distributions 必须是对象(曾因赋值行尾随逗号 + # 变 1 元组序列化成 JSON 数组,单租户路径不受累——Phase 3 Task 8 冒烟发现) + assert body["distributions"]["intent"] == { + "interested": 1, "not_interested": 1, "pending": 0} assert body["push_health"]["success"] == 1 assert not (tmp_path / "tenants" / "jcc.db").exists() # 未为统计建空库 diff --git a/tests/test_tenant_admin_api.py b/tests/test_tenant_admin_api.py index f084ac8..032312e 100644 --- a/tests/test_tenant_admin_api.py +++ b/tests/test_tenant_admin_api.py @@ -508,3 +508,66 @@ def test_no_credentials_leak_in_responses(super_client): assert r.status_code == 200 for needle in ("password_hash", "salt", "scrypt", "demo-pass-1"): assert needle not in r.text, f"响应泄露凭据字段: {needle}" + + +# ---- 站点/域名管理放开本租户管理员(Phase 3 Task 1) -------------------------------- + + +def test_tenant_admin_can_manage_own_sites(super_client): + """四端点(POST sites / PATCH sites / POST domains / DELETE domains) + 放宽到 _tenant_guard:本租户管理员可自助管理站点与域名;打别的租户 + 仍 403(slug 收口)。""" + _create(super_client, slug="demo", admin="demo-admin", + password="demo-pass-1", domains=["demo.example.com"]) + _create(super_client, slug="other", profile="blank") + token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1") + h = _bearer(token) + + # POST sites → 201 + r = super_client.post(f"{TENANTS}/demo/sites", headers=h, + json={"slug": "es", "name": "ES 站", + "domains": ["es.demo.example.com"]}) + assert r.status_code == 201, r.text + # PATCH sites/{site} → 200 + r = super_client.patch(f"{TENANTS}/demo/sites/es", headers=h, + json={"name": "ES 站 v2"}) + assert r.status_code == 200 + assert r.json()["name"] == "ES 站 v2" + # POST domains → 201 + r = super_client.post(f"{TENANTS}/demo/sites/es/domains", headers=h, + json={"domain": "es2.demo.example.com"}) + assert r.status_code == 201 + # DELETE domains/{domain} → 200 + r = super_client.delete( + f"{TENANTS}/demo/sites/es/domains/es2.demo.example.com", headers=h) + assert r.status_code == 200 + + # 对照: 同 token 打别的租户四端点 → 403 + assert super_client.post(f"{TENANTS}/other/sites", headers=h, + json={"slug": "x"}).status_code == 403 + assert super_client.patch(f"{TENANTS}/other/sites/main", headers=h, + json={"name": "x"}).status_code == 403 + assert super_client.post(f"{TENANTS}/other/sites/main/domains", + headers=h, + json={"domain": "x.example.com"}).status_code == 403 + assert super_client.delete( + f"{TENANTS}/other/sites/main/domains/x.example.com", + headers=h).status_code == 403 + + +def test_tenant_lifecycle_still_super_only(super_client): + """不动清单:租户生命周期端点保持超管专属——创建 / 全平台列表 / + PATCH /tenants/{slug}(停用/改名,租户不能自停自改)。""" + _create(super_client, slug="demo", admin="demo-admin", + password="demo-pass-1") + token = _tenant_admin_token(super_client, "demo-admin", "demo-pass-1") + h = _bearer(token) + r = super_client.post(TENANTS, headers=h, json={ + "name": "越权租户", "slug": "evil", "profile": "blank", + "admin_username": "evil-admin", "admin_password": "evil-pass-1"}) + assert r.status_code == 403 + assert super_client.get(TENANTS, headers=h).status_code == 403 + assert super_client.patch(f"{TENANTS}/demo", headers=h, + json={"status": "disabled"}).status_code == 403 + assert super_client.patch(f"{TENANTS}/demo", headers=h, + json={"name": "自改名"}).status_code == 403