Merge branch 'phase3-admin'

This commit is contained in:
浅念
2026-10-14 00:13:29 +08:00
334 changed files with 52676 additions and 49 deletions
+42 -9
View File
@@ -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 部署记档段已锚定要点)
+5 -4
View File
@@ -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)
+42
View File
@@ -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
+30
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
*.html linguist-detectable=false
*.vue linguist-detectable=true
+12
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
/node_modules/*
/dist/*
/src/main.ts
+20
View File
@@ -0,0 +1,20 @@
{
"printWidth": 100,
"tabWidth": 2,
"useTabs": false,
"semi": false,
"vueIndentScriptAndStyle": true,
"singleQuote": true,
"quoteProps": "as-needed",
"bracketSpacing": true,
"trailingComma": "none",
"bracketSameLine": false,
"jsxSingleQuote": false,
"arrowParens": "always",
"insertPragma": false,
"requirePragma": false,
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "auto",
"rangeStart": 0
}
+9
View File
@@ -0,0 +1,9 @@
dist
node_modules
public
.husky
.vscode
src/components/Layout/MenuLeft/index.vue
src/assets
stats.html
+82
View File
@@ -0,0 +1,82 @@
module.exports = {
// 继承推荐规范配置
extends: [
'stylelint-config-standard',
'stylelint-config-recommended-scss',
'stylelint-config-recommended-vue/scss',
'stylelint-config-html/vue',
'stylelint-config-recess-order'
],
// 指定不同文件对应的解析器
overrides: [
{
files: ['**/*.{vue,html}'],
customSyntax: 'postcss-html'
},
{
files: ['**/*.{css,scss}'],
customSyntax: 'postcss-scss'
}
],
// 自定义规则
rules: {
'import-notation': 'string', // 指定导入CSS文件的方式("string"|"url")
'selector-class-pattern': null, // 选择器类名命名规则
'custom-property-pattern': null, // 自定义属性命名规则
'keyframes-name-pattern': null, // 动画帧节点样式命名规则
'no-descending-specificity': null, // 允许无降序特异性
'no-empty-source': null, // 允许空样式
'property-no-vendor-prefix': null, // 允许属性前缀
// 允许 global 、export 、deep伪类
'selector-pseudo-class-no-unknown': [
true,
{
ignorePseudoClasses: ['global', 'export', 'deep']
}
],
// 允许未知属性
'property-no-unknown': [
true,
{
ignoreProperties: []
}
],
// 允许未知规则
'at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'apply',
'use',
'mixin',
'include',
'extend',
'each',
'if',
'else',
'for',
'while',
'reference'
]
}
],
'scss/at-rule-no-unknown': [
true,
{
ignoreAtRules: [
'apply',
'use',
'mixin',
'include',
'extend',
'each',
'if',
'else',
'for',
'while',
'reference'
]
}
]
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"recommendations": [
"vue.volar",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"stylelint.vscode-stylelint",
"lokalise.i18n-ally",
"bradlc.vscode-tailwindcss"
]
}
+12
View File
@@ -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
}
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 SuperManTT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+3
View File
@@ -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`)。
+83
View File
@@ -0,0 +1,83 @@
// 从 URL 和路径模块中导入必要的功能
import fs from 'fs'
import path, { dirname } from 'path'
import { fileURLToPath } from 'url'
// 从 ESLint 插件中导入推荐配置
import pluginJs from '@eslint/js'
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
import pluginVue from 'eslint-plugin-vue'
import globals from 'globals'
import tseslint from 'typescript-eslint'
// 使用 import.meta.url 获取当前模块的路径
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// 读取 .auto-import.json 文件的内容,并将其解析为 JSON 对象
const autoImportConfig = JSON.parse(
fs.readFileSync(path.resolve(__dirname, '.auto-import.json'), 'utf-8')
)
export default [
// 指定文件匹配规则
{
files: ['**/*.{js,mjs,cjs,ts,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
]
+44
View File
@@ -0,0 +1,44 @@
<!doctype html>
<html>
<head>
<title>all-kefu 管理后台</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="all-kefu 管理后台——多租户 AI 客服平台控制台" />
<link rel="icon" type="image/png" href="/favicon.png" />
<style>
/* 防止页面刷新时白屏的初始样式 */
html {
background-color: #fafbfc;
}
html.dark {
background-color: #070707;
}
</style>
<script>
// 初始化 html class 主题属性
;(function () {
try {
if (typeof Storage === 'undefined' || !window.localStorage) {
return
}
const themeType = localStorage.getItem('sys-theme')
if (themeType === 'dark') {
document.documentElement.classList.add('dark')
}
} catch (e) {
console.warn('Failed to apply initial theme:', e)
}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+87
View File
@@ -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"
}
}
+5855
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
onlyBuiltDependencies:
- "@parcel/watcher"
- "@tailwindcss/oxide"
- core-js
- esbuild
- es5-ext
- vue-demi
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+838
View File
@@ -0,0 +1,838 @@
// scripts/clean-dev.ts
import fs from 'fs/promises'
import path from 'path'
// 现代化颜色主题
const theme = {
// 基础颜色
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
// 前景色
primary: '\x1b[38;5;75m', // 亮蓝色
success: '\x1b[38;5;82m', // 亮绿色
warning: '\x1b[38;5;220m', // 亮黄色
error: '\x1b[38;5;196m', // 亮红色
info: '\x1b[38;5;159m', // 青色
purple: '\x1b[38;5;141m', // 紫色
orange: '\x1b[38;5;208m', // 橙色
gray: '\x1b[38;5;245m', // 灰色
white: '\x1b[38;5;255m', // 白色
// 背景色
bgDark: '\x1b[48;5;235m', // 深灰背景
bgBlue: '\x1b[48;5;24m', // 蓝色背景
bgGreen: '\x1b[48;5;22m', // 绿色背景
bgRed: '\x1b[48;5;52m' // 红色背景
}
// 现代化图标集
const icons = {
rocket: '🚀',
fire: '🔥',
star: '⭐',
gem: '💎',
crown: '👑',
magic: '✨',
warning: '⚠️',
success: '✅',
error: '❌',
info: 'ℹ️',
folder: '📁',
file: '📄',
image: '🖼️',
code: '💻',
data: '📊',
globe: '🌐',
map: '🗺️',
chat: '💬',
bolt: '⚡',
shield: '🛡️',
key: '🔑',
link: '🔗',
clean: '🧹',
trash: '🗑️',
check: '✓',
cross: '✗',
arrow: '→',
loading: '⏳'
}
// 格式化工具
const fmt = {
title: (text: string) => `${theme.bold}${theme.primary}${text}${theme.reset}`,
subtitle: (text: string) => `${theme.purple}${text}${theme.reset}`,
success: (text: string) => `${theme.success}${text}${theme.reset}`,
error: (text: string) => `${theme.error}${text}${theme.reset}`,
warning: (text: string) => `${theme.warning}${text}${theme.reset}`,
info: (text: string) => `${theme.info}${text}${theme.reset}`,
highlight: (text: string) => `${theme.bold}${theme.white}${text}${theme.reset}`,
dim: (text: string) => `${theme.dim}${theme.gray}${text}${theme.reset}`,
orange: (text: string) => `${theme.orange}${text}${theme.reset}`,
// 带背景的文本
badge: (text: string, bg: string = theme.bgBlue) =>
`${bg}${theme.white}${theme.bold} ${text} ${theme.reset}`,
// 渐变效果模拟
gradient: (text: string) => {
const colors = ['\x1b[38;5;75m', '\x1b[38;5;81m', '\x1b[38;5;87m', '\x1b[38;5;159m']
const chars = text.split('')
return chars.map((char, i) => `${colors[i % colors.length]}${char}`).join('') + theme.reset
}
}
// 创建现代化标题横幅
function createModernBanner() {
console.log()
console.log(
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
` ║ ${icons.rocket} ${fmt.title('ART DESIGN PRO')} ${fmt.subtitle('· 代码精简程序')} ${icons.magic} ║`
)
console.log(
` ║ ${fmt.dim('为项目移除演示数据,快速切换至开发模式')} ║`
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
)
console.log()
}
// 创建分割线
function createDivider(char = '─', color = theme.primary) {
console.log(`${color}${' ' + char.repeat(66)}${theme.reset}`)
}
// 创建卡片样式容器
function createCard(title: string, content: string[]) {
console.log(` ${fmt.badge('', theme.bgBlue)} ${fmt.title(title)}`)
console.log()
content.forEach((line) => {
console.log(` ${line}`)
})
console.log()
}
// 进度条动画
function createProgressBar(current: number, total: number, text: string, width = 40) {
const percentage = Math.round((current / total) * 100)
const filled = Math.round((current / total) * width)
const empty = width - filled
const filledBar = '█'.repeat(filled)
const emptyBar = '░'.repeat(empty)
process.stdout.write(
`\r ${fmt.info('进度')} [${theme.success}${filledBar}${theme.gray}${emptyBar}${theme.reset}] ${fmt.highlight(percentage + '%')})}`
)
if (current === total) {
console.log()
}
}
// 统计信息
const stats = {
deletedFiles: 0,
deletedPaths: 0,
failedPaths: 0,
startTime: Date.now(),
totalFiles: 0
}
// 清理目标
const targets = [
'README.md',
'README.zh-CN.md',
'CHANGELOG.md',
'CHANGELOG.zh-CN.md',
'src/views/change',
'src/views/safeguard',
'src/views/article',
'src/views/examples',
'src/views/system/nested',
'src/views/widgets',
'src/views/template',
'src/views/dashboard/analysis',
'src/views/dashboard/ecommerce',
'src/mock/json',
'src/mock/temp/articleList.ts',
'src/mock/temp/commentDetail.ts',
'src/mock/temp/commentList.ts',
'src/assets/images/cover',
'src/assets/images/safeguard',
'src/assets/images/3d',
'src/components/core/charts/art-map-chart',
'src/components/business/comment-widget'
]
// 递归统计文件数量
async function countFiles(targetPath: string): Promise<number> {
const fullPath = path.resolve(process.cwd(), targetPath)
try {
const stat = await fs.stat(fullPath)
if (stat.isFile()) {
return 1
} else if (stat.isDirectory()) {
const entries = await fs.readdir(fullPath)
let count = 0
for (const entry of entries) {
const entryPath = path.join(targetPath, entry)
count += await countFiles(entryPath)
}
return count
}
} catch {
return 0
}
return 0
}
// 统计所有目标的文件数量
async function countAllFiles(): Promise<number> {
let totalCount = 0
for (const target of targets) {
const count = await countFiles(target)
totalCount += count
}
return totalCount
}
// 删除文件和目录
async function remove(targetPath: string, index: number) {
const fullPath = path.resolve(process.cwd(), targetPath)
createProgressBar(index + 1, targets.length, targetPath)
try {
const fileCount = await countFiles(targetPath)
await fs.rm(fullPath, { recursive: true, force: true })
stats.deletedFiles += fileCount
stats.deletedPaths++
await new Promise((resolve) => setTimeout(resolve, 50))
} catch (err) {
stats.failedPaths++
console.log()
console.log(` ${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(targetPath)}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理路由模块
async function cleanRouteModules() {
const modulesPath = path.resolve(process.cwd(), 'src/router/modules')
try {
// 删除演示相关的路由模块
const modulesToRemove = [
'template.ts',
'widgets.ts',
'examples.ts',
'article.ts',
'safeguard.ts',
'help.ts'
]
for (const module of modulesToRemove) {
const modulePath = path.join(modulesPath, module)
try {
await fs.rm(modulePath, { force: true })
} catch {
// 文件不存在时忽略错误
}
}
// 重写 dashboard.ts - 只保留 console
const dashboardContent = `import { AppRouteRecord } from '@/types/router'
export const dashboardRoutes: AppRouteRecord = {
name: 'Dashboard',
path: '/dashboard',
component: '/index/index',
meta: {
title: 'menus.dashboard.title',
icon: 'ri:pie-chart-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'console',
name: 'Console',
component: '/dashboard/console',
meta: {
title: 'menus.dashboard.console',
keepAlive: false,
fixedTab: true
}
}
]
}
`
await fs.writeFile(path.join(modulesPath, 'dashboard.ts'), dashboardContent, 'utf-8')
// 重写 system.ts - 移除 nested 嵌套菜单
const systemContent = `import { AppRouteRecord } from '@/types/router'
export const systemRoutes: AppRouteRecord = {
path: '/system',
name: 'System',
component: '/index/index',
meta: {
title: 'menus.system.title',
icon: 'ri:user-3-line',
roles: ['R_SUPER', 'R_ADMIN']
},
children: [
{
path: 'user',
name: 'User',
component: '/system/user',
meta: {
title: 'menus.system.user',
keepAlive: true,
roles: ['R_SUPER', 'R_ADMIN']
}
},
{
path: 'role',
name: 'Role',
component: '/system/role',
meta: {
title: 'menus.system.role',
keepAlive: true,
roles: ['R_SUPER']
}
},
{
path: 'user-center',
name: 'UserCenter',
component: '/system/user-center',
meta: {
title: 'menus.system.userCenter',
isHide: true,
keepAlive: true,
isHideTab: true
}
},
{
path: 'menu',
name: 'Menus',
component: '/system/menu',
meta: {
title: 'menus.system.menu',
keepAlive: true,
roles: ['R_SUPER'],
authList: [
{ title: '新增', authMark: 'add' },
{ title: '编辑', authMark: 'edit' },
{ title: '删除', authMark: 'delete' }
]
}
}
]
}
`
await fs.writeFile(path.join(modulesPath, 'system.ts'), systemContent, 'utf-8')
// 重写 index.ts - 只导入保留的模块
const indexContent = `import { AppRouteRecord } from '@/types/router'
import { dashboardRoutes } from './dashboard'
import { systemRoutes } from './system'
import { resultRoutes } from './result'
import { exceptionRoutes } from './exception'
/**
* 导出所有模块化路由
*/
export const routeModules: AppRouteRecord[] = [
dashboardRoutes,
systemRoutes,
resultRoutes,
exceptionRoutes
]
`
await fs.writeFile(path.join(modulesPath, 'index.ts'), indexContent, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清理路由模块完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理路由模块失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理路由别名
async function cleanRoutesAlias() {
const routesAliasPath = path.resolve(process.cwd(), 'src/router/routesAlias.ts')
try {
const cleanedAlias = `/**
* 公共路由别名
# 存放系统级公共路由路径,如布局容器、登录页等
*/
export enum RoutesAlias {
Layout = '/index/index', // 布局容器
Login = '/auth/login' // 登录页
}
`
await fs.writeFile(routesAliasPath, cleanedAlias, 'utf-8')
console.log(` ${icons.success} ${fmt.success('重写路由别名配置完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理路由别名失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理变更日志
async function cleanChangeLog() {
const changeLogPath = path.resolve(process.cwd(), 'src/mock/upgrade/changeLog.ts')
try {
const cleanedChangeLog = `import { ref } from 'vue'
interface UpgradeLog {
version: string // 版本号
title: string // 更新标题
date: string // 更新日期
detail?: string[] // 更新内容
requireReLogin?: boolean // 是否需要重新登录
remark?: string // 备注
}
export const upgradeLogList = ref<UpgradeLog[]>([])
`
await fs.writeFile(changeLogPath, cleanedChangeLog, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清空变更日志数据完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理变更日志失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 清理语言文件
async function cleanLanguageFiles() {
const languageFiles = [
{ path: 'src/locales/langs/zh.json', name: '中文语言文件' },
{ path: 'src/locales/langs/en.json', name: '英文语言文件' }
]
for (const { path: langPath, name } of languageFiles) {
try {
const fullPath = path.resolve(process.cwd(), langPath)
const content = await fs.readFile(fullPath, 'utf-8')
const langData = JSON.parse(content)
const menusToRemove = [
'widgets',
'template',
'article',
'examples',
'safeguard',
'plan',
'help'
]
if (langData.menus) {
menusToRemove.forEach((menuKey) => {
if (langData.menus[menuKey]) {
delete langData.menus[menuKey]
}
})
if (langData.menus.dashboard) {
if (langData.menus.dashboard.analysis) {
delete langData.menus.dashboard.analysis
}
if (langData.menus.dashboard.ecommerce) {
delete langData.menus.dashboard.ecommerce
}
}
if (langData.menus.system) {
const systemKeysToRemove = [
'nested',
'menu1',
'menu2',
'menu21',
'menu3',
'menu31',
'menu32',
'menu321'
]
systemKeysToRemove.forEach((key) => {
if (langData.menus.system[key]) {
delete langData.menus.system[key]
}
})
}
}
await fs.writeFile(fullPath, JSON.stringify(langData, null, 2), 'utf-8')
console.log(` ${icons.success} ${fmt.success(`清理${name}完成`)}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error(`清理${name}失败`)}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
}
// 清理快速入口组件
async function cleanFastEnterComponent() {
const fastEnterPath = path.resolve(process.cwd(), 'src/config/fastEnter.ts')
try {
const cleanedFastEnter = `/**
* 快速入口配置
* 包含:应用列表、快速链接等配置
*/
import { WEB_LINKS } from '@/utils/constants'
import type { FastEnterConfig } from '@/types/config'
const fastEnterConfig: FastEnterConfig = {
// 显示条件(屏幕宽度)
minWidth: 1200,
// 应用列表
applications: [
{
name: '工作台',
description: '系统概览与数据统计',
icon: 'ri:pie-chart-line',
iconColor: '#377dff',
enabled: true,
order: 1,
routeName: 'Console'
},
{
name: '官方文档',
description: '使用指南与开发文档',
icon: 'ri:bill-line',
iconColor: '#ffb100',
enabled: true,
order: 2,
link: WEB_LINKS.DOCS
},
{
name: '技术支持',
description: '技术支持与问题反馈',
icon: 'ri:user-location-line',
iconColor: '#ff6b6b',
enabled: true,
order: 3,
link: WEB_LINKS.COMMUNITY
},
{
name: '哔哩哔哩',
description: '技术分享与交流',
icon: 'ri:bilibili-line',
iconColor: '#FB7299',
enabled: true,
order: 4,
link: WEB_LINKS.BILIBILI
}
],
// 快速链接
quickLinks: [
{
name: '登录',
enabled: true,
order: 1,
routeName: 'Login'
},
{
name: '注册',
enabled: true,
order: 2,
routeName: 'Register'
},
{
name: '忘记密码',
enabled: true,
order: 3,
routeName: 'ForgetPassword'
},
{
name: '个人中心',
enabled: true,
order: 4,
routeName: 'UserCenter'
}
]
}
export default Object.freeze(fastEnterConfig)
`
await fs.writeFile(fastEnterPath, cleanedFastEnter, 'utf-8')
console.log(` ${icons.success} ${fmt.success('清理快速入口配置完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('清理快速入口配置失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 更新菜单接口
async function updateMenuApi() {
const apiPath = path.resolve(process.cwd(), 'src/api/system-manage.ts')
try {
const content = await fs.readFile(apiPath, 'utf-8')
const updatedContent = content.replace(
"url: '/api/v3/system/menus'",
"url: '/api/v3/system/menus/simple'"
)
await fs.writeFile(apiPath, updatedContent, 'utf-8')
console.log(` ${icons.success} ${fmt.success('更新菜单接口完成')}`)
} catch (err) {
console.log(` ${icons.error} ${fmt.error('更新菜单接口失败')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
}
}
// 用户确认函数
async function getUserConfirmation(): Promise<boolean> {
const { createInterface } = await import('readline')
return new Promise((resolve) => {
const rl = createInterface({
input: process.stdin,
output: process.stdout
})
console.log(
` ${fmt.highlight('请输入')} ${fmt.success('yes')} ${fmt.highlight('确认执行清理操作,或按 Enter 取消')}`
)
console.log()
process.stdout.write(` ${icons.arrow} `)
rl.question('', (answer: string) => {
rl.close()
resolve(answer.toLowerCase().trim() === 'yes')
})
})
}
// 显示清理警告
async function showCleanupWarning() {
createCard('安全警告', [
`${fmt.warning('此操作将永久删除以下演示内容,且无法恢复!')}`,
`${fmt.dim('请仔细阅读清理列表,确认后再继续操作')}`
])
const cleanupItems = [
{
icon: icons.image,
name: '图片资源',
desc: '演示用的封面图片、3D图片、运维图片等',
color: theme.orange
},
{
icon: icons.file,
name: '演示页面',
desc: 'widgets、template、article、examples、safeguard等页面',
color: theme.purple
},
{
icon: icons.code,
name: '路由模块文件',
desc: '删除演示路由模块,只保留核心模块(dashboard、system、result、exception)',
color: theme.primary
},
{
icon: icons.link,
name: '路由别名',
desc: '重写routesAlias.ts,移除演示路由别名',
color: theme.info
},
{
icon: icons.data,
name: 'Mock数据',
desc: '演示用的JSON数据、文章列表、评论数据等',
color: theme.success
},
{
icon: icons.globe,
name: '多语言文件',
desc: '清理中英文语言包中的演示菜单项',
color: theme.warning
},
{ icon: icons.map, name: '地图组件', desc: '移除art-map-chart地图组件', color: theme.error },
{ icon: icons.chat, name: '评论组件', desc: '移除comment-widget评论组件', color: theme.orange },
{
icon: icons.bolt,
name: '快速入口',
desc: '移除分析页、礼花效果、聊天、更新日志、定价、留言管理等无效项目',
color: theme.purple
}
]
console.log(` ${fmt.badge('', theme.bgRed)} ${fmt.title('将要清理的内容')}`)
console.log()
cleanupItems.forEach((item, index) => {
console.log(` ${item.color}${theme.reset} ${fmt.highlight(`${index + 1}. ${item.name}`)}`)
console.log(` ${fmt.dim(item.desc)}`)
})
console.log()
console.log(` ${fmt.badge('', theme.bgGreen)} ${fmt.title('保留的功能模块')}`)
console.log()
const preservedModules = [
{ name: 'Dashboard', desc: '工作台页面' },
{ name: 'System', desc: '系统管理模块' },
{ name: 'Result', desc: '结果页面' },
{ name: 'Exception', desc: '异常页面' },
{ name: 'Auth', desc: '登录注册功能' },
{ name: 'Core Components', desc: '核心组件库' }
]
preservedModules.forEach((module) => {
console.log(` ${icons.check} ${fmt.success(module.name)} ${fmt.dim(`- ${module.desc}`)}`)
})
console.log()
createDivider()
console.log()
}
// 显示统计信息
async function showStats() {
const duration = Date.now() - stats.startTime
const seconds = (duration / 1000).toFixed(2)
console.log()
createCard('清理统计', [
`${fmt.success('成功删除')}: ${fmt.highlight(stats.deletedFiles.toString())} 个文件`,
`${fmt.info('涉及路径')}: ${fmt.highlight(stats.deletedPaths.toString())} 个目录/文件`,
...(stats.failedPaths > 0
? [
`${icons.error} ${fmt.error('删除失败')}: ${fmt.highlight(stats.failedPaths.toString())} 个路径`
]
: []),
`${fmt.info('耗时')}: ${fmt.highlight(seconds)} 秒`
])
}
// 创建成功横幅
function createSuccessBanner() {
console.log()
console.log(
fmt.gradient(' ╔══════════════════════════════════════════════════════════════════╗')
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
` ║ ${icons.star} ${fmt.success('清理完成!项目已准备就绪')} ${icons.rocket} ║`
)
console.log(
` ║ ${fmt.dim('现在可以开始您的开发之旅了!')} ║`
)
console.log(
fmt.gradient(' ║ ║')
)
console.log(
fmt.gradient(' ╚══════════════════════════════════════════════════════════════════╝')
)
console.log()
}
// 主函数
async function main() {
// 清屏并显示横幅
console.clear()
createModernBanner()
// 显示清理警告
await showCleanupWarning()
// 统计文件数量
console.log(` ${fmt.info('正在统计文件数量...')}`)
stats.totalFiles = await countAllFiles()
console.log(` ${fmt.info('即将清理')}: ${fmt.highlight(stats.totalFiles.toString())} 个文件`)
console.log(` ${fmt.dim(`涉及 ${targets.length} 个目录/文件路径`)}`)
console.log()
// 用户确认
const confirmed = await getUserConfirmation()
if (!confirmed) {
console.log(` ${fmt.warning('操作已取消,清理中止')}`)
console.log()
return
}
console.log()
console.log(` ${icons.check} ${fmt.success('确认成功,开始清理...')}`)
console.log()
// 开始清理过程
console.log(` ${fmt.badge('步骤 1/6', theme.bgBlue)} ${fmt.title('删除演示文件')}`)
console.log()
for (let i = 0; i < targets.length; i++) {
await remove(targets[i], i)
}
console.log()
console.log(` ${fmt.badge('步骤 2/6', theme.bgBlue)} ${fmt.title('清理路由模块')}`)
console.log()
await cleanRouteModules()
console.log()
console.log(` ${fmt.badge('步骤 3/6', theme.bgBlue)} ${fmt.title('重写路由别名')}`)
console.log()
await cleanRoutesAlias()
console.log()
console.log(` ${fmt.badge('步骤 4/6', theme.bgBlue)} ${fmt.title('清空变更日志')}`)
console.log()
await cleanChangeLog()
console.log()
console.log(` ${fmt.badge('步骤 5/6', theme.bgBlue)} ${fmt.title('清理语言文件')}`)
console.log()
await cleanLanguageFiles()
console.log()
console.log(` ${fmt.badge('步骤 6/7', theme.bgBlue)} ${fmt.title('清理快速入口')}`)
console.log()
await cleanFastEnterComponent()
console.log()
console.log(` ${fmt.badge('步骤 7/7', theme.bgBlue)} ${fmt.title('更新菜单接口')}`)
console.log()
await updateMenuApi()
// 显示统计信息
await showStats()
// 显示成功横幅
createSuccessBanner()
}
main().catch((err) => {
console.log()
console.log(` ${icons.error} ${fmt.error('清理脚本执行出错')}`)
console.log(` ${fmt.dim('错误详情: ' + err)}`)
console.log()
process.exit(1)
})
+39
View File
@@ -0,0 +1,39 @@
<template>
<ElConfigProvider
size="default"
:locale="locales[language]"
:z-index="3000"
:card="{
shadow: 'never'
}"
>
<RouterView></RouterView>
</ElConfigProvider>
</template>
<script setup lang="ts">
import { useUserStore } from './store/modules/user'
import zh from 'element-plus/es/locale/lang/zh-cn'
import en from 'element-plus/es/locale/lang/en'
import { toggleTransition } from './utils/ui/animation'
import { checkStorageCompatibility } from './utils/storage'
import { initializeTheme } from './hooks/core/useTheme'
const userStore = useUserStore()
const { language } = storeToRefs(userStore)
const locales = {
zh: zh,
en: en
}
onBeforeMount(() => {
toggleTransition(true)
initializeTheme()
})
onMounted(() => {
checkStorageCompatibility()
toggleTransition(false)
})
</script>
+211
View File
@@ -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)
})
})
+238
View File
@@ -0,0 +1,238 @@
/**
* HTTP client 单元测试(Phase 3 Task 3)
*
* 契约对齐后端 app/platform/auth.py 与 auth_routes.py:
* - Authorization: Bearer <token> 注入
* - 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<AxiosResponse> => {
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 <token>', 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<typeof body>('/api/platform/auth/login', {}, { adapter })
expect(res).toEqual(body)
})
})
})
+439
View File
@@ -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([])
})
})
@@ -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')
})
})
+61
View File
@@ -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)
})
})
+78
View File
@@ -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
)
})
})
+89
View File
@@ -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')
})
})
+218
View File
@@ -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
)
})
})
+38
View File
@@ -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<LoginResponse> {
return http.post<LoginResponse>('/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
)
}
+143
View File
@@ -0,0 +1,143 @@
/**
* 平台 API HTTP client(Phase 3 Task 3,自建薄封装)
*
* ## 为什么不用模板的 src/utils/http
*
* 模板封装与后端约定对不上,改造成本高于自建:
* - 响应:模板要求 `{code, msg, data}` 业务信封;后端(FastAPI)业务语义
* 直用 HTTP 状态码,成功响应就是数据本身
* - 错误:模板读 `data.msg`;FastAPI 错误体是 `{detail: 中文}`
* - 凭据:模板裸塞 `Authorization: <token>`;后端要求 `Bearer <token>`
* - 模板无 409 强制改密语义
*
* ## 本 client 约定
*
* - 请求拦截:auth store 有 token 时自动注入 `Authorization: Bearer <token>`
* - 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<ApiErrorBody>): 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<ApiErrorBody>) => {
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<T>(config: RequestOptions): Promise<T> {
const response = await instance.request<T>(config)
return response.data
}
/** API 方法集合 */
export const http = {
get<T = unknown>(url: string, config?: RequestOptions): Promise<T> {
return request<T>({ ...config, url, method: 'GET' })
},
post<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
return request<T>({ ...config, url, method: 'POST', data })
},
put<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
return request<T>({ ...config, url, method: 'PUT', data })
},
patch<T = unknown>(url: string, data?: unknown, config?: RequestOptions): Promise<T> {
return request<T>({ ...config, url, method: 'PATCH', data })
},
del<T = unknown>(url: string, config?: RequestOptions): Promise<T> {
return request<T>({ ...config, url, method: 'DELETE' })
}
}
+402
View File
@@ -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<string, Record<string, unknown>>
}
/** 分组保存响应(PUT .../config/{group};config 为该组掩码视图) */
export interface PutConfigGroupResponse {
slug: string
group: string
version: number
config: Record<string, unknown>
}
/**
* 组级重置响应(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<string, Record<string, unknown>>
}
/** 回滚请求(POST .../config/rollback,产生新版本;后端只读 version) */
export interface ConfigRollbackRequest {
version: number
}
/** 回滚响应 */
export interface ConfigRollbackResponse {
slug: string
rollback_to: number
version: number
config: Record<string, Record<string, unknown>>
}
/** 词表试金石请求(POST .../wall/test)
* wall 覆盖 = 编辑器当前表单值的预演(后端 compile_wall 校验 + 纯内存
* 判定,不落库);缺省用当前生效墙。 */
export interface WallTestRequest {
sentences: string[]
wall?: Record<string, unknown>
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<TenantConfigResponse> {
return http.get<TenantConfigResponse>(CONFIG_ENDPOINTS.current(slug), config)
}
/**
* 分组保存(整组替换语义):请求体 = 组值本身,备注走 comment 查询参数。
* 调用方必须先 GET 全组、改字段后整组提交——部分提交会丢掉未携带的键
* (后端 deep merge 出厂默认兜底,但租户既有定制会被覆盖丢失)。
*/
export function putConfigGroup(
slug: string,
group: ConfigGroupKey | string,
value: Record<string, unknown>,
comment?: string,
config?: RequestOptions
): Promise<PutConfigGroupResponse> {
return http.put<PutConfigGroupResponse>(
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<ConfigVersionListResponse> {
return http.get<ConfigVersionListResponse>(
CONFIG_ENDPOINTS.versions(slug),
config
)
}
/**
* 单版本详情(GET .../config/versions/{n}):含整份快照(密钥字段为
* '__SET__' 掩码视图——历史行存原值但绝不回传)。版本不存在 → 404。
*/
export function fetchConfigVersionDetail(
slug: string,
version: number,
config?: RequestOptions
): Promise<ConfigVersionDetailResponse> {
return http.get<ConfigVersionDetailResponse>(
CONFIG_ENDPOINTS.versionDetail(slug, version),
config
)
}
/**
* 回滚(POST .../config/rollback):目标版本整份复制为新版本(历史不可变
* ——旧版本行永不改写),reload 热生效。响应含新版本号 + 新快照掩码视图
* (调用方据此回写当前配置态,无需再 GET config)。
*/
export function rollbackConfig(
slug: string,
body: ConfigRollbackRequest,
config?: RequestOptions
): Promise<ConfigRollbackResponse> {
return http.post<ConfigRollbackResponse>(
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<ResetConfigGroupResponse> {
return http.post<ResetConfigGroupResponse>(
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<WallTestResponse> {
return http.post<WallTestResponse>(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<AvatarUploadResponse> {
const form = new FormData()
form.append('file', file)
return http.post<AvatarUploadResponse>(
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<EmailTestResponse> {
return http.post<EmailTestResponse>(
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<PushTestResponse> {
return http.post<PushTestResponse>(
CONFIG_ENDPOINTS.pushTest(slug),
undefined,
config
)
}
/**
* GET 的 brand 组(原样快照,可能缺键/类型漂移)→ 表单友好的完整值。
* 缺键补空串作表单中性值:提交空串 = 显式清空覆盖出厂(出厂
* title/color/avatar 本非空——"AI Support"/"#066fc0"/内置头像),快照
* 缺省键则由消费侧深合并回落出厂;UI 所见即所得。topics 只留字符串项。
*/
export function normalizeBrandGroup(
value: Record<string, unknown> | 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')
: []
}
}
+239
View File
@@ -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<string, unknown>
site: string | null
ip: string
device: string
country: string | null
created_at: string | null
updated_at: string | null
evaluation: ConversationEvaluation | null
profile: {
chips: Record<string, unknown>
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<string, string> = {
lead: '新线索',
returning: '回访',
correction: '更正'
}
if (!type) return '—'
return labels[type] ?? type
}
/** 会话状态展示文案(ConversationStatus) */
export function conversationStatusLabel(status: string | null | undefined): string {
const labels: Record<string, string> = {
active: '进行中',
completed: '已完成',
abandoned: '已放弃'
}
if (!status) return '—'
return labels[status] ?? status
}
/** 会话来源展示文案(ConversationSource) */
export function conversationSourceLabel(source: string | null | undefined): string {
const labels: Record<string, string> = {
standalone: '独立站',
embedded: '内嵌页',
widget: '挂件',
app: '应用'
}
if (!source) return '—'
return labels[source] ?? source
}
// ---- 调用函数 -------------------------------------------------------------------
/** 会话列表(分页 + intent/site/日期区间筛选) */
export function fetchConversations(
slug: string,
params?: ConversationListParams
): Promise<ConversationListResponse> {
return http.get<ConversationListResponse>(CONVERSATION_ENDPOINTS.list(slug), { params })
}
/** 会话详情(元数据 + 画像 chips + 评估 + 消息全量) */
export function fetchConversationDetail(
slug: string,
convId: string
): Promise<ConversationDetail> {
return http.get<ConversationDetail>(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<PushLogResponse> {
return http.get<PushLogResponse>(CONVERSATION_ENDPOINTS.pushLogs(slug), { params })
}
+44
View File
@@ -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 : ''
}
+70
View File
@@ -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<PlatformSettingsResponse> {
return http.get<PlatformSettingsResponse>(SETTINGS_ENDPOINTS.list, config)
}
/**
* upsert 单个设置项(PUT /api/platform/settings):同 key 再写 = 整值
* 替换(无部分合并)。密钥键提交 '__SET__' 哨兵 = 保持库值(后端还原,
* 哨兵字面量绝不落库)。响应 {key, value} 为掩码视图。
* 本页按"仅提交脏键"调用——未改动 的键不发请求。
*/
export function putPlatformSetting(
body: PutPlatformSettingRequest,
config?: RequestOptions
): Promise<PlatformSettingItem> {
return http.put<PlatformSettingItem>(SETTINGS_ENDPOINTS.put, body, config)
}
+99
View File
@@ -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<string, number>
source: Record<string, number>
site: Record<string, number>
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<StatsResponse> {
return http.get<StatsResponse>(STATS_ENDPOINTS.platform, { params, ...options })
}
/**
* 单租户统计(本租户管理员或超管):租户管理员的"自己指标卡"走这里,
* slug 取登录态 tenant_slug(auth store)
*/
export function fetchTenantStats(slug: string, params?: StatsParams): Promise<StatsResponse> {
return http.get<StatsResponse>(STATS_ENDPOINTS.tenant(slug), { params })
}
+218
View File
@@ -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<string, unknown>
}
/** 管理员概要(_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<string, unknown>
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<string, unknown>
}
/** 绑定域名请求(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<TenantListResponse> {
return http.get<TenantListResponse>(TENANT_ENDPOINTS.list)
}
/** 租户详情(含 sites/domains/brand_overlay/admins;本租户管理员亦可读) */
export function fetchTenantDetail(
slug: string,
config?: RequestOptions
): Promise<TenantDetailResponse> {
return http.get<TenantDetailResponse>(TENANT_ENDPOINTS.detail(slug), config)
}
/**
* 创建租户(创建向导提交;201)。向导调用建议传
* `{ showErrorMessage: false }`——400 预检撞车 detail(slug 已存在 /
* 出厂 push.prefix 被占的改 blank 引导)在向导内联常驻展示。
*/
export function createTenant(
body: CreateTenantRequest,
config?: RequestOptions
): Promise<TenantCreatedResponse> {
return http.post<TenantCreatedResponse>(TENANT_ENDPOINTS.create, body, config)
}
/** 改名 / 停用 / 复启用(PATCH;slug 不可变,body 不含 slug 键) */
export function patchTenant(
slug: string,
body: PatchTenantRequest,
config?: RequestOptions
): Promise<TenantPatchResponse> {
return http.patch<TenantPatchResponse>(TENANT_ENDPOINTS.patch(slug), body, config)
}
/** 新建站点(brand_overlay 只放显式覆盖的字段——留空=继承租户默认) */
export function createSite(
slug: string,
body: CreateSiteRequest,
config?: RequestOptions
): Promise<SiteSummary> {
return http.post<SiteSummary>(TENANT_ENDPOINTS.createSite(slug), body, config)
}
/** 改站点(停用/复启用/改名/改覆盖层;整字典赋值——旧键不残留) */
export function patchSite(
slug: string,
siteSlug: string,
body: PatchSiteRequest,
config?: RequestOptions
): Promise<SiteSummary> {
return http.patch<SiteSummary>(TENANT_ENDPOINTS.patchSite(slug, siteSlug), body, config)
}
/** 绑定域名(全局唯一;400 detail 带现有归属"租户 x 的站点 y") */
export function addSiteDomain(
slug: string,
siteSlug: string,
body: AddDomainRequest,
config?: RequestOptions
): Promise<DomainBindResponse> {
return http.post<DomainBindResponse>(TENANT_ENDPOINTS.addDomain(slug, siteSlug), body, config)
}
/** 解绑域名(只解本站点的该域名;不存在 404) */
export function deleteSiteDomain(
slug: string,
siteSlug: string,
domain: string,
config?: RequestOptions
): Promise<DomainUnbindResponse> {
return http.del<DomainUnbindResponse>(
TENANT_ENDPOINTS.deleteDomain(slug, siteSlug, domain),
config
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 439 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 B

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

After

Width:  |  Height:  |  Size: 4.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

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

After

Width:  |  Height:  |  Size: 4.8 KiB

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

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+292
View File
@@ -0,0 +1,292 @@
// 全局样式
// 顶部进度条颜色
#nprogress .bar {
z-index: 2400;
background-color: color-mix(in srgb, var(--theme-color) 70%, white);
}
#nprogress .peg {
box-shadow:
0 0 10px var(--theme-color),
0 0 5px var(--theme-color) !important;
}
#nprogress .spinner-icon {
border-top-color: var(--theme-color) !important;
border-left-color: var(--theme-color) !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
* {
cursor: default !important;
}
}
// 背景滤镜
*,
::before,
::after {
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
// 色弱模式
.color-weak {
filter: invert(80%);
-webkit-filter: invert(80%);
}
#noop {
display: none;
}
// 语言切换选中样式
.langDropDownStyle {
// 选中项背景颜色
.is-selected {
background-color: var(--art-el-active-color) !important;
}
// 语言切换按钮菜单样式优化
.lang-btn-item {
.el-dropdown-menu__item {
padding-left: 13px !important;
padding-right: 6px !important;
margin-bottom: 3px !important;
}
&:last-child {
.el-dropdown-menu__item {
margin-bottom: 0 !important;
}
}
.menu-txt {
min-width: 60px;
display: block;
}
i {
font-size: 10px;
margin-left: 10px;
}
}
}
// 盒子默认边框
.page-content {
border: 1px solid var(--art-card-border) !important;
}
@mixin art-card-base($border-color, $shadow: none, $radius-diff: 4px) {
background: var(--default-box-color);
border: 1px solid #{$border-color} !important;
border-radius: calc(var(--custom-radius) + #{$radius-diff}) !important;
box-shadow: #{$shadow} !important;
--el-card-border-color: var(--default-border) !important;
}
.art-card,
.art-card-sm,
.art-card-xs {
border: 1px solid var(--art-card-border);
}
// 盒子边框
[data-box-mode='border-mode'] {
.page-content,
.art-table-card {
border: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(var(--art-card-border), none, 4px);
}
.art-card-sm {
@include art-card-base(var(--art-card-border), none, 0px);
}
.art-card-xs {
@include art-card-base(var(--art-card-border), none, -4px);
}
}
// 盒子阴影
[data-box-mode='shadow-mode'] {
.page-content,
.art-table-card {
box-shadow: 0px 0px 4px 0px rgba(0, 0, 0, 0.04) !important;
border: 1px solid var(--art-gray-200) !important;
}
.layout-sidebar {
border-right: 1px solid var(--art-card-border) !important;
}
.art-card {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
4px
);
}
.art-card-sm {
@include art-card-base(
var(--art-gray-200),
(0 1px 3px 0 rgba(0, 0, 0, 0.03), 0 1px 2px -1px rgba(0, 0, 0, 0.08)),
2px
);
}
.art-card-xs {
@include art-card-base(
var(--art-gray-200),
(0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 1px -1px rgba(0, 0, 0, 0.08)),
-4px
);
}
}
// 元素全屏
.el-full-screen {
position: fixed;
top: 0;
left: 0;
right: 0;
width: 100vw !important;
height: 100% !important;
z-index: 2300;
margin-top: 0;
padding: 15px;
box-sizing: border-box;
background-color: var(--default-box-color);
display: flex;
flex-direction: column;
}
// 表格卡片
.art-table-card {
flex: 1;
display: flex;
flex-direction: column;
margin-top: 12px;
border-radius: calc(var(--custom-radius) / 2 + 2px) !important;
.el-card__body {
height: 100%;
overflow: hidden;
}
}
// 容器全高
.art-full-height {
height: var(--art-full-height);
display: flex;
flex-direction: column;
@media (max-width: 640px) {
height: auto;
}
}
// 徽章样式
.art-badge {
position: absolute;
top: 0;
right: 20px;
bottom: 0;
width: 6px;
height: 6px;
margin: auto;
background: #ff3860;
border-radius: 50%;
animation: breathe 1.5s ease-in-out infinite;
&.art-badge-horizontal {
right: 0;
}
&.art-badge-mixed {
right: 0;
}
&.art-badge-dual {
right: 5px;
top: 5px;
bottom: auto;
}
}
// 文字徽章样式
.art-text-badge {
position: absolute;
top: 0;
right: 12px;
bottom: 0;
min-width: 20px;
height: 18px;
line-height: 17px;
padding: 0 5px;
margin: auto;
font-size: 10px;
color: #fff;
text-align: center;
background: #fd4e4e;
border-radius: 4px;
}
@keyframes breathe {
0% {
opacity: 0.7;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
}
100% {
opacity: 0.7;
transform: scale(1);
}
}
// 修复老机型 loading 定位问题
.art-loading-fix {
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.art-loading-fix .el-loading-spinner {
position: static !important;
top: auto !important;
left: auto !important;
transform: none !important;
}
// 去除移动端点击背景色
@media screen and (max-width: 1180px) {
* {
-webkit-tap-highlight-color: transparent;
}
}
+93
View File
@@ -0,0 +1,93 @@
/*
* 深色主题
* 单页面移除深色主题 document.getElementsByTagName("html")[0].removeAttribute('class')
*/
$font-color: rgba(#ffffff, 0.85);
/* 覆盖element-plus默认深色背景色 */
html.dark {
// element-plus
--el-bg-color: var(--default-box-color);
--el-text-color-regular: #{$font-color};
// 富文本编辑器
// 工具栏背景颜色
--w-e-toolbar-bg-color: #18191c;
// 输入区域背景颜色
--w-e-textarea-bg-color: #090909;
// 工具栏文字颜色
--w-e-toolbar-color: var(--art-gray-600);
// 选中菜单颜色
--w-e-toolbar-active-bg-color: #25262b;
// 弹窗边框颜色
--w-e-toolbar-border-color: var(--default-border-dashed);
// 分割线颜色
--w-e-textarea-border-color: var(--default-border-dashed);
// 链接输入框边框颜色
--w-e-modal-button-border-color: var(--default-border-dashed);
// 表格头颜色
--w-e-textarea-slight-bg-color: #090909;
// 按钮背景颜色
--w-e-modal-button-bg-color: #090909;
// hover toolbar 背景颜色
--w-e-toolbar-active-color: var(--art-gray-800);
}
.dark {
.page-content .article-list .item .left .outer > div {
border-right-color: var(--dark-border-color) !important;
}
// 富文本编辑器
.editor-wrapper {
*:not(pre code *) {
color: inherit !important;
}
}
// 分隔线
.w-e-bar-divider {
background-color: var(--art-gray-300) !important;
}
.w-e-select-list,
.w-e-drop-panel,
.w-e-bar-item-group .w-e-bar-item-menus-container,
.w-e-text-container [data-slate-editor] pre > code {
border: 1px solid var(--default-border) !important;
}
// 下拉选择框
.w-e-select-list {
background-color: var(--default-box-color) !important;
}
/* 下拉选择框 hover 样式调整 */
.w-e-select-list ul li:hover,
/* 工具栏 hover 按钮背景颜色 */
.w-e-bar-item button:hover {
background-color: #090909 !important;
}
/* 代码块 */
.w-e-text-container [data-slate-editor] pre > code {
background-color: #25262b !important;
text-shadow: none !important;
}
/* 引用 */
.w-e-text-container [data-slate-editor] blockquote {
border-left: 4px solid var(--default-border-dashed) !important;
background-color: var(--art-color);
}
.editor-wrapper {
.w-e-text-container [data-slate-editor] .table-container th:last-of-type {
border-right: 1px solid var(--default-border-dashed) !important;
}
.w-e-modal {
background-color: var(--art-color);
}
}
}
@@ -0,0 +1,2 @@
// 导入暗黑主题
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
@@ -0,0 +1,34 @@
// https://github.com/element-plus/element-plus/blob/dev/packages/theme-chalk/src/common/var.scss
// 自定义Element 亮色主题
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
$colors: (
'white': #ffffff,
'black': #000000,
'success': (
'base': #13deb9
),
'warning': (
'base': #ffae1f
),
'danger': (
'base': #ff4d4f
),
'error': (
'base': #fa896b
)
),
$button: (
'hover-bg-color': var(--el-color-primary-light-9),
'hover-border-color': var(--el-color-primary),
'border-color': var(--el-color-primary),
'text-color': var(--el-color-primary)
),
$messagebox: (
'border-radius': '12px'
),
$popover: (
'padding': '14px',
'border-radius': '10px'
)
);
+519
View File
@@ -0,0 +1,519 @@
// 优化 Element Plus 组件库默认样式
:root {
// 系统主色
--main-color: var(--el-color-primary);
--el-color-white: white !important;
--el-color-black: white !important;
// 输入框边框颜色
// --el-border-color: #E4E4E7 !important; // DCDFE6
// 按钮粗度
--el-font-weight-primary: 400 !important;
--el-component-custom-height: 36px !important;
--el-component-size: var(--el-component-custom-height) !important;
// 边框、按钮圆角...
--el-border-radius-base: calc(var(--custom-radius) / 3 + 2px) !important;
--el-border-radius-small: calc(var(--custom-radius) / 3 + 4px) !important;
--el-messagebox-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
--el-popover-border-radius: calc(var(--custom-radius) / 3 + 4px) !important;
.region .el-radio-button__original-radio:checked + .el-radio-button__inner {
color: var(--theme-color);
}
}
// 优化 el-form-item 标签高度
.el-form-item__label {
height: var(--el-component-custom-height) !important;
line-height: var(--el-component-custom-height) !important;
}
// 日期选择器
.el-date-range-picker {
--el-datepicker-inrange-bg-color: var(--art-gray-200) !important;
}
// el-card 背景色跟系统背景色保持一致
html.dark .el-card {
--el-card-bg-color: var(--default-box-color) !important;
}
// 修改 el-pagination 大小
.el-pagination--default {
& {
--el-pagination-button-width: 32px !important;
--el-pagination-button-height: var(--el-pagination-button-width) !important;
}
@media (max-width: 1180px) {
& {
--el-pagination-button-width: 28px !important;
}
}
.el-select--default .el-select__wrapper {
min-height: var(--el-pagination-button-width) !important;
}
.el-pagination__jump .el-input {
height: var(--el-pagination-button-width) !important;
}
}
.el-pager li {
padding: 0 10px !important;
// border: 1px solid red !important;
}
// 优化菜单折叠展开动画(提升动画流畅度)
.el-menu.el-menu--inline {
transition: max-height 0.26s cubic-bezier(0.4, 0, 0.2, 1) !important;
}
// 优化菜单 item hover 动画(提升鼠标跟手感)
.el-sub-menu__title,
.el-menu-item {
transition: background-color 0s !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 start --------------------------------
// 修改 el-button 高度
.el-button--default {
height: var(--el-component-custom-height) !important;
}
// circle 按钮宽度优化
.el-button--default.is-circle {
width: var(--el-component-custom-height) !important;
}
// 修改 el-select 高度
.el-select--default {
.el-select__wrapper {
min-height: var(--el-component-custom-height) !important;
}
}
// 修改 el-checkbox-button 高度
.el-checkbox-button--default .el-checkbox-button__inner,
// 修改 el-radio-button 高度
.el-radio-button--default .el-radio-button__inner {
padding: 10px 15px !important;
}
// -------------------------------- 修改 el-size=default 组件默认高度 end --------------------------------
.el-pagination.is-background .btn-next,
.el-pagination.is-background .btn-prev,
.el-pagination.is-background .el-pager li {
border-radius: 6px;
}
.el-popover {
min-width: 80px;
border-radius: var(--el-border-radius-small) !important;
}
.el-dialog {
border-radius: 100px !important;
border-radius: calc(var(--custom-radius) / 1.2 + 2px) !important;
overflow: hidden;
}
.el-dialog__header {
.el-dialog__title {
font-size: 16px;
}
}
.el-dialog__body {
padding: 25px 0 !important;
position: relative; // 为了兼容 el-pagination 样式,需要设置 relative,不然会影响 el-pagination 的样式,比如 el-pagination__jump--small 会被影响,导致 el-pagination__jump--small 按钮无法点击,详见 URL_ADDRESS.com/element-plus/element-plus/issues/5684#issuecomment-1176299275;
}
.el-dialog.el-dialog-border {
.el-dialog__body {
// 上边框
&::before,
// 下边框
&::after {
content: '';
position: absolute;
left: -16px;
width: calc(100% + 32px);
height: 1px;
background-color: var(--art-gray-300);
}
&::before {
top: 0;
}
&::after {
bottom: 0;
}
}
}
// el-message 样式优化
.el-message {
background-color: var(--default-box-color) !important;
border: 0 !important;
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05) !important;
p {
font-size: 13px;
}
}
// 修改 el-dropdown 样式
.el-dropdown-menu {
padding: 6px !important;
border-radius: 10px !important;
border: none !important;
.el-dropdown-menu__item {
padding: 6px 16px !important;
border-radius: 6px !important;
&:hover:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-el-active-color) !important;
}
&:focus:not(.is-disabled) {
color: var(--art-gray-900) !important;
background-color: var(--art-gray-200) !important;
}
}
}
// 隐藏 select、dropdown 的三角
.el-select__popper,
.el-dropdown__popper {
margin-top: -6px !important;
.el-popper__arrow {
display: none;
}
}
.el-dropdown-selfdefine:focus {
outline: none !important;
}
// 处理移动端组件兼容性
@media screen and (max-width: 640px) {
.el-message-box,
.el-dialog {
width: calc(100% - 24px) !important;
}
.el-date-picker.has-sidebar.has-time {
width: calc(100% - 24px);
left: 12px !important;
}
.el-picker-panel *[slot='sidebar'],
.el-picker-panel__sidebar {
display: none;
}
.el-picker-panel *[slot='sidebar'] + .el-picker-panel__body,
.el-picker-panel__sidebar + .el-picker-panel__body {
margin-left: 0;
}
}
// 修改el-button样式
.el-button {
&.el-button--text {
background-color: transparent !important;
padding: 0 !important;
span {
margin-left: 0 !important;
}
}
}
// 修改el-tag样式
.el-tag {
font-weight: 500;
transition: all 0s !important;
&.el-tag--default {
height: 26px !important;
}
}
.el-checkbox-group {
&.el-table-filter__checkbox-group label.el-checkbox {
height: 17px !important;
.el-checkbox__label {
font-weight: 400 !important;
}
}
}
.el-radio--default {
// 优化单选按钮大小
.el-radio__input {
.el-radio__inner {
width: 16px;
height: 16px;
&::after {
width: 6px;
height: 6px;
}
}
}
}
.el-checkbox {
.el-checkbox__inner {
border-radius: 2px !important;
}
}
// 优化复选框样式
.el-checkbox--default {
.el-checkbox__inner {
width: 16px !important;
height: 16px !important;
border-radius: 4px !important;
&::before {
content: '';
height: 4px !important;
top: 5px !important;
background-color: #fff !important;
transform: scale(0.6) !important;
}
}
.is-checked {
.el-checkbox__inner {
&::after {
width: 3px;
height: 8px;
margin: auto;
border: 2px solid var(--el-checkbox-checked-icon-color);
border-left: 0;
border-top: 0;
transform: translate(-45%, -60%) rotate(45deg) scale(0.86) !important;
transform-origin: center;
}
}
}
}
.el-notification .el-notification__icon {
font-size: 22px !important;
}
// 修改 el-message-box 样式
.el-message-box__headerbtn .el-message-box__close,
.el-dialog__headerbtn .el-dialog__close {
top: 7px;
right: 7px;
width: 30px;
height: 30px;
border-radius: 5px;
transition: all 0.3s;
&:hover {
background-color: var(--art-hover-color) !important;
color: var(--art-gray-900) !important;
}
}
.el-message-box {
padding: 25px 20px !important;
}
.el-message-box__title {
font-weight: 500 !important;
}
.el-table__column-filter-trigger i {
color: var(--theme-color) !important;
margin: -3px 0 0 2px;
}
// 去除 el-dropdown 鼠标放上去出现的边框
.el-tooltip__trigger:focus-visible {
outline: unset;
}
// ipad 表单右侧按钮优化
@media screen and (max-width: 1180px) {
.el-table-fixed-column--right {
padding-right: 0 !important;
}
}
.login-out-dialog {
padding: 30px 20px !important;
border-radius: 10px !important;
}
// 修改 dialog 动画
.dialog-fade-enter-active {
.el-dialog:not(.is-draggable) {
animation: dialog-open 0.3s cubic-bezier(0.32, 0.14, 0.15, 0.86);
// 修复 el-dialog 动画后宽度不自适应问题
.el-select__selected-item {
display: inline-block;
}
}
}
.dialog-fade-leave-active {
animation: fade-out 0.2s linear;
.el-dialog:not(.is-draggable) {
animation: dialog-close 0.5s;
}
}
@keyframes dialog-open {
0% {
opacity: 0;
transform: scale(0.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes dialog-close {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.2);
}
}
// 遮罩层动画
@keyframes fade-out {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
// 修改 el-select 样式
.el-select__popper:not(.el-tree-select__popper) {
.el-select-dropdown__list {
padding: 5px !important;
.el-select-dropdown__item {
height: 34px !important;
line-height: 34px !important;
border-radius: 6px !important;
&.is-selected {
color: var(--art-gray-900) !important;
font-weight: 400 !important;
background-color: var(--art-el-active-color) !important;
margin-bottom: 4px !important;
}
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.el-select-dropdown__item:hover ~ .is-selected,
.el-select-dropdown__item.is-selected:has(~ .el-select-dropdown__item:hover) {
background-color: transparent !important;
}
}
}
// 修改 el-tree-select 样式
.el-tree-select__popper {
.el-select-dropdown__list {
padding: 5px !important;
.el-tree-node {
.el-tree-node__content {
height: 36px !important;
border-radius: 6px !important;
&:hover {
background-color: var(--art-gray-200) !important;
}
}
}
}
}
// 实现水波纹在文字下面效果
.el-button > span {
position: relative;
z-index: 10;
}
// 优化颜色选择器圆角
.el-color-picker__color {
border-radius: 2px !important;
}
// 优化日期时间选择器底部圆角
.el-picker-panel {
.el-picker-panel__footer {
border-radius: 0 0 var(--el-border-radius-base) var(--el-border-radius-base);
}
}
// 优化树型菜单样式
.el-tree-node__content {
border-radius: 4px;
margin-bottom: 4px;
padding: 1px 0;
&:hover {
background-color: var(--art-hover-color) !important;
}
}
.dark {
.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content {
background-color: var(--art-gray-300) !important;
}
}
// 隐藏折叠菜单弹窗 hover 出现的边框
.menu-left-popper:focus-within,
.horizontal-menu-popper:focus-within {
box-shadow: none !important;
outline: none !important;
}
// 数字输入组件右侧按钮高度跟随自定义组件高度
.el-input-number--default.is-controls-right {
.el-input-number__decrease,
.el-input-number__increase {
height: calc((var(--el-component-size) / 2)) !important;
}
}
File diff suppressed because it is too large Load Diff

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