feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本

包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Neo
2026-04-06 00:03:48 +08:00
parent 70324d8542
commit 6f8f12314f
515 changed files with 76604 additions and 7456 deletions

View File

@@ -1,5 +1,39 @@
{ {
"permissions": { "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": [ "additionalDirectories": [
"C:\\Users\\Administrator\\.claude", "C:\\Users\\Administrator\\.claude",
"c:\\NeoZQYY\\.git" "c:\\NeoZQYY\\.git"

View File

@@ -101,27 +101,35 @@ SYSTEM_LOG_ROOT=C:/NeoZQYY/export/SYSTEM/LOGS
# 后端输出路径 # 后端输出路径
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
BACKEND_LOG_ROOT=C:/NeoZQYY/export/BACKEND/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= DASHSCOPE_API_KEY=
BAILIAN_MODEL=qwen-plus DASHSCOPE_WORKSPACE_ID=
BAILIAN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
BAILIAN_TEST_APP_ID=
# 8 个百炼 AI 应用 ID从百炼平台获取 # 8 个百炼 AI 应用 ID从百炼平台获取,通过 app_id 指定应用
# 应用 1通用对话 | 应用 2财务洞察 | 应用 3客户数据维客线索分析 # 应用 1通用对话 | 应用 2财务洞察 | 应用 3客户数据维客线索分析
# 应用 4关系分析/任务建议 | 应用 5话术参考 | 应用 6备注分析 # 应用 4关系分析/任务建议 | 应用 5话术参考 | 应用 6备注分析
# 应用 7客户分析 | 应用 8维客线索整理 # 应用 7客户分析 | 应用 8维客线索整理
BAILIAN_APP_ID_1_CHAT= DASHSCOPE_APP_ID_1_CHAT=
BAILIAN_APP_ID_2_FINANCE= DASHSCOPE_APP_ID_2_FINANCE=
BAILIAN_APP_ID_3_CLUE= DASHSCOPE_APP_ID_3_CLUE=
BAILIAN_APP_ID_4_ANALYSIS= DASHSCOPE_APP_ID_4_ANALYSIS=
BAILIAN_APP_ID_5_TACTICS= DASHSCOPE_APP_ID_5_TACTICS=
BAILIAN_APP_ID_6_NOTE= DASHSCOPE_APP_ID_6_NOTE=
BAILIAN_APP_ID_7_CUSTOMER= DASHSCOPE_APP_ID_7_CUSTOMER=
BAILIAN_APP_ID_8_CONSOLIDATE= DASHSCOPE_APP_ID_8_CONSOLIDATE=
# 应用 9Session 日志摘要生成Kiro agent_on_stop + batch_generate_summaries 使用)
DASHSCOPE_APP_ID_SUMMARY=
# 内部 API 认证 tokenETL 等内部服务调用 /api/internal/* 端点时使用)
INTERNAL_API_TOKEN=
# 后端 API 地址ETL 触发 AI 事件时使用,如 http://localhost:8000
BACKEND_API_URL=
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# 管道限流配置RateLimiter 请求间隔,秒) # 管道限流配置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_TASKS=PRODUCTS,TABLES,MEMBERS,ASSISTANTS,PACKAGES_DEF,ORDERS,PAYMENTS,REFUNDS,COUPON_USAGE,INVENTORY_CHANGE,TOPUPS,TABLE_DISCOUNT,LEDGER
# RUN_DWS_TASKS= # RUN_DWS_TASKS=
# RUN_INDEX_TASKS= # RUN_INDEX_TASKS=
INDEX_LOOKBACK_DAYS=60 INDEX_LOOKBACK_DAYS=90
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# DWS 月度/薪资配置 # DWS 月度/薪资配置
@@ -340,4 +348,12 @@ ETL_PYTHON_EXECUTABLE=C:/NeoZQYY/.venv/Scripts/python.exe
# 运维面板服务器根目录 # 运维面板服务器根目录
# CHANGE 2026-03-06 | 必须显式设置,消除 __file__ 推算风险 # CHANGE 2026-03-06 | 必须显式设置,消除 __file__ 推算风险
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
OPS_SERVER_BASE=C:/NeoZQYY 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

10
.gitignore vendored
View File

@@ -71,8 +71,6 @@ infra/**/*.secret
*.swp *.swp
*.swo *.swo
*~ *~
.specstory/
.cursorindexingignore
# ===== Claude Code 本地配置 ===== # ===== Claude Code 本地配置 =====
.claude/settings.local.json .claude/settings.local.json
@@ -81,12 +79,8 @@ infra/**/*.secret
*.lnk *.lnk
.Deleted/ .Deleted/
# ===== Kiro 运行时状态 ===== # ===== 归档目录(用户定期手动清理) =====
.kiro/state/ _DEL/
# ===== 运维脚本运行时状态 ===== # ===== 运维脚本运行时状态 =====
scripts/ops/.monitor_token scripts/ops/.monitor_token
# ===== Kiro Powers含敏感 DSN =====
powers/

View File

@@ -1,17 +0,0 @@
---
inclusion: always
---
# AI 执行行为约束
## 上下文保护
目标:避免大量文件内容或命令输出涌入主对话,导致上下文爆炸。
### 委托子代理的场景
- 批量文件读取≥3 个文件)或大范围代码搜索
- 需要探索不熟悉的模块/目录结构
- CLI 命令输出量大或需要多步骤 shell 操作
### 主流程直接处理的场景
- 读取单个已知文件(路径明确、内容可预期)
- 简单的单条命令(如 `uv sync`、单个 pytest 文件)
- 小范围精确搜索(已知关键词和文件范围)

View File

@@ -1,38 +0,0 @@
---
inclusion: always
---
# CLI 环境规范Windows PowerShell
本项目运行在 Windows + PowerShell 环境。以下是构造命令时必须掌握的前置知识。
## PowerShell 语法要点
- 环境变量:`$env:VAR_NAME`(不是 `$VAR_NAME`
- 命令连接符:`;`(不是 `&&`
- `where``Where-Object` 别名,查可执行文件用 `Get-Command <name>`
- 删除文件/目录:`Remove-Item`(不是 `rm -rf`
- 路径分隔符 `\`,但 Python/Node 工具也接受 `/`
## Python 调用
- 项目虚拟环境:`uv run python``.venv\Scripts\python.exe`
- 安装依赖:`uv sync`(不是 `pip install`
- 运行模块:`uv run python -m <module>`
- 系统 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 在执行前自动拦截,此处不再重复。

View File

@@ -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

View File

@@ -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/<spec-name>/` (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/` 对应文档

View File

@@ -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 数据库的实际数据验证,公式和比例关系具有权威性。

View File

@@ -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 层权威规范(强制优先参考)
DWSData 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 = 0Hard floor14-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`
| 档位 | 小时区间 | 抽成(元/小时) | 打赏抽成 | 假期 |
|------|----------|-----------------|----------|------|
| T00 档) | 0-120 | 28 | 50% | 3 天 |
| T11 档) | 120-150 | 18 | 40% | 4 天 |
| T22 档) | 150-180 | 13 | 35% | 5 天 |
| T33 档) | 180-210 | 10 | 30% | 6 天 |
| T44 档) | 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 归入 BILLIARDV5 归入 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 手册和审计记录的全面收集整理。

View File

@@ -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` 的"统一输出路径配置"节

View File

@@ -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`。

View File

@@ -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 注释

View File

@@ -1,7 +0,0 @@
---
inclusion: always
---
# 语言规范
- 说明性文字一律简体中文(对话、文档、注释、变更说明);代码标识符和第三方 CLI 原文保留英文
- 文档与代码变更同步更新;注释只写"为什么/边界/假设"
- 全仓 UTF-8 无 BOM禁止 GBK/Big5 混用

View File

@@ -1,62 +0,0 @@
---
inclusion: always
---
# 编码前需求审问(强制)
AI 在用户清晰度结束的地方开始产生幻觉。因此,在写任何一行代码之前,必须通过持续提问来延伸用户的清晰度,找出思维中的 gaps避免在破碎的基础上构建。
## 触发条件
当用户提出涉及以下任一场景的需求时,进入「审问模式」:
- 新建功能/模块/页面/接口
- 重构或重新设计已有逻辑
- 涉及多模块联动的改动
- 任何需求描述中存在模糊、隐含假设、或未定义边界的情况
## 强制流程
### 1. 进入 Planning 模式
收到需求后,不立即动手,先进入提问循环。每轮提出 3-5 个针对性问题,直到所有维度都有明确答案。
### 2. 必问清单(最低要求)
以下问题必须逐一确认,不得假设答案:
| 维度 | 必问问题 |
|------|----------|
| 用户 | 这是给谁用的?(角色/人群) |
| 核心行为 | 用户执行的核心操作是什么? |
| 完成后果 | 操作完成后发生什么?(跳转/提示/状态变化) |
| 数据写入 | 需要保存什么数据?保存到哪里? |
| 数据展示 | 需要展示什么数据?数据来源? |
| 错误处理 | 出错时发生什么?用户看到什么? |
| 成功反馈 | 成功时发生什么?用户看到什么? |
| 认证 | 需要登录/鉴权吗?什么权限级别? |
| 存储 | 需要数据库吗?哪个库?新表还是已有表? |
| 终端适配 | 需要在手机上工作吗?响应式要求? |
| 边界条件 | 并发/幂等/数据量上限/超时? |
### 3. 追问规则
- 用户回答后,如果答案引出新的未定义项,继续追问
- 不接受"你看着办"作为最终答案——至少确认关键维度
- 每轮追问聚焦于上一轮答案暴露的 gaps
- 当所有必问维度都有明确答案、且无新假设浮出时,才可结束审问
### 4. 输出需求确认摘要
审问结束后,输出一份简洁的「需求确认摘要」,包含:
- 目标用户与场景
- 核心功能描述(一句话)
- 数据流向(输入 → 处理 → 输出/存储)
- 关键约束与边界条件
- 明确排除的内容(不做什么)
用户确认摘要后,才可进入实施阶段。
## 与前置调研的关系
- 本规则在 `pre-change-research.md`(前置调研)之前执行
- 流程顺序:需求审问 → 用户确认 → 前置调研 → 用户确认 → 编码实施
- 如果审问阶段发现需求本身不成立,直接终止,不进入调研
## 例外
- 用户明确说"直接改"、"跳过审问"、"不用问了"
- Bug 修复且用户已给出明确的复现步骤和期望行为
- 纯格式/文档/注释调整
- 用户提供了完整的 spec 文档且所有维度已覆盖

View File

@@ -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 排序)
- 注释/文档纯文字修改(不涉及逻辑描述变更)
- 用户明确说"直接改"或"跳过调研"
- 新建文件且不涉及已有逻辑的修改

View File

@@ -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` 常用命令节。

View File

@@ -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 ServerAI 工具集成) |
| `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 自动加载)。

View File

@@ -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/<date>__<slug>.md
- 是否需要在每个修改文件写入 AI_CHANGELOG
- 是否需要在逻辑变更处加 CHANGE 标记注释

View File

@@ -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 ServerAI 工具集成)
│ ├── server.py
│ └── pyproject.toml
├── packages/shared/ # 跨项目共享包enums, money, datetime_utils
├── db/
│ ├── etl_feiqiu/
│ │ ├── schemas/ # 六层 Schema DDLmeta/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/<YYYY-MM-DD>__<slug>.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/` |

View File

@@ -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

View File

@@ -1,25 +0,0 @@
---
inclusion: always
---
# 技术栈
- Python 3.10+uv workspace4 成员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 自动加载)。

View File

@@ -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` 被误用为路径,创建了垃圾目录。

View File

@@ -40,6 +40,28 @@
"WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram" "WECHAT_DEVTOOLS_PROJECT": "C:\\NeoZQYY\\apps\\miniprogram"
}, },
"disabled": true "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
} }
} }
} }

View File

@@ -5,6 +5,7 @@
} }
], ],
"settings": { "settings": {
"liveServer.settings.port": 5501 "liveServer.settings.port": 5501,
"typescript.autoClosingTags": false
} }
} }

View File

@@ -19,7 +19,7 @@ apps/admin-web/
├── src/ ├── src/
│ ├── App.tsx # 主布局 + 路由配置 + 路由守卫 │ ├── App.tsx # 主布局 + 路由配置 + 路由守卫
│ ├── main.tsx # 应用入口 │ ├── main.tsx # 应用入口
│ ├── pages/ # 8 个功能页面 │ ├── pages/ # 18 个功能页面
│ │ ├── Login.tsx # 登录页 │ │ ├── Login.tsx # 登录页
│ │ ├── TaskConfig.tsx # 任务配置Flow 选择 + 任务勾选 + 参数设置) │ │ ├── TaskConfig.tsx # 任务配置Flow 选择 + 任务勾选 + 参数设置)
│ │ ├── TaskManager.tsx # 任务管理(队列 + 执行历史 + 实时日志) │ │ ├── TaskManager.tsx # 任务管理(队列 + 执行历史 + 实时日志)
@@ -27,13 +27,23 @@ apps/admin-web/
│ │ ├── DBViewer.tsx # 数据库查看器Schema/表/列浏览 + SQL 执行) │ │ ├── DBViewer.tsx # 数据库查看器Schema/表/列浏览 + SQL 执行)
│ │ ├── LogViewer.tsx # 日志查看器 │ │ ├── LogViewer.tsx # 日志查看器
│ │ ├── EnvConfig.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/ # 可复用组件 │ ├── components/ # 可复用组件
│ │ ├── BusinessDayHint.tsx # 营业日提示组件 │ │ ├── BusinessDayHint.tsx # 营业日提示组件
│ │ ├── DwdTableSelector.tsx # DWD 表选择器 │ │ ├── DwdTableSelector.tsx # DWD 表选择器
│ │ ├── ErrorBoundary.tsx # 错误边界 │ │ ├── ErrorBoundary.tsx # 错误边界
│ │ ├── LogStream.tsx # 实时日志流组件 │ │ ├── LogStream.tsx # 实时日志流组件
│ │ ├── ScheduleTab.tsx # 调度配置标签页 │ │ ├── ScheduleTab.tsx # 调度配置标签页(含最小运行间隔、强制执行、上次成功时间)
│ │ └── TaskSelector.tsx # 任务选择器 │ │ └── TaskSelector.tsx # 任务选择器
│ ├── api/ # API 调用层 │ ├── api/ # API 调用层
│ │ ├── client.ts # Axios 实例baseURL + JWT 拦截器) │ │ ├── client.ts # Axios 实例baseURL + JWT 拦截器)
@@ -44,11 +54,17 @@ apps/admin-web/
│ │ ├── etlStatus.ts # ETL 状态 API │ │ ├── etlStatus.ts # ETL 状态 API
│ │ ├── dbViewer.ts # 数据库查看器 API │ │ ├── dbViewer.ts # 数据库查看器 API
│ │ ├── envConfig.ts # 环境配置 API │ │ ├── envConfig.ts # 环境配置 API
│ │ ── opsPanel.ts # 运维面板 API │ │ ── opsPanel.ts # 运维面板 API
│ │ ├── registry.ts # 注册体系 API租户/店铺/简写ID/同步)
│ │ ├── tenantAdmins.ts # 租户管理员 CRUD API
│ │ ├── adminAI.ts # AI 监控后台 APIDashboard/调度/调用/缓存/预算/批量/告警)
│ │ ├── devTrace.ts # DevTrace 全链路日志 API日期/请求/详情/清理/设置/覆盖率)
│ │ └── taskEngine.ts # P18 任务引擎运营看板 API转移日志/待审核/参数管理9 个函数)
│ ├── store/ │ ├── store/
│ │ ├── authStore.ts # Zustand 认证状态JWT 持久化 + hydrate │ │ ├── authStore.ts # Zustand 认证状态JWT 持久化 + hydrate
│ │ └── businessDayStore.ts # 营业日状态管理 │ │ └── businessDayStore.ts # 营业日状态管理
── types/ # TypeScript 类型定义 ── types/ # TypeScript 类型定义
│ │ └── devTrace.ts # DevTrace 类型TraceSpan/TraceRequest/TraceDetail/TraceSettings/TraceCoverage
├── index.html # HTML 入口 ├── index.html # HTML 入口
├── vite.config.ts # Vite 配置 ├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置 ├── tsconfig.json # TypeScript 配置
@@ -106,6 +122,84 @@ ETL 任务的核心配置界面:
- 依赖同步(`uv sync` - 依赖同步(`uv sync`
- 系统资源概况CPU、内存、磁盘 - 系统资源概况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` 组件保护 - 所有功能页面通过 `PrivateRoute` 组件保护
@@ -162,3 +256,9 @@ ETL 任务的核心配置界面:
- [ ] 权限管理界面(角色/权限配置) - [ ] 权限管理界面(角色/权限配置)
- [ ] 暗色主题支持 - [ ] 暗色主题支持
- [ ] 国际化i18n - [ ] 国际化i18n
- [x] 租户管理员 CRUD + 2 步创建 + 软删除NS4.1
- [x] 注册体系管理 — 简写ID 管理 + 店铺同步NS4.1
- [x] 调度任务最小运行间隔 + 强制执行P16
- [x] AI 监控后台 — Dashboard + 调度状态 + 调用明细 + 手动操作P15
- [x] 开发调试全链路日志 — DevTrace 页面 + 覆盖率扫描 + Span 树展示
- [x] P18 任务引擎运营看板 — 转移日志 + 待审核任务 + 参数管理3 页面 + 9 API

View File

@@ -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 }) => {
// 区块 1OpsPanel 子组件(系统资源信息)
// SystemResourceSection 会展示 CPU / 内存 / 磁盘等信息
await expect(page.locator('text=CPU').first()).toBeVisible();
// 区块 2数据库健康监控DbHealthCard
await expect(page.locator('text=数据库').first()).toBeVisible();
// 区块 3AI 运行总览
await expect(page.locator('text=AI 运行总览').first()).toBeVisible();
// 区块 4AI 调度摘要
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/);
});
});

View File

@@ -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');
});
});

View File

@@ -0,0 +1,230 @@
/**
* E2E 测试公共辅助:注入 JWT 令牌 + 通用 API mock。
*
* 认证方式:向 localStorage 写入伪造的 access_token / refresh_token
* 与 authStore.hydrate() 逻辑一致,页面加载后自动识别为已登录状态。
*/
import { type Page } from '@playwright/test';
/* ------------------------------------------------------------------ */
/* 伪造 JWTpayload 可被 authStore.parseJwtPayload 正确解析) */
/* ------------------------------------------------------------------ */
function makeFakeJwt(payload: Record<string, unknown>): 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<void> {
// 先访问 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<void> {
// 运维面板
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: [] }),
}),
);
}

View File

@@ -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/);
});
});

View File

@@ -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');
});
});

View File

@@ -9,7 +9,8 @@
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
@@ -22,11 +23,13 @@
"zustand": "^5.0.5" "zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.58.2",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@types/react": "^19.1.4", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5", "@types/react-dom": "^19.1.5",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
"fast-check": "^4.6.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^6.3.5", "vite": "^6.3.5",

View File

@@ -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'] },
},
],
});

View File

@@ -31,6 +31,9 @@ dependencies:
version: 5.0.5(@types/react@19.1.4)(react@19.1.0) version: 5.0.5(@types/react@19.1.4)(react@19.1.0)
devDependencies: devDependencies:
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@testing-library/jest-dom': '@testing-library/jest-dom':
specifier: ^6.6.3 specifier: ^6.6.3
version: 6.6.3 version: 6.6.3
@@ -46,6 +49,9 @@ devDependencies:
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^4.5.2 specifier: ^4.5.2
version: 4.5.2(vite@6.3.5) version: 4.5.2(vite@6.3.5)
fast-check:
specifier: ^4.6.0
version: 4.6.0
jsdom: jsdom:
specifier: ^26.1.0 specifier: ^26.1.0
version: 26.1.0 version: 26.1.0
@@ -646,6 +652,14 @@ packages:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
dev: true 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: /@rc-component/async-validator@5.1.0:
resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==} resolution: {integrity: sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==}
engines: {node: '>=14.x'} engines: {node: '>=14.x'}
@@ -1527,6 +1541,13 @@ packages:
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
dev: true 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): /fdir@6.5.0(picomatch@4.0.3):
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1560,6 +1581,14 @@ packages:
mime-types: 2.1.35 mime-types: 2.1.35
dev: false 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: /fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1823,6 +1852,22 @@ packages:
engines: {node: '>=12'} engines: {node: '>=12'}
dev: true 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: /postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -1850,6 +1895,10 @@ packages:
engines: {node: '>=6'} engines: {node: '>=6'}
dev: true 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): /rc-cascader@3.33.1(react-dom@19.1.0)(react@19.1.0):
resolution: {integrity: sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==} resolution: {integrity: sha512-Kyl4EJ7ZfCBuidmZVieegcbFw0RcU5bHHSbtEdmuLYd0fYHCAiYKZ6zon7fWAVyC6rWWOOib0XKdTSf7ElC9rg==}
peerDependencies: peerDependencies:

View File

@@ -2,8 +2,15 @@
* 主布局与路由配置。 * 主布局与路由配置。
* *
* - Ant Design LayoutSider + Content + Footer状态栏 * - Ant Design LayoutSider + Content + Footer状态栏
* - react-router-dom6 个功能页面路由 + 登录页路由 * - 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"; import React, { useEffect, useState, useCallback } from "react";
@@ -12,12 +19,12 @@ import { Layout, Menu, Spin, Space, Typography, Tag, Button, Tooltip } from "ant
import { import {
SettingOutlined, SettingOutlined,
UnorderedListOutlined, UnorderedListOutlined,
ToolOutlined,
DatabaseOutlined,
DashboardOutlined, DashboardOutlined,
FileTextOutlined, ClockCircleOutlined,
LogoutOutlined, LogoutOutlined,
DesktopOutlined, TeamOutlined,
BugOutlined,
ApartmentOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import type { MenuProps } from "antd"; import type { MenuProps } from "antd";
import { useAuthStore } from "./store/authStore"; import { useAuthStore } from "./store/authStore";
@@ -25,31 +32,85 @@ import { useBusinessDayStore } from "./store/businessDayStore";
import { fetchQueue } from "./api/execution"; import { fetchQueue } from "./api/execution";
import type { QueuedTask } from "./types"; import type { QueuedTask } from "./types";
import Login from "./pages/Login"; import Login from "./pages/Login";
import TaskConfig from "./pages/TaskConfig";
import TaskManager from "./pages/TaskManager";
import EnvConfig from "./pages/EnvConfig"; import EnvConfig from "./pages/EnvConfig";
import DBViewer from "./pages/DBViewer"; import DBViewer from "./pages/DBViewer";
import ETLStatus from "./pages/ETLStatus"; import TenantAdmins from "./pages/TenantAdmins";
import LogViewer from "./pages/LogViewer"; import AIRunLogs from "./pages/AIRunLogs";
import OpsPanel from "./pages/OpsPanel"; 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 { Sider, Content, Footer } = Layout;
const { Text } = Typography; const { Text } = Typography;
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 侧边栏导航配置 */ /* 侧边栏导航配置7 个一级菜单) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const NAV_ITEMS: MenuProps["items"] = [ export const NAV_ITEMS: MenuProps["items"] = [
{ key: "/", icon: <SettingOutlined />, label: "任务配置" }, { key: "/dashboard", icon: <DashboardOutlined />, label: "运行状态" },
{ key: "/task-manager", icon: <UnorderedListOutlined />, label: "任务管理" }, { key: "/etl-tasks", icon: <UnorderedListOutlined />, label: "ETL 任务管理" },
{ key: "/etl-status", icon: <DashboardOutlined />, label: "ETL 状态" }, {
{ key: "/db-viewer", icon: <DatabaseOutlined />, label: "数据库" }, key: "task-engine-group", icon: <ApartmentOutlined />, label: "小程序任务管理",
{ key: "/log-viewer", icon: <FileTextOutlined />, label: "日志" }, children: [
{ key: "/env-config", icon: <ToolOutlined />, label: "环境配置" }, { key: "/task-engine/trigger-jobs", label: "定时任务" },
{ key: "/ops-panel", icon: <DesktopOutlined />, label: "运维面板" }, { key: "/task-engine/transfer-log", label: "转移日志" },
{ key: "/task-engine/pending-review", label: "待审核任务" },
{ key: "/task-engine/config", label: "参数管理" },
],
},
{ key: "/triggers", icon: <ClockCircleOutlined />, label: "触发器管理" },
{ key: "/tenant-admins", icon: <TeamOutlined />, label: "租户管理员" },
{
key: "settings-group", icon: <SettingOutlined />, label: "系统设置",
children: [
{ key: "/settings/env-config", label: "环境配置" },
{ key: "/triggers?tab=biz", label: "触发器配置" },
],
},
{
key: "logs-group", icon: <BugOutlined />, 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 = () => {
<Layout style={{ minHeight: "100vh" }}> <Layout style={{ minHeight: "100vh" }}>
<Sider <Sider
collapsible collapsible
style={{ display: "flex", flexDirection: "column" }} style={{
overflow: "auto",
height: "100vh",
position: "sticky",
top: 0,
left: 0,
display: "flex",
flexDirection: "column",
}}
> >
<div <div
style={{ style={{
@@ -118,7 +187,8 @@ const AppLayout: React.FC = () => {
<Menu <Menu
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[location.pathname]} selectedKeys={getSelectedKeys(location.pathname, location.search)}
defaultOpenKeys={getDefaultOpenKeys(location.pathname)}
items={NAV_ITEMS} items={NAV_ITEMS}
onClick={onMenuClick} onClick={onMenuClick}
/> />
@@ -138,13 +208,31 @@ const AppLayout: React.FC = () => {
<Layout> <Layout>
<Content style={{ margin: 16, minHeight: 280 }}> <Content style={{ margin: 16, minHeight: 280 }}>
<Routes> <Routes>
<Route path="/" element={<TaskConfig />} /> {/* 重定向 */}
<Route path="/task-manager" element={<TaskManager />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/env-config" element={<EnvConfig />} /> <Route path="/log-viewer" element={<Navigate to="/etl-tasks?tab=queue" replace />} />
<Route path="/db-viewer" element={<DBViewer />} />
<Route path="/etl-status" element={<ETLStatus />} /> {/* 新页面 */}
<Route path="/log-viewer" element={<LogViewer />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/ops-panel" element={<OpsPanel />} /> <Route path="/etl-tasks" element={<ETLTasks />} />
<Route path="/triggers" element={<TriggerManager />} />
{/* 小程序任务管理 */}
<Route path="/task-engine/trigger-jobs" element={<TriggerJobs />} />
<Route path="/task-engine/transfer-log" element={<TransferLog />} />
<Route path="/task-engine/pending-review" element={<PendingReview />} />
<Route path="/task-engine/config" element={<TaskEngineConfig />} />
{/* 系统设置 */}
<Route path="/settings/env-config" element={<EnvConfig />} />
{/* 日志调试 */}
<Route path="/logs/dev-trace" element={<DevTrace />} />
<Route path="/logs/ai-run-logs" element={<AIRunLogs />} />
<Route path="/logs/db-viewer" element={<DBViewer />} />
{/* 不变 */}
<Route path="/tenant-admins" element={<TenantAdmins />} />
</Routes> </Routes>
</Content> </Content>
<Footer <Footer

View File

@@ -0,0 +1,299 @@
/**
* 单元测试Dashboard 页面 + DbHealthCard 组件
*
* _Requirements: 2.1, 2.2, 2.3, 2.4, 2.5_
*
* - 测试 4 个区块正确渲染
* - 测试跳转链接指向正确路由
* - 测试 DbHealthCard connected/disconnected/timeout 状态渲染
*/
import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import { MemoryRouter } from "react-router-dom";
import React from "react";
import DbHealthCard from "../components/DbHealthCard";
import type { DbHealthItem } from "../api/dbHealth";
/* ------------------------------------------------------------------ */
/* 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 所有 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: () => <div data-testid="ai-dashboard-mock">AIDashboard</div>,
}));
/* ------------------------------------------------------------------ */
/* 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(
<MemoryRouter initialEntries={["/dashboard"]}>
<Dashboard />
</MemoryRouter>,
);
// 区块 1OpsPanel 子组件 — 页面标题"运行状态"
expect(await screen.findByText("运行状态")).toBeInTheDocument();
// 区块 2数据库健康监控
expect(screen.getByText("数据库健康监控")).toBeInTheDocument();
// 区块 3AI 运行总览mocked AIDashboard
expect(screen.getByText("AI 运行总览")).toBeInTheDocument();
expect(screen.getByTestId("ai-dashboard-mock")).toBeInTheDocument();
// 区块 4AI 调度摘要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(
<MemoryRouter initialEntries={["/dashboard"]}>
<Dashboard />
</MemoryRouter>,
);
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(<DbHealthCard items={connectedItems} />);
expect(screen.getByText("etl_feiqiu")).toBeInTheDocument();
expect(screen.getByText("zqyy_app")).toBeInTheDocument();
const connectedTags = screen.getAllByText("已连接");
expect(connectedTags).toHaveLength(2);
});
it("展示连接池指标(活跃/空闲连接数)", () => {
render(<DbHealthCard items={connectedItems} />);
expect(screen.getByText(/活跃 5/)).toBeInTheDocument();
expect(screen.getByText(/空闲 10/)).toBeInTheDocument();
});
it("展示数据库大小和慢查询数量", () => {
render(<DbHealthCard items={connectedItems} />);
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(<DbHealthCard items={disconnectedItems} />);
expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument();
expect(screen.getByText("未连接")).toBeInTheDocument();
});
it("disconnected 数据库显示无法获取指标提示", () => {
render(<DbHealthCard items={disconnectedItems} />);
expect(screen.getByText("数据库未连接,无法获取指标")).toBeInTheDocument();
});
});
describe("DbHealthCard — timeout 状态", () => {
it("超时时显示「加载超时」标签", () => {
render(<DbHealthCard items={[]} timeout={true} />);
expect(screen.getByText("加载超时")).toBeInTheDocument();
});
it("超时时显示重试按钮,点击触发 onRetry", () => {
const onRetry = vi.fn();
render(<DbHealthCard items={[]} timeout={true} onRetry={onRetry} />);
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(<DbHealthCard items={mixedItems} />);
expect(screen.getByText("已连接")).toBeInTheDocument();
expect(screen.getByText("未连接")).toBeInTheDocument();
expect(screen.getByText("etl_feiqiu")).toBeInTheDocument();
expect(screen.getByText("test_etl_feiqiu")).toBeInTheDocument();
});
});

View File

@@ -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: () => <div data-testid="mock-task-config">TaskConfig Mock</div>,
}));
vi.mock("../pages/TaskManager", () => ({
QueueTab: () => <div data-testid="mock-queue-tab">QueueTab Mock</div>,
HistoryTab: () => <div data-testid="mock-history-tab">HistoryTab Mock</div>,
}));
vi.mock("../components/ScheduleTab", () => ({
default: () => <div data-testid="mock-schedule-tab">ScheduleTab Mock</div>,
}));
vi.mock("../pages/ETLStatus", () => ({
default: () => <div data-testid="mock-etl-status">ETLStatus Mock</div>,
}));
import ETLTasks from "../pages/ETLTasks";
afterEach(() => {
cleanup();
});
/* ------------------------------------------------------------------ */
/* 测试:默认 Tab 为 configRequirements 3.1, 3.2 */
/* ------------------------------------------------------------------ */
describe("ETLTasks — 默认 Tab (Requirements 3.1, 3.2)", () => {
it("无 ?tab 参数时,默认激活 config Tab发起", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks"]}>
<ETLTasks />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("发起");
});
it("无效 ?tab 参数时,回退到默认 config Tab", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=invalid"]}>
<ETLTasks />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/etl-tasks?tab=config"]}>
<ETLTasks />
</MemoryRouter>,
);
expect(screen.getByTestId("mock-task-config")).toBeInTheDocument();
});
it("queue Tab 渲染 QueueTab 组件", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=queue"]}>
<ETLTasks />
</MemoryRouter>,
);
expect(screen.getByTestId("mock-queue-tab")).toBeInTheDocument();
});
it("schedule Tab 渲染 ScheduleTab 组件", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=schedule"]}>
<ETLTasks />
</MemoryRouter>,
);
expect(screen.getByTestId("mock-schedule-tab")).toBeInTheDocument();
});
it("history Tab 渲染 HistoryTab 组件", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=history"]}>
<ETLTasks />
</MemoryRouter>,
);
expect(screen.getByTestId("mock-history-tab")).toBeInTheDocument();
});
it("status Tab 渲染 ETLStatus 组件", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=status"]}>
<ETLTasks />
</MemoryRouter>,
);
expect(screen.getByTestId("mock-etl-status")).toBeInTheDocument();
});
it("页面标题包含「ETL 任务管理」", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks"]}>
<ETLTasks />
</MemoryRouter>,
);
expect(screen.getByText("ETL 任务管理")).toBeInTheDocument();
});
it("5 个 Tab 标签文本正确:发起、队列、调度、历史、状态", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks"]}>
<ETLTasks />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/etl-tasks?tab=config"]}>
<ETLTasks />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("发起");
});
it("?tab=queue 激活「队列」Tab", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=queue"]}>
<ETLTasks />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("队列");
});
it("?tab=schedule 激活「调度」Tab", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=schedule"]}>
<ETLTasks />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("调度");
});
it("?tab=history 激活「历史」Tab", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=history"]}>
<ETLTasks />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("历史");
});
it("?tab=status 激活「状态」Tab", () => {
render(
<MemoryRouter initialEntries={["/etl-tasks?tab=status"]}>
<ETLTasks />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("状态");
});
});

View File

@@ -8,7 +8,7 @@
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { filterLogLines } from "../pages/LogViewer"; import { filterLogLines } from "../pages/_archived/LogViewer";
describe("filterLogLines — 日志过滤正确性", () => { describe("filterLogLines — 日志过滤正确性", () => {
/* ---- 1. 空关键词返回所有行 ---- */ /* ---- 1. 空关键词返回所有行 ---- */

View File

@@ -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<MenuProps["items"]>[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 (
<div data-testid="location">
{location.pathname}
{location.search}
</div>
);
}
/**
* 最小路由配置:只包含重定向规则和一个 LocationDisplay 兜底,
* 不需要渲染完整 App避免 mock 大量依赖)。
*/
function RedirectTestApp() {
return (
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/log-viewer" element={<Navigate to="/etl-tasks?tab=queue" replace />} />
<Route path="*" element={<LocationDisplay />} />
</Routes>
);
}
describe("路由重定向 (Requirements 8.3, 10.1, 10.2)", () => {
it("/ 重定向到 /dashboard", () => {
const { getByTestId } = render(
<MemoryRouter initialEntries={["/"]}>
<RedirectTestApp />
</MemoryRouter>,
);
expect(getByTestId("location").textContent).toBe("/dashboard");
});
it("/log-viewer 重定向到 /etl-tasks?tab=queue", () => {
const { getByTestId } = render(
<MemoryRouter initialEntries={["/log-viewer"]}>
<RedirectTestApp />
</MemoryRouter>,
);
expect(getByTestId("location").textContent).toBe("/etl-tasks?tab=queue");
});
});

View File

@@ -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<string, string> {
const map = new Map<string, string>();
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<string>();
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 },
);
});
});

View File

@@ -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 (
<div data-testid={`tab-panel-${tabKey}`}>
<input
data-testid={`state-input-${tabKey}`}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
);
}
vi.mock("../pages/TaskConfig", () => ({
default: () => <StatefulTab tabKey="config" />,
}));
vi.mock("../pages/TaskManager", () => ({
default: () => <StatefulTab tabKey="manager" />,
}));
vi.mock("../pages/ETLStatus", () => ({
default: () => <StatefulTab tabKey="status" />,
}));
import ETLTasks from "../pages/ETLTasks";
/* ------------------------------------------------------------------ */
/* 常量 */
/* ------------------------------------------------------------------ */
const VALID_TABS = ["config", "manager", "status"] as const;
type TabKey = (typeof VALID_TABS)[number];
const TAB_LABELS: Record<TabKey, string> = {
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(
<MemoryRouter initialEntries={[`/etl-tasks?tab=${sourceTab}`]}>
<ETLTasks />
</MemoryRouter>,
);
// 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);
});

View File

@@ -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`。
*
* 当前仅测试 ETLTasksTriggerManager 待后续任务创建后扩展)。
*/
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: () => <div data-testid="tab-content-config">TaskConfig</div>,
}));
vi.mock("../pages/TaskManager", () => ({
QueueTab: () => <div data-testid="tab-content-queue">QueueTab</div>,
HistoryTab: () => <div data-testid="tab-content-history">HistoryTab</div>,
}));
vi.mock("../components/ScheduleTab", () => ({
default: () => <div data-testid="tab-content-schedule">ScheduleTab</div>,
}));
vi.mock("../pages/ETLStatus", () => ({
default: () => <div data-testid="tab-content-status">ETLStatus</div>,
}));
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<string, string>;
}
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(
<MemoryRouter initialEntries={[`${basePath}?tab=${tab}`]}>
<Component />
</MemoryRouter>,
);
// 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(
<MemoryRouter initialEntries={[`${basePath}?tab=${initialTab}`]}>
<Component />
<LocationSpy onLocation={(s) => { currentSearch = s; }} />
</MemoryRouter>,
);
// 找到目标 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(
<MemoryRouter initialEntries={[basePath]}>
<Component />
</MemoryRouter>,
);
const activeTab1 = document.querySelector(".ant-tabs-tab-active");
expect(activeTab1).not.toBeNull();
expect(activeTab1!.textContent).toContain(tabLabels[defaultTab]);
cleanup();
// 无效 tab 参数
render(
<MemoryRouter initialEntries={[`${basePath}?tab=invalid`]}>
<Component />
</MemoryRouter>,
);
const activeTab2 = document.querySelector(".ant-tabs-tab-active");
expect(activeTab2).not.toBeNull();
expect(activeTab2!.textContent).toContain(tabLabels[defaultTab]);
cleanup();
});
});
}
});

View File

@@ -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: () => <div data-testid="mock-ai-operations">AIOperations Mock</div>,
}));
vi.mock("../pages/AITriggerJobs", () => ({
default: () => <div data-testid="mock-ai-trigger-jobs">AITriggerJobs Mock</div>,
}));
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(
<MemoryRouter initialEntries={["/triggers"]}>
<TriggerManager />
</MemoryRouter>,
);
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(
<MemoryRouter initialEntries={["/triggers"]}>
<TriggerManager />
</MemoryRouter>,
);
const activeTab = document.querySelector(".ant-tabs-tab-active");
expect(activeTab).not.toBeNull();
expect(activeTab!.textContent).toContain("全部");
});
it("页面标题包含「触发器管理」", () => {
render(
<MemoryRouter initialEntries={["/triggers"]}>
<TriggerManager />
</MemoryRouter>,
);
expect(screen.getByText("触发器管理")).toBeInTheDocument();
});
});
/* ------------------------------------------------------------------ */
/* 测试:"全部"Tab 为只读Requirement 4.3 */
/* ------------------------------------------------------------------ */
describe("TriggerManager — 全部 Tab 只读 (Requirement 4.3)", () => {
it("「全部」Tab 表格中无「编辑」按钮", async () => {
render(
<MemoryRouter initialEntries={["/triggers?tab=all"]}>
<TriggerManager />
</MemoryRouter>,
);
// 等待统一视图数据加载完成
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(
<MemoryRouter initialEntries={["/triggers?tab=biz"]}>
<TriggerManager />
</MemoryRouter>,
);
// 等待业务触发器数据加载
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(
<MemoryRouter initialEntries={["/triggers?tab=biz"]}>
<TriggerManager />
</MemoryRouter>,
);
// 等待数据加载
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();
});
});

View File

@@ -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<string, unknown> | 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<DashboardResponse> {
const { data } = await apiClient.get<DashboardResponse>("/admin/ai/dashboard", {
params: siteId != null ? { site_id: siteId } : undefined,
});
return data;
}
// 调度任务
export async function getTriggerJobs(params: TriggerJobQuery): Promise<TriggerJobListResponse> {
const { data } = await apiClient.get<TriggerJobListResponse>("/admin/ai/trigger-jobs", { params });
return data;
}
export async function getTriggerJobDetail(id: number): Promise<TriggerJobDetailResponse> {
const { data } = await apiClient.get<TriggerJobDetailResponse>(`/admin/ai/trigger-jobs/${id}`);
return data;
}
export async function retryTriggerJob(id: number): Promise<RetryResponse> {
const { data } = await apiClient.post<RetryResponse>(`/admin/ai/trigger-jobs/${id}/retry`);
return data;
}
// 调用记录
export async function getRunLogs(params: RunLogQuery): Promise<RunLogListResponse> {
const { data } = await apiClient.get<RunLogListResponse>("/admin/ai/run-logs", { params });
return data;
}
export async function getRunLogDetail(id: number): Promise<RunLogDetailResponse> {
const { data } = await apiClient.get<RunLogDetailResponse>(`/admin/ai/run-logs/${id}`);
return data;
}
// 缓存管理
export async function invalidateCache(body: CacheInvalidateReq): Promise<CacheInvalidateResponse> {
const { data } = await apiClient.post<CacheInvalidateResponse>("/admin/ai/cache/invalidate", body);
return data;
}
// Token 预算
export async function getBudget(): Promise<BudgetResponse> {
const { data } = await apiClient.get<BudgetResponse>("/admin/ai/budget");
return data;
}
// 批量执行
export async function createBatchRun(body: BatchRunReq): Promise<BatchRunEstimate> {
const { data } = await apiClient.post<BatchRunEstimate>("/admin/ai/batch-run", body);
return data;
}
export async function confirmBatchRun(batchId: string): Promise<BatchRunConfirmResponse> {
const { data } = await apiClient.post<BatchRunConfirmResponse>("/admin/ai/batch-run/confirm", {
batch_id: batchId,
});
return data;
}
// 告警
export async function getAlerts(params: AlertQuery): Promise<AlertListResponse> {
const { data } = await apiClient.get<AlertListResponse>("/admin/ai/alerts", { params });
return data;
}
export async function ackAlert(id: number): Promise<AlertActionResponse> {
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ack`);
return data;
}
export async function ignoreAlert(id: number): Promise<AlertActionResponse> {
const { data } = await apiClient.post<AlertActionResponse>(`/admin/ai/alerts/${id}/ignore`);
return data;
}

View File

@@ -122,20 +122,22 @@ apiClient.interceptors.response.use(
try { try {
// 用独立 axios 调用避免被自身拦截器干扰 // 用独立 axios 调用避免被自身拦截器干扰
const { data } = await axios.post<{ // ResponseWrapperMiddleware 包装响应为 { code: 0, data: { access_token, refresh_token } }
access_token: string; const resp = await axios.post<{
refresh_token: string; code: number;
data: { access_token: string; refresh_token: string };
}>("/api/auth/refresh", { refresh_token: refreshToken }); }>("/api/auth/refresh", { refresh_token: refreshToken });
localStorage.setItem(ACCESS_TOKEN_KEY, data.access_token); const tokens = resp.data.data;
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token); 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 = {
...originalRequest.headers, ...originalRequest.headers,
Authorization: `Bearer ${data.access_token}`, Authorization: `Bearer ${tokens.access_token}`,
}; };
return apiClient(originalRequest); return apiClient(originalRequest);
} catch (refreshError) { } catch (refreshError) {

View File

@@ -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<DbHealthItem[]> {
const { data } = await apiClient.get<DbHealthItem[]>('/admin/db-health');
return data;
}

View File

@@ -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<TraceRequestListResponse> {
const { data } = await apiClient.get<TraceRequestListResponse>("/admin/dev-trace/requests", { params });
return data;
}
// ---- 请求详情(含完整 spans ----
export async function fetchRequestDetail(requestId: string): Promise<TraceDetail> {
const { data } = await apiClient.get<TraceDetail>(`/admin/dev-trace/request/${requestId}`);
return data;
}
// ---- 手动清理 ----
export async function cleanupLogs(startDate: string, endDate: string): Promise<CleanupResponse> {
const { data } = await apiClient.post<CleanupResponse>("/admin/dev-trace/cleanup", {
start_date: startDate,
end_date: endDate,
});
return data;
}
// ---- 设置 ----
export async function fetchSettings(): Promise<TraceSettings> {
const { data } = await apiClient.get<TraceSettings>("/admin/dev-trace/settings");
return data;
}
export async function updateSettings(settings: Partial<TraceSettings>): Promise<TraceSettings> {
const { data } = await apiClient.put<TraceSettings>("/admin/dev-trace/settings", settings);
return data;
}
// ---- 覆盖率 ----
export async function fetchCoverage(): Promise<TraceCoverage> {
const { data } = await apiClient.get<TraceCoverage>("/admin/dev-trace/coverage");
return data;
}
export async function triggerCoverageScan(): Promise<TraceCoverage> {
const { data } = await apiClient.post<TraceCoverage>("/admin/dev-trace/coverage/scan");
return data;
}

View File

@@ -10,8 +10,8 @@ import { apiClient } from './client';
/** ETL 游标信息 */ /** ETL 游标信息 */
export interface CursorInfo { export interface CursorInfo {
task_code: string; task_code: string;
last_fetch_time: string | null; last_start: string | null;
record_count: number | null; last_end: string | null;
} }
/** 最近执行记录 */ /** 最近执行记录 */

View File

@@ -45,3 +45,17 @@ export async function deleteFromQueue(id: string): Promise<void> {
export async function cancelExecution(id: string): Promise<void> { export async function cancelExecution(id: string): Promise<void> {
await apiClient.post(`/execution/${id}/cancel`); 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;
}

View File

@@ -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<TenantItem[]> {
const { data } = await apiClient.get<TenantItem[]>("/admin/tenants");
return data;
}
/** 获取指定租户下所有活跃店铺 */
export async function fetchTenantSites(
tenantId: number,
): Promise<SiteItem[]> {
const { data } = await apiClient.get<SiteItem[]>(
`/admin/tenants/${tenantId}/sites`,
);
return data;
}
/** 设置/修改店铺简写ID */
export async function updateSiteCode(
siteId: number,
newCode: string,
): Promise<SiteCodeResult> {
const { data } = await apiClient.put<SiteCodeResult>(
`/admin/sites/${siteId}/site-code`,
{ newCode } satisfies UpdateSiteCodeRequest,
);
return data;
}
/** 查看简写ID 变更历史 */
export async function fetchSiteCodeHistory(
siteId: number,
): Promise<SiteCodeHistoryItem[]> {
const { data } = await apiClient.get<SiteCodeHistoryItem[]>(
`/admin/sites/${siteId}/site-code-history`,
);
return data;
}
/** 手动触发店铺同步 */
export async function syncSites(): Promise<SiteSyncResult> {
const { data } = await apiClient.post<SiteSyncResult>(
"/admin/sites/sync",
);
return data;
}
/* ------------------------------------------------------------------ */
/* 测试功能:手动创建/删除店铺 */
/* ------------------------------------------------------------------ */
/** 创建店铺请求 */
export interface CreateSitePayload {
tenantId: number;
siteId: number;
siteName: string;
siteCode?: string;
}
/** 手动创建店铺(测试功能) */
export async function createSite(
payload: CreateSitePayload,
): Promise<SiteItem> {
const { data } = await apiClient.post<SiteItem>("/admin/sites", payload);
return data;
}
/** 删除店铺(测试功能,硬删除) */
export async function deleteSite(id: number): Promise<void> {
await apiClient.delete(`/admin/sites/${id}`);
}

View File

@@ -3,7 +3,7 @@
*/ */
import { apiClient } from './client'; 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<ScheduledTask[]> { export async function fetchSchedules(): Promise<ScheduledTask[]> {
@@ -18,6 +18,9 @@ export async function createSchedule(payload: {
task_config: TaskConfig; task_config: TaskConfig;
schedule_config: ScheduleConfig; schedule_config: ScheduleConfig;
run_immediately?: boolean; run_immediately?: boolean;
min_run_interval_value?: number;
min_run_interval_unit?: string;
min_run_intervals?: Record<string, MinRunIntervalItem>;
}): Promise<ScheduledTask> { }): Promise<ScheduledTask> {
const { data } = await apiClient.post<ScheduledTask>('/schedules', payload); const { data } = await apiClient.post<ScheduledTask>('/schedules', payload);
return data; return data;
@@ -31,6 +34,9 @@ export async function updateSchedule(
task_codes: string[]; task_codes: string[];
task_config: TaskConfig; task_config: TaskConfig;
schedule_config: ScheduleConfig; schedule_config: ScheduleConfig;
min_run_interval_value: number;
min_run_interval_unit: string;
min_run_intervals: Record<string, MinRunIntervalItem>;
}>, }>,
): Promise<ScheduledTask> { ): Promise<ScheduledTask> {
const { data } = await apiClient.put<ScheduledTask>(`/schedules/${id}`, payload); const { data } = await apiClient.put<ScheduledTask>(`/schedules/${id}`, payload);
@@ -49,8 +55,10 @@ export async function toggleSchedule(id: string): Promise<ScheduledTask> {
} }
/** 手动执行调度任务一次(不更新调度间隔) */ /** 手动执行调度任务一次(不更新调度间隔) */
export async function runScheduleNow(id: string): Promise<{ message: string; task_id: string }> { 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`); const { data } = await apiClient.post<{ message: string; task_id: string }>(`/schedules/${id}/run`, null, {
params: force ? { force: true } : undefined,
});
return data; return data;
} }

View File

@@ -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<string, unknown> | 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<TransferLogPage> {
const resp = await apiClient.get<TransferLogPage>(
"/admin/task-engine/transfer-log",
{ params: query },
);
return resp.data;
}
export async function fetchMemberTransferHistory(
memberId: number,
): Promise<TransferLogItem[]> {
const resp = await apiClient.get<TransferLogItem[]>(
`/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<PendingReviewPage> {
const resp = await apiClient.get<PendingReviewPage>(
"/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<ConfigParamList> {
const resp = await apiClient.get<ConfigParamList>(
"/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;
}

View File

@@ -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<TenantAdminListResponse> {
const { data } = await apiClient.get<TenantAdminListResponse>(
"/admin/tenant-admins",
{ params },
);
return data;
}
/** 创建租户管理员 */
export async function createTenantAdmin(
payload: TenantAdminCreatePayload,
): Promise<TenantAdminItem> {
const { data } = await apiClient.post<TenantAdminItem>(
"/admin/tenant-admins",
payload,
);
return data;
}
/** 编辑租户管理员 */
export async function editTenantAdmin(
id: number,
payload: TenantAdminEditPayload,
): Promise<TenantAdminItem> {
const { data } = await apiClient.patch<TenantAdminItem>(
`/admin/tenant-admins/${id}`,
payload,
);
return data;
}
/** 重置密码 */
export async function resetTenantAdminPassword(
id: number,
payload: ResetPasswordPayload,
): Promise<void> {
await apiClient.post(`/admin/tenant-admins/${id}/reset-password`, payload);
}
/** 删除租户管理员(软删除) */
export async function deleteTenantAdmin(id: number): Promise<void> {
await apiClient.delete(`/admin/tenant-admins/${id}`);
}

View File

@@ -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<string, unknown> | 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<TriggerJob[]> {
const { data } = await apiClient.get<TriggerJob[]>('/trigger-jobs');
return data;
}
/** 手动执行指定任务 */
export async function runTriggerJob(jobId: number): Promise<RunJobResult> {
const { data } = await apiClient.post<RunJobResult>(`/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<TriggerJob> {
const { data } = await apiClient.patch<TriggerJob>(
`/trigger-jobs/${jobId}/config`,
body,
);
return data;
}
/** 【测试】清空所有 coach_tasks */
export async function clearAllTasks(): Promise<RunJobResult> {
const { data } = await apiClient.delete<RunJobResult>('/admin/task-engine/clear-all-tasks');
return data;
}

View File

@@ -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<UnifiedTriggerItem[]> {
const { data } = await apiClient.get<UnifiedTriggerItem[]>('/admin/triggers/unified');
return data;
}

View File

@@ -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<DbHealthCardProps> = ({
items,
loading = false,
timeout = false,
onRetry,
}) => {
// 加载超时状态
if (timeout) {
return (
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<WarningOutlined style={{ fontSize: 32, color: "#faad14", marginBottom: 12 }} />
<div style={{ marginBottom: 12 }}>
<Tag color="warning" icon={<WarningOutlined />}></Tag>
</div>
{onRetry && (
<Button icon={<ReloadOutlined />} onClick={onRetry}>
</Button>
)}
</div>
</Card>
);
}
// 加载中
if (loading) {
return (
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
<div style={{ textAlign: "center", padding: "24px 0" }}>
<Spin tip="加载中..." />
</div>
</Card>
);
}
// 无数据
if (!items || items.length === 0) {
return (
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
<Empty description="暂无数据" />
</Card>
);
}
return (
<Card size="small" title="数据库健康监控" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{items.map((item) => (
<Col span={12} key={item.db_name} style={{ marginBottom: 16 }}>
<Card
size="small"
type="inner"
title={
<span>
<DatabaseOutlined style={{ marginRight: 6 }} />
{item.db_name}
</span>
}
extra={
item.status === "connected" ? (
<Tag color="success" icon={<CheckCircleOutlined />}></Tag>
) : (
<Tag color="error" icon={<CloseCircleOutlined />}></Tag>
)
}
>
{item.status === "connected" ? (
<>
{/* 连接池进度条 */}
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4, fontSize: 12, color: "#666" }}>
{item.active_connections ?? 0} / {item.idle_connections ?? 0}
</div>
<Progress
percent={poolPercent(item.active_connections, item.idle_connections)}
size="small"
format={(pct) => `${pct}%`}
/>
</div>
{/* 统计指标 */}
<Row gutter={16}>
<Col span={12}>
<Statistic
title="数据库大小"
value={item.db_size_mb ?? "-"}
suffix="MB"
valueStyle={{ fontSize: 16 }}
/>
</Col>
<Col span={12}>
<Statistic
title="慢查询1h"
value={item.slow_query_count ?? 0}
valueStyle={{
fontSize: 16,
color: (item.slow_query_count ?? 0) > 0 ? "#faad14" : undefined,
}}
/>
</Col>
</Row>
</>
) : (
<div style={{ textAlign: "center", padding: "16px 0", color: "#999" }}>
</div>
)}
</Card>
</Col>
))}
</Row>
</Card>
);
};
export default DbHealthCard;

View File

@@ -15,6 +15,8 @@ import {
import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons'; import { ReloadOutlined, EditOutlined, DeleteOutlined, HistoryOutlined, PlayCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/zh-cn';
import type { ScheduledTask, ScheduleConfig } from '../types'; import type { ScheduledTask, ScheduleConfig } from '../types';
import { import {
fetchSchedules, fetchSchedules,
@@ -25,6 +27,9 @@ import {
} from '../api/schedules'; } from '../api/schedules';
import ScheduleHistoryDrawer from './ScheduleHistoryDrawer'; import ScheduleHistoryDrawer from './ScheduleHistoryDrawer';
dayjs.extend(relativeTime);
dayjs.locale('zh-cn');
const { Text } = Typography; const { Text } = Typography;
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -178,6 +183,8 @@ const ScheduleTab: React.FC = () => {
const cfg = record.schedule_config; const cfg = record.schedule_config;
form.setFieldsValue({ form.setFieldsValue({
name: record.name, 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: { schedule_config: {
...cfg, ...cfg,
daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined, daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined,
@@ -226,6 +233,8 @@ const ScheduleTab: React.FC = () => {
await updateSchedule(editing.id, { await updateSchedule(editing.id, {
name: values.name, name: values.name,
schedule_config: scheduleConfig, 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('调度任务已更新'); 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<string | null>(null);
/* 打开手动执行确认 Modal */
const openRunConfirm = (id: string) => {
setRunTargetId(id);
setRunForceCheck(false);
setRunConfirmOpen(true);
};
/* 确认手动执行 */
const handleRunNow = async () => {
if (!runTargetId) return;
try { try {
await runScheduleNow(id); await runScheduleNow(runTargetId, runForceCheck);
message.success('已提交到执行队列'); message.success('已提交到执行队列');
} catch { setRunConfirmOpen(false);
message.error('执行失败'); } 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) => render: (s: string | null) =>
s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—', s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—',
}, },
{
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: '操作', title: '操作',
key: 'action', key: 'action',
width: 300, width: 300,
render: (_: unknown, record: ScheduledTask) => ( render: (_: unknown, record: ScheduledTask) => (
<Space size="small"> <Space size="small">
<Popconfirm title="确认立即执行一次?(不影响调度间隔)" onConfirm={() => handleRunNow(record.id)}> <Button type="link" icon={<PlayCircleOutlined />} size="small" onClick={() => openRunConfirm(record.id)}>
<Button type="link" icon={<PlayCircleOutlined />} size="small">
</Button>
</Button>
</Popconfirm>
<Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => openHistory(record)}> <Button type="link" icon={<HistoryOutlined />} size="small" onClick={() => openHistory(record)}>
</Button> </Button>
@@ -388,6 +432,21 @@ const ScheduleTab: React.FC = () => {
</Form.Item> </Form.Item>
<ScheduleConfigFields scheduleType={scheduleType} /> <ScheduleConfigFields scheduleType={scheduleType} />
<Form.Item label="最小运行间隔">
<Space>
<Form.Item name="min_run_interval_value" noStyle initialValue={0}>
<InputNumber min={0} placeholder="0 = 无限制" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="min_run_interval_unit" noStyle initialValue="minutes">
<Select style={{ width: 100 }} options={[
{ label: '分钟', value: 'minutes' },
{ label: '小时', value: 'hours' },
{ label: '天', value: 'days' },
]} />
</Form.Item>
</Space>
</Form.Item>
</Form> </Form>
</Modal> </Modal>
@@ -398,6 +457,21 @@ const ScheduleTab: React.FC = () => {
scheduleName={historyScheduleName} scheduleName={historyScheduleName}
onClose={() => setHistoryOpen(false)} onClose={() => setHistoryOpen(false)}
/> />
{/* 手动执行确认 Modal */}
<Modal
title="确认立即执行"
open={runConfirmOpen}
onOk={handleRunNow}
onCancel={() => setRunConfirmOpen(false)}
okText="执行"
cancelText="取消"
>
<p></p>
<Checkbox checked={runForceCheck} onChange={(e) => setRunForceCheck(e.target.checked)}>
</Checkbox>
</Modal>
</> </>
); );
}; };

View File

@@ -13,7 +13,7 @@
import React, { useEffect, useState, useMemo, useCallback } from "react"; import React, { useEffect, useState, useMemo, useCallback } from "react";
import { import {
Collapse, Checkbox, Spin, Alert, Button, Space, Typography, Collapse, Checkbox, Spin, Alert, Button, Space, Typography,
Tag, Badge, Modal, Tooltip, Divider, Tag, Badge, Modal, Tooltip, Divider, InputNumber, Select,
} from "antd"; } from "antd";
import { import {
CheckCircleOutlined, WarningOutlined, SyncOutlined, TableOutlined, CheckCircleOutlined, WarningOutlined, SyncOutlined, TableOutlined,
@@ -21,7 +21,7 @@ import {
import type { CheckboxChangeEvent } from "antd/es/checkbox"; import type { CheckboxChangeEvent } from "antd/es/checkbox";
import { fetchTaskRegistry, fetchDwdTablesRich, checkTaskSync } from "../api/tasks"; import { fetchTaskRegistry, fetchDwdTablesRich, checkTaskSync } from "../api/tasks";
import type { DwdTableItem as ApiDwdTableItem, SyncCheckResult } from "../api/tasks"; import type { DwdTableItem as ApiDwdTableItem, SyncCheckResult } from "../api/tasks";
import type { TaskDefinition, DwdTableItem } from "../types"; import type { TaskDefinition, DwdTableItem, MinRunIntervalItem } from "../types";
const { Text } = Typography; const { Text } = Typography;
@@ -44,6 +44,9 @@ export interface TaskSelectorProps {
onTasksChange: (tasks: string[]) => void; onTasksChange: (tasks: string[]) => void;
selectedDwdTables?: string[]; selectedDwdTables?: string[];
onDwdTablesChange?: (tables: string[]) => void; onDwdTablesChange?: (tables: string[]) => void;
/** per-task-code 最小执行间隔 */
taskIntervals?: Record<string, MinRunIntervalItem>;
onTaskIntervalsChange?: (intervals: Record<string, MinRunIntervalItem>) => void;
} }
interface DomainGroup { interface DomainGroup {
@@ -105,6 +108,7 @@ function buildDomainGroups(
const TaskSelector: React.FC<TaskSelectorProps> = ({ const TaskSelector: React.FC<TaskSelectorProps> = ({
layers, selectedTasks, onTasksChange, layers, selectedTasks, onTasksChange,
selectedDwdTables = [], onDwdTablesChange, selectedDwdTables = [], onDwdTablesChange,
taskIntervals = {}, onTaskIntervalsChange,
}) => { }) => {
const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({}); const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({});
const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, DwdTableItem[]>>({}); const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, DwdTableItem[]>>({});
@@ -241,6 +245,24 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
[selectedDwdTables, onDwdTablesChange], [selectedDwdTables, onDwdTablesChange],
); );
/* 间隔设置 */
const handleIntervalChange = useCallback(
(code: string, field: "value" | "unit", val: number | string) => {
if (!onTaskIntervalsChange) return;
const current = taskIntervals[code] ?? { value: 0, unit: "minutes" as const };
const updated = { ...current, [field]: val };
if (updated.value === 0 || updated.value === null) {
// 值为 0 时移除该任务的间隔设置
const next = { ...taskIntervals };
delete next[code];
onTaskIntervalsChange(next);
} else {
onTaskIntervalsChange({ ...taskIntervals, [code]: updated as MinRunIntervalItem });
}
},
[taskIntervals, onTaskIntervalsChange],
);
/* 渲染 */ /* 渲染 */
if (loading) return <Spin tip="加载任务列表…" />; if (loading) return <Spin tip="加载任务列表…" />;
if (error) return <Alert type="error" message="加载失败" description={error} />; if (error) return <Alert type="error" message="加载失败" description={error} />;
@@ -373,7 +395,7 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
</div> </div>
<div style={{ paddingLeft: 4 }}> <div style={{ paddingLeft: 4 }}>
{lt.tasks.map((t) => ( {lt.tasks.map((t) => (
<div key={t.code} style={{ padding: "2px 0" }}> <div key={t.code} style={{ padding: "2px 0", display: "flex", alignItems: "center", gap: 8 }}>
<Checkbox <Checkbox
checked={selectedTasks.includes(t.code)} checked={selectedTasks.includes(t.code)}
onChange={(e) => handleTaskToggle(t.code, e.target.checked)} onChange={(e) => handleTaskToggle(t.code, e.target.checked)}
@@ -385,6 +407,31 @@ const TaskSelector: React.FC<TaskSelectorProps> = ({
)} )}
{!t.is_common && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}></Tag>} {!t.is_common && <Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}></Tag>}
</Checkbox> </Checkbox>
{/* per-task 最小执行间隔 */}
{onTaskIntervalsChange && (
<Space size={2} style={{ marginLeft: "auto", flexShrink: 0 }}>
<InputNumber
size="small"
min={0}
max={9999}
placeholder="间隔"
value={taskIntervals[t.code]?.value || null}
onChange={(v) => handleIntervalChange(t.code, "value", v ?? 0)}
style={{ width: 70 }}
/>
<Select
size="small"
value={taskIntervals[t.code]?.unit ?? "minutes"}
onChange={(v) => handleIntervalChange(t.code, "unit", v)}
style={{ width: 72 }}
options={[
{ label: "分钟", value: "minutes" },
{ label: "小时", value: "hours" },
{ label: "天", value: "days" },
]}
/>
</Space>
)}
</div> </div>
))} ))}
</div> </div>

View File

@@ -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<string, boolean>;
onPull: (env: string) => void;
onSyncDeps: (env: string) => void;
}
const GitStatusSection: React.FC<GitStatusSectionProps> = ({
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 (
<>
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{gitInfos.map((git) => {
const envCfg = services.find((s) => s.env === git.env);
const label = envCfg?.label ?? git.env;
return (
<Col span={12} key={git.env}>
<Card size="small" type="inner" title={label}>
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
<Descriptions.Item label="分支">
<Tag color="blue">{git.branch}</Tag>
{git.has_local_changes && (
<Tooltip title="工作区有未提交的变更">
<Tag color="warning"></Tag>
</Tooltip>
)}
</Descriptions.Item>
<Descriptions.Item label="最新提交">
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
{git.last_commit_message}
</Text>
</Descriptions.Item>
<Descriptions.Item label="提交时间">
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
</Descriptions.Item>
</Descriptions>
<Space>
<Button
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading[`pull-${git.env}`]}
onClick={() => onPull(git.env)}
>
Git Pull
</Button>
<Button
size="small"
icon={<SyncOutlined />}
loading={actionLoading[`sync-${git.env}`]}
onClick={() => onSyncDeps(git.env)}
>
</Button>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleViewEnv(git.env, label)}
>
</Button>
</Space>
</Card>
</Col>
);
})}
</Row>
</Card>
{/* 配置查看弹窗 */}
<Modal
title={envModalTitle}
open={envModalOpen}
onCancel={() => setEnvModalOpen(false)}
footer={null}
width={700}
>
<TextArea
value={envModalContent}
readOnly
autoSize={{ minRows: 10, maxRows: 30 }}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
</Modal>
</>
);
};
export default GitStatusSection;

View File

@@ -0,0 +1,127 @@
/**
* 服务状态区块
*
* 展示各环境服务运行状态,支持启动/停止/重启操作。
* 从 OpsPanel 拆分,可独立使用(如 Dashboard 聚合页)。
*/
import React from "react";
import {
Card,
Row,
Col,
Tag,
Button,
Space,
Descriptions,
} from "antd";
import {
PlayCircleOutlined,
PauseCircleOutlined,
ReloadOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
} from "@ant-design/icons";
import type { ServiceStatus } from "../../api/opsPanel";
/** 秒数格式化为 "Xd Xh Xm" */
function formatUptime(seconds: number | null): string {
if (seconds == null) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (d > 0) parts.push(`${d}`);
if (h > 0) parts.push(`${h}`);
parts.push(`${m}`);
return parts.join(" ");
}
export interface ServiceStatusSectionProps {
services: ServiceStatus[];
actionLoading: Record<string, boolean>;
onStart: (env: string) => void;
onStop: (env: string) => void;
onRestart: (env: string) => void;
}
const ServiceStatusSection: React.FC<ServiceStatusSectionProps> = ({
services,
actionLoading,
onStart,
onStop,
onRestart,
}) => (
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}>
<Row gutter={16}>
{services.map((svc) => (
<Col span={12} key={svc.env}>
<Card
size="small"
type="inner"
title={
<Space>
{svc.running
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
{svc.label}
<Tag color={svc.running ? "success" : "error"}>
{svc.running ? "运行中" : "已停止"}
</Tag>
</Space>
}
extra={<Tag>:{svc.port}</Tag>}
>
{svc.running && (
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
<Descriptions.Item label="运行时长">
<ClockCircleOutlined style={{ marginRight: 4 }} />
{formatUptime(svc.uptime_seconds)}
</Descriptions.Item>
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
</Descriptions>
)}
<Space>
{!svc.running && (
<Button
type="primary"
size="small"
icon={<PlayCircleOutlined />}
loading={actionLoading[`start-${svc.env}`]}
onClick={() => onStart(svc.env)}
>
</Button>
)}
{svc.running && (
<>
<Button
danger
size="small"
icon={<PauseCircleOutlined />}
loading={actionLoading[`stop-${svc.env}`]}
onClick={() => onStop(svc.env)}
>
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={actionLoading[`restart-${svc.env}`]}
onClick={() => onRestart(svc.env)}
>
</Button>
</>
)}
</Space>
</Card>
</Col>
))}
</Row>
</Card>
);
export default ServiceStatusSection;

View File

@@ -0,0 +1,65 @@
/**
* 系统资源区块
*
* 展示服务器 CPU / 内存 / 磁盘使用情况。
* 从 OpsPanel 拆分,可独立使用(如 Dashboard 聚合页)。
*/
import React from "react";
import { Card, Row, Col, Statistic, Progress, Typography } from "antd";
import type { SystemInfo } from "../../api/opsPanel";
const { Text } = Typography;
export interface SystemResourceSectionProps {
system: SystemInfo;
}
const SystemResourceSection: React.FC<SystemResourceSectionProps> = ({ system }) => (
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
<Row gutter={24}>
<Col span={8}>
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
<Progress
percent={system.cpu_percent}
size="small"
status={system.cpu_percent > 80 ? "exception" : "normal"}
showInfo={false}
/>
</Col>
<Col span={8}>
<Statistic
title="内存"
value={system.memory_used_gb}
suffix={`/ ${system.memory_total_gb} GB`}
precision={1}
/>
<Progress
percent={system.memory_percent}
size="small"
status={system.memory_percent > 85 ? "exception" : "normal"}
showInfo={false}
/>
</Col>
<Col span={8}>
<Statistic
title="磁盘"
value={system.disk_used_gb}
suffix={`/ ${system.disk_total_gb} GB`}
precision={1}
/>
<Progress
percent={system.disk_percent}
size="small"
status={system.disk_percent > 90 ? "exception" : "normal"}
showInfo={false}
/>
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
{new Date(system.boot_time).toLocaleString()}
</Text>
</Card>
);
export default SystemResourceSection;

View File

@@ -0,0 +1,14 @@
/**
* OpsPanel 子组件导出
*
* 从 OpsPanel 拆分的三个独立区块,可被 Dashboard 等页面单独引用。
*/
export { default as SystemResourceSection } from "./SystemResourceSection";
export type { SystemResourceSectionProps } from "./SystemResourceSection";
export { default as ServiceStatusSection } from "./ServiceStatusSection";
export type { ServiceStatusSectionProps } from "./ServiceStatusSection";
export { default as GitStatusSection } from "./GitStatusSection";
export type { GitStatusSectionProps } from "./GitStatusSection";

View File

@@ -0,0 +1,217 @@
/**
* AI 运行总览 Dashboard 页面。
*
* - 顶部:门店筛选 + 刷新
* - 第一行4 个统计卡片今日调用、成功率、Token 消耗、平均延迟)
* - 第二行7 天趋势表格 + App 调用占比表格
* - 第三行Token 预算进度条 + App 健康状态
* - 第四行:告警列表
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Card, Row, Col, Statistic, Table, Tag, Badge, Progress,
Select, Button, message, Typography, Space,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getDashboard,
type DashboardResponse, type DailyTrend, type AppDistItem,
type AlertItem, type AppHealthItem,
} from "../api/adminAI";
const { Title } = Typography;
const ALERT_STATUS_COLOR: Record<string, string> = {
failed: "red", timeout: "orange", circuit_open: "volcano",
};
const ALERT_MGMT_COLOR: Record<string, string> = {
pending: "warning", acknowledged: "success", ignored: "default",
};
const HEALTH_STATUS: Record<string, "success" | "error" | "warning" | "default"> = {
success: "success", failed: "error", timeout: "warning", circuit_open: "error",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
// ---- 表格列定义 ----
const trendColumns: ColumnsType<DailyTrend> = [
{ title: "日期", dataIndex: "date", key: "date", width: 120 },
{ title: "调用量", dataIndex: "calls", key: "calls", align: "right" },
{
title: "成功率", dataIndex: "success_rate", key: "success_rate", align: "right",
render: (v: number) => `${(v * 100).toFixed(1)}%`,
},
];
const distColumns: ColumnsType<AppDistItem> = [
{ title: "App 类型", dataIndex: "app_type", key: "app_type" },
{ title: "调用次数", dataIndex: "count", key: "count", align: "right" },
{
title: "占比", dataIndex: "percentage", key: "percentage", align: "right",
render: (v: number) => `${(v * 100).toFixed(1)}%`,
},
];
const alertColumns: ColumnsType<AlertItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 80 },
{ title: "App", dataIndex: "app_type", key: "app_type", width: 160 },
{
title: "状态", dataIndex: "status", key: "status", width: 110,
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 110,
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
},
{
title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true,
render: (v: string | null) => v ?? "—",
},
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
];
// ---- 页面组件 ----
const AIDashboard: React.FC = () => {
const [data, setData] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(false);
const [siteId, setSiteId] = useState<number | undefined>(undefined);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await getDashboard(siteId);
setData(res);
} catch {
message.error("加载 Dashboard 失败");
} finally {
setLoading(false);
}
}, [siteId]);
useEffect(() => { load(); }, [load]);
return (
<div>
{/* 顶部:门店筛选 + 刷新 */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Space>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Select
allowClear placeholder="门店筛选" style={{ width: 200 }}
value={siteId} onChange={(v) => setSiteId(v)}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
</Space>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Row>
{/* 第一行4 个统计卡片 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card><Statistic title="今日调用次数" value={data?.today_calls ?? 0} /></Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="今日成功率" suffix="%"
value={data ? (data.today_success_rate * 100).toFixed(1) : "0.0"}
/>
</Card>
</Col>
<Col span={6}>
<Card><Statistic title="今日 Token 消耗" value={data?.today_tokens ?? 0} /></Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="平均延迟" suffix="ms"
value={data ? data.today_avg_latency_ms.toFixed(0) : "0"}
/>
</Card>
</Col>
</Row>
{/* 第二行7 天趋势 + App 调用占比 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Card title="近 7 天趋势" size="small">
<Table<DailyTrend>
columns={trendColumns}
dataSource={data?.trend_7d ?? []}
rowKey="date" size="small" pagination={false}
loading={loading}
/>
</Card>
</Col>
<Col span={12}>
<Card title="App 调用占比" size="small">
<Table<AppDistItem>
columns={distColumns}
dataSource={data?.app_distribution ?? []}
rowKey="app_type" size="small" pagination={false}
loading={loading}
/>
</Card>
</Col>
</Row>
{/* 第三行Token 预算 + App 健康状态 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<Card title="Token 预算" size="small">
<div style={{ marginBottom: 12 }}>
<span>{data?.budget.daily_used ?? 0} / {data?.budget.daily_limit ?? 0}</span>
<Progress
percent={data ? +(data.budget.daily_pct * 100).toFixed(1) : 0}
status={data && data.budget.daily_pct > 0.9 ? "exception" : "active"}
/>
</div>
<div>
<span>{data?.budget.monthly_used ?? 0} / {data?.budget.monthly_limit ?? 0}</span>
<Progress
percent={data ? +(data.budget.monthly_pct * 100).toFixed(1) : 0}
status={data && data.budget.monthly_pct > 0.9 ? "exception" : "active"}
/>
</div>
</Card>
</Col>
<Col span={12}>
<Card title="App 健康状态" size="small">
{(data?.app_health ?? []).map((item: AppHealthItem) => (
<div key={item.app_type} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span>{item.app_type}</span>
<Space>
<Badge status={HEALTH_STATUS[item.last_status ?? ""] ?? "default"} text={item.last_status ?? "无记录"} />
<span style={{ fontSize: 12, color: "#999" }}>{fmtTime(item.last_call_at)}</span>
</Space>
</div>
))}
{(data?.app_health ?? []).length === 0 && <span style={{ color: "#999" }}></span>}
</Card>
</Col>
</Row>
{/* 第四行:告警列表 */}
<Card title="告警列表" size="small">
<Table<AlertItem>
columns={alertColumns}
dataSource={data?.recent_alerts ?? []}
rowKey="id" size="small" pagination={{ pageSize: 10 }}
loading={loading}
/>
</Card>
</div>
);
};
export default AIDashboard;

View File

@@ -0,0 +1,329 @@
/**
* AI 手动操作页面。
*
* 4 个 Card 区域:
* - Card 1手动重跑App + member_id + site_id → 执行)
* - Card 2缓存失效app_type + member_id + site_id → 失效)
* - Card 3批量执行app_types + member_ids + site_id → 预估 → 确认)
* - Card 4告警管理告警列表 + 确认/忽略)
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Card, Row, Col, Select, Input, Button, Table, Tag, Space,
Checkbox, Modal, Statistic, message, Typography,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
retryTriggerJob, invalidateCache, createBatchRun, confirmBatchRun,
getAlerts, ackAlert, ignoreAlert,
type AlertItem, type BatchRunEstimate,
} from "../api/adminAI";
const { TextArea } = Input;
const { Title } = Typography;
const APP_TYPE_OPTIONS = [
{ label: "App3 维客线索", value: "app3_clue" },
{ label: "App4 关系分析", value: "app4_analysis" },
{ label: "App5 话术参考", value: "app5_tactics" },
{ label: "App6 备注分析", value: "app6_note_analysis" },
{ label: "App7 客户分析", value: "app7_customer_analysis" },
{ label: "App8 线索整理", value: "app8_clue_consolidated" },
];
const ALERT_STATUS_COLOR: Record<string, string> = {
failed: "red", timeout: "orange", circuit_open: "volcano",
};
const ALERT_MGMT_COLOR: Record<string, string> = {
pending: "warning", acknowledged: "success", ignored: "default",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
const AIOperations: React.FC = () => {
// ---- Card 1: 手动重跑 ----
const [retryJobId, setRetryJobId] = useState<string>("");
const [retryLoading, setRetryLoading] = useState(false);
const handleRetry = async () => {
const id = Number(retryJobId);
if (!id || Number.isNaN(id)) { message.warning("请输入有效的任务 ID"); return; }
setRetryLoading(true);
try {
const res = await retryTriggerJob(id);
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
setRetryJobId("");
} catch {
message.error("重跑失败");
} finally {
setRetryLoading(false);
}
};
// ---- Card 2: 缓存失效 ----
const [cacheAppType, setCacheAppType] = useState<string | undefined>();
const [cacheMemberId, setCacheMemberId] = useState<string>("");
const [cacheSiteId, setCacheSiteId] = useState<number>(2790685415443269);
const [cacheLoading, setCacheLoading] = useState(false);
const [cacheResult, setCacheResult] = useState<number | null>(null);
const handleInvalidate = async () => {
setCacheLoading(true);
setCacheResult(null);
try {
const res = await invalidateCache({
site_id: cacheSiteId,
app_type: cacheAppType,
member_id: cacheMemberId ? Number(cacheMemberId) : undefined,
});
setCacheResult(res.affected_count);
message.success(`已失效 ${res.affected_count} 条缓存`);
} catch {
message.error("缓存失效操作失败");
} finally {
setCacheLoading(false);
}
};
// ---- Card 3: 批量执行 ----
const [batchAppTypes, setBatchAppTypes] = useState<string[]>([]);
const [batchMemberIds, setBatchMemberIds] = useState<string>("");
const [batchSiteId, setBatchSiteId] = useState<number>(2790685415443269);
const [batchLoading, setBatchLoading] = useState(false);
const [batchEstimate, setBatchEstimate] = useState<BatchRunEstimate | null>(null);
const [confirmVisible, setConfirmVisible] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const parseMemberIds = (text: string): number[] =>
text.split(/[\n,;\s]+/).map(Number).filter((n) => !Number.isNaN(n) && n > 0);
const handleBatchEstimate = async () => {
const memberIds = parseMemberIds(batchMemberIds);
if (batchAppTypes.length === 0) { message.warning("请选择至少一个 App"); return; }
if (memberIds.length === 0) { message.warning("请输入有效的会员 ID"); return; }
setBatchLoading(true);
try {
const res = await createBatchRun({ app_types: batchAppTypes, member_ids: memberIds, site_id: batchSiteId });
setBatchEstimate(res);
setConfirmVisible(true);
} catch {
message.error("预估失败");
} finally {
setBatchLoading(false);
}
};
const handleBatchConfirm = async () => {
if (!batchEstimate) return;
setConfirmLoading(true);
try {
await confirmBatchRun(batchEstimate.batch_id);
message.success("批量执行已启动");
setConfirmVisible(false);
setBatchEstimate(null);
setBatchAppTypes([]);
setBatchMemberIds("");
} catch {
message.error("确认执行失败");
} finally {
setConfirmLoading(false);
}
};
// ---- Card 4: 告警管理 ----
const [alerts, setAlerts] = useState<AlertItem[]>([]);
const [alertTotal, setAlertTotal] = useState(0);
const [alertLoading, setAlertLoading] = useState(false);
const [alertPage, setAlertPage] = useState(1);
const loadAlerts = useCallback(async () => {
setAlertLoading(true);
try {
const res = await getAlerts({ page: alertPage, page_size: 10 });
setAlerts(res.items);
setAlertTotal(res.total);
} catch {
message.error("加载告警列表失败");
} finally {
setAlertLoading(false);
}
}, [alertPage]);
useEffect(() => { loadAlerts(); }, [loadAlerts]);
const handleAck = async (id: number) => {
try {
await ackAlert(id);
message.success("已确认告警");
loadAlerts();
} catch {
message.error("确认失败");
}
};
const handleIgnore = async (id: number) => {
try {
await ignoreAlert(id);
message.success("已忽略告警");
loadAlerts();
} catch {
message.error("忽略失败");
}
};
const alertColumns: ColumnsType<AlertItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
{ title: "App", dataIndex: "app_type", key: "app_type", width: 150 },
{
title: "状态", dataIndex: "status", key: "status", width: 100,
render: (v: string) => <Tag color={ALERT_STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{
title: "告警状态", dataIndex: "alert_status", key: "alert_status", width: 100,
render: (v: string | null) => v ? <Tag color={ALERT_MGMT_COLOR[v] ?? "default"}>{v}</Tag> : "—",
},
{ title: "错误信息", dataIndex: "error_message", key: "error_message", ellipsis: true, render: (v) => v ?? "—" },
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 160, render: fmtTime },
{
title: "操作", key: "action", width: 140,
render: (_: unknown, r: AlertItem) => (
<Space>
<Button size="small" onClick={() => handleAck(r.id)} disabled={r.alert_status === "acknowledged"}></Button>
<Button size="small" onClick={() => handleIgnore(r.id)} disabled={r.alert_status === "ignored"}></Button>
</Space>
),
},
];
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>AI </Title>
<Row gutter={16} style={{ marginBottom: 16 }}>
{/* Card 1: 手动重跑 */}
<Col span={12}>
<Card title="手动重跑" size="small">
<Space direction="vertical" style={{ width: "100%" }}>
<Input
placeholder="调度任务 ID" value={retryJobId}
onChange={(e) => setRetryJobId(e.target.value)}
/>
<Button type="primary" onClick={handleRetry} loading={retryLoading}></Button>
</Space>
</Card>
</Col>
{/* Card 2: 缓存失效 */}
<Col span={12}>
<Card title="缓存失效" size="small">
<Space direction="vertical" style={{ width: "100%" }}>
<Select
allowClear placeholder="App 类型(可选)" style={{ width: "100%" }}
value={cacheAppType} onChange={setCacheAppType}
options={APP_TYPE_OPTIONS}
/>
<Input
placeholder="会员 ID可选" value={cacheMemberId}
onChange={(e) => setCacheMemberId(e.target.value)}
/>
<Select
placeholder="门店" style={{ width: "100%" }}
value={cacheSiteId} onChange={setCacheSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<Space>
<Button type="primary" danger onClick={handleInvalidate} loading={cacheLoading}></Button>
{cacheResult != null && <Statistic title="受影响记录" value={cacheResult} />}
</Space>
</Space>
</Card>
</Col>
</Row>
{/* Card 3: 批量执行 */}
<Card title="批量执行" size="small" style={{ marginBottom: 16 }}>
<Row gutter={16}>
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}> App</div>
<Checkbox.Group
options={APP_TYPE_OPTIONS}
value={batchAppTypes}
onChange={(v) => setBatchAppTypes(v as string[])}
style={{ display: "flex", flexDirection: "column", gap: 4 }}
/>
</Col>
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}> ID</div>
<TextArea
rows={6} value={batchMemberIds}
onChange={(e) => setBatchMemberIds(e.target.value)}
placeholder="例如:&#10;12345&#10;67890"
/>
</Col>
<Col span={8}>
<div style={{ marginBottom: 8, fontWeight: 500 }}></div>
<Select
style={{ width: "100%", marginBottom: 16 }}
value={batchSiteId} onChange={setBatchSiteId}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<Button type="primary" onClick={handleBatchEstimate} loading={batchLoading}>
</Button>
</Col>
</Row>
</Card>
{/* 批量执行确认弹窗 */}
<Modal
title="确认批量执行"
open={confirmVisible}
onCancel={() => { setConfirmVisible(false); setBatchEstimate(null); }}
onOk={handleBatchConfirm}
confirmLoading={confirmLoading}
okText="确认执行" cancelText="取消"
>
{batchEstimate && (
<Row gutter={16}>
<Col span={12}>
<Statistic title="预估调用次数" value={batchEstimate.estimated_calls} suffix="次" />
</Col>
<Col span={12}>
<Statistic title="预估 Token 消耗" value={batchEstimate.estimated_tokens} />
</Col>
</Row>
)}
<p style={{ marginTop: 16, color: "#faad14" }}>
</p>
</Modal>
{/* Card 4: 告警管理 */}
<Card
title="告警管理" size="small"
extra={<Button icon={<ReloadOutlined />} size="small" onClick={loadAlerts}></Button>}
>
<Table<AlertItem>
columns={alertColumns}
dataSource={alerts}
rowKey="id" size="small"
loading={alertLoading}
pagination={{
current: alertPage, pageSize: 10, total: alertTotal,
onChange: (p) => setAlertPage(p),
showTotal: (t) => `${t}`,
}}
/>
</Card>
</div>
);
};
export default AIOperations;

View File

@@ -0,0 +1,229 @@
/**
* AI 调用明细页面。
*
* - 顶部筛选器app_type / status / trigger_type / site_id / 日期范围
* - 主体分页表格app_type、trigger_type、member_id、tokens、延迟、状态
* - 点击行Drawer 展示完整 prompt / response / error_message
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Card, Table, Tag, Select, Button, DatePicker, Row, Space,
Drawer, Descriptions, message, Typography,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getRunLogs, getRunLogDetail,
type RunLogItem, type RunLogDetailResponse, type RunLogQuery,
} from "../api/adminAI";
const { RangePicker } = DatePicker;
const { Title } = Typography;
const STATUS_COLOR: Record<string, string> = {
success: "green", failed: "red", timeout: "orange",
circuit_open: "volcano", pending: "default", running: "processing",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
const AIRunLogs: React.FC = () => {
const [items, setItems] = useState<RunLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// 筛选状态
const [appType, setAppType] = useState<string | undefined>();
const [status, setStatus] = useState<string | undefined>();
const [triggerType, setTriggerType] = useState<string | undefined>();
const [siteId, setSiteId] = useState<number | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
// Drawer 详情
const [drawerVisible, setDrawerVisible] = useState(false);
const [detail, setDetail] = useState<RunLogDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const params: RunLogQuery = {
page, page_size: pageSize,
app_type: appType, status, trigger_type: triggerType,
site_id: siteId,
date_from: dateRange?.[0], date_to: dateRange?.[1],
};
const res = await getRunLogs(params);
setItems(res.items);
setTotal(res.total);
} catch {
message.error("加载调用记录失败");
} finally {
setLoading(false);
}
}, [page, pageSize, appType, status, triggerType, siteId, dateRange]);
useEffect(() => { load(); }, [load]);
const handleRowClick = async (id: number) => {
setDetailLoading(true);
setDrawerVisible(true);
try {
const res = await getRunLogDetail(id);
setDetail(res);
} catch {
message.error("加载详情失败");
} finally {
setDetailLoading(false);
}
};
const APP_TYPE_OPTIONS = [
"app1_chat", "app2_finance", "app3_clue", "app4_analysis",
"app5_tactics", "app6_note_analysis", "app7_customer_analysis",
"app8_clue_consolidated",
].map((v) => ({ label: v, value: v }));
const columns: ColumnsType<RunLogItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
{ title: "App 类型", dataIndex: "app_type", key: "app_type", width: 160 },
{ title: "触发方式", dataIndex: "trigger_type", key: "trigger_type", width: 110 },
{ title: "会员 ID", dataIndex: "member_id", key: "member_id", width: 100, render: (v) => v ?? "—" },
{ title: "Tokens", dataIndex: "tokens_used", key: "tokens_used", width: 90, align: "right" },
{
title: "延迟", dataIndex: "latency_ms", key: "latency_ms", width: 90, align: "right",
render: (v: number | null) => v != null ? `${v}ms` : "—",
},
{
title: "状态", dataIndex: "status", key: "status", width: 110,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
];
return (
<div>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Row>
{/* 筛选器行 */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
allowClear placeholder="App 类型" style={{ width: 180 }}
value={appType} onChange={(v) => { setAppType(v); setPage(1); }}
options={APP_TYPE_OPTIONS}
/>
<Select
allowClear placeholder="状态" style={{ width: 130 }}
value={status} onChange={(v) => { setStatus(v); setPage(1); }}
options={[
{ label: "success", value: "success" },
{ label: "failed", value: "failed" },
{ label: "timeout", value: "timeout" },
{ label: "circuit_open", value: "circuit_open" },
]}
/>
<Select
allowClear placeholder="触发方式" style={{ width: 130 }}
value={triggerType} onChange={(v) => { setTriggerType(v); setPage(1); }}
options={[
{ label: "event", value: "event" },
{ label: "scheduled", value: "scheduled" },
{ label: "manual", value: "manual" },
{ label: "backfill", value: "backfill" },
]}
/>
<Select
allowClear placeholder="门店" style={{ width: 180 }}
value={siteId} onChange={(v) => { setSiteId(v); setPage(1); }}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<RangePicker
onChange={(_, dateStrings) => {
setDateRange(dateStrings[0] ? [dateStrings[0], dateStrings[1]] : null);
setPage(1);
}}
/>
</Space>
</Card>
{/* 主体表格 */}
<Table<RunLogItem>
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
scroll={{ x: 1000 }}
onRow={(record) => ({
onClick: () => handleRowClick(record.id),
style: { cursor: "pointer" },
})}
pagination={{
current: page, pageSize, total,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}}
/>
{/* 详情 Drawer */}
<Drawer
title={`调用记录详情 #${detail?.id ?? ""}`}
open={drawerVisible}
onClose={() => { setDrawerVisible(false); setDetail(null); }}
width={640}
loading={detailLoading}
>
{detail && (
<>
<Descriptions column={2} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="App 类型">{detail.app_type}</Descriptions.Item>
<Descriptions.Item label="触发方式">{detail.trigger_type}</Descriptions.Item>
<Descriptions.Item label="会员 ID">{detail.member_id ?? "—"}</Descriptions.Item>
<Descriptions.Item label="门店 ID">{detail.site_id}</Descriptions.Item>
<Descriptions.Item label="Tokens">{detail.tokens_used}</Descriptions.Item>
<Descriptions.Item label="延迟">{detail.latency_ms != null ? `${detail.latency_ms}ms` : "—"}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status] ?? "default"}>{detail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Session ID">{detail.session_id ?? "—"}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
<Descriptions.Item label="完成时间" span={2}>{fmtTime(detail.finished_at)}</Descriptions.Item>
</Descriptions>
{detail.error_message && (
<Card title="错误信息" size="small" style={{ marginBottom: 16 }}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", color: "#cf1322" }}>
{detail.error_message}
</pre>
</Card>
)}
<Card title="Request Prompt" size="small" style={{ marginBottom: 16 }}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", background: "#f5f5f5", padding: 8, borderRadius: 4 }}>
{detail.request_prompt ?? "(无)"}
</pre>
</Card>
<Card title="Response" size="small">
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 300, overflow: "auto", background: "#f5f5f5", padding: 8, borderRadius: 4 }}>
{detail.response_text ?? "(无)"}
</pre>
</Card>
</>
)}
</Drawer>
</div>
);
};
export default AIRunLogs;

View File

@@ -0,0 +1,247 @@
/**
* AI 调度状态页面。
*
* - 顶部筛选器event_type / status / site_id / 日期范围
* - 统计行:今日去重跳过数
* - 主体:分页表格(事件类型、会员、状态、执行链、耗时、操作列)
* - 操作列:查看详情 Modal、手动重跑 Popconfirm
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Card, Table, Tag, Select, Button, DatePicker, Row, Col, Space,
Statistic, Modal, Popconfirm, Descriptions, message, Typography,
} from "antd";
import { ReloadOutlined } from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import {
getTriggerJobs, getTriggerJobDetail, retryTriggerJob,
type TriggerJobItem, type TriggerJobDetailResponse, type TriggerJobQuery,
} from "../api/adminAI";
const { RangePicker } = DatePicker;
const { Title } = Typography;
const STATUS_COLOR: Record<string, string> = {
pending: "default", running: "processing", success: "success",
failed: "error", skipped_duplicate: "warning", timeout: "orange",
};
function fmtTime(raw: string | null): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
function calcDuration(start: string | null, end: string | null): string {
if (!start || !end) return "—";
const ms = new Date(end).getTime() - new Date(start).getTime();
if (Number.isNaN(ms) || ms < 0) return "—";
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
const AITriggerJobs: React.FC = () => {
const [items, setItems] = useState<TriggerJobItem[]>([]);
const [total, setTotal] = useState(0);
const [skipped, setSkipped] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [pageSize] = useState(20);
// 筛选状态
const [eventType, setEventType] = useState<string | undefined>();
const [status, setStatus] = useState<string | undefined>();
const [siteId, setSiteId] = useState<number | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
// 详情 Modal
const [detailVisible, setDetailVisible] = useState(false);
const [detail, setDetail] = useState<TriggerJobDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const params: TriggerJobQuery = {
page, page_size: pageSize,
event_type: eventType, status, site_id: siteId,
date_from: dateRange?.[0], date_to: dateRange?.[1],
};
const res = await getTriggerJobs(params);
setItems(res.items);
setTotal(res.total);
setSkipped(res.today_skipped_duplicates);
} catch {
message.error("加载调度任务失败");
} finally {
setLoading(false);
}
}, [page, pageSize, eventType, status, siteId, dateRange]);
useEffect(() => { load(); }, [load]);
const handleViewDetail = async (id: number) => {
setDetailLoading(true);
setDetailVisible(true);
try {
const res = await getTriggerJobDetail(id);
setDetail(res);
} catch {
message.error("加载详情失败");
} finally {
setDetailLoading(false);
}
};
const handleRetry = async (id: number) => {
try {
const res = await retryTriggerJob(id);
message.success(`已创建重跑任务 #${res.trigger_job_id}`);
load();
} catch {
message.error("重跑失败");
}
};
const columns: ColumnsType<TriggerJobItem> = [
{ title: "ID", dataIndex: "id", key: "id", width: 70 },
{ title: "事件类型", dataIndex: "event_type", key: "event_type", width: 140 },
{ title: "会员 ID", dataIndex: "member_id", key: "member_id", width: 100, render: (v) => v ?? "—" },
{
title: "状态", dataIndex: "status", key: "status", width: 120,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "执行链", dataIndex: "app_chain", key: "app_chain", ellipsis: true, render: (v) => v ?? "—" },
{
title: "强制执行", dataIndex: "is_forced", key: "is_forced", width: 80,
render: (v: boolean) => v ? <Tag color="blue"></Tag> : "否",
},
{
title: "耗时", key: "duration", width: 90,
render: (_: unknown, r: TriggerJobItem) => calcDuration(r.started_at, r.finished_at),
},
{ title: "创建时间", dataIndex: "created_at", key: "created_at", width: 170, render: fmtTime },
{
title: "操作", key: "action", width: 160, fixed: "right" as const,
render: (_: unknown, r: TriggerJobItem) => (
<Space>
<Button size="small" onClick={() => handleViewDetail(r.id)}></Button>
<Popconfirm title="确认手动重跑此任务?" onConfirm={() => handleRetry(r.id)}>
<Button size="small" type="link" danger></Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>AI </Title>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Row>
{/* 筛选器行 */}
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<Select
allowClear placeholder="事件类型" style={{ width: 160 }}
value={eventType} onChange={(v) => { setEventType(v); setPage(1); }}
options={[
{ label: "consumption", value: "consumption" },
{ label: "note", value: "note" },
{ label: "task_assign", value: "task_assign" },
{ label: "coach_consumption", value: "coach_consumption" },
{ label: "scheduled", value: "scheduled" },
]}
/>
<Select
allowClear placeholder="状态" style={{ width: 140 }}
value={status} onChange={(v) => { setStatus(v); setPage(1); }}
options={[
{ label: "pending", value: "pending" },
{ label: "running", value: "running" },
{ label: "success", value: "success" },
{ label: "failed", value: "failed" },
{ label: "skipped_duplicate", value: "skipped_duplicate" },
]}
/>
<Select
allowClear placeholder="门店" style={{ width: 180 }}
value={siteId} onChange={(v) => { setSiteId(v); setPage(1); }}
options={[{ label: "默认门店", value: 2790685415443269 }]}
/>
<RangePicker
onChange={(_, dateStrings) => {
setDateRange(dateStrings[0] ? [dateStrings[0], dateStrings[1]] : null);
setPage(1);
}}
/>
</Space>
</Card>
{/* 统计行 */}
<Row style={{ marginBottom: 16 }}>
<Col>
<Statistic title="今日去重跳过数" value={skipped} />
</Col>
</Row>
{/* 主体表格 */}
<Table<TriggerJobItem>
columns={columns}
dataSource={items}
rowKey="id"
loading={loading}
scroll={{ x: 1100 }}
pagination={{
current: page, pageSize, total,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}}
/>
{/* 详情 Modal */}
<Modal
title={`调度任务详情 #${detail?.id ?? ""}`}
open={detailVisible}
onCancel={() => { setDetailVisible(false); setDetail(null); }}
footer={null} width={640}
loading={detailLoading}
>
{detail && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="事件类型">{detail.event_type}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_COLOR[detail.status] ?? "default"}>{detail.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="会员 ID">{detail.member_id ?? "—"}</Descriptions.Item>
<Descriptions.Item label="门店 ID">{detail.site_id}</Descriptions.Item>
<Descriptions.Item label="执行链">{detail.app_chain ?? "—"}</Descriptions.Item>
<Descriptions.Item label="连接器">{detail.connector_type}</Descriptions.Item>
<Descriptions.Item label="强制执行">{detail.is_forced ? "是" : "否"}</Descriptions.Item>
<Descriptions.Item label="耗时">{calcDuration(detail.started_at, detail.finished_at)}</Descriptions.Item>
<Descriptions.Item label="创建时间" span={2}>{fmtTime(detail.created_at)}</Descriptions.Item>
{detail.error_message && (
<Descriptions.Item label="错误信息" span={2}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
{detail.error_message}
</pre>
</Descriptions.Item>
)}
{detail.payload && (
<Descriptions.Item label="Payload" span={2}>
<pre style={{ margin: 0, whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>
{JSON.stringify(detail.payload, null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
)}
</Modal>
</div>
);
};
export default AITriggerJobs;

View File

@@ -0,0 +1,380 @@
/**
* 运行状态仪表盘Dashboard
*
* 登录后默认首页,聚合 4 个区块:
* 1. OpsPanel 子组件系统资源、服务状态、Git 状态)
* 2. 数据库健康监控DbHealthCard
* 3. AI 运行总览(复用 AIDashboard
* 4. AI 调度摘要(今日触发数、成功率、最近错误 + 跳转链接)
*
* 跳转链接:
* - "ETL 状态详情" → /etl-tasks?tab=status
* - "触发器详情" → /triggers?tab=all
* - "AI 调度详情" → /triggers?tab=ai
*
* _Requirements: 2.1, 2.2, 2.6, 2.7, 2.8, 7.1, 7.2_
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Typography,
Spin,
message,
Modal,
Card,
Row,
Col,
Statistic,
Tag,
Button,
Space,
Divider,
List,
} from "antd";
import {
DashboardOutlined,
ReloadOutlined,
RightOutlined,
CloseCircleOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { useNavigate } from "react-router-dom";
import type { SystemInfo, ServiceStatus, GitInfo } from "../api/opsPanel";
import {
fetchSystemInfo,
fetchServicesStatus,
fetchGitInfo,
startService,
stopService,
restartService,
gitPull,
syncDeps,
} from "../api/opsPanel";
import {
SystemResourceSection,
ServiceStatusSection,
GitStatusSection,
} from "../components/ops";
import { fetchDbHealth } from "../api/dbHealth";
import type { DbHealthItem } from "../api/dbHealth";
import DbHealthCard from "../components/DbHealthCard";
import AIDashboard from "./AIDashboard";
import { getTriggerJobs, type TriggerJobItem } from "../api/adminAI";
const { Title, Text } = Typography;
/* 超时阈值(毫秒) */
const DB_HEALTH_TIMEOUT = 10_000;
const Dashboard: React.FC = () => {
const navigate = useNavigate();
// ---- OpsPanel 数据 ----
const [system, setSystem] = useState<SystemInfo | null>(null);
const [services, setServices] = useState<ServiceStatus[]>([]);
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
const [opsLoading, setOpsLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
// ---- DB 健康数据 ----
const [dbItems, setDbItems] = useState<DbHealthItem[]>([]);
const [dbLoading, setDbLoading] = useState(true);
const [dbTimeout, setDbTimeout] = useState(false);
// ---- AI 调度摘要 ----
const [triggerItems, setTriggerItems] = useState<TriggerJobItem[]>([]);
const [triggerTotal, setTriggerTotal] = useState(0);
const [triggerLoading, setTriggerLoading] = useState(true);
// ---- OpsPanel 数据加载(复用 OpsPanel.tsx 逻辑) ----
const loadOps = useCallback(async () => {
try {
const [sys, svc, git] = await Promise.all([
fetchSystemInfo(),
fetchServicesStatus(),
fetchGitInfo(),
]);
setSystem(sys);
setServices(svc);
setGitInfos(git);
} catch {
message.error("加载运维数据失败");
} finally {
setOpsLoading(false);
}
}, []);
// ---- DB 健康加载 ----
const loadDbHealth = useCallback(async () => {
setDbLoading(true);
setDbTimeout(false);
const timer = setTimeout(() => {
setDbTimeout(true);
setDbLoading(false);
}, DB_HEALTH_TIMEOUT);
try {
const items = await fetchDbHealth();
clearTimeout(timer);
setDbItems(items);
setDbTimeout(false);
} catch {
clearTimeout(timer);
message.error("加载数据库健康数据失败");
} finally {
setDbLoading(false);
}
}, []);
// ---- AI 调度摘要加载 ----
const loadTriggerSummary = useCallback(async () => {
setTriggerLoading(true);
try {
const res = await getTriggerJobs({ page: 1, page_size: 50 });
setTriggerItems(res.items);
setTriggerTotal(res.total);
} catch {
message.error("加载 AI 调度数据失败");
} finally {
setTriggerLoading(false);
}
}, []);
// ---- 初始化 + 定时刷新 ----
useEffect(() => {
loadOps();
loadDbHealth();
loadTriggerSummary();
const timer = setInterval(loadOps, 15_000);
return () => clearInterval(timer);
}, [loadOps, loadDbHealth, loadTriggerSummary]);
// ---- OpsPanel 操作处理(复用 OpsPanel.tsx 逻辑) ----
const withAction = async (key: string, fn: () => Promise<void>) => {
setActionLoading((prev) => ({ ...prev, [key]: true }));
try {
await fn();
} finally {
setActionLoading((prev) => ({ ...prev, [key]: false }));
}
};
const handleStart = (env: string) =>
withAction(`start-${env}`, async () => {
const r = await startService(env);
r.success ? message.success(r.message) : message.warning(r.message);
await loadOps();
});
const handleStop = (env: string) =>
withAction(`stop-${env}`, async () => {
const r = await stopService(env);
r.success ? message.success(r.message) : message.warning(r.message);
await loadOps();
});
const handleRestart = (env: string) =>
withAction(`restart-${env}`, async () => {
const r = await restartService(env);
r.success ? message.success(r.message) : message.warning(r.message);
await loadOps();
});
const handlePull = (env: string) =>
withAction(`pull-${env}`, async () => {
const r = await gitPull(env);
if (r.success) {
message.success("拉取成功");
Modal.info({
title: `Git Pull - ${env}`,
content: (
<pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>
{r.output}
</pre>
),
width: 600,
});
} else {
message.error("拉取失败");
Modal.error({
title: `Git Pull 失败 - ${env}`,
content: (
<pre style={{ maxHeight: 300, overflow: "auto", fontSize: 12 }}>
{r.output}
</pre>
),
width: 600,
});
}
await loadOps();
});
const handleSyncDeps = (env: string) =>
withAction(`sync-${env}`, async () => {
const r = await syncDeps(env);
r.success ? message.success("依赖同步完成") : message.error(r.message);
});
// ---- AI 调度摘要计算 ----
const todayStr = new Date().toISOString().slice(0, 10);
const todayJobs = triggerItems.filter(
(j) => j.created_at && j.created_at.startsWith(todayStr),
);
const todayCount = todayJobs.length;
const todaySuccess = todayJobs.filter((j) => j.status === "success").length;
const todaySuccessRate =
todayCount > 0 ? ((todaySuccess / todayCount) * 100).toFixed(1) : "0.0";
const recentErrors = triggerItems
.filter((j) => j.status === "failed")
.slice(0, 5);
// ---- 渲染 ----
if (opsLoading) {
return (
<Spin
size="large"
style={{ display: "flex", justifyContent: "center", marginTop: 120 }}
/>
);
}
return (
<div>
{/* 页面标题 + 快捷跳转 */}
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>
<DashboardOutlined style={{ marginRight: 8 }} />
</Title>
<Space>
<Button size="small" onClick={() => navigate("/etl-tasks?tab=status")}>
ETL <RightOutlined />
</Button>
<Button size="small" onClick={() => navigate("/triggers?tab=all")}>
<RightOutlined />
</Button>
<Button size="small" onClick={() => navigate("/triggers?tab=ai")}>
AI <RightOutlined />
</Button>
</Space>
</Row>
{/* 区块 1OpsPanel 子组件 */}
{system && <SystemResourceSection system={system} />}
<ServiceStatusSection
services={services}
actionLoading={actionLoading}
onStart={handleStart}
onStop={handleStop}
onRestart={handleRestart}
/>
<GitStatusSection
gitInfos={gitInfos}
services={services}
actionLoading={actionLoading}
onPull={handlePull}
onSyncDeps={handleSyncDeps}
/>
{/* 区块 2数据库健康监控 */}
<DbHealthCard
items={dbItems}
loading={dbLoading}
timeout={dbTimeout}
onRetry={loadDbHealth}
/>
{/* 区块 3AI 运行总览(复用 AIDashboard */}
<Divider orientation="left">AI </Divider>
<AIDashboard />
{/* 区块 4AI 调度摘要 */}
<Divider orientation="left">AI </Divider>
<Card
size="small"
title={
<Space>
<ThunderboltOutlined />
<span>AI </span>
</Space>
}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
onClick={loadTriggerSummary}
loading={triggerLoading}
>
</Button>
}
style={{ marginBottom: 16 }}
>
{/* 统计卡片行 */}
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}>
<Statistic title="今日触发数" value={todayCount} />
</Col>
<Col span={8}>
<Statistic title="今日成功率" value={todaySuccessRate} suffix="%" />
</Col>
<Col span={8}>
<Statistic
title="总记录数"
value={triggerTotal}
valueStyle={{ fontSize: 16 }}
/>
</Col>
</Row>
{/* 最近错误列表 */}
<Card type="inner" size="small" title="最近错误" style={{ marginBottom: 12 }}>
{recentErrors.length === 0 ? (
<Text type="secondary"></Text>
) : (
<List
size="small"
dataSource={recentErrors}
renderItem={(item) => (
<List.Item>
<Space>
<CloseCircleOutlined style={{ color: "#ff4d4f" }} />
<Tag color="error">{item.status}</Tag>
<Text>{item.event_type}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
ID: {item.id}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{item.created_at
? new Date(item.created_at).toLocaleString("zh-CN")
: "—"}
</Text>
</Space>
</List.Item>
)}
/>
)}
</Card>
{/* 跳转链接 */}
<Button
type="link"
onClick={() => navigate("/triggers?tab=ai")}
style={{ padding: 0 }}
>
AI <RightOutlined />
</Button>
</Card>
</div>
);
};
export default Dashboard;

View File

@@ -0,0 +1,760 @@
/**
* 开发调试全链路日志页面。
*
* - 顶部:覆盖率状态栏 + 筛选栏
* - 左侧请求列表Table分页
* - 右侧:选中请求的 span 链路树
* - 设置面板Drawer日志开关、保留天数、清理等
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Table, Tag, Alert, Button, Select, Input, InputNumber,
Checkbox, DatePicker, TimePicker, Space, Typography, Row, Col,
Drawer, Switch, message, Spin, Tooltip, Divider, Progress,
} from "antd";
import {
SettingOutlined, ReloadOutlined, SearchOutlined,
DeleteOutlined, ScanOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import dayjs from "dayjs";
import {
fetchDates, fetchRequests, fetchRequestDetail,
fetchCoverage, triggerCoverageScan,
fetchSettings, updateSettings, cleanupLogs,
} from "../api/devTrace";
import type {
TraceRequest, TraceDetail, TraceSpan, TraceSettings,
TraceFilter, TraceCoverage, SpanType, TraceType, CoverageCategory,
} from "../types/devTrace";
const { Title, Text } = Typography;
// ---- 常量 ----
const SPAN_TYPE_OPTIONS: SpanType[] = [
"HTTP_IN", "AUTH", "ROUTE", "SERVICE",
"DB_QUERY", "DB_CONN", "DB_CONN_RELEASE",
"HTTP_OUT", "ERROR", "DB_ERROR",
"MIDDLEWARE", "MIDDLEWARE_ERROR",
"SSE_START", "SSE_EVENT", "SSE_END",
"AI_CALL", "AI_STREAM", "AI_ERROR",
"WS_CONNECT", "WS_MESSAGE", "WS_DISCONNECT",
"JOB_START", "JOB_END", "JOB_ERROR",
];
const TRACE_TYPE_OPTIONS: { label: string; value: TraceType }[] = [
{ label: "HTTP", value: "http" },
{ label: "SSE", value: "sse" },
{ label: "WebSocket", value: "ws" },
{ label: "Job", value: "job" },
];
const METHOD_OPTIONS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
/** span_type → 颜色映射 */
const SPAN_COLOR: Record<string, string> = {
HTTP_IN: "#1890ff", HTTP_OUT: "#1890ff",
AUTH: "#fa8c16",
ROUTE: "#1890ff",
SERVICE: "#52c41a",
DB_QUERY: "#722ed1", DB_CONN: "#722ed1", DB_CONN_RELEASE: "#722ed1",
DB_ERROR: "#f5222d",
ERROR: "#f5222d",
MIDDLEWARE: "#8c8c8c", MIDDLEWARE_ERROR: "#f5222d",
SSE_START: "#13c2c2", SSE_EVENT: "#13c2c2", SSE_END: "#13c2c2",
AI_CALL: "#2f54eb", AI_STREAM: "#2f54eb", AI_ERROR: "#f5222d",
WS_CONNECT: "#faad14", WS_MESSAGE: "#faad14", WS_DISCONNECT: "#faad14",
JOB_START: "#8c8c8c", JOB_END: "#8c8c8c", JOB_ERROR: "#f5222d",
};
const ERROR_SPAN_TYPES = new Set(["ERROR", "DB_ERROR", "MIDDLEWARE_ERROR", "AI_ERROR", "JOB_ERROR"]);
// ---- 辅助函数 ----
function fmtTime(raw: string | null | undefined): string {
if (!raw) return "—";
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString("zh-CN");
}
function fmtDuration(ms: number): string {
if (ms < 1000) return `${ms.toFixed(0)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function coveragePct(cat: CoverageCategory): number {
return cat.total === 0 ? 100 : Math.round((cat.covered / cat.total) * 100);
}
// ---- 覆盖率状态栏 ----
const CoverageBar: React.FC<{
coverage: TraceCoverage | null;
loading: boolean;
onScan: () => void;
}> = ({ coverage, loading, onScan }) => {
if (!coverage) {
return (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={
<Row align="middle" gutter={16}>
<Col>
<Text strong>Trace </Text>
</Col>
<Col>
<Text type="secondary"></Text>
</Col>
<Col>
<Button
size="small" icon={<ScanOutlined />}
loading={loading} onClick={onScan}
>
</Button>
</Col>
</Row>
}
/>
);
}
const dims: { label: string; cat: CoverageCategory }[] = [
{ label: "路由", cat: coverage.routes },
{ label: "Service", cat: coverage.services },
{ label: "Job", cat: coverage.jobs },
{ label: "SSE", cat: coverage.sse_endpoints },
{ label: "WS", cat: coverage.ws_endpoints },
];
const allUncovered = dims.flatMap((d) =>
d.cat.uncovered.map((name) => `${d.label}: ${name}`),
);
return (
<Alert
type={allUncovered.length === 0 ? "success" : "warning"}
showIcon
style={{ marginBottom: 12 }}
message={
<Row align="middle" gutter={16}>
<Col>
<Text strong>Trace </Text>
</Col>
{dims.map((d) => (
<Col key={d.label}>
<Space size={4}>
<Text type="secondary">{d.label}</Text>
<Progress
type="circle" size={28}
percent={coveragePct(d.cat)}
format={(p) => `${p}%`}
/>
</Space>
</Col>
))}
<Col>
<Button
size="small" icon={<ScanOutlined />}
loading={loading} onClick={onScan}
>
</Button>
</Col>
</Row>
}
description={
allUncovered.length > 0 ? (
<div style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{allUncovered.join("、")}
</Text>
</div>
) : null
}
/>
);
};
// ---- Span 链路树 ----
const SpanTree: React.FC<{ detail: TraceDetail | null; loading: boolean }> = ({ detail, loading }) => {
if (loading) return <Spin style={{ display: "block", marginTop: 40, textAlign: "center" }} />;
if (!detail) {
return <Text type="secondary" style={{ display: "block", textAlign: "center", marginTop: 40 }}></Text>;
}
return (
<div style={{ padding: "0 8px" }}>
<div style={{ marginBottom: 8 }}>
<Text strong>{detail.method} {detail.path}</Text>
<Tag color={detail.error ? "red" : "green"} style={{ marginLeft: 8 }}>
{detail.status_code ?? "—"}
</Tag>
<Text type="secondary" style={{ marginLeft: 8 }}>{fmtDuration(detail.total_duration_ms)}</Text>
</div>
<Divider style={{ margin: "8px 0" }} />
{detail.spans.map((span, idx) => (
<SpanRow key={idx} span={span} index={idx} />
))}
</div>
);
};
const SpanRow: React.FC<{ span: TraceSpan; index: number }> = ({ span, index }) => {
const isError = ERROR_SPAN_TYPES.has(span.span_type);
const color = SPAN_COLOR[span.span_type] ?? "#8c8c8c";
const isDbQuery = span.span_type === "DB_QUERY";
// 根据 span 类型决定缩进层级
const indent = getSpanIndent(span.span_type);
return (
<div
style={{
marginLeft: indent * 16,
padding: "4px 8px",
marginBottom: 2,
borderLeft: `3px solid ${color}`,
background: isError ? "#fff2f0" : index % 2 === 0 ? "#fafafa" : "#fff",
borderRadius: 2,
fontSize: 13,
}}
>
<Row justify="space-between" align="top">
<Col flex="auto">
<Tag color={color} style={{ fontSize: 11, lineHeight: "18px" }}>
{span.span_type}
</Tag>
<Text style={{ color: isError ? "#f5222d" : undefined }}>
{span.description_zh || `${span.module}.${span.function}`}
</Text>
</Col>
<Col flex="none">
<Text type="secondary" style={{ fontSize: 12, whiteSpace: "nowrap" }}>
{fmtDuration(span.duration_ms)}
</Text>
</Col>
</Row>
{/* DB_QUERY / DB_ERROR展示 SQL 详情 */}
{(isDbQuery || span.span_type === "DB_ERROR") && !!span.extra?.sql && (
<div style={{ margin: "4px 0 0", fontSize: 11 }}>
<pre style={{
margin: 0, padding: "4px 8px",
background: span.span_type === "DB_ERROR" ? "#fff1f0" : "#f0f5ff",
border: `1px solid ${span.span_type === "DB_ERROR" ? "#ffa39e" : "#adc6ff"}`,
borderRadius: 2,
whiteSpace: "pre-wrap", wordBreak: "break-all",
maxHeight: 200, overflow: "auto",
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
}}>
{String(span.extra.sql)}
</pre>
{/* 绑定参数 */}
{span.extra.params != null && (
<div style={{ marginTop: 2, color: "#8c8c8c" }}>
<span style={{ color: "#722ed1" }}></span>
{JSON.stringify(span.extra.params)}
</div>
)}
{/* 行数 + 调用来源 */}
<div style={{ marginTop: 2, color: "#8c8c8c", display: "flex", gap: 12 }}>
{span.extra.row_count != null && (
<span><span style={{ color: "#1890ff" }}></span>{String(span.extra.row_count)}</span>
)}
{span.extra.caller != null && (
<span><span style={{ color: "#52c41a" }}></span>{String(span.extra.caller)}</span>
)}
</div>
</div>
)}
{/* 通用 params 展示(非 DB_QUERY/DB_ERROR且 params 非空) */}
{!isDbQuery && span.span_type !== "DB_ERROR" && span.params && Object.keys(span.params).length > 0 && (
<div style={{ marginTop: 4, fontSize: 11, color: "#8c8c8c" }}>
<span style={{ color: "#722ed1" }}></span>
<span style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace" }}>
{JSON.stringify(span.params, null, 0)}
</span>
</div>
)}
{/* ERROR展示错误信息 */}
{isError && span.result_summary && (
<div style={{ marginTop: 4, color: "#f5222d", fontSize: 12 }}>
{span.result_summary}
</div>
)}
</div>
);
};
/** 根据 span_type 返回缩进层级(模拟层级关系) */
function getSpanIndent(spanType: SpanType): number {
switch (spanType) {
case "HTTP_IN": case "HTTP_OUT": return 0;
case "MIDDLEWARE": case "MIDDLEWARE_ERROR": return 1;
case "AUTH": return 1;
case "ROUTE": return 1;
case "SERVICE": return 2;
case "DB_QUERY": case "DB_CONN": case "DB_CONN_RELEASE": case "DB_ERROR": return 3;
case "SSE_START": case "SSE_END": return 1;
case "SSE_EVENT": case "AI_CALL": case "AI_STREAM": case "AI_ERROR": return 2;
case "WS_CONNECT": case "WS_DISCONNECT": return 1;
case "WS_MESSAGE": return 2;
case "JOB_START": case "JOB_END": case "JOB_ERROR": return 0;
case "ERROR": return 1;
default: return 1;
}
}
// ---- 设置面板Task 20 ----
const SettingsDrawer: React.FC<{
open: boolean;
onClose: () => void;
}> = ({ open, onClose }) => {
const [settings, setSettings] = useState<TraceSettings | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [cleanRange, setCleanRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [cleaning, setCleaning] = useState(false);
const loadSettings = useCallback(async () => {
setLoading(true);
try {
const res = await fetchSettings();
setSettings(res);
} catch {
message.error("加载设置失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) loadSettings();
}, [open, loadSettings]);
const handleToggle = async (field: keyof TraceSettings, value: boolean) => {
try {
const res = await updateSettings({ [field]: value });
setSettings(res);
message.success("设置已更新");
} catch {
message.error("更新失败");
}
};
const handleSaveRetention = async () => {
if (!settings) return;
setSaving(true);
try {
const res = await updateSettings({ retention_days: settings.retention_days });
setSettings(res);
message.success("保留天数已更新");
} catch {
message.error("更新失败");
} finally {
setSaving(false);
}
};
const handleCleanup = async () => {
if (!cleanRange) { message.warning("请选择日期范围"); return; }
setCleaning(true);
try {
const res = await cleanupLogs(
cleanRange[0].format("YYYY-MM-DD"),
cleanRange[1].format("YYYY-MM-DD"),
);
message.success(`已清理 ${res.deleted_files} 个文件(${res.deleted_dates.length} 天)`);
} catch {
message.error("清理失败");
} finally {
setCleaning(false);
}
};
return (
<Drawer
title="Trace 设置" open={open} onClose={onClose}
width={400} destroyOnClose
>
{loading ? <Spin /> : settings && (
<div>
<div style={{ marginBottom: 20 }}>
<Text strong></Text>
<Switch
checked={settings.enabled}
onChange={(v) => handleToggle("enabled", v)}
style={{ marginLeft: 12 }}
/>
</div>
<div style={{ marginBottom: 20 }}>
<Text strong> SQL</Text>
<Switch
checked={settings.log_sql}
onChange={(v) => handleToggle("log_sql", v)}
style={{ marginLeft: 12 }}
/>
</div>
<div style={{ marginBottom: 20 }}>
<Text strong></Text>
<Switch
checked={settings.log_params}
onChange={(v) => handleToggle("log_params", v)}
style={{ marginLeft: 12 }}
/>
</div>
<Divider />
<div style={{ marginBottom: 20 }}>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
<Space>
<InputNumber
min={1} max={365}
value={settings.retention_days}
onChange={(v) => v && setSettings({ ...settings, retention_days: v })}
/>
<Button type="primary" size="small" loading={saving} onClick={handleSaveRetention}>
</Button>
</Space>
</div>
</div>
<div style={{ marginBottom: 20 }}>
<Text strong></Text>
<div style={{ marginTop: 4 }}>
<Text type="secondary" copyable style={{ fontSize: 12 }}>{settings.log_dir}</Text>
</div>
</div>
<Divider />
<div>
<Text strong></Text>
<div style={{ marginTop: 8 }}>
<Space direction="vertical" style={{ width: "100%" }}>
<DatePicker.RangePicker
style={{ width: "100%" }}
value={cleanRange}
onChange={(v) => setCleanRange(v as [dayjs.Dayjs, dayjs.Dayjs] | null)}
/>
<Button
danger icon={<DeleteOutlined />}
loading={cleaning} onClick={handleCleanup}
block
>
</Button>
</Space>
</div>
</div>
</div>
)}
</Drawer>
);
};
// ---- 请求列表列定义 ----
const requestColumns: ColumnsType<TraceRequest> = [
{
title: "时间", dataIndex: "timestamp", key: "timestamp", width: 170,
render: fmtTime,
},
{
title: "类型", dataIndex: "trace_type", key: "trace_type", width: 70,
render: (v: TraceType) => {
const colors: Record<TraceType, string> = { http: "blue", sse: "cyan", ws: "gold", job: "default" };
return <Tag color={colors[v]}>{v.toUpperCase()}</Tag>;
},
},
{ title: "方法", dataIndex: "method", key: "method", width: 70 },
{
title: "路径", dataIndex: "path", key: "path", ellipsis: true,
render: (v: string) => <Tooltip title={v}>{v}</Tooltip>,
},
{
title: "状态", dataIndex: "status_code", key: "status_code", width: 60,
render: (v: number | null) => {
if (v == null) return "—";
const color = v >= 400 ? "red" : v >= 300 ? "orange" : "green";
return <Tag color={color}>{v}</Tag>;
},
},
{
title: "耗时", dataIndex: "total_duration_ms", key: "duration", width: 90,
render: fmtDuration,
sorter: (a, b) => a.total_duration_ms - b.total_duration_ms,
},
{
title: "DB", dataIndex: "db_query_count", key: "db", width: 50,
render: (v: number) => v > 0 ? <Text type="secondary">{v}</Text> : "—",
},
{
title: "错误", dataIndex: "error", key: "error", width: 60,
render: (v: string | null) => v ? <Tag color="red"></Tag> : null,
},
];
// ---- 主页面组件 ----
const DevTrace: React.FC = () => {
// 数据状态
const [dates, setDates] = useState<string[]>([]);
const [requests, setRequests] = useState<TraceRequest[]>([]);
const [total, setTotal] = useState(0);
const [detail, setDetail] = useState<TraceDetail | null>(null);
const [coverage, setCoverage] = useState<TraceCoverage | null>(null);
// 加载状态
const [listLoading, setListLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [coverageLoading, setCoverageLoading] = useState(false);
// 筛选状态
const [filter, setFilter] = useState<TraceFilter>({
date: dayjs().format("YYYY-MM-DD"),
page: 1,
page_size: 30,
});
// UI 状态
const [selectedRowKey, setSelectedRowKey] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
// 加载日期列表
const loadDates = useCallback(async () => {
try {
const res = await fetchDates();
setDates(res.dates);
} catch {
// 静默降级
}
}, []);
// 加载请求列表
const loadRequests = useCallback(async () => {
setListLoading(true);
try {
const res = await fetchRequests(filter);
setRequests(res.items);
setTotal(res.total);
} catch {
message.error("加载请求列表失败");
} finally {
setListLoading(false);
}
}, [filter]);
// 加载覆盖率
const loadCoverage = useCallback(async () => {
setCoverageLoading(true);
try {
const res = await fetchCoverage();
setCoverage(res);
} catch {
message.warning("覆盖率数据加载失败,可点击扫描按钮重试");
} finally {
setCoverageLoading(false);
}
}, []);
// 手动扫描覆盖率
const handleScan = async () => {
setCoverageLoading(true);
try {
const res = await triggerCoverageScan();
setCoverage(res);
message.success("覆盖率扫描完成");
} catch {
message.error("扫描失败");
} finally {
setCoverageLoading(false);
}
};
// 点击行查看详情
const handleRowClick = async (record: TraceRequest) => {
setSelectedRowKey(record.request_id);
setDetailLoading(true);
try {
const res = await fetchRequestDetail(record.request_id);
setDetail(res);
} catch {
message.error("加载详情失败");
} finally {
setDetailLoading(false);
}
};
// 初始化
useEffect(() => { loadDates(); loadCoverage(); }, [loadDates, loadCoverage]);
useEffect(() => { loadRequests(); }, [loadRequests]);
// 筛选变更辅助
const updateFilter = (patch: Partial<TraceFilter>) => {
setFilter((prev) => ({ ...prev, ...patch, page: 1 }));
};
// 过滤掉被屏蔽的记录
const visibleRequests = requests.filter((r) => !hiddenIds.has(r.request_id));
// 清空:把当前可见记录全部加入屏蔽集合(累积)
const handleClearList = () => {
setHiddenIds((prev) => {
const next = new Set(prev);
for (const r of requests) next.add(r.request_id);
return next;
});
setDetail(null);
setSelectedRowKey(null);
};
// 取消清空:清空屏蔽集合
const handleUnclearList = () => {
setHiddenIds(new Set());
};
return (
<div>
{/* 标题栏 */}
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Space>
<Button icon={<DeleteOutlined />} onClick={handleClearList} disabled={visibleRequests.length === 0}>
</Button>
{hiddenIds.size > 0 && (
<Button onClick={handleUnclearList}>
({hiddenIds.size})
</Button>
)}
<Button icon={<ReloadOutlined />} onClick={() => { loadRequests(); loadCoverage(); }} loading={listLoading}>
</Button>
<Button icon={<SettingOutlined />} onClick={() => setSettingsOpen(true)}>
</Button>
</Space>
</Row>
{/* 覆盖率状态栏 */}
<CoverageBar coverage={coverage} loading={coverageLoading} onScan={handleScan} />
{/* 筛选栏 */}
<div style={{ marginBottom: 12, background: "#fafafa", padding: "10px 12px", borderRadius: 4 }}>
<Space wrap size={[8, 8]}>
<Select
placeholder="日期" style={{ width: 130 }}
value={filter.date}
onChange={(v) => updateFilter({ date: v })}
options={dates.map((d) => ({ label: d, value: d }))}
showSearch
/>
<TimePicker.RangePicker
format="HH:mm"
placeholder={["开始时间", "结束时间"]}
onChange={(_, strs) => updateFilter({
start_time: strs[0] || undefined,
end_time: strs[1] || undefined,
})}
/>
<Select
placeholder="类型" style={{ width: 100 }} allowClear
options={TRACE_TYPE_OPTIONS}
onChange={(v) => updateFilter({ trace_type: v })}
/>
<Select
placeholder="方法" style={{ width: 100 }} allowClear
options={METHOD_OPTIONS.map((m) => ({ label: m, value: m }))}
onChange={(v) => updateFilter({ method: v })}
/>
<Input
placeholder="路径关键词" style={{ width: 160 }}
prefix={<SearchOutlined />} allowClear
onPressEnter={(e) => updateFilter({ path_contains: (e.target as HTMLInputElement).value || undefined })}
onBlur={(e) => updateFilter({ path_contains: e.target.value || undefined })}
/>
<InputNumber
placeholder="状态码" style={{ width: 90 }}
min={100} max={599}
onChange={(v) => updateFilter({ status_code: v ?? undefined })}
/>
<InputNumber
placeholder="最小耗时(ms)" style={{ width: 130 }}
min={0}
onChange={(v) => updateFilter({ min_duration: v ?? undefined })}
/>
<Checkbox
onChange={(e) => updateFilter({ has_error: e.target.checked || undefined })}
>
</Checkbox>
<Select
placeholder="Span 类型" style={{ width: 150 }} allowClear
options={SPAN_TYPE_OPTIONS.map((s) => ({ label: s, value: s }))}
onChange={(v) => updateFilter({ span_type: v })}
/>
</Space>
</div>
{/* 左右分栏 */}
<Row gutter={12} style={{ minHeight: 500 }}>
{/* 左侧:请求列表 */}
<Col span={14}>
<Table<TraceRequest>
columns={requestColumns}
dataSource={visibleRequests}
rowKey="request_id"
size="small"
loading={listLoading}
pagination={{
current: filter.page,
pageSize: filter.page_size,
total,
showSizeChanger: true,
pageSizeOptions: ["20", "30", "50", "100"],
showTotal: (t) => `${t}`,
onChange: (page, pageSize) => setFilter((prev) => ({ ...prev, page, page_size: pageSize })),
}}
onRow={(record) => ({
onClick: () => handleRowClick(record),
style: {
cursor: "pointer",
background: record.request_id === selectedRowKey ? "#e6f7ff" : undefined,
},
})}
scroll={{ y: 520 }}
/>
</Col>
{/* 右侧Span 链路树 */}
<Col span={10}>
<div style={{
border: "1px solid #f0f0f0", borderRadius: 4,
padding: 12, minHeight: 520, maxHeight: 600,
overflow: "auto", background: "#fff",
}}>
<SpanTree detail={detail} loading={detailLoading} />
</div>
</Col>
</Row>
{/* 设置面板 */}
<SettingsDrawer open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</div>
);
};
export default DevTrace;

View File

@@ -14,7 +14,7 @@ import {
type CursorInfo, type RecentRun, type CursorInfo, type RecentRun,
} from '../api/etlStatus'; } from '../api/etlStatus';
const { Title, Text } = Typography; const { Title } = Typography;
const STATUS_COLOR: Record<string, string> = { const STATUS_COLOR: Record<string, string> = {
success: 'green', failed: 'red', running: 'blue', cancelled: 'orange', success: 'green', failed: 'red', running: 'blue', cancelled: 'orange',
@@ -38,11 +38,8 @@ function formatDuration(ms: number | null): string {
const cursorColumns: ColumnsType<CursorInfo> = [ const cursorColumns: ColumnsType<CursorInfo> = [
{ title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> }, { title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> },
{ title: '最后抓取时间', dataIndex: 'last_fetch_time', key: 'last_fetch_time', render: (v: string | null) => formatTime(v) }, { title: '数据起始时间', dataIndex: 'last_start', key: 'last_start', render: (v: string | null) => formatTime(v) },
{ { title: '数据截止时间', dataIndex: 'last_end', key: 'last_end', render: (v: string | null) => formatTime(v) },
title: '记录数', dataIndex: 'record_count', key: 'record_count', align: 'right',
render: (v: number | null) => (v != null ? <Text strong>{v.toLocaleString()}</Text> : '—'),
},
]; ];
const runColumns: ColumnsType<RecentRun> = [ const runColumns: ColumnsType<RecentRun> = [

View File

@@ -0,0 +1,121 @@
/**
* ETL 任务管理页面 — 合并 TaskConfig / QueueTab / ScheduleTab / ETLStatus 为 Tab 视图。
*
* - 5 个 Tabconfig发起、queue队列、schedule调度、history历史、status状态
* - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=config|queue|schedule|history|status
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
*
* CHANGE 2026-07-14 | Task 9.1:从占位页面替换为完整 Tab 视图实现
* CHANGE 2026-03-25 | 将 TaskManager 内部子 Tab队列/调度)提升到顶层,去掉历史 Tab
*/
import React, { useMemo } from 'react';
import { Tabs, Typography } from 'antd';
import { useSearchParams } from 'react-router-dom';
import {
SettingOutlined,
UnorderedListOutlined,
ClockCircleOutlined,
HistoryOutlined,
DashboardOutlined,
} from '@ant-design/icons';
import TaskConfig from './TaskConfig';
import { QueueTab, HistoryTab } from './TaskManager';
import ScheduleTab from '../components/ScheduleTab';
import ETLStatus from './ETLStatus';
const { Title } = Typography;
const VALID_TABS = ['config', 'queue', 'schedule', 'history', 'status'] as const;
type TabKey = (typeof VALID_TABS)[number];
const DEFAULT_TAB: TabKey = 'config';
function isValidTab(value: string | null): value is TabKey {
return value != null && (VALID_TABS as readonly string[]).includes(value);
}
const ETLTasks: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab: TabKey = useMemo(() => {
const raw = searchParams.get('tab');
return isValidTab(raw) ? raw : DEFAULT_TAB;
}, [searchParams]);
const handleTabChange = (key: string) => {
setSearchParams({ tab: key }, { replace: true });
};
const items = useMemo(
() => [
{
key: 'config' as TabKey,
label: (
<span>
<SettingOutlined style={{ marginRight: 6 }} />
</span>
),
children: <TaskConfig />,
},
{
key: 'queue' as TabKey,
label: (
<span>
<UnorderedListOutlined style={{ marginRight: 6 }} />
</span>
),
children: <QueueTab />,
},
{
key: 'schedule' as TabKey,
label: (
<span>
<ClockCircleOutlined style={{ marginRight: 6 }} />
</span>
),
children: <ScheduleTab />,
},
{
key: 'history' as TabKey,
label: (
<span>
<HistoryOutlined style={{ marginRight: 6 }} />
</span>
),
children: <HistoryTab />,
},
{
key: 'status' as TabKey,
label: (
<span>
<DashboardOutlined style={{ marginRight: 6 }} />
</span>
),
children: <ETLStatus />,
},
],
[],
);
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<UnorderedListOutlined style={{ marginRight: 8 }} />
ETL
</Title>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={items}
destroyInactiveTabPane={false}
/>
</div>
);
};
export default ETLTasks;

View File

@@ -25,7 +25,7 @@ const Login: React.FC = () => {
try { try {
await login(values.username, values.password); await login(values.username, values.password);
message.success("登录成功"); message.success("登录成功");
navigate("/", { replace: true }); navigate("/dashboard", { replace: true });
} catch (err: unknown) { } catch (err: unknown) {
const detail = const detail =
(err as { response?: { data?: { detail?: string } } })?.response?.data (err as { response?: { data?: { detail?: string } } })?.response?.data

View File

@@ -6,43 +6,16 @@
* - 各环境服务状态 + 启停重启按钮 * - 各环境服务状态 + 启停重启按钮
* - 各环境 Git 状态 + pull / 同步依赖按钮 * - 各环境 Git 状态 + pull / 同步依赖按钮
* - 各环境 .env 配置查看(敏感值脱敏) * - 各环境 .env 配置查看(敏感值脱敏)
*
* CHANGE 2026-07-25 | admin-web-restructure 8.1
* 拆分为 SystemResourceSection / ServiceStatusSection / GitStatusSection 三个子组件,
* 本页面改为组合子组件,功能不变。
*/ */
import React, { useEffect, useState, useCallback } from "react"; import React, { useEffect, useState, useCallback } from "react";
import { import { Modal, message, Spin, Typography } from "antd";
Card, import { DesktopOutlined } from "@ant-design/icons";
Row, import type { SystemInfo, ServiceStatus, GitInfo } from "../api/opsPanel";
Col,
Tag,
Button,
Space,
Statistic,
Progress,
Modal,
message,
Descriptions,
Spin,
Tooltip,
Typography,
Input,
} from "antd";
import {
PlayCircleOutlined,
PauseCircleOutlined,
ReloadOutlined,
CloudDownloadOutlined,
SyncOutlined,
FileTextOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ClockCircleOutlined,
DesktopOutlined,
} from "@ant-design/icons";
import type {
SystemInfo,
ServiceStatus,
GitInfo,
} from "../api/opsPanel";
import { import {
fetchSystemInfo, fetchSystemInfo,
fetchServicesStatus, fetchServicesStatus,
@@ -52,28 +25,14 @@ import {
restartService, restartService,
gitPull, gitPull,
syncDeps, syncDeps,
fetchEnvFile,
} from "../api/opsPanel"; } from "../api/opsPanel";
import {
SystemResourceSection,
ServiceStatusSection,
GitStatusSection,
} from "../components/ops";
const { Text, Title } = Typography; const { Title } = Typography;
const { TextArea } = Input;
/* ------------------------------------------------------------------ */
/* 工具函数 */
/* ------------------------------------------------------------------ */
/** 秒数格式化为 "Xd Xh Xm" */
function formatUptime(seconds: number | null): string {
if (seconds == null) return "-";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (d > 0) parts.push(`${d}`);
if (h > 0) parts.push(`${h}`);
parts.push(`${m}`);
return parts.join(" ");
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* 组件 */ /* 组件 */
@@ -85,9 +44,6 @@ const OpsPanel: React.FC = () => {
const [gitInfos, setGitInfos] = useState<GitInfo[]>([]); const [gitInfos, setGitInfos] = useState<GitInfo[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({}); const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [envModalOpen, setEnvModalOpen] = useState(false);
const [envModalContent, setEnvModalContent] = useState("");
const [envModalTitle, setEnvModalTitle] = useState("");
// ---- 数据加载 ---- // ---- 数据加载 ----
@@ -165,17 +121,6 @@ const OpsPanel: React.FC = () => {
r.success ? message.success("依赖同步完成") : message.error(r.message); r.success ? message.success("依赖同步完成") : message.error(r.message);
}); });
const handleViewEnv = async (env: string, label: string) => {
try {
const r = await fetchEnvFile(env);
setEnvModalTitle(`${label} .env 配置`);
setEnvModalContent(r.content);
setEnvModalOpen(true);
} catch {
message.error("读取配置文件失败");
}
};
// ---- 渲染 ---- // ---- 渲染 ----
if (loading) { if (loading) {
@@ -189,175 +134,23 @@ const OpsPanel: React.FC = () => {
</Title> </Title>
{/* ---- 系统资源 ---- */} {system && <SystemResourceSection system={system} />}
{system && (
<Card size="small" title="服务器资源" style={{ marginBottom: 16 }}>
<Row gutter={24}>
<Col span={8}>
<Statistic title="CPU 使用率" value={system.cpu_percent} suffix="%" />
<Progress percent={system.cpu_percent} size="small" status={system.cpu_percent > 80 ? "exception" : "normal"} showInfo={false} />
</Col>
<Col span={8}>
<Statistic title="内存" value={system.memory_used_gb} suffix={`/ ${system.memory_total_gb} GB`} precision={1} />
<Progress percent={system.memory_percent} size="small" status={system.memory_percent > 85 ? "exception" : "normal"} showInfo={false} />
</Col>
<Col span={8}>
<Statistic title="磁盘" value={system.disk_used_gb} suffix={`/ ${system.disk_total_gb} GB`} precision={1} />
<Progress percent={system.disk_percent} size="small" status={system.disk_percent > 90 ? "exception" : "normal"} showInfo={false} />
</Col>
</Row>
<Text type="secondary" style={{ fontSize: 12, marginTop: 8, display: "block" }}>
{new Date(system.boot_time).toLocaleString()}
</Text>
</Card>
)}
{/* ---- 服务状态 ---- */} <ServiceStatusSection
<Card size="small" title="服务状态" style={{ marginBottom: 16 }}> services={services}
<Row gutter={16}> actionLoading={actionLoading}
{services.map((svc) => ( onStart={handleStart}
<Col span={12} key={svc.env}> onStop={handleStop}
<Card onRestart={handleRestart}
size="small" />
type="inner"
title={
<Space>
{svc.running
? <CheckCircleOutlined style={{ color: "#52c41a" }} />
: <CloseCircleOutlined style={{ color: "#ff4d4f" }} />}
{svc.label}
<Tag color={svc.running ? "success" : "error"}>
{svc.running ? "运行中" : "已停止"}
</Tag>
</Space>
}
extra={<Tag>:{svc.port}</Tag>}
>
{svc.running && (
<Descriptions size="small" column={3} style={{ marginBottom: 12 }}>
<Descriptions.Item label="PID">{svc.pid}</Descriptions.Item>
<Descriptions.Item label="运行时长">
<ClockCircleOutlined style={{ marginRight: 4 }} />
{formatUptime(svc.uptime_seconds)}
</Descriptions.Item>
<Descriptions.Item label="内存">{svc.memory_mb ?? "-"} MB</Descriptions.Item>
</Descriptions>
)}
<Space>
{!svc.running && (
<Button
type="primary"
size="small"
icon={<PlayCircleOutlined />}
loading={actionLoading[`start-${svc.env}`]}
onClick={() => handleStart(svc.env)}
>
</Button>
)}
{svc.running && (
<>
<Button
danger
size="small"
icon={<PauseCircleOutlined />}
loading={actionLoading[`stop-${svc.env}`]}
onClick={() => handleStop(svc.env)}
>
</Button>
<Button
size="small"
icon={<ReloadOutlined />}
loading={actionLoading[`restart-${svc.env}`]}
onClick={() => handleRestart(svc.env)}
>
</Button>
</>
)}
</Space>
</Card>
</Col>
))}
</Row>
</Card>
{/* ---- Git 状态 & 配置 ---- */} <GitStatusSection
<Card size="small" title="代码与配置" style={{ marginBottom: 16 }}> gitInfos={gitInfos}
<Row gutter={16}> services={services}
{gitInfos.map((git) => { actionLoading={actionLoading}
const envCfg = services.find((s) => s.env === git.env); onPull={handlePull}
const label = envCfg?.label ?? git.env; onSyncDeps={handleSyncDeps}
return ( />
<Col span={12} key={git.env}>
<Card size="small" type="inner" title={label}>
<Descriptions size="small" column={1} style={{ marginBottom: 12 }}>
<Descriptions.Item label="分支">
<Tag color="blue">{git.branch}</Tag>
{git.has_local_changes && (
<Tooltip title="工作区有未提交的变更">
<Tag color="warning"></Tag>
</Tooltip>
)}
</Descriptions.Item>
<Descriptions.Item label="最新提交">
<Text code style={{ fontSize: 12 }}>{git.last_commit_hash}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
{git.last_commit_message}
</Text>
</Descriptions.Item>
<Descriptions.Item label="提交时间">
<Text type="secondary" style={{ fontSize: 12 }}>{git.last_commit_time}</Text>
</Descriptions.Item>
</Descriptions>
<Space>
<Button
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading[`pull-${git.env}`]}
onClick={() => handlePull(git.env)}
>
Git Pull
</Button>
<Button
size="small"
icon={<SyncOutlined />}
loading={actionLoading[`sync-${git.env}`]}
onClick={() => handleSyncDeps(git.env)}
>
</Button>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleViewEnv(git.env, label)}
>
</Button>
</Space>
</Card>
</Col>
);
})}
</Row>
</Card>
{/* ---- 配置查看弹窗 ---- */}
<Modal
title={envModalTitle}
open={envModalOpen}
onCancel={() => setEnvModalOpen(false)}
footer={null}
width={700}
>
<TextArea
value={envModalContent}
readOnly
autoSize={{ minRows: 10, maxRows: 30 }}
style={{ fontFamily: "monospace", fontSize: 12 }}
/>
</Modal>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,293 @@
/**
* P18 待审核任务页面。
*
* 展示 status='pending_review' 的任务,支持重新分配和关闭操作。
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Table, Card, Typography, Button, Space, InputNumber, Tag, Tooltip,
Modal, Input, Drawer, message,
} from "antd";
import {
ReloadOutlined, ExclamationCircleOutlined, AuditOutlined,
SwapOutlined, CloseCircleOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import dayjs from "dayjs";
import {
fetchPendingReviews, reassignTask, closeTask,
fetchMemberTransferHistory,
type PendingReviewItem, type PendingReviewQuery, type TransferLogItem,
} from "../api/taskEngine";
import { useAuthStore } from "../store/authStore";
const { Title, Text } = Typography;
const { TextArea } = Input;
function formatTime(raw: string | null): string {
if (!raw) return "—";
return dayjs(raw).format("YYYY-MM-DD HH:mm");
}
const PendingReview: React.FC = () => {
const user = useAuthStore((s) => s.user);
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
const [items, setItems] = useState<PendingReviewItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<PendingReviewQuery>({ page: 1, page_size: 20 });
// 重新分配弹窗
const [reassignVisible, setReassignVisible] = useState(false);
const [reassignTaskId, setReassignTaskId] = useState<number | null>(null);
const [toAssistantId, setToAssistantId] = useState<number | null>(null);
const [reassigning, setReassigning] = useState(false);
// 关闭弹窗
const [closeVisible, setCloseVisible] = useState(false);
const [closeTaskId, setCloseTaskId] = useState<number | null>(null);
const [closeReason, setCloseReason] = useState("");
const [closing, setClosing] = useState(false);
// 转移历史抽屉
const [historyVisible, setHistoryVisible] = useState(false);
const [historyMemberId, setHistoryMemberId] = useState<number | null>(null);
const [historyItems, setHistoryItems] = useState<TransferLogItem[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await fetchPendingReviews(query);
setItems(data.items);
setTotal(data.total);
} catch {
message.error("加载待审核任务失败");
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const handleReassign = async () => {
if (!reassignTaskId || !toAssistantId) return;
setReassigning(true);
try {
await reassignTask(reassignTaskId, toAssistantId);
message.success("重新分配成功");
setReassignVisible(false);
setToAssistantId(null);
load();
} catch {
message.error("重新分配失败");
} finally {
setReassigning(false);
}
};
const handleClose = async () => {
if (!closeTaskId || !closeReason.trim()) return;
setClosing(true);
try {
await closeTask(closeTaskId, closeReason.trim());
message.success("任务已关闭");
setCloseVisible(false);
setCloseReason("");
load();
} catch {
message.error("关闭任务失败");
} finally {
setClosing(false);
}
};
const showHistory = async (memberId: number) => {
setHistoryMemberId(memberId);
setHistoryVisible(true);
setHistoryLoading(true);
try {
const data = await fetchMemberTransferHistory(memberId);
setHistoryItems(data);
} catch {
message.error("加载转移历史失败");
} finally {
setHistoryLoading(false);
}
};
const columns: ColumnsType<PendingReviewItem> = [
{
title: "创建时间", dataIndex: "created_at", key: "created_at", width: 160,
render: (v: string) => formatTime(v),
},
{
title: "门店", dataIndex: "site_name", key: "site_name", width: 120,
render: (v: string, r) => v || `#${r.site_id}`,
},
{
title: "客户", key: "member", width: 140,
render: (_: unknown, r) => (
<Tooltip title={`ID: ${r.member_id}`}>
<a onClick={() => showHistory(r.member_id)}>
{r.member_name || `会员#${r.member_id}`}
</a>
</Tooltip>
),
},
{
title: "当前助教", key: "assistant", width: 120,
render: (_: unknown, r) => r.assistant_name || `#${r.assistant_id}`,
},
{
title: "任务类型", dataIndex: "task_type_label", key: "type", width: 120,
render: (v: string) => <Tag color="blue">{v || "未知"}</Tag>,
},
{
title: "转移次数", dataIndex: "transfer_count", key: "tc", width: 90,
render: (v: number) => (
<Tag color={v >= 2 ? "red" : "default"} icon={v >= 2 ? <ExclamationCircleOutlined /> : undefined}>
{v}
</Tag>
),
},
{
title: "优先级分", dataIndex: "priority_score", key: "score", width: 90,
render: (v: number | null) => v != null ? v.toFixed(2) : "—",
},
];
// 超级管理员才显示操作列
if (isSuperAdmin) {
columns.push({
title: "操作", key: "action", width: 180, fixed: "right",
render: (_: unknown, r) => (
<Space size={4}>
<Button
type="primary" size="small" icon={<SwapOutlined />}
onClick={() => { setReassignTaskId(r.id); setReassignVisible(true); }}
>
</Button>
<Button
danger size="small" icon={<CloseCircleOutlined />}
onClick={() => { setCloseTaskId(r.id); setCloseVisible(true); }}
>
</Button>
</Space>
),
});
}
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={4} style={{ margin: 0 }}>
<AuditOutlined style={{ marginRight: 8 }} />
</Title>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<InputNumber
placeholder="门店 ID"
style={{ width: 140 }}
onChange={(v) => setQuery((q) => ({ ...q, site_id: (v as number) ?? undefined, page: 1 }))}
/>
</Space>
</Card>
<Card size="small">
<Table<PendingReviewItem>
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
size="small"
scroll={{ x: 1100 }}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (page, pageSize) => setQuery((q) => ({ ...q, page, page_size: pageSize })),
}}
/>
</Card>
{/* 重新分配弹窗 */}
<Modal
title="重新分配任务"
open={reassignVisible}
onOk={handleReassign}
onCancel={() => { setReassignVisible(false); setToAssistantId(null); }}
confirmLoading={reassigning}
okButtonProps={{ disabled: !toAssistantId }}
>
<Text> ID</Text>
<InputNumber
style={{ width: "100%", marginTop: 8 }}
placeholder="目标助教 ID"
value={toAssistantId}
onChange={(v) => setToAssistantId(v)}
/>
<Text type="secondary" style={{ display: "block", marginTop: 8, fontSize: 12 }}>
POOL manual_override
</Text>
</Modal>
{/* 关闭任务弹窗 */}
<Modal
title="关闭任务"
open={closeVisible}
onOk={handleClose}
onCancel={() => { setCloseVisible(false); setCloseReason(""); }}
confirmLoading={closing}
okButtonProps={{ disabled: !closeReason.trim(), danger: true }}
okText="确认关闭"
>
<Text></Text>
<TextArea
rows={3}
maxLength={500}
showCount
style={{ marginTop: 8 }}
value={closeReason}
onChange={(e) => setCloseReason(e.target.value)}
placeholder="例如:客户已流失,无需继续跟进"
/>
</Modal>
{/* 转移历史抽屉 */}
<Drawer
title={`会员 #${historyMemberId} 转移历史`}
open={historyVisible}
onClose={() => setHistoryVisible(false)}
width={600}
>
<Table<TransferLogItem>
rowKey="id"
dataSource={historyItems}
loading={historyLoading}
size="small"
pagination={false}
columns={[
{ title: "时间", dataIndex: "created_at", render: (v: string) => formatTime(v), width: 140 },
{ title: "原助教", key: "from", render: (_: unknown, r: TransferLogItem) => r.from_assistant_name || `#${r.from_assistant_id}`, width: 100 },
{ title: "新助教", key: "to", render: (_: unknown, r: TransferLogItem) => r.to_assistant_name || `#${r.to_assistant_id}`, width: 100 },
{ title: "原因", dataIndex: "transfer_reason", width: 120 },
{ title: "得分", dataIndex: "transfer_score", render: (v: number | null) => v != null ? v.toFixed(2) : "—", width: 80 },
]}
/>
</Drawer>
</div>
);
};
export default PendingReview;

View File

@@ -47,7 +47,7 @@ import { useNavigate } from "react-router-dom";
import TaskSelector from "../components/TaskSelector"; import TaskSelector from "../components/TaskSelector";
import { validateTaskConfig, fetchFlows } from "../api/tasks"; import { validateTaskConfig, fetchFlows } from "../api/tasks";
import type { FlowDef, ProcessingModeDef } from "../api/tasks"; import type { FlowDef, ProcessingModeDef } from "../api/tasks";
import { submitToQueue, executeDirectly } from "../api/execution"; import { submitToQueue, executeDirectly, cleanupOutput } from "../api/execution";
import { createSchedule } from "../api/schedules"; import { createSchedule } from "../api/schedules";
import { useAuthStore } from "../store/authStore"; import { useAuthStore } from "../store/authStore";
import BusinessDayHint from "../components/BusinessDayHint"; import BusinessDayHint from "../components/BusinessDayHint";
@@ -55,6 +55,7 @@ import type { RadioChangeEvent } from "antd";
import type { Dayjs } from "dayjs"; import type { Dayjs } from "dayjs";
import dayjs from "dayjs"; import dayjs from "dayjs";
import type { TaskConfig as TaskConfigType, ScheduleConfig } from "../types"; import type { TaskConfig as TaskConfigType, ScheduleConfig } from "../types";
import type { MinRunIntervalItem } from "../types";
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input; const { TextArea } = Input;
@@ -228,6 +229,7 @@ const TaskConfig: React.FC = () => {
/* ---------- 任务选择 ---------- */ /* ---------- 任务选择 ---------- */
const [selectedTasks, setSelectedTasks] = useState<string[]>([]); const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]); const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]);
const [taskIntervals, setTaskIntervals] = useState<Record<string, MinRunIntervalItem>>({});
/* ---------- 高级选项 ---------- */ /* ---------- 高级选项 ---------- */
const [dryRun, setDryRun] = useState(false); const [dryRun, setDryRun] = useState(false);
@@ -320,12 +322,26 @@ const TaskConfig: React.FC = () => {
/* ---------- 事件处理 ---------- */ /* ---------- 事件处理 ---------- */
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value); const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
// CHANGE 2026-03-27 | 包含 ODS 层的 flow 执行前清理输出目录,每类任务只保留最近 10 个运行记录
const tryCleanupOutput = async () => {
if (!layers.includes("ODS")) return;
try {
const result = await cleanupOutput();
if (result.dirs_deleted > 0) {
message.info(`已清理 ${result.dirs_deleted} 个旧运行记录`);
}
} catch {
message.warning("输出目录清理失败,不影响任务执行");
}
};
const handleSubmitToQueue = async () => { const handleSubmitToQueue = async () => {
setSubmitting(true); setSubmitting(true);
try { try {
await tryCleanupOutput();
await submitToQueue(buildTaskConfig()); await submitToQueue(buildTaskConfig());
message.success("已提交到执行队列"); message.success("已提交到执行队列");
navigate("/task-manager"); navigate("/etl-tasks?tab=queue");
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "提交失败"; const msg = err instanceof Error ? err.message : "提交失败";
message.error(`提交到队列失败:${msg}`); message.error(`提交到队列失败:${msg}`);
@@ -334,12 +350,14 @@ const TaskConfig: React.FC = () => {
} }
}; };
// CHANGE 2026-03-27 | 直接执行后跳转历史 tab 并自动打开任务详情
const handleExecuteDirectly = async () => { const handleExecuteDirectly = async () => {
setSubmitting(true); setSubmitting(true);
try { try {
await executeDirectly(buildTaskConfig()); await tryCleanupOutput();
const { execution_id } = await executeDirectly(buildTaskConfig());
message.success("任务已开始执行"); message.success("任务已开始执行");
navigate("/task-manager"); navigate(`/etl-tasks?tab=history&openExecution=${execution_id}`);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : "执行失败"; const msg = err instanceof Error ? err.message : "执行失败";
message.error(`直接执行失败:${msg}`); message.error(`直接执行失败:${msg}`);
@@ -399,6 +417,7 @@ const TaskConfig: React.FC = () => {
task_config: taskConfig, task_config: taskConfig,
schedule_config: scheduleConfig, schedule_config: scheduleConfig,
run_immediately: !!values.run_immediately, run_immediately: !!values.run_immediately,
min_run_intervals: Object.keys(taskIntervals).length > 0 ? taskIntervals : undefined,
}); });
message.success("调度任务已创建"); message.success("调度任务已创建");
setScheduleModalOpen(false); setScheduleModalOpen(false);
@@ -681,13 +700,15 @@ const TaskConfig: React.FC = () => {
</Card> </Card>
{/* ---- 任务选择(含 DWD 表过滤) ---- */} {/* ---- 任务选择(含 DWD 表过滤) ---- */}
<Card size="small" title="任务选择" style={cardStyle}> <Card size="small" title={<Space size={8}><Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}></Text></Space>} style={cardStyle}>
<TaskSelector <TaskSelector
layers={layers} layers={layers}
selectedTasks={selectedTasks} selectedTasks={selectedTasks}
onTasksChange={setSelectedTasks} onTasksChange={setSelectedTasks}
selectedDwdTables={selectedDwdTables} selectedDwdTables={selectedDwdTables}
onDwdTablesChange={setSelectedDwdTables} onDwdTablesChange={setSelectedDwdTables}
taskIntervals={taskIntervals}
onTaskIntervalsChange={setTaskIntervals}
/> />
</Card> </Card>

View File

@@ -0,0 +1,353 @@
/**
* P18 任务引擎参数管理页面。
*
* 展示 biz.cfg_task_generator_params 全局默认 + 门店覆盖参数。
* 超级管理员可编辑/新增/删除;门店管理员只读。
* 权重参数w_rs/w_ms/w_ml以卡片形式整体编辑后端联合校验。
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Table, Card, Typography, Button, Space, Tag, InputNumber,
Modal, Select, Popconfirm, Tooltip, message,
} from "antd";
import {
ReloadOutlined, SettingOutlined, PlusOutlined,
EditOutlined, DeleteOutlined, SaveOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import dayjs from "dayjs";
import {
fetchConfigParams, updateConfigParam, createConfigParam, deleteConfigParam,
type ConfigParam,
} from "../api/taskEngine";
import { useAuthStore } from "../store/authStore";
const { Title, Text } = Typography;
/** 参数中文描述映射 */
const PARAM_LABELS: Record<string, string> = {
high_priority_recall_threshold: "高优先召回阈值",
priority_recall_threshold: "优先召回阈值",
rs_min_for_relationship: "关系构建 RS 下限",
rs_max_for_relationship: "关系构建 RS 上限",
consecutive_recall_fail_cycles: "连续失败触发转移轮数",
min_wbi_for_transfer: "触发转移最低 WBI",
guard_assistant_coverage_ratio: "助教绑定率保护阈值",
guard_new_assistant_days: "新助教入驻保护天数",
transfer_score_w_rs: "转移排序 RS 权重",
transfer_score_w_ms: "转移排序 MS 权重",
transfer_score_w_ml: "转移排序 ML 权重",
max_transfer_count: "单客户最大转移次数",
follow_up_visit_retention_hours: "回访任务保留时长(h)",
};
const WEIGHT_KEYS = ["transfer_score_w_rs", "transfer_score_w_ms", "transfer_score_w_ml"];
const TaskEngineConfig: React.FC = () => {
const user = useAuthStore((s) => s.user);
const isSuperAdmin = user?.roles?.includes("super_admin") ?? false;
const [params, setParams] = useState<ConfigParam[]>([]);
const [loading, setLoading] = useState(false);
// 行内编辑
const [editingId, setEditingId] = useState<number | null>(null);
const [editValue, setEditValue] = useState<number>(0);
const [saving, setSaving] = useState(false);
// 新增弹窗
const [addVisible, setAddVisible] = useState(false);
const [addSiteId, setAddSiteId] = useState<number | null>(null);
const [addKey, setAddKey] = useState<string>("");
const [addValue, setAddValue] = useState<number>(0);
const [adding, setAdding] = useState(false);
// 权重卡片编辑
const [weightVisible, setWeightVisible] = useState(false);
const [weightSiteId, setWeightSiteId] = useState<number | null>(null);
const [wRs, setWRs] = useState(0.5);
const [wMs, setWMs] = useState(0.3);
const [wMl, setWMl] = useState(0.2);
const [weightSaving, setWeightSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await fetchConfigParams();
setParams(data.params);
} catch {
message.error("加载参数配置失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleSave = async () => {
if (editingId == null) return;
setSaving(true);
try {
await updateConfigParam(editingId, editValue);
message.success("参数已更新");
setEditingId(null);
load();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
message.error(msg || "更新失败");
} finally {
setSaving(false);
}
};
const handleAdd = async () => {
if (!addSiteId || !addKey) return;
setAdding(true);
try {
await createConfigParam(addSiteId, addKey, addValue);
message.success("门店覆盖参数已添加");
setAddVisible(false);
setAddKey("");
setAddValue(0);
load();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
message.error(msg || "添加失败");
} finally {
setAdding(false);
}
};
const handleDelete = async (paramId: number) => {
try {
await deleteConfigParam(paramId);
message.success("门店覆盖已删除");
load();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
message.error(msg || "删除失败");
}
};
/** 打开权重卡片编辑弹窗 */
const openWeightEditor = (siteId: number | null) => {
const siteParams = params.filter(
(p) => p.site_id === siteId && WEIGHT_KEYS.includes(p.param_key),
);
const findVal = (key: string) => siteParams.find((p) => p.param_key === key)?.param_value ?? 0;
setWeightSiteId(siteId);
setWRs(findVal("transfer_score_w_rs"));
setWMs(findVal("transfer_score_w_ms"));
setWMl(findVal("transfer_score_w_ml"));
setWeightVisible(true);
};
const handleWeightSave = async () => {
const sum = wRs + wMs + wMl;
if (Math.abs(sum - 1.0) > 0.001) {
message.error(`权重之和必须为 1.0,当前为 ${sum.toFixed(4)}`);
return;
}
setWeightSaving(true);
try {
// 逐个更新三个权重参数
const weightParams = params.filter(
(p) => p.site_id === weightSiteId && WEIGHT_KEYS.includes(p.param_key),
);
const valMap: Record<string, number> = {
transfer_score_w_rs: wRs,
transfer_score_w_ms: wMs,
transfer_score_w_ml: wMl,
};
for (const wp of weightParams) {
await updateConfigParam(wp.id, valMap[wp.param_key]);
}
message.success("权重配置已更新");
setWeightVisible(false);
load();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
message.error(msg || "权重更新失败");
} finally {
setWeightSaving(false);
}
};
const columns: ColumnsType<ConfigParam> = [
{
title: "参数", dataIndex: "param_key", key: "param_key", width: 220,
render: (v: string) => (
<Tooltip title={v}>
<Text strong>{PARAM_LABELS[v] || v}</Text>
<br />
<Text type="secondary" style={{ fontSize: 11 }}>{v}</Text>
</Tooltip>
),
},
{
title: "门店", key: "site", width: 120,
render: (_: unknown, r) => r.site_id == null
? <Tag color="blue"></Tag>
: <span>{r.site_name || `#${r.site_id}`}</span>,
},
{
title: "参数值", key: "value", width: 160,
render: (_: unknown, r) => {
if (editingId === r.id) {
return (
<Space>
<InputNumber
size="small"
value={editValue}
onChange={(v) => v != null && setEditValue(v)}
step={WEIGHT_KEYS.includes(r.param_key) ? 0.01 : 1}
/>
<Button size="small" type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave} />
<Button size="small" onClick={() => setEditingId(null)}></Button>
</Space>
);
}
return <Text>{r.param_value}</Text>;
},
},
{
title: "说明", dataIndex: "description", key: "desc", width: 200,
render: (v: string | null) => v || "—",
},
{
title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 160,
render: (v: string) => dayjs(v).format("YYYY-MM-DD HH:mm"),
},
];
if (isSuperAdmin) {
columns.push({
title: "操作", key: "action", width: 160, fixed: "right",
render: (_: unknown, r) => {
// 权重参数用卡片编辑
if (WEIGHT_KEYS.includes(r.param_key)) {
return (
<Button
size="small" icon={<EditOutlined />}
onClick={() => openWeightEditor(r.site_id)}
>
</Button>
);
}
return (
<Space size={4}>
<Button
size="small" icon={<EditOutlined />}
onClick={() => { setEditingId(r.id); setEditValue(r.param_value); }}
>
</Button>
{r.site_id != null && (
<Popconfirm title="确认删除此门店覆盖?" onConfirm={() => handleDelete(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
)}
</Space>
);
},
});
}
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={4} style={{ margin: 0 }}>
<SettingOutlined style={{ marginRight: 8 }} />
</Title>
<Space>
{isSuperAdmin && (
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddVisible(true)}>
</Button>
)}
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Space>
</div>
<Card size="small">
<Table<ConfigParam>
rowKey="id"
columns={columns}
dataSource={params}
loading={loading}
size="small"
scroll={{ x: 1000 }}
pagination={false}
/>
</Card>
{/* 新增门店覆盖弹窗 */}
<Modal
title="新增门店覆盖参数"
open={addVisible}
onOk={handleAdd}
onCancel={() => setAddVisible(false)}
confirmLoading={adding}
okButtonProps={{ disabled: !addSiteId || !addKey }}
>
<Space direction="vertical" style={{ width: "100%" }}>
<div>
<Text> ID</Text>
<InputNumber style={{ width: "100%" }} value={addSiteId} onChange={(v) => setAddSiteId(v)} />
</div>
<div>
<Text></Text>
<Select
style={{ width: "100%" }}
value={addKey || undefined}
onChange={(v) => setAddKey(v)}
placeholder="选择参数"
options={Object.entries(PARAM_LABELS).map(([k, label]) => ({ value: k, label: `${label} (${k})` }))}
/>
</div>
<div>
<Text></Text>
<InputNumber style={{ width: "100%" }} value={addValue} onChange={(v) => v != null && setAddValue(v)} />
</div>
</Space>
</Modal>
{/* 权重卡片编辑弹窗 */}
<Modal
title={`权重配置${weightSiteId != null ? ` — 门店 #${weightSiteId}` : "(全局)"}`}
open={weightVisible}
onOk={handleWeightSave}
onCancel={() => setWeightVisible(false)}
confirmLoading={weightSaving}
>
<Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
1.0 0.001
</Text>
<Space direction="vertical" style={{ width: "100%" }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Text style={{ width: 120 }}>RS (w_rs)</Text>
<InputNumber value={wRs} onChange={(v) => v != null && setWRs(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Text style={{ width: 120 }}>MS (w_ms)</Text>
<InputNumber value={wMs} onChange={(v) => v != null && setWMs(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Text style={{ width: 120 }}>ML (w_ml)</Text>
<InputNumber value={wMl} onChange={(v) => v != null && setWMl(v)} step={0.05} min={0} max={1} style={{ flex: 1 }} />
</div>
<div style={{ textAlign: "right", marginTop: 8 }}>
<Text type={Math.abs(wRs + wMs + wMl - 1.0) > 0.001 ? "danger" : "success"}>
{(wRs + wMs + wMl).toFixed(4)}
</Text>
</div>
</Space>
</Modal>
</div>
);
};
export default TaskEngineConfig;

View File

@@ -7,6 +7,7 @@
*/ */
import React, { useEffect, useState, useCallback, useRef } from 'react'; import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { import {
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer, Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
Typography, Descriptions, Empty, Spin, Typography, Descriptions, Empty, Spin,
@@ -14,12 +15,12 @@ import {
import { import {
ReloadOutlined, DeleteOutlined, StopOutlined, ReloadOutlined, DeleteOutlined, StopOutlined,
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined, UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
FileTextOutlined, FileTextOutlined, PlayCircleOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import type { QueuedTask, ExecutionLog } from '../types'; import type { QueuedTask, ExecutionLog } from '../types';
import { import {
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution, fetchQueue, fetchHistory, deleteFromQueue, cancelExecution, rerunExecution,
} from '../api/execution'; } from '../api/execution';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import LogStream from '../components/LogStream'; import LogStream from '../components/LogStream';
@@ -37,6 +38,7 @@ const STATUS_COLOR: Record<string, string> = {
success: 'success', success: 'success',
failed: 'error', failed: 'error',
cancelled: 'warning', cancelled: 'warning',
interrupted: 'volcano',
}; };
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
@@ -62,7 +64,7 @@ function fmtDuration(ms: number | null | undefined): string {
/* 队列 Tab */ /* 队列 Tab */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const QueueTab: React.FC = () => { export const QueueTab: React.FC = () => {
const [data, setData] = useState<QueuedTask[]>([]); const [data, setData] = useState<QueuedTask[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -236,7 +238,7 @@ const QueueTab: React.FC = () => {
/* 历史 Tab */ /* 历史 Tab */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
const HistoryTab: React.FC = () => { export const HistoryTab: React.FC = () => {
const [data, setData] = useState<ExecutionLog[]>([]); const [data, setData] = useState<ExecutionLog[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<ExecutionLog | null>(null); const [detail, setDetail] = useState<ExecutionLog | null>(null);
@@ -263,6 +265,16 @@ const HistoryTab: React.FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// CHANGE 2026-03-22 | 重新执行历史任务
const handleRerun = useCallback(async (id: string) => {
try {
const { execution_id } = await rerunExecution(id);
message.success(`已重新执行,新 ID: ${execution_id.slice(0, 8)}`);
load();
} catch { message.error('重新执行失败'); }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { setData(await fetchHistory()); } try { setData(await fetchHistory()); }
@@ -330,6 +342,23 @@ const HistoryTab: React.FC = () => {
} }
}, [closeHistoryWs, load]); }, [closeHistoryWs, load]);
// CHANGE 2026-03-27 | 支持 URL 参数 openExecution 自动打开任务详情
const [searchParams, setSearchParams] = useSearchParams();
const openExecutionHandled = useRef(false);
useEffect(() => {
const openId = searchParams.get('openExecution');
if (!openId || openExecutionHandled.current || loading || data.length === 0) return;
openExecutionHandled.current = true;
const target = data.find((r) => r.id === openId);
if (target) {
handleRowClick(target);
} else {
handleRowClick({ id: openId, status: 'running' } as ExecutionLog);
}
searchParams.delete('openExecution');
setSearchParams(searchParams, { replace: true });
}, [data, loading, searchParams, setSearchParams, handleRowClick]);
const columns: ColumnsType<ExecutionLog> = [ const columns: ColumnsType<ExecutionLog> = [
{ {
title: '执行 ID', dataIndex: 'id', key: 'id', width: 120, title: '执行 ID', dataIndex: 'id', key: 'id', width: 120,
@@ -366,19 +395,25 @@ const HistoryTab: React.FC = () => {
) : '—', ) : '—',
}, },
{ {
title: '操作', key: 'action', width: 80, align: 'center', title: '操作', key: 'action', width: 140, align: 'center',
render: (_: unknown, record: ExecutionLog) => { render: (_: unknown, record: ExecutionLog) => (
if (record.status === 'running') { <Space size={0}>
return ( {record.status === 'running' && (
<Popconfirm title="确认终止该任务?" onConfirm={(e) => { e?.stopPropagation(); handleCancelHistory(record.id); }} onCancel={(e) => e?.stopPropagation()}> <Popconfirm title="确认终止该任务?" onConfirm={(e) => { e?.stopPropagation(); handleCancelHistory(record.id); }} onCancel={(e) => e?.stopPropagation()}>
<Button type="link" danger icon={<StopOutlined />} size="small" onClick={(e) => e.stopPropagation()}> <Button type="link" danger icon={<StopOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
</Button> </Button>
</Popconfirm> </Popconfirm>
); )}
} {record.status !== 'running' && (
return null; <Popconfirm title="确认重新执行该任务?" onConfirm={(e) => { e?.stopPropagation(); handleRerun(record.id); }} onCancel={(e) => e?.stopPropagation()}>
}, <Button type="link" icon={<PlayCircleOutlined />} size="small" onClick={(e) => e.stopPropagation()}>
</Button>
</Popconfirm>
)}
</Space>
),
}, },
]; ];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,181 @@
/**
* P18 客户转移日志页面。
*
* 展示 biz.coach_task_transfer_log 分页列表,支持门店/时间/助教筛选。
*/
import React, { useEffect, useState, useCallback } from "react";
import {
Table, Card, Typography, Button, Space, DatePicker, InputNumber,
Tag, Tooltip, message,
} from "antd";
import {
ReloadOutlined, SwapOutlined, CheckCircleOutlined, CloseCircleOutlined,
} from "@ant-design/icons";
import type { ColumnsType } from "antd/es/table";
import dayjs from "dayjs";
import {
fetchTransferLogs, type TransferLogItem, type TransferLogQuery,
} from "../api/taskEngine";
const { Title, Text } = Typography;
const { RangePicker } = DatePicker;
function formatTime(raw: string | null): string {
if (!raw) return "—";
return dayjs(raw).format("YYYY-MM-DD HH:mm");
}
/** guard_checks JSON → 三项检查标签 */
function renderGuardChecks(checks: Record<string, unknown> | null) {
if (!checks) return <Text type="secondary"></Text>;
return (
<Space size={4} wrap>
{Object.entries(checks).map(([k, v]) => (
<Tag
key={k}
color={v ? "success" : "error"}
icon={v ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
>
{k}
</Tag>
))}
</Space>
);
}
const REASON_LABELS: Record<string, string> = {
consecutive_recall_fail: "连续召回失败",
manual_reassign: "人工重新分配",
ownership_change: "归属变更",
};
const TransferLog: React.FC = () => {
const [items, setItems] = useState<TransferLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<TransferLogQuery>({ page: 1, page_size: 20 });
const load = useCallback(async () => {
setLoading(true);
try {
const data = await fetchTransferLogs(query);
setItems(data.items);
setTotal(data.total);
} catch {
message.error("加载转移日志失败");
} finally {
setLoading(false);
}
}, [query]);
useEffect(() => { load(); }, [load]);
const columns: ColumnsType<TransferLogItem> = [
{
title: "转移时间", dataIndex: "created_at", key: "created_at", width: 160,
render: (v: string) => formatTime(v),
},
{
title: "门店", dataIndex: "site_name", key: "site_name", width: 120,
render: (v: string, r) => v || `#${r.site_id}`,
},
{
title: "客户", key: "member", width: 140,
render: (_: unknown, r) => (
<Tooltip title={`ID: ${r.member_id}`}>
{r.member_name || `会员#${r.member_id}`}
</Tooltip>
),
},
{
title: "原助教", key: "from", width: 120,
render: (_: unknown, r) => r.from_assistant_name || `#${r.from_assistant_id}`,
},
{
title: "新助教", key: "to", width: 120,
render: (_: unknown, r) => r.to_assistant_name || `#${r.to_assistant_id}`,
},
{
title: "转移原因", dataIndex: "transfer_reason", key: "reason", width: 140,
render: (v: string | null) => v ? (
<Tag>{REASON_LABELS[v] || v}</Tag>
) : "—",
},
{
title: "转移得分", dataIndex: "transfer_score", key: "score", width: 90,
render: (v: number | null) => v != null ? v.toFixed(2) : "—",
},
{
title: "保护检查", dataIndex: "guard_checks", key: "guards", width: 200,
render: (v: Record<string, unknown> | null) => renderGuardChecks(v),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<Title level={4} style={{ margin: 0 }}>
<SwapOutlined style={{ marginRight: 8 }} />
</Title>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</div>
<Card size="small" style={{ marginBottom: 16 }}>
<Space wrap>
<RangePicker
placeholder={["开始日期", "结束日期"]}
onChange={(dates) => {
setQuery((q) => ({
...q,
from_date: dates?.[0]?.format("YYYY-MM-DD"),
to_date: dates?.[1]?.format("YYYY-MM-DD"),
page: 1,
}));
}}
/>
<InputNumber
placeholder="助教 ID"
style={{ width: 140 }}
onChange={(v) => setQuery((q) => ({
...q,
assistant_id: (v as number) ?? undefined,
page: 1,
}))}
/>
<InputNumber
placeholder="门店 ID"
style={{ width: 140 }}
onChange={(v) => setQuery((q) => ({
...q,
site_id: (v as number) ?? undefined,
page: 1,
}))}
/>
</Space>
</Card>
<Card size="small">
<Table<TransferLogItem>
rowKey="id"
columns={columns}
dataSource={items}
loading={loading}
size="small"
scroll={{ x: 1200 }}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (page, pageSize) => setQuery((q) => ({ ...q, page, page_size: pageSize })),
}}
/>
</Card>
</div>
);
};
export default TransferLog;

View File

@@ -0,0 +1,206 @@
/**
* 定时任务管理页面。
*
* 展示 biz.trigger_jobs 表中所有定时任务,支持手动执行。
*/
import React, { useEffect, useState, useCallback } from 'react';
import { Table, Tag, Button, message, Modal, Typography, Card, Space, Popconfirm, Tooltip } from 'antd';
import {
ReloadOutlined,
ClockCircleOutlined,
PlayCircleOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { fetchTriggerJobs, runTriggerJob, clearAllTasks, type TriggerJob } from '../api/triggerJobs';
const { Title, Text } = Typography;
const TRIGGER_LABEL: Record<string, string> = {
cron: '定时Cron',
interval: '间隔',
event: '事件触发',
};
const STATUS_COLOR: Record<string, string> = {
enabled: 'green',
disabled: 'default',
};
function formatTime(raw: string | null): string {
if (!raw) return '—';
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
}
function formatTriggerConfig(job: TriggerJob): string {
const cfg = job.trigger_config;
if (!cfg) return '—';
if (job.trigger_condition === 'cron') return cfg.cron_expression as string || '—';
if (job.trigger_condition === 'interval') {
const sec = cfg.interval_seconds as number;
if (sec >= 3600) return `${sec / 3600} 小时`;
if (sec >= 60) return `${sec / 60} 分钟`;
return `${sec}`;
}
if (job.trigger_condition === 'event') return `事件: ${cfg.event_name || '—'}`;
return JSON.stringify(cfg);
}
const TriggerJobs: React.FC = () => {
const [jobs, setJobs] = useState<TriggerJob[]>([]);
const [loading, setLoading] = useState(false);
const [runningId, setRunningId] = useState<number | null>(null);
const [clearing, setClearing] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await fetchTriggerJobs();
setJobs(data);
} catch {
message.error('加载定时任务失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleRun = async (jobId: number) => {
setRunningId(jobId);
try {
const result = await runTriggerJob(jobId);
if (result.success) {
message.success(result.message);
} else {
message.error(result.message);
}
await load();
} catch {
message.error('执行失败');
} finally {
setRunningId(null);
}
};
const handleClearAllTasks = async () => {
setClearing(true);
try {
const result = await clearAllTasks();
if (result.success) {
Modal.success({
title: '清空完成',
content: result.message,
});
await load();
} else {
message.error(result.message);
}
} catch {
message.error('清空任务失败');
} finally {
setClearing(false);
}
};
const columns: ColumnsType<TriggerJob> = [
{
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
render: (name: string, record) => (
<Tooltip title={record.description || name}>
<Text strong>{record.description || name}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>{name}</Text>
</Tooltip>
),
},
{
title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120,
render: (v: string) => <Tag>{TRIGGER_LABEL[v] || v}</Tag>,
},
{
title: '触发配置', key: 'trigger_config', width: 150,
render: (_: unknown, record) => <code style={{ fontSize: 12 }}>{formatTriggerConfig(record)}</code>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => <Tag color={STATUS_COLOR[v] || 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>,
},
{
title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170,
render: (v: string | null) => formatTime(v),
},
{
title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170,
render: (v: string | null) => formatTime(v),
},
{
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
render: (v: string | null) => v
? <Tooltip title={v}><Text type="danger" ellipsis style={{ maxWidth: 180 }}><ExclamationCircleOutlined /> {v}</Text></Tooltip>
: <Text type="success"><CheckCircleOutlined /> </Text>,
},
{
title: '操作', key: 'action', width: 100, fixed: 'right',
render: (_: unknown, record) => (
<Popconfirm
title={`确认手动执行「${record.description || record.job_name}」?`}
onConfirm={() => handleRun(record.id)}
okText="执行"
cancelText="取消"
>
<Button
type="primary"
size="small"
icon={<PlayCircleOutlined />}
loading={runningId === record.id}
disabled={record.status !== 'enabled'}
>
</Button>
</Popconfirm>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0 }}>
<ClockCircleOutlined style={{ marginRight: 8 }} />
</Title>
<Space>
<Popconfirm
title="确认清空所有助教任务?"
description="将删除 coach_tasks 和 coach_task_history 中的全部数据,此操作不可撤销。"
onConfirm={handleClearAllTasks}
okText="确认清空"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button danger loading={clearing}>🧹 </Button>
</Popconfirm>
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}></Button>
</Space>
</div>
<Card size="small">
<Table<TriggerJob>
rowKey="id"
columns={columns}
dataSource={jobs}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1200 }}
/>
</Card>
</div>
);
};
export default TriggerJobs;

View File

@@ -0,0 +1,462 @@
/**
* 触发器统一管理页面 — 聚合 biz / ai / etl 三类触发器为 Tab 视图。
*
* - 4 个 Taball全部只读统一视图、biz业务、aiAI、etlETL
* - Tab 切换通过 useSearchParams 同步 URL 查询参数 ?tab=all|biz|ai|etl
* - destroyInactiveTabPane={false} 保持 Tab 状态不丢失
* - "全部"Tab 调用 fetchUnifiedTriggers(),展示统一字段表格
* - "业务"Tab 复用 TriggerJobs 组件 + 编辑 Modal
* - "AI"Tab 复用 AIOperations + AITriggerJobs 组件
* - "ETL"Tab 展示 scheduled_tasks 数据
*
* CHANGE 2026-07-15 | Task 10.1:创建 TriggerManager 页面
*/
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import {
Tabs, Typography, Table, Tag, message, Modal, Form, Input, InputNumber, Space,
Button, Card,
} from 'antd';
import {
AppstoreOutlined,
SettingOutlined,
RobotOutlined,
CloudServerOutlined,
EditOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom';
import type { ColumnsType } from 'antd/es/table';
import { fetchUnifiedTriggers, type UnifiedTriggerItem } from '../api/triggers';
import {
fetchTriggerJobs, updateTriggerConfig,
type TriggerJob, type UpdateTriggerConfigReq,
} from '../api/triggerJobs';
import { fetchSchedules } from '../api/schedules';
import type { ScheduledTask } from '../types';
import AIOperations from './AIOperations';
import AITriggerJobs from './AITriggerJobs';
const { Title } = Typography;
/* ───────── Tab 常量 ───────── */
const VALID_TABS = ['all', 'biz', 'ai', 'etl'] as const;
type TabKey = (typeof VALID_TABS)[number];
const DEFAULT_TAB: TabKey = 'all';
function isValidTab(value: string | null): value is TabKey {
return value != null && (VALID_TABS as readonly string[]).includes(value);
}
/* ───────── 工具函数 ───────── */
const SOURCE_COLOR: Record<string, string> = {
biz: 'blue', ai: 'purple', etl: 'green',
};
const SOURCE_LABEL: Record<string, string> = {
biz: '业务', ai: 'AI', etl: 'ETL',
};
function formatTime(raw: string | null): string {
if (!raw) return '—';
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
}
/* ───────── "全部"Tab统一视图只读 ───────── */
const AllTriggersTab: React.FC = () => {
const [data, setData] = useState<UnifiedTriggerItem[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setData(await fetchUnifiedTriggers());
} catch {
message.error('加载统一触发器数据失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const columns: ColumnsType<UnifiedTriggerItem> = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
{
title: '类型', dataIndex: 'source', key: 'source', width: 80,
render: (v: string) => (
<Tag color={SOURCE_COLOR[v] ?? 'default'}>{SOURCE_LABEL[v] ?? v}</Tag>
),
},
{ title: '触发条件', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120 },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (v: string) => {
const color = v === 'running' ? 'processing' : v === 'error' ? 'error'
: v === 'disabled' ? 'default' : 'success';
return <Tag color={color}>{v}</Tag>;
},
},
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
{
title: '最近错误', dataIndex: 'last_error', key: 'last_error', ellipsis: true,
render: (v: string | null) => v
? <Typography.Text type="danger" ellipsis style={{ maxWidth: 200 }}>{v}</Typography.Text>
: '—',
},
];
return (
<Card
size="small"
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}></Button>}
>
<Table<UnifiedTriggerItem>
rowKey={(r) => `${r.source}-${r.id}`}
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
size="small"
scroll={{ x: 1000 }}
/>
</Card>
);
};
/* ───────── "业务"TabTriggerJobs + 编辑 Modal ───────── */
const BizTriggersTab: React.FC = () => {
const [jobs, setJobs] = useState<TriggerJob[]>([]);
const [loading, setLoading] = useState(false);
const [editingJob, setEditingJob] = useState<TriggerJob | null>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm<UpdateTriggerConfigReq>();
const load = useCallback(async () => {
setLoading(true);
try {
setJobs(await fetchTriggerJobs());
} catch {
message.error('加载业务触发器失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const openEdit = (job: TriggerJob) => {
setEditingJob(job);
const cfg = job.trigger_config ?? {};
form.setFieldsValue({
cron_expression: (cfg.cron_expression as string) ?? undefined,
interval_seconds: (cfg.interval_seconds as number) ?? undefined,
});
setEditModalOpen(true);
};
const handleSave = async () => {
if (!editingJob) return;
try {
const values = await form.validateFields();
// 只发送有值的字段
const body: UpdateTriggerConfigReq = {};
if (values.cron_expression != null && values.cron_expression !== '') {
body.cron_expression = values.cron_expression;
}
if (values.interval_seconds != null) {
body.interval_seconds = values.interval_seconds;
}
if (!body.cron_expression && body.interval_seconds == null) {
message.warning('请至少填写 cron 表达式或间隔秒数');
return;
}
setSaving(true);
await updateTriggerConfig(editingJob.id, body);
message.success('触发器配置已更新');
setEditModalOpen(false);
setEditingJob(null);
form.resetFields();
await load();
} catch (err: unknown) {
// 422 错误展示具体信息
if (err && typeof err === 'object' && 'response' in err) {
const resp = (err as { response?: { status?: number; data?: { detail?: string } } }).response;
if (resp?.status === 422 && resp.data?.detail) {
message.error(resp.data.detail);
return;
}
}
message.error('保存失败');
} finally {
setSaving(false);
}
};
const TRIGGER_LABEL: Record<string, string> = {
cron: '定时Cron', interval: '间隔', event: '事件触发',
};
const formatTriggerConfig = (job: TriggerJob): string => {
const cfg = job.trigger_config;
if (!cfg) return '—';
if (job.trigger_condition === 'cron') return (cfg.cron_expression as string) || '—';
if (job.trigger_condition === 'interval') {
const sec = cfg.interval_seconds as number;
if (sec >= 3600) return `${sec / 3600} 小时`;
if (sec >= 60) return `${sec / 60} 分钟`;
return `${sec}`;
}
if (job.trigger_condition === 'event') return `事件: ${cfg.event_name || '—'}`;
return JSON.stringify(cfg);
};
const columns: ColumnsType<TriggerJob> = [
{
title: '任务名称', dataIndex: 'job_name', key: 'job_name', width: 180,
render: (name: string, record) => (
<>
<Typography.Text strong>{record.description || name}</Typography.Text>
<br />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{name}</Typography.Text>
</>
),
},
{
title: '触发方式', dataIndex: 'trigger_condition', key: 'trigger_condition', width: 120,
render: (v: string) => <Tag>{TRIGGER_LABEL[v] || v}</Tag>,
},
{
title: '触发配置', key: 'trigger_config', width: 150,
render: (_: unknown, record) => <code style={{ fontSize: 12 }}>{formatTriggerConfig(record)}</code>,
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (v: string) => (
<Tag color={v === 'enabled' ? 'green' : 'default'}>{v === 'enabled' ? '启用' : '禁用'}</Tag>
),
},
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
{
title: '最近错误', dataIndex: 'last_error', key: 'last_error', width: 200,
render: (v: string | null) => v
? <Typography.Text type="danger" ellipsis style={{ maxWidth: 180 }}>{v}</Typography.Text>
: <Typography.Text type="success"></Typography.Text>,
},
{
title: '操作', key: 'action', width: 80, fixed: 'right',
render: (_: unknown, record) => (
<Button
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
disabled={record.status !== 'enabled'}
>
</Button>
),
},
];
return (
<>
<Card
size="small"
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}></Button>}
>
<Table<TriggerJob>
rowKey="id"
columns={columns}
dataSource={jobs}
loading={loading}
pagination={false}
size="small"
scroll={{ x: 1200 }}
/>
</Card>
<Modal
title={`编辑触发器配置 — ${editingJob?.description || editingJob?.job_name || ''}`}
open={editModalOpen}
onCancel={() => { setEditModalOpen(false); setEditingJob(null); form.resetFields(); }}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item
name="cron_expression"
label="Cron 表达式5 字段格式)"
help="例如0 */2 * * *(每 2 小时执行)"
>
<Input placeholder="分 时 日 月 周" />
</Form.Item>
<Form.Item
name="interval_seconds"
label="间隔秒数"
help="最小值为 1"
rules={[{ type: 'number', min: 1, message: 'interval_seconds 必须 >= 1' }]}
>
<InputNumber style={{ width: '100%' }} min={1} placeholder="秒" />
</Form.Item>
</Form>
</Modal>
</>
);
};
/* ───────── "AI"TabAIOperations + AITriggerJobs ───────── */
const AITriggersTab: React.FC = () => (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<AIOperations />
<AITriggerJobs />
</Space>
);
/* ───────── "ETL"Tabscheduled_tasks 数据 ───────── */
const ETLTriggersTab: React.FC = () => {
const [data, setData] = useState<ScheduledTask[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setData(await fetchSchedules());
} catch {
message.error('加载 ETL 调度任务失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const columns: ColumnsType<ScheduledTask> = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 200 },
{
title: '任务代码', dataIndex: 'task_codes', key: 'task_codes', width: 200,
render: (v: string[]) => v?.join(', ') ?? '—',
},
{
title: '状态', dataIndex: 'enabled', key: 'enabled', width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '上次状态', dataIndex: 'last_status', key: 'last_status', width: 100,
render: (v: string | null) => v
? <Tag color={v === 'success' ? 'success' : v === 'failed' ? 'error' : 'default'}>{v}</Tag>
: '—',
},
{ title: '上次执行', dataIndex: 'last_run_at', key: 'last_run_at', width: 170, render: formatTime },
{ title: '下次执行', dataIndex: 'next_run_at', key: 'next_run_at', width: 170, render: formatTime },
{ title: '执行次数', dataIndex: 'run_count', key: 'run_count', width: 90 },
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: formatTime },
];
return (
<Card
size="small"
extra={<Button icon={<ReloadOutlined />} size="small" onClick={load} loading={loading}></Button>}
>
<Table<ScheduledTask>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t}` }}
size="small"
scroll={{ x: 1000 }}
/>
</Card>
);
};
/* ───────── 主组件 ───────── */
const TriggerManager: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const activeTab: TabKey = useMemo(() => {
const raw = searchParams.get('tab');
return isValidTab(raw) ? raw : DEFAULT_TAB;
}, [searchParams]);
const handleTabChange = (key: string) => {
setSearchParams({ tab: key }, { replace: true });
};
const items = useMemo(
() => [
{
key: 'all' as TabKey,
label: (
<span>
<AppstoreOutlined style={{ marginRight: 6 }} />
</span>
),
children: <AllTriggersTab />,
},
{
key: 'biz' as TabKey,
label: (
<span>
<SettingOutlined style={{ marginRight: 6 }} />
</span>
),
children: <BizTriggersTab />,
},
{
key: 'ai' as TabKey,
label: (
<span>
<RobotOutlined style={{ marginRight: 6 }} />
AI
</span>
),
children: <AITriggersTab />,
},
{
key: 'etl' as TabKey,
label: (
<span>
<CloudServerOutlined style={{ marginRight: 6 }} />
ETL
</span>
),
children: <ETLTriggersTab />,
},
],
[],
);
return (
<div>
<Title level={4} style={{ marginBottom: 16 }}>
<SettingOutlined style={{ marginRight: 8 }} />
</Title>
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={items}
destroyInactiveTabPane={false}
/>
</div>
);
};
export default TriggerManager;

View File

@@ -1,5 +1,9 @@
/** /**
* * [ARCHIVED]
*
* ETLTasks "任务管理"Tab
* 2026-03-25
* admin-web-restructure spec 8LogViewer
* *
* - ID WebSocket * - ID WebSocket
* - * -
@@ -13,9 +17,9 @@ import {
FileTextOutlined, SearchOutlined, ClearOutlined, FileTextOutlined, SearchOutlined, ClearOutlined,
AppstoreOutlined, UnorderedListOutlined, AppstoreOutlined, UnorderedListOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { apiClient } from "../api/client"; import { apiClient } from "../../api/client";
import LogStream from "../components/LogStream"; import LogStream from "../../components/LogStream";
import TaskLogViewer from "../components/TaskLogViewer"; import TaskLogViewer from "../../components/TaskLogViewer";
const { Title, Text } = Typography; const { Title, Text } = Typography;

View File

@@ -19,6 +19,7 @@ export interface AuthUser {
username: string; username: string;
display_name: string; display_name: string;
site_id: number; site_id: number;
roles: string[];
} }
/** 后端 /api/auth/login 响应体 */ /** 后端 /api/auth/login 响应体 */
@@ -64,6 +65,7 @@ function parseJwtPayload(token: string): AuthUser | null {
username: payload.username as string, username: payload.username as string,
display_name: (payload.display_name as string) ?? "", display_name: (payload.display_name as string) ?? "",
site_id: payload.site_id as number, site_id: payload.site_id as number,
roles: (payload.roles as string[]) ?? [],
}; };
} catch { } catch {
return null; return null;

View File

@@ -0,0 +1,92 @@
/**
* 开发调试全链路日志 — TypeScript 类型定义。
*
* 与后端 trace 模块的 Pydantic 模型和 JSON Lines 输出结构对应。
*/
// ---- Span 类型枚举 ----
export type SpanType =
| "HTTP_IN" | "AUTH" | "ROUTE" | "SERVICE"
| "DB_QUERY" | "DB_CONN" | "DB_CONN_RELEASE"
| "HTTP_OUT" | "ERROR" | "DB_ERROR"
| "MIDDLEWARE" | "MIDDLEWARE_ERROR"
| "SSE_START" | "SSE_EVENT" | "SSE_END"
| "AI_CALL" | "AI_STREAM" | "AI_ERROR"
| "WS_CONNECT" | "WS_MESSAGE" | "WS_DISCONNECT"
| "JOB_START" | "JOB_END" | "JOB_ERROR";
export type TraceType = "http" | "sse" | "ws" | "job";
// ---- 数据模型 ----
export interface TraceSpan {
span_type: SpanType;
module: string;
function: string;
description_zh: string;
description_en: string;
params: Record<string, unknown>;
result_summary: string;
duration_ms: number;
timestamp: string;
extra: Record<string, unknown>;
}
export interface TraceRequest {
request_id: string;
trace_type: TraceType;
timestamp: string;
method: string;
path: string;
status_code: number | null;
total_duration_ms: number;
user_id: number | null;
site_id: number | null;
db_query_count: number;
db_total_ms: number;
error: string | null;
span_count: number;
}
export interface TraceDetail extends Omit<TraceRequest, "span_count"> {
spans: TraceSpan[];
}
export interface TraceSettings {
enabled: boolean;
log_dir: string;
retention_days: number;
log_sql: boolean;
log_params: boolean;
}
export interface TraceFilter {
date: string;
start_time?: string;
end_time?: string;
trace_type?: TraceType;
method?: string;
path_contains?: string;
status_code?: number;
min_duration?: number;
has_error?: boolean;
span_type?: SpanType;
page?: number;
page_size?: number;
}
export interface CoverageCategory {
total: number;
covered: number;
uncovered: string[];
}
export interface TraceCoverage {
scan_time: string;
routes: CoverageCategory;
services: CoverageCategory;
jobs: CoverageCategory;
sse_endpoints: CoverageCategory;
ws_endpoints: CoverageCategory;
}

View File

@@ -125,6 +125,12 @@ export interface ExecutionLog {
schedule_id: string | null; schedule_id: string | null;
} }
/** 单个任务的最小执行间隔 */
export interface MinRunIntervalItem {
value: number;
unit: "minutes" | "hours" | "days";
}
/** 调度任务 */ /** 调度任务 */
export interface ScheduledTask { export interface ScheduledTask {
id: string; id: string;
@@ -138,6 +144,10 @@ export interface ScheduledTask {
next_run_at: string | null; next_run_at: string | null;
run_count: number; run_count: number;
last_status: string | null; last_status: string | null;
min_run_interval_value: number;
min_run_interval_unit: string;
last_success_at: string | null;
min_run_intervals: Record<string, MinRunIntervalItem>;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/__tests__/tasklogparser.test.ts","./src/api/businessday.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/opspanel.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/components/businessdayhint.tsx","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/schedulehistorydrawer.tsx","./src/components/scheduletab.tsx","./src/components/tasklogviewer.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/opspanel.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/store/authstore.ts","./src/store/businessdaystore.ts","./src/types/index.ts","./src/utils/tasklogparser.ts"],"version":"5.8.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/__tests__/tasklogparser.test.ts","./src/api/businessday.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/opspanel.ts","./src/api/registry.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/api/tenantadmins.ts","./src/components/businessdayhint.tsx","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/schedulehistorydrawer.tsx","./src/components/scheduletab.tsx","./src/components/tasklogviewer.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/opspanel.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/pages/tenantadmins/index.tsx","./src/store/authstore.ts","./src/store/businessdaystore.ts","./src/types/index.ts","./src/utils/tasklogparser.ts"],"version":"5.8.3"}

View File

@@ -21,10 +21,28 @@ apps/backend/
│ ├── auth/ # 认证模块 │ ├── auth/ # 认证模块
│ │ ├── dependencies.py # FastAPI 依赖注入CurrentUser │ │ ├── dependencies.py # FastAPI 依赖注入CurrentUser
│ │ └── jwt.py # JWT 签发/验证/密码哈希 │ │ └── jwt.py # JWT 签发/验证/密码哈希
│ ├── routers/ # 17 个路由模块(详见 API 参考) │ ├── routers/ # 18 个路由模块(详见 API 参考)
│ ├── schemas/ # Pydantic 请求/响应模型 │ ├── schemas/ # Pydantic 请求/响应模型
│ ├── services/ # 业务逻辑层 │ ├── services/ # 业务逻辑层
│ ├── middleware/ # 中间件ResponseWrapper 全局响应包装) │ ├── middleware/ # 中间件ResponseWrapper 全局响应包装)
│ ├── ai/ # AI 模块DashScope Application API + 8 个应用)
│ │ ├── config.py # AIConfig — 环境变量加载DASHSCOPE_*
│ │ ├── dashscope_client.py # DashScope Application API 统一封装
│ │ ├── dispatcher.py # AIDispatcher — 事件调度 + 调用链编排
│ │ ├── circuit_breaker.py # 熔断器(按 app_id 独立)
│ │ ├── rate_limiter.py # 限流器(用户/门店维度)
│ │ ├── budget_tracker.py # Token 预算追踪(日/月限额)
│ │ ├── run_log_service.py # AI 运行日志 CRUD
│ │ ├── exceptions.py # 异常层级DashScopeError 基类)
│ │ ├── cache_service.py # AI 缓存读写biz.ai_cache + status 状态控制)
│ │ ├── conversation_service.py # 对话管理session_id 双轨)
│ │ ├── schemas.py # AI 相关 SchemaSSEEvent 等)
│ │ ├── apps/ # 8 个 AI 应用app1_chat ~ app8_consolidation
│ │ ├── prompts/ # Prompt 模板app2/app8 独立模板)
│ │ └── data_fetchers/ # 共享数据获取层NS2 新增)
│ │ ├── member_data.py # 客户消费/会员卡/备注数据
│ │ ├── assistant_data.py # 助教信息/服务记录
│ │ └── page_context.py # 页面上下文文本化10 种入口)
│ └── ws/ # WebSocket实时日志 │ └── ws/ # WebSocket实时日志
├── tests/ # 后端测试 ├── tests/ # 后端测试
├── pyproject.toml # 依赖声明 ├── pyproject.toml # 依赖声明
@@ -71,6 +89,7 @@ ETL 只读连接自动设置 `default_transaction_read_only = on` 和 RLS `app.c
1. 管理后台认证(`/api/auth/*`):用户名 + 密码 → JWT 1. 管理后台认证(`/api/auth/*`):用户名 + 密码 → JWT
2. 小程序认证(`/api/xcx-auth/*`):微信 code → openid → JWT 2. 小程序认证(`/api/xcx-auth/*`):微信 code → openid → JWT
3. 租户管理后台认证(`/api/tenant/auth/*`):用户名 + 密码 → JWT`aud=tenant-admin`,与小程序完全隔离)
JWT 令牌分两种: JWT 令牌分两种:
- 完整令牌:已审批用户,包含 `user_id` + `site_id` + `roles` - 完整令牌:已审批用户,包含 `user_id` + `site_id` + `roles`
@@ -128,7 +147,20 @@ JWT 令牌分两种:
| `/api/xcx-test` | `xcx_test.py` | MVP 全链路验证 | 无 | | `/api/xcx-test` | `xcx_test.py` | MVP 全链路验证 | 无 |
| `/api/wx-callback` | `wx_callback.py` | 微信消息推送回调 | 签名验证 | | `/api/wx-callback` | `wx_callback.py` | 微信消息推送回调 | 签名验证 |
| `/api/retention-clue` | `member_retention_clue.py` | 维客线索 CRUD | JWT | | `/api/retention-clue` | `member_retention_clue.py` | 维客线索 CRUD | JWT |
| `/api/tenant/auth` | `tenant_auth.py` | 租户管理员登录/刷新令牌 | 无 |
| `/api/tenant` | `tenant_users.py` | 租户用户审核/管理(申请列表/关联建议/审核/用户编辑/绑定) | 租户JWT |
| `/api/tenant/excel` | `tenant_excel.py` | 租户 Excel 上传/校验/冲突/确认/记录/模板下载 | 租户JWT |
| `/api/tenant` | `tenant_clues.py` | 租户维客线索管理(客户搜索/线索CRUD/隐藏显示) | 租户JWT |
| `/api/tenant/site-admins` | `tenant_site_admins.py` | 店铺管理员 CRUD列表/创建/编辑/删除/重置密码,仅 tenant_admin | 租户JWT |
| `/api/admin` | `admin_tenant_admins.py` | 管理端租户管理员 CRUD列表/创建/编辑/删除/重置密码) | JWT+管理员 |
| `/api/admin` | `admin_registry.py` | 注册体系管理(租户列表/店铺列表/简写ID/店铺同步) | JWT+管理员 |
| `/api/admin/ai` | `admin_ai.py` | AI 监控后台Dashboard/调度状态/调用明细/缓存/预算/批量/告警13 端点) | JWT+管理员 |
| `/api/admin/dev-trace` | `admin_dev_trace.py` | 开发调试全链路日志(日期/请求列表/详情/清理/设置/覆盖率8 端点) | JWT+管理员 |
| `/api/admin/task-engine` | `admin_task_engine.py` | P18 任务引擎运营看板(转移日志分页+历史、待审核任务分页+重新分配+关闭、参数管理 CRUD9 端点) | JWT+管理员 |
| `/api/xcx/chat` | `xcx_chat.py` | 小程序 CHAT 对话/消息/发送/SSE 流式 | JWT | | `/api/xcx/chat` | `xcx_chat.py` | 小程序 CHAT 对话/消息/发送/SSE 流式 | JWT |
| `/api/admin/db-health` | `admin_db_health.py` | 数据库健康监控4 库连接池/大小/慢查询) | JWT |
| `/api/admin/triggers` | `admin_triggers.py` | 触发器统一视图biz/ai/etl 三源聚合) | JWT |
| `/api/trigger-jobs` | `trigger_jobs.py` | 触发器任务管理(列表/详情/PATCH 配置编辑) | JWT |
| `/api/ops` | `ops_panel.py` | 运维面板(服务启停/Git/系统信息) | 无 | | `/api/ops` | `ops_panel.py` | 运维面板(服务启停/Git/系统信息) | 无 |
| `/ws/logs` | `ws/logs.py` | WebSocket 实时日志推送 | — | | `/ws/logs` | `ws/logs.py` | WebSocket 实时日志推送 | — |
| `/health` | `main.py` | 健康检查 | 无 | | `/health` | `main.py` | 健康检查 | 无 |
@@ -148,17 +180,57 @@ JWT 令牌分两种:
| `task_queue.py` | 任务队列管理(入队/消费/重排) | | `task_queue.py` | 任务队列管理(入队/消费/重排) |
| `task_registry.py` | ETL 任务/Flow/DWD 表静态注册表 | | `task_registry.py` | ETL 任务/Flow/DWD 表静态注册表 |
| `cli_builder.py` | ETL CLI 命令构建器 | | `cli_builder.py` | ETL CLI 命令构建器 |
| `task_generator.py` | 任务生成器(基于 WBI/NCI 指数 | | `task_generator.py` | 任务生成器(四级漏斗 + 保底 relationship_building独立连接 |
| `task_manager.py` | 任务管理(置顶/放弃/状态变更) | | `task_manager.py` | 任务管理(置顶/放弃/状态变更) |
| `task_expiry.py` | 任务过期检查与处理 | | `task_expiry.py` | 任务过期检查与处理 |
| `task_manager.py` | 任务管理CRUD + 列表扩展 + 详情) | | `task_manager.py` | 任务管理CRUD + 列表扩展 + 详情) |
| `performance_service.py` | 绩效概览 + 明细ETL 直连查询) | | `performance_service.py` | 绩效概览 + 明细ETL 直连查询) |
| `note_service.py` | 备注服务CRUD + 星星评分) | | `note_service.py` | 备注服务CRUD + 星星评分) |
| `fdw_queries.py` | ETL 查询集中封装(直连 ETL 库 + 门店隔离 RLS | | `fdw_queries.py` | ETL 查询集中封装(直连 ETL 库 + 门店隔离 RLS,含区域日粒度查询(`get_finance_overview_area`/`get_finance_revenue_area`)和缓存读写(`get_finance_board_cache`/`set_finance_board_cache` |
| `note_reclassifier.py` | 备注重分类(召回完成后回填) | | `note_reclassifier.py` | 备注重分类(召回完成后回填) |
| `recall_detector.py` | 召回完成检测ETL 数据更新触发) | | `recall_detector.py` | 召回完成检测ETL 数据更新触发) |
| `trigger_scheduler.py` | 触发器调度器cron/interval/event | | `trigger_scheduler.py` | 触发器调度器cron/interval/event |
| `chat_service.py` | CHAT 模块业务逻辑(对话管理/消息持久化/referenceCard | | `chat_service.py` | CHAT 模块业务逻辑(对话管理/消息持久化/referenceCard |
| `ai/admin_service.py` | AI 监控后台聚合服务Dashboard 统计/批量执行/告警管理) |
| `ai/cleanup_service.py` | AI 数据清理服务90 天保留 + 缓存上限 20000/App |
| `admin_task_engine.py` | P18 任务引擎运营看板路由(转移日志/待审核任务/参数管理9 端点) |
## AI 模块NS2 Prompt 细化)
8 个千问 AI 应用,通过百炼平台调用 Qwen3.5-Plus 模型。分三层架构:
```
应用层apps/app1_chat ~ app8_consolidation
↓ 调用
数据获取层data_fetchers/ ← NS2 新增
↓ 查询
基础设施层database.py / cache_service.py / dashscope_client.py
```
### 数据获取层(`app/ai/data_fetchers/`
NS2 新增的共享模块,封装 FDW 查询逻辑,供多个应用复用:
| 函数 | 数据来源 | 消费方 |
|------|---------|--------|
| `fetch_member_consumption_data()` | ETL FDW 视图(结算/商品/会员卡/到店) | App3/6/7 |
| `fetch_member_notes()` | `biz.notes` | App4/6 |
| `fetch_assistant_info()` | ETL FDW 视图(助教维度/月度汇总) | App4/5 |
| `fetch_service_history()` | ETL FDW 视图(服务日志/亲密度) | App4/5 |
| `build_page_text()` | 多数据源(按 contextType 路由) | App1 |
关键约束:
- 金额口径使用 `items_sum`,禁止 `consume_money`
- 所有 FDW 查询通过 `SET LOCAL app.current_site_id` 实现 RLS 隔离
- 部分数据获取失败不阻断 Prompt 生成(错误降级)
### 页面上下文App1
App1 通用对话支持 10 种页面入口,通过 `contextType` 路由到对应的文本化函数:
`task-detail` / `customer-detail` / `coach-detail` / `task-list` / `customer-service-records` / `board-finance` / `board-customer` / `board-coach` / `performance` / `my-profile`
每种入口自动获取页面数据并格式化为结构化中文文本,注入 system prompt。
## 依赖 ## 依赖

View File

@@ -12,15 +12,19 @@ import json
import logging import logging
from typing import AsyncGenerator from typing import AsyncGenerator
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.data_fetchers import build_page_text
from app.ai.schemas import SSEEvent from app.ai.schemas import SSEEvent
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_ID = "app1_chat" APP_ID = "app1_chat"
# system prompt 总字符数上限
_MAX_SYSTEM_PROMPT_LEN = 4000
async def chat_stream( async def chat_stream(
*, *,
@@ -32,7 +36,7 @@ async def chat_stream(
source_page: str | None = None, source_page: str | None = None,
page_context: dict | None = None, page_context: dict | None = None,
screen_content: str | None = None, screen_content: str | None = None,
bailian: BailianClient, client: DashScopeClient,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> AsyncGenerator[SSEEvent, None]: ) -> AsyncGenerator[SSEEvent, None]:
"""流式对话入口,返回 SSEEvent 异步生成器。 """流式对话入口,返回 SSEEvent 异步生成器。
@@ -76,11 +80,12 @@ async def chat_stream(
) )
# 3. 构建消息列表system prompt + user message # 3. 构建消息列表system prompt + user message
messages = _build_messages( messages = await _build_messages(
message=message, message=message,
user_id=user_id, user_id=user_id,
nickname=nickname, nickname=nickname,
role=role, role=role,
site_id=site_id,
source_page=source_page, source_page=source_page,
page_context=page_context, page_context=page_context,
screen_content=screen_content, screen_content=screen_content,
@@ -118,12 +123,13 @@ async def chat_stream(
yield SSEEvent(type="error", message=str(e)) yield SSEEvent(type="error", message=str(e))
def _build_messages( async def _build_messages(
*, *,
message: str, message: str,
user_id: int | str, user_id: int | str,
nickname: str, nickname: str,
role: str, role: str,
site_id: int,
source_page: str | None, source_page: str | None,
page_context: dict | None, page_context: dict | None,
screen_content: str | None, screen_content: str | None,
@@ -132,25 +138,38 @@ def _build_messages(
首条 system 消息注入页面上下文和用户信息。 首条 system 消息注入页面上下文和用户信息。
""" """
system_content = _build_system_prompt( system_content = await _build_system_prompt(
user_id=user_id, user_id=user_id,
nickname=nickname, nickname=nickname,
role=role, role=role,
site_id=site_id,
source_page=source_page, source_page=source_page,
page_context=page_context, page_context=page_context,
screen_content=screen_content, screen_content=screen_content,
) )
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
# system prompt 总字符数控制
if len(content_str) > _MAX_SYSTEM_PROMPT_LEN:
# 截断 page_context 中的 data_text
pc = system_content.get("page_context", {})
dt = pc.get("data_text", "")
if dt and len(dt) > 500:
pc["data_text"] = dt[:500] + "…(已截断)"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
return [ return [
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)}, {"role": "system", "content": content_str},
{"role": "user", "content": message}, {"role": "user", "content": message},
] ]
def _build_system_prompt( async def _build_system_prompt(
*, *,
user_id: int | str, user_id: int | str,
nickname: str, nickname: str,
role: str, role: str,
site_id: int,
source_page: str | None, source_page: str | None,
page_context: dict | None, page_context: dict | None,
screen_content: str | None, screen_content: str | None,
@@ -161,7 +180,12 @@ def _build_system_prompt(
注入页面上下文供 AI 理解当前场景。 注入页面上下文供 AI 理解当前场景。
""" """
prompt: dict = { prompt: dict = {
"task": "你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。", "task": (
"你是台球门店的 AI 助手,根据用户的问题和当前页面上下文提供帮助。"
"当 page_context 中包含 memberNickname、contextId 或 data_text 时,"
"你必须直接使用这些信息回答问题,不要再向用户索要已有的信息。"
"例如用户在客户详情页提问时,直接基于该客户的数据回答,无需要求提供会员 ID。"
),
"biz_params": { "biz_params": {
"user_prompt_params": { "user_prompt_params": {
"User_ID": str(user_id), "User_ID": str(user_id),
@@ -172,10 +196,11 @@ def _build_system_prompt(
} }
# 注入页面上下文(首条消息) # 注入页面上下文(首条消息)
page_ctx = _build_page_context( page_ctx = await _build_page_context(
source_page=source_page, source_page=source_page,
page_context=page_context, page_context=page_context,
screen_content=screen_content, screen_content=screen_content,
site_id=site_id,
) )
if page_ctx: if page_ctx:
prompt["page_context"] = page_ctx prompt["page_context"] = page_ctx
@@ -183,25 +208,52 @@ def _build_system_prompt(
return prompt return prompt
def _build_page_context( async def _build_page_context(
*, *,
source_page: str | None, source_page: str | None,
page_context: dict | None, page_context: dict | None,
screen_content: str | None, screen_content: str | None,
site_id: int,
) -> dict: ) -> dict:
"""构建页面上下文信息。 """构建页面上下文信息。
P5-A 阶段:直接透传前端传入的上下文字段。 根据 source_pagecontextType调用 build_page_text 获取结构化文本,
P5-B 阶段:各页面逐步实现文本化工具,丰富 screen_content 看板类页面从 page_context 提取筛选参数传入 filters
contextType 为空或未识别时返回空 dict跳过注入
""" """
# TODO: P5-B 各页面文本化工具细化
ctx: dict = {} ctx: dict = {}
if source_page: if source_page:
ctx["source_page"] = source_page ctx["source_page"] = source_page
# 从 page_context 提取 contextId 和筛选参数
context_id = None
filters: dict = {}
if page_context:
context_id = page_context.get("contextId")
# 看板类页面筛选参数透传
for key in ("timeDimension", "areaFilter", "dimension", "typeFilter", "projectFilter"):
if key in page_context:
filters[key] = page_context[key]
# 调用 data_fetcher 获取页面数据文本
try:
data_text = await build_page_text(
source_page=source_page,
context_id=context_id,
site_id=site_id,
filters=filters if filters else None,
)
if data_text:
ctx["data_text"] = data_text
except Exception:
logger.warning("页面上下文文本化失败: source_page=%s", source_page, exc_info=True)
if page_context: if page_context:
ctx["page_context"] = page_context ctx["page_context"] = page_context
if screen_content: if screen_content:
ctx["screen_content"] = screen_content ctx["screen_content"] = screen_content
return ctx return ctx

View File

@@ -15,7 +15,7 @@ import logging
import os import os
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.prompts.app2_finance_prompt import build_prompt from app.ai.prompts.app2_finance_prompt import build_prompt
@@ -124,7 +124,7 @@ def compute_time_range(dimension: str, business_date: date) -> tuple[date, date]
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:

View File

@@ -15,25 +15,42 @@ from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.data_fetchers import fetch_member_consumption_data
from app.ai.schemas import CacheTypeEnum from app.ai.schemas import CacheTypeEnum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_ID = "app3_clue" APP_ID = "app3_clue"
# system message content 上限
_MAX_SYSTEM_CONTENT_LEN = 8000
def build_prompt(
def _default_member_data() -> dict:
"""数据获取失败时的默认空值。"""
return {
"member_nickname": "",
"consumption_records": [],
"member_cards": [],
"card_balance_total": 0,
"stored_value_balance_total": 0,
"expected_visit_date": None,
"days_since_last_visit": None,
}
async def build_prompt(
context: dict, context: dict,
cache_svc: AICacheService | None = None, cache_svc: AICacheService | None = None,
) -> list[dict]: ) -> list[dict]:
"""构建 Prompt 消息列表。 """构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段 从 data_fetchers 获取真实消费数据,失败时降级为空值
P5-B 阶段P9-T1补充 consumption_records 等完整数据。
Args: Args:
context: 包含 site_id, member_id, nickname 等 context: 包含 site_id, member_id, nickname 等
@@ -45,9 +62,28 @@ def build_prompt(
site_id = context["site_id"] site_id = context["site_id"]
member_id = context["member_id"] member_id = context["member_id"]
# 获取消费数据(失败时降级)
data_fetch_failed = False
try:
member_data = await fetch_member_consumption_data(site_id, member_id)
except Exception:
logger.warning("App3 消费数据获取失败,使用默认空值: site_id=%s member_id=%s", site_id, member_id, exc_info=True)
member_data = _default_member_data()
data_fetch_failed = True
# 构建 referenceApp6 线索 + 最近 2 套 App8 历史(附 generated_at # 构建 referenceApp6 线索 + 最近 2 套 App8 历史(附 generated_at
reference = _build_reference(site_id, member_id, cache_svc) reference = _build_reference(site_id, member_id, cache_svc)
member_nickname = member_data.get("member_nickname", "")
consumption_records = member_data.get("consumption_records", [])
# 空数据标注
if not consumption_records:
if data_fetch_failed:
consumption_records = "⚠ 消费数据获取失败,该客户暂无消费记录可供分析"
else:
consumption_records = "该客户暂无消费记录"
system_content = { system_content = {
"task": "分析客户消费数据,提取维客线索。", "task": "分析客户消费数据,提取维客线索。",
"app_id": APP_ID, "app_id": APP_ID,
@@ -67,14 +103,28 @@ def build_prompt(
} }
] ]
}, },
# TODO: P9-T1 细化 - consumption_records 等客户消费数据 "current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
"data": { "member_nickname": member_nickname,
"consumption_records": "待 P9-T1 补充", "main_data": {
"member_info": "待 P9-T1 补充", "consumption_records": consumption_records,
"member_cards": member_data.get("member_cards", []),
"card_balance_total": member_data.get("card_balance_total", 0),
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
"expected_visit_date": member_data.get("expected_visit_date"),
"days_since_last_visit": member_data.get("days_since_last_visit"),
}, },
"reference": reference, "reference": reference,
} }
# Token 预算控制:截断 consumption_records
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
records = system_content["main_data"].get("consumption_records")
if isinstance(records, list) and len(records) > 5:
system_content["main_data"]["consumption_records"] = records[:5]
system_content["main_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
user_content = ( user_content = (
f"请分析会员 {member_id} 的消费数据,提取维客线索。" f"请分析会员 {member_id} 的消费数据,提取维客线索。"
"每条线索包含 category、summary、detail、emoji 四个字段。" "每条线索包含 category、summary、detail、emoji 四个字段。"
@@ -82,7 +132,7 @@ def build_prompt(
) )
return [ return [
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)}, {"role": "system", "content": content_str},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
] ]
@@ -134,7 +184,7 @@ def _build_reference(
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:
@@ -162,7 +212,7 @@ async def run(
nickname = context.get("nickname", "") nickname = context.get("nickname", "")
# 1. 构建 Prompt # 1. 构建 Prompt
messages = build_prompt(context, cache_svc) messages = await build_prompt(context, cache_svc)
# 2. 创建对话记录 # 2. 创建对话记录
conversation_id = conv_svc.create_conversation( conversation_id = conv_svc.create_conversation(

View File

@@ -11,27 +11,50 @@ app_id = "app4_analysis"
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from datetime import datetime
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.data_fetchers import (
fetch_assistant_info,
fetch_member_consumption_data,
fetch_member_notes,
fetch_service_history,
)
from app.ai.schemas import CacheTypeEnum from app.ai.schemas import CacheTypeEnum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_ID = "app4_analysis" APP_ID = "app4_analysis"
# system message content 上限
_MAX_SYSTEM_CONTENT_LEN = 8000
def build_prompt(
def _default_member_data() -> dict:
"""数据获取失败时的默认空值。"""
return {
"member_nickname": "",
"consumption_records": [],
"member_cards": [],
"card_balance_total": 0,
"stored_value_balance_total": 0,
"expected_visit_date": None,
"days_since_last_visit": None,
}
async def build_prompt(
context: dict, context: dict,
cache_svc: AICacheService | None = None, cache_svc: AICacheService | None = None,
) -> list[dict]: ) -> list[dict]:
"""构建 Prompt 消息列表。 """构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段 并发获取助教信息、服务历史、客户消费数据、备注,部分失败不阻断
P5-B 阶段P6-T4补充 service_history、assistant_info 等完整数据。
Args: Args:
context: 包含 site_id, assistant_id, member_id context: 包含 site_id, assistant_id, member_id
@@ -44,10 +67,50 @@ def build_prompt(
assistant_id = context["assistant_id"] assistant_id = context["assistant_id"]
member_id = context["member_id"] member_id = context["member_id"]
# 并发获取 4 类数据,部分失败不阻断
results = await asyncio.gather(
fetch_assistant_info(site_id, assistant_id),
fetch_service_history(site_id, assistant_id, member_id),
fetch_member_consumption_data(site_id, member_id),
fetch_member_notes(site_id, member_id),
return_exceptions=True,
)
# 降级处理
fetch_errors: list[str] = []
if isinstance(results[0], Exception):
logger.warning("App4 助教信息获取失败: %s", results[0])
assistant_info = {}
fetch_errors.append("助教信息获取失败")
else:
assistant_info = results[0]
if isinstance(results[1], Exception):
logger.warning("App4 服务历史获取失败: %s", results[1])
service_history: list = []
fetch_errors.append("服务历史获取失败")
else:
service_history = results[1]
if isinstance(results[2], Exception):
logger.warning("App4 消费数据获取失败: %s", results[2])
member_data = _default_member_data()
fetch_errors.append("消费数据获取失败")
else:
member_data = results[2]
if isinstance(results[3], Exception):
logger.warning("App4 备注获取失败: %s", results[3])
notes: list = []
fetch_errors.append("备注获取失败")
else:
notes = results[3]
# 构建 referenceApp8 最新 + 最近 2 套历史 # 构建 referenceApp8 最新 + 最近 2 套历史
reference = _build_reference(site_id, member_id, cache_svc) reference = _build_reference(site_id, member_id, cache_svc)
system_content = { system_content: dict = {
"task": "分析助教与客户的关系,生成任务建议。", "task": "分析助教与客户的关系,生成任务建议。",
"app_id": APP_ID, "app_id": APP_ID,
"output_format": { "output_format": {
@@ -55,14 +118,51 @@ def build_prompt(
"action_suggestions": ["建议1", "建议2"], "action_suggestions": ["建议1", "建议2"],
"one_line_summary": "一句话总结", "one_line_summary": "一句话总结",
}, },
# TODO: P6-T4 细化 - service_history、assistant_info "current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
"data": { "assistant_info": assistant_info if assistant_info else "⚠ 助教信息获取失败",
"service_history": "待 P6-T4 补充", "service_history": service_history if service_history else "暂无服务记录",
"assistant_info": "待 P6-T4 补充", "task_assignment_basis": {
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
"member_cards": member_data.get("member_cards", []),
"card_balance_total": member_data.get("card_balance_total", 0),
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
"expected_visit_date": member_data.get("expected_visit_date"),
"days_since_last_visit": member_data.get("days_since_last_visit"),
},
"customer_data": {
"system_data": {
"member_nickname": member_data.get("member_nickname", ""),
},
"notes": notes if notes else "暂无备注",
}, },
"reference": reference, "reference": reference,
} }
if fetch_errors:
system_content["_data_warnings"] = fetch_errors
# Token 预算控制
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
# 优先截断 service_history
sh = system_content.get("service_history")
if isinstance(sh, list) and len(sh) > 5:
system_content["service_history"] = sh[:5]
system_content["_truncated_service_history"] = f"服务记录已截断,原始共 {len(sh)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
records = system_content["task_assignment_basis"].get("consumption_records")
if isinstance(records, list) and len(records) > 5:
system_content["task_assignment_basis"]["consumption_records"] = records[:5]
system_content["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始共 {len(records)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
n = system_content["customer_data"].get("notes")
if isinstance(n, list) and len(n) > 10:
system_content["customer_data"]["notes"] = n[:10]
system_content["customer_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
# 缓存不存在时在 user prompt 中标注 # 缓存不存在时在 user prompt 中标注
no_history_hint = "" no_history_hint = ""
if not reference: if not reference:
@@ -75,7 +175,7 @@ def build_prompt(
) )
return [ return [
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)}, {"role": "system", "content": content_str},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
] ]
@@ -127,7 +227,7 @@ def _build_reference(
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:
@@ -149,7 +249,7 @@ async def run(
nickname = context.get("nickname", "") nickname = context.get("nickname", "")
# 1. 构建 Prompt # 1. 构建 Prompt
messages = build_prompt(context, cache_svc) messages = await build_prompt(context, cache_svc)
# 2. 创建对话记录 # 2. 创建对话记录
conversation_id = conv_svc.create_conversation( conversation_id = conv_svc.create_conversation(

View File

@@ -10,27 +10,51 @@ app_id = "app5_tactics"
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from datetime import datetime
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.data_fetchers import (
fetch_assistant_info,
fetch_member_consumption_data,
fetch_member_notes,
fetch_service_history,
)
from app.ai.schemas import CacheTypeEnum from app.ai.schemas import CacheTypeEnum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_ID = "app5_tactics" APP_ID = "app5_tactics"
# system message content 上限
_MAX_SYSTEM_CONTENT_LEN = 8000
def build_prompt(
def _default_member_data() -> dict:
"""数据获取失败时的默认空值。"""
return {
"member_nickname": "",
"consumption_records": [],
"member_cards": [],
"card_balance_total": 0,
"stored_value_balance_total": 0,
"expected_visit_date": None,
"days_since_last_visit": None,
}
async def build_prompt(
context: dict, context: dict,
cache_svc: AICacheService | None = None, cache_svc: AICacheService | None = None,
) -> list[dict]: ) -> list[dict]:
"""构建 Prompt 消息列表。 """构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段。 复用 App4 的数据获取逻辑(并发获取助教信息、服务历史、消费数据、备注),
P5-B 阶段P6-T4补充 service_history、assistant_info随 App4 同步) 额外从 context["app4_result"] 获取 task_suggestion
Args: Args:
context: 包含 site_id, assistant_id, member_id, app4_result(dict) context: 包含 site_id, assistant_id, member_id, app4_result(dict)
@@ -42,35 +66,117 @@ def build_prompt(
site_id = context["site_id"] site_id = context["site_id"]
assistant_id = context["assistant_id"] assistant_id = context["assistant_id"]
member_id = context["member_id"] member_id = context["member_id"]
app4_result = context.get("app4_result", {}) # App4 结果作为 task_suggestion缺失时设为空对象
task_suggestion = context.get("app4_result") or {}
# 并发获取 4 类数据,部分失败不阻断
results = await asyncio.gather(
fetch_assistant_info(site_id, assistant_id),
fetch_service_history(site_id, assistant_id, member_id),
fetch_member_consumption_data(site_id, member_id),
fetch_member_notes(site_id, member_id),
return_exceptions=True,
)
# 降级处理
fetch_errors: list[str] = []
if isinstance(results[0], Exception):
logger.warning("App5 助教信息获取失败: %s", results[0])
assistant_info = {}
fetch_errors.append("助教信息获取失败")
else:
assistant_info = results[0]
if isinstance(results[1], Exception):
logger.warning("App5 服务历史获取失败: %s", results[1])
service_history: list = []
fetch_errors.append("服务历史获取失败")
else:
service_history = results[1]
if isinstance(results[2], Exception):
logger.warning("App5 消费数据获取失败: %s", results[2])
member_data = _default_member_data()
fetch_errors.append("消费数据获取失败")
else:
member_data = results[2]
if isinstance(results[3], Exception):
logger.warning("App5 备注获取失败: %s", results[3])
notes: list = []
fetch_errors.append("备注获取失败")
else:
notes = results[3]
# 构建 reference最近 2 套 App8 历史 # 构建 reference最近 2 套 App8 历史
reference = _build_reference(site_id, member_id, cache_svc) reference = _build_reference(site_id, member_id, cache_svc)
system_content = { system_content: dict = {
"task": "基于关系分析和任务建议,生成沟通话术参考。", "task": (
"基于关系分析和任务建议,生成沟通话术参考。"
"输出必须严格遵循 output_format 中定义的 JSON 结构,"
"每条话术必须包含 scenario场景描述和 script话术内容两个字段"
"禁止使用 content 或其他字段名替代。"
),
"app_id": APP_ID, "app_id": APP_ID,
"task_suggestion": app4_result, "task_suggestion": task_suggestion,
"output_format": { "output_format": {
"tactics": [ "tactics": [
{"scenario": "场景描述", "script": "话术内容"} {"scenario": "场景描述", "script": "话术内容"}
] ]
}, },
# TODO: P6-T4 细化 - service_history、assistant_info随 App4 同步) "current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
"data": { "assistant_info": assistant_info if assistant_info else "⚠ 助教信息获取失败",
"service_history": "待 P6-T4 补充", "service_history": service_history if service_history else "暂无服务记录",
"assistant_info": "待 P6-T4 补充", "task_assignment_basis": {
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
"member_cards": member_data.get("member_cards", []),
"card_balance_total": member_data.get("card_balance_total", 0),
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
"expected_visit_date": member_data.get("expected_visit_date"),
"days_since_last_visit": member_data.get("days_since_last_visit"),
},
"customer_data": {
"system_data": {
"member_nickname": member_data.get("member_nickname", ""),
},
"notes": notes if notes else "暂无备注",
}, },
"reference": reference, "reference": reference,
} }
if fetch_errors:
system_content["_data_warnings"] = fetch_errors
# Token 预算控制
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
sh = system_content.get("service_history")
if isinstance(sh, list) and len(sh) > 5:
system_content["service_history"] = sh[:5]
system_content["_truncated_service_history"] = f"服务记录已截断,原始共 {len(sh)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
records = system_content["task_assignment_basis"].get("consumption_records")
if isinstance(records, list) and len(records) > 5:
system_content["task_assignment_basis"]["consumption_records"] = records[:5]
system_content["task_assignment_basis"]["_truncated"] = f"消费记录已截断,原始共 {len(records)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
n = system_content["customer_data"].get("notes")
if isinstance(n, list) and len(n) > 10:
system_content["customer_data"]["notes"] = n[:10]
system_content["customer_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
user_content = ( user_content = (
f"请为助教 {assistant_id} 生成与会员 {member_id} 沟通的话术参考。" f"请为助教 {assistant_id} 生成与会员 {member_id} 沟通的话术参考。"
"返回 tactics 数组,每条包含 scenario 和 script 字段。" "返回 tactics 数组,每条包含 scenario 和 script 字段。"
) )
return [ return [
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)}, {"role": "system", "content": content_str},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
] ]
@@ -109,7 +215,7 @@ def _build_reference(
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:
@@ -131,7 +237,7 @@ async def run(
nickname = context.get("nickname", "") nickname = context.get("nickname", "")
# 1. 构建 Prompt # 1. 构建 Prompt
messages = build_prompt(context, cache_svc) messages = await build_prompt(context, cache_svc)
# 2. 创建对话记录 # 2. 创建对话记录
conversation_id = conv_svc.create_conversation( conversation_id = conv_svc.create_conversation(

View File

@@ -13,27 +13,45 @@ app_id = "app6_note"
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from datetime import datetime
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
from app.ai.schemas import CacheTypeEnum from app.ai.schemas import CacheTypeEnum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_ID = "app6_note" APP_ID = "app6_note"
# system message content 上限
_MAX_SYSTEM_CONTENT_LEN = 8000
def build_prompt(
def _default_member_data() -> dict:
"""数据获取失败时的默认空值。"""
return {
"member_nickname": "",
"consumption_records": [],
"member_cards": [],
"card_balance_total": 0,
"stored_value_balance_total": 0,
"expected_visit_date": None,
"days_since_last_visit": None,
}
async def build_prompt(
context: dict, context: dict,
cache_svc: AICacheService | None = None, cache_svc: AICacheService | None = None,
) -> list[dict]: ) -> list[dict]:
"""构建 Prompt 消息列表。 """构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段 并发获取消费数据和备注,失败时降级为空值
P5-B 阶段P9-T1补充 consumption_data 等完整数据。
Args: Args:
context: 包含 site_id, member_id, note_content, noted_by_name context: 包含 site_id, member_id, note_content, noted_by_name
@@ -46,11 +64,47 @@ def build_prompt(
member_id = context["member_id"] member_id = context["member_id"]
note_content = context.get("note_content", "") note_content = context.get("note_content", "")
noted_by_name = context.get("noted_by_name", "") noted_by_name = context.get("noted_by_name", "")
noted_by_created_at = context.get("noted_by_created_at", "")
# 并发获取消费数据和备注
results = await asyncio.gather(
fetch_member_consumption_data(site_id, member_id),
fetch_member_notes(site_id, member_id),
return_exceptions=True,
)
fetch_errors: list[str] = []
if isinstance(results[0], Exception):
logger.warning("App6 消费数据获取失败: %s", results[0])
member_data = _default_member_data()
fetch_errors.append("消费数据获取失败")
else:
member_data = results[0]
if isinstance(results[1], Exception):
logger.warning("App6 备注获取失败: %s", results[1])
all_notes: list = []
fetch_errors.append("备注获取失败")
else:
all_notes = results[1]
# 构建 referenceApp3 线索 + 最近 2 套 App8 历史 # 构建 referenceApp3 线索 + 最近 2 套 App8 历史
reference = _build_reference(site_id, member_id, cache_svc) reference = _build_reference(site_id, member_id, cache_svc)
system_content = { # 将消费数据和备注注入 reference
reference["member_nickname"] = member_data.get("member_nickname", "")
reference["consumption_data"] = {
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
"member_cards": member_data.get("member_cards", []),
"card_balance_total": member_data.get("card_balance_total", 0),
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
"expected_visit_date": member_data.get("expected_visit_date"),
"days_since_last_visit": member_data.get("days_since_last_visit"),
}
reference["all_notes"] = all_notes if all_notes else []
system_content: dict = {
"task": "分析备注内容,提取维客线索并评分。", "task": "分析备注内容,提取维客线索并评分。",
"app_id": APP_ID, "app_id": APP_ID,
"rules": { "rules": {
@@ -73,15 +127,33 @@ def build_prompt(
} }
], ],
}, },
"note_content": note_content, "current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
"noted_by_name": noted_by_name, "current_note": {
# TODO: P9-T1 细化 - consumption_data 等客户消费数据 "content": note_content,
"data": { "recorded_by": noted_by_name,
"consumption_data": "待 P9-T1 补充", "created_at": noted_by_created_at,
}, },
"reference": reference, "reference": reference,
} }
if fetch_errors:
system_content["_data_warnings"] = fetch_errors
# Token 预算控制
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
records = system_content["reference"].get("consumption_data", {}).get("consumption_records")
if isinstance(records, list) and len(records) > 5:
system_content["reference"]["consumption_data"]["consumption_records"] = records[:5]
system_content["reference"]["consumption_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
n = system_content["reference"].get("all_notes")
if isinstance(n, list) and len(n) > 10:
system_content["reference"]["all_notes"] = n[:10]
system_content["reference"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
user_content = ( user_content = (
f"请分析以下备注内容,提取维客线索并评分。\n" f"请分析以下备注内容,提取维客线索并评分。\n"
f"备注提供人:{noted_by_name}\n" f"备注提供人:{noted_by_name}\n"
@@ -91,7 +163,7 @@ def build_prompt(
) )
return [ return [
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)}, {"role": "system", "content": content_str},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
] ]
@@ -143,7 +215,7 @@ def _build_reference(
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:
@@ -164,7 +236,7 @@ async def run(
nickname = context.get("nickname", "") nickname = context.get("nickname", "")
# 1. 构建 Prompt # 1. 构建 Prompt
messages = build_prompt(context, cache_svc) messages = await build_prompt(context, cache_svc)
# 2. 创建对话记录 # 2. 创建对话记录
conversation_id = conv_svc.create_conversation( conversation_id = conv_svc.create_conversation(

View File

@@ -13,27 +13,45 @@ app_id = "app7_customer"
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
from datetime import datetime
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.data_fetchers import fetch_member_consumption_data, fetch_member_notes
from app.ai.schemas import CacheTypeEnum from app.ai.schemas import CacheTypeEnum
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
APP_ID = "app7_customer" APP_ID = "app7_customer"
# system message content 上限
_MAX_SYSTEM_CONTENT_LEN = 8000
def build_prompt(
def _default_member_data() -> dict:
"""数据获取失败时的默认空值。"""
return {
"member_nickname": "",
"consumption_records": [],
"member_cards": [],
"card_balance_total": 0,
"stored_value_balance_total": 0,
"expected_visit_date": None,
"days_since_last_visit": None,
}
async def build_prompt(
context: dict, context: dict,
cache_svc: AICacheService | None = None, cache_svc: AICacheService | None = None,
) -> list[dict]: ) -> list[dict]:
"""构建 Prompt 消息列表。 """构建 Prompt 消息列表。
P5-A 阶段:返回占位 Prompt标注待细化字段 并发获取消费数据和备注,备注标注来源信息
P5-B 阶段P9-T1补充 objective_data 等完整数据。
Args: Args:
context: 包含 site_id, member_id context: 包含 site_id, member_id
@@ -45,10 +63,46 @@ def build_prompt(
site_id = context["site_id"] site_id = context["site_id"]
member_id = context["member_id"] member_id = context["member_id"]
# 并发获取消费数据和备注
results = await asyncio.gather(
fetch_member_consumption_data(site_id, member_id),
fetch_member_notes(site_id, member_id),
return_exceptions=True,
)
fetch_errors: list[str] = []
if isinstance(results[0], Exception):
logger.warning("App7 消费数据获取失败: %s", results[0])
member_data = _default_member_data()
fetch_errors.append("消费数据获取失败")
else:
member_data = results[0]
if isinstance(results[1], Exception):
logger.warning("App7 备注获取失败: %s", results[1])
notes_raw: list = []
fetch_errors.append("备注获取失败")
else:
notes_raw = results[1]
# 备注标注来源信息
if notes_raw:
subjective_notes = []
for note in notes_raw:
recorded_by = note.get("recorded_by", "未知")
annotated = dict(note)
annotated["content"] = f"{note.get('content', '')}【来源:{recorded_by},请甄别信息真实性】"
subjective_notes.append(annotated)
else:
subjective_notes = "该客户暂无主观备注信息"
member_nickname = member_data.get("member_nickname", "")
# 构建 reference最新 + 最近 2 套 App8 历史 # 构建 reference最新 + 最近 2 套 App8 历史
reference = _build_reference(site_id, member_id, cache_svc) reference = _build_reference(site_id, member_id, cache_svc)
system_content = { system_content: dict = {
"task": "综合分析客户数据,生成运营策略建议。", "task": "综合分析客户数据,生成运营策略建议。",
"app_id": APP_ID, "app_id": APP_ID,
"rules": { "rules": {
@@ -62,13 +116,41 @@ def build_prompt(
], ],
"summary": "一句话总结", "summary": "一句话总结",
}, },
# TODO: P9-T1 细化 - objective_data 等客户消费数据 "current_time": datetime.now().strftime("%Y-%m-%d %H:%M"),
"data": { "member_id": member_id,
"objective_data": "待 P9-T1 补充", "member_nickname": member_nickname,
"objective_data": {
"consumption_records": member_data.get("consumption_records", []) or "该客户暂无消费记录",
"member_cards": member_data.get("member_cards", []),
"card_balance_total": member_data.get("card_balance_total", 0),
"stored_value_balance_total": member_data.get("stored_value_balance_total", 0),
"expected_visit_date": member_data.get("expected_visit_date"),
"days_since_last_visit": member_data.get("days_since_last_visit"),
},
"subjective_data": {
"notes": subjective_notes,
}, },
"reference": reference, "reference": reference,
} }
if fetch_errors:
system_content["_data_warnings"] = fetch_errors
# Token 预算控制
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
records = system_content["objective_data"].get("consumption_records")
if isinstance(records, list) and len(records) > 5:
system_content["objective_data"]["consumption_records"] = records[:5]
system_content["objective_data"]["_truncated"] = f"消费记录已截断,原始共 {len(records)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
if len(content_str) > _MAX_SYSTEM_CONTENT_LEN:
n = system_content["subjective_data"].get("notes")
if isinstance(n, list) and len(n) > 10:
system_content["subjective_data"]["notes"] = n[:10]
system_content["subjective_data"]["_truncated_notes"] = f"备注已截断,原始共 {len(n)}"
content_str = json.dumps(system_content, ensure_ascii=False, default=str)
user_content = ( user_content = (
f"请综合分析会员 {member_id} 的客户数据,生成运营策略建议。" f"请综合分析会员 {member_id} 的客户数据,生成运营策略建议。"
"返回 strategies 数组(每条含 title 和 content和 summary 字段。" "返回 strategies 数组(每条含 title 和 content和 summary 字段。"
@@ -76,7 +158,7 @@ def build_prompt(
) )
return [ return [
{"role": "system", "content": json.dumps(system_content, ensure_ascii=False)}, {"role": "system", "content": content_str},
{"role": "user", "content": user_content}, {"role": "user", "content": user_content},
] ]
@@ -128,7 +210,7 @@ def _build_reference(
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:
@@ -149,7 +231,7 @@ async def run(
nickname = context.get("nickname", "") nickname = context.get("nickname", "")
# 1. 构建 Prompt # 1. 构建 Prompt
messages = build_prompt(context, cache_svc) messages = await build_prompt(context, cache_svc)
# 2. 创建对话记录 # 2. 创建对话记录
conversation_id = conv_svc.create_conversation( conversation_id = conv_svc.create_conversation(

View File

@@ -11,7 +11,7 @@ from __future__ import annotations
import json import json
import logging import logging
from app.ai.bailian_client import BailianClient from app.ai.dashscope_client import DashScopeClient
from app.ai.cache_service import AICacheService from app.ai.cache_service import AICacheService
from app.ai.conversation_service import ConversationService from app.ai.conversation_service import ConversationService
from app.ai.prompts.app8_consolidation_prompt import build_prompt from app.ai.prompts.app8_consolidation_prompt import build_prompt
@@ -120,7 +120,7 @@ def _determine_source(providers: str) -> str:
async def run( async def run(
context: dict, context: dict,
bailian: BailianClient, client: DashScopeClient,
cache_svc: AICacheService, cache_svc: AICacheService,
conv_svc: ConversationService, conv_svc: ConversationService,
) -> dict: ) -> dict:

View File

@@ -1,273 +0,0 @@
"""百炼 API 统一封装层。
使用 openai Python SDK百炼兼容 OpenAI 协议),提供流式和非流式两种调用模式。
所有 AI 应用通过此客户端统一调用阿里云通义千问。
"""
from __future__ import annotations
import asyncio
import copy
import json
import logging
from datetime import datetime
from typing import Any, AsyncGenerator
import openai
logger = logging.getLogger(__name__)
# ── 异常类 ──────────────────────────────────────────────────────────
class BailianApiError(Exception):
"""百炼 API 调用失败(重试耗尽后)。"""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message)
self.status_code = status_code
class BailianJsonParseError(Exception):
"""百炼 API 返回的 JSON 解析失败。"""
def __init__(self, message: str, raw_content: str = ""):
super().__init__(message)
self.raw_content = raw_content
class BailianAuthError(BailianApiError):
"""百炼 API Key 无效HTTP 401"""
def __init__(self, message: str = "API Key 无效或已过期"):
super().__init__(message, status_code=401)
# ── 客户端 ──────────────────────────────────────────────────────────
class BailianClient:
"""百炼 API 统一封装层。
使用 openai.AsyncOpenAI 客户端base_url 指向百炼端点。
提供流式chat_stream和非流式chat_json两种调用模式。
"""
# 重试配置
MAX_RETRIES = 3
BASE_INTERVAL = 1 # 秒
def __init__(self, api_key: str, base_url: str, model: str):
"""初始化百炼客户端。
Args:
api_key: 百炼 API Key环境变量 BAILIAN_API_KEY
base_url: 百炼 API 端点(环境变量 BAILIAN_BASE_URL
model: 模型标识,如 qwen-plus环境变量 BAILIAN_MODEL
"""
self.model = model
self._client = openai.AsyncOpenAI(
api_key=api_key,
base_url=base_url,
)
async def chat_stream(
self,
messages: list[dict],
*,
temperature: float = 0.7,
max_tokens: int = 2000,
) -> AsyncGenerator[str, None]:
"""流式调用,逐 chunk yield 文本。用于应用 1 SSE。
Args:
messages: 消息列表
temperature: 温度参数,默认 0.7
max_tokens: 最大 token 数,默认 2000
Yields:
文本 chunk
"""
messages = self._inject_current_time(messages)
response = await self._call_with_retry(
model=self.model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
)
async for chunk in response:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
async def chat_json(
self,
messages: list[dict],
*,
temperature: float = 0.3,
max_tokens: int = 4000,
) -> tuple[dict, int]:
"""非流式调用,返回解析后的 JSON dict 和 tokens_used。
用于应用 2-8 的结构化输出。使用 response_format={"type": "json_object"}
确保返回合法 JSON。
Args:
messages: 消息列表
temperature: 温度参数,默认 0.3(结构化输出用低温度)
max_tokens: 最大 token 数,默认 4000
Returns:
(parsed_json_dict, tokens_used) 元组
Raises:
BailianJsonParseError: 响应内容无法解析为 JSON
BailianApiError: API 调用失败(重试耗尽后)
"""
messages = self._inject_current_time(messages)
response = await self._call_with_retry(
model=self.model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=False,
response_format={"type": "json_object"},
)
raw_content = response.choices[0].message.content or ""
tokens_used = response.usage.total_tokens if response.usage else 0
try:
parsed = json.loads(raw_content)
except (json.JSONDecodeError, TypeError) as e:
logger.error("百炼 API 返回非法 JSON: %s", raw_content[:500])
raise BailianJsonParseError(
f"JSON 解析失败: {e}",
raw_content=raw_content,
) from e
return parsed, tokens_used
def _inject_current_time(self, messages: list[dict]) -> list[dict]:
"""纯函数:在首条消息的 contentJSON 字符串)中注入 current_time 字段。
- 深拷贝输入,不修改原始 messages
- 首条消息 content 尝试解析为 JSON注入 current_time
- 如果首条消息 content 不是 JSON则包装为 JSON
- 其余消息不变
- current_time 格式ISO 8601 精确到秒,如 2026-03-08T14:30:00
Args:
messages: 原始消息列表
Returns:
注入 current_time 后的新消息列表
"""
if not messages:
return []
result = copy.deepcopy(messages)
first = result[0]
content = first.get("content", "")
now_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
try:
parsed = json.loads(content)
if isinstance(parsed, dict):
parsed["current_time"] = now_str
else:
# content 是合法 JSON 但不是 dict如数组、字符串包装为 dict
parsed = {"original_content": parsed, "current_time": now_str}
except (json.JSONDecodeError, TypeError):
# content 不是 JSON包装为 dict
parsed = {"content": content, "current_time": now_str}
first["content"] = json.dumps(parsed, ensure_ascii=False)
return result
async def _call_with_retry(self, **kwargs: Any) -> Any:
"""带指数退避的重试封装。
重试策略:
- 最多重试 MAX_RETRIES 次(默认 3 次)
- 间隔BASE_INTERVAL × 2^(n-1),即 1s → 2s → 4s
- HTTP 4xx不重试直接抛出401 → BailianAuthError
- HTTP 5xx / 超时:重试
Args:
**kwargs: 传递给 openai client 的参数
Returns:
API 响应对象
Raises:
BailianAuthError: API Key 无效HTTP 401
BailianApiError: API 调用失败(重试耗尽后)
"""
is_stream = kwargs.get("stream", False)
last_error: Exception | None = None
for attempt in range(self.MAX_RETRIES):
try:
if is_stream:
# 流式调用:返回 async iterator
return await self._client.chat.completions.create(**kwargs)
else:
return await self._client.chat.completions.create(**kwargs)
except openai.AuthenticationError as e:
# 401API Key 无效,不重试
logger.error("百炼 API 认证失败: %s", e)
raise BailianAuthError(str(e)) from e
except openai.BadRequestError as e:
# 400请求参数错误不重试
logger.error("百炼 API 请求参数错误: %s", e)
raise BailianApiError(str(e), status_code=400) from e
except openai.RateLimitError as e:
# 429限流不重试属于 4xx
logger.error("百炼 API 限流: %s", e)
raise BailianApiError(str(e), status_code=429) from e
except openai.PermissionDeniedError as e:
# 403权限不足不重试
logger.error("百炼 API 权限不足: %s", e)
raise BailianApiError(str(e), status_code=403) from e
except openai.NotFoundError as e:
# 404资源不存在不重试
logger.error("百炼 API 资源不存在: %s", e)
raise BailianApiError(str(e), status_code=404) from e
except openai.UnprocessableEntityError as e:
# 422不可处理不重试
logger.error("百炼 API 不可处理的请求: %s", e)
raise BailianApiError(str(e), status_code=422) from e
except (openai.InternalServerError, openai.APIConnectionError, openai.APITimeoutError) as e:
# 5xx / 超时 / 连接错误:重试
last_error = e
if attempt < self.MAX_RETRIES - 1:
wait_time = self.BASE_INTERVAL * (2 ** attempt)
logger.warning(
"百炼 API 调用失败(第 %d/%d 次),%ds 后重试: %s",
attempt + 1,
self.MAX_RETRIES,
wait_time,
e,
)
await asyncio.sleep(wait_time)
else:
logger.error(
"百炼 API 调用失败,已达最大重试次数 %d: %s",
self.MAX_RETRIES,
e,
)
# 重试耗尽
status_code = getattr(last_error, "status_code", None)
raise BailianApiError(
f"百炼 API 调用失败(重试 {self.MAX_RETRIES} 次后): {last_error}",
status_code=status_code,
) from last_error

View File

@@ -0,0 +1,101 @@
"""Token 预算追踪器 — 从 ai_run_logs 聚合日/月 token 消耗。
每次 AI 调用前检查预算,超限时拒绝请求。
日预算默认 100,000 tokens月预算默认 2,000,000 tokens。
聚合数据通过构造函数注入的 callable 获取(解耦 AIRunLogService
callable 签名:() -> int分别返回当日/当月已消耗 token 数。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Protocol
class UsageProvider(Protocol):
"""Token 用量数据提供者协议。"""
def get_daily_usage(self) -> int:
"""返回当日已消耗 token 数。"""
...
def get_monthly_usage(self) -> int:
"""返回当月已消耗 token 数。"""
...
@dataclass
class BudgetStatus:
"""预算检查结果。"""
allowed: bool
daily_used: int
monthly_used: int
reason: str | None = None # "daily_exceeded" / "monthly_exceeded" / None
class BudgetTracker:
"""Token 预算追踪器,从 ai_run_logs 聚合。
支持两种注入方式:
1. 传入 UsageProvider 实例(如 AIRunLogService
2. 传入两个 callableget_daily_usage / get_monthly_usage
"""
def __init__(
self,
daily_limit: int = 100_000,
monthly_limit: int = 2_000_000,
*,
get_daily_usage: Callable[[], int] | None = None,
get_monthly_usage: Callable[[], int] | None = None,
usage_provider: UsageProvider | None = None,
) -> None:
self.daily_limit = daily_limit
self.monthly_limit = monthly_limit
# 优先使用 usage_provider其次使用独立 callable
if usage_provider is not None:
self._get_daily_usage = usage_provider.get_daily_usage
self._get_monthly_usage = usage_provider.get_monthly_usage
elif get_daily_usage is not None and get_monthly_usage is not None:
self._get_daily_usage = get_daily_usage
self._get_monthly_usage = get_monthly_usage
else:
raise ValueError(
"必须提供 usage_provider 或同时提供 "
"get_daily_usage 和 get_monthly_usage callable"
)
def check_budget(self) -> BudgetStatus:
"""检查当前预算状态。
先检查日预算,再检查月预算。
任一超限即返回 allowed=False 并附带原因。
"""
daily_used = self._get_daily_usage()
monthly_used = self._get_monthly_usage()
if daily_used >= self.daily_limit:
return BudgetStatus(
allowed=False,
daily_used=daily_used,
monthly_used=monthly_used,
reason="daily_exceeded",
)
if monthly_used >= self.monthly_limit:
return BudgetStatus(
allowed=False,
daily_used=daily_used,
monthly_used=monthly_used,
reason="monthly_exceeded",
)
return BudgetStatus(
allowed=True,
daily_used=daily_used,
monthly_used=monthly_used,
reason=None,
)

View File

@@ -3,18 +3,38 @@ AI 缓存读写服务。
负责 biz.ai_cache 表的 CRUD 和保留策略管理。 负责 biz.ai_cache 表的 CRUD 和保留策略管理。
所有查询和写入操作强制 site_id 隔离。 所有查询和写入操作强制 site_id 隔离。
P14 改造:
- 新增 status 字段处理valid/expired/invalidated/generating
- 查询仅返回 status='valid' 且未过期的记录
- 按 App 类型设置过期时间
- 每 App 保留最新 20,000 条
""" """
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
from datetime import datetime from datetime import datetime, timedelta, timezone
from app.database import get_connection from app.database import get_connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 缓存过期策略cache_type → 过期天数0 表示当日 23:59:59
CACHE_EXPIRY_DAYS: dict[str, int] = {
"app2_finance": 0, # 当日 23:59:59
"app3_clue": 7,
"app4_analysis": 7,
"app5_tactics": 7,
"app6_note_analysis": 30,
"app7_customer_analysis": 7,
"app8_clue_consolidated": 7,
}
# 每 App 保留上限
CACHE_MAX_PER_APP = 20_000
class AICacheService: class AICacheService:
"""AI 缓存读写服务。""" """AI 缓存读写服务。"""
@@ -25,9 +45,9 @@ class AICacheService:
site_id: int, site_id: int,
target_id: str, target_id: str,
) -> dict | None: ) -> dict | None:
"""查询最新缓存记录。 """查询最新有效缓存记录。
按 (cache_type, site_id, target_id) 查询 created_at 最新的一条 仅返回 status='valid' 且未过期的记录
无记录时返回 None。 无记录时返回 None。
""" """
conn = get_connection() conn = get_connection()
@@ -37,9 +57,11 @@ class AICacheService:
""" """
SELECT id, cache_type, site_id, target_id, SELECT id, cache_type, site_id, target_id,
result_json, score, triggered_by, result_json, score, triggered_by,
created_at, expires_at created_at, expires_at, status
FROM biz.ai_cache FROM biz.ai_cache
WHERE cache_type = %s AND site_id = %s AND target_id = %s WHERE cache_type = %s AND site_id = %s AND target_id = %s
AND (status = 'valid' OR status IS NULL)
AND (expires_at IS NULL OR expires_at > now())
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1
""", """,
@@ -95,7 +117,15 @@ class AICacheService:
score: int | None = None, score: int | None = None,
expires_at: datetime | None = None, expires_at: datetime | None = None,
) -> int: ) -> int:
"""写入缓存记录,返回 id。写入后清理超限记录。""" """写入缓存记录,返回 id。
自动设置 status='valid' 和按 App 类型计算 expires_at。
写入后清理超限记录(每 App 保留 20,000 条)。
"""
# 自动计算过期时间(如果未显式指定)
if expires_at is None:
expires_at = self._calc_expires_at(cache_type)
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor() as cur: with conn.cursor() as cur:
@@ -103,8 +133,8 @@ class AICacheService:
""" """
INSERT INTO biz.ai_cache INSERT INTO biz.ai_cache
(cache_type, site_id, target_id, result_json, (cache_type, site_id, target_id, result_json,
triggered_by, score, expires_at) triggered_by, score, expires_at, status)
VALUES (%s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, 'valid')
RETURNING id RETURNING id
""", """,
( (
@@ -126,7 +156,7 @@ class AICacheService:
finally: finally:
conn.close() conn.close()
# 写入成功后清理超限记录(失败仅记录警告,不影响写入结果) # 写入成功后清理超限记录
try: try:
deleted = self._cleanup_excess(cache_type, site_id, target_id) deleted = self._cleanup_excess(cache_type, site_id, target_id)
if deleted > 0: if deleted > 0:
@@ -143,12 +173,89 @@ class AICacheService:
return cache_id return cache_id
def set_generating(
self,
cache_type: str,
site_id: int,
target_id: str,
triggered_by: str | None = None,
) -> int:
"""写入 generating 状态占位记录,返回 id。完成后调用 finalize_cache 更新。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO biz.ai_cache
(cache_type, site_id, target_id, result_json, status, triggered_by)
VALUES (%s, %s, %s, '{}', 'generating', %s)
RETURNING id
""",
(cache_type, site_id, target_id, triggered_by),
)
row = cur.fetchone()
conn.commit()
return row[0]
except Exception:
conn.rollback()
raise
finally:
conn.close()
def finalize_cache(
self,
cache_id: int,
result_json: dict,
score: int | None = None,
cache_type: str | None = None,
) -> None:
"""将 generating 记录更新为 valid填充结果和过期时间。"""
expires_at = self._calc_expires_at(cache_type) if cache_type else None
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE biz.ai_cache
SET result_json = %s, score = %s, status = 'valid', expires_at = %s
WHERE id = %s AND status = 'generating'
""",
(
json.dumps(result_json, ensure_ascii=False),
score,
expires_at,
cache_id,
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
@staticmethod
def _calc_expires_at(cache_type: str | None) -> datetime | None:
"""根据 cache_type 计算过期时间。未知类型返回 None。"""
if cache_type is None:
return None
days = CACHE_EXPIRY_DAYS.get(cache_type)
if days is None:
return None
now = datetime.now(timezone.utc)
if days == 0:
# 当日 23:59:59UTC+8
local_now = now + timedelta(hours=8)
end_of_day = local_now.replace(hour=23, minute=59, second=59, microsecond=0)
return end_of_day - timedelta(hours=8) # 转回 UTC
return now + timedelta(days=days)
def _cleanup_excess( def _cleanup_excess(
self, self,
cache_type: str, cache_type: str,
site_id: int, site_id: int,
target_id: str, target_id: str,
max_count: int = 500, max_count: int = CACHE_MAX_PER_APP,
) -> int: ) -> int:
"""清理超限记录,保留最近 max_count 条,返回删除数量。""" """清理超限记录,保留最近 max_count 条,返回删除数量。"""
conn = get_connection() conn = get_connection()

View File

@@ -0,0 +1,116 @@
"""熔断器 — 按 app_id 独立的断路保护。
状态机CLOSED → OPEN连续失败达阈值→ HALF_OPEN超时后探测→ CLOSED/OPEN。
内存实现,单实例部署,不依赖外部存储。
"""
from __future__ import annotations
import enum
import time
from dataclasses import dataclass, field
class CircuitState(enum.Enum):
"""熔断器状态。"""
CLOSED = "closed" # 正常放行
OPEN = "open" # 熔断中,拒绝请求
HALF_OPEN = "half_open" # 探测中,放行单个请求
@dataclass
class _BreakerState:
"""单个 app_id 的熔断内部状态。"""
state: CircuitState = CircuitState.CLOSED
failure_count: int = 0
last_failure_time: float = 0.0
last_state_change: float = field(default_factory=time.monotonic)
class CircuitBreaker:
"""按 app_id 独立的熔断器。
- check()检查当前状态OPEN 且超时自动转 HALF_OPEN
- record_success()HALF_OPEN→CLOSEDCLOSED 重置失败计数
- record_failure()连续达阈值→OPENHALF_OPEN 失败→重新 OPEN
"""
def __init__(
self,
failure_threshold: int = 5,
recovery_timeout: int = 60,
) -> None:
self._failure_threshold = failure_threshold
self._recovery_timeout = recovery_timeout
self._breakers: dict[str, _BreakerState] = {}
def _get_state(self, app_id: str) -> _BreakerState:
"""获取或初始化指定 app_id 的状态。"""
if app_id not in self._breakers:
self._breakers[app_id] = _BreakerState()
return self._breakers[app_id]
def check(self, app_id: str) -> CircuitState:
"""检查当前熔断状态。
- CLOSED / HALF_OPEN允许通过返回对应状态
- OPEN 且未超时:返回 OPEN拒绝
- OPEN 且已超时:自动转 HALF_OPEN返回 HALF_OPEN允许探测
"""
breaker = self._get_state(app_id)
if breaker.state == CircuitState.CLOSED:
return CircuitState.CLOSED
if breaker.state == CircuitState.HALF_OPEN:
return CircuitState.HALF_OPEN
# OPEN 状态:检查是否超过恢复超时
elapsed = time.monotonic() - breaker.last_failure_time
if elapsed >= self._recovery_timeout:
# 超时,转为 HALF_OPEN 探测
breaker.state = CircuitState.HALF_OPEN
breaker.last_state_change = time.monotonic()
return CircuitState.HALF_OPEN
return CircuitState.OPEN
def record_success(self, app_id: str) -> None:
"""记录调用成功。
- HALF_OPEN→CLOSED探测成功恢复正常
- CLOSED 下重置失败计数
"""
breaker = self._get_state(app_id)
if breaker.state == CircuitState.HALF_OPEN:
breaker.state = CircuitState.CLOSED
breaker.failure_count = 0
breaker.last_state_change = time.monotonic()
elif breaker.state == CircuitState.CLOSED:
# CLOSED 状态下成功重置失败计数
breaker.failure_count = 0
def record_failure(self, app_id: str) -> None:
"""记录调用失败。
- CLOSED累加失败计数达阈值→OPEN
- HALF_OPEN探测失败→重新 OPEN
"""
breaker = self._get_state(app_id)
now = time.monotonic()
if breaker.state == CircuitState.HALF_OPEN:
# 探测失败,重新熔断
breaker.state = CircuitState.OPEN
breaker.failure_count = self._failure_threshold
breaker.last_failure_time = now
breaker.last_state_change = now
elif breaker.state == CircuitState.CLOSED:
breaker.failure_count += 1
breaker.last_failure_time = now
if breaker.failure_count >= self._failure_threshold:
breaker.state = CircuitState.OPEN
breaker.last_state_change = now

View File

@@ -0,0 +1,68 @@
"""AI 模块配置 — 从环境变量加载 DashScope 相关参数。
所有 DASHSCOPE_* 环境变量和 INTERNAL_API_TOKEN 统一在此管理,
启动时通过 from_env() 校验必需变量,缺失立即报错。
"""
from __future__ import annotations
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class AIConfig:
"""AI 模块配置从环境变量加载。不可变frozen"""
api_key: str # DASHSCOPE_API_KEY
workspace_id: str | None # DASHSCOPE_WORKSPACE_ID可选
app_id_1_chat: str # DASHSCOPE_APP_ID_1_CHAT
app_id_2_finance: str # DASHSCOPE_APP_ID_2_FINANCE
app_id_3_clue: str # DASHSCOPE_APP_ID_3_CLUE
app_id_4_analysis: str # DASHSCOPE_APP_ID_4_ANALYSIS
app_id_5_tactics: str # DASHSCOPE_APP_ID_5_TACTICS
app_id_6_note: str # DASHSCOPE_APP_ID_6_NOTE
app_id_7_customer: str # DASHSCOPE_APP_ID_7_CUSTOMER
app_id_8_consolidate: str # DASHSCOPE_APP_ID_8_CONSOLIDATE
internal_api_token: str # INTERNAL_API_TOKEN
@classmethod
def from_env(cls) -> AIConfig:
"""从环境变量加载配置。
必需变量缺失时立即抛出 ValueError禁止静默回退空字符串。
可选变量DASHSCOPE_WORKSPACE_ID缺失时为 None。
"""
required_mapping: dict[str, str] = {
"DASHSCOPE_API_KEY": "api_key",
"DASHSCOPE_APP_ID_1_CHAT": "app_id_1_chat",
"DASHSCOPE_APP_ID_2_FINANCE": "app_id_2_finance",
"DASHSCOPE_APP_ID_3_CLUE": "app_id_3_clue",
"DASHSCOPE_APP_ID_4_ANALYSIS": "app_id_4_analysis",
"DASHSCOPE_APP_ID_5_TACTICS": "app_id_5_tactics",
"DASHSCOPE_APP_ID_6_NOTE": "app_id_6_note",
"DASHSCOPE_APP_ID_7_CUSTOMER": "app_id_7_customer",
"DASHSCOPE_APP_ID_8_CONSOLIDATE": "app_id_8_consolidate",
"INTERNAL_API_TOKEN": "internal_api_token",
}
# 收集所有缺失的必需变量,一次性报错
missing: list[str] = []
values: dict[str, str] = {}
for env_name, field_name in required_mapping.items():
val = os.environ.get(env_name)
if not val: # None 或空字符串均视为缺失
missing.append(env_name)
else:
values[field_name] = val
if missing:
raise ValueError(
f"AI 配置缺失必需环境变量: {', '.join(missing)}"
)
# 可选变量
workspace_id = os.environ.get("DASHSCOPE_WORKSPACE_ID") or None
return cls(workspace_id=workspace_id, **values)

View File

@@ -27,6 +27,7 @@ class ConversationService:
site_id: int, site_id: int,
source_page: str | None = None, source_page: str | None = None,
source_context: dict | None = None, source_context: dict | None = None,
title: str | None = None,
) -> int: ) -> int:
"""创建对话记录,返回 conversation_id。 """创建对话记录,返回 conversation_id。
@@ -38,8 +39,8 @@ class ConversationService:
cur.execute( cur.execute(
""" """
INSERT INTO biz.ai_conversations INSERT INTO biz.ai_conversations
(user_id, nickname, app_id, site_id, source_page, source_context) (user_id, nickname, app_id, site_id, source_page, source_context, title)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", """,
( (
@@ -49,6 +50,7 @@ class ConversationService:
site_id, site_id,
source_page, source_page,
json.dumps(source_context, ensure_ascii=False) if source_context else None, json.dumps(source_context, ensure_ascii=False) if source_context else None,
title,
), ),
) )
row = cur.fetchone() row = cur.fetchone()
@@ -89,6 +91,22 @@ class ConversationService:
finally: finally:
conn.close() conn.close()
def update_title(self, conversation_id: int, title: str) -> None:
"""更新对话标题。"""
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"UPDATE biz.ai_conversations SET title = %s WHERE id = %s",
(title, conversation_id),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def get_conversations( def get_conversations(
self, self,
user_id: int | str, user_id: int | str,
@@ -104,7 +122,7 @@ class ConversationService:
cur.execute( cur.execute(
""" """
SELECT id, user_id, nickname, app_id, site_id, SELECT id, user_id, nickname, app_id, site_id,
source_page, source_context, created_at source_page, source_context, title, created_at
FROM biz.ai_conversations FROM biz.ai_conversations
WHERE user_id = %s AND site_id = %s WHERE user_id = %s AND site_id = %s
ORDER BY created_at DESC ORDER BY created_at DESC

View File

@@ -0,0 +1,318 @@
"""DashScope Application API 统一封装层。
使用 dashscope.Application.call() 调用百炼智能体应用,
替代原 openai SDK 的通用模型 API。
- call_app_stream(): App1 流式调用asyncio.Queue 桥接 async generator
- call_app(): App2~8 单轮调用asyncio.to_thread() 包装
- _call_with_retry(): 指数退避重试1s→2s→4s
"""
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, AsyncGenerator, Callable
import dashscope
from dashscope import Application
from app.ai.exceptions import (
DashScopeApiError,
DashScopeAuthError,
DashScopeJsonParseError,
DashScopeTimeoutError,
)
logger = logging.getLogger(__name__)
class DashScopeClient:
"""DashScope Application API 统一封装层。
通过 app_id 调用百炼控制台配置的智能体应用,
充分利用云端 System Prompt 和 MCP 工具。
"""
MAX_RETRIES = 3
BASE_INTERVAL = 1 # 秒
def __init__(self, api_key: str, workspace_id: str | None = None):
"""初始化。dashscope 通过全局变量设置密钥。
Args:
api_key: DashScope API Key
workspace_id: 百炼工作空间 ID可选
"""
dashscope.api_key = api_key
self._workspace_id = workspace_id
async def call_app_stream(
self,
app_id: str,
prompt: str,
session_id: str | None = None,
biz_params: dict | None = None,
) -> AsyncGenerator[str, None]:
"""App1 流式调用。
在线程中消费同步迭代器,通过 asyncio.Queue 桥接到 async generator。
错误通过 queue 传递给调用方。
Args:
app_id: 百炼应用 ID
prompt: 用户输入
session_id: 百炼 session_id多轮对话
biz_params: 业务参数(如 user_prompt_params
Yields:
文本 chunk
"""
queue: asyncio.Queue[str | BaseException | None] = asyncio.Queue()
loop = asyncio.get_running_loop()
def _consume_in_thread() -> None:
"""在线程中消费同步迭代器,逐 chunk 放入 queue。"""
try:
call_kwargs: dict[str, Any] = {
"app_id": app_id,
"prompt": prompt,
"stream": True,
"incremental_output": True,
}
if session_id is not None:
call_kwargs["session_id"] = session_id
if biz_params is not None:
call_kwargs["biz_params"] = biz_params
if self._workspace_id is not None:
call_kwargs["workspace"] = self._workspace_id
response = Application.call(**call_kwargs)
for chunk in response:
if chunk.status_code == 200:
text = chunk.output.get("text", "")
if text:
asyncio.run_coroutine_threadsafe(
queue.put(text), loop
)
else:
# 非 200 状态码,构造异常传递给调用方
status = chunk.status_code
msg = getattr(chunk, "message", "") or f"状态码 {status}"
if status == 401:
err = DashScopeAuthError(msg)
else:
err = DashScopeApiError(msg, status_code=status)
asyncio.run_coroutine_threadsafe(
queue.put(err), loop
)
return
# 正常结束信号
asyncio.run_coroutine_threadsafe(queue.put(None), loop)
except Exception as exc:
# 线程内未预期异常,传递给调用方
asyncio.run_coroutine_threadsafe(
queue.put(exc), loop
)
loop.run_in_executor(None, _consume_in_thread)
while True:
item = await queue.get()
if item is None:
break
if isinstance(item, BaseException):
raise item
yield item
async def call_app(
self,
app_id: str,
prompt: str,
session_id: str | None = None,
biz_params: dict | None = None,
) -> tuple[dict, int, str | None]:
"""App2~8 单轮调用。
通过 asyncio.to_thread() 包装同步 Application.call()
解析 response.output.text 获取 JSON 内容。
非合法 JSON 触发重试(最多 3 次),不做本地修复。
Args:
app_id: 百炼应用 ID
prompt: 后端拼好的完整数据 JSON 字符串
session_id: 百炼 session_id可选
biz_params: 业务参数(可选)
Returns:
(parsed_json, tokens_used, new_session_id) 元组
Raises:
DashScopeApiError: API 调用失败(重试耗尽)
DashScopeJsonParseError: JSON 解析失败(重试耗尽)
"""
call_kwargs: dict[str, Any] = {
"app_id": app_id,
"prompt": prompt,
}
if session_id is not None:
call_kwargs["session_id"] = session_id
if biz_params is not None:
call_kwargs["biz_params"] = biz_params
if self._workspace_id is not None:
call_kwargs["workspace"] = self._workspace_id
# 非合法 JSON 纯重试,最多 MAX_RETRIES 次
last_json_error: DashScopeJsonParseError | None = None
for json_attempt in range(self.MAX_RETRIES):
response = await self._call_with_retry(
Application.call, **call_kwargs
)
# 提取 output.text
raw_text: str = ""
if hasattr(response, "output"):
output = response.output
if isinstance(output, dict):
raw_text = output.get("text", "")
elif hasattr(output, "text"):
raw_text = output.text or ""
# 提取 tokens_used
tokens_used = 0
if hasattr(response, "usage") and response.usage:
usage = response.usage
if isinstance(usage, dict):
# input_tokens + output_tokens
tokens_used = usage.get("input_tokens", 0) + usage.get(
"output_tokens", 0
)
elif hasattr(usage, "total_tokens"):
tokens_used = usage.total_tokens or 0
# 提取 new_session_id
new_session_id: str | None = None
if hasattr(response, "output") and isinstance(response.output, dict):
new_session_id = response.output.get("session_id")
# 解析 JSON
try:
parsed = json.loads(raw_text)
if isinstance(parsed, list):
# CHANGE 2026-03-23 | Prompt: App2 LLM 返回 list 而非 dict
# 百炼 LLM 有时直接返回 insights 数组而非包裹 dict
# 自动包装为 {"insights": list} 避免无意义重试
logger.info(
"LLM 返回 list长度 %d),自动包装为 {\"insights\": [...]}",
len(parsed),
)
parsed = {"insights": parsed}
if not isinstance(parsed, dict):
raise TypeError(f"期望 dict实际 {type(parsed).__name__}")
return parsed, tokens_used, new_session_id
except (json.JSONDecodeError, TypeError) as e:
last_json_error = DashScopeJsonParseError(
f"JSON 解析失败(第 {json_attempt + 1}/{self.MAX_RETRIES} 次): {e}",
raw_content=raw_text,
)
logger.warning(
"Application API 返回非法 JSON%d/%d 次): %s",
json_attempt + 1,
self.MAX_RETRIES,
raw_text[:500],
)
# 非合法 JSON 纯重试,不做本地修复
continue
# JSON 重试耗尽
raise last_json_error # type: ignore[misc]
async def _call_with_retry(self, func: Callable, **kwargs: Any) -> Any:
"""指数退避重试封装。
重试策略:
- 最多重试 MAX_RETRIES 次(默认 3 次)
- 间隔BASE_INTERVAL × 2^(n-1),即 1s → 2s → 4s
- HTTP 4xx → 不重试立即抛出401 → DashScopeAuthError
- HTTP 5xx / 超时 / 连接错误 → 重试
Args:
func: 同步调用函数(如 Application.call
**kwargs: 传递给 func 的参数
Returns:
API 响应对象status_code == 200
Raises:
DashScopeAuthError: API Key 无效HTTP 401
DashScopeTimeoutError: 调用超时(重试耗尽)
DashScopeApiError: API 调用失败(重试耗尽)
"""
last_error: Exception | None = None
for attempt in range(self.MAX_RETRIES):
try:
response = await asyncio.to_thread(func, **kwargs)
except Exception as exc:
# 网络/连接/超时等底层异常 → 可重试
last_error = exc
if attempt < self.MAX_RETRIES - 1:
wait_time = self.BASE_INTERVAL * (2**attempt)
logger.warning(
"DashScope API 底层异常(第 %d/%d 次),%ds 后重试: %s",
attempt + 1,
self.MAX_RETRIES,
wait_time,
exc,
)
await asyncio.sleep(wait_time)
continue
else:
logger.error(
"DashScope API 底层异常,已达最大重试次数 %d: %s",
self.MAX_RETRIES,
exc,
)
raise DashScopeApiError(
f"DashScope API 调用失败(重试 {self.MAX_RETRIES} 次后): {exc}",
) from exc
# Application.call() 返回 response 对象,通过 status_code 判断成功/失败
status_code = getattr(response, "status_code", None)
if status_code == 200:
return response
# 非 200根据状态码分类处理
message = getattr(response, "message", "") or f"状态码 {status_code}"
if status_code is not None and 400 <= status_code < 500:
# 4xx不重试立即抛出
if status_code == 401:
raise DashScopeAuthError(message)
raise DashScopeApiError(message, status_code=status_code)
# 5xx 或其他未知状态码 → 可重试
last_error = DashScopeApiError(message, status_code=status_code)
if attempt < self.MAX_RETRIES - 1:
wait_time = self.BASE_INTERVAL * (2**attempt)
logger.warning(
"DashScope API 调用失败(第 %d/%d 次,状态码 %s%ds 后重试: %s",
attempt + 1,
self.MAX_RETRIES,
status_code,
wait_time,
message,
)
await asyncio.sleep(wait_time)
else:
logger.error(
"DashScope API 调用失败,已达最大重试次数 %d(状态码 %s: %s",
self.MAX_RETRIES,
status_code,
message,
)
# 重试耗尽
raise last_error # type: ignore[misc]

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