diff --git a/.claude/settings.json b/.claude/settings.json index ecef76c..8b2f384 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,39 @@ { "permissions": { + "allow": [ + "Bash(du -sh /c/NeoZQYY/*)", + "Bash(du -sh /c/NeoZQYY/.*)", + "Bash(wc -l /c/NeoZQYY/scripts/ops/*.py)", + "Read(//c/Users/Administrator/.kiro//**)", + "Bash(ls -lS /c/NeoZQYY/tmp/*.md)", + "Bash(xargs -I {} basename {})", + "Bash(sed 's/_[a-z].*//')", + "Bash(ls tests/*.py)", + "Bash(sed 's/test_//')", + "Bash(ls test_property_*.py)", + "Bash(ls test_p*.py)", + "Bash(ls test_rns*.py)", + "Bash(ls test_tenant_*.py)", + "Bash(ls test_trace_*.py)", + "Bash(mv .kiro/steering _DEL/.kiro/steering)", + "Bash(mv .kiroignore _DEL/.kiroignore)", + "Bash(mv .specstory _DEL/.specstory)", + "Bash(mv .cursorindexingignore _DEL/.cursorindexingignore)", + "Bash(mv AI_CHANGELOG.md _DEL/AI_CHANGELOG.md)", + "Bash(mv _tmp_replace2.py _DEL/)", + "Bash(mv backend_test_results.txt _DEL/)", + "Bash(mv test_results.txt _DEL/)", + "Bash(mv dev-trace-coverage-working.png _DEL/)", + "Bash(mv dev-trace-page.png _DEL/)", + "Bash(mv export/pytest_result.txt _DEL/export/)", + "Bash(mv export/test_auth_results.txt _DEL/export/)", + "Bash(mv export/p13_test_result.txt _DEL/export/)", + "Bash(mv export/p13_result.txt _DEL/export/)", + "Bash(cp -r tmp _DEL/tmp_backup)", + "Bash(rm -rf tmp/*)", + "Bash(touch tmp/.gitkeep)", + "Bash(ls -la c:/NeoZQYY/docs/audit/session_logs/_session_index*.json)" + ], "additionalDirectories": [ "C:\\Users\\Administrator\\.claude", "c:\\NeoZQYY\\.git" diff --git a/.env.template b/.env.template index 2dda883..0fcb016 100644 --- a/.env.template +++ b/.env.template @@ -101,27 +101,35 @@ SYSTEM_LOG_ROOT=C:/NeoZQYY/export/SYSTEM/LOGS # 后端输出路径 # ------------------------------------------------------------------------------ BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/LOGS +# 用户头像存储目录(chooseAvatar 上传后保存到此目录,文件名 {user_id}.jpg) +AVATAR_EXPORT_PATH=C:/NeoZQYY/export/BACKEND/avatars # ------------------------------------------------------------------------------ -# 阿里云百炼 AI 配置 +# DashScope AI 配置(百炼 Application API) # ------------------------------------------------------------------------------ -BAILIAN_API_KEY= -BAILIAN_MODEL=qwen-plus -BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 -BAILIAN_TEST_APP_ID= +DASHSCOPE_API_KEY= +DASHSCOPE_WORKSPACE_ID= -# 8 个百炼 AI 应用 ID(从百炼平台获取) +# 8 个百炼 AI 应用 ID(从百炼平台获取,通过 app_id 指定应用) # 应用 1:通用对话 | 应用 2:财务洞察 | 应用 3:客户数据维客线索分析 # 应用 4:关系分析/任务建议 | 应用 5:话术参考 | 应用 6:备注分析 # 应用 7:客户分析 | 应用 8:维客线索整理 -BAILIAN_APP_ID_1_CHAT= -BAILIAN_APP_ID_2_FINANCE= -BAILIAN_APP_ID_3_CLUE= -BAILIAN_APP_ID_4_ANALYSIS= -BAILIAN_APP_ID_5_TACTICS= -BAILIAN_APP_ID_6_NOTE= -BAILIAN_APP_ID_7_CUSTOMER= -BAILIAN_APP_ID_8_CONSOLIDATE= +DASHSCOPE_APP_ID_1_CHAT= +DASHSCOPE_APP_ID_2_FINANCE= +DASHSCOPE_APP_ID_3_CLUE= +DASHSCOPE_APP_ID_4_ANALYSIS= +DASHSCOPE_APP_ID_5_TACTICS= +DASHSCOPE_APP_ID_6_NOTE= +DASHSCOPE_APP_ID_7_CUSTOMER= +DASHSCOPE_APP_ID_8_CONSOLIDATE= +# 应用 9:Session 日志摘要生成(Kiro agent_on_stop + batch_generate_summaries 使用) +DASHSCOPE_APP_ID_SUMMARY= + +# 内部 API 认证 token(ETL 等内部服务调用 /api/internal/* 端点时使用) +INTERNAL_API_TOKEN= + +# 后端 API 地址(ETL 触发 AI 事件时使用,如 http://localhost:8000) +BACKEND_API_URL= # ------------------------------------------------------------------------------ # 管道限流配置(RateLimiter 请求间隔,秒) @@ -267,7 +275,7 @@ DWD_FACT_UPSERT=true RUN_TASKS=PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,LEDGER # RUN_DWS_TASKS= # RUN_INDEX_TASKS= -INDEX_LOOKBACK_DAYS=60 +INDEX_LOOKBACK_DAYS=90 # ------------------------------------------------------------------------------ # DWS 月度/薪资配置 @@ -340,4 +348,12 @@ ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe # 运维面板服务器根目录 # CHANGE 2026-03-06 | 必须显式设置,消除 __file__ 推算风险 # ------------------------------------------------------------------------------ -OPS_SERVER_BASE=C:/NeoZQYY \ No newline at end of file +OPS_SERVER_BASE=C:/NeoZQYY + +# === Dev Trace Log === +# 全链路请求追踪日志(仅开发/测试环境使用,生产环境关闭) +DEV_TRACE_ENABLED=true +DEV_TRACE_LOG_DIR=export/dev-trace-logs +DEV_TRACE_LOG_RETENTION_DAYS=7 +DEV_TRACE_LOG_SQL=true +DEV_TRACE_LOG_PARAMS=true diff --git a/.gitignore b/.gitignore index e1ead13..4f83602 100644 --- a/.gitignore +++ b/.gitignore @@ -71,8 +71,6 @@ infra/**/*.secret *.swp *.swo *~ -.specstory/ -.cursorindexingignore # ===== Claude Code 本地配置 ===== .claude/settings.local.json @@ -81,12 +79,8 @@ infra/**/*.secret *.lnk .Deleted/ -# ===== Kiro 运行时状态 ===== -.kiro/state/ +# ===== 归档目录(用户定期手动清理) ===== +_DEL/ # ===== 运维脚本运行时状态 ===== scripts/ops/.monitor_token - - -# ===== Kiro Powers(含敏感 DSN) ===== -powers/ diff --git a/.kiro/steering/agent-behavior.md b/.kiro/steering/agent-behavior.md deleted file mode 100644 index 3385b5d..0000000 --- a/.kiro/steering/agent-behavior.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -inclusion: always ---- -# AI 执行行为约束 - -## 上下文保护 -目标:避免大量文件内容或命令输出涌入主对话,导致上下文爆炸。 - -### 委托子代理的场景 -- 批量文件读取(≥3 个文件)或大范围代码搜索 -- 需要探索不熟悉的模块/目录结构 -- CLI 命令输出量大或需要多步骤 shell 操作 - -### 主流程直接处理的场景 -- 读取单个已知文件(路径明确、内容可预期) -- 简单的单条命令(如 `uv sync`、单个 pytest 文件) -- 小范围精确搜索(已知关键词和文件范围) diff --git a/.kiro/steering/cli-env.md b/.kiro/steering/cli-env.md deleted file mode 100644 index 2ff94cc..0000000 --- a/.kiro/steering/cli-env.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -inclusion: always ---- -# CLI 环境规范(Windows PowerShell) - -本项目运行在 Windows + PowerShell 环境。以下是构造命令时必须掌握的前置知识。 - -## PowerShell 语法要点 -- 环境变量:`$env:VAR_NAME`(不是 `$VAR_NAME`) -- 命令连接符:`;`(不是 `&&`) -- `where` 是 `Where-Object` 别名,查可执行文件用 `Get-Command ` -- 删除文件/目录:`Remove-Item`(不是 `rm -rf`) -- 路径分隔符 `\`,但 Python/Node 工具也接受 `/` - -## Python 调用 -- 项目虚拟环境:`uv run python` 或 `.venv\Scripts\python.exe` -- 安装依赖:`uv sync`(不是 `pip install`) -- 运行模块:`uv run python -m ` -- 系统 Python:`python`(来自 miniconda3) - -## Shell 隔离与 REPL 防护(强制) - -> 背景:Kiro 的 `executePwsh` 复用同一个 shell session。若意外进入 Python REPL,后续所有命令被吞掉,表现为"无输出 + exit code 0"。 - -### 核心原则 -- 禁止裸调 `python`/`node`/`ipython`——必须带 `-c`、`-m` 或脚本路径 -- 优先写脚本文件再执行,避免 `-c` 内联引号地狱 -- 禁止管道喂给 python(如 `echo "code" | python`) - -### 长时间命令 -- 预估 > 30s 的命令,提前告知用户 -- pytest hypothesis 建议设 `timeout` 为预估时间 3 倍 -- 超时后不要立即重试,先确认前一个进程状态 - -### REPL 劫持 -症状:exit code 0 但无输出、连续命令无输出、出现 `>>>` 提示符。检测与自动恢复由 `repl-hijack-guard` hook 在命令执行后自动处理。 - -> cwd 校验和命令语法检查由 `cwd-guard-shell` hook 在执行前自动拦截,此处不再重复。 diff --git a/.kiro/steering/db-docs.md b/.kiro/steering/db-docs.md deleted file mode 100644 index e25c9b1..0000000 --- a/.kiro/steering/db-docs.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: - - "**/migrations/**/*.*" - - "**/*.sql" - - "**/*schema*.*" - - "**/*ddl*.*" - - "**/*.prisma" ---- - -# Database Schema Documentation Rules - -当你修改任何可能影响 PostgreSQL schema/表结构的内容时(迁移脚本/DDL/表定义/ORM 模型): - -1) 必须同步更新 BD 手册目录: - docs/database - -2) 文档最低要求: - - 变更说明:新增/修改/删除的表、字段、约束、索引 - - 兼容性:对 ETL、后端 API、小程序字段映射的影响 - - 回滚策略:如何撤销(DDL 回滚 / 数据回填) - - 验证步骤:最少包含 3 条校验 SQL \ No newline at end of file diff --git a/.kiro/steering/doc-map.md b/.kiro/steering/doc-map.md deleted file mode 100644 index 838214d..0000000 --- a/.kiro/steering/doc-map.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -inclusion: manual -name: doc-map -description: 项目文档地图索引。需要定位文档、理解项目结构、查找规范时手动加载。 ---- - -# 文档地图索引 - -完整文档地图:`#[[file:docs/DOCUMENTATION-MAP.md]]` - -## 快速定位 - -| 需要什么 | 去哪里找 | -|---------|---------| -| DB 变更审计(业务库) | `docs/database/BD_Manual_*.md` | -| DB 变更审计(ETL 库) | `apps/etl/connectors/feiqiu/docs/database/` | -| API 端点参考 | `apps/backend/docs/API-REFERENCE.md` | -| ETL 任务说明 | `apps/etl/connectors/feiqiu/docs/etl_tasks/` | -| ETL 业务规则 | `apps/etl/connectors/feiqiu/docs/business-rules/` | -| 输出路径规范 | `docs/deployment/EXPORT-PATHS.md` | -| 上线检查清单 | `docs/deployment/LAUNCH-CHECKLIST.md` | -| 变更审计记录 | `docs/audit/changes/` | -| PRD / Spec 拆分 | `docs/prd/specs/` | -| 小程序 UI 原型 | `docs/h5_ui/pages/` | -| 迁移脚本 | `db/etl_feiqiu/migrations/` + `db/zqyy_app/migrations/` | -| DDL 基线 | `docs/database/ddl/` | -| 模块 README | 各 `apps/*/README.md` + `packages/shared/README.md` + `db/README.md` | -| Kiro Spec | `.kiro/specs//` (requirements + design + tasks) | - -## 行为规范提示 - -- 新增 DB 表/字段 → 必须写 `BD_Manual_*.md`(见 `db-docs.md` steering) -- 新增输出路径 → 先加 `.env` 变量,再更新 `EXPORT-PATHS.md`(见 `export-paths.md` steering) -- 逻辑改动 → 审计由 hooks 自动检测提醒,按需触发 `/audit` -- 新增/修改 API → 同步更新 `API-REFERENCE.md` -- 新增 ETL 任务 → 同步更新 `docs/etl_tasks/` 对应文档 diff --git a/.kiro/steering/dwd-doc-authority.md b/.kiro/steering/dwd-doc-authority.md deleted file mode 100644 index 7183b5d..0000000 --- a/.kiro/steering/dwd-doc-authority.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: "**/tasks/**,**/loaders/**,**/scd/**,**/dws/**,**/dwd/**,**/quality/**,**/business-rules/**,**/schemas/**,**/routers/**,**/financial*,**/settlement*,**/consume*,**/accounting*,**/salary*,**/assistant*,**/member*,**/index*,**/winback*,**/newconv*,**/relation_index*,**/spending*,**/stock*,**/finance_*,**/income_*,**/discount_*,**/order_contribution*,**/cfg_*,**/orchestration/**,**/config/**" -name: dwd-doc-authority -description: DWD-DOC 标杆文档强制规则。涉及 ETL 任务/财务/结算/消费/助教/会员/指数/统计/配置相关文件时自动加载。 ---- - -# DWD-DOC 标杆文档(权威数据源,强制优先参考) - -`docs/reports/DWD-DOC/` 是本项目的业务模型与财务数据权威标杆文档。 -所有涉及金额口径、支付渠道、消费链路、账务公式、字段语义的开发工作,必须以此目录为第一参考源。 - -## 文档清单 - -| 文件 | 内容 | 关键规则 | -|------|------|----------| -| `README.md` | 总览 + GAP 闭环状态 | 文档索引入口 | -| `01-business-panorama.md` | 消费链路 + 优惠机制 + 消费场景 | settle_type 枚举、助教费用拆分、团购券三层价格 | -| `02-accounting-panorama.md` | 支付渠道 + 对账公式 + consume_money 口径 | 支付渠道恒等式、F2 三期公式 | -| `03-financial-panorama.md` | 收入构成 + 储值卡资金流 + 对账矩阵 | 平台结算互斥关系 | -| `04-dimension-panorama.md` | 维度表与主数据全景 | SCD2 维度取值规则 | -| `05-f2-balance-audit.md` | F2 收支平衡公式专项 | 三期公式 + 139 笔失败根因 | -| `06-calibration-checklist.md` | 校准清单 + 验证 SQL | 全部验证公式集中 | -| `consume/consume-money-caliber.md` | consume_money 口径变化时间线 | 三种口径(A/B/C)定义与切换时间点 | - -## 强制规则(所有 session 生效) - -1. **consume_money 禁止直接用于计算**:存在三种历史口径(A/B/C)混合,DWS 层及下游统一使用 `items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money` -2. **助教费用必须拆分**:使用 `assistant_pd_money`(陪打)和 `assistant_cx_money`(超休),禁止使用笼统的 `service_fee` / `ASSISTANT_BASE` / `ASSISTANT_BONUS`(`service_fee` 仅在平台结算表中表示"平台服务费",语义不同) -3. **支付渠道恒等式**:`balance_amount = recharge_card_amount + gift_card_amount`(100% 成立),三者不可重复计算 -4. **settle_type 过滤**:正向交易取 `IN (1, 3)`,本表无 `is_delete` 字段 -5. **电费未启用**:`electricity_money` 全为 0,`gross_amount` 不含电费是正确的 -6. **折扣互斥**:`discount_manual`(大客户优惠)与 `discount_other` 互斥,两者之和 = `adjust_amount` -7. **现金流互斥**:`cash_inflow_total` 中 `platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥 -8. **废单判断**:使用 `dwd_assistant_service_log_ex.is_trash`,`dwd_assistant_trash_event` 已废弃(2026-02-22 DROP) -9. **储值卡字段命名**:DWS 层使用 `balance_pay`(总额)、`recharge_card_pay`(现金充值卡)、`gift_card_pay`(赠送卡);`recharge_card_consume`(财务日报) -10. **会员字段断档(DQ-6)**:`settlement_head.member_phone/member_name` 自 2025-12 起全为 NULL。需要会员信息时通过 `member_id` LEFT JOIN `dwd.dim_member`(取 `scd2_is_current=1`) -11. **会员卡字段断档(DQ-7)**:`settlement_head.member_card_type_name` 自 2025-07-21 起全为 NULL。需要会员卡类型时通过 `member_id` LEFT JOIN `dwd.dim_member_card_account`(取 `scd2_is_current=1`)。通用规则:结算单上所有会员相关冗余字段均不可靠,一律通过 ID 关联维度表获取 - -## 与其他文档的优先级 - -当 BD 手册、ETL 任务文档、业务规则文档、SPEC 文档、DDL 注释与 DWD-DOC 冲突时,以 DWD-DOC 为准。 - -> 标杆文档基于 2026-03-06 对 test_etl_feiqiu 数据库的实际数据验证,公式和比例关系具有权威性。 diff --git a/.kiro/steering/dws-doc-authority.md b/.kiro/steering/dws-doc-authority.md deleted file mode 100644 index e2fbcf9..0000000 --- a/.kiro/steering/dws-doc-authority.md +++ /dev/null @@ -1,279 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: "**/tasks/**,**/loaders/**,**/scd/**,**/dws/**,**/dwd/**,**/quality/**,**/business-rules/**,**/schemas/**,**/routers/**,**/financial*,**/settlement*,**/consume*,**/accounting*,**/salary*,**/assistant*,**/member*,**/index*,**/winback*,**/newconv*,**/relation_index*,**/spending*,**/stock*,**/finance_*,**/income_*,**/discount_*,**/order_contribution*,**/cfg_*,**/orchestration/**,**/config/**" -name: dws-doc-authority -description: DWS 层权威规范。涉及 ETL 任务/财务/结算/消费/助教/会员/指数/统计/配置相关文件时自动加载。 ---- - -# DWS 层权威规范(强制优先参考) - -DWS(Data Warehouse Summary)层从 DWD 明细层按业务维度聚合计算,输出汇总统计表,服务于助教业绩、会员分析、财务统计、指数算法等业务场景。 - -> DWD-DOC(`docs/reports/DWD-DOC/`)中的强制规则在 DWS 层同样生效,本文档不重复列出。两者冲突时以 DWD-DOC 为准。 - -## 一、任务体系(19 个已注册任务) - -### 1.1 助教业绩域(6 个) - -| 任务代码 | 目标表 | 粒度 | 核心指标 | -|----------|--------|------|----------| -| `DWS_ASSISTANT_DAILY` | `dws_assistant_daily_detail` | 日期+助教 | 服务次数/时长/金额、去重客户数、废除统计、惩罚检测 | -| `DWS_ASSISTANT_MONTHLY` | `dws_assistant_monthly_summary` | 月份+助教 | 月度累计、有效业绩、档位匹配、排名(考虑并列) | -| `DWS_ASSISTANT_CUSTOMER` | `dws_assistant_customer_stats` | 日期+助教+会员 | 全量累计、6 个滚动窗口(7/10/15/30/60/90 天)、活跃度 | -| `DWS_ASSISTANT_SALARY` | `dws_assistant_salary_calc` | 月份+助教 | 课时收入、奖金明细、应发工资、假期 | -| `DWS_ASSISTANT_FINANCE` | `dws_assistant_finance_analysis` | 日期+助教 | 日度收入、日均成本、毛利润、毛利率 | -| `DWS_ASSISTANT_ORDER_CONTRIBUTION` | `dws_assistant_order_contribution` | 日期+助教 | 订单总流水、净流水、时效贡献、时效净贡献 | - -### 1.2 会员分析域(2 个) - -| 任务代码 | 目标表 | 粒度 | 核心指标 | -|----------|--------|------|----------| -| `DWS_MEMBER_CONSUMPTION` | `dws_member_consumption_summary` | 日期+会员 | 全量累计消费、6 个滚动窗口、卡余额、活跃度、客户分层 | -| `DWS_MEMBER_VISIT` | `dws_member_visit_detail` | 日期+会员+结账单 | 消费金额拆分、支付方式拆分、台桌时长、助教服务明细(JSON) | - -### 1.3 财务统计域(4 个) - -| 任务代码 | 目标表 | 粒度 | 核心指标 | -|----------|--------|------|----------| -| `DWS_FINANCE_DAILY` | `dws_finance_daily_summary` | 日期 | 发生额、优惠合计、确认收入、现金流入/流出/净变动、卡消费、充值统计 | -| `DWS_FINANCE_RECHARGE` | `dws_finance_recharge_summary` | 日期 | 充值笔数/总额、首充/续充拆分、去重会员数、全店卡余额快照、赠送卡按卡类型拆分(酒水卡/台费卡/抵用券 × 余额+新增) | -| `DWS_FINANCE_INCOME_STRUCTURE` | `dws_finance_income_structure` | 日期+收入类型 | 按收入类型(台费/商品/助教基础课/附加课)和区域分析 | -| `DWS_FINANCE_DISCOUNT_DETAIL` | `dws_finance_discount_detail` | 日期+折扣类型 | 折扣类型拆分(GROUPBUY/VIP/ROUNDING/GIFT_CARD_*/BIG_CUSTOMER/OTHER) | - -### 1.4 库存汇总域(3 个) - -| 任务代码 | 目标表 | 粒度 | 更新策略 | -|----------|--------|------|----------| -| `DWS_GOODS_STOCK_DAILY` | `dws_goods_stock_daily_summary` | 日期+商品 | upsert | -| `DWS_GOODS_STOCK_WEEKLY` | `dws_goods_stock_weekly_summary` | ISO 周+商品 | upsert | -| `DWS_GOODS_STOCK_MONTHLY` | `dws_goods_stock_monthly_summary` | 月份+商品 | upsert | - -### 1.5 运维任务(2 个) - -| 任务代码 | 说明 | -|----------|------| -| `DWS_BUILD_ORDER_SUMMARY` | 构建订单汇总中间表 `dws_order_summary` | -| `DWS_MAINTENANCE` | 统一维护:物化视图刷新 + 历史数据清理 | - - -## 二、强制规则(所有 session 生效) - -### 2.1 幂等更新策略 -1. **汇总表默认 delete-before-insert**:按日期范围 + `site_id` 先删后插,保证幂等 -2. **库存表使用 upsert**:`ON CONFLICT DO UPDATE`,因库存快照需保留最新值 -3. **禁止 TRUNCATE**:DWS 表数据量大,TRUNCATE 会导致全表锁定 - -### 2.2 课程类型与定价 -4. **课程类型通过 `cfg_skill_type` 映射**:`skill_id` → `course_type_code`(BASE/BONUS/ROOM),禁止硬编码 skill_id 判断课程类型 -5. **定价通过 `cfg_assistant_level_price` 取值**:按 SCD2 生效期 as-of join,禁止硬编码价格 -6. **包厢课统一价格**:`dws.salary.room_course_price = 138`(元/小时),从配置读取 - -### 2.3 绩效档位与工资 -7. **绩效档位通过 `cfg_performance_tier` 取值**:按有效业绩小时数匹配 `[min_hours, max_hours)` 区间 -8. **新入职折算规则**:入职日期在当月 1 日后视为新入职,按日均业绩 × 30 定档;入职日期 > 25 日最高定档至 T2 -9. **奖金规则通过 `cfg_bonus_rules` 取值**:SPRINT 类型不累计取最高档,TOP_RANK 类型按排名发放(第 1 名 1000 元、第 2 名 600 元、第 3 名 400 元) -10. **排名计算考虑并列**:使用 `calculate_rank_with_ties()`,相同业绩小时数并列同名次 - -### 2.4 会员与散客 -11. **散客判断**:`member_id ≤ 0` 为散客,不计入会员统计(但计入助教业绩) -12. **客户分层规则**:高价值(90 天 ≥ 3 次且 ≥ 1000 元)→ 中等(30 天内有消费)→ 低活跃(90 天内有但 30 天内无)→ 流失 -13. **会员信息一律通过 ID 关联维度表**:结算单上所有会员冗余字段均不可靠(DQ-6/DQ-7),通过 `member_id` LEFT JOIN `dwd.dim_member`(`scd2_is_current=1`) - -### 2.5 时间窗口与调度 -14. **滚动窗口标准集**:7/10/15/30/60/90 天,使用 `calculate_rolling_stats()` 统一计算 -15. **月度任务宽限期**:月初前 `dws.monthly.prev_month_grace_days`(默认 5)天可处理上月数据 -16. **工资计算周期**:月初前 `dws.salary.run_days`(默认 5)天运行,超期需 `dws.salary.allow_out_of_cycle = true` - -### 2.6 SCD2 维度取值 -17. **助教等级 as-of 取值**:工资计算按月份生效期取历史版本,日度统计按 `stat_date` 取当日版本 -18. **会员卡余额 as-of 取值**:通过 `get_member_card_balance_asof()` 按日期取快照 - -### 2.7 台桌分类 -19. **`cfg_area_category` 仅精确匹配 + 兜底**:2026-03-07 改版后无 LIKE 匹配,分类为 BILLIARD/SNOOKER/OTHER,`BILLIARD_VIP` 已废弃 - -## 三、指数算法体系 - -### 3.1 总览 - -| 指数 | 全称 | 输出表 | 作用 | -|------|------|--------|------| -| WBI | Winback Index | `dws_member_winback_index` | 老客挽回优先级 | -| NCI | Newconv Index | `dws_member_newconv_index` | 新客转化优先级 | -| RS | Relation Index | `dws_member_assistant_relation_index` | 助教-会员关系强度 | -| OS | Ownership Index | — | 所有权指数 | -| MS | Maintenance Score | — | 维护分 | -| ML | Manual Ledger | `dws_ml_manual_order_alloc` | 人工台账(唯一真源) | -| SPI | Spending Power Index | `dws_member_spending_power_index` | 消费力指数 | - -### 3.2 WBI(老客挽回指数)强制规则 -20. **分项得分**:Overdue(超期分,加权经验 CDF)+ Drop(降频分,近 14 天差值)+ Recharge(充值压力,衰减分)+ Value(价值分,对数压缩) -21. **Raw Score 公式**:`WBI_raw = w_over × overdue + w_drop × drop + w_re × recharge + w_value × value` -22. **近访抑制(Recency Suppression)**:距今 < 14 天 suppression = 0(Hard floor);14-17 天 Sigmoid 衰减 -23. **分流规则**:STOP(距今 ≥ 60 天,高余额例外可选)→ NEW(到店 ≤ 2 次或首访 ≤ 30 天或充值未回访)→ OLD(其他) - -### 3.3 NCI(新客转化指数)强制规则 -24. **分项得分**:Welcome(欢迎分,首访/单访 3 天内触发)+ Need(转化紧迫度)+ Salvage(可救度,30-60 天线性衰减)+ Recharge/Value(同 WBI) -25. **活跃抑制**:新客近 14 天来店 ≥ 2 次且最近活跃,用 0.2 系数抑制转化召回分 - -### 3.4 指数参数配置 -26. **参数通过 `cfg_index_parameters` 加载**:按 `index_type` 分组,支持 EWMA 平滑,禁止硬编码权重/阈值 - - -## 四、配置表体系 - -### 4.1 绩效档位(`dws.cfg_performance_tier`) - -| 档位 | 小时区间 | 抽成(元/小时) | 打赏抽成 | 假期 | -|------|----------|-----------------|----------|------| -| T0(0 档) | 0-120 | 28 | 50% | 3 天 | -| T1(1 档) | 120-150 | 18 | 40% | 4 天 | -| T2(2 档) | 150-180 | 13 | 35% | 5 天 | -| T3(3 档) | 180-210 | 10 | 30% | 6 天 | -| T4(4 档) | 210+ | 8 | 25% | 自由假期 | - -> 以上为 2026-03-01 起生效版本,历史版本通过 `effective_from/effective_to` SCD2 管理。 - -### 4.2 助教等级定价(`dws.cfg_assistant_level_price`) - -| 等级 | 基础课(元/小时) | 附加课(元/小时) | -|------|-------------------|-------------------| -| 8(助教管理) | 98 | 190 | -| 10(初级) | 98 | 190 | -| 20(中级) | 108 | 190 | -| 30(高级) | 118 | 190 | -| 40(星级) | 138 | 190 | - -### 4.3 奖金规则(`dws.cfg_bonus_rules`) - -| 规则类型 | 生效期 | 说明 | -|----------|--------|------| -| SPRINT(冲刺奖金) | ≤ 2026-02-28 | 不累计,取最高档 | -| TOP_RANK(排名奖金) | ≥ 2026-03-01 | 第 1 名 1000 元、第 2 名 600 元、第 3 名 400 元 | - -### 4.4 技能→课程类型映射(`dws.cfg_skill_type`) - -| 课程类型代码 | 名称 | 定价规则 | -|-------------|------|----------| -| BASE | 基础课(陪打/PD) | 按等级定价 98-138 元/小时 | -| BONUS | 附加课(超休/CX) | 固定 190 元/小时 | -| ROOM | 包厢课 | 统一 138 元/小时(`dws.salary.room_course_price`) | - -### 4.5 台桌分类(`dws.cfg_area_category`) - -| 分类代码 | 说明 | 备注 | -|----------|------|------| -| BILLIARD | 台球(含原 V1-V4) | 2026-03-07 改版 | -| SNOOKER | 斯诺克(含原 V5) | 2026-03-07 改版 | -| OTHER | 兜底 | 未匹配时归入 | - -> `BILLIARD_VIP` 已废弃(2026-03-07),禁止引用。 - -### 4.6 指数参数(`dws.cfg_index_parameters`) - -按 `index_type`(WBI/NCI/RS/OS/MS/ML/SPI)分组加载,支持 EWMA 平滑。所有权重和阈值从此表读取,禁止硬编码。 - -## 五、BaseDwsTask 公共机制 - -### 5.1 时间分层(TimeLayer) - -| 枚举值 | 范围 | 用途 | -|--------|------|------| -| LAST_2_DAYS | 近 2 天 | 日度增量 | -| LAST_1_MONTH | 近 30 天 | 月度汇总 | -| LAST_3_MONTHS | 近 90 天 | 季度分析 | -| LAST_6_MONTHS | 近 6 个月(不含本月) | 半年趋势 | -| ALL | 从 2000-01-01 起 | 全量重算 | - -### 5.2 配置缓存(ConfigCache) -- 类级别共享,TTL 5 分钟 -- 包含:绩效档位、等级定价、奖金规则、区域分类、技能类型 -- 支持 SCD2 生效期过滤 - -### 5.3 数据读写方法 -- `iter_dwd_rows()`:分批迭代 DWD 数据(默认 1000 行/批) -- `query_dwd()`:直接执行任意 SQL -- `delete_existing_data()`:按日期范围 + site_id 删除 -- `bulk_insert()`:批量插入 -- `upsert()`:ON CONFLICT DO UPDATE - -### 5.4 辅助计算 -- `calculate_rolling_stats()`:滚动窗口统计 -- `calculate_rank_with_ties()`:并列排名 -- `is_new_hire_in_month()`:新入职判断 -- `is_guest()`:散客判断(member_id ≤ 0) -- `safe_decimal()` / `safe_int()`:安全类型转换 -- `seconds_to_hours()` / `hours_to_seconds()`:时间单位转换 -- `get_assistant_level_asof()`:SCD2 助教等级 -- `get_member_card_balance_asof()`:SCD2 会员卡余额 - -## 六、字段命名规范 - -### 6.1 金额字段 -- 统一 `NUMERIC(12,2)`,货币单位 CNY -- 储值卡:DWS 层使用 `balance_pay`(总额)、`recharge_card_pay`(现金充值卡)、`gift_card_pay`(赠送卡) -- 财务日报:使用 `recharge_card_consume` -- 助教费用:`assistant_pd_money`(陪打)、`assistant_cx_money`(超休),禁止使用 `service_fee` - -### 6.2 时间字段 -- `stat_date`:统计日期(DATE) -- `stat_month`:统计月份(CHAR(7),格式 YYYY-MM) -- `created_at` / `updated_at`:TIMESTAMPTZ - -### 6.3 标识字段 -- `site_id`:门店 ID(多门店隔离,RLS) -- `tenant_id`:租户 ID -- `member_id`:会员 ID(≤ 0 为散客) -- `assistant_id`:助教 ID - -## 七、调度与 Flow 类型 - -| Flow 类型 | 包含阶段 | 说明 | -|-----------|----------|------| -| `dwd_dws` | 仅 DWS 汇总 | 日常增量 | -| `dwd_dws_index` | DWS 汇总 + 指数计算 | 含指数更新 | -| `api_full` | ODS → DWD → DWS → INDEX | 全流程 | - -处理模式:`increment_only`(默认)、`verify_only`(仅校验修复)、`increment_verify`(先增量后校验) - -## 八、DWS 层完整表清单 - -### 汇总表 -`dws_assistant_daily_detail`、`dws_assistant_monthly_summary`、`dws_assistant_customer_stats`、`dws_assistant_salary_calc`、`dws_assistant_finance_analysis`、`dws_assistant_order_contribution`、`dws_member_consumption_summary`、`dws_member_visit_detail`、`dws_finance_daily_summary`、`dws_finance_recharge_summary`、`dws_finance_income_structure`、`dws_finance_discount_detail`、`dws_goods_stock_daily_summary`、`dws_goods_stock_weekly_summary`、`dws_goods_stock_monthly_summary`、`dws_order_summary` - -### 指数表 -`dws_member_winback_index`、`dws_member_newconv_index`、`dws_member_assistant_relation_index`、`dws_member_assistant_intimacy`、`dws_member_spending_power_index`、`dws_index_percentile_history` - -### 其他表 -`dws_platform_settlement`、`dws_ml_manual_order_source`、`dws_ml_manual_order_alloc`、`dws_assistant_recharge_commission`、`dws_assistant_project_tag`、`dws_member_project_tag` - -### 视图 -`v_member_recall_priority` - -### 配置表 -`cfg_performance_tier`、`cfg_assistant_level_price`、`cfg_bonus_rules`、`cfg_skill_type`、`cfg_area_category`、`cfg_index_parameters` - -## 九、废弃对象(禁止引用) - -| 对象 | 删除日期 | 替代方案 | -|------|----------|----------| -| `BILLIARD_VIP` 分类代码 | 2026-03-07 | V1-V4 归入 BILLIARD,V5 归入 SNOOKER | -| `dwd_assistant_trash_event` | 2026-02-22 | `dwd_assistant_service_log_ex.is_trash` | -| `RecallIndexTask` / `IntimacyIndexTask` | 2026-02-13 | WBI + NCI + RelationIndexTask | -| SPRINT 奖金规则 | 2026-02-28 止 | TOP_RANK 排名奖金(2026-03-01 起) | - -## 十、关键文档索引 - -| 文档 | 路径 | -|------|------| -| DWS 任务详解 | `apps/etl/connectors/feiqiu/docs/etl_tasks/dws_tasks.md` | -| DWS 指标定义 | `apps/etl/connectors/feiqiu/docs/business-rules/dws_metrics.md` | -| 指数算法说明 | `apps/etl/connectors/feiqiu/docs/business-rules/index_algorithm_cn.md` | -| BaseDwsTask 机制 | `apps/etl/connectors/feiqiu/docs/etl_tasks/base_task_mechanism.md` | -| BD 手册(DWS 表) | `apps/etl/connectors/feiqiu/docs/database/DWS/main/` | -| DWD-DOC 权威规则 | `.kiro/steering/dwd-doc-authority.md` | - -## 与其他文档的优先级 - -DWS 层开发时的参考优先级:DWD-DOC > 本文档 > BD 手册 > ETL 任务文档 > 业务规则文档 > DDL 注释。 - -> 本文档基于 2026-03-19 对项目代码、配置表、BD 手册和审计记录的全面收集整理。 diff --git a/.kiro/steering/export-paths-full.md b/.kiro/steering/export-paths-full.md deleted file mode 100644 index 452a9fc..0000000 --- a/.kiro/steering/export-paths-full.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: "**/.env*,**/scripts/**,**/export/**,**/EXPORT-PATHS*" -name: export-paths-full -description: 输出路径完整规范(目录结构、环境变量映射、检查清单)。读到 .env / scripts / export 文件时自动加载。 ---- - -# 输出路径完整规范 - -## 目录结构与环境变量 - -``` -export/ -├── ETL-Connectors/feiqiu/ -│ ├── JSON/ — EXPORT_ROOT / FETCH_ROOT -│ ├── LOGS/ — LOG_ROOT -│ └── REPORTS/ — ETL_REPORT_ROOT -├── SYSTEM/ -│ ├── LOGS/ — SYSTEM_LOG_ROOT -│ ├── REPORTS/ -│ │ ├── dataflow_analysis/ — SYSTEM_ANALYZE_ROOT -│ │ ├── field_audit/ — FIELD_AUDIT_ROOT -│ │ └── full_dataflow_doc/ — FULL_DATAFLOW_DOC_ROOT -│ └── CACHE/ -│ └── api_samples/ — API_SAMPLE_CACHE_ROOT -└── BACKEND/ - └── LOGS/ — BACKEND_LOG_ROOT -``` - -## 路径读取方式详细 -- `scripts/ops/` 脚本:通过 `_env_paths.get_output_path("变量名")` 读取(内部自动 `load_dotenv`) -- ETL 核心模块:通过 `env_parser.py` → `AppConfig` 的 `io.*` 配置节读取 -- ETL 独立脚本:通过 `os.environ.get("ETL_REPORT_ROOT")` 读取,缺失时抛错 -- 后端:通过 `os.environ.get("BACKEND_LOG_ROOT")` 读取 - -## 新增输出场景的检查清单 - -当任何操作需要写入文件时,按以下顺序确认: -1. 该输出是否已有对应的环境变量?→ 直接使用 -2. 是否属于现有目录分类(ETL/SYSTEM/BACKEND)?→ 使用对应父目录变量 + 子路径 -3. 都不匹配?→ 在 `export/` 下新建合理子目录,新增环境变量,更新 `.env` / `.env.template` / `EXPORT-PATHS.md` - -## 共享工具 -- `scripts/ops/_env_paths.py`:提供 `get_output_path(env_var)` 函数,自动 `load_dotenv` + 读取 + 建目录 + 缺失报错 - -## 参考文档 -- 完整目录说明:`docs/deployment/EXPORT-PATHS.md` -- 环境变量定义:根 `.env` 的"统一输出路径配置"节 diff --git a/.kiro/steering/export-paths.md b/.kiro/steering/export-paths.md deleted file mode 100644 index f8e21bc..0000000 --- a/.kiro/steering/export-paths.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -inclusion: always ---- -# 产出物路径规范(强制) - -## 一、程序输出 → `export/` 目录 -路径从 `.env` 环境变量读取。禁止硬编码路径,禁止在 `export/` 外创建输出目录。 -- 环境变量缺失时必须报错,禁止静默回退 -- 读取方式:`scripts/ops/` → `_env_paths.get_output_path()`;ETL → `AppConfig.io.*`;独立脚本 → `os.environ.get()` + 显式报错 -- 新增输出类型:先在 `.env` + `.env.template` 加变量,再更新 `docs/deployment/EXPORT-PATHS.md` - -> 完整目录结构与映射表见 `export-paths-full.md`(fileMatch 自动加载)。 - -## 二、文档产出 → `docs/` 对应子目录 -禁止在 `docs/` 根目录散放文件(`README.md` 和 `DOCUMENTATION-MAP.md` 除外)。 - -常用归档路径:分析报告 → `docs/reports/`,架构 → `docs/architecture/`,BD 手册 → `docs/database/`(业务库)或 `apps/etl/.../docs/database/`(ETL),审计 → `docs/audit/changes/`,PRD → `docs/prd/specs/`,部署 → `docs/deployment/`。 - -> 完整归档规则表见 `doc-map.md`(手动加载)或 `docs/DOCUMENTATION-MAP.md`。 diff --git a/.kiro/steering/feiqiu-data-rules.md b/.kiro/steering/feiqiu-data-rules.md deleted file mode 100644 index 0bc3ad8..0000000 --- a/.kiro/steering/feiqiu-data-rules.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -inclusion: always ---- -# 飞球数据规范(入口索引) - -涉及财务、结算、助教、会员、统计、指数、工资、任务调度、DWD/DWS 层开发时,必须参考以下两份权威文档(fileMatch 自动加载,也可手动引用): - -- `dwd-doc-authority.md` — DWD 层 11 条强制规则(consume_money 口径、支付恒等式、会员字段断档等) -- `dws-doc-authority.md` — DWS 层 26 条强制规则(幂等策略、课程定价、绩效档位、指数算法、配置表体系等) -- `docs/database/BD_Manual_fdw_reverse_retention_clue.md` — FDW 反向映射手册(ETL 库通过 postgres_fdw 只读访问业务库 `member_retention_clue` 维客线索表) - -## 最高频硬规则速查(完整规则见上述文档) - -1. `consume_money` 禁止直接用于计算 → 用 `items_sum` 拆分字段 -2. 助教费用必须拆分:`assistant_pd_money`(陪打)+ `assistant_cx_money`(超休) -3. 支付恒等式:`balance_amount = recharge_card_amount + gift_card_amount` -4. 会员信息一律通过 `member_id` JOIN 维度表(`scd2_is_current=1`),结算单冗余字段不可靠 -5. 散客:`member_id ≤ 0` -6. 课程类型/定价/绩效档位/奖金/指数权重 → 全部从配置表读取,禁止硬编码 -7. DWS 汇总表默认 delete-before-insert,库存表用 upsert - -## 参考优先级 - -DWD-DOC > DWS 权威规范 > BD 手册 > ETL 任务文档 > 业务规则文档 > DDL 注释 diff --git a/.kiro/steering/language-zh.md b/.kiro/steering/language-zh.md deleted file mode 100644 index d1e368b..0000000 --- a/.kiro/steering/language-zh.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -inclusion: always ---- -# 语言规范 -- 说明性文字一律简体中文(对话、文档、注释、变更说明);代码标识符和第三方 CLI 原文保留英文 -- 文档与代码变更同步更新;注释只写"为什么/边界/假设" -- 全仓 UTF-8 无 BOM,禁止 GBK/Big5 混用 diff --git a/.kiro/steering/planning-interrogation.md b/.kiro/steering/planning-interrogation.md deleted file mode 100644 index 23dfebd..0000000 --- a/.kiro/steering/planning-interrogation.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -inclusion: always ---- -# 编码前需求审问(强制) - -AI 在用户清晰度结束的地方开始产生幻觉。因此,在写任何一行代码之前,必须通过持续提问来延伸用户的清晰度,找出思维中的 gaps,避免在破碎的基础上构建。 - -## 触发条件 -当用户提出涉及以下任一场景的需求时,进入「审问模式」: -- 新建功能/模块/页面/接口 -- 重构或重新设计已有逻辑 -- 涉及多模块联动的改动 -- 任何需求描述中存在模糊、隐含假设、或未定义边界的情况 - -## 强制流程 - -### 1. 进入 Planning 模式 -收到需求后,不立即动手,先进入提问循环。每轮提出 3-5 个针对性问题,直到所有维度都有明确答案。 - -### 2. 必问清单(最低要求) -以下问题必须逐一确认,不得假设答案: - -| 维度 | 必问问题 | -|------|----------| -| 用户 | 这是给谁用的?(角色/人群) | -| 核心行为 | 用户执行的核心操作是什么? | -| 完成后果 | 操作完成后发生什么?(跳转/提示/状态变化) | -| 数据写入 | 需要保存什么数据?保存到哪里? | -| 数据展示 | 需要展示什么数据?数据来源? | -| 错误处理 | 出错时发生什么?用户看到什么? | -| 成功反馈 | 成功时发生什么?用户看到什么? | -| 认证 | 需要登录/鉴权吗?什么权限级别? | -| 存储 | 需要数据库吗?哪个库?新表还是已有表? | -| 终端适配 | 需要在手机上工作吗?响应式要求? | -| 边界条件 | 并发/幂等/数据量上限/超时? | - -### 3. 追问规则 -- 用户回答后,如果答案引出新的未定义项,继续追问 -- 不接受"你看着办"作为最终答案——至少确认关键维度 -- 每轮追问聚焦于上一轮答案暴露的 gaps -- 当所有必问维度都有明确答案、且无新假设浮出时,才可结束审问 - -### 4. 输出需求确认摘要 -审问结束后,输出一份简洁的「需求确认摘要」,包含: -- 目标用户与场景 -- 核心功能描述(一句话) -- 数据流向(输入 → 处理 → 输出/存储) -- 关键约束与边界条件 -- 明确排除的内容(不做什么) - -用户确认摘要后,才可进入实施阶段。 - -## 与前置调研的关系 -- 本规则在 `pre-change-research.md`(前置调研)之前执行 -- 流程顺序:需求审问 → 用户确认 → 前置调研 → 用户确认 → 编码实施 -- 如果审问阶段发现需求本身不成立,直接终止,不进入调研 - -## 例外 -- 用户明确说"直接改"、"跳过审问"、"不用问了" -- Bug 修复且用户已给出明确的复现步骤和期望行为 -- 纯格式/文档/注释调整 -- 用户提供了完整的 spec 文档且所有维度已覆盖 diff --git a/.kiro/steering/pre-change-research.md b/.kiro/steering/pre-change-research.md deleted file mode 100644 index 991c7da..0000000 --- a/.kiro/steering/pre-change-research.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -inclusion: always ---- -# 逻辑改动前置调研(强制) - -任何涉及逻辑改动的任务(ETL 流程、业务规则、API 接口、数据模型、前端交互逻辑等),在写第一行代码之前,必须完成以下调研步骤: - -## 强制流程 - -### 1. 委托子代理调研(节省主流程上下文) -使用 `context-gatherer` 子代理执行调研,传入以下指令要点: -- 要改动的模块/文件路径 -- 搜索 `docs/audit/changes/` 中相关的历史审计记录 -- **查询 Session 索引**:读取 `docs/audit/session_logs/_session_index.json`,按 `summary.files_modified` 筛选涉及目标模块的历史 session,提取 `description`(操作摘要)和 `startTime`,了解该模块近期被修改的上下文和原因(详见 `docs/audit/SESSION-LOG-GUIDE.md`) -- 阅读涉及模块的 README、PRD spec(`docs/prd/specs/`) -- 数据库相关:BD 手册(`docs/database/BD_Manual_*.md` + `apps/etl/connectors/feiqiu/docs/database/`) -- ETL 相关:产品说明、数据流报告 -- 接口相关:OpenAPI spec、接口文档 -- 读取要修改的文件及其直接依赖(调用方、被调用方) -- 确认数据流向:上游输入 → 当前处理 → 下游消费 -- 识别潜在影响范围(哪些模块/表/接口会受波及) - -子代理返回精炼摘要,主流程不直接读取大量文件,保持上下文干净。 - -> **Session 日志作为调研数据源**:Session 索引(`_session_index.json`)记录了每轮 AI 操作的结构化摘要(文件变更、子代理调用、错误、LLM 生成的操作描述),是了解"某个文件/模块近期发生了什么"的最高效数据源。相比逐个打开审计记录,索引查询零 Token 成本且信息密度更高。 - -### 2. 输出上下文摘要 -基于子代理返回的调研结果,向用户输出简要的「改动前上下文摘要」,包含: -- 当前模块的职责和关键逻辑 -- 历史变更要点(如有) -- 本次改动的影响范围评估 -- 需要注意的风险点或边界条件 - -用户确认后再开始实施。 - -## 例外 -- 纯格式调整(缩进、空行、import 排序) -- 注释/文档纯文字修改(不涉及逻辑描述变更) -- 用户明确说"直接改"或"跳过调研" -- 新建文件且不涉及已有逻辑的修改 diff --git a/.kiro/steering/product-full.md b/.kiro/steering/product-full.md deleted file mode 100644 index e7f83a2..0000000 --- a/.kiro/steering/product-full.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: "**/tasks/**,**/models/**,**/loaders/**,**/scd/**,**/quality/**,**/business-rules/**" -name: product-full -description: 产品详细说明(ETL 功能、指数算法、在线/离线模式)。读到 ETL 任务/模型/业务规则文件时自动加载。 ---- - -# 产品详细说明 - -## ETL 功能 -- 从上游 SaaS API 抽取运营数据(订单、支付、会员、助教、库存等) -- 原始数据落地 ODS,保留源 payload 便于回溯 -- 清洗装载至 DWD,维度走 SCD2,事实按时间增量 -- 汇总至 DWS:助教业绩、财务日报、会员分析、工资计算、自定义指数算法(WBI/NCI/RS/OS/MS/ML/SPI) -- 支持在线(API 抓取)和离线(JSON 回放)两种模式 - -## 主要入口 -详见 `tech.md` 常用命令节。 diff --git a/.kiro/steering/project-overview.md b/.kiro/steering/project-overview.md deleted file mode 100644 index d55beda..0000000 --- a/.kiro/steering/project-overview.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -inclusion: always ---- -# 项目概览 - -NeoZQYY Monorepo — 面向台球门店业务的全栈数据平台。多门店隔离(`site_id` + RLS),领域语言中文,货币 CNY,金额 numeric(2)。 - -## 子系统与目录 -| 目录 | 说明 | -|------|------| -| `apps/etl/connectors/feiqiu/` | 飞球 Connector:上游 SaaS API → ODS → DWD → DWS | -| `apps/backend/` | FastAPI 后端 | -| `apps/miniprogram/` | 微信小程序(C 端) | -| `apps/admin-web/` | 管理后台(React + Vite + Ant Design) | -| `apps/mcp-server/` | MCP Server(AI 工具集成) | -| `packages/shared/` | 跨项目共享包(枚举、金额精度、时间工具) | -| `db/` | DDL / 迁移 / 种子(`etl_feiqiu/`、`zqyy_app/`、`fdw/`) | -| `docs/` | 项目级文档 + `audit/`(统一审计落地点) | -| `tests/` | Monorepo 级属性测试(hypothesis) | -| `scripts/` | 项目级运维脚本(`ops/`、`audit/`、`migrate/`、`server/`) | - -## 高风险路径(变更需审计) -- ETL:`api/`、`cli/`、`config/`、`database/`、`loaders/`、`models/`、`orchestration/`、`scd/`、`tasks/`、`utils/`、`quality/` -- `apps/backend/app/`、`apps/admin-web/src/`、`apps/miniprogram/miniprogram/` -- `packages/shared/`、`db/`、根目录散文件(`.env*`、`pyproject.toml`) - -## 文件归属规则 -- 模块专属 docs/tests/scripts → 模块内部 -- 项目级/跨模块 → 根目录对应文件夹 -- 审计产物统一写 `docs/audit/`,禁止写入子模块 -- 编码:UTF-8、纯 SQL、迁移脚本日期前缀、任务大写蛇形 - -## 废弃对象黑名单(高频误引) - -| 对象 | 类型 | 删除日期 | 替代方案 | -|------|------|----------|----------| -| `dwd.dwd_assistant_trash_event` / `_ex` | DWD 表 | 2026-02-22 | `dwd_assistant_service_log_ex.is_trash` | -| `ods.assistant_cancellation_records` | ODS 表 | 2026-02-22 | 不再需要独立链路 | -| `ODS_ASSISTANT_ABOLISH` / `ASSISTANT_ABOLISH` | ETL/调度任务 | 2026-02-22 | 无 | -| `BILLIARD_VIP` | cfg_area_category 分类代码 | 2026-03-07 | V1-V4 归入 `BILLIARD`,V5 归入 `SNOOKER` | -| `dws_member_recall_index` / `v_dws_member_recall_index` | DWS 表 + RLS 视图 | 2026-03-20 | `dws_member_winback_index`(WBI)+ `dws_member_newconv_index`(NCI) | - -所有 `_archived/` 目录存放已废弃内容,除非用户明确要求,禁止读取或参考。 - -## 治理 -任何逻辑改动必须可追溯、可验证、可回滚。审计检测与提醒由 hooks 自动执行。 - -> 详细目录树见 `structure.md`(fileMatch 自动加载);ETL 功能细节见 `product-full.md`(fileMatch 自动加载)。 diff --git a/.kiro/steering/steering-readme-maintainer.md b/.kiro/steering/steering-readme-maintainer.md deleted file mode 100644 index 099bd82..0000000 --- a/.kiro/steering/steering-readme-maintainer.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -inclusion: manual ---- - -# 变更影响审查与文档同步(手动参考) - -说明:本文件用于“按需加载”的快速参考(可作为 /slash command),详细流程请优先使用 skill: -- steering-readme-maintainer - -## 何时使用 -- 发生业务/资金口径/ETL/接口/鉴权/小程序交互等“逻辑改动”时 - -## 快速清单 -- 是否需要更新 project-overview.md / tech.md / structure.md / README.md / (各子目录下README.md) -- 是否需要补齐审计记录 docs/audit/changes/__.md -- 是否需要在每个修改文件写入 AI_CHANGELOG -- 是否需要在逻辑变更处加 CHANGE 标记注释 diff --git a/.kiro/steering/structure.md b/.kiro/steering/structure.md deleted file mode 100644 index fa6bddf..0000000 --- a/.kiro/steering/structure.md +++ /dev/null @@ -1,112 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: "pyproject.toml,**/pyproject.toml,.kiro/steering/project-overview.md,.kiro/agents/**" -name: structure-full -description: 完整目录树 + 架构模式 + 文件归属规则展开。读到项目配置或 steering/agent 定义时自动加载。 ---- - -# NeoZQYY Monorepo 完整结构 - -``` -NeoZQYY/ -├── apps/ -│ ├── etl/connectors/feiqiu/ # 飞球 Connector(数据源连接器) -│ │ ├── api/ # API 客户端(HTTP、本地 JSON 回放、录制) -│ │ ├── cli/ # CLI 入口 -│ │ ├── config/ # 配置(默认值、环境变量、AppConfig、调度任务) -│ │ ├── database/ # 数据库连接与操作(Python 模块) -│ │ ├── tasks/ # ETL 任务(ods/dwd/dws/index/utility/verification) -│ │ ├── loaders/ # 数据加载器(ods/dimensions/facts) -│ │ ├── scd/ # SCD2 处理器 -│ │ ├── orchestration/ # 调度器、任务注册、游标、运行记录 -│ │ ├── quality/ # 数据质量检查 -│ │ ├── models/ # 解析器与验证器 -│ │ ├── utils/ # 工具函数(日志、JSON 存储、窗口切分) -│ │ ├── docs/ # ETL 专属文档(api-reference、business-rules、etl_tasks 等) -│ │ ├── tests/ # ETL 测试(unit/integration) -│ │ ├── scripts/ # ETL 专属脚本(check/repair/rebuild/export/audit) -│ │ └── pyproject.toml -│ ├── backend/ # FastAPI 后端 -│ │ ├── app/ # main.py, config.py, database.py, routers/, middleware/, schemas/ -│ │ ├── tests/ # 后端测试 -│ │ └── pyproject.toml -│ ├── miniprogram/ # 微信小程序 -│ │ ├── miniprogram/ # 小程序源码 -│ │ └── doc/ # 小程序文档 -│ ├── admin-web/ # 管理后台 -│ │ ├── src/ # 前端源码(api/components/pages/store/types) -│ │ └── src/__tests__/ # 前端测试 -│ └── mcp-server/ # MCP Server(AI 工具集成) -│ ├── server.py -│ └── pyproject.toml -├── packages/shared/ # 跨项目共享包(enums, money, datetime_utils) -├── db/ -│ ├── etl_feiqiu/ -│ │ ├── schemas/ # 六层 Schema DDL(meta/ods/dwd/core/dws/app) -│ │ ├── migrations/ # 迁移脚本(日期前缀) -│ │ ├── seeds/ # 种子数据 -│ │ └── scripts/ # 测试数据库脚本 -│ ├── zqyy_app/schemas/ # 业务数据库 DDL -│ └── fdw/ # FDW 跨库映射 -├── docs/ # 项目级文档 -│ ├── audit/ # 统一审计落地点 -│ │ ├── changes/ # 变更审计记录 -│ │ ├── prompt_logs/ # Prompt 日志 -│ │ └── audit_dashboard.md # 审计一览表(自动生成) -│ ├── database/ # 全局数据库文档 -│ ├── architecture/ # 架构设计 -│ ├── deployment/ # 部署文档(EXPORT-PATHS.md、LAUNCH-CHECKLIST.md) -│ ├── prd/ # 产品需求 -│ ├── contracts/ # 数据契约 -│ └── ... -├── tests/ # Monorepo 级属性测试(hypothesis) -├── scripts/ # 项目级运维脚本 -│ ├── audit/ # 审计工具(gen_audit_dashboard.py) -│ ├── ops/ # 日常运维(init_databases、clone_to_test_db 等) -│ ├── migrate/ # 一次性迁移脚本 -│ └── server/ # 服务器部署脚本 -├── pyproject.toml # uv workspace 根配置(4 成员) -├── .env.template -└── README.md -``` - -## 架构模式 -- 任务模式:继承 `BaseTask`(Extract → Transform → Load),在 `orchestration/task_registry.py` 注册 -- 加载器模式:每张目标表一个 Loader,`upsert()` + 冲突处理 -- 配置分层:DEFAULTS → `.env` → CLI 覆盖 -- Flow:通过 `--pipeline` 参数指定(如 `api_full`) -- 多门店隔离:`site_id` + RLS(`app` schema 视图层) -- 跨库访问:`zqyy_app` 通过 FDW 只读映射 `etl_feiqiu.app` - -## 文件归属规则(展开说明) - -### 模块内部(各 APP / Connector 自治) -每个子模块的 `docs/`、`tests/`、`scripts/` 属于模块专属,只放该模块自身的内容。 -禁止将项目级内容放入模块内部目录,也禁止将模块专属内容放到根目录。 - -### 项目级(根目录统管) -- `docs/` — 跨模块文档:架构设计、PRD、权限矩阵、数据契约、运维手册、路线图 -- `docs/audit/` — 统一审计落地点(所有模块的变更记录、Prompt 日志、审计一览表) -- `docs/database/` — 全局数据库文档(跨模块共享的 DB 视角) -- `tests/` — Monorepo 级属性测试(守护项目结构/约定/跨模块一致性) -- `scripts/` — 项目级运维脚本(数据库初始化、迁移、审计工具等) - -### 审计产物路径(硬约束) -- 变更审计记录:`docs/audit/changes/__.md` -- 审计一览表:`docs/audit/audit_dashboard.md`(自动生成,勿手动编辑) -- Prompt 日志:`docs/audit/prompt_logs/` -- 一览表生成脚本:`scripts/audit/gen_audit_dashboard.py` -- 禁止将审计产物写入子模块内部 - -### 速查表 - -| 判断标准 | 放置位置 | -|----------|----------| -| 只有本模块开发者需要看的文档 | 模块内 `docs/` | -| 跨模块对照或全局视角的文档 | 根 `docs/` | -| 只验证本模块逻辑的测试 | 模块内 `tests/` | -| 守护 monorepo 结构/约定的测试 | 根 `tests/` | -| 只操作本模块数据的脚本 | 模块内 `scripts/` | -| 运维/全局工具脚本 | 根 `scripts/` | -| 审计记录(任何模块的变更) | 根 `docs/audit/` | -| 数据库文档(全局 schema 视角) | 根 `docs/database/` | diff --git a/.kiro/steering/tech-full.md b/.kiro/steering/tech-full.md deleted file mode 100644 index 591618d..0000000 --- a/.kiro/steering/tech-full.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -inclusion: fileMatch -fileMatchPattern: "**/pyproject.toml,**/config/**,**/migrations/**,**/.env*,**/seeds/**" -name: tech-full -description: 技术栈详细信息(依赖清单、DDL 基线、测试工具、种子数据)。读到配置/迁移/依赖文件时自动加载。 ---- - -# 技术栈详细信息 - -## 核心依赖 -- ETL:`psycopg2-binary`、`requests`、`python-dateutil`、`tzdata`、`python-dotenv`、`openpyxl` -- 后端:`fastapi`、`uvicorn[standard]`、`psycopg2-binary`、`python-dotenv` -- 管理后台:`React`、`Vite`、`Ant Design`(独立 pnpm 管理) -- 共享包:`neozqyy-shared`(workspace 内部引用) -- 测试:`pytest`、`hypothesis` - -## 数据库详细 -- 业务库 `zqyy_app`(用户/RBAC/任务/审批),通过 FDW 只读映射 ETL 数据 -- DDL 基线:`docs/database/ddl/`(从测试库自动导出,按 schema 分文件),重新生成:`python scripts/ops/gen_consolidated_ddl.py` -- 旧 DDL / 迁移脚本已归档至 `db/_archived/ddl_baseline_2026-02-22/` -- 种子数据:`db/etl_feiqiu/seeds/`、`db/zqyy_app/seeds/` - -## 测试 -- ETL 单元测试:`cd apps/etl/connectors/feiqiu && pytest tests/unit` -- ETL 集成测试:`TEST_DB_DSN="..." pytest tests/integration` -- Monorepo 属性测试:`pytest tests/ -v`(根目录,hypothesis) -- 测试工具:`apps/etl/connectors/feiqiu/tests/unit/task_test_utils.py` 提供 FakeDB/FakeAPI diff --git a/.kiro/steering/tech.md b/.kiro/steering/tech.md deleted file mode 100644 index 6c85ab2..0000000 --- a/.kiro/steering/tech.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -inclusion: always ---- -# 技术栈 - -- Python 3.10+,uv workspace(4 成员:etl/connectors/feiqiu、backend、mcp-server、shared) -- 管理后台:React + Vite + Ant Design(`apps/admin-web/`,独立 pnpm) -- PostgreSQL 四库:`etl_feiqiu` / `test_etl_feiqiu`(ETL,六层 Schema)、`zqyy_app` / `test_zqyy_app`(业务) -- DSN:`PG_DSN`(ETL)、`APP_DB_DSN`(业务),根 `.env` 定义 -- 配置分层:根 `.env` < `.env.local` < 环境变量 < CLI 参数;ETL 配置类 → `AppConfig` - -## 常用命令 -```bash -uv sync # 安装依赖 -cd apps/etl/connectors/feiqiu && python -m cli.main --dry-run --tasks DWD_LOAD_FROM_ODS -cd apps/backend && uvicorn app.main:app --reload -cd apps/etl/connectors/feiqiu && pytest tests/unit # ETL 单元测试 -cd C:\NeoZQYY && pytest tests/ -v # 属性测试 -``` - -## 脚本规范 -- 复杂操作优先写 Python 脚本,避免 PowerShell 复杂逻辑 -- 一次性运维脚本 → `scripts/ops/`;模块专属 → 模块内 `scripts/` - -> 依赖清单、DDL 基线等见 `tech-full.md`(fileMatch 自动加载)。 \ No newline at end of file diff --git a/.kiro/steering/testing-env.md b/.kiro/steering/testing-env.md deleted file mode 100644 index 68df515..0000000 --- a/.kiro/steering/testing-env.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -inclusion: always ---- -# 测试与验证环境规范(强制) - -AI 执行测试/验证/调试/一次性脚本时,必须使用与正式运行一致的参数环境。 - -## 规则 -1. 必须 `load_dotenv` 加载根 `.env`;必需变量(`FETCH_ROOT`、`EXPORT_ROOT`、`PG_DSN` 等)缺失时立即报错,禁止静默回退空字符串 -2. cwd 与正式一致:ETL → `apps/etl/connectors/feiqiu/`;后端 → `apps/backend/` -3. 配置走 `AppConfig.load()` 正常流程,不得为测试单独构造简化配置 -4. 数据库使用测试库:`test_etl_feiqiu` / `test_zqyy_app`(`TEST_DB_DSN`),禁止连正式库 - -## 例外 -- 用户明确指定简化环境 -- 纯单元测试用 FakeDB/FakeAPI(不涉及真实路径/连接) -- `--dry-run` CLI 验证(路径配置仍需完整) - -> 背景:曾因 `FETCH_ROOT` 未加载,`or` 链回退空字符串,时区值 `Asia/Shanghai` 被误用为路径,创建了垃圾目录。 diff --git a/.mcp.json b/.mcp.json index 0412715..356f2a3 100644 --- a/.mcp.json +++ b/.mcp.json @@ -40,6 +40,28 @@ "WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram" }, "disabled": true + }, + "playwright": { + "command": "npx", + "args": ["@playwright/mcp@latest"], + "disabled": true + }, + "openapi": { + "command": "uv", + "args": [ + "tool", "run", + "--from", "awslabs.openapi-mcp-server@latest", + "--with", "fastmcp>=2.14.0,<3.0.0", + "awslabs.openapi-mcp-server.exe", + "--api-name", "miniapp-backend", + "--api-url", "http://127.0.0.1:8000", + "--spec-path", "C:\\NeoZQYY\\docs\\contracts\\openapi\\backend-api.json", + "--log-level", "ERROR" + ], + "env": { + "PYTHONUTF8": "1" + }, + "disabled": true } } } diff --git a/NeoZQYY.code-workspace b/NeoZQYY.code-workspace index 3242722..d774ce5 100644 --- a/NeoZQYY.code-workspace +++ b/NeoZQYY.code-workspace @@ -5,6 +5,7 @@ } ], "settings": { - "liveServer.settings.port": 5501 + "liveServer.settings.port": 5501, + "typescript.autoClosingTags": false } } \ No newline at end of file diff --git a/apps/admin-web/README.md b/apps/admin-web/README.md index 422acf2..bf345cd 100644 --- a/apps/admin-web/README.md +++ b/apps/admin-web/README.md @@ -19,7 +19,7 @@ apps/admin-web/ ├── src/ │ ├── App.tsx # 主布局 + 路由配置 + 路由守卫 │ ├── main.tsx # 应用入口 -│ ├── pages/ # 8 个功能页面 +│ ├── pages/ # 18 个功能页面 │ │ ├── Login.tsx # 登录页 │ │ ├── TaskConfig.tsx # 任务配置(Flow 选择 + 任务勾选 + 参数设置) │ │ ├── TaskManager.tsx # 任务管理(队列 + 执行历史 + 实时日志) @@ -27,13 +27,23 @@ apps/admin-web/ │ │ ├── DBViewer.tsx # 数据库查看器(Schema/表/列浏览 + SQL 执行) │ │ ├── LogViewer.tsx # 日志查看器 │ │ ├── EnvConfig.tsx # 环境配置管理 -│ │ └── OpsPanel.tsx # 运维面板(服务启停 + Git + 系统监控) +│ │ ├── OpsPanel.tsx # 运维面板(服务启停 + Git + 系统监控) +│ │ ├── TenantAdmins/ # 租户管理员管理(2步创建 + 软删除 + 简写ID管理) +│ │ ├── AIDashboard.tsx # AI 运行总览(统计卡片 + 趋势图 + 饼图 + 预算 + 告警) +│ │ ├── AITriggerJobs.tsx # AI 调度状态(分页表格 + 筛选 + 手动重跑) +│ │ ├── AIRunLogs.tsx # AI 调用明细(分页表格 + 详情抽屉) +│ │ ├── AIOperations.tsx # AI 手动操作(重跑 + 缓存失效 + 批量执行 + 告警管理) +│ │ ├── DevTrace.tsx # 开发调试全链路日志(覆盖率 + 筛选 + 请求列表 + Span 树 + 设置) +│ │ ├── TransferLog.tsx # P18 客户转移日志(分页表格 + 门店/时间/助教筛选 + guard_checks 标签) +│ │ ├── PendingReview.tsx # P18 待审核任务(分页表格 + 重新分配/关闭弹窗 + 转移历史抽屉) +│ │ ├── TaskEngineConfig.tsx # P18 参数管理(全局默认+门店覆盖 + 行内编辑 + 权重卡片编辑) +│ │ └── TriggerJobs.tsx # 定时任务管理(biz.trigger_jobs 表展示 + 手动执行 + 清空任务) │ ├── components/ # 可复用组件 │ │ ├── BusinessDayHint.tsx # 营业日提示组件 │ │ ├── DwdTableSelector.tsx # DWD 表选择器 │ │ ├── ErrorBoundary.tsx # 错误边界 │ │ ├── LogStream.tsx # 实时日志流组件 -│ │ ├── ScheduleTab.tsx # 调度配置标签页 +│ │ ├── ScheduleTab.tsx # 调度配置标签页(含最小运行间隔、强制执行、上次成功时间) │ │ └── TaskSelector.tsx # 任务选择器 │ ├── api/ # API 调用层 │ │ ├── client.ts # Axios 实例(baseURL + JWT 拦截器) @@ -44,11 +54,17 @@ apps/admin-web/ │ │ ├── etlStatus.ts # ETL 状态 API │ │ ├── dbViewer.ts # 数据库查看器 API │ │ ├── envConfig.ts # 环境配置 API -│ │ └── opsPanel.ts # 运维面板 API +│ │ ├── opsPanel.ts # 运维面板 API +│ │ ├── registry.ts # 注册体系 API(租户/店铺/简写ID/同步) +│ │ ├── tenantAdmins.ts # 租户管理员 CRUD API +│ │ ├── adminAI.ts # AI 监控后台 API(Dashboard/调度/调用/缓存/预算/批量/告警) +│ │ ├── devTrace.ts # DevTrace 全链路日志 API(日期/请求/详情/清理/设置/覆盖率) +│ │ └── taskEngine.ts # P18 任务引擎运营看板 API(转移日志/待审核/参数管理,9 个函数) │ ├── store/ │ │ ├── authStore.ts # Zustand 认证状态(JWT 持久化 + hydrate) │ │ └── businessDayStore.ts # 营业日状态管理 -│ └── types/ # TypeScript 类型定义 +│ ├── types/ # TypeScript 类型定义 +│ │ └── devTrace.ts # DevTrace 类型(TraceSpan/TraceRequest/TraceDetail/TraceSettings/TraceCoverage) ├── index.html # HTML 入口 ├── vite.config.ts # Vite 配置 ├── tsconfig.json # TypeScript 配置 @@ -106,6 +122,84 @@ ETL 任务的核心配置界面: - 依赖同步(`uv sync`) - 系统资源概况(CPU、内存、磁盘) +### 租户管理员管理 (`/tenant-admins`) +- 管理员列表(支持分页、关键词搜索、显示/隐藏已禁用记录) +- 2 步创建流程:第 1 步选择租户 + 输入账号信息 + 选择管辖门店;第 2 步可选设置简写ID +- 软删除管理员(二次确认 → `is_active=false`) +- 编辑管理员(用户名可修改,需校验唯一性;所属租户只读) +- 简写ID 管理弹窗:展示租户下所有店铺及当前 code,支持修改和查看变更历史 +- 手动触发店铺同步(从 ETL 库同步最新店铺信息) + +### AI 运行总览 (`/ai/dashboard`) +AI 模块运行状况一览: +- 顶部统计卡片(今日调用次数、成功率、Token 消耗、平均延迟) +- 近 7 天趋势折线图(调用量 + 成功率) +- 各 App 调用占比饼图 +- Token 预算使用进度条(日/月) +- 告警列表 + App 健康状态 + App 配置信息(只读) +- 支持门店筛选 + +### AI 调度状态 (`/ai/trigger-jobs`) +调度任务执行状态监控: +- 分页表格(事件类型、会员、状态、执行链、耗时) +- 筛选器(event_type / status / site_id / 日期范围) +- 今日去重跳过数统计 +- 操作列:查看详情、手动重跑(二次确认) + +### AI 调用明细 (`/ai/run-logs`) +AI 调用记录追踪: +- 分页表格(app_type、trigger_type、member_id、tokens、延迟、状态) +- 筛选器(app_type / status / trigger_type / site_id / 日期范围) +- 点击行展开详情抽屉(完整 prompt/response/error_message) + +### AI 手动操作 (`/ai/operations`) +AI 人工干预操作: +- 手动重跑(App 选择 + 会员 + 门店 → 单次执行) +- 缓存失效(按 App / 会员 / 门店批量失效) +- 批量执行(成本二次确认流程:预估 → 确认弹窗 → 执行) +- 告警管理(告警列表 + 确认/忽略操作) + +### 开发调试全链路日志 (`/dev-trace`) +后端请求全链路追踪日志的可视化查看与管理: +- 覆盖率状态栏:路由/Service/Job/SSE/WS 五维度进度条 + 未覆盖项列表 +- 筛选栏:日期、时间范围、trace_type、HTTP 方法、路径关键词、状态码、最小耗时、仅错误、Span 类型 +- 请求列表:分页表格(时间/类型/方法/路径/状态/耗时/DB 查询数/错误标记) +- Span 链路树:选中请求后展示完整 span 树,支持缩进层级、SQL 详情、参数、错误信息 +- 设置抽屉:日志开关、记录 SQL/参数、保留天数、日志目录、手动清理日期范围 +- 覆盖率扫描:手动触发 AST 扫描,检测追踪覆盖情况 + +对应后端模块 `apps/backend/app/trace/`,通过 8 个 admin API 端点(`/api/admin/dev-trace/*`)通信,需 admin 角色鉴权。 + +### 客户转移日志 (`/task-engine/transfer-log`) +P18 任务引擎运营看板 — 转移日志页面: +- 分页表格展示 `biz.coach_task_transfer_log` 记录 +- 筛选器:门店 ID、日期范围(RangePicker)、助教 ID +- guard_checks JSON 渲染为彩色标签(通过/未通过) +- transfer_reason 映射中文标签(连续召回失败/人工重新分配/归属变更) + +### 待审核任务 (`/task-engine/pending-review`) +P18 任务引擎运营看板 — 待审核任务页面: +- 分页表格展示 `status='pending_review'` 的任务 +- 超级管理员操作列:重新分配(输入目标助教 ID)、关闭(填写原因) +- 点击客户名打开转移历史抽屉 +- 门店管理员只读(无操作列) + +### 参数管理 (`/task-engine/config`) +P18 任务引擎运营看板 — 参数管理页面: +- 展示 `biz.cfg_task_generator_params` 全局默认 + 门店覆盖参数 +- 行内编辑单个参数值 +- 权重参数(w_rs/w_ms/w_ml)使用卡片编辑弹窗,前端预校验三者之和 = 1.0 +- 新增门店覆盖(Select 选择参数名 + InputNumber 输入值) +- 删除门店覆盖(全局默认参数禁止删除) +- 超级管理员可编辑/新增/删除;门店管理员只读 + +### 定时任务管理 (`/trigger-jobs`) +`biz.trigger_jobs` 表中定时任务的管理页面: +- 表格展示所有定时任务(名称、触发条件、Cron/间隔配置、状态、上次执行时间) +- 手动执行单个任务(二次确认) +- 清空所有任务(Popconfirm 二次确认 + Modal 成功/失败反馈) +- 执行期间按钮 loading 状态防止重复操作 + ## 认证与路由守卫 - 所有功能页面通过 `PrivateRoute` 组件保护 @@ -162,3 +256,9 @@ ETL 任务的核心配置界面: - [ ] 权限管理界面(角色/权限配置) - [ ] 暗色主题支持 - [ ] 国际化(i18n) +- [x] 租户管理员 CRUD + 2 步创建 + 软删除(NS4.1) +- [x] 注册体系管理 — 简写ID 管理 + 店铺同步(NS4.1) +- [x] 调度任务最小运行间隔 + 强制执行(P16) +- [x] AI 监控后台 — Dashboard + 调度状态 + 调用明细 + 手动操作(P15) +- [x] 开发调试全链路日志 — DevTrace 页面 + 覆盖率扫描 + Span 树展示 +- [x] P18 任务引擎运营看板 — 转移日志 + 待审核任务 + 参数管理(3 页面 + 9 API) diff --git a/apps/admin-web/e2e/dashboard.spec.ts b/apps/admin-web/e2e/dashboard.spec.ts new file mode 100644 index 0000000..d9c73b7 --- /dev/null +++ b/apps/admin-web/e2e/dashboard.spec.ts @@ -0,0 +1,68 @@ +/** + * Dashboard 页面 E2E 测试。 + * + * 验证点: + * - 4 个区块渲染(OpsPanel、DbHealthCard、AI 运行总览、AI 调度摘要) + * - 跳转链接正确(ETL 状态详情、触发器详情、AI 调度详情) + */ + +import { test, expect } from '@playwright/test'; +import { injectAuth, mockAllApis } from './helpers'; + +test.describe('Dashboard 页面', () => { + test.beforeEach(async ({ page }) => { + await injectAuth(page); + await mockAllApis(page); + await page.goto('/dashboard'); + // 等待页面标题渲染,确认 Dashboard 已加载 + await expect(page.locator('text=运行状态').first()).toBeVisible(); + }); + + test('4 个区块均渲染', async ({ page }) => { + // 区块 1:OpsPanel 子组件(系统资源信息) + // SystemResourceSection 会展示 CPU / 内存 / 磁盘等信息 + await expect(page.locator('text=CPU').first()).toBeVisible(); + + // 区块 2:数据库健康监控(DbHealthCard) + await expect(page.locator('text=数据库').first()).toBeVisible(); + + // 区块 3:AI 运行总览 + await expect(page.locator('text=AI 运行总览').first()).toBeVisible(); + + // 区块 4:AI 调度摘要 + await expect(page.locator('text=AI 调度摘要').first()).toBeVisible(); + // 验证统计卡片存在 + await expect(page.locator('text=今日触发数')).toBeVisible(); + await expect(page.locator('text=今日成功率')).toBeVisible(); + await expect(page.locator('text=总记录数')).toBeVisible(); + }); + + test('ETL 状态详情跳转到 /etl-tasks?tab=status', async ({ page }) => { + const btn = page.locator('button', { hasText: 'ETL 状态详情' }); + await expect(btn).toBeVisible(); + await btn.click(); + await expect(page).toHaveURL(/\/etl-tasks\?tab=status/); + }); + + test('触发器详情跳转到 /triggers?tab=all', async ({ page }) => { + const btn = page.locator('button', { hasText: '触发器详情' }); + await expect(btn).toBeVisible(); + await btn.click(); + await expect(page).toHaveURL(/\/triggers\?tab=all/); + }); + + test('AI 调度详情跳转到 /triggers?tab=ai', async ({ page }) => { + const btn = page.locator('button', { hasText: 'AI 调度详情' }); + await expect(btn).toBeVisible(); + await btn.click(); + await expect(page).toHaveURL(/\/triggers\?tab=ai/); + }); + + test('AI 调度摘要底部链接跳转到 /triggers?tab=ai', async ({ page }) => { + // 卡片底部的 "查看 AI 调度详情" 链接 + const link = page.locator('text=查看 AI 调度详情'); + await expect(link).toBeVisible(); + await link.click(); + await expect(page).toHaveURL(/\/triggers\?tab=ai/); + }); +}); diff --git a/apps/admin-web/e2e/etl-tasks.spec.ts b/apps/admin-web/e2e/etl-tasks.spec.ts new file mode 100644 index 0000000..5e3d7de --- /dev/null +++ b/apps/admin-web/e2e/etl-tasks.spec.ts @@ -0,0 +1,97 @@ +/** + * ETL 任务管理页面 E2E 测试。 + * + * 验证点: + * - 5 个 Tab 切换(config、queue、schedule、history、status) + * - 各 Tab 内容渲染 + * - Tab 与 URL 查询参数同步 + */ + +import { test, expect } from '@playwright/test'; +import { injectAuth, mockAllApis } from './helpers'; + +test.describe('ETL 任务管理页面', () => { + test.beforeEach(async ({ page }) => { + await injectAuth(page); + await mockAllApis(page); + await page.goto('/etl-tasks'); + // 等待页面标题渲染 + await expect(page.locator('text=ETL 任务管理').first()).toBeVisible(); + }); + + test('默认显示 config Tab', async ({ page }) => { + // 默认 Tab 为"发起" + const configTab = page.locator('[role="tab"]', { hasText: '发起' }); + await expect(configTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('5 个 Tab 均可见', async ({ page }) => { + await expect(page.locator('[role="tab"]', { hasText: '发起' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: '队列' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: '调度' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: '历史' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: '状态' })).toBeVisible(); + }); + + test('切换到 queue Tab', async ({ page }) => { + const queueTab = page.locator('[role="tab"]', { hasText: '队列' }); + await queueTab.click(); + await expect(queueTab).toHaveAttribute('aria-selected', 'true'); + // URL 同步 + await expect(page).toHaveURL(/\?tab=queue/); + }); + + test('切换到 schedule Tab', async ({ page }) => { + const scheduleTab = page.locator('[role="tab"]', { hasText: '调度' }); + await scheduleTab.click(); + await expect(scheduleTab).toHaveAttribute('aria-selected', 'true'); + // URL 同步 + await expect(page).toHaveURL(/\?tab=schedule/); + }); + + test('切换到 history Tab', async ({ page }) => { + const historyTab = page.locator('[role="tab"]', { hasText: '历史' }); + await historyTab.click(); + await expect(historyTab).toHaveAttribute('aria-selected', 'true'); + // URL 同步 + await expect(page).toHaveURL(/\?tab=history/); + }); + + test('切换到 status Tab', async ({ page }) => { + const statusTab = page.locator('[role="tab"]', { hasText: '状态' }); + await statusTab.click(); + await expect(statusTab).toHaveAttribute('aria-selected', 'true'); + // URL 同步 + await expect(page).toHaveURL(/\?tab=status/); + }); + + test('通过 URL 直接访问 queue Tab', async ({ page }) => { + await page.goto('/etl-tasks?tab=queue'); + const queueTab = page.locator('[role="tab"]', { hasText: '队列' }); + await expect(queueTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('通过 URL 直接访问 schedule Tab', async ({ page }) => { + await page.goto('/etl-tasks?tab=schedule'); + const scheduleTab = page.locator('[role="tab"]', { hasText: '调度' }); + await expect(scheduleTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('通过 URL 直接访问 history Tab', async ({ page }) => { + await page.goto('/etl-tasks?tab=history'); + const historyTab = page.locator('[role="tab"]', { hasText: '历史' }); + await expect(historyTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('通过 URL 直接访问 status Tab', async ({ page }) => { + await page.goto('/etl-tasks?tab=status'); + const statusTab = page.locator('[role="tab"]', { hasText: '状态' }); + await expect(statusTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('无效 tab 参数回退到默认 config', async ({ page }) => { + await page.goto('/etl-tasks?tab=invalid'); + const configTab = page.locator('[role="tab"]', { hasText: '发起' }); + await expect(configTab).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/apps/admin-web/e2e/helpers.ts b/apps/admin-web/e2e/helpers.ts new file mode 100644 index 0000000..94f651c --- /dev/null +++ b/apps/admin-web/e2e/helpers.ts @@ -0,0 +1,230 @@ +/** + * E2E 测试公共辅助:注入 JWT 令牌 + 通用 API mock。 + * + * 认证方式:向 localStorage 写入伪造的 access_token / refresh_token, + * 与 authStore.hydrate() 逻辑一致,页面加载后自动识别为已登录状态。 + */ + +import { type Page } from '@playwright/test'; + +/* ------------------------------------------------------------------ */ +/* 伪造 JWT(payload 可被 authStore.parseJwtPayload 正确解析) */ +/* ------------------------------------------------------------------ */ + +function makeFakeJwt(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = btoa(JSON.stringify(payload)); + const sig = 'fake_signature'; + return `${header}.${body}.${sig}`; +} + +const FAKE_ACCESS_TOKEN = makeFakeJwt({ + user_id: 1, + username: 'admin', + display_name: '测试管理员', + site_id: 1, + roles: ['admin'], + exp: Math.floor(Date.now() / 1000) + 3600, +}); + +const FAKE_REFRESH_TOKEN = 'fake_refresh_token'; + +/* ------------------------------------------------------------------ */ +/* 注入登录状态 */ +/* ------------------------------------------------------------------ */ + +/** + * 在页面导航前注入 localStorage 令牌,模拟已登录状态。 + * 必须在 page.goto() 之前调用。 + */ +export async function injectAuth(page: Page): Promise { + // 先访问 baseURL 以获得同源 localStorage 访问权限 + await page.goto('/login', { waitUntil: 'commit' }); + await page.evaluate( + ([accessToken, refreshToken]) => { + localStorage.setItem('access_token', accessToken); + localStorage.setItem('refresh_token', refreshToken); + }, + [FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN] as const, + ); +} + +/* ------------------------------------------------------------------ */ +/* 通用 API mock — 拦截所有 /api/** 请求返回空数据 */ +/* ------------------------------------------------------------------ */ + +/** 为所有 /api/ 请求注册兜底 mock,避免真实网络调用 */ +export async function mockAllApis(page: Page): Promise { + // 运维面板 + await page.route('**/api/ops/system-info', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { + cpu_percent: 25.0, + memory_percent: 60.0, + disk_percent: 45.0, + uptime_seconds: 86400, + platform: 'Windows', + python_version: '3.10.0', + }, + }), + }), + ); + + await page.route('**/api/ops/services', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: [ + { name: 'backend', env: 'prod', status: 'running', pid: 1234, port: 8000 }, + ], + }), + }), + ); + + await page.route('**/api/ops/git-info', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: [ + { env: 'prod', branch: 'main', commit: 'abc1234', dirty: false }, + ], + }), + }), + ); + + // 数据库健康 + await page.route('**/api/db-health**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: [ + { db_name: 'etl_feiqiu', status: 'healthy', latency_ms: 5, details: null }, + { db_name: 'zqyy_app', status: 'healthy', latency_ms: 3, details: null }, + ], + }), + }), + ); + + // AI 调度摘要(trigger jobs) + await page.route('**/api/admin/ai/trigger-jobs**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: { items: [], total: 0 }, + }), + }), + ); + + // AI Dashboard 相关 + await page.route('**/api/admin/ai/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: [] }), + }), + ); + + // 统一触发器 + await page.route('**/api/triggers/unified**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: [ + { + id: 1, name: '测试触发器', source: 'biz', + trigger_condition: 'cron', status: 'running', + last_run_at: '2026-01-01T00:00:00', next_run_at: '2026-01-02T00:00:00', + last_error: null, + }, + ], + }), + }), + ); + + // 业务触发器 + await page.route('**/api/trigger-jobs**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: [ + { + id: 1, job_name: 'test_job', description: '测试任务', + trigger_condition: 'cron', trigger_config: { cron_expression: '0 */2 * * *' }, + status: 'enabled', last_run_at: null, next_run_at: null, last_error: null, + }, + ], + }), + }), + ); + + // ETL 调度任务 + await page.route('**/api/schedules**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + code: 0, + data: [ + { + id: 1, name: 'ETL 日常同步', task_codes: ['ODS_LOAD'], + enabled: true, last_status: 'success', + last_run_at: '2026-01-01T00:00:00', next_run_at: '2026-01-02T00:00:00', + run_count: 100, created_at: '2025-01-01T00:00:00', + }, + ], + }), + }), + ); + + // 执行队列(Footer 状态栏) + await page.route('**/api/execution/queue**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: [] }), + }), + ); + + // ETL 任务配置 + await page.route('**/api/task-config**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: [] }), + }), + ); + + // ETL 状态 + await page.route('**/api/etl-status**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: [] }), + }), + ); + + // 兜底:其他未匹配的 /api/ 请求返回空成功 + await page.route('**/api/**', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ code: 0, data: [] }), + }), + ); +} diff --git a/apps/admin-web/e2e/navigation.spec.ts b/apps/admin-web/e2e/navigation.spec.ts new file mode 100644 index 0000000..332cba5 --- /dev/null +++ b/apps/admin-web/e2e/navigation.spec.ts @@ -0,0 +1,122 @@ +/** + * 导航与路由 E2E 测试。 + * + * 验证点: + * - 默认路由 / → /dashboard 重定向 + * - /log-viewer → /etl-tasks?tab=manager 重定向 + * - 菜单高亮 + * - Tab-URL 同步 + */ + +import { test, expect } from '@playwright/test'; +import { injectAuth, mockAllApis } from './helpers'; + +test.describe('路由重定向', () => { + test.beforeEach(async ({ page }) => { + await injectAuth(page); + await mockAllApis(page); + }); + + test('/ 重定向到 /dashboard', async ({ page }) => { + await page.goto('/'); + await expect(page).toHaveURL(/\/dashboard/); + // 确认 Dashboard 页面已渲染 + await expect(page.locator('text=运行状态').first()).toBeVisible(); + }); + + test('/log-viewer 重定向到 /etl-tasks?tab=queue', async ({ page }) => { + await page.goto('/log-viewer'); + await expect(page).toHaveURL(/\/etl-tasks\?tab=queue/); + }); + + test('未登录时重定向到 /login', async ({ page }) => { + // 清除 localStorage 中的令牌 + await page.goto('/login', { waitUntil: 'commit' }); + await page.evaluate(() => { + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + }); + await page.goto('/dashboard'); + await expect(page).toHaveURL(/\/login/); + }); +}); + +test.describe('菜单高亮', () => { + test.beforeEach(async ({ page }) => { + await injectAuth(page); + await mockAllApis(page); + }); + + test('Dashboard 页面菜单高亮"运行状态"', async ({ page }) => { + await page.goto('/dashboard'); + await expect(page.locator('text=运行状态').first()).toBeVisible(); + // Ant Design Menu 选中项会有 ant-menu-item-selected 类 + const menuItem = page.locator('.ant-menu-item-selected', { hasText: '运行状态' }); + await expect(menuItem).toBeVisible(); + }); + + test('ETL 任务页面菜单高亮"ETL 任务管理"', async ({ page }) => { + await page.goto('/etl-tasks'); + const menuItem = page.locator('.ant-menu-item-selected', { hasText: 'ETL 任务管理' }); + await expect(menuItem).toBeVisible(); + }); + + test('触发器管理页面菜单高亮"触发器管理"', async ({ page }) => { + await page.goto('/triggers'); + const menuItem = page.locator('.ant-menu-item-selected', { hasText: '触发器管理' }); + await expect(menuItem).toBeVisible(); + }); +}); + +test.describe('Tab-URL 同步', () => { + test.beforeEach(async ({ page }) => { + await injectAuth(page); + await mockAllApis(page); + }); + + test('ETL 任务页 Tab 切换同步 URL', async ({ page }) => { + await page.goto('/etl-tasks'); + await expect(page.locator('text=ETL 任务管理').first()).toBeVisible(); + + // 点击"队列"Tab + await page.locator('[role="tab"]', { hasText: '队列' }).click(); + await expect(page).toHaveURL(/\?tab=queue/); + + // 点击"调度"Tab + await page.locator('[role="tab"]', { hasText: '调度' }).click(); + await expect(page).toHaveURL(/\?tab=schedule/); + + // 点击"历史"Tab + await page.locator('[role="tab"]', { hasText: '历史' }).click(); + await expect(page).toHaveURL(/\?tab=history/); + + // 点击"状态"Tab + await page.locator('[role="tab"]', { hasText: '状态' }).click(); + await expect(page).toHaveURL(/\?tab=status/); + + // 点击"发起"Tab 回到默认 + await page.locator('[role="tab"]', { hasText: '发起' }).click(); + await expect(page).toHaveURL(/\?tab=config/); + }); + + test('触发器管理页 Tab 切换同步 URL', async ({ page }) => { + await page.goto('/triggers'); + await expect(page.locator('text=触发器管理').first()).toBeVisible(); + + // 点击"业务"Tab + await page.locator('[role="tab"]', { hasText: '业务' }).click(); + await expect(page).toHaveURL(/\?tab=biz/); + + // 点击"AI"Tab + await page.locator('[role="tab"]', { hasText: 'AI' }).click(); + await expect(page).toHaveURL(/\?tab=ai/); + + // 点击"ETL"Tab + await page.locator('[role="tab"]', { hasText: 'ETL' }).click(); + await expect(page).toHaveURL(/\?tab=etl/); + + // 回到"全部"Tab + await page.locator('[role="tab"]', { hasText: '全部' }).click(); + await expect(page).toHaveURL(/\?tab=all/); + }); +}); diff --git a/apps/admin-web/e2e/trigger-manager.spec.ts b/apps/admin-web/e2e/trigger-manager.spec.ts new file mode 100644 index 0000000..8594e7f --- /dev/null +++ b/apps/admin-web/e2e/trigger-manager.spec.ts @@ -0,0 +1,85 @@ +/** + * 触发器管理页面 E2E 测试。 + * + * 验证点: + * - 4 个 Tab(全部、业务、AI、ETL) + * - 统一视图数据展示 + * - 业务 Tab 编辑功能存在 + */ + +import { test, expect } from '@playwright/test'; +import { injectAuth, mockAllApis } from './helpers'; + +test.describe('触发器管理页面', () => { + test.beforeEach(async ({ page }) => { + await injectAuth(page); + await mockAllApis(page); + await page.goto('/triggers'); + // 等待页面标题渲染 + await expect(page.locator('text=触发器管理').first()).toBeVisible(); + }); + + test('4 个 Tab 均可见', async ({ page }) => { + await expect(page.locator('[role="tab"]', { hasText: '全部' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: '业务' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: 'AI' })).toBeVisible(); + await expect(page.locator('[role="tab"]', { hasText: 'ETL' })).toBeVisible(); + }); + + test('默认显示"全部"Tab', async ({ page }) => { + const allTab = page.locator('[role="tab"]', { hasText: '全部' }); + await expect(allTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('统一视图表格展示数据', async ({ page }) => { + // "全部"Tab 的统一视图表格应展示 mock 数据 + // 等待表格渲染 + await expect(page.locator('table').first()).toBeVisible(); + // 验证 mock 数据中的触发器名称 + await expect(page.locator('text=测试触发器')).toBeVisible(); + // 验证类型标签 + await expect(page.locator('text=业务')).toBeVisible(); + }); + + test('切换到业务 Tab 并验证编辑按钮', async ({ page }) => { + const bizTab = page.locator('[role="tab"]', { hasText: '业务' }); + await bizTab.click(); + await expect(bizTab).toHaveAttribute('aria-selected', 'true'); + await expect(page).toHaveURL(/\?tab=biz/); + + // 等待业务触发器表格加载 + await expect(page.locator('table').first()).toBeVisible(); + // 验证编辑按钮存在(mock 数据中 status=enabled,编辑按钮应可用) + const editBtn = page.locator('button', { hasText: '编辑' }).first(); + await expect(editBtn).toBeVisible(); + }); + + test('切换到 AI Tab', async ({ page }) => { + const aiTab = page.locator('[role="tab"]', { hasText: 'AI' }); + await aiTab.click(); + await expect(aiTab).toHaveAttribute('aria-selected', 'true'); + await expect(page).toHaveURL(/\?tab=ai/); + }); + + test('切换到 ETL Tab', async ({ page }) => { + const etlTab = page.locator('[role="tab"]', { hasText: 'ETL' }); + await etlTab.click(); + await expect(etlTab).toHaveAttribute('aria-selected', 'true'); + await expect(page).toHaveURL(/\?tab=etl/); + + // 等待 ETL 调度表格加载 + await expect(page.locator('table').first()).toBeVisible(); + }); + + test('通过 URL 直接访问 biz Tab', async ({ page }) => { + await page.goto('/triggers?tab=biz'); + const bizTab = page.locator('[role="tab"]', { hasText: '业务' }); + await expect(bizTab).toHaveAttribute('aria-selected', 'true'); + }); + + test('通过 URL 直接访问 ai Tab', async ({ page }) => { + await page.goto('/triggers?tab=ai'); + const aiTab = page.locator('[role="tab"]', { hasText: 'AI' }); + await expect(aiTab).toHaveAttribute('aria-selected', 'true'); + }); +}); diff --git a/apps/admin-web/package.json b/apps/admin-web/package.json index 316e9ff..5155fb9 100644 --- a/apps/admin-web/package.json +++ b/apps/admin-web/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "e2e": "playwright test" }, "dependencies": { "@ant-design/icons": "^5.6.1", @@ -22,11 +23,13 @@ "zustand": "^5.0.5" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/react": "^19.1.4", "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.2", + "fast-check": "^4.6.0", "jsdom": "^26.1.0", "typescript": "~5.8.3", "vite": "^6.3.5", diff --git a/apps/admin-web/playwright.config.ts b/apps/admin-web/playwright.config.ts new file mode 100644 index 0000000..7d36189 --- /dev/null +++ b/apps/admin-web/playwright.config.ts @@ -0,0 +1,31 @@ +/** + * Playwright E2E 测试配置。 + * + * baseURL 指向 Vite 默认开发端口 5173。 + * 运行前需先启动 `pnpm dev`。 + */ + +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: 'html', + + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/apps/admin-web/pnpm-lock.yaml b/apps/admin-web/pnpm-lock.yaml index 2cc7797..4b1b63e 100644 --- a/apps/admin-web/pnpm-lock.yaml +++ b/apps/admin-web/pnpm-lock.yaml @@ -31,6 +31,9 @@ dependencies: version: 5.0.5(@types/react@19.1.4)(react@19.1.0) devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 @@ -46,6 +49,9 @@ devDependencies: '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.5.2(vite@6.3.5) + fast-check: + specifier: ^4.6.0 + version: 4.6.0 jsdom: specifier: ^26.1.0 version: 26.1.0 @@ -646,6 +652,14 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /@playwright/test@1.58.2: + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright: 1.58.2 + dev: true + /@rc-component/async-validator@5.1.0: resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} engines: {node: '>=14.x'} @@ -1527,6 +1541,13 @@ packages: engines: {node: '>=12.0.0'} dev: true + /fast-check@4.6.0: + resolution: {integrity: sha512-h7H6Dm0Fy+H4ciQYFxFjXnXkzR2kr9Fb22c0UBpHnm59K2zpr2t13aPTHlltFiNT6zuxp6HMPAVVvgur4BLdpA==} + engines: {node: '>=12.17.0'} + dependencies: + pure-rand: 8.3.0 + dev: true + /fdir@6.5.0(picomatch@4.0.3): resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1560,6 +1581,14 @@ packages: mime-types: 2.1.35 dev: false + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1823,6 +1852,22 @@ packages: engines: {node: '>=12'} dev: true + /playwright-core@1.58.2: + resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==} + engines: {node: '>=18'} + hasBin: true + dev: true + + /playwright@1.58.2: + resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==} + engines: {node: '>=18'} + hasBin: true + dependencies: + playwright-core: 1.58.2 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1850,6 +1895,10 @@ packages: engines: {node: '>=6'} dev: true + /pure-rand@8.3.0: + resolution: {integrity: sha512-1ws1Ab8fnsf4bvpL+SujgBnr3KFs5abgCLVzavBp+f2n8Ld5YTOZlkv/ccYPhu3X9s+MEeqPRMqKlJz/kWDK8A==} + dev: true + /rc-cascader@3.33.1(react-dom@19.1.0)(react@19.1.0): resolution: {integrity: sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==} peerDependencies: diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index b9d1762..42edf68 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -2,8 +2,15 @@ * 主布局与路由配置。 * * - Ant Design Layout:Sider + Content + Footer(状态栏) - * - react-router-dom:6 个功能页面路由 + 登录页路由 + * - react-router-dom:路由守卫 + 7 个一级菜单模块 * - 路由守卫:未登录重定向到登录页 + * + * CHANGE 2026-03-23 | Task 6 Change B:新增「定时任务」菜单项和 /trigger-jobs 路由; + * 新增租户管理员、AI 监控子菜单组(4 子路由)、开发调试日志路由; + * import 7 个新页面组件(TriggerJobs/TenantAdmins/AIDashboard/AITriggerJobs/AIRunLogs/AIOperations/DevTrace) + * CHANGE 2026-07-14 | Task 7.1:侧边栏菜单从 11 个一级项重组为 7 个; + * 新增 Dashboard/ETLTasks/TriggerManager 占位页面;路由重构(新增重定向、移动路由); + * 移除 LogViewer 及不再直接路由的页面 import;登录后导航到 /dashboard */ import React, { useEffect, useState, useCallback } from "react"; @@ -12,12 +19,12 @@ import { Layout, Menu, Spin, Space, Typography, Tag, Button, Tooltip } from "ant import { SettingOutlined, UnorderedListOutlined, - ToolOutlined, - DatabaseOutlined, DashboardOutlined, - FileTextOutlined, + ClockCircleOutlined, LogoutOutlined, - DesktopOutlined, + TeamOutlined, + BugOutlined, + ApartmentOutlined, } from "@ant-design/icons"; import type { MenuProps } from "antd"; import { useAuthStore } from "./store/authStore"; @@ -25,31 +32,85 @@ import { useBusinessDayStore } from "./store/businessDayStore"; import { fetchQueue } from "./api/execution"; import type { QueuedTask } from "./types"; import Login from "./pages/Login"; -import TaskConfig from "./pages/TaskConfig"; -import TaskManager from "./pages/TaskManager"; import EnvConfig from "./pages/EnvConfig"; import DBViewer from "./pages/DBViewer"; -import ETLStatus from "./pages/ETLStatus"; -import LogViewer from "./pages/LogViewer"; -import OpsPanel from "./pages/OpsPanel"; +import TenantAdmins from "./pages/TenantAdmins"; +import AIRunLogs from "./pages/AIRunLogs"; +import DevTrace from "./pages/DevTrace"; +import TriggerJobs from "./pages/TriggerJobs"; +import TransferLog from "./pages/TransferLog"; +import PendingReview from "./pages/PendingReview"; +import TaskEngineConfig from "./pages/TaskEngineConfig"; +import Dashboard from "./pages/Dashboard"; +import ETLTasks from "./pages/ETLTasks"; +import TriggerManager from "./pages/TriggerManager"; const { Sider, Content, Footer } = Layout; const { Text } = Typography; /* ------------------------------------------------------------------ */ -/* 侧边栏导航配置 */ +/* 侧边栏导航配置(7 个一级菜单) */ /* ------------------------------------------------------------------ */ -const NAV_ITEMS: MenuProps["items"] = [ - { key: "/", icon: , label: "任务配置" }, - { key: "/task-manager", icon: , label: "任务管理" }, - { key: "/etl-status", icon: , label: "ETL 状态" }, - { key: "/db-viewer", icon: , label: "数据库" }, - { key: "/log-viewer", icon: , label: "日志" }, - { key: "/env-config", icon: , label: "环境配置" }, - { key: "/ops-panel", icon: , label: "运维面板" }, +export const NAV_ITEMS: MenuProps["items"] = [ + { key: "/dashboard", icon: , label: "运行状态" }, + { key: "/etl-tasks", icon: , label: "ETL 任务管理" }, + { + key: "task-engine-group", icon: , label: "小程序任务管理", + children: [ + { key: "/task-engine/trigger-jobs", label: "定时任务" }, + { key: "/task-engine/transfer-log", label: "转移日志" }, + { key: "/task-engine/pending-review", label: "待审核任务" }, + { key: "/task-engine/config", label: "参数管理" }, + ], + }, + { key: "/triggers", icon: , label: "触发器管理" }, + { key: "/tenant-admins", icon: , label: "租户管理员" }, + { + key: "settings-group", icon: , label: "系统设置", + children: [ + { key: "/settings/env-config", label: "环境配置" }, + { key: "/triggers?tab=biz", label: "触发器配置" }, + ], + }, + { + key: "logs-group", icon: , label: "日志调试", + children: [ + { key: "/logs/dev-trace", label: "DevTrace" }, + { key: "/logs/ai-run-logs", label: "AI 调用明细" }, + { key: "/logs/db-viewer", label: "数据库查看器" }, + ], + }, ]; +/* ------------------------------------------------------------------ */ +/* 侧边栏高亮辅助函数 */ +/* ------------------------------------------------------------------ */ + +/** 根据当前路径计算 selectedKeys */ +export function getSelectedKeys(pathname: string, search: string): string[] { + const fullPath = pathname + search; + // 精确匹配含查询参数的菜单项(如 /triggers?tab=biz) + if (fullPath === "/triggers?tab=biz") return ["/triggers?tab=biz"]; + // 子路由匹配 + if (pathname.startsWith("/task-engine/")) return [pathname]; + if (pathname.startsWith("/settings/")) return [pathname]; + if (pathname.startsWith("/logs/")) return [pathname]; + // 一级路由直接匹配 + return [pathname]; +} + +/** 根据当前路径计算 defaultOpenKeys */ +export function getDefaultOpenKeys(pathname: string): string[] { + const keys: string[] = []; + if (pathname.startsWith("/task-engine/")) keys.push("task-engine-group"); + if (pathname.startsWith("/settings/")) keys.push("settings-group"); + if (pathname.startsWith("/logs/")) keys.push("logs-group"); + // 触发器配置跳转入口也需要展开系统设置 + if (pathname === "/triggers") keys.push("settings-group"); + return keys; +} + /* ------------------------------------------------------------------ */ /* 路由守卫 */ /* ------------------------------------------------------------------ */ @@ -97,7 +158,15 @@ const AppLayout: React.FC = () => {
{ @@ -138,13 +208,31 @@ const AppLayout: React.FC = () => { - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* 重定向 */} + } /> + } /> + + {/* 新页面 */} + } /> + } /> + } /> + + {/* 小程序任务管理 */} + } /> + } /> + } /> + } /> + + {/* 系统设置 */} + } /> + + {/* 日志调试 */} + } /> + } /> + } /> + + {/* 不变 */} + } />
{ + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +/* ------------------------------------------------------------------ */ +/* Mock 所有 Dashboard 依赖的 API 模块 */ +/* ------------------------------------------------------------------ */ + +vi.mock("../api/opsPanel", () => ({ + fetchSystemInfo: vi.fn().mockResolvedValue({ + cpu_percent: 25, + memory_total_gb: 16, + memory_used_gb: 8, + memory_percent: 50, + disk_total_gb: 500, + disk_used_gb: 250, + disk_percent: 50, + boot_time: "2026-01-01T00:00:00", + }), + fetchServicesStatus: vi.fn().mockResolvedValue([ + { + env: "prod", + label: "生产环境", + running: true, + pid: 1234, + port: 8000, + uptime_seconds: 3600, + memory_mb: 256, + cpu_percent: 10, + }, + ]), + fetchGitInfo: vi.fn().mockResolvedValue([ + { + env: "prod", + branch: "main", + last_commit_hash: "abc1234", + last_commit_message: "test commit", + last_commit_time: "2026-01-01T00:00:00", + has_local_changes: false, + }, + ]), + startService: vi.fn(), + stopService: vi.fn(), + restartService: vi.fn(), + gitPull: vi.fn(), + syncDeps: vi.fn(), +})); + +vi.mock("../api/dbHealth", () => ({ + fetchDbHealth: vi.fn().mockResolvedValue([ + { + db_name: "etl_feiqiu", + status: "connected", + active_connections: 5, + idle_connections: 10, + db_size_mb: 128.5, + slow_query_count: 2, + }, + { + db_name: "test_etl_feiqiu", + status: "disconnected", + active_connections: null, + idle_connections: null, + db_size_mb: null, + slow_query_count: null, + }, + ]), +})); + +vi.mock("../api/adminAI", () => ({ + getTriggerJobs: vi.fn().mockResolvedValue({ + items: [], + total: 0, + page: 1, + page_size: 50, + today_skipped_duplicates: 0, + }), +})); + +// Mock AIDashboard 为简单占位,避免其内部 API 调用 +vi.mock("../pages/AIDashboard", () => ({ + default: () =>
AIDashboard
, +})); + +/* ------------------------------------------------------------------ */ +/* Dashboard 页面测试 */ +/* ------------------------------------------------------------------ */ + +describe("Dashboard 页面 (Requirements 2.1, 2.2)", () => { + let Dashboard: React.FC; + + beforeEach(async () => { + vi.clearAllMocks(); + const mod = await import("../pages/Dashboard"); + Dashboard = mod.default; + }); + + it("渲染 4 个区块:OpsPanel 子组件、DbHealthCard、AI 运行总览、AI 调度摘要", async () => { + render( + + + , + ); + + // 区块 1:OpsPanel 子组件 — 页面标题"运行状态" + expect(await screen.findByText("运行状态")).toBeInTheDocument(); + + // 区块 2:数据库健康监控 + expect(screen.getByText("数据库健康监控")).toBeInTheDocument(); + + // 区块 3:AI 运行总览(mocked AIDashboard) + expect(screen.getByText("AI 运行总览")).toBeInTheDocument(); + expect(screen.getByTestId("ai-dashboard-mock")).toBeInTheDocument(); + + // 区块 4:AI 调度摘要(Divider + Card title 各出现一次) + const summaryElements = screen.getAllByText("AI 调度摘要"); + expect(summaryElements.length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("今日触发数")).toBeInTheDocument(); + expect(screen.getByText("今日成功率")).toBeInTheDocument(); + }); + + it("跳转链接:ETL 状态详情、触发器详情、AI 调度详情", async () => { + render( + + + , + ); + + await screen.findByText("运行状态"); + + expect(screen.getByText(/ETL 状态详情/)).toBeInTheDocument(); + expect(screen.getByText(/触发器详情/)).toBeInTheDocument(); + + // AI 调度详情出现两次(顶部按钮 + 底部链接),取第一个 + const aiButtons = screen.getAllByText(/AI 调度详情/); + expect(aiButtons.length).toBeGreaterThanOrEqual(1); + }); +}); + +/* ------------------------------------------------------------------ */ +/* DbHealthCard 组件测试(纯展示组件,不需要 mock API) */ +/* ------------------------------------------------------------------ */ + +describe("DbHealthCard — connected 状态 (Requirements 2.3, 2.4)", () => { + const connectedItems: DbHealthItem[] = [ + { + db_name: "etl_feiqiu", + status: "connected", + active_connections: 5, + idle_connections: 10, + db_size_mb: 128.5, + slow_query_count: 2, + }, + { + db_name: "zqyy_app", + status: "connected", + active_connections: 3, + idle_connections: 7, + db_size_mb: 256.0, + slow_query_count: 0, + }, + ]; + + it("为每个 connected 数据库渲染卡片,显示「已连接」标签", () => { + render(); + + expect(screen.getByText("etl_feiqiu")).toBeInTheDocument(); + expect(screen.getByText("zqyy_app")).toBeInTheDocument(); + + const connectedTags = screen.getAllByText("已连接"); + expect(connectedTags).toHaveLength(2); + }); + + it("展示连接池指标(活跃/空闲连接数)", () => { + render(); + + expect(screen.getByText(/活跃 5/)).toBeInTheDocument(); + expect(screen.getByText(/空闲 10/)).toBeInTheDocument(); + }); + + it("展示数据库大小和慢查询数量", () => { + render(); + + const sizeLabels = screen.getAllByText("数据库大小"); + expect(sizeLabels.length).toBeGreaterThanOrEqual(1); + + const slowLabels = screen.getAllByText(/慢查询/); + expect(slowLabels.length).toBeGreaterThanOrEqual(1); + }); +}); + +describe("DbHealthCard — disconnected 状态 (Requirement 2.5)", () => { + const disconnectedItems: DbHealthItem[] = [ + { + db_name: "test_etl_feiqiu", + status: "disconnected", + active_connections: null, + idle_connections: null, + db_size_mb: null, + slow_query_count: null, + }, + ]; + + it("disconnected 数据库显示「未连接」标签", () => { + render(); + + expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument(); + expect(screen.getByText("未连接")).toBeInTheDocument(); + }); + + it("disconnected 数据库显示无法获取指标提示", () => { + render(); + + expect(screen.getByText("数据库未连接,无法获取指标")).toBeInTheDocument(); + }); +}); + +describe("DbHealthCard — timeout 状态", () => { + it("超时时显示「加载超时」标签", () => { + render(); + + expect(screen.getByText("加载超时")).toBeInTheDocument(); + }); + + it("超时时显示重试按钮,点击触发 onRetry", () => { + const onRetry = vi.fn(); + render(); + + const retryBtn = screen.getByText("重试"); + expect(retryBtn).toBeInTheDocument(); + + fireEvent.click(retryBtn); + expect(onRetry).toHaveBeenCalledOnce(); + }); +}); + +describe("DbHealthCard — mixed 状态(connected + disconnected)", () => { + const mixedItems: DbHealthItem[] = [ + { + db_name: "etl_feiqiu", + status: "connected", + active_connections: 5, + idle_connections: 10, + db_size_mb: 128.5, + slow_query_count: 2, + }, + { + db_name: "test_etl_feiqiu", + status: "disconnected", + active_connections: null, + idle_connections: null, + db_size_mb: null, + slow_query_count: null, + }, + ]; + + it("同时渲染 connected 和 disconnected 卡片", () => { + render(); + + expect(screen.getByText("已连接")).toBeInTheDocument(); + expect(screen.getByText("未连接")).toBeInTheDocument(); + expect(screen.getByText("etl_feiqiu")).toBeInTheDocument(); + expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument(); + }); +}); diff --git a/apps/admin-web/src/__tests__/etlTasks.test.tsx b/apps/admin-web/src/__tests__/etlTasks.test.tsx new file mode 100644 index 0000000..5f8a0d5 --- /dev/null +++ b/apps/admin-web/src/__tests__/etlTasks.test.tsx @@ -0,0 +1,238 @@ +/** + * 单元测试:ETLTasks 页面 + * + * _Requirements: 3.1, 3.2, 3.3, 3.4, 10.4_ + * + * - 测试默认 Tab 为 config(发起) + * - 测试 5 个 Tab 内容正确渲染 + * - 测试 URL 参数驱动 Tab 选择 + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { MemoryRouter } from "react-router-dom"; + +/* ------------------------------------------------------------------ */ +/* Ant Design jsdom 兼容:polyfill window.matchMedia */ +/* ------------------------------------------------------------------ */ + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +/* ------------------------------------------------------------------ */ +/* Mock 子组件,避免内部 API 调用 */ +/* ------------------------------------------------------------------ */ + +vi.mock("../pages/TaskConfig", () => ({ + default: () =>
TaskConfig Mock
, +})); +vi.mock("../pages/TaskManager", () => ({ + QueueTab: () =>
QueueTab Mock
, + HistoryTab: () =>
HistoryTab Mock
, +})); +vi.mock("../components/ScheduleTab", () => ({ + default: () =>
ScheduleTab Mock
, +})); +vi.mock("../pages/ETLStatus", () => ({ + default: () =>
ETLStatus Mock
, +})); + +import ETLTasks from "../pages/ETLTasks"; + +afterEach(() => { + cleanup(); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:默认 Tab 为 config(Requirements 3.1, 3.2) */ +/* ------------------------------------------------------------------ */ + +describe("ETLTasks — 默认 Tab (Requirements 3.1, 3.2)", () => { + it("无 ?tab 参数时,默认激活 config Tab(发起)", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("发起"); + }); + + it("无效 ?tab 参数时,回退到默认 config Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("发起"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:4 个 Tab 内容正确渲染(Requirements 3.2, 3.3, 3.4) */ +/* ------------------------------------------------------------------ */ + +describe("ETLTasks — 5 个 Tab 内容渲染 (Requirements 3.2, 3.3, 3.4)", () => { + it("config Tab 渲染 TaskConfig 组件", () => { + render( + + + , + ); + + expect(screen.getByTestId("mock-task-config")).toBeInTheDocument(); + }); + + it("queue Tab 渲染 QueueTab 组件", () => { + render( + + + , + ); + + expect(screen.getByTestId("mock-queue-tab")).toBeInTheDocument(); + }); + + it("schedule Tab 渲染 ScheduleTab 组件", () => { + render( + + + , + ); + + expect(screen.getByTestId("mock-schedule-tab")).toBeInTheDocument(); + }); + + it("history Tab 渲染 HistoryTab 组件", () => { + render( + + + , + ); + + expect(screen.getByTestId("mock-history-tab")).toBeInTheDocument(); + }); + + it("status Tab 渲染 ETLStatus 组件", () => { + render( + + + , + ); + + expect(screen.getByTestId("mock-etl-status")).toBeInTheDocument(); + }); + + it("页面标题包含「ETL 任务管理」", () => { + render( + + + , + ); + + expect(screen.getByText("ETL 任务管理")).toBeInTheDocument(); + }); + + it("5 个 Tab 标签文本正确:发起、队列、调度、历史、状态", () => { + render( + + + , + ); + + const tabs = document.querySelectorAll(".ant-tabs-tab"); + expect(tabs).toHaveLength(5); + + const tabTexts = Array.from(tabs).map((t) => t.textContent); + expect(tabTexts).toContain("发起"); + expect(tabTexts).toContain("队列"); + expect(tabTexts).toContain("调度"); + expect(tabTexts).toContain("历史"); + expect(tabTexts).toContain("状态"); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:URL 参数驱动 Tab 选择(Requirement 10.4) */ +/* ------------------------------------------------------------------ */ + +describe("ETLTasks — URL 参数驱动 Tab 选择 (Requirement 10.4)", () => { + it("?tab=config 激活「发起」Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("发起"); + }); + + it("?tab=queue 激活「队列」Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("队列"); + }); + + it("?tab=schedule 激活「调度」Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("调度"); + }); + + it("?tab=history 激活「历史」Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("历史"); + }); + + it("?tab=status 激活「状态」Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("状态"); + }); +}); diff --git a/apps/admin-web/src/__tests__/logFilter.test.ts b/apps/admin-web/src/__tests__/logFilter.test.ts index 921bdf7..b4548ef 100644 --- a/apps/admin-web/src/__tests__/logFilter.test.ts +++ b/apps/admin-web/src/__tests__/logFilter.test.ts @@ -8,7 +8,7 @@ */ import { describe, it, expect } from "vitest"; -import { filterLogLines } from "../pages/LogViewer"; +import { filterLogLines } from "../pages/_archived/LogViewer"; describe("filterLogLines — 日志过滤正确性", () => { /* ---- 1. 空关键词返回所有行 ---- */ diff --git a/apps/admin-web/src/__tests__/menuAndRedirects.test.tsx b/apps/admin-web/src/__tests__/menuAndRedirects.test.tsx new file mode 100644 index 0000000..980bc75 --- /dev/null +++ b/apps/admin-web/src/__tests__/menuAndRedirects.test.tsx @@ -0,0 +1,140 @@ +/** + * 单元测试:菜单结构与路由重定向 + * + * _Requirements: 1.1, 8.3, 10.1, 10.2_ + * + * - 验证 NAV_ITEMS 包含 7 个一级菜单项且子项正确 + * - 验证 `/` 重定向到 `/dashboard` + * - 验证 `/log-viewer` 重定向到 `/etl-tasks?tab=queue` + */ + +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { MemoryRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; +import { NAV_ITEMS } from "../App"; +import type { MenuProps } from "antd"; + +/* ------------------------------------------------------------------ */ +/* 菜单结构测试(纯数据,无 DOM) */ +/* ------------------------------------------------------------------ */ + +type MenuItem = NonNullable[number] & { + key?: string; + label?: string; + children?: MenuItem[]; +}; + +describe("菜单结构验证 (Requirement 1.1)", () => { + const items = NAV_ITEMS as MenuItem[]; + + it("NAV_ITEMS 包含 7 个一级菜单项", () => { + expect(items).toHaveLength(7); + }); + + it("7 个一级菜单项的 label 和顺序正确", () => { + const labels = items.map((item) => item.label); + expect(labels).toEqual([ + "运行状态", + "ETL 任务管理", + "小程序任务管理", + "触发器管理", + "租户管理员", + "系统设置", + "日志调试", + ]); + }); + + it("「小程序任务管理」包含 4 个子项:定时任务、转移日志、待审核任务、参数管理", () => { + const taskEngine = items.find((i) => i.label === "小程序任务管理"); + expect(taskEngine).toBeDefined(); + const children = taskEngine!.children ?? []; + expect(children).toHaveLength(4); + expect(children.map((c) => c.label)).toEqual([ + "定时任务", + "转移日志", + "待审核任务", + "参数管理", + ]); + }); + + it("「系统设置」包含 2 个子项:环境配置、触发器配置", () => { + const settings = items.find((i) => i.label === "系统设置"); + expect(settings).toBeDefined(); + const children = settings!.children ?? []; + expect(children).toHaveLength(2); + expect(children.map((c) => c.label)).toEqual(["环境配置", "触发器配置"]); + }); + + it("「日志调试」包含 3 个子项:DevTrace、AI 调用明细、数据库查看器", () => { + const logs = items.find((i) => i.label === "日志调试"); + expect(logs).toBeDefined(); + const children = logs!.children ?? []; + expect(children).toHaveLength(3); + expect(children.map((c) => c.label)).toEqual([ + "DevTrace", + "AI 调用明细", + "数据库查看器", + ]); + }); + + it("无子项的一级菜单(运行状态、ETL 任务管理、触发器管理、租户管理员)没有 children", () => { + const noChildrenLabels = ["运行状态", "ETL 任务管理", "触发器管理", "租户管理员"]; + for (const label of noChildrenLabels) { + const item = items.find((i) => i.label === label); + expect(item).toBeDefined(); + expect((item as MenuItem).children).toBeUndefined(); + } + }); +}); + +/* ------------------------------------------------------------------ */ +/* 路由重定向测试 */ +/* ------------------------------------------------------------------ */ + +/** + * 辅助组件:捕获当前 location 用于断言。 + * 渲染后通过 testId 读取 pathname + search。 + */ +function LocationDisplay() { + const location = useLocation(); + return ( +
+ {location.pathname} + {location.search} +
+ ); +} + +/** + * 最小路由配置:只包含重定向规则和一个 LocationDisplay 兜底, + * 不需要渲染完整 App(避免 mock 大量依赖)。 + */ +function RedirectTestApp() { + return ( + + } /> + } /> + } /> + + ); +} + +describe("路由重定向 (Requirements 8.3, 10.1, 10.2)", () => { + it("/ 重定向到 /dashboard", () => { + const { getByTestId } = render( + + + , + ); + expect(getByTestId("location").textContent).toBe("/dashboard"); + }); + + it("/log-viewer 重定向到 /etl-tasks?tab=queue", () => { + const { getByTestId } = render( + + + , + ); + expect(getByTestId("location").textContent).toBe("/etl-tasks?tab=queue"); + }); +}); diff --git a/apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts b/apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts new file mode 100644 index 0000000..9712cdc --- /dev/null +++ b/apps/admin-web/src/__tests__/sidebarHighlight.property.test.ts @@ -0,0 +1,160 @@ +/** + * 属性测试:侧边栏高亮与当前路由一致 + * + * Feature: admin-web-restructure, Property 7: 侧边栏高亮与当前路由一致 + * **Validates: Requirements 10.3** + * + * 对于任意有效的应用路由路径,侧边栏中被高亮(selectedKeys)的菜单项 + * 应对应该路由所属的一级模块。 + */ + +import { describe, it, expect } from "vitest"; +import * as fc from "fast-check"; +import { getSelectedKeys, NAV_ITEMS } from "../App"; + +/* ------------------------------------------------------------------ */ +/* 路由 → 一级模块映射(从 NAV_ITEMS 和路由配置提取) */ +/* ------------------------------------------------------------------ */ + +/** + * 所有有效路由及其所属一级模块 key 的映射。 + * 一级模块 key 定义: + * - 无子菜单的项:直接用 item.key(如 "/dashboard") + * - 有子菜单的项:用 group key(如 "task-engine-group") + */ +const ROUTE_TO_MODULE: Array<{ + pathname: string; + search: string; + moduleKey: string; + /** 该路由在菜单中对应的 selectedKey */ + expectedSelectedKey: string; +}> = [ + // 运行状态 + { pathname: "/dashboard", search: "", moduleKey: "/dashboard", expectedSelectedKey: "/dashboard" }, + // ETL 任务管理 + { pathname: "/etl-tasks", search: "", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" }, + { pathname: "/etl-tasks", search: "?tab=config", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" }, + { pathname: "/etl-tasks", search: "?tab=queue", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" }, + { pathname: "/etl-tasks", search: "?tab=schedule", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" }, + { pathname: "/etl-tasks", search: "?tab=history", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" }, + { pathname: "/etl-tasks", search: "?tab=status", moduleKey: "/etl-tasks", expectedSelectedKey: "/etl-tasks" }, + // 小程序任务管理 + { pathname: "/task-engine/trigger-jobs", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/trigger-jobs" }, + { pathname: "/task-engine/transfer-log", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/transfer-log" }, + { pathname: "/task-engine/pending-review", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/pending-review" }, + { pathname: "/task-engine/config", search: "", moduleKey: "task-engine-group", expectedSelectedKey: "/task-engine/config" }, + // 触发器管理 + { pathname: "/triggers", search: "", moduleKey: "/triggers", expectedSelectedKey: "/triggers" }, + { pathname: "/triggers", search: "?tab=all", moduleKey: "/triggers", expectedSelectedKey: "/triggers" }, + // 特殊情况:/triggers?tab=biz 同时是"系统设置 > 触发器配置"的快捷入口, + // getSelectedKeys 会精确匹配到 settings-group 下的子项 + { pathname: "/triggers", search: "?tab=biz", moduleKey: "settings-group", expectedSelectedKey: "/triggers?tab=biz" }, + { pathname: "/triggers", search: "?tab=ai", moduleKey: "/triggers", expectedSelectedKey: "/triggers" }, + { pathname: "/triggers", search: "?tab=etl", moduleKey: "/triggers", expectedSelectedKey: "/triggers" }, + // 租户管理员 + { pathname: "/tenant-admins", search: "", moduleKey: "/tenant-admins", expectedSelectedKey: "/tenant-admins" }, + // 系统设置 + { pathname: "/settings/env-config", search: "", moduleKey: "settings-group", expectedSelectedKey: "/settings/env-config" }, + // 日志调试 + { pathname: "/logs/dev-trace", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/dev-trace" }, + { pathname: "/logs/ai-run-logs", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/ai-run-logs" }, + { pathname: "/logs/db-viewer", search: "", moduleKey: "logs-group", expectedSelectedKey: "/logs/db-viewer" }, +]; + +/* ------------------------------------------------------------------ */ +/* 辅助:从 NAV_ITEMS 构建 selectedKey → moduleKey 的反查表 */ +/* ------------------------------------------------------------------ */ + +/** 收集所有菜单叶子节点的 key,映射到其所属一级模块 key */ +function buildKeyToModuleMap(): Map { + const map = new Map(); + for (const item of NAV_ITEMS ?? []) { + if (!item || !("key" in item)) continue; + const topKey = item.key as string; + if ("children" in item && item.children) { + // 有子菜单:子项 key → group key + for (const child of item.children) { + if (child && "key" in child) { + map.set(child.key as string, topKey); + } + } + } else { + // 无子菜单:自身 key → 自身 key + map.set(topKey, topKey); + } + } + return map; +} + +const KEY_TO_MODULE = buildKeyToModuleMap(); + +/* ------------------------------------------------------------------ */ +/* 属性测试 */ +/* ------------------------------------------------------------------ */ + +describe("Property 7: 侧边栏高亮与当前路由一致", () => { + // 生成器:从所有有效路由中随机选取 + const routeArb = fc.constantFrom(...ROUTE_TO_MODULE); + + it("对任意有效路由,selectedKeys 应包含该路由对应的菜单项 key", () => { + fc.assert( + fc.property(routeArb, (route) => { + const selectedKeys = getSelectedKeys(route.pathname, route.search); + + // selectedKeys 不应为空 + expect(selectedKeys.length).toBeGreaterThan(0); + + // selectedKeys 中应包含预期的 key + expect(selectedKeys).toContain(route.expectedSelectedKey); + }), + { numRuns: 100 }, + ); + }); + + it("对任意有效路由,selectedKeys 对应的一级模块应与路由所属模块一致", () => { + fc.assert( + fc.property(routeArb, (route) => { + const selectedKeys = getSelectedKeys(route.pathname, route.search); + const selectedKey = selectedKeys[0]; + + // 查找 selectedKey 所属的一级模块 + const actualModule = KEY_TO_MODULE.get(selectedKey); + + // 如果 selectedKey 不在菜单叶子节点中(如一级路由直接匹配), + // 则 selectedKey 本身就是模块 key + const resolvedModule = actualModule ?? selectedKey; + + expect(resolvedModule).toBe(route.moduleKey); + }), + { numRuns: 100 }, + ); + }); + + it("NAV_ITEMS 应包含 7 个一级菜单项", () => { + expect(NAV_ITEMS).toHaveLength(7); + }); + + it("所有有效路由的 selectedKey 都能在 NAV_ITEMS 中找到对应菜单项", () => { + fc.assert( + fc.property(routeArb, (route) => { + const selectedKeys = getSelectedKeys(route.pathname, route.search); + const selectedKey = selectedKeys[0]; + + // selectedKey 必须存在于菜单的叶子节点或一级节点中 + const allMenuKeys = new Set(); + for (const item of NAV_ITEMS ?? []) { + if (!item || !("key" in item)) continue; + allMenuKeys.add(item.key as string); + if ("children" in item && item.children) { + for (const child of item.children) { + if (child && "key" in child) allMenuKeys.add(child.key as string); + } + } + } + + expect(allMenuKeys.has(selectedKey)).toBe(true); + }), + { numRuns: 100 }, + ); + }); +}); diff --git a/apps/admin-web/src/__tests__/tabStatePreservation.property.test.tsx b/apps/admin-web/src/__tests__/tabStatePreservation.property.test.tsx new file mode 100644 index 0000000..f5dc076 --- /dev/null +++ b/apps/admin-web/src/__tests__/tabStatePreservation.property.test.tsx @@ -0,0 +1,139 @@ +/** + * 属性测试:Tab 切换状态保持 round-trip + * + * Feature: admin-web-restructure, Property 2: Tab 切换状态保持 round-trip + * **Validates: Requirements 3.5** + * + * 对于任意 ETL 任务管理的 Tab 视图,在某个 Tab 中设置筛选条件后 + * 切换到另一个 Tab 再切回来,原 Tab 的筛选条件应保持不变。 + * + * 策略:Mock 子组件为带 text input 的有状态组件, + * 通过输入文本 → 切换 Tab → 切回 → 验证文本保留来证明状态保持。 + */ + +import { useState } from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import * as fc from "fast-check"; + +/* ------------------------------------------------------------------ */ +/* Mock 子组件:带有状态的简单输入框 */ +/* ------------------------------------------------------------------ */ + +function StatefulTab({ tabKey }: { tabKey: string }) { + const [value, setValue] = useState(""); + return ( +
+ setValue(e.target.value)} + /> +
+ ); +} + +vi.mock("../pages/TaskConfig", () => ({ + default: () => , +})); +vi.mock("../pages/TaskManager", () => ({ + default: () => , +})); +vi.mock("../pages/ETLStatus", () => ({ + default: () => , +})); + +import ETLTasks from "../pages/ETLTasks"; + +/* ------------------------------------------------------------------ */ +/* 常量 */ +/* ------------------------------------------------------------------ */ + +const VALID_TABS = ["config", "manager", "status"] as const; +type TabKey = (typeof VALID_TABS)[number]; + +const TAB_LABELS: Record = { + config: "任务配置", + manager: "任务管理", + status: "ETL 状态", +}; + +/* ------------------------------------------------------------------ */ +/* 辅助函数 */ +/* ------------------------------------------------------------------ */ + +/** 点击指定 Tab */ +function clickTab(tabKey: TabKey) { + const tabElements = document.querySelectorAll(".ant-tabs-tab"); + for (const el of tabElements) { + if (el.textContent?.includes(TAB_LABELS[tabKey])) { + fireEvent.click(el); + return; + } + } + throw new Error(`Tab "${tabKey}" not found`); +} + +/* ------------------------------------------------------------------ */ +/* 每次测试后清理 DOM */ +/* ------------------------------------------------------------------ */ + +afterEach(() => { + cleanup(); +}); + +/* ------------------------------------------------------------------ */ +/* 属性测试 */ +/* ------------------------------------------------------------------ */ + +// DOM 渲染属性测试:numRuns=20 覆盖所有 tab 组合多次,避免 jsdom 超时 +const PBT_NUM_RUNS = 20; +const PBT_TIMEOUT = 30_000; + +describe("Property 2: Tab 切换状态保持 round-trip", () => { + // 生成器:从有效 tab 值中随机选取两个不同的 tab + const distinctTabPairArb = fc + .tuple(fc.constantFrom(...VALID_TABS), fc.constantFrom(...VALID_TABS)) + .filter(([a, b]) => a !== b); + + // 生成器:模拟用户输入的筛选条件文本 + // 使用 constantFrom 避免 fast-check v4 API 兼容性问题(char/stringOf 已移除) + const inputTextArb = fc.constantFrom( + "hello", "test-filter", "ETL_001", "搜索关键词", "abc123", + "x", "long-filter-value-example", "special!@#", " spaces ", "UPPER", + ); + + it("在某 Tab 设置状态 → 切换到另一 Tab → 切回 → 状态保持不变", () => { + fc.assert( + fc.property(distinctTabPairArb, inputTextArb, ([sourceTab, otherTab], text) => { + cleanup(); + + // 渲染页面,初始 Tab 为 sourceTab + render( + + + , + ); + + // 1. 在 sourceTab 的输入框中输入文本 + const input = screen.getByTestId(`state-input-${sourceTab}`) as HTMLInputElement; + fireEvent.change(input, { target: { value: text } }); + expect(input.value).toBe(text); + + // 2. 切换到 otherTab + clickTab(otherTab); + + // 3. 切回 sourceTab + clickTab(sourceTab); + + // 4. 验证 sourceTab 的输入框文本保持不变 + const inputAfter = screen.getByTestId(`state-input-${sourceTab}`) as HTMLInputElement; + expect(inputAfter.value).toBe(text); + + cleanup(); + }), + { numRuns: PBT_NUM_RUNS }, + ); + }, PBT_TIMEOUT); +}); diff --git a/apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx b/apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx new file mode 100644 index 0000000..b01e363 --- /dev/null +++ b/apps/admin-web/src/__tests__/tabUrlSync.property.test.tsx @@ -0,0 +1,191 @@ +/** + * 属性测试:Tab 切换与 URL 查询参数同步 round-trip + * + * Feature: admin-web-restructure, Property 8: Tab 切换与 URL 查询参数同步 round-trip + * **Validates: Requirements 10.4** + * + * 对于任意 Tab 视图页面,设置 URL 查询参数 `?tab=X` 后渲染页面, + * 当前激活的 Tab 应为 X;点击 Tab Y 后,URL 查询参数应更新为 `?tab=Y`。 + * + * 当前仅测试 ETLTasks(TriggerManager 待后续任务创建后扩展)。 + */ + +import React from "react"; +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, fireEvent, cleanup } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import * as fc from "fast-check"; + +/* ------------------------------------------------------------------ */ +/* Mock 子组件,避免内部 API 调用 */ +/* ------------------------------------------------------------------ */ + +vi.mock("../pages/TaskConfig", () => ({ + default: () =>
TaskConfig
, +})); +vi.mock("../pages/TaskManager", () => ({ + QueueTab: () =>
QueueTab
, + HistoryTab: () =>
HistoryTab
, +})); +vi.mock("../components/ScheduleTab", () => ({ + default: () =>
ScheduleTab
, +})); +vi.mock("../pages/ETLStatus", () => ({ + default: () =>
ETLStatus
, +})); + +import ETLTasks from "../pages/ETLTasks"; + +/* ------------------------------------------------------------------ */ +/* 辅助:捕获当前 URL search 参数 */ +/* ------------------------------------------------------------------ */ + +function LocationSpy({ onLocation }: { onLocation: (s: string) => void }) { + const location = useLocation(); + React.useEffect(() => { + onLocation(location.search); + }); + return null; +} + +/* ------------------------------------------------------------------ */ +/* 页面配置(可扩展 TriggerManager) */ +/* ------------------------------------------------------------------ */ + +interface TabPageConfig { + name: string; + validTabs: readonly string[]; + defaultTab: string; + Component: React.FC; + basePath: string; + /** Tab label 文本中包含的关键字,用于定位 Tab 元素 */ + tabLabels: Record; +} + +const ETL_TASKS_CONFIG: TabPageConfig = { + name: "ETLTasks", + validTabs: ["config", "queue", "schedule", "history", "status"], + defaultTab: "config", + Component: ETLTasks, + basePath: "/etl-tasks", + tabLabels: { + config: "发起", + queue: "队列", + schedule: "调度", + history: "历史", + status: "状态", + }, +}; + +// TriggerManager 配置占位,待 task 10.1 创建后启用 +// const TRIGGER_MANAGER_CONFIG: TabPageConfig = { ... }; + +const PAGE_CONFIGS: TabPageConfig[] = [ETL_TASKS_CONFIG]; + +/* ------------------------------------------------------------------ */ +/* 每次测试后清理 DOM */ +/* ------------------------------------------------------------------ */ + +afterEach(() => { + cleanup(); +}); + +/* ------------------------------------------------------------------ */ +/* 属性测试 */ +/* ------------------------------------------------------------------ */ + +// DOM 渲染属性测试:numRuns=20 覆盖所有 tab 值多次,避免 jsdom 超时 +const PBT_NUM_RUNS = 20; +// 给 DOM 属性测试更长的超时(30s) +const PBT_TIMEOUT = 30_000; + +describe("Property 8: Tab 切换与 URL 查询参数同步 round-trip", () => { + for (const pageConfig of PAGE_CONFIGS) { + const { name, validTabs, defaultTab, Component, basePath, tabLabels } = pageConfig; + + describe(`${name} 页面`, () => { + // 生成器:从有效 tab 值中随机选取 + const tabArb = fc.constantFrom(...validTabs); + + it("设置 ?tab=X 后,激活的 Tab 应为 X", () => { + fc.assert( + fc.property(tabArb, (tab) => { + cleanup(); + render( + + + , + ); + + // Ant Design Tabs 的激活 tab 有 .ant-tabs-tab-active 类 + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain(tabLabels[tab]); + + cleanup(); + }), + { numRuns: PBT_NUM_RUNS }, + ); + }, PBT_TIMEOUT); + + it("点击 Tab Y 后,URL 查询参数应更新为 ?tab=Y", () => { + fc.assert( + fc.property(tabArb, tabArb, (initialTab, targetTab) => { + cleanup(); + let currentSearch = ""; + + render( + + + { currentSearch = s; }} /> + , + ); + + // 找到目标 Tab 并点击 + const tabElements = document.querySelectorAll(".ant-tabs-tab"); + let targetTabEl: Element | null = null; + for (const el of tabElements) { + if (el.textContent?.includes(tabLabels[targetTab])) { + targetTabEl = el; + break; + } + } + expect(targetTabEl).not.toBeNull(); + fireEvent.click(targetTabEl!); + + // 验证 URL 参数已更新 + const params = new URLSearchParams(currentSearch); + expect(params.get("tab")).toBe(targetTab); + + cleanup(); + }), + { numRuns: PBT_NUM_RUNS }, + ); + }, PBT_TIMEOUT); + + it("无效或缺失的 tab 参数应回退到默认 Tab", () => { + // 无 tab 参数 + render( + + + , + ); + const activeTab1 = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab1).not.toBeNull(); + expect(activeTab1!.textContent).toContain(tabLabels[defaultTab]); + cleanup(); + + // 无效 tab 参数 + render( + + + , + ); + const activeTab2 = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab2).not.toBeNull(); + expect(activeTab2!.textContent).toContain(tabLabels[defaultTab]); + cleanup(); + }); + }); + } +}); diff --git a/apps/admin-web/src/__tests__/triggerManager.test.tsx b/apps/admin-web/src/__tests__/triggerManager.test.tsx new file mode 100644 index 0000000..0c02060 --- /dev/null +++ b/apps/admin-web/src/__tests__/triggerManager.test.tsx @@ -0,0 +1,298 @@ +/** + * 单元测试:TriggerManager 页面 + * + * _Requirements: 4.1, 4.3, 4.4, 4.7_ + * + * - 测试 4 个 Tab 正确渲染 + * - 测试"全部"Tab 为只读(无编辑按钮) + * - 测试"业务"Tab 编辑表单仅包含 cron_expression 和 interval_seconds + * - 测试 422 错误在表单中展示具体错误信息 + */ + +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { MemoryRouter } from "react-router-dom"; + +/* ------------------------------------------------------------------ */ +/* Ant Design jsdom 兼容:polyfill window.matchMedia */ +/* ------------------------------------------------------------------ */ + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +/* ------------------------------------------------------------------ */ +/* Mock API 模块 */ +/* ------------------------------------------------------------------ */ + +const mockFetchUnifiedTriggers = vi.fn().mockResolvedValue([ + { + id: 1, + name: "同步会员数据", + source: "biz", + trigger_condition: "cron", + status: "running", + last_run_at: "2026-07-15T10:00:00", + next_run_at: "2026-07-15T12:00:00", + last_error: null, + }, + { + id: 2, + name: "AI 事件链", + source: "ai", + trigger_condition: "event", + status: "idle", + last_run_at: null, + next_run_at: null, + last_error: null, + }, +]); + +const mockFetchTriggerJobs = vi.fn().mockResolvedValue([ + { + id: 101, + job_type: "sync", + job_name: "sync_members", + trigger_condition: "cron", + trigger_config: { cron_expression: "0 */2 * * *", interval_seconds: 7200 }, + last_run_at: "2026-07-15T10:00:00", + next_run_at: "2026-07-15T12:00:00", + status: "enabled", + description: "同步会员数据", + last_error: null, + created_at: "2026-01-01T00:00:00", + }, +]); + +const mockUpdateTriggerConfig = vi.fn().mockResolvedValue({ + id: 101, + job_type: "sync", + job_name: "sync_members", + trigger_condition: "cron", + trigger_config: { cron_expression: "0 */3 * * *", interval_seconds: 7200 }, + last_run_at: "2026-07-15T10:00:00", + next_run_at: "2026-07-15T15:00:00", + status: "enabled", + description: "同步会员数据", + last_error: null, + created_at: "2026-01-01T00:00:00", +}); + +const mockFetchSchedules = vi.fn().mockResolvedValue([]); + +vi.mock("../api/triggers", () => ({ + fetchUnifiedTriggers: (...args: unknown[]) => mockFetchUnifiedTriggers(...args), +})); + +vi.mock("../api/triggerJobs", () => ({ + fetchTriggerJobs: (...args: unknown[]) => mockFetchTriggerJobs(...args), + updateTriggerConfig: (...args: unknown[]) => mockUpdateTriggerConfig(...args), +})); + +vi.mock("../api/schedules", () => ({ + fetchSchedules: (...args: unknown[]) => mockFetchSchedules(...args), +})); + +vi.mock("../pages/AIOperations", () => ({ + default: () =>
AIOperations Mock
, +})); + +vi.mock("../pages/AITriggerJobs", () => ({ + default: () =>
AITriggerJobs Mock
, +})); + +import TriggerManager from "../pages/TriggerManager"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:4 个 Tab 正确渲染(Requirement 4.1) */ +/* ------------------------------------------------------------------ */ + +describe("TriggerManager — 4 个 Tab 渲染 (Requirement 4.1)", () => { + it("渲染 4 个 Tab 标签:全部、业务、AI、ETL", async () => { + render( + + + , + ); + + await waitFor(() => { + const tabs = document.querySelectorAll(".ant-tabs-tab"); + expect(tabs).toHaveLength(4); + }); + + const tabs = document.querySelectorAll(".ant-tabs-tab"); + const tabTexts = Array.from(tabs).map((t) => t.textContent); + expect(tabTexts).toContain("全部"); + expect(tabTexts).toContain("业务"); + expect(tabTexts).toContain("AI"); + expect(tabTexts).toContain("ETL"); + }); + + it("默认激活「全部」Tab", () => { + render( + + + , + ); + + const activeTab = document.querySelector(".ant-tabs-tab-active"); + expect(activeTab).not.toBeNull(); + expect(activeTab!.textContent).toContain("全部"); + }); + + it("页面标题包含「触发器管理」", () => { + render( + + + , + ); + + expect(screen.getByText("触发器管理")).toBeInTheDocument(); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:"全部"Tab 为只读(Requirement 4.3) */ +/* ------------------------------------------------------------------ */ + +describe("TriggerManager — 全部 Tab 只读 (Requirement 4.3)", () => { + it("「全部」Tab 表格中无「编辑」按钮", async () => { + render( + + + , + ); + + // 等待统一视图数据加载完成 + await waitFor(() => { + expect(mockFetchUnifiedTriggers).toHaveBeenCalled(); + }); + + // 等待表格渲染数据 + await waitFor(() => { + expect(screen.getByText("同步会员数据")).toBeInTheDocument(); + }); + + // 全部 Tab 不应有编辑按钮 + const editButtons = screen.queryAllByRole("button", { name: /编辑/ }); + expect(editButtons).toHaveLength(0); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:"业务"Tab 编辑表单字段(Requirement 4.4, 4.7) */ +/* ------------------------------------------------------------------ */ + +describe("TriggerManager — 业务 Tab 编辑表单 (Requirements 4.4, 4.7)", () => { + it("编辑 Modal 仅包含 cron_expression 和 interval_seconds 两个字段", async () => { + render( + + + , + ); + + // 等待业务触发器数据加载 + await waitFor(() => { + expect(mockFetchTriggerJobs).toHaveBeenCalled(); + }); + + // 等待表格渲染 + await waitFor(() => { + expect(screen.getByText("同步会员数据")).toBeInTheDocument(); + }); + + // 点击编辑按钮 + const editBtn = screen.getByRole("button", { name: /编辑/ }); + fireEvent.click(editBtn); + + // 等待 Modal 打开 + await waitFor(() => { + expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument(); + }); + + // 验证表单包含 cron_expression 字段 + expect(screen.getByLabelText(/Cron 表达式/)).toBeInTheDocument(); + + // 验证表单包含 interval_seconds 字段 + expect(screen.getByLabelText(/间隔秒数/)).toBeInTheDocument(); + + // 验证 Modal 中的 Form.Item 数量 — 只有 2 个 + const modal = document.querySelector(".ant-modal-body"); + expect(modal).not.toBeNull(); + const formItems = modal!.querySelectorAll(".ant-form-item"); + expect(formItems).toHaveLength(2); + }); +}); + +/* ------------------------------------------------------------------ */ +/* 测试:422 错误展示具体错误信息(Requirement 4.7) */ +/* ------------------------------------------------------------------ */ + +describe("TriggerManager — 422 错误展示 (Requirement 4.7)", () => { + it("422 错误时调用 updateTriggerConfig 并提取 detail 信息", async () => { + const error422 = { + response: { + status: 422, + data: { detail: "cron 表达式格式无效,需要 5 字段格式" }, + }, + }; + mockUpdateTriggerConfig.mockRejectedValueOnce(error422); + + render( + + + , + ); + + // 等待数据加载 + await waitFor(() => { + expect(screen.getByText("同步会员数据")).toBeInTheDocument(); + }); + + // 点击编辑按钮 + const editBtn = screen.getByRole("button", { name: /编辑/ }); + fireEvent.click(editBtn); + + // 等待 Modal 打开 + await waitFor(() => { + expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument(); + }); + + // 填写一个无效的 cron 表达式 + const cronInput = screen.getByLabelText(/Cron 表达式/); + fireEvent.change(cronInput, { target: { value: "invalid-cron" } }); + + // 点击保存(Modal 的确定按钮,Ant Design 渲染为"保 存"带空格) + const okBtn = screen.getByRole("button", { name: /保\s*存/ }); + fireEvent.click(okBtn); + + // 验证 updateTriggerConfig 被调用(触发了 422 错误路径) + await waitFor(() => { + expect(mockUpdateTriggerConfig).toHaveBeenCalledWith(101, { + cron_expression: "invalid-cron", + interval_seconds: 7200, + }); + }); + + // 验证 422 错误后 Modal 仍然打开(未关闭,说明错误被处理而非忽略) + expect(screen.getByText(/编辑触发器配置/)).toBeInTheDocument(); + }); +}); diff --git a/apps/admin-web/src/api/adminAI.ts b/apps/admin-web/src/api/adminAI.ts new file mode 100644 index 0000000..98bf47a --- /dev/null +++ b/apps/admin-web/src/api/adminAI.ts @@ -0,0 +1,277 @@ +/** + * AI 监控后台 API + * + * 对接后端 /api/admin/ai/* 端点,提供 Dashboard、调度任务、调用记录、 + * 缓存管理、Token 预算、批量执行、告警管理等功能。 + */ + +import { apiClient } from "./client"; + +// ---- 类型定义 ---- + +// Dashboard +export interface DailyTrend { + date: string; + calls: number; + success_rate: number; +} + +export interface AppDistItem { + app_type: string; + count: number; + percentage: number; +} + +export interface BudgetInfo { + daily_used: number; + daily_limit: number; + daily_pct: number; + monthly_used: number; + monthly_limit: number; + monthly_pct: number; +} + +export interface AlertItem { + id: number; + app_type: string; + status: string; + alert_status: string | null; + error_message: string | null; + created_at: string; +} + +export interface AppHealthItem { + app_type: string; + last_status: string | null; + last_call_at: string | null; +} + +export interface DashboardResponse { + today_calls: number; + today_success_rate: number; + today_tokens: number; + today_avg_latency_ms: number; + trend_7d: DailyTrend[]; + app_distribution: AppDistItem[]; + budget: BudgetInfo; + recent_alerts: AlertItem[]; + app_health: AppHealthItem[]; +} + +// 调度任务 +export interface TriggerJobItem { + id: number; + event_type: string; + member_id: number | null; + status: string; + app_chain: string | null; + is_forced: boolean; + site_id: number; + started_at: string | null; + finished_at: string | null; + created_at: string; +} + +export interface TriggerJobDetailResponse extends TriggerJobItem { + payload: Record | null; + error_message: string | null; + connector_type: string; +} + +export interface TriggerJobListResponse { + items: TriggerJobItem[]; + total: number; + page: number; + page_size: number; + today_skipped_duplicates: number; +} + +export interface RetryResponse { + trigger_job_id: number; + status: string; +} + +export interface TriggerJobQuery { + event_type?: string; + status?: string; + site_id?: number; + date_from?: string; + date_to?: string; + page?: number; + page_size?: number; +} + +// 调用记录 +export interface RunLogItem { + id: number; + app_type: string; + trigger_type: string; + member_id: number | null; + tokens_used: number; + latency_ms: number | null; + status: string; + site_id: number; + created_at: string; +} + +export interface RunLogDetailResponse extends RunLogItem { + request_prompt: string | null; + response_text: string | null; + error_message: string | null; + session_id: string | null; + finished_at: string | null; +} + +export interface RunLogListResponse { + items: RunLogItem[]; + total: number; + page: number; + page_size: number; +} + +export interface RunLogQuery { + app_type?: string; + status?: string; + trigger_type?: string; + site_id?: number; + date_from?: string; + date_to?: string; + page?: number; + page_size?: number; +} + +// 缓存管理 +export interface CacheInvalidateReq { + site_id: number; + app_type?: string; + member_id?: number; +} + +export interface CacheInvalidateResponse { + affected_count: number; +} + +// Token 预算 +export interface BudgetResponse { + daily_used: number; + daily_limit: number; + daily_pct: number; + monthly_used: number; + monthly_limit: number; + monthly_pct: number; +} + +// 批量执行 +export interface BatchRunReq { + app_types: string[]; + member_ids: number[]; + site_id: number; +} + +export interface BatchRunEstimate { + batch_id: string; + estimated_calls: number; + estimated_tokens: number; +} + +export interface BatchRunConfirmResponse { + status: string; +} + +// 告警 +export interface AlertQuery { + alert_status?: string; + site_id?: number; + page?: number; + page_size?: number; +} + +export interface AlertListResponse { + items: AlertItem[]; + total: number; + page: number; + page_size: number; +} + +export interface AlertActionResponse { + id: number; + alert_status: string; +} + +// ---- API 调用 ---- + +// Dashboard +export async function getDashboard(siteId?: number): Promise { + const { data } = await apiClient.get("/admin/ai/dashboard", { + params: siteId != null ? { site_id: siteId } : undefined, + }); + return data; +} + +// 调度任务 +export async function getTriggerJobs(params: TriggerJobQuery): Promise { + const { data } = await apiClient.get("/admin/ai/trigger-jobs", { params }); + return data; +} + +export async function getTriggerJobDetail(id: number): Promise { + const { data } = await apiClient.get(`/admin/ai/trigger-jobs/${id}`); + return data; +} + +export async function retryTriggerJob(id: number): Promise { + const { data } = await apiClient.post(`/admin/ai/trigger-jobs/${id}/retry`); + return data; +} + +// 调用记录 +export async function getRunLogs(params: RunLogQuery): Promise { + const { data } = await apiClient.get("/admin/ai/run-logs", { params }); + return data; +} + +export async function getRunLogDetail(id: number): Promise { + const { data } = await apiClient.get(`/admin/ai/run-logs/${id}`); + return data; +} + +// 缓存管理 +export async function invalidateCache(body: CacheInvalidateReq): Promise { + const { data } = await apiClient.post("/admin/ai/cache/invalidate", body); + return data; +} + +// Token 预算 +export async function getBudget(): Promise { + const { data } = await apiClient.get("/admin/ai/budget"); + return data; +} + +// 批量执行 +export async function createBatchRun(body: BatchRunReq): Promise { + const { data } = await apiClient.post("/admin/ai/batch-run", body); + return data; +} + +export async function confirmBatchRun(batchId: string): Promise { + const { data } = await apiClient.post("/admin/ai/batch-run/confirm", { + batch_id: batchId, + }); + return data; +} + +// 告警 +export async function getAlerts(params: AlertQuery): Promise { + const { data } = await apiClient.get("/admin/ai/alerts", { params }); + return data; +} + +export async function ackAlert(id: number): Promise { + const { data } = await apiClient.post(`/admin/ai/alerts/${id}/ack`); + return data; +} + +export async function ignoreAlert(id: number): Promise { + const { data } = await apiClient.post(`/admin/ai/alerts/${id}/ignore`); + return data; +} diff --git a/apps/admin-web/src/api/client.ts b/apps/admin-web/src/api/client.ts index 8e86514..c85236e 100644 --- a/apps/admin-web/src/api/client.ts +++ b/apps/admin-web/src/api/client.ts @@ -122,20 +122,22 @@ apiClient.interceptors.response.use( try { // 用独立 axios 调用避免被自身拦截器干扰 - const { data } = await axios.post<{ - access_token: string; - refresh_token: string; + // ResponseWrapperMiddleware 包装响应为 { code: 0, data: { access_token, refresh_token } } + const resp = await axios.post<{ + code: number; + data: { access_token: string; refresh_token: string }; }>("/api/auth/refresh", { refresh_token: refreshToken }); - localStorage.setItem(ACCESS_TOKEN_KEY, data.access_token); - localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token); + const tokens = resp.data.data; + localStorage.setItem(ACCESS_TOKEN_KEY, tokens.access_token); + localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token); - processPendingQueue(data.access_token, null); + processPendingQueue(tokens.access_token, null); // 重放原始请求 originalRequest.headers = { ...originalRequest.headers, - Authorization: `Bearer ${data.access_token}`, + Authorization: `Bearer ${tokens.access_token}`, }; return apiClient(originalRequest); } catch (refreshError) { diff --git a/apps/admin-web/src/api/dbHealth.ts b/apps/admin-web/src/api/dbHealth.ts new file mode 100644 index 0000000..f8fe1b9 --- /dev/null +++ b/apps/admin-web/src/api/dbHealth.ts @@ -0,0 +1,21 @@ +/** + * 数据库健康监控 API 调用。 + */ + +import { apiClient } from './client'; + +/** 数据库健康状态 */ +export interface DbHealthItem { + db_name: string; + status: 'connected' | 'disconnected'; + active_connections: number | null; + idle_connections: number | null; + db_size_mb: number | null; + slow_query_count: number | null; +} + +/** 获取所有数据库健康状态 */ +export async function fetchDbHealth(): Promise { + const { data } = await apiClient.get('/admin/db-health'); + return data; +} diff --git a/apps/admin-web/src/api/devTrace.ts b/apps/admin-web/src/api/devTrace.ts new file mode 100644 index 0000000..3c36af3 --- /dev/null +++ b/apps/admin-web/src/api/devTrace.ts @@ -0,0 +1,84 @@ +/** + * 开发调试全链路日志 API。 + * + * 对接后端 /api/admin/dev-trace/* 端点,提供日志查询、设置管理、 + * 覆盖率扫描、手动清理等功能。 + */ + +import { apiClient } from "./client"; +import type { + TraceFilter, + TraceRequest, + TraceDetail, + TraceSettings, + TraceCoverage, +} from "../types/devTrace"; + +// ---- 响应类型 ---- + +export interface TraceRequestListResponse { + items: TraceRequest[]; + total: number; + page: number; + page_size: number; +} + +export interface CleanupResponse { + deleted_dates: string[]; + deleted_files: number; +} + +// ---- 日期列表 ---- + +export async function fetchDates(): Promise<{ dates: string[] }> { + const { data } = await apiClient.get<{ dates: string[] }>("/admin/dev-trace/dates"); + return data; +} + +// ---- 请求列表(分页 + 筛选) ---- + +export async function fetchRequests(params: TraceFilter): Promise { + const { data } = await apiClient.get("/admin/dev-trace/requests", { params }); + return data; +} + +// ---- 请求详情(含完整 spans) ---- + +export async function fetchRequestDetail(requestId: string): Promise { + const { data } = await apiClient.get(`/admin/dev-trace/request/${requestId}`); + return data; +} + +// ---- 手动清理 ---- + +export async function cleanupLogs(startDate: string, endDate: string): Promise { + const { data } = await apiClient.post("/admin/dev-trace/cleanup", { + start_date: startDate, + end_date: endDate, + }); + return data; +} + +// ---- 设置 ---- + +export async function fetchSettings(): Promise { + const { data } = await apiClient.get("/admin/dev-trace/settings"); + return data; +} + +export async function updateSettings(settings: Partial): Promise { + const { data } = await apiClient.put("/admin/dev-trace/settings", settings); + return data; +} + +// ---- 覆盖率 ---- + +export async function fetchCoverage(): Promise { + const { data } = await apiClient.get("/admin/dev-trace/coverage"); + return data; +} + +export async function triggerCoverageScan(): Promise { + const { data } = await apiClient.post("/admin/dev-trace/coverage/scan"); + return data; +} diff --git a/apps/admin-web/src/api/etlStatus.ts b/apps/admin-web/src/api/etlStatus.ts index 21316be..338dc1b 100644 --- a/apps/admin-web/src/api/etlStatus.ts +++ b/apps/admin-web/src/api/etlStatus.ts @@ -10,8 +10,8 @@ import { apiClient } from './client'; /** ETL 游标信息 */ export interface CursorInfo { task_code: string; - last_fetch_time: string | null; - record_count: number | null; + last_start: string | null; + last_end: string | null; } /** 最近执行记录 */ diff --git a/apps/admin-web/src/api/execution.ts b/apps/admin-web/src/api/execution.ts index 1991103..f573849 100644 --- a/apps/admin-web/src/api/execution.ts +++ b/apps/admin-web/src/api/execution.ts @@ -45,3 +45,17 @@ export async function deleteFromQueue(id: string): Promise { export async function cancelExecution(id: string): Promise { await apiClient.post(`/execution/${id}/cancel`); } + +// CHANGE 2026-03-22 | 重新执行历史任务 +/** 重新执行指定的历史任务 */ +export async function rerunExecution(id: string): Promise<{ execution_id: string }> { + const { data } = await apiClient.post<{ execution_id: string }>(`/execution/${id}/rerun`); + return data; +} + +// CHANGE 2026-03-27 | 清理输出目录,每类任务只保留最近 10 个运行记录 +/** 清理 EXPORT_ROOT 下旧运行记录 */ +export async function cleanupOutput(): Promise<{ task_folders_scanned: number; dirs_deleted: number; errors: string[] }> { + const { data } = await apiClient.post<{ task_folders_scanned: number; dirs_deleted: number; errors: string[] }>('/execution/cleanup-output'); + return data; +} diff --git a/apps/admin-web/src/api/registry.ts b/apps/admin-web/src/api/registry.ts new file mode 100644 index 0000000..cc7505c --- /dev/null +++ b/apps/admin-web/src/api/registry.ts @@ -0,0 +1,134 @@ +/** + * 注册体系 API 调用(租户/店铺/简写ID/同步)。 + * + * 复用 apiClient(已含 JWT 拦截器 + 响应解包)。 + * 端点前缀:/admin + */ + +import { apiClient } from "./client"; + +/* ------------------------------------------------------------------ */ +/* 类型定义 */ +/* ------------------------------------------------------------------ */ + +/** 租户列表项(后端 CamelModel 序列化为 camelCase) */ +export interface TenantItem { + id: number; + tenantId: number; + tenantName: string; + connectorName: string; + isActive: boolean; +} + +/** 店铺列表项 */ +export interface SiteItem { + id: number; + siteId: number; + siteName: string; + siteCode: string | null; + siteLabel: string | null; + isActive: boolean; +} + +/** 设置/修改简写ID 请求 */ +export interface UpdateSiteCodeRequest { + newCode: string; +} + +/** 简写ID 修改结果 */ +export interface SiteCodeResult { + siteId: number; + oldCode: string | null; + newCode: string; + historyCleaned: boolean; +} + +/** 简写ID 变更历史条目 */ +export interface SiteCodeHistoryItem { + id: number; + siteCode: string; + isCurrent: boolean; + createdAt: string; + retiredAt: string | null; +} + +/** 店铺同步结果 */ +export interface SiteSyncResult { + inserted: number; + updated: number; +} + +/* ------------------------------------------------------------------ */ +/* API 调用 */ +/* ------------------------------------------------------------------ */ + +/** 获取所有活跃租户列表 */ +export async function fetchTenants(): Promise { + const { data } = await apiClient.get("/admin/tenants"); + return data; +} + +/** 获取指定租户下所有活跃店铺 */ +export async function fetchTenantSites( + tenantId: number, +): Promise { + const { data } = await apiClient.get( + `/admin/tenants/${tenantId}/sites`, + ); + return data; +} + +/** 设置/修改店铺简写ID */ +export async function updateSiteCode( + siteId: number, + newCode: string, +): Promise { + const { data } = await apiClient.put( + `/admin/sites/${siteId}/site-code`, + { newCode } satisfies UpdateSiteCodeRequest, + ); + return data; +} + +/** 查看简写ID 变更历史 */ +export async function fetchSiteCodeHistory( + siteId: number, +): Promise { + const { data } = await apiClient.get( + `/admin/sites/${siteId}/site-code-history`, + ); + return data; +} + +/** 手动触发店铺同步 */ +export async function syncSites(): Promise { + const { data } = await apiClient.post( + "/admin/sites/sync", + ); + return data; +} + +/* ------------------------------------------------------------------ */ +/* 测试功能:手动创建/删除店铺 */ +/* ------------------------------------------------------------------ */ + +/** 创建店铺请求 */ +export interface CreateSitePayload { + tenantId: number; + siteId: number; + siteName: string; + siteCode?: string; +} + +/** 手动创建店铺(测试功能) */ +export async function createSite( + payload: CreateSitePayload, +): Promise { + const { data } = await apiClient.post("/admin/sites", payload); + return data; +} + +/** 删除店铺(测试功能,硬删除) */ +export async function deleteSite(id: number): Promise { + await apiClient.delete(`/admin/sites/${id}`); +} diff --git a/apps/admin-web/src/api/schedules.ts b/apps/admin-web/src/api/schedules.ts index 81ac40f..349561a 100644 --- a/apps/admin-web/src/api/schedules.ts +++ b/apps/admin-web/src/api/schedules.ts @@ -3,7 +3,7 @@ */ import { apiClient } from './client'; -import type { ScheduledTask, ScheduleConfig, TaskConfig, ExecutionLog } from '../types'; +import type { ScheduledTask, ScheduleConfig, TaskConfig, ExecutionLog, MinRunIntervalItem } from '../types'; /** 获取调度任务列表 */ export async function fetchSchedules(): Promise { @@ -18,6 +18,9 @@ export async function createSchedule(payload: { task_config: TaskConfig; schedule_config: ScheduleConfig; run_immediately?: boolean; + min_run_interval_value?: number; + min_run_interval_unit?: string; + min_run_intervals?: Record; }): Promise { const { data } = await apiClient.post('/schedules', payload); return data; @@ -31,6 +34,9 @@ export async function updateSchedule( task_codes: string[]; task_config: TaskConfig; schedule_config: ScheduleConfig; + min_run_interval_value: number; + min_run_interval_unit: string; + min_run_intervals: Record; }>, ): Promise { const { data } = await apiClient.put(`/schedules/${id}`, payload); @@ -49,8 +55,10 @@ export async function toggleSchedule(id: string): Promise { } /** 手动执行调度任务一次(不更新调度间隔) */ -export async function runScheduleNow(id: string): Promise<{ message: string; task_id: string }> { - const { data } = await apiClient.post<{ message: string; task_id: string }>(`/schedules/${id}/run`); +export async function runScheduleNow(id: string, force = false): Promise<{ message: string; task_id: string }> { + const { data } = await apiClient.post<{ message: string; task_id: string }>(`/schedules/${id}/run`, null, { + params: force ? { force: true } : undefined, + }); return data; } diff --git a/apps/admin-web/src/api/taskEngine.ts b/apps/admin-web/src/api/taskEngine.ts new file mode 100644 index 0000000..53e5231 --- /dev/null +++ b/apps/admin-web/src/api/taskEngine.ts @@ -0,0 +1,187 @@ +/** + * P18 任务引擎运营看板 API。 + * + * 端点前缀:/api/admin/task-engine + */ + +import { apiClient } from "./client"; + +/* ------------------------------------------------------------------ */ +/* 类型定义 */ +/* ------------------------------------------------------------------ */ + +export interface TransferLogItem { + id: number; + site_id: number; + site_name: string; + member_id: number; + member_name: string; + from_assistant_id: number; + from_assistant_name: string; + to_assistant_id: number; + to_assistant_name: string; + transfer_reason: string | null; + transfer_score: number | null; + guard_checks: Record | null; + created_at: string; +} + +export interface TransferLogPage { + items: TransferLogItem[]; + total: number; +} + +export interface PendingReviewItem { + id: number; + site_id: number; + site_name: string; + member_id: number; + member_name: string; + assistant_id: number; + assistant_name: string; + task_type: string; + task_type_label: string; + transfer_count: number; + priority_score: number | null; + created_at: string; +} + +export interface PendingReviewPage { + items: PendingReviewItem[]; + total: number; +} + +export interface ConfigParam { + id: number; + site_id: number | null; + site_name: string | null; + param_key: string; + param_value: number; + description: string | null; + updated_at: string; +} + +export interface ConfigParamList { + params: ConfigParam[]; +} + +/* ------------------------------------------------------------------ */ +/* 转移日志 */ +/* ------------------------------------------------------------------ */ + +export interface TransferLogQuery { + site_id?: number; + from_date?: string; + to_date?: string; + assistant_id?: number; + page?: number; + page_size?: number; +} + +export async function fetchTransferLogs( + query: TransferLogQuery = {}, +): Promise { + const resp = await apiClient.get( + "/admin/task-engine/transfer-log", + { params: query }, + ); + return resp.data; +} + +export async function fetchMemberTransferHistory( + memberId: number, +): Promise { + const resp = await apiClient.get( + `/admin/task-engine/transfer-log/${memberId}/history`, + ); + return resp.data; +} + +/* ------------------------------------------------------------------ */ +/* 待审核任务 */ +/* ------------------------------------------------------------------ */ + +export interface PendingReviewQuery { + site_id?: number; + page?: number; + page_size?: number; +} + +export async function fetchPendingReviews( + query: PendingReviewQuery = {}, +): Promise { + const resp = await apiClient.get( + "/admin/task-engine/pending-review", + { params: query }, + ); + return resp.data; +} + +export async function reassignTask( + taskId: number, + toAssistantId: number, +): Promise<{ success: boolean; new_task_id: number | null }> { + const resp = await apiClient.post( + `/admin/task-engine/pending-review/${taskId}/reassign`, + { to_assistant_id: toAssistantId }, + ); + return resp.data; +} + +export async function closeTask( + taskId: number, + reason: string, +): Promise<{ success: boolean }> { + const resp = await apiClient.post( + `/admin/task-engine/pending-review/${taskId}/close`, + { reason }, + ); + return resp.data; +} + +/* ------------------------------------------------------------------ */ +/* 参数管理 */ +/* ------------------------------------------------------------------ */ + +export async function fetchConfigParams( + siteId?: number, +): Promise { + const resp = await apiClient.get( + "/admin/task-engine/config", + { params: siteId != null ? { site_id: siteId } : {} }, + ); + return resp.data; +} + +export async function updateConfigParam( + paramId: number, + paramValue: number, +): Promise<{ success: boolean }> { + const resp = await apiClient.put( + `/admin/task-engine/config/${paramId}`, + { param_value: paramValue }, + ); + return resp.data; +} + +export async function createConfigParam( + siteId: number, + paramKey: string, + paramValue: number, +): Promise<{ success: boolean; id: number }> { + const resp = await apiClient.post("/admin/task-engine/config", { + site_id: siteId, + param_key: paramKey, + param_value: paramValue, + }); + return resp.data; +} + +export async function deleteConfigParam( + paramId: number, +): Promise<{ success: boolean }> { + const resp = await apiClient.delete( + `/admin/task-engine/config/${paramId}`, + ); + return resp.data; +} diff --git a/apps/admin-web/src/api/tenantAdmins.ts b/apps/admin-web/src/api/tenantAdmins.ts new file mode 100644 index 0000000..6d40ccc --- /dev/null +++ b/apps/admin-web/src/api/tenantAdmins.ts @@ -0,0 +1,110 @@ +/** + * 租户管理员 CRUD API 调用。 + * + * 复用 apiClient(已含 JWT 拦截器 + 响应解包)。 + * 端点前缀:/admin/tenant-admins + */ + +import { apiClient } from "./client"; + +/* ------------------------------------------------------------------ */ +/* 类型定义 */ +/* ------------------------------------------------------------------ */ + +/** 列表项(后端 CamelModel 序列化为 camelCase) */ +export interface TenantAdminItem { + id: number; + username: string; + displayName: string | null; + tenantId: number; + tenantName: string | null; + adminType: string; + managedSiteIds: number[]; + isActive: boolean; + createdAt: string; + lastLoginAt: string | null; +} + +/** 分页响应 */ +export interface TenantAdminListResponse { + items: TenantAdminItem[]; + total: number; + page: number; + page_size: number; +} + +/** 创建请求 */ +export interface TenantAdminCreatePayload { + username: string; + password: string; + displayName: string; + tenantId: number; + managedSiteIds: number[]; +} + +/** 编辑请求(所有字段可选) */ +export interface TenantAdminEditPayload { + username?: string; + displayName?: string; + managedSiteIds?: number[]; + isActive?: boolean; +} + +/** 重置密码请求 */ +export interface ResetPasswordPayload { + newPassword: string; +} + +/* ------------------------------------------------------------------ */ +/* API 调用 */ +/* ------------------------------------------------------------------ */ + +/** 列表查询(分页 + 关键词搜索 + 可选包含已禁用) */ +export async function fetchTenantAdmins(params: { + page?: number; + page_size?: number; + keyword?: string; + include_inactive?: boolean; +}): Promise { + const { data } = await apiClient.get( + "/admin/tenant-admins", + { params }, + ); + return data; +} + +/** 创建租户管理员 */ +export async function createTenantAdmin( + payload: TenantAdminCreatePayload, +): Promise { + const { data } = await apiClient.post( + "/admin/tenant-admins", + payload, + ); + return data; +} + +/** 编辑租户管理员 */ +export async function editTenantAdmin( + id: number, + payload: TenantAdminEditPayload, +): Promise { + const { data } = await apiClient.patch( + `/admin/tenant-admins/${id}`, + payload, + ); + return data; +} + +/** 重置密码 */ +export async function resetTenantAdminPassword( + id: number, + payload: ResetPasswordPayload, +): Promise { + await apiClient.post(`/admin/tenant-admins/${id}/reset-password`, payload); +} + +/** 删除租户管理员(软删除) */ +export async function deleteTenantAdmin(id: number): Promise { + await apiClient.delete(`/admin/tenant-admins/${id}`); +} diff --git a/apps/admin-web/src/api/triggerJobs.ts b/apps/admin-web/src/api/triggerJobs.ts new file mode 100644 index 0000000..b6295eb --- /dev/null +++ b/apps/admin-web/src/api/triggerJobs.ts @@ -0,0 +1,62 @@ +/** + * 定时任务管理 API 调用。 + */ + +import { apiClient } from './client'; + +/** 定时任务信息 */ +export interface TriggerJob { + id: number; + job_type: string; + job_name: string; + trigger_condition: string; + trigger_config: Record | null; + last_run_at: string | null; + next_run_at: string | null; + status: string; + description: string | null; + last_error: string | null; + created_at: string | null; +} + +/** 手动执行结果 */ +export interface RunJobResult { + success: boolean; + message: string; +} + +/** 获取所有定时任务 */ +export async function fetchTriggerJobs(): Promise { + const { data } = await apiClient.get('/trigger-jobs'); + return data; +} + +/** 手动执行指定任务 */ +export async function runTriggerJob(jobId: number): Promise { + const { data } = await apiClient.post(`/trigger-jobs/${jobId}/run`); + return data; +} + +/** 触发器配置更新请求 */ +export interface UpdateTriggerConfigReq { + cron_expression?: string; + interval_seconds?: number; +} + +/** 更新触发器配置(cron 表达式或间隔秒数) */ +export async function updateTriggerConfig( + jobId: number, + body: UpdateTriggerConfigReq, +): Promise { + const { data } = await apiClient.patch( + `/trigger-jobs/${jobId}/config`, + body, + ); + return data; +} + +/** 【测试】清空所有 coach_tasks */ +export async function clearAllTasks(): Promise { + const { data } = await apiClient.delete('/admin/task-engine/clear-all-tasks'); + return data; +} diff --git a/apps/admin-web/src/api/triggers.ts b/apps/admin-web/src/api/triggers.ts new file mode 100644 index 0000000..8074fa6 --- /dev/null +++ b/apps/admin-web/src/api/triggers.ts @@ -0,0 +1,23 @@ +/** + * 触发器统一视图 API 调用。 + */ + +import { apiClient } from './client'; + +/** 统一触发器条目 */ +export interface UnifiedTriggerItem { + id: number; + name: string; + source: 'biz' | 'ai' | 'etl'; + trigger_condition: string; + status: string; + last_run_at: string | null; + next_run_at: string | null; + last_error: string | null; +} + +/** 获取所有触发器统一视图 */ +export async function fetchUnifiedTriggers(): Promise { + const { data } = await apiClient.get('/admin/triggers/unified'); + return data; +} diff --git a/apps/admin-web/src/components/DbHealthCard.tsx b/apps/admin-web/src/components/DbHealthCard.tsx new file mode 100644 index 0000000..807a9aa --- /dev/null +++ b/apps/admin-web/src/components/DbHealthCard.tsx @@ -0,0 +1,167 @@ +/** + * 数据库健康监控卡片 + * + * 展示每个数据库的连接池状态、大小、慢查询数量。 + * 纯展示组件,接收 DbHealthItem[] 数据 + 加载/超时状态。 + * + * - connected:展示完整指标(进度条 + 统计数值) + * - disconnected:显示"未连接"状态标签 + * - 加载超时:展示"加载超时"状态 + 重试按钮 + */ + +import React from "react"; +import { + Card, + Row, + Col, + Tag, + Progress, + Statistic, + Button, + Spin, + Empty, +} from "antd"; +import { + DatabaseOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ReloadOutlined, + WarningOutlined, +} from "@ant-design/icons"; +import type { DbHealthItem } from "../api/dbHealth"; + +export interface DbHealthCardProps { + /** 数据库健康数据列表 */ + items: DbHealthItem[]; + /** 是否正在加载 */ + loading?: boolean; + /** 是否加载超时 */ + timeout?: boolean; + /** 重试回调(超时时展示重试按钮) */ + onRetry?: () => void; +} + +/** 连接池总数(活跃 + 空闲),用于计算进度条百分比 */ +function poolPercent(active: number | null, idle: number | null): number { + if (active == null || idle == null) return 0; + const total = active + idle; + if (total === 0) return 0; + return Math.round((active / total) * 100); +} + +const DbHealthCard: React.FC = ({ + items, + loading = false, + timeout = false, + onRetry, +}) => { + // 加载超时状态 + if (timeout) { + return ( + +
+ +
+ }>加载超时 +
+ {onRetry && ( + + )} +
+
+ ); + } + + // 加载中 + if (loading) { + return ( + +
+ +
+
+ ); + } + + // 无数据 + if (!items || items.length === 0) { + return ( + + + + ); + } + + return ( + + + {items.map((item) => ( + + + + {item.db_name} + + } + extra={ + item.status === "connected" ? ( + }>已连接 + ) : ( + }>未连接 + ) + } + > + {item.status === "connected" ? ( + <> + {/* 连接池进度条 */} +
+
+ 连接池(活跃 {item.active_connections ?? 0} / 空闲 {item.idle_connections ?? 0}) +
+ `${pct}%`} + /> +
+ {/* 统计指标 */} + + + + + + 0 ? "#faad14" : undefined, + }} + /> + + + + ) : ( +
+ 数据库未连接,无法获取指标 +
+ )} +
+ + ))} +
+
+ ); +}; + +export default DbHealthCard; diff --git a/apps/admin-web/src/components/ScheduleTab.tsx b/apps/admin-web/src/components/ScheduleTab.tsx index 47b195b..478f275 100644 --- a/apps/admin-web/src/components/ScheduleTab.tsx +++ b/apps/admin-web/src/components/ScheduleTab.tsx @@ -15,6 +15,8 @@ import { import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; import type { ScheduledTask, ScheduleConfig } from '../types'; import { fetchSchedules, @@ -25,6 +27,9 @@ import { } from '../api/schedules'; import ScheduleHistoryDrawer from './ScheduleHistoryDrawer'; +dayjs.extend(relativeTime); +dayjs.locale('zh-cn'); + const { Text } = Typography; /* ------------------------------------------------------------------ */ @@ -178,6 +183,8 @@ const ScheduleTab: React.FC = () => { const cfg = record.schedule_config; form.setFieldsValue({ name: record.name, + min_run_interval_value: record.min_run_interval_value ?? 0, + min_run_interval_unit: record.min_run_interval_unit ?? 'minutes', schedule_config: { ...cfg, daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined, @@ -226,6 +233,8 @@ const ScheduleTab: React.FC = () => { await updateSchedule(editing.id, { name: values.name, schedule_config: scheduleConfig, + min_run_interval_value: values.min_run_interval_value ?? 0, + min_run_interval_unit: values.min_run_interval_unit ?? 'minutes', }); message.success('调度任务已更新'); @@ -259,13 +268,32 @@ const ScheduleTab: React.FC = () => { } }; - /* 手动执行一次(不更新调度间隔) */ - const handleRunNow = async (id: string) => { + /* 手动执行 — 状态 */ + const [runConfirmOpen, setRunConfirmOpen] = useState(false); + const [runForceCheck, setRunForceCheck] = useState(false); + const [runTargetId, setRunTargetId] = useState(null); + + /* 打开手动执行确认 Modal */ + const openRunConfirm = (id: string) => { + setRunTargetId(id); + setRunForceCheck(false); + setRunConfirmOpen(true); + }; + + /* 确认手动执行 */ + const handleRunNow = async () => { + if (!runTargetId) return; try { - await runScheduleNow(id); + await runScheduleNow(runTargetId, runForceCheck); message.success('已提交到执行队列'); - } catch { - message.error('执行失败'); + setRunConfirmOpen(false); + } catch (err: unknown) { + const axiosErr = err as { response?: { status?: number; data?: { detail?: string } } }; + if (axiosErr?.response?.status === 409) { + message.warning(axiosErr.response.data?.detail ?? '任务正在运行或未达到最小间隔'); + } else { + message.error('执行失败'); + } } }; @@ -321,17 +349,33 @@ const ScheduleTab: React.FC = () => { render: (s: string | null) => s ? {s} : '—', }, + { + title: '最小间隔', + dataIndex: 'min_run_interval_value', + key: 'min_run_interval', + width: 120, + render: (value: number, record: ScheduledTask) => { + if (!value) return '无限制'; + const unit = INTERVAL_UNIT_LABEL[record.min_run_interval_unit] ?? record.min_run_interval_unit; + return `${value} ${unit}`; + }, + }, + { + title: '上次成功', + dataIndex: 'last_success_at', + key: 'last_success_at', + width: 120, + render: (value: string | null) => (value ? dayjs(value).fromNow() : '—'), + }, { title: '操作', key: 'action', width: 300, render: (_: unknown, record: ScheduledTask) => ( - handleRunNow(record.id)}> - - + @@ -388,6 +432,21 @@ const ScheduleTab: React.FC = () => { + + + + + + + + handleIntervalChange(t.code, "unit", v)} + style={{ width: 72 }} + options={[ + { label: "分钟", value: "minutes" }, + { label: "小时", value: "hours" }, + { label: "天", value: "days" }, + ]} + /> + + )}
))} diff --git a/apps/admin-web/src/components/ops/GitStatusSection.tsx b/apps/admin-web/src/components/ops/GitStatusSection.tsx new file mode 100644 index 0000000..4118066 --- /dev/null +++ b/apps/admin-web/src/components/ops/GitStatusSection.tsx @@ -0,0 +1,145 @@ +/** + * Git 状态与配置区块 + * + * 展示各环境 Git 分支/提交信息,支持 pull / 同步依赖 / 查看 .env 配置。 + * 从 OpsPanel 拆分,可独立使用(如 Dashboard 聚合页)。 + */ + +import React, { useState } from "react"; +import { + Card, + Row, + Col, + Tag, + Button, + Space, + Descriptions, + Modal, + Tooltip, + Typography, + Input, + message, +} from "antd"; +import { + CloudDownloadOutlined, + SyncOutlined, + FileTextOutlined, +} from "@ant-design/icons"; +import type { ServiceStatus, GitInfo } from "../../api/opsPanel"; +import { fetchEnvFile } from "../../api/opsPanel"; + +const { Text } = Typography; +const { TextArea } = Input; + +export interface GitStatusSectionProps { + gitInfos: GitInfo[]; + /** 用于查找 env 对应的 label */ + services: ServiceStatus[]; + actionLoading: Record; + onPull: (env: string) => void; + onSyncDeps: (env: string) => void; +} + +const GitStatusSection: React.FC = ({ + gitInfos, + services, + actionLoading, + onPull, + onSyncDeps, +}) => { + const [envModalOpen, setEnvModalOpen] = useState(false); + const [envModalContent, setEnvModalContent] = useState(""); + const [envModalTitle, setEnvModalTitle] = useState(""); + + const handleViewEnv = async (env: string, label: string) => { + try { + const r = await fetchEnvFile(env); + setEnvModalTitle(`${label} .env 配置`); + setEnvModalContent(r.content); + setEnvModalOpen(true); + } catch { + message.error("读取配置文件失败"); + } + }; + + return ( + <> + + + {gitInfos.map((git) => { + const envCfg = services.find((s) => s.env === git.env); + const label = envCfg?.label ?? git.env; + return ( + + + + + {git.branch} + {git.has_local_changes && ( + + 有变更 + + )} + + + {git.last_commit_hash} + + {git.last_commit_message} + + + + {git.last_commit_time} + + + + + + + + + + ); + })} + + + + {/* 配置查看弹窗 */} + setEnvModalOpen(false)} + footer={null} + width={700} + > +