Merge branch 'phase3-admin'
@@ -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 部署记档段已锚定要点)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
*.html linguist-detectable=false
|
||||
*.vue linguist-detectable=true
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
/node_modules/*
|
||||
/dist/*
|
||||
/src/main.ts
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
dist
|
||||
node_modules
|
||||
public
|
||||
.husky
|
||||
.vscode
|
||||
|
||||
src/components/Layout/MenuLeft/index.vue
|
||||
src/assets
|
||||
stats.html
|
||||
@@ -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'
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"stylelint.vscode-stylelint",
|
||||
"lokalise.i18n-ally",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`)。
|
||||
@@ -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
|
||||
]
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
onlyBuiltDependencies:
|
||||
- "@parcel/watcher"
|
||||
- "@tailwindcss/oxide"
|
||||
- core-js
|
||||
- esbuild
|
||||
- es5-ext
|
||||
- vue-demi
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -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)
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
: []
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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 : ''
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 954 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 726 B |
|
After Width: | Height: | Size: 944 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 810 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 409 B |
|
After Width: | Height: | Size: 431 B |
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 286 B |
|
After Width: | Height: | Size: 293 B |
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 416 B |
|
After Width: | Height: | Size: 509 B |
@@ -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 |
|
After Width: | Height: | Size: 5.0 KiB |
@@ -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 |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
)
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||