Compare commits

...

15 Commits

Author SHA1 Message Date
浅念 10b7e5e534 fix(management): 待回复/风险卡标注时点口径——当前指标不随窗口,防管理者误读(批次C终评I1) 2026-09-10 10:45:16 +08:00
浅念 e3fadfe1a6 fix(scheduler): 调度根治——4h 增量只扫活跃对话+账号表进 4h+超时保险丝
用户报「员工列表时间与云客差 2 天」。根因链:4h 增量序列全对话遍历
(WA 9,357 对×~3.5s≈9h,整序列 15h+)超过自身 4h 间隔→与 02:00 全量在
asyncio.Lock 上背靠背互相排队→近两晚全量被挤掉→账号表(仅全量刷)停更
两天,员工列表「数据更新/最新消息时间」显示 09-08 快照。

三层修复:
- 活跃窗:_discover_conversations/群发现加 active_since_ms——已扫过的
  对话仅保留 last_chat_time 在窗内的(云客 lastChatTime 滞后小时级,
  24h 窗足够容错),从未扫过的(新覆盖优先)不受限;陈旧对话无新消息,
  每日 full 兜底。CLI --active-window-hours,四消息实体透传
- 4h 序列头部加 accounts/wa-accounts(~2min)——员工列表时间最多滞后
  4h,不再依赖每晚全量
- 超时保险丝:_page_subprocess 加 timeout(超时 kill 返回 124,上游
  break 下轮补)——messages 各实体 15-80min 预算,单命令拖不死相位;
  full/full-history 不设限

722 测试绿(序列断言 7→9 命令+窗口参数)。干跑验证:近 24h 活跃私聊/
群相对全量降一个数量级(本地旧库群 2011→537;私聊依赖 friends 先刷,
序列顺序已保证)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:38:47 +08:00
浅念 3cf77ec6d5 feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:38:05 +08:00
浅念 7343e1c9aa feat(management): 经营管理导航分组+驾驶舱路由 2026-09-10 10:20:13 +08:00
浅念 5196a72ac2 feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标+时长比率格式化共享
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:15:10 +08:00
浅念 df8d2b30d5 feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 10:02:35 +08:00
浅念 c56f13059d fix(group-stats): 群统计「查看详情」进纯聊天窗——无会话侧栏,操作列单按钮
用户反馈:微信群统计列表点查看聊天记录进入的页面带「好友列表/群列表」
侧栏不合适——云客该入口是专注的群详情聊天页(截图验证:无会话侧栏,
仅系统菜单+群名+工具栏+消息流)。

- ChatTimeline 增 bare 专注模式(?bare=1 隐藏会话侧栏);员工聊天窗口
  入口(不带 bare)侧栏常驻——昨天 WA 侧栏修复的场景不受影响
- GroupList 微信 tab 操作列精简为单按钮「查看详情」(=查看聊天记录,
  跳 bare 模式;导出HTML移除——聊天页头部本有导出),WA tab 不动

本地双路径验证:bare 无好友列表/有群聊标题;无 bare 侧栏在;列表点击
跳转 ?bare=1 正常。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 09:58:22 +08:00
浅念 7b0e1601b7 feat(management): 前端API client——管理端点typed契约(批次B冻结面)
对批次B as-built逐键核对后相对任务稿的修正(以真机响应为准):
- DashboardResp 补 caliber_notes(总述口径说明)
- DrilldownResp 补 page/page_size/scope(各分支均返回)
- FactDrillRow 补 chat_hint(owner+talker 直达聊天记录)
- LifecycleDrillRow.nickname 收窄为非空(后端 friend_id 兜底)
- EmployeeRow.metrics 注明 user_id 键为字符串透传字段

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 09:57:44 +08:00
浅念 940762e7ff fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 09:49:59 +08:00
浅念 2b10c07fb7 docs(management): 批次C前端计划——驾驶舱+导航(Task C0-C6)
评审4真问题全修:drilldown行补channel字段(C0后端契约补全)/DimSummary
补name+weight/端口3000→8000;advisory全采:服务图双轴/共享格式化提升
format.ts/去硬编码41/client契约字段补全
2026-09-10 09:44:39 +08:00
浅念 9fbd572bb7 docs(management): 批次B终评收尾——validate_config补源名唯一性+遗留观察项归档
批次B终评Approve后两处闭环:
- validate_config补源指标名跨维度唯一校验(comparisons按源名建键的
  静默覆盖防线)+测试
- 观察项归档:call_band样本基数(批次F)/源名唯一(已修)/性能与winsorize(批次F)
2026-09-09 19:28:48 +08:00
浅念 23ac95f2e4 docs(management): 批次B真机冒烟——六端点耗时/回测员工抽查/记录
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 19:17:56 +08:00
浅念 3d5644c42a fix(management): drilldown生命周期历史改peer键控对齐批次A口径+comparisons键域统一+补测试
评审修正(I1/M1-M4/M6):
- I1 lifecycle_customers 历史改 (channel,peer) 键控跨owner合并,归属仍由
  Customer.owner_account_id 决定——与批次A _lifecycle_daily 同口径;新增
  共享客户测试(历史在他人账号下仍全量呈现)
- M3 employees 行 comparisons 键域统一为指标源名(config code→spec.metric
  映射,active_friends_band/call_band 不再出现),与 sections 一致
- M4 未回复分支空 owners 先短路,免去无效 finalize 调用
- M1 employee_metrics._percentile 改公有 percentile 供同包复用,删除
  api_assembly.percentile_nearest(存储层 _percentile 不同层保留)
- M2 drilldown 内联 SQL(FirstReplyFact/PhoneCall)下沉 api_assembly
  (fr_fact_rows/user_calls_page),路由层无裸查询
- M6 补 start>end→422 断言;新增 config_source=db 测试(激活配置
  min_days=9→五天数据员工翻 insufficient_sample,守护生效可见)

tests/test_management_api.py: 21 passed;全量 721 passed

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:59:45 +08:00
浅念 31cd2d92aa feat(management): 只读管理API六端点——dashboard/employees/detail/sections/drilldown白名单/指标定义
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-09 18:34:58 +08:00
浅念 57ace98792 docs(management): 批次B计划同步band basis决议——active_friends周期distinct(与B2/B3实现一致) 2026-09-09 18:12:26 +08:00
29 changed files with 4085 additions and 123 deletions
@@ -241,3 +241,65 @@
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
| 10 | 核实文档完整 | ✅ | 本文补齐 |
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) |
---
# 批次B冒烟(Task B5,2026-09-09)
经营管理后台六端点真机冒烟 + 性能初测。回测员工王云青 `369D257EEC9A4357935FDE19DDD99CAA`(#1 高负载+群运营),窗口 30 天 `20260810~20260908`。
## 0. 环境与启动方式
- **基线**:`pytest tests/ -q` → **721 passed in 60.9s**(批次B收尾后全绿)。
- **后端启动**:docker 无 `yunke-backend/frontend-1` 容器;冒烟前 8000 端口空闲(连接拒绝,无常驻进程)。本任务自起:`yunke-server/.venv/Scripts/python.exe -m uvicorn app.api.main:app --port 8000`(无 `--reload`),`GET /api/health` → `{"status":"ok"}`;`openapi.json` 含 management 路由(grep 命中)确认新代码已加载。
- **鉴权模式**:`.env` 中 `API_TOKEN=`(空值)→ 鉴权中间件 no-op,**免鉴权直接 curl**(无 Authorization 头)。
- **进程处置**:uvicorn 留跑供用户查看,Git Bash PID **2423**(`kill 2423` 关闭;或任务管理器结束对应 python.exe)。
## 1. 六端点结果(HTTP 码 × 耗时 × 大小)
首次访问含冷启动(连接池/查询编译/InnoDB 冷缓存),dashboard 另测暖态三次:
| 端点 | HTTP | 首次耗时 | 暖态耗时 | 大小 | 判定 |
|---|---|---|---|---|---|
| `/api/management/dashboard` | 200 | 12.03s | **2.92 / 2.39 / 2.18s** | 22,309B | ⚠️ 超 1.5s 目标(慢段定位见 §2) |
| `/api/management/employees?page_size=50` | 200 | 0.34s | 0.30 / 0.31s | 58,865B | ✅ ≤1.5s |
| `/api/management/employees/{王云青}` | 200 | 0.40s | — | 4,739B | ✅ |
| `…/metrics?section=service` | 200 | 0.40s | — | 4,655B | ✅ |
| `…/drilldown?metric_code=first_reply_p90` | 200 | 0.016s | — | 5,268B | ✅ |
| `/api/management/metric-definitions` | 200 | 0.005s | — | 4,081B | ✅ |
## 2. dashboard 慢段定位(分段实测,不做优化——决策归批次F)
用 app 同一 session 复放 dashboard 组装流水线逐段计时(合计 2.685s ≈ 暖态端点耗时):
| 段 | 耗时 |
|---|---|
| load_score_config | 0.044s |
| assemble_employees(评分+异常,2 窗聚合) | 0.262s |
| wide_daily_rows / call_daily_rows | 0.007s / 0.002s |
| service_daily_trend | 0.032s |
| data_as_of | 0.009s |
| **action_counts(unreplied+churn+sensitive)** | **2.328s** |
action_counts 内部:`unreplied(hours=2)` **2.38s**;`churn(real,30d)` 0.12s;sensitive count 0.005s。unreplied 再拆:候选窗口函数查询(row_number over 67,116 行 friend 分区 → 3,123 候选)仅 **0.10~0.16s**;>500 候选走「仅本页决赛圈」分支无 N+1,但**单条尾部消息点查耗时 1.7~1.9s**。EXPLAIN 实证:`channel=None`(全平台)时无法使用 channel 打头的全部索引(`ix_message_channel_owner_talker_time(channel,owner,talker,timestamp)` 等),优化器退化到 `ix_message_room`(`room_id IS NULL/''`)→ **扫 378,202 行 + filesort**(message 表 96.4 万行)。
优化方向(供批次F选择):① 查询改写按渠道 UNION/`channel IN (…)` 绑定前缀走现有索引;② 补 `(owner_account_id, talker_id, timestamp)` 无 channel 前缀索引;③ dashboard 对 action_counts 做短 TTL 缓存。除 unreplied 外全链路 <0.5s,达标无压力。
## 3. 合理性抽查
- **employees 列表**:`total=41`/rows=41(=本文① staff 总数 ✓)。**王云青行与宽表 SQL 聚合逐字段全等**:msg_sent=111 / msg_recv=1003 / cust_threads=89 / threads_answered=57 / fr_sum_ms=698,140,844、days_with_data=29。数量级说明:「千级」预期对应全期全口径(全期 162,740 条消息/12,055 串,群聊为主);本窗 friend 口径 recv=1003 千级 ✓,sent=111 为真实分布——同窗全口径 recv≈2.0 万(stat_daily_staff 实测) vs friend 1,003,差额几乎全是群消息;cust_threads=89 是串模型多日串折叠(本文 §3 已验证折叠比 57.4%)+本窗 friend 活动偏低。
- **dashboard kpis**:active_employees=**40**(~40 ✓,=本文① stat 有数员工数)、active_customers=2,195、msg_total=42,422、cust_threads=9,330、answered=8,460、call 263/接通 113;lifecycle new/cooling/dormant/reactivated=231/1,432/3,353/145。**anomalies=28(critical 3 / warning 25)**,首条 `reply_p90_worsened` critical:P90 324.98min vs 基线期 18.06min(+1700%,样本 62、涉及 51 客户)。dimension_summary:risk/action 全 41 pending(批次C接线,符合预期),service scored 27/insufficient 4/pending 10、均分 56.0。趋势 30 点,末日 20260908 全零——数据止于 20260907(本文快照范围),边界正确。
- **detail(王云青)**:`score.trial=true` ✓、grade=**E**(total=39,scored 非 insufficient)、dimensions:risk/action **pending** ✓(批次C)、service 47.8 / customer 0.0 / workload 100;含 accounts/prev/baseline/anomalies。
- **metrics section=service**:数值与宽表窗聚合一致 + trend 30 点 ✓。
- **drilldown first_reply_p90**:total=**57**=该员工账号窗内 fact 行数(=threads_answered ✓)。首行 fr_ms=**498,242,238ms≈5.8 天**——超「分钟~小时」预期,但与源表全等(fact MAX 同值);窗内全库更极端值达 1,617,406,247ms≈18.7 天(thread_end 08-14 → reply 09-02,沉睡客户复活串),属真实数据形态非计算缺陷。该员工 p50=97,153ms(1.6min)/p90=1,651,758ms(27.5min)分钟级 ✓。极值是否 winsorize/封顶归批次F口径决策。
- **metric-definitions**:19 项定义(msg_sent…call_followup_rate)+ score_config + caliber_notes,`config_source="db"`(评分配置 v1.0 种子生效 ✓)。
**结论:六端点全 200、数据与库逐字段可对;唯一性能缺口=dashboard 的 unreplied 行动计数(channel 无前缀索引可用),暖态 2.2~2.9s,其余端点全部达标。**
## 4. 批次B遗留观察项归档(终评补录,2026-09-09)
| # | 项 | 归档 |
|---|---|---|
| 1 | call_band 样本基数:band 类指标样本量取 active_friends(规格字面),WA 通话量上来后"通话多客户少"员工会被误标 insufficient——当前数据零影响(实测 calls≥1 且 af<10 仅 5 人且 calls≤5,怎么选都不足);归批次F回测前一行修正(sample_basis 字段或按源指标取样本) |
| 2 | 配置源名唯一性:两个 code 指向同一源指标会使 employees comparisons 键静默覆盖——已在 validate_config 加源名唯一校验+测试(批次B终评闭环),默认配置六个源名互异不受影响 |
| 3 | dashboard 性能(2.2~2.9s,unreplied channel=None 无前缀索引)与 fr_ms 天级极值 winsorize:见上文 §2/§3,决策归批次F |
@@ -190,7 +190,7 @@ DEFAULT_SCORE_CONFIG: dict = {
"workload": {"weight": 15, "name": "有效负载", "metrics": {
"active_friends_band": {"type": "band", "metric": "active_friends",
"low": 5, "high": 80,
"basis": "period_daily_avg"},
"basis": "period_distinct"},
"call_band": {"type": "band", "metric": "call_count",
"low": 1, "high": 200, "basis": "period_total",
"note": "通话低不扣分仅提示;band 得分下限 80"},
@@ -452,7 +452,7 @@ def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | Non
```
规则(写死进测试):
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 日均与 call_count 总量。
- 周期值派生:`reply_2h_rate = reply_2h_count/cust_threads`(threads=0 → None);`fr_p90_ms` 直取;`active_share` **分子=期末该渠道最近一行(≤end)的 active_friends,分母=同一行的 active_friends+cooling_customer+dormant_customer**(分母 0 → None;宽表无生命周期 active 存量列,active 以 active_friends 列充当);band 用 active_friends 周期 distinct(B2 决议:抗单日噪声,与 B1 口径一致)与 call_count 总量。
- 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。
@@ -478,7 +478,7 @@ def score_employee(config, current: PeriodMetrics, baseline: PeriodMetrics | Non
| reply_p90_worsened | p90 > 基线p90×1.5 且样本≥soft;×2.5 升 critical | warning/critical | metric_code=first_reply_p90 |
| reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 |
| workload_overload | active_friends 日均 > 80(band 上限) | warning | 员工详情 |
| workload_overload | active_friends 周期 distinct > band 上限 80 | warning | 员工详情 |
卡片字段照 v1 §5.4 JSON(rule_code/severity/employee_user_id/title/current_value/baseline_value/unit/sample_size/**affected_count**/reason[纯数据事实]/drilldown/suggested_action——文案固定模板,不用 LLM)。`affected_count` 按规则定义:reply_p90_worsened 与 reply_rate_dropped=期内未回串数(cust_threads−threads_answered);activity_drop=当前期活跃客户数;workload_overload=active_friends 日均。
@@ -0,0 +1,305 @@
# 云客经营管理后台·批次C(前端导航+管理驾驶舱)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付经营管理后台的第一个可视化页面——管理驾驶舱(`/management/dashboard`)+「经营管理」导航分组;管理者 5 分钟看整体、异常两次点击进事实。
**Architecture:** 前端纯增量:`api/management.ts`(六端点 typed client,契约=批次B终评冻结面)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `components/management/` 六个组件 + `ManagementDashboardView.vue`。复用 useChart/api client/format utils/seq 守卫惯例;不引新 UI 框架、不引雷达图(维度分布用条形图)。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §4/§5/§7/§13 与批次B契约清单;员工总览/详情页是批次D。
**Tech Stack:** Vue3 + TS + Element Plus + ECharts(useChart 按需已有 Line/Bar)+ vitest(若仓库已配)。
---
## 执行者必读
1. **后端就绪**(本地 8000 已跑或按 DEPLOY 起):六端点见 `/tmp` 之外的真实路径 `/api/management/*`;**契约以批次B终评清单为准**(None≠0;`scope.channel` 回显原值;drilldown 白名单六值;异常卡片 12 字段;`config_source=db`)。
2. **前端惯例**:路由扁平 + `meta.title` + 登录守卫(public 才放行);API 走 `getJson/requestJson`(client.ts 已带 Bearer+baseURL=/api);图表用 `useChart`(容器稳定,不 v-if);请求序号 seq 守卫防旧响应覆盖(DashboardView 现有模式照抄);格式化放 `utils/format.ts` 不重复实现;el-select 空串 value 显示英文占位用 `'all'` 哨兵。
3. **不做**:员工总览/详情页、辅导、快照(D/E 批次);`/analytics/dashboard` 不重定向(并存过渡);二期菜单不渲染不放空页。
4. **验收基准**:v2 §5——5 分钟判总体;异常两次点击到事实;全部卡片显示数据截至与对比窗口;`npm run build` 过。
5. 仓库有并行会话活动——`git status` 后只 add 自己文件;提交中文 conventional;master 直提。
## 文件地图
| 文件 | 职责 | 动作 |
|---|---|---|
| `yunkes/src/api/management.ts` | 六端点 typed client | 新建 |
| `yunkes/src/composables/useManagementFilters.ts` | URL query ↔ 筛选(channel/start/end/compare + 预设窗) | 新建 |
| `yunkes/src/components/management/DataAsOfBadge.vue` | 数据截至/版本/试算角标 | 新建 |
| `yunkes/src/components/management/ManagementKpiCard.vue` | KPI 卡(主值+辅助+点击跳转) | 新建 |
| `yunkes/src/components/management/ManagementFilterBar.vue` | 筛选栏(渠道/时间预设/自定义/口径入口按钮) | 新建 |
| `yunkes/src/components/management/AnomalyList.vue` | 异常榜(severity 标签+数字+下钻事件) | 新建 |
| `yunkes/src/components/management/MetricDrilldownDrawer.vue` | 下钻抽屉(drilldown 端点行清单+聊天跳转) | 新建 |
| `yunkes/src/components/management/MetricDefsDrawer.vue` | 指标口径抽屉(metric-definitions 渲染) | 新建 |
| `yunkes/src/views/management/ManagementDashboardView.vue` | 驾驶舱页 | 新建 |
| `yunkes/src/router/index.ts` | /management/dashboard 路由 | 修改 |
| `yunkes/src/layouts/MainLayout.vue` | 「经营管理」分组 | 修改 |
---
### Task C0: 后端契约补全——drilldown 行补 channel 字段
**Files:** Modify `yunke-server/app/management/api_assembly.py` + `yunke-server/tests/test_management_api.py`
批次B已提交但未部署,前端冻结前补齐(评审发现:fr_fact_rows/lifecycle_customers 的行无 channel 键,前端 channel=all 时 WA 行会跳错聊天路由):
1. `fr_fact_rows` 每行加 `channel`(SELECT 已有该列,透传即可);
2. `lifecycle_customers` 每行加 `channel`(c.channel 透传);
3. 测试断言补两行(drilldown p90 与 cooling/dormant 的响应行含 channel)。
- [ ] `cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -q`(23 passed)+ 全量 -q
- [ ] commit `fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)`
### Task C1: api/management.ts(typed client)
**Files:** Create `yunkes/src/api/management.ts`
完整代码(契约=批次B as-built;先对照后端 router 响应键核对再落笔):
```typescript
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
import { getJson } from './client'
import type { Channel } from './types'
export type MgmtChannel = 'all' | Channel
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
export type DrilldownCode =
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
export interface Kpis {
active_customers: number; active_employees: number
msg_total: number; cust_threads: number; threads_answered: number
call_count: number; call_seconds: number; answered_call: number
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
}
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
export interface AnomalyCard {
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
title: string; current_value: number; baseline_value: number | null; unit: string
sample_size: number; affected_count: number; reason: string
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
suggested_action: string
}
export interface DashboardResp {
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
kpis: Kpis
communication_trend: CommTrendPoint[]
service_trend: SvcTrendPoint[]
lifecycle: Kpis['lifecycle']
dimension_summary: DimSummary
anomalies: AnomalyCard[]
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
}
export interface EmployeeRow {
user_id: string; name: string | null; is_on_job: boolean | null
metrics: Record<string, number | null>
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
anomaly_count: number; anomaly_weight: number
}
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number }
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string | null; last_day: number; state: string }
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
export interface DrilldownResp { total: number; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
export interface MetricDefsResp {
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
caliber_notes: string
config_source: 'db' | 'default'
}
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
export const getEmployees = (params: Record<string, unknown>) =>
getJson<EmployeesResp>('/management/employees', params)
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
```
(员工详情/sections 两个端点的 client 函数批次D 再加——本期页面不消费,YAGNI;本期 client 为四函数。)
- [ ] 步骤:写文件 → `cd yunkes && npx vue-tsc --noEmit`(或 build)类型过 → 后端(8000,dev 5173 的 /api 代理已指向它)冒烟一次 getDashboard 类型与真实响应吻合(console 对比关键字段)→ commit `feat(management): 前端API client——管理端点typed契约(批次B冻结面)`
### Task C2: useManagementFilters composable
**Files:** Create `yunkes/src/composables/useManagementFilters.ts`(+ vitest 若仓库已配测试脚本,先 `grep '"test"' yunkes/package.json` 确认)
完整代码:
```typescript
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom'
export const CHANNELS = [
{ value: 'all', label: '全部渠道' },
{ value: 'wechat', label: '微信' },
{ value: 'whatsapp', label: 'WhatsApp' },
] as const
function ymd(d: Date): number { return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate() }
function addDays(d: Date, n: number): Date { const x = new Date(d); x.setDate(x.getDate() + n); return x }
/** 预设 → [start,end] yyyymmdd(上海自然日由本地时区近似——与后端一致的公历日)。 */
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
switch (p) {
case 'last7': return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
case 'last30': return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
case 'thisMonth': return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
case 'lastMonth': {
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
return { start: ymd(first), end: ymd(last) }
}
}
// custom 由调用方直传
return { start: ymd(t0), end: ymd(t0) }
}
export function useManagementFilters() {
const route = useRoute()
const router = useRouter()
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
const v = route.query.channel
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
})
const start = computed(() => {
const v = Number(route.query.start)
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').start
})
const end = computed(() => {
const v = Number(route.query.end)
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').end
})
const compare = computed(() => {
const v = route.query.compare
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
})
function apply(patch: Record<string, string | number | undefined>) {
const q: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
}
router.replace({ query: q })
}
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
else apply({ start: undefined, end: undefined })
}
return { channel, start, end, compare, apply, applyPreset, presetRange }
}
```
- [ ] vitest(若已配):presetRange 五预设的日期算术(含月末/跨月)+ query 缺省回退 last30 + apply 写回 query。未配则手测并在报告注明。
- [ ] commit `feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵`
### Task C3: 六个基础组件
**Files:** Create `components/management/` 下六件。规格(完整 SFC 代码进实施,要点钉死):
1. **DataAsOfBadge**:props `{ dataAsOf: number | null; metricVersion?: string; trial?: boolean; configSource?: string }`;渲染 el-tag 组:数据截至(`data_as_of` ms→`YYYY-MM-DD HH:mm`,null 显示「无数据」灰 tag)+ 指标版本 + 试算(warning 色,trial=true 才显示)。格式化用 utils/format.ts 现有函数,无则组件内小 helper。
2. **ManagementKpiCard**:props `{ label: string; value: number | null; unit?: string; hint?: string; to?: string }`;主值大字(null 显示 `--`),hint 小字,有 to 则整卡 clickable `router.push`。不做趋势箭头(对比值批次D 表格里呈现)。
3. **ManagementFilterBar**:props modelValue 透传 filters composable 的值 + `@apply`;内容:el-radio-group 渠道(CHANNELS)+ el-select 预设(四预设+自定义)+ 自定义时 el-date-picker type=daterange(value-format YYYYMMDD 字符串转 number)+「指标口径」文字按钮(emit open-defs)+ 插槽放 DataAsOfBadge。改即 apply(防抖 300ms 不需要——change 事件天然低频)。
4. **AnomalyList**:props `{ anomalies: AnomalyCard[] }`;el-table 或列表卡片:severity(critical=红 danger tag「严重」/warning=橙 warning tag「关注」)、title、current vs baseline(unit 感知格式化:ms→时长/0-1→%/其他千分位)、sample_size、affected_count、suggested_action(灰小字);行 click → emit `drill(card)`。空态「暂无异常」。
5. **MetricDrilldownDrawer**:props `{ userId: string; card: AnomalyCard | null; start: number; end: number; channel: MgmtChannel }`(v-model:visible);打开时按 card.drilldown.query.metric_code 调 getEmployeeDrilldown:
- `first_reply_p90/p50` → 表格:客户(peer_id)/串末条时间/回复时间/首响时长(fr_ms 降序已由后端);行点击跳聊天:`/staff-chat/{owner_account_id}/single/{peer_id}`(channel=whatsapp 时 `/chat/whatsapp/{owner}/{peer}`)。
- `unreplied` → 表格 peer/等待时长/预览;note/truncated 提示。
- `cooling/dormant_customers` → 表格 客户/最后互动日/状态;行点击跳该客户聊天(同上映射)。
- `call_followup` → 表格 通话(时间/号码/时长)+ summary 卡。
分页 el-pagination(后端分页);员工姓名/标题显示 card.employee_user_id 对应名字由父传(dashboard anomalies 无 name——显示 user_id 短版即可,批次D 带名字)。
6. **MetricDefsDrawer**:v-model:visible;打开调 getMetricDefs;el-collapse 按 维度/单指标 两组渲染:每指标 name+unit+direction(越大越好/越小越好)+formula+caliber;顶部 score_config 摘要(版本/试算标记/样本门槛/各维度权重与 pending 状态)+ caliber_notes 段落。
- [ ] 步骤:六组件逐个写 + vue-tsc 过 → 冒烟渲染(C5 页面里真用)→ commit `feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标`
### Task C4: 路由 + 导航
- router/index.ts 在 analytics 四页之后加:
```typescript
{
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
path: '/management/dashboard',
name: 'management-dashboard',
component: () => import('@/views/management/ManagementDashboardView.vue'),
meta: { title: '管理驾驶舱' },
},
```
- MainLayout「数据分析」el-sub-menu 之前加「经营管理」分组(单项:管理驾驶舱 → goPlain('/management/dashboard'))——分组在最上(管理优先级高于查看)。
- 验证:登录态点菜单进页面、刷新保持、activeMenu 高亮正确。
- [ ] commit `feat(management): 经营管理导航分组+驾驶舱路由`
### Task C5: ManagementDashboardView
**Files:** Create `yunkes/src/views/management/ManagementDashboardView.vue`
结构(完整 SFC 进实施,要点钉死):
```text
<ManagementFilterBar>(含 DataAsOfBadge + 口径按钮)
六个 KPI 卡(el-row/gutter,1366px 两行三卡):
活跃客户(kpis.active_customers,hint=串/被回)→无跳转(批次D员工页)
活跃员工(kpis.active_employees,hint=期内有数据天数>0 口径)
消息量(msg_total,hint=1:1口径·与数据看板口径不同)
待回复(action_summary.unreplied_2h,红点>0)→/analytics/unreplied
即将流失(action_summary.churn_real_30d)→/analytics/churn
风险待处理(action_summary.sensitive_pending)→/analytics/sensitive
双图表(el-row 两列,useChart×2):
客户沟通趋势:Line msg_total + Bar call_cnt 双 y 轴(x=day)
服务质量趋势:双 y 轴——左轴 ms(Line fr_p50_ms/fr_p90_ms,tooltip 时长格式化)、
右轴 0-1(Line reply_2h_rate,%)——不注明双轴则率线贴底
——图内空数据/None 点处理:reply_2h_rate null 断点(connectNulls=false)
双条形图(el-row 两列):
生命周期分布:Bar new/cooling/dormant/reactivated 四类
员工维度分布:Bar dimension_summary 三维 avg_score(0-100)+pending 数 tooltip
异常榜(AnomalyList @drill → MetricDrilldownDrawer)
seq 守卫:filters 变化 → seq++ → 请求 → 回来校验 seq 才渲染(照 DashboardView 模式)
loading:el-skeleton 首屏;错误:el-alert + 重试按钮(保留上次数据)
```
要点:
- `watch([channel, start, end, compare], load)`;`onMounted(load)`;
- day 轴 `20260810`→`08-10` 短格式;
- **共享格式化**:把 DashboardView 里的 `fmtFr`(ms→时长)/`fmtPct`(0-1→%) 提升为 `utils/format.ts` 的 `formatMsDuration`/`formatRatio`(AnomalyList/抽屉/图表 tooltip 三处消费,不复制第三份);
- 异常榜点卡片 → drawer(metric_code 白名单外 type=employee 的卡片——activity_drop/overload 无 drilldown 端点,drawer 显示「员工分析批次D上线」占位说明——**两类 drilldown.type=employee 的卡片点击行为:显示 el-empty 占位**);
- 试算角标常显(config_source 透传)。
- [ ] 步骤:写页面 → `npm run build` 过 → 本地前后端真机走查(起 8000 后端 + `npm run dev` 5173,Playwright 或手动):六卡有数、双图渲染、异常卡点开抽屉、抽屉行跳聊天、口径抽屉渲染、刷新 URL 保持、1366px 布局不塌 → commit `feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实`
### Task C6: 收尾验收
- [ ] `cd yunkes && npm run build`(0 error);
- [ ] `npm run test --silent`(若配了 vitest)或记录未配;
- [ ] 验收清单核对(v2 §5.3/§19.1 驾驶舱子集):5 分钟判总体 ✓/异常两次点击进事实 ✓/口径入口 ✓/数据截至+对比窗口显示 ✓/渠道筛选 ✓/URL 保持 ✓/试算标记 ✓/空态与错误态 ✓;
- [ ] 全量后端测试仍绿(前端不动后端,确认无意外);
- [ ] commit(若剩零散修正)。
---
## 批次C验收清单
- [ ] 「经营管理」分组出现且仅含管理驾驶舱;
- [ ] 六 KPI 卡数字与后端响应一致(抽查待回复/风险与 analytics 页对得上);
- [ ] 双趋势图渲染 30 点、None 断点正确;
- [ ] 异常卡 critical 红标在前;点击 metric 类卡片→抽屉→行→聊天定位(两次点击到事实);
- [ ] employee 类异常卡占位不报错;
- [ ] 口径抽屉含试算标记与三方口径差异说明;
- [ ] URL query 刷新/分享保持筛选;
- [ ] `npm run build` 过;后端 722 测试不受影响。
## 批次D-F
D:员工总览+详情页(消费 employees/detail/sections 端点+指标解释抽屉);E:辅导闭环+考核快照(写端点);F:调度接入+dashboard 性能修复(unreplied 索引)+部署上线。
+2
View File
@@ -49,6 +49,7 @@ from app.api.routers import (
export,
groups,
login,
management,
media,
messages,
moments,
@@ -101,6 +102,7 @@ app.include_router(sessions.router)
app.include_router(export.router)
app.include_router(voice_clips.router)
app.include_router(analytics.router)
app.include_router(management.router)
@app.get("/api/health")
+543
View File
@@ -0,0 +1,543 @@
"""GET /api/management/* —— 经营管理后台只读端组(批次B Task B4)。
六个端点:dashboard(总览)/ employees(列表,一期四视图共用行集)/
employees/{user_id}(详情)/ employees/{user_id}/metrics(分段指标+趋势)/
employees/{user_id}/drilldown(白名单下钻)/ metric-definitions(口径字典)。
惯例同 analytics.py:channel=all 在 router 层翻译为 None 传服务层;
start/end yyyymmdd 缺一/日历非法/start>end/跨度>366 天皆 422(_window);
重查询与行构造在 app.management.api_assembly,本文件只做薄编排。
所有查询共用单 AsyncSession——**顺序 await,不并发**(见 assembly 模块)。
"""
from __future__ import annotations
import datetime as dt
from typing import Annotated, Any, Literal
import sqlalchemy as sa
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics import health as health_svc
from app.analytics import metrics as metrics_svc
from app.api.deps import get_session
from app.db.models import Staff, WechatAccount, WhatsappAccount
from app.management import api_assembly as asm
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
baseline_window,
day_range,
period_metrics,
prev_window,
)
from app.management.metric_definitions import METRIC_DEFINITIONS
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
router = APIRouter(prefix="/api/management", tags=["management"])
SessionDep = Annotated[AsyncSession, Depends(get_session)]
AllChannelParam = Annotated[Literal["all", "wechat", "whatsapp"], Query()]
YmdParam = Annotated[int, Query(ge=20000101, le=29991231)]
CompareParam = Annotated[
Literal["previous", "personal_baseline", "team_median"], Query()
]
ViewParam = Annotated[
Literal["management", "workload", "service", "customer"], Query()
]
StatusParam = Annotated[Literal["all", "on_job", "offline"], Query()]
SortByParam = Annotated[
Literal["anomaly_severity", "total_score", "active_friends", "cust_threads"],
Query(),
]
SortOrderParam = Annotated[Literal["asc", "desc"], Query()]
SectionParam = Annotated[
Literal["overview", "service", "customer", "workload", "calls"], Query()
]
# drilldown 白名单(外 422 "unknown metric_code")
_DRILLDOWN_CODES = frozenset({
"first_reply_p50", "first_reply_p90", "unreplied",
"cooling_customers", "dormant_customers", "call_followup",
})
# unreplied 适配:上游无私有 owner 过滤,取全量决赛圈结果再按 owner 过滤。
# 500 = analytics.metrics._FINALIZE_ALL_MAX 同值(候选 ≤500 时单次调用即
# 拿到全部已过滤 items;超过则上游本就按候选分页,响应标 truncated)。
_UNREPLIED_FETCH = 500
# 三方口径差异说明(硬编码,与 METRIC_DEFINITIONS.caliber 互补的总述)
CALIBER_NOTES = (
"管理域指标为 1:1 私聊口径且跨日精确(消息串按会话全历史计算、"
"归属串末条所在自然日);现有看板为私聊+群聊混合、日切口径"
"(跨夜串重复计/丢失,回复率分子分母同源近似)。"
"通话指标(通话数/时长/接通/跟进)无消息渠道维度——"
"voice_call 与 wa_voice_call 合并计数,不随 channel 参数过滤。"
)
# 各 section 的期指标字段(自 PeriodMetrics 挑)与宽表/通话表趋势列
_SECTION_FIELDS: dict[str, tuple[str, ...]] = {
"overview": ("days_with_data", "msg_sent", "msg_recv", "active_friends",
"active_rooms_avg"),
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
"fr_p50_ms", "fr_p90_ms", "reply_2h_count", "reply_24h_count"),
"customer": ("new_customer", "cooling_customer", "dormant_customer",
"reactivated_customer", "end_active_friends"),
"workload": ("active_friends", "active_rooms_avg", "cust_threads"),
"calls": ("call_count", "call_seconds", "answered_call", "call_followup"),
}
_SECTION_TREND_COLS: dict[str, tuple[str, ...]] = {
"overview": ("msg_sent", "msg_recv", "active_friends"),
"service": ("cust_threads", "threads_answered", "fr_sum_ms",
"first_reply_p50_ms", "first_reply_p90_ms"),
"customer": ("new_customer_count", "cooling_customer_count",
"dormant_customer_count", "reactivated_customer_count"),
"workload": ("active_friends", "active_rooms"),
"calls": ("call_count", "call_seconds", "answered_call_count",
"call_followup_count"),
}
def _window(start: int | None, end: int | None) -> tuple[int, int]:
"""管理端点公共窗口校验(照 analytics custom scope 惯例)。"""
if start is None or end is None:
raise HTTPException(422, "start and end are required")
try:
sd = dt.date(start // 10000, (start // 100) % 100, start % 100)
ed = dt.date(end // 10000, (end // 100) % 100, end % 100)
except ValueError as exc: # 如 20260230——Query 只拦范围不拦日历
raise HTTPException(422, "invalid start/end calendar date") from exc
if sd > ed:
raise HTTPException(422, "start must be <= end")
if (ed - sd).days > 365: # 含端点共 366 天
raise HTTPException(422, "span must be <= 366 days")
return start, end
def _translate(channel: str) -> str | None:
return None if channel == "all" else channel
async def _get_staff(session: AsyncSession, user_id: str) -> Staff:
staff = (await session.execute(
sa.select(Staff).where(Staff.user_id == user_id)
)).scalar_one_or_none()
if staff is None:
raise HTTPException(404, f"employee {user_id} not found")
return staff
@router.get("/dashboard")
async def management_dashboard(
session: SessionDep,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
compare: CompareParam = "previous",
) -> dict[str, Any]:
"""经营管理总览:KPI + 双趋势 + 生命周期 + 维度分布 + 异常汇总 +
行动入口计数,单往返。compare 仅回显(对比数值在 employees 行内)。"""
start, end = _window(start, end)
ch = _translate(channel)
config, config_source = await asm.load_score_config(session)
assembled = await asm.assemble_employees(
session, config, start, end, ch, with_prev=False
)
pms = [a["cur"] for a in assembled.values()]
# ---- 趋势两查:宽表全员逐日 + 通话小表逐日 + fact 逐日 ----
wide = await asm.wide_daily_rows(session, start, end, ch)
wide_by_day = {d["day"]: d for d in wide}
threads_by_day = {d["day"]: d["cust_threads"] for d in wide}
calls = await asm.call_daily_rows(session, start, end)
call_by_day = {d["day"]: d["call_count"] for d in calls}
comm_trend = []
for day in day_range(start, end):
d = wide_by_day.get(day)
comm_trend.append({
"day": day,
"msg_total": (d["msg_sent"] + d["msg_recv"]) if d else 0,
# 团队合计口径:当日各员工宽表 active_friends 求和
"active_customers": d["active_friends"] if d else 0,
"call_cnt": call_by_day.get(day, 0),
})
service_trend = await asm.service_daily_trend(
session, start, end, ch, threads_by_day
)
lifecycle = {
"new": sum(p.new_customer for p in pms),
"cooling": sum(p.cooling_customer for p in pms),
"dormant": sum(p.dormant_customer for p in pms),
"reactivated": sum(p.reactivated_customer for p in pms),
}
kpis = {
# 全员 active_friends 求和:跨员工可能重复计客户(共享客户)
"active_customers": sum(p.active_friends for p in pms),
"active_employees": sum(1 for p in pms if p.days_with_data > 0),
"msg_total": sum(p.msg_sent + p.msg_recv for p in pms),
"cust_threads": sum(p.cust_threads for p in pms),
"threads_answered": sum(p.threads_answered for p in pms),
"call_count": sum(p.call_count for p in pms),
"call_seconds": sum(p.call_seconds for p in pms),
"answered_call": sum(p.answered_call for p in pms),
"lifecycle": lifecycle,
}
# ---- 维度分布(全员 score_employee 的维度状态计数) ----
dim_summary: dict[str, dict] = {}
for a in assembled.values():
for key, dim in a["score"]["dimensions"].items():
e = dim_summary.setdefault(key, {
"name": dim.get("name"), "weight": dim.get("weight", 0),
"scored_count": 0, "insufficient_count": 0,
"pending_count": 0, "_scores": [],
})
if dim["status"] == "scored":
e["scored_count"] += 1
e["_scores"].append(dim["score"])
elif dim["status"] == "insufficient":
e["insufficient_count"] += 1
else: # pending / pending_data
e["pending_count"] += 1
for e in dim_summary.values():
scores = e.pop("_scores")
e["avg_score"] = (
sum(scores) / len(scores) if scores else None
)
# ---- 异常汇总:critical 先、warning 后,同 severity 按 affected desc ----
anomalies = [a for data in assembled.values() for a in data["anomalies"]]
sev_rank = {"critical": 0, "warning": 1}
anomalies.sort(key=lambda a: (
sev_rank.get(a["severity"], 9),
-a.get("affected_count", 0),
a["employee_user_id"],
))
return {
"scope": {"channel": channel, "start": start, "end": end,
"compare": compare},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
"kpis": kpis,
"communication_trend": comm_trend,
"service_trend": service_trend,
"lifecycle": lifecycle,
"dimension_summary": dim_summary,
"anomalies": anomalies,
"action_summary": await metrics_svc.action_counts(session, channel=ch),
"caliber_notes": {
"active_customers": "kpis.active_customers 为全员周期 distinct "
"求和,跨员工共享客户可能重复计",
"trend_active_customers": "communication_trend.active_customers "
"为宽表日值团队合计(日口径求和)",
"reply_2h_rate": "分子=fact 当日 ≤2h 计数,分母=宽表当日串数",
},
}
@router.get("/employees")
async def management_employees(
session: SessionDep,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
view: ViewParam = "management",
keyword: str | None = None,
status: StatusParam = "all",
sort_by: SortByParam = "anomaly_severity",
sort_order: SortOrderParam = "desc",
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""员工列表(一期 management/workload/service/customer 四视图共用
行集,view 仅回显)。默认 anomaly_severity 排序:含 critical 者 →
权重和 → 卡片数;total_score 排序 None 恒排最后。"""
start, end = _window(start, end)
ch = _translate(channel)
config, config_source = await asm.load_score_config(session)
assembled = await asm.assemble_employees(
session, config, start, end, ch, with_prev=True
)
items = list(assembled.items())
if ch is not None:
# 渠道过滤=账号归属(该渠道无账号的员工不进列表;有账号无数据仍
# 展示零值行)
owners_uids = set((await account_map(session, ch)).values())
items = [(u, d) for u, d in items if u in owners_uids]
if keyword:
kw = keyword.strip().lower()
items = [
(u, d) for u, d in items
if kw in (d["name"] or "").lower() or kw in u.lower()
]
if status == "on_job":
items = [(u, d) for u, d in items if d["is_on_job"] is True]
elif status == "offline":
items = [(u, d) for u, d in items if d["is_on_job"] is not True]
items.sort(key=lambda kv: kv[0]) # 决胜底座:user_id asc(稳定排序)
if sort_by == "anomaly_severity":
items.sort(key=lambda kv: (
any(a["severity"] == "critical" for a in kv[1]["anomalies"]),
kv[1]["anomaly_weight"], kv[1]["anomaly_count"],
), reverse=(sort_order == "desc"))
elif sort_by == "total_score":
# None(样本不足等)恒排最后,与升降序无关
if sort_order == "desc":
items.sort(key=lambda kv: (
kv[1]["score"]["total_score"] is None,
-(kv[1]["score"]["total_score"] or 0),
))
else:
items.sort(key=lambda kv: (
kv[1]["score"]["total_score"] is None,
kv[1]["score"]["total_score"] or 0,
))
else: # active_friends / cust_threads(PeriodMetrics 数值列)
if sort_order == "desc":
items.sort(key=lambda kv: -getattr(kv[1]["cur"], sort_by))
else:
items.sort(key=lambda kv: getattr(kv[1]["cur"], sort_by))
total = len(items)
page_rows = items[(page - 1) * page_size: page * page_size]
return {
"total": total,
"page": page,
"page_size": page_size,
"rows": [asm.build_employee_row(u, d, config) for u, d in page_rows],
"scope": {"channel": channel, "start": start, "end": end,
"view": view},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
}
@router.get("/employees/{user_id}")
async def management_employee_detail(
session: SessionDep,
user_id: str,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""员工详情:身份+账号清单+期指标全字段+评分完整明细(含逐指标
metrics_detail)+异常卡片+prev/baseline 原始 PeriodMetrics。"""
start, end = _window(start, end)
ch = _translate(channel)
staff = await _get_staff(session, user_id)
config, config_source = await asm.load_score_config(session)
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
bs, be = baseline_window(start)
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
ps, pe = prev_window(start, end)
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
prev_map.get(user_id))
if cur is None: # 防御:_get_staff 已保证在 staff,period_metrics 必含
raise HTTPException(404, f"employee {user_id} not found")
team = asm.team_derived_medians(
await period_metrics(session, start, end, ch)
)
score = asm.score_with_guards(config, cur, baseline=base, team=team,
prev=prev)
anomalies = detect_anomalies(
user_id, cur, base, n_days=len(day_range(start, end)), config=config
)
accounts: list[dict] = []
for wa in (await session.execute(
sa.select(WechatAccount).where(WechatAccount.user_id == user_id)
)).scalars():
accounts.append({
"channel": "wechat", "account_id": wa.wx_id,
"nick_name": wa.nick_name, "user_name": wa.user_name,
})
for wa in (await session.execute(
sa.select(WhatsappAccount).where(WhatsappAccount.user_id == user_id)
)).scalars():
accounts.append({
"channel": "whatsapp", "account_id": wa.uid,
"nick_name": wa.nick_name, "user_name": wa.user_name,
})
return {
"user_id": user_id,
"name": staff.name,
"is_on_job": staff.is_on_job,
"accounts": accounts,
"metrics": cur._asdict(),
"score": score,
"anomalies": anomalies,
"prev": prev._asdict() if prev is not None else None,
"baseline": base._asdict() if base is not None else None,
"scope": {"channel": channel, "start": start, "end": end},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
"config_source": config_source,
}
@router.get("/employees/{user_id}/metrics")
async def management_employee_metrics(
session: SessionDep,
user_id: str,
section: SectionParam = "overview",
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
) -> dict[str, Any]:
"""员工分段指标:该域期指标 + 窗口逐日趋势 + 四对比
(current/prev/baseline/median,派生率走 scoring.derive_metrics 口径)。"""
start, end = _window(start, end)
ch = _translate(channel)
await _get_staff(session, user_id)
cur_map = await period_metrics(session, start, end, ch, user_ids={user_id})
bs, be = baseline_window(start)
base_map = await period_metrics(session, bs, be, ch, user_ids={user_id})
ps, pe = prev_window(start, end)
prev_map = await period_metrics(session, ps, pe, ch, user_ids={user_id})
cur, base, prev = (cur_map.get(user_id), base_map.get(user_id),
prev_map.get(user_id))
if cur is None:
raise HTTPException(404, f"employee {user_id} not found")
team = asm.team_derived_medians(
await period_metrics(session, start, end, ch)
)
if section == "calls":
src_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
else:
src_rows = await asm.wide_daily_rows(session, start, end, ch,
user_id=user_id)
by_day = {r["day"]: r for r in src_rows}
cols = _SECTION_TREND_COLS[section]
trend = []
for day in day_range(start, end):
row = by_day.get(day)
point: dict[str, Any] = {"day": day}
for col in cols:
if row is not None:
point[col] = row.get(col)
else: # 无数据日:计数列补 0,分位列补 None
point[col] = None if col.startswith("first_reply") else 0
trend.append(point)
return {
"section": section,
"metrics": {f: getattr(cur, f) for f in _SECTION_FIELDS[section]},
"trend": trend,
"comparisons": asm.derived_comparisons(cur, prev, base, team),
"scope": {"channel": channel, "start": start, "end": end,
"user_id": user_id},
"data_as_of": await health_svc.data_as_of(session),
"metric_version": METRIC_VERSION,
}
@router.get("/employees/{user_id}/drilldown")
async def management_employee_drilldown(
session: SessionDep,
user_id: str,
metric_code: str,
channel: AllChannelParam = "all",
start: YmdParam | None = None,
end: YmdParam | None = None,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
"""指标下钻(白名单六项,外 422):首响分位逐串证据 / 未回串 /
降温·沉睡客户名单 / 通话跟进列表。所有分支按员工 owner 账号过滤。"""
if metric_code not in _DRILLDOWN_CODES:
raise HTTPException(422, f"unknown metric_code: {metric_code}")
start, end = _window(start, end)
ch = _translate(channel)
await _get_staff(session, user_id)
scope = {"user_id": user_id, "metric_code": metric_code,
"channel": channel, "start": start, "end": end}
owners = await asm.user_owner_accounts(session, user_id, ch)
def _paged(items: list[dict]) -> dict[str, Any]:
ix = (page - 1) * page_size
return {"items": items[ix: ix + page_size], "total": len(items),
"page": page, "page_size": page_size, "scope": scope}
if metric_code in ("first_reply_p50", "first_reply_p90"):
if not owners:
return _paged([])
total, items = await asm.fr_fact_rows(
session, owners, ch, start, end, page=page, page_size=page_size
)
return {"items": items, "total": total, "page": page,
"page_size": page_size, "scope": scope}
if metric_code == "unreplied":
if not owners: # 无账号→必空,免进上游决赛圈点查
return _paged([])
# 上游无私有 owner 过滤:一次拉全量决赛圈结果(候选 ≤500 时完整),
# 按 owner 账号过滤后内存分页;候选超限标 truncated(过滤可能不全)
res = await metrics_svc.unreplied(
session, channel=ch, hours=24, page=1, page_size=_UNREPLIED_FETCH
)
owner_set = set(owners)
owned = [i for i in res["items"]
if i["owner_account_id"] in owner_set]
out = _paged(owned)
out["note"] = ("按该员工 owner 账号自全量未回清单过滤"
"(hours=24,与看板清单同源同判定)")
if res["total"] > len(res["items"]):
out["truncated"] = True
return out
if metric_code in ("cooling_customers", "dormant_customers"):
want = ("cooling" if metric_code == "cooling_customers"
else "dormant")
if not owners:
return _paged([])
matches = await asm.lifecycle_customers(session, owners, end, want)
return _paged(matches)
# call_followup:窗口通话列表(start desc)+ 期聚合摘要
# (逐通话跟进标记复用批次A _followup_exists 太重,留批次C按需再加)
total, items = await asm.user_calls_page(
session, user_id, start, end, page=page, page_size=page_size
)
summary_rows = await asm.call_daily_rows(session, start, end,
user_id=user_id)
summary = {col: sum(r[col] for r in summary_rows)
for col in ("call_count", "call_seconds",
"answered_call_count", "call_followup_count")}
return {"items": items, "total": int(total), "page": page,
"page_size": page_size, "summary": summary, "scope": scope,
"note": "逐通话跟进标记留批次C;此处为窗口通话列表+期聚合"
"(followup 为接通后24h内有跟进的通话数)"}
@router.get("/metric-definitions")
async def management_metric_definitions(
session: SessionDep,
) -> dict[str, Any]:
"""指标口径字典 + 当前评分配置(含 pending 维度)+ 三方口径差异说明。
config_source: "db"=激活配置 / "default"=未种子回退默认。"""
config, source = await asm.load_score_config(session)
return {
"definitions": METRIC_DEFINITIONS,
"score_config": {
"version": config.get("version"),
"name": config.get("name"),
"trial": config.get("trial"),
"normalization": config.get("normalization"),
"sample_guards": config.get("sample_guards"),
"grade_bounds": config.get("grade_bounds"),
"dimensions": config.get("dimensions"),
},
"config_source": source,
"caliber_notes": CALIBER_NOTES,
}
+533
View File
@@ -0,0 +1,533 @@
"""管理 API 组装层(批次B Task B4)——router 六端点共用的重查询与行构造。
拆出 router 的原因:employees/dashboard 的组装(多窗聚合 + 评分 + 异常 +
对比派生)上百行,放 router 会淹没端点定义;本模块只做数据组装,参数
校验/HTTP 语义留在 router。
查询纪律(与 analytics.metrics 同):**禁止 asyncio.gather**——单
AsyncSession 不能并发执行查询,全部顺序 await。重端点不做 N+1:
period_metrics 每窗一次调用全员;员工循环(评分/异常)是纯函数。
"""
from __future__ import annotations
import copy
import statistics
from bisect import bisect_right
import sqlalchemy as sa
from sqlalchemy.ext.asyncio import AsyncSession
from app.analytics.lifecycle import classify
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
MsgDailyStats,
PhoneCall,
Staff,
)
from app.management.anomalies import detect_anomalies
from app.management.employee_metrics import (
PeriodMetrics,
baseline_window,
day_range,
percentile,
period_metrics,
prev_window,
team_medians,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
from app.management.scoring import derive_metrics, score_employee
from app.storage.employee_metrics import METRIC_VERSION
from app.storage.staff_summary import account_map
from app.storage.summary import day_bounds_ms
# 异常权重(anomaly_severity 排序/汇总):critical=2 / warning=1
SEVERITY_WEIGHT = {"critical": 2, "warning": 1}
# 派生指标中需对「员工派生值」另取中位的键(scoring.team 键=派生指标名;
# 其余如 fr_p90_ms/active_friends/call_count 直接用 team_medians 输出)
_DERIVED_MEDIAN_KEYS = ("reply_2h_rate", "reply_24h_rate", "active_share")
async def load_score_config(session: AsyncSession) -> tuple[dict, str]:
"""激活评分配置(DB)→ (config_json, "db");无 active 行 →
(DEFAULT_SCORE_CONFIG 深拷贝, "default")——未跑种子的环境也能出分。"""
row = (await session.execute(
sa.select(EmployeeScoreConfig)
.where(EmployeeScoreConfig.status == "active")
.order_by(EmployeeScoreConfig.id.desc())
.limit(1)
)).scalar_one_or_none()
if row is not None:
return row.config_json, "db"
return copy.deepcopy(DEFAULT_SCORE_CONFIG), "default"
def team_derived_medians(metrics: dict[str, PeriodMetrics]) -> dict[str, float | None]:
"""评分用团队中位:PeriodMetrics 字段中位 + 派生率/占比的员工级中位
(scoring 要求 team 键=派生指标名,见其模块 docstring)。"""
team = dict(team_medians(metrics))
for key in _DERIVED_MEDIAN_KEYS:
vals = [
v for v in (derive_metrics(pm).get(key) for pm in metrics.values())
if v is not None
]
team[key] = statistics.median(vals) if vals else None
return team
def score_with_guards(
config: dict,
cur: PeriodMetrics,
baseline: PeriodMetrics | None = None,
team: dict[str, float] | None = None,
prev: PeriodMetrics | None = None,
) -> dict:
"""score_employee + min_days 接线(B2 评审遗留):当前窗天数 <
config.sample_guards.min_days → score_status 覆盖 insufficient_sample,
total/明细保留展示(按天稀疏的数据不参与考核但可查看)。"""
score = score_employee(config, cur, baseline=baseline, team=team, prev=prev)
min_days = config.get("sample_guards", {}).get("min_days", 5)
if cur.days_with_data < min_days:
score["score_status"] = "insufficient_sample"
return score
async def assemble_employees(
session: AsyncSession,
config: dict,
start_day: int,
end_day: int,
channel: str | None,
*,
with_prev: bool = True,
) -> dict[str, dict]:
"""全员组装:staff → 当前/基线(/前)窗 period_metrics → 逐员工
score_with_guards + detect_anomalies(纯函数循环)。
dashboard 传 with_prev=False(只需评分+异常,省一次前窗聚合);
employees/detail 需要 prev/团队中位时传 True。返回
{uid: {name, is_on_job, cur, prev, baseline, score, anomalies,
anomaly_count, anomaly_weight}}——过滤/排序/分页由调用方做。
"""
staff_rows = (await session.execute(
sa.select(Staff.user_id, Staff.name, Staff.is_on_job)
)).all()
staff = {uid: (name, onjob) for uid, name, onjob in staff_rows if uid}
if not staff:
return {}
n_days = len(day_range(start_day, end_day))
cur = await period_metrics(session, start_day, end_day, channel)
bs, be = baseline_window(start_day)
base = await period_metrics(session, bs, be, channel)
prev: dict[str, PeriodMetrics] | None = None
team: dict[str, float | None] | None = None
if with_prev:
ps, pe = prev_window(start_day, end_day)
prev = await period_metrics(session, ps, pe, channel)
team = team_derived_medians(cur)
out: dict[str, dict] = {}
for uid, (name, onjob) in staff.items():
c = cur.get(uid)
if c is None: # 防御:period_metrics 恒含全员,此处不该走到
continue
b = base.get(uid)
p = prev.get(uid) if prev is not None else None
score = score_with_guards(config, c, baseline=b, team=team, prev=p)
anomalies = detect_anomalies(uid, c, b, n_days=n_days, config=config)
out[uid] = {
"name": name,
"is_on_job": onjob,
"cur": c,
"prev": p,
"baseline": b,
"score": score,
"anomalies": anomalies,
"anomaly_count": len(anomalies),
"anomaly_weight": sum(
SEVERITY_WEIGHT.get(a["severity"], 0) for a in anomalies
),
}
return out
def _code_to_source(config: dict) -> dict[str, str]:
"""配置指标 code → 源指标名(spec.metric,无则 code 本身)。
comparisons 键域统一为源指标名(与 derive_metrics / sections 端点一致,
冻结批次C契约):active_friends_band→active_friends、call_band→call_count,
其余 code 本身即源名。"""
out: dict[str, str] = {}
for dim in config.get("dimensions", {}).values():
for code, spec in dim.get("metrics", {}).items():
out[code] = spec.get("metric", code)
return out
def build_employee_row(uid: str, data: dict, config: dict) -> dict:
"""employees 列表行:metrics 全字段 + comparisons 四值(自评分
metrics_detail 透传,键映射为源指标名)+ score 摘要 + 异常计数/权重。"""
score = data["score"]
code_src = _code_to_source(config)
return {
"user_id": uid,
"name": data["name"],
"is_on_job": data["is_on_job"],
"metrics": data["cur"]._asdict(),
"comparisons": {
code_src.get(code, code): {
"current": d["value"],
"prev": d["prev"],
"baseline": d["baseline"],
"median": d["median"],
}
for code, d in score["metrics_detail"].items()
},
"score": {
"total_score": score["total_score"],
"grade": score["grade"],
"score_status": score["score_status"],
"trial": score["trial"],
"dimensions": {
key: {"score": dim["score"], "status": dim["status"]}
for key, dim in score["dimensions"].items()
},
},
"anomaly_count": data["anomaly_count"],
"anomaly_weight": data["anomaly_weight"],
}
def derived_comparisons(
cur: PeriodMetrics,
prev: PeriodMetrics | None,
baseline: PeriodMetrics | None,
team: dict[str, float | None] | None,
) -> dict[str, dict]:
"""四对比(current/prev/baseline/median),派生口径=scoring.derive_metrics
——评分、异常、展示三方同一派生式。"""
cv = derive_metrics(cur)
pv = derive_metrics(prev) if prev is not None else {}
bv = derive_metrics(baseline) if baseline is not None else {}
return {
key: {
"current": cv.get(key),
"prev": pv.get(key),
"baseline": bv.get(key),
"median": team.get(key) if team else None,
}
for key in cv
}
async def wide_daily_rows(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
user_id: str | None = None,
) -> list[dict]:
"""宽表逐日聚合(加性列跨渠道求和;user_id=None 为全员合计)。
first_reply_p50/p90_ms 是 (day,channel,user) 行内单值不可求和:
单员工视图(channel=None 双渠道同日)取当日 cust_threads 较大渠道的
值(当日主力渠道,并列取 wechat);全员视图不取用这两列(分位由
service_daily_trend 从 fact 精确算)。
"""
conds = [
EmployeeMetricDaily.day.between(start_day, end_day),
EmployeeMetricDaily.metric_version == METRIC_VERSION,
]
if channel is not None:
conds.append(EmployeeMetricDaily.channel == channel)
if user_id is not None:
conds.append(EmployeeMetricDaily.user_id == user_id)
rows = (await session.execute(
sa.select(
EmployeeMetricDaily.day.label("day"),
EmployeeMetricDaily.channel.label("ch"),
sa.func.sum(EmployeeMetricDaily.msg_sent).label("sent"),
sa.func.sum(EmployeeMetricDaily.msg_recv).label("recv"),
sa.func.sum(EmployeeMetricDaily.active_friends).label("af"),
sa.func.sum(EmployeeMetricDaily.active_rooms).label("ar"),
sa.func.sum(EmployeeMetricDaily.cust_threads).label("thr"),
sa.func.sum(EmployeeMetricDaily.threads_answered).label("ans"),
sa.func.sum(EmployeeMetricDaily.fr_sum_ms).label("frs"),
sa.func.sum(EmployeeMetricDaily.new_customer_count).label("nw"),
sa.func.sum(EmployeeMetricDaily.cooling_customer_count).label("cool"),
sa.func.sum(EmployeeMetricDaily.dormant_customer_count).label("dorm"),
sa.func.sum(EmployeeMetricDaily.reactivated_customer_count).label("react"),
sa.func.max(EmployeeMetricDaily.first_reply_p50_ms).label("p50"),
sa.func.max(EmployeeMetricDaily.first_reply_p90_ms).label("p90"),
).where(*conds).group_by(EmployeeMetricDaily.day, EmployeeMetricDaily.channel)
)).all()
per_day: dict[int, dict] = {}
def _blank(day: int) -> dict:
return {
"day": day, "msg_sent": 0, "msg_recv": 0, "active_friends": 0,
"active_rooms": 0, "cust_threads": 0, "threads_answered": 0,
"fr_sum_ms": 0, "new_customer_count": 0,
"cooling_customer_count": 0, "dormant_customer_count": 0,
"reactivated_customer_count": 0,
"first_reply_p50_ms": None, "first_reply_p90_ms": None,
"_pick": (-1, -1), # 主力渠道追踪 (cust_threads, wechat 加分)
}
for (day, ch, sent, recv, af, ar, thr, ans, frs, nw, cool, dorm,
react, p50, p90) in rows:
d = per_day.setdefault(day, _blank(day))
d["msg_sent"] += int(sent or 0)
d["msg_recv"] += int(recv or 0)
d["active_friends"] += int(af or 0)
d["active_rooms"] += int(ar or 0)
d["cust_threads"] += int(thr or 0)
d["threads_answered"] += int(ans or 0)
d["fr_sum_ms"] += int(frs or 0)
d["new_customer_count"] += int(nw or 0)
d["cooling_customer_count"] += int(cool or 0)
d["dormant_customer_count"] += int(dorm or 0)
d["reactivated_customer_count"] += int(react or 0)
if user_id is not None:
pick = (int(thr or 0), 1 if ch == "wechat" else 0)
if pick > d["_pick"]:
d["_pick"] = pick
d["first_reply_p50_ms"] = p50
d["first_reply_p90_ms"] = p90
out = []
for day in sorted(per_day):
d = per_day[day]
d.pop("_pick", None)
out.append(d)
return out
async def call_daily_rows(
session: AsyncSession,
start_day: int,
end_day: int,
user_id: str | None = None,
) -> list[dict]:
"""通话小表逐日聚合(恒全渠道——表无渠道维);user_id=None 为全员。"""
conds = [EmployeeCallDaily.day.between(start_day, end_day)]
if user_id is not None:
conds.append(EmployeeCallDaily.user_id == user_id)
rows = (await session.execute(
sa.select(
EmployeeCallDaily.day,
sa.func.sum(EmployeeCallDaily.call_count),
sa.func.sum(EmployeeCallDaily.call_seconds),
sa.func.sum(EmployeeCallDaily.answered_call_count),
sa.func.sum(EmployeeCallDaily.call_followup_count),
).where(*conds).group_by(EmployeeCallDaily.day)
)).all()
return [
{
"day": day, "call_count": int(cc or 0),
"call_seconds": int(cs or 0),
"answered_call_count": int(ca or 0),
"call_followup_count": int(cf or 0),
}
for day, cc, cs, ca, cf in rows
]
async def service_daily_trend(
session: AsyncSession,
start_day: int,
end_day: int,
channel: str | None,
threads_by_day: dict[int, int],
) -> list[dict]:
"""服务趋势(dashboard 全团队):fact 逐日 (day, fr_ms) 行拉回 Python
最近邻秩分位(跨员工合并样本——团队级分位);reply_2h_rate = fact
当日 ≤2h 计数 / 宽表当日 cust_threads 合计(分母 0 → None)。
输出全窗口逐日(无数据日 p50/p90 为 None),threads_by_day 来自
wide_daily_rows 的全员 cust_threads 列。
"""
conds = [FirstReplyFact.day.between(start_day, end_day)]
if channel is not None:
conds.append(FirstReplyFact.channel == channel)
rows = (await session.execute(
sa.select(FirstReplyFact.day, FirstReplyFact.fr_ms).where(*conds)
)).all()
by_day: dict[int, list[int]] = {}
for day, fr in rows:
by_day.setdefault(day, []).append(int(fr))
out = []
for day in day_range(start_day, end_day):
frs = by_day.get(day, [])
cnt_2h = sum(1 for f in frs if f <= 7_200_000)
denom = threads_by_day.get(day, 0)
out.append({
"day": day,
"fr_p50_ms": percentile(frs, 1, 2),
"fr_p90_ms": percentile(frs, 9, 10),
"reply_2h_rate": (cnt_2h / denom) if denom else None,
})
return out
async def user_owner_accounts(
session: AsyncSession, user_id: str, channel: str | None
) -> list[str]:
"""员工在所选渠道下的 owner 账号(channel=None → 两渠道合并)。"""
chs = ("wechat", "whatsapp") if channel is None else (channel,)
out: list[str] = []
for ch in chs:
amap = await account_map(session, ch)
out.extend(owner for owner, uid in amap.items() if uid == user_id)
return out
async def lifecycle_customers(
session: AsyncSession,
owner_accounts: list[str],
as_of_day: int,
want_state: str,
) -> list[dict]:
"""drilldown cooling/dormant:该员工账号归属客户(delete_flag 过滤)+
msg_daily_stats friend 行全历史 → lifecycle.classify(as_of=窗口末日),
返回状态匹配行 {friend_id, channel, nickname, last_day, state},按 last_day asc
(最陈旧在前)、friend_id asc 排序。
历史按 (channel, peer) 键控、跨 owner 合并——与批次A _lifecycle_daily
的 peer 键控口径一致(归属由 Customer.owner_account_id 决定,互动史
不随归属走:共享/换归属客户的全部历史都算,否则 KPI 计了而名单丢人)。
hist 为 ≤as_of 的历史消息数(时点语义,as_of 之后的消息不虚增沉睡门槛)。"""
custs = (await session.execute(
sa.select(Customer).where(
sa.or_(Customer.delete_flag.is_(None), Customer.delete_flag == 0),
Customer.owner_account_id.in_(owner_accounts),
)
)).scalars().all()
if not custs:
return []
ids = [c.friend_id for c in custs]
rows = (await session.execute(
sa.select(
MsgDailyStats.channel,
MsgDailyStats.peer_id,
MsgDailyStats.day,
MsgDailyStats.send_cnt,
MsgDailyStats.recv_cnt,
).where(
MsgDailyStats.peer_kind == "friend",
MsgDailyStats.peer_id.in_(ids),
)
)).all()
hist: dict[tuple[str, str], dict[int, int]] = {}
for ch, peer, day, snd, rcv in rows:
dm = hist.setdefault((ch, peer), {})
dm[day] = dm.get(day, 0) + int(snd or 0) + int(rcv or 0)
matches: list[dict] = []
for c in custs:
dm = hist.get((c.channel, c.friend_id))
if not dm:
continue
days_list = sorted(dm)
idx = bisect_right(days_list, as_of_day) - 1
if idx < 0:
continue # 窗口末日之前从未互动
last = days_list[idx]
prev_d = days_list[idx - 1] if idx > 0 else None
hist_msgs = sum(dm[d] for d in days_list[: idx + 1])
state = classify(days_list[0], last, prev_d, as_of_day, hist_msgs)
if state == want_state:
matches.append({
"friend_id": c.friend_id,
"channel": c.channel,
"nickname": c.friend_nickname or c.friend_remark or c.friend_id,
"last_day": last,
"state": state,
})
matches.sort(key=lambda m: (m["last_day"], m["friend_id"]))
return matches
async def fr_fact_rows(
session: AsyncSession,
owner_accounts: list[str],
channel: str | None,
start_day: int,
end_day: int,
*,
page: int,
page_size: int,
) -> tuple[int, list[dict]]:
"""drilldown 首响分位:该员工账号窗内 fact 逐串证据行,fr_ms desc
(同值按日/串末时刻 desc 决胜)分页。chat_hint 给出会话定位参数
(owner+talker 直达聊天记录)。返回 (total, items)。"""
conds = [
FirstReplyFact.day.between(start_day, end_day),
FirstReplyFact.owner_account_id.in_(owner_accounts),
]
if channel is not None:
conds.append(FirstReplyFact.channel == channel)
total = (await session.execute(
sa.select(sa.func.count()).select_from(FirstReplyFact).where(*conds)
)).scalar_one()
rows = (await session.execute(
sa.select(FirstReplyFact).where(*conds)
.order_by(FirstReplyFact.fr_ms.desc(), FirstReplyFact.day.desc(),
FirstReplyFact.thread_end_ts.desc())
.offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [{
"day": f.day,
"channel": f.channel,
"owner_account_id": f.owner_account_id,
"peer_id": f.peer_id,
"thread_end_ts": f.thread_end_ts,
"reply_ts": f.reply_ts,
"fr_ms": f.fr_ms,
"chat_hint": {"owner_account_id": f.owner_account_id,
"talker_id": f.peer_id},
} for f in rows]
return int(total), items
async def user_calls_page(
session: AsyncSession,
user_id: str,
start_day: int,
end_day: int,
*,
page: int,
page_size: int,
) -> tuple[int, list[dict]]:
"""drilldown 通话跟进:该员工窗内通话行(start_call_time desc,
call_id desc 决胜)分页。返回 (total, items)。"""
lo, _ = day_bounds_ms(start_day)
_, hi = day_bounds_ms(end_day) # 末日次日 00:00(左闭右开上界)
conds = [
PhoneCall.user_id == user_id,
PhoneCall.start_call_time.is_not(None),
PhoneCall.start_call_time >= lo,
PhoneCall.start_call_time < hi,
]
total = (await session.execute(
sa.select(sa.func.count()).select_from(PhoneCall).where(*conds)
)).scalar_one()
rows = (await session.execute(
sa.select(PhoneCall).where(*conds)
.order_by(PhoneCall.start_call_time.desc(), PhoneCall.call_id.desc())
.offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
items = [{
"call_id": c.call_id,
"channel": c.channel,
"call_number": c.call_number,
"start_call_time": c.start_call_time,
"call_duration": c.call_duration,
} for c in rows]
return int(total), items
@@ -88,12 +88,13 @@ def baseline_window(start: int) -> tuple[int, int]:
return _to_key(bs), _to_key(be)
def _percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
def percentile(vals: list[int], q_num: int, q_den: int) -> int | None:
"""最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
整数算术 rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
(与 math.ceil(q·n) 等价,与批次A _percentile 同口径);空列表→None
(与 0 严格区分)。
公有供同包 api_assembly(B4 服务趋势)复用;整数算术
rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
(与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
后者为不同层单独保留);空列表→None(与 0 严格区分)。
"""
if not vals:
return None
@@ -246,8 +247,8 @@ async def period_metrics(
if uid is not None and uid in target:
fr_by_user.setdefault(uid, []).append(int(fr))
for uid, frs in fr_by_user.items():
acc[uid]["fr_p50_ms"] = _percentile(frs, 1, 2)
acc[uid]["fr_p90_ms"] = _percentile(frs, 9, 10)
acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
acc[uid]["fr_p90_ms"] = percentile(frs, 9, 10)
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000)
acc[uid]["reply_24h_count"] = sum(1 for fr in frs if fr <= 86_400_000)
@@ -108,6 +108,8 @@ def validate_config(config: dict) -> list[str]:
- grade_bounds 须含 A/B/C/D 且严格降序
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
同名 code 会相互覆盖(后写胜出)
- 源指标名跨维度不得重复:employees 行 comparisons 按源名建键
(api_assembly._code_to_source),两个 code 指向同一源名会静默覆盖
"""
problems: list[str] = []
dims = config.get("dimensions", {})
@@ -125,6 +127,7 @@ def validate_config(config: dict) -> list[str]:
f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
)
seen_codes: dict[str, str] = {}
seen_sources: dict[str, str] = {}
for key, dim in dims.items():
if dim.get("status") == "pending_data":
continue
@@ -133,6 +136,13 @@ def validate_config(config: dict) -> list[str]:
problems.append(f"指标 code 重复定义于多维度: {code}")
seen_codes[code] = key
src = spec.get("metric", code)
if src in seen_sources:
problems.append(
f"{key}.{code} 与 {seen_sources[src]} 指向同一源指标 {src}"
f"(comparisons 键域将相互覆盖)"
)
else:
seen_sources[src] = f"{key}.{code}"
if src not in METRIC_DEFINITIONS:
problems.append(f"{key}.{code} 引用未定义指标 {src}")
mtype = spec.get("type")
+79 -41
View File
@@ -602,50 +602,72 @@ class SyncScheduler:
if not bash:
logger.error("page sync 'full': bash not found on PATH")
return
commands: list[list[str]] = [[bash, "scripts/full_sync_all.sh"]]
commands = [([bash, "scripts/full_sync_all.sh"], None)] # (args, timeout)——full 不设超时(全量本身 10-20h)
elif kind == "messages":
# 先刷客户列表(最近沟通时间是云客列表的实时排序键,friends 快照
# 滞后会让本地排序与云客第一页完全不同,2026-08-20),再抓全部四类
# 消息增量(2026-08-21 全接入:水位增量后单对话 1 页,可承受);
# 末尾追加员工朋友圈浅扫(2026-08-25,max-pages 3 即每员工最多
# 3 页,新帖通常落第一页;全量翻页由每日 full 走 full_sync_all.sh)
# 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
# ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
# 更新时间/最新消息时间」从此最多滞后 4h(此前只在每日
# full 刷,长任务挤掉 full 就停更);
# ② 消息实体只扫**近 24h 活跃**对话(--active-window-hours;
# 从未扫过的不受限)——全对话遍历(WA 9,357 个×~3.5s≈9h)
# 曾把序列拖到 15h+,超过 4h 间隔→与 02:00 full 背靠背
# 互相挤死;陈旧对话无新消息,每日 full 兜底;
# ③ 每命令超时保险丝(超时 kill 下轮补,增量幂等)。
# friends 仍先刷(最近沟通时间是排序键+活跃窗口的判定源)。
msg_entities = [
("accounts", None, 900),
("wa-accounts", None, 900),
("friends", None, 2400),
("wa-friends", None, 2400),
("messages", 24, 4800),
("wa-messages", 24, 4800),
("group-messages", 24, 4800),
("wa-group-messages", 24, 2400),
("moments", None, 2400),
]
commands = [
[sys.executable, "scripts/scrape_via_page.py", "--entity", "friends"],
[sys.executable, "scripts/scrape_via_page.py", "--entity", "wa-friends"],
] + [
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
e,
"--max-conversations",
"99999",
]
for e in ("messages", "wa-messages", "group-messages", "wa-group-messages")
] + [
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
"moments",
"--max-pages",
"3",
]
(
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
entity,
]
+ (
[
"--max-conversations",
"99999",
"--active-window-hours",
str(window),
]
if window
else (
["--max-pages", "3"]
if entity == "moments"
else []
)
),
timeout,
)
for entity, window, timeout in msg_entities
]
elif kind == "full-history":
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
commands = [
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
e,
"--max-conversations",
"99999",
"--full-history",
]
(
[
sys.executable,
"scripts/scrape_via_page.py",
"--entity",
e,
"--max-conversations",
"99999",
"--full-history",
],
None, # 每周对齐不设超时
)
for e in entities
]
else:
@@ -653,8 +675,8 @@ class SyncScheduler:
return
rc = 0
for args in commands:
rc = await self._page_subprocess(args) or rc
for args, timeout in commands:
rc = await self._page_subprocess(args, timeout=timeout) or rc
if rc != 0:
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
break
@@ -703,8 +725,13 @@ class SyncScheduler:
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed")
async def _page_subprocess(self, args: list[str]) -> int:
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。"""
async def _page_subprocess(self, args: list[str], *, timeout: float | None = None) -> int:
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。
``timeout``(秒,2026-09-10 调度根治保险丝):超时 kill 子进程返回
非零——上游 break 跳过本序列剩余命令,增量幂等下轮自动补,防止
单命令拖死整个调度相位(4h 序列曾因此 15h+ 与 full 互相挤死)。
"""
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
settings = get_settings()
if settings.db_url and "DB_URL" not in env:
@@ -714,7 +741,18 @@ class SyncScheduler:
cwd=str(REPO_ROOT),
env=env,
)
return await proc.wait()
if timeout is None:
return await proc.wait()
try:
return await asyncio.wait_for(proc.wait(), timeout)
except asyncio.TimeoutError:
logger.error(
"page sync subprocess timeout after %.0fs (killed): %s",
timeout, " ".join(args[-6:]),
)
proc.kill()
await proc.wait()
return 124 # 超时语义(同 shell timeout 惯例)
async def _safe_run(
self, entity: str, *, log: bool = True
+68 -24
View File
@@ -514,7 +514,9 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
await sleep(1)
async def _discover_conversations(channel: str, voice_channel: str) -> list[tuple[str, str]]:
async def _discover_conversations(
channel: str, voice_channel: str, active_since_ms: int | None = None
) -> list[tuple[str, str]]:
"""Conversation-pair discovery for the message scrapers (wechat + WA).
Sources (union, dedup):
@@ -527,6 +529,11 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
Order: pairs with no messages yet FIRST (new coverage survives an
interruption), then the rest by customer last_chat_time DESC.
``active_since_ms``(2026-09-10 调度根治):4h 增量模式传「now-24h」——
**已扫过**的对话仅保留 last_chat_time 在窗内的(活跃的),陈旧对话本轮
跳过(没有新消息,每日全量兜底);**从未扫过**的(fresh)无论新旧全保
(新覆盖优先原则不变)。None=不过滤(全量模式)。
"""
import json as _json
@@ -536,14 +543,14 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
async with async_engine.connect() as conn:
cust = (
await conn.execute(sa_text(
"SELECT owner_account_id, friend_id FROM customer "
"SELECT owner_account_id, friend_id, last_chat_time FROM customer "
"WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
"ORDER BY last_chat_time DESC"
), {"ch": channel})
).fetchall()
voice = (
await conn.execute(sa_text(
"SELECT raw_json FROM phone_call WHERE channel=:vc"
"SELECT raw_json, start_call_time FROM phone_call WHERE channel=:vc"
), {"vc": voice_channel})
).fetchall()
with_msgs = (
@@ -558,16 +565,27 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
fresh: list[tuple[str, str]] = []
known: list[tuple[str, str]] = []
def _add(key: tuple[str, str]) -> None:
def _active(last_ts: int | None) -> bool:
"""窗口判定:未开窗口恒真;有窗时 last_ts 缺失(从未聊过)也保(fresh)。"""
if active_since_ms is None:
return True
return last_ts is None or last_ts >= active_since_ms
def _add(key: tuple[str, str], last_ts: int | None) -> None:
if key in seen:
return
seen.add(key)
(fresh if key not in have else known).append(key)
if key not in have:
fresh.append(key) # 从未扫过:新覆盖优先,不受窗口限制
elif _active(last_ts):
known.append(key)
# else: 已扫过且陈旧 → 本轮跳过
for owner, friend in cust:
_add((owner, friend))
for owner, friend, last_ts in cust:
_add((owner, friend), last_ts)
voice_only = 0
for (rj,) in voice:
cust_keys = {(r[0], r[1]) for r in cust}
for rj, call_ts in voice:
try:
d = _json.loads(rj)
except (TypeError, ValueError):
@@ -576,13 +594,15 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
if not owner or not talker or str(talker).endswith("@chatroom"):
continue
before = len(seen)
_add((str(owner), str(talker)))
if len(seen) > before and (str(owner), str(talker)) not in cust:
_add((str(owner), str(talker)), call_ts)
if len(seen) > before and (str(owner), str(talker)) not in cust_keys:
voice_only += 1
skipped = len(cust) + voice_only - len(fresh) - len(known)
logger.info(
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, "
f"{len(fresh)} unscanned (first) / {len(known)} already have messages"
f"{len(fresh)} unscanned (first) / {len(known)} active in window"
+ (f" / {skipped} stale skipped" if active_since_ms is not None else f" / {len(known)} already have messages")
)
return fresh + known
@@ -635,7 +655,7 @@ def _page_min_ts(data: dict) -> int:
return min(ts_list) if ts_list else 0
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
"""Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
@@ -648,7 +668,13 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
from app.db.base import async_session_maker
from app.storage.repos import upsert_messages
conversations = await _discover_conversations("wechat", "voice_call")
conversations = await _discover_conversations(
"wechat", "voice_call",
active_since_ms=(
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
),
)
watermarks = await _conversation_watermarks("wechat")
cust_activity = await _customer_activity("wechat")
@@ -853,7 +879,7 @@ def _normalize_group_message(row: dict, owner_id: str, room_id: str) -> dict | N
}
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
"""Scrape WeChat GROUP chat messages via browser fetch POST /wechatweb/msg/chats.
Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
@@ -868,12 +894,17 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
from app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text
active_since_ms = (
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
)
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group "
"WHERE channel='wechat' AND owner_account_id IS NOT NULL "
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t) "
"ORDER BY last_chat_time DESC"
))
), {"t": active_since_ms})
groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks()
@@ -2143,7 +2174,7 @@ async def scrape_voice_clips(cookie_str: str, max_employees: int = 200, page_siz
# ---------------------------------------------------------------------------
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False):
async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full_history: bool = False, active_window_hours: int | None = None):
"""Scrape WhatsApp chat records via browser fetch POST
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
@@ -2158,7 +2189,13 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
from app.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
from app.storage.repos import upsert_messages
conversations = await _discover_conversations("whatsapp", "wa_voice_call")
conversations = await _discover_conversations(
"whatsapp", "wa_voice_call",
active_since_ms=(
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
),
)
watermarks = await _conversation_watermarks("whatsapp")
cust_activity = await _customer_activity("whatsapp")
@@ -2281,7 +2318,7 @@ def _normalize_wa_group_message(record: dict) -> dict:
return rec
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False):
async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_history: bool = False, active_window_hours: int | None = None):
"""WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
@@ -2295,11 +2332,16 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
from app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text
active_since_ms = (
int((__import__("time").time() - active_window_hours * 3600) * 1000)
if active_window_hours else None
)
async with async_engine.connect() as conn:
result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group "
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL"
))
"WHERE channel='whatsapp' AND owner_account_id IS NOT NULL "
"AND (:t IS NULL OR last_chat_time IS NULL OR last_chat_time >= :t)"
), {"t": active_since_ms})
groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks()
@@ -2473,6 +2515,8 @@ async def main():
parser.add_argument("--max-pages", type=int, default=200, help="列表翻页上限 (accounts/wa-accounts/wa-friends/voice-calls/moments)")
parser.add_argument("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)")
parser.add_argument("--active-window-hours", type=int, default=None,
help="只扫近 N 小时活跃的对话(4h 增量模式传 24;未扫过的对话不受限;默认不过滤=全量发现)")
parser.add_argument("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
args = parser.parse_args()
@@ -2496,9 +2540,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
elif args.entity == "groups":
await scrape_groups(cookie, args.max_employees)
elif args.entity == "messages":
await scrape_messages(cookie, args.max_conversations, args.full_history)
await scrape_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "group-messages":
await scrape_group_messages(cookie, args.max_conversations, args.full_history)
await scrape_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "accounts":
await scrape_accounts(cookie, args.max_pages)
elif args.entity == "wa-accounts":
@@ -2514,9 +2558,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
elif args.entity == "voice-clips":
await scrape_voice_clips(cookie, args.max_employees)
elif args.entity == "wa-messages":
await scrape_wa_messages(cookie, args.max_conversations, args.full_history)
await scrape_wa_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "wa-group-messages":
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history)
await scrape_wa_group_messages(cookie, args.max_conversations, args.full_history, args.active_window_hours)
elif args.entity == "moments":
await scrape_moments(cookie, args.max_pages)
elif args.entity == "media":
+710
View File
@@ -0,0 +1,710 @@
"""管理 API 六只读端点判别测试(批次B Task B4)。
覆盖:
- 参数校验:422(缺 start / 跨度>366 / 日历非法 / drilldown 白名单外 /
page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/
metric_version);
- employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时
whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample;
- detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline;
- sections:五段各有 trend 数组与四对比;
- drilldown:p90 分页与 fr_ms desc/unreplied 按 owner 过滤/cooling-dormant
生命周期判定(已删客户剔除)/call_followup 列表+期聚合摘要;
- metric-definitions:trial 标记/口径说明/config_source 回退。
"""
import copy
import datetime as dt
from zoneinfo import ZoneInfo
from app.db.models import (
Customer,
EmployeeCallDaily,
EmployeeMetricDaily,
EmployeeScoreConfig,
FirstReplyFact,
Message,
MsgDailyStats,
PhoneCall,
Staff,
WechatAccount,
WhatsappAccount,
)
from app.management.metric_definitions import DEFAULT_SCORE_CONFIG
_SH = ZoneInfo("Asia/Shanghai")
START, END = 20260901, 20260905 # 当前窗 5 天(min_days=5 恰好够)
def _ts(*args) -> int:
return int(dt.datetime(*args, tzinfo=_SH).timestamp() * 1000)
def _mday(day, *, channel="wechat", user_id="u1", metric_version="v1", **kw):
cols = {
"msg_sent": 0, "msg_recv": 0, "active_friends": 0, "active_rooms": 0,
"cust_threads": 0, "threads_answered": 0, "fr_sum_ms": 0,
"new_customer_count": 0, "cooling_customer_count": 0,
"dormant_customer_count": 0, "reactivated_customer_count": 0,
"data_complete": True,
}
cols.update(kw)
return EmployeeMetricDaily(
day=day, channel=channel, user_id=user_id,
metric_version=metric_version, **cols
)
def _fact(fr_ms, *, channel="wechat", owner="wx1", peer="p1", day=20260901, i=0):
end = _ts(day // 10000, day // 100 % 100, day % 100, 10, 0) + i * 60_000
return FirstReplyFact(
channel=channel, owner_account_id=owner, peer_id=peer,
thread_end_ts=end, reply_ts=end + fr_ms,
reply_msg_id=f"{owner}-{peer}-m{i}", day=day, fr_ms=fr_ms,
)
async def _seed_common(s):
"""三员工全量种子:u1(微信,2 warning)/u2(双渠道,1 critical+1 warning)/
u3(仅 WhatsApp,无异常)——支撑 dashboard/employees/detail/sections/
drilldown 全部判据。"""
for uid, name, onjob in (("u1", "张三", True), ("u2", "李四", True),
("u3", "王五", False)):
s.add(Staff(user_id=uid, name=name, is_on_job=onjob))
s.add(WechatAccount(wx_id="wx1", user_id="u1", nick_name="张三的微信",
user_name="张三"))
s.add(WechatAccount(wx_id="wx2", user_id="u2"))
s.add(WhatsappAccount(uid="wa2", user_id="u2"))
s.add(WhatsappAccount(uid="wa3", user_id="u3"))
# ---- 当前窗宽表(0901-0905)----
for d in range(1, 6):
s.add(_mday(20260900 + d, user_id="u1", msg_sent=1, cust_threads=2,
threads_answered=2, fr_sum_ms=240_000, active_friends=3,
active_rooms=1, cooling_customer_count=2,
dormant_customer_count=1,
first_reply_p50_ms=110_000, first_reply_p90_ms=220_000))
s.add(_mday(20260900 + d, user_id="u2", msg_sent=10, msg_recv=10,
cust_threads=2, threads_answered=2, fr_sum_ms=1_200_000,
active_friends=4, cooling_customer_count=1,
reactivated_customer_count=1))
s.add(_mday(20260900 + d, channel="whatsapp", user_id="u3",
msg_sent=5, msg_recv=5, cust_threads=1, active_friends=1))
# ---- 基线窗宽表(0827-0831,u1 高日均触发 activity_drop)----
for d in range(27, 32):
s.add(_mday(20260800 + d, user_id="u1", msg_sent=14, msg_recv=14))
s.add(_mday(20260800 + d, user_id="u2", msg_sent=5, msg_recv=5))
# ---- fact:当前窗(u1 120s warning / u2 600s critical)+ 基线 60s ----
s.add(_fact(120_000, peer="pA", day=20260901, i=0))
s.add(_fact(120_000, peer="pB", day=20260902, i=1))
s.add(_fact(600_000, owner="wx2", peer="pC", day=20260901, i=2))
s.add(_fact(600_000, owner="wx2", peer="pD", day=20260902, i=3))
for owner, peer, day, i in (("wx1", "pA", 20260827, 10),
("wx1", "pB", 20260828, 11),
("wx2", "pC", 20260827, 12),
("wx2", "pD", 20260828, 13)):
s.add(_fact(60_000, owner=owner, peer=peer, day=day, i=i))
# ---- msg_daily_stats:活跃客户 distinct + data_as_of + 未回候选 ----
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p1", day=20260901,
send_cnt=0, recv_cnt=1, last_ts=_ts(2026, 9, 1, 10, 0),
last_is_send=False))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="p2", day=20260902,
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 2, 10, 0),
last_is_send=True))
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="p3", day=20260901,
send_cnt=2, recv_cnt=0, last_ts=_ts(2026, 9, 1, 11, 0),
last_is_send=True))
s.add(MsgDailyStats(channel="whatsapp", owner_account_id="wa3",
peer_kind="friend", peer_id="p4", day=20260903,
send_cnt=1, recv_cnt=0, last_ts=_ts(2026, 9, 3, 9, 0),
last_is_send=True))
# drilldown cooling/dormant 客户互动史(全历史,窗外):
# f1 末次 0801(gap 35, hist 12)→ dormant;f2 末次 0825(gap 11)→ cooling
for day, n in ((20260701, 6), (20260801, 6)):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="f1", day=day,
send_cnt=n, recv_cnt=0,
last_ts=_ts(day // 10000, day // 100 % 100,
day % 100, 10, 0),
last_is_send=True))
for day in (20260801, 20260825):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="f2", day=day,
send_cnt=2, recv_cnt=2,
last_ts=_ts(2026, 8, day % 100, 11, 0),
last_is_send=True))
# ---- 客户档案(f9 已删,须被 drilldown 剔除)----
s.add(Customer(friend_id="f1", channel="wechat", owner_account_id="wx1",
friend_nickname="客户一", user_id="u1", delete_flag=0))
s.add(Customer(friend_id="f2", channel="wechat", owner_account_id="wx1",
friend_nickname="客户二", user_id="u1", delete_flag=0))
s.add(Customer(friend_id="f9", channel="wechat", owner_account_id="wx1",
friend_nickname="已删客户", user_id="u1", delete_flag=1))
# ---- 未回候选 p1 的尾部消息(unreplied 决赛圈点查)----
s.add(Message(msg_id="m-p1", channel="wechat", owner_account_id="wx1",
talker_id="p1", is_send=False, type="text", text="在吗",
timestamp=_ts(2026, 9, 1, 10, 0)))
# ---- 通话 ----
s.add(EmployeeCallDaily(day=20260901, user_id="u1", call_count=2,
call_seconds=100, answered_call_count=1,
call_followup_count=1))
s.add(EmployeeCallDaily(day=20260902, user_id="u2", call_count=1,
call_seconds=60, answered_call_count=1,
call_followup_count=0))
s.add(PhoneCall(call_id="c1", channel="voice_call", user_id="u1",
start_call_time=_ts(2026, 9, 1, 15, 0), call_duration=120,
call_number="13800000001"))
s.add(PhoneCall(call_id="c2", channel="voice_call", user_id="u1",
start_call_time=_ts(2026, 9, 2, 15, 0), call_duration=0,
call_number="13800000002"))
await s.commit()
# ---------------------------------------------------------------- 参数与错误
async def test_dashboard_missing_start_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard", params={"end": END})
assert r.status_code == 422
async def test_dashboard_span_and_calendar_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"start": 20250101, "end": 20261231})
assert r.status_code == 422
r = await client.get("/api/management/dashboard",
params={"start": 20260230, "end": 20260905})
assert r.status_code == 422 # 日历非法(2 月 30 日)
r = await client.get("/api/management/dashboard",
params={"start": 20260906, "end": 20260901})
assert r.status_code == 422 # start > end
async def test_drilldown_unknown_metric_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "bogus",
"start": START, "end": END})
assert r.status_code == 422
assert "unknown metric_code" in r.json()["detail"]
async def test_employees_page_size_over_100_422(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"page_size": 101})
assert r.status_code == 422
async def test_employee_detail_404(make_client):
async def seed(s):
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/ghost",
params={"start": START, "end": END})
assert r.status_code == 404
async def test_drilldown_employee_404(make_client):
async def seed(s):
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/ghost/drilldown",
params={"metric_code": "unreplied",
"start": START, "end": END})
assert r.status_code == 404
# ---------------------------------------------------------------- dashboard
async def test_dashboard_structure_and_kpis(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
for key in ("scope", "data_as_of", "metric_version", "kpis",
"communication_trend", "service_trend", "lifecycle",
"dimension_summary", "anomalies", "action_summary",
"config_source", "caliber_notes"):
assert key in body, key
assert body["metric_version"] == "v1"
assert body["config_source"] == "default"
assert body["scope"] == {"channel": "all", "start": START, "end": END,
"compare": "previous"}
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
k = body["kpis"]
# 活跃客户=PM distinct 求和(u1:2 + u2:1 + u3:1)
assert k["active_customers"] == 4
assert k["active_employees"] == 3
assert k["msg_total"] == 155 # u1 5 + u2 100 + u3 50
assert k["cust_threads"] == 25 # 10 + 10 + 5
assert k["threads_answered"] == 20 # 10 + 10 + 0
assert k["call_count"] == 3
assert k["call_seconds"] == 160
assert k["answered_call"] == 2
# 期末存量:u1 cooling2/dormant1 + u2 cooling1;reactivated 期内求和 u2 5
assert body["lifecycle"] == {"new": 0, "cooling": 3, "dormant": 1,
"reactivated": 5}
assert k["lifecycle"] == body["lifecycle"]
# 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2
trend = body["communication_trend"]
assert [t["day"] for t in trend] == [20260901 + i for i in range(5)]
assert trend[0]["msg_total"] == 31
assert trend[0]["active_customers"] == 8
assert trend[0]["call_cnt"] == 2
assert trend[1]["call_cnt"] == 1
assert trend[4] == {"day": 20260905, "msg_total": 31,
"active_customers": 8, "call_cnt": 0}
# 服务趋势:0901 fact [120k, 600k] → p50=120k/p90=600k;
# reply_2h_rate = 2/5(两串均 ≤2h——600s=10min / 宽表当日串 5)
st = {t["day"]: t for t in body["service_trend"]}
assert st[20260901]["fr_p50_ms"] == 120_000
assert st[20260901]["fr_p90_ms"] == 600_000
assert st[20260901]["reply_2h_rate"] == 0.4
assert st[20260903]["fr_p50_ms"] is None # 无 fact 日
assert st[20260903]["reply_2h_rate"] == 0.0 # 分母有串、分子 0
# 维度摘要:5 维全员计数,总数=员工数 3
dims = body["dimension_summary"]
assert set(dims) == {"customer", "service", "action", "workload", "risk"}
for d in dims.values():
assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
assert "avg_score" in d
# 异常:critical 在前(u2),warning 其后按 affected_count desc
anoms = body["anomalies"]
assert anoms[0]["severity"] == "critical"
assert anoms[0]["employee_user_id"] == "u2"
assert all(a["severity"] == "critical" for a in anoms[:1])
assert all(a["severity"] == "warning" for a in anoms[1:])
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
"sensitive_pending": 0}
# ---------------------------------------------------------------- employees
async def test_employees_default_sort_and_paging(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 3
assert body["page"] == 1
assert body["page_size"] == 20
assert body["metric_version"] == "v1"
assert body["config_source"] == "default"
assert len(body["rows"]) == 3
# 默认 anomaly_severity 排序:含 critical 的 u2 在最前
# (u2=1 critical——基线无串样本率不可算,rate 规则不触发;u1=2 warning)
ids = [row["user_id"] for row in body["rows"]]
assert ids == ["u2", "u1", "u3"]
u2, u1, u3 = body["rows"]
assert u2["anomaly_count"] == 1 and u2["anomaly_weight"] == 2
assert u1["anomaly_count"] == 2 and u1["anomaly_weight"] == 2
assert u3["anomaly_count"] == 0 and u3["anomaly_weight"] == 0
# 行结构:metrics 全字段 / comparisons 四值 / score 摘要
assert u1["name"] == "张三" and u1["is_on_job"] is True
assert u1["metrics"]["cust_threads"] == 10
assert u1["metrics"]["end_active_friends"] == 3
# M3:comparisons 键域=源指标名(与 sections 端点一致;band 配置 code
# 映射为其 spec.metric——active_friends/call_count,不出现 *_band)
assert set(u1["comparisons"]) >= {
"active_share", "reply_2h_rate", "reply_24h_rate", "fr_p90_ms",
"active_friends", "call_count"}
assert "active_friends_band" not in u1["comparisons"]
assert "call_band" not in u1["comparisons"]
for c in u1["comparisons"].values():
assert set(c) == {"current", "prev", "baseline", "median"}
assert u1["comparisons"]["fr_p90_ms"]["current"] == 120_000
assert u1["comparisons"]["fr_p90_ms"]["baseline"] == 60_000
assert set(u1["score"]["dimensions"]) == {
"customer", "service", "action", "workload", "risk"}
for d in u1["score"]["dimensions"].values():
assert set(d) == {"score", "status"}
assert u1["score"]["trial"] is True
assert u1["score"]["score_status"] == "scored" # 5 天数据 ≥ min_days
async def test_employees_channel_filter(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "wechat", "start": START,
"end": END})
ids = [row["user_id"] for row in r.json()["rows"]]
assert ids == ["u2", "u1"] # u3 仅 WhatsApp,wechat 视图消失
r = await client.get("/api/management/employees",
params={"channel": "whatsapp", "start": START,
"end": END})
ids = [row["user_id"] for row in r.json()["rows"]]
assert ids == ["u2", "u3"] # u1 无 WA 账号
async def test_employees_keyword(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END, "keyword": "张"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u1"]
r = await client.get("/api/management/employees",
params={"start": START, "end": END, "keyword": "U3"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
async def test_employees_status_filter(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"status": "on_job"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u2", "u1"]
r = await client.get("/api/management/employees",
params={"start": START, "end": END,
"status": "offline"})
assert [row["user_id"] for row in r.json()["rows"]] == ["u3"]
async def test_employees_min_days_insufficient(make_client):
"""B2 评审遗留接线:days_with_data < min_days → score_status 覆盖
insufficient_sample,但可评明细(total_score)保留展示。"""
async def seed(s):
s.add(Staff(user_id="u9", name="赵九", is_on_job=True))
s.add(WechatAccount(wx_id="wx9", user_id="u9"))
# 仅 2 天宽表数据(< min_days=5),但串样本 30 ≥ min_threads 可评分
for d in (1, 2):
s.add(_mday(20260900 + d, user_id="u9", cust_threads=15,
threads_answered=15, fr_sum_ms=900_000,
active_friends=10))
s.add(_fact(60_000, owner="wx9", peer="q1", day=20260901, i=0))
s.add(_fact(60_000, owner="wx9", peer="q2", day=20260902, i=1))
for i in range(6): # 6 个 distinct peer → workload band 可评
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx9",
peer_kind="friend", peer_id=f"q{i}",
day=20260901, send_cnt=1, recv_cnt=0,
last_ts=_ts(2026, 9, 1, 12, 0),
last_is_send=True))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"start": START, "end": END})
assert r.status_code == 200
row = r.json()["rows"][0]
assert row["user_id"] == "u9"
assert row["metrics"]["days_with_data"] == 2
assert row["score"]["score_status"] == "insufficient_sample"
assert row["score"]["total_score"] is not None # 明细保留展示
async def test_employees_db_score_config_guard(make_client):
"""config_source=db 路径:激活配置的 min_days=9 生效——u1 五天数据
在默认配置(min_days=5)下 scored,在 DB 配置下翻为 insufficient_sample。"""
async def seed(s):
await _seed_common(s)
cfg = copy.deepcopy(DEFAULT_SCORE_CONFIG)
cfg["version"] = "v1-test"
cfg["name"] = "评审测试配置"
cfg["sample_guards"]["min_days"] = 9
s.add(EmployeeScoreConfig(version="v1-test", name="评审测试配置",
status="active", config_json=cfg,
created_by="test"))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees",
params={"channel": "all", "start": START,
"end": END})
assert r.status_code == 200
body = r.json()
assert body["config_source"] == "db"
u1 = next(row for row in body["rows"] if row["user_id"] == "u1")
assert u1["metrics"]["days_with_data"] == 5
assert u1["score"]["score_status"] == "insufficient_sample" # 5 < 9
assert u1["score"]["total_score"] is not None
# ---------------------------------------------------------------- detail
async def test_employee_detail_full(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1",
params={"channel": "all", "start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["user_id"] == "u1"
assert body["name"] == "张三"
assert body["accounts"] == [
{"channel": "wechat", "account_id": "wx1",
"nick_name": "张三的微信", "user_name": "张三"}]
# metrics 全字段(22 列 namedtuple)
assert len(body["metrics"]) == 22
assert body["metrics"]["cust_threads"] == 10
# score 完整明细:dimensions 含逐指标 metrics;metrics_detail 扁平
assert body["score"]["dimensions"]["service"]["metrics"]["fr_p90_ms"][
"score"] == 100.0
assert body["score"]["metrics_detail"]["fr_p90_ms"]["value"] == 120_000
assert body["score"]["metrics_detail"]["reply_2h_rate"]["value"] == 0.2
assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000
# anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠:
# 5 天 × msg_sent 14 = 70)
assert len(body["anomalies"]) == 2
assert all(a["severity"] == "warning" for a in body["anomalies"])
assert body["prev"]["msg_sent"] == 70
assert body["baseline"]["msg_sent"] == 70
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
# ---------------------------------------------------------------- sections
async def test_employee_sections(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
base = "/api/management/employees/u1/metrics"
for section in ("overview", "service", "customer", "workload", "calls"):
r = await client.get(base, params={
"section": section, "channel": "wechat",
"start": START, "end": END})
assert r.status_code == 200, section
body = r.json()
assert body["section"] == section
assert len(body["trend"]) == 5 # 全窗口逐日(含零填充)
assert all("day" in t for t in body["trend"])
assert body["comparisons"]["reply_2h_rate"]["current"] == 0.2
assert set(body["comparisons"]["reply_2h_rate"]) == {
"current", "prev", "baseline", "median"}
r = await client.get(base, params={"section": "service",
"channel": "wechat",
"start": START, "end": END})
body = r.json()
assert body["metrics"]["cust_threads"] == 10
assert body["metrics"]["reply_2h_count"] == 2
t0 = body["trend"][0]
assert t0 == {"day": 20260901, "cust_threads": 2, "threads_answered": 2,
"fr_sum_ms": 240_000, "first_reply_p50_ms": 110_000,
"first_reply_p90_ms": 220_000}
r = await client.get(base, params={"section": "calls", "channel": "all",
"start": START, "end": END})
calls = {t["day"]: t for t in r.json()["trend"]}
assert calls[20260901]["call_count"] == 2
assert calls[20260901]["call_followup_count"] == 1
assert calls[20260905]["call_count"] == 0 # 零填充
assert r.json()["metrics"]["call_count"] == 2
# ---------------------------------------------------------------- drilldown
async def test_drilldown_fr_p90(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
url = "/api/management/employees/u2/drilldown"
r = await client.get(url, params={
"metric_code": "first_reply_p90", "channel": "all",
"start": START, "end": END, "page": 1, "page_size": 1})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
assert len(body["items"]) == 1
item = body["items"][0]
# fr_ms desc + 同值按日 desc 决胜:0902 的 pD 在前
assert item["fr_ms"] == 600_000
assert item["peer_id"] == "pD"
assert item["channel"] == "wechat"
assert item["owner_account_id"] == "wx2"
assert item["chat_hint"] == {"owner_account_id": "wx2", "talker_id": "pD"}
r = await client.get(url, params={
"metric_code": "first_reply_p90", "channel": "all",
"start": START, "end": END, "page": 2, "page_size": 1})
assert r.json()["items"][0]["peer_id"] == "pC"
# u2 的 WA 账号无 fact 行
r = await client.get(url, params={
"metric_code": "first_reply_p50", "channel": "whatsapp",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_unreplied(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "unreplied",
"channel": "wechat",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 # p1(wx1)进入,u1 过滤后
assert body["items"][0]["peer_id"] == "p1"
assert body["items"][0]["owner_account_id"] == "wx1"
# u2 名下无未回候选(p3 尾部为员工消息)
r = await client.get("/api/management/employees/u2/drilldown",
params={"metric_code": "unreplied",
"channel": "wechat",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_lifecycle_customers(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "cooling_customers",
"channel": "all",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 1 # f2 降温;f1 沉睡、f9 已删
assert body["items"][0] == {"friend_id": "f2", "channel": "wechat",
"nickname": "客户二",
"last_day": 20260825, "state": "cooling"}
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
body = r.json()
assert body["total"] == 1
assert body["items"][0]["friend_id"] == "f1"
assert body["items"][0]["state"] == "dormant"
async def test_drilldown_shared_customer_history(make_client):
"""I1:共享/换归属客户——互动史挂在他人账号(wx2)名下而 Customer.owner
=wx1:历史按 (channel, peer) 键控跨 owner 合并(对齐批次A _lifecycle_daily
的 peer 键控口径),客户不丢、历史完整(hist 12 ≥ 5 判沉睡)。"""
async def seed(s):
await _seed_common(s)
for day, month in ((20260701, 7), (20260801, 8)):
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx2",
peer_kind="friend", peer_id="f3", day=day,
send_cnt=3, recv_cnt=3,
last_ts=_ts(2026, month, day % 100, 12, 0),
last_is_send=True))
s.add(Customer(friend_id="f3", channel="wechat",
owner_account_id="wx1", friend_nickname="共享客户",
user_id="u1", delete_flag=0))
await s.commit()
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
body = r.json()
assert body["total"] == 2 # f1 + f3(历史全在 wx2 名下也不丢)
f3 = next(i for i in body["items"] if i["friend_id"] == "f3")
assert f3 == {"friend_id": "f3", "channel": "wechat",
"nickname": "共享客户",
"last_day": 20260801, "state": "dormant"}
# 归属口径仍是 Customer.owner(wx1):u2(wx2)名下看不到
r = await client.get("/api/management/employees/u2/drilldown",
params={"metric_code": "dormant_customers",
"channel": "all",
"start": START, "end": END})
assert r.json()["total"] == 0
async def test_drilldown_call_followup(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/employees/u1/drilldown",
params={"metric_code": "call_followup",
"channel": "all",
"start": START, "end": END})
assert r.status_code == 200
body = r.json()
assert body["total"] == 2
# start_call_time desc:c2(0902)在前
assert [i["call_id"] for i in body["items"]] == ["c2", "c1"]
assert set(body["items"][0]) == {
"call_id", "channel", "call_number", "start_call_time",
"call_duration"}
assert body["summary"] == {"call_count": 2, "call_seconds": 100,
"answered_call_count": 1,
"call_followup_count": 1}
# ------------------------------------------------------- metric-definitions
async def test_metric_definitions(make_client):
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/metric-definitions")
assert r.status_code == 200
body = r.json()
assert body["definitions"]["cust_threads"]["unit"] == "串"
assert body["score_config"]["trial"] is True
assert body["score_config"]["dimensions"]["risk"]["status"] == "pending_data"
assert body["config_source"] == "default" # 未种子配置→回退默认
assert "1:1" in body["caliber_notes"]
@@ -300,6 +300,16 @@ def test_validate_config_cross_dimension_code_unique():
assert any("active_share" in p and "重复" in p for p in validate_config(dup))
def test_validate_config_source_name_unique():
"""两个 code 指向同一源指标:comparisons 按源名建键会静默覆盖,须报问题。"""
dup = json.loads(json.dumps(DEFAULT_SCORE_CONFIG))
# service 里造一个不同 code 但同指向 active_friends 的 band(源名冲突)
dup["dimensions"]["service"]["metrics"]["load_band"] = {
"type": "band", "metric": "active_friends", "low": 1, "high": 50,
}
assert any("同一源指标 active_friends" in p for p in validate_config(dup))
def test_validate_config_default_config_clean():
"""默认配置自身必须全绿(空问题列表)。"""
assert validate_config(DEFAULT_SCORE_CONFIG) == []
+15 -7
View File
@@ -572,7 +572,12 @@ async def test_safe_page_run_full_invokes_bash_full_sync(engine, monkeypatch):
async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch):
"""messages job = friends→wa-friends→四类消息→moments 浅扫的命令序列。"""
"""messages job = accounts→friends→四类消息(活跃窗)→moments 浅扫的命令序列。
2026-09-10 调度根治:头部刷员工账号表(员工列表时间最多滞后 4h);
消息实体只扫近 24h 活跃对话(--active-window-hours 24,防序列 15h+
挤死 full);陈旧对话每日 full 兜底。
"""
sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
commands: list[list[str]] = []
@@ -584,22 +589,25 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
await sched._safe_page_run("messages")
# 2026-08-21: messages job 是 friends→wa-friends→四类消息的命令序列,
# 每条都调 scrape_via_page.py;2026-08-25 末尾追加 moments 浅扫
assert len(commands) == 7
assert len(commands) == 9
for args in commands:
assert args[1].endswith("scrape_via_page.py")
def entity_of(args: list[str]) -> str:
return args[args.index("--entity") + 1]
assert entity_of(commands[0]) == "friends"
assert entity_of(commands[1]) == "wa-friends"
for args in commands[2:6]:
# 头部:账号刷新(员工列表「数据更新/最新消息时间」来源)
assert entity_of(commands[0]) == "accounts"
assert entity_of(commands[1]) == "wa-accounts"
assert entity_of(commands[2]) == "friends"
assert entity_of(commands[3]) == "wa-friends"
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
for args in commands[4:8]:
assert entity_of(args) in (
"messages", "wa-messages", "group-messages", "wa-group-messages",
)
assert args[args.index("--max-conversations") + 1] == "99999"
assert args[args.index("--active-window-hours") + 1] == "24"
# moments 浅扫在序列末尾:每员工最多 3 页
moments_cmd = commands[-1]
assert entity_of(moments_cmd) == "moments"
+72
View File
@@ -0,0 +1,72 @@
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
// 已对照批次B as-built(management.py/api_assembly.py)逐键核对:
// - dashboard 另有 caliber_notes(总述口径说明,Record<string,string>)
// - drilldown 各分支均返回 page/page_size/scope(与 items/total 同层)
// - first_reply_* 行含 chat_hint(owner+talker 直达聊天记录)
// - lifecycle 行 nickname 恒非空(后端 friend_nickname→remark→friend_id 兜底)
import { getJson } from './client'
import type { Channel } from './types'
export type MgmtChannel = 'all' | Channel
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
export type DrilldownCode =
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
export interface Kpis {
active_customers: number; active_employees: number
msg_total: number; cust_threads: number; threads_answered: number
call_count: number; call_seconds: number; answered_call: number
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
}
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
export interface AnomalyCard {
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
title: string; current_value: number; baseline_value: number | null; unit: string
sample_size: number; affected_count: number; reason: string
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
suggested_action: string
}
export interface DashboardResp {
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
kpis: Kpis
communication_trend: CommTrendPoint[]
service_trend: SvcTrendPoint[]
lifecycle: Kpis['lifecycle']
dimension_summary: DimSummary
anomalies: AnomalyCard[]
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
caliber_notes: Record<string, string>
}
export interface EmployeeRow {
user_id: string; name: string | null; is_on_job: boolean | null
// metrics=PeriodMetrics._asdict():数值字段外的 user_id 键是字符串(透传
// 字段,UI 取身份用顶层 row.user_id,动态取数只碰数值键)
metrics: Record<string, number | null>
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
anomaly_count: number; anomaly_weight: number
}
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number; chat_hint: { owner_account_id: string; talker_id: string } }
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string; last_day: number; state: string }
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
export interface DrilldownResp { total: number; page: number; page_size: number; scope: MgmtScope; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
export interface MetricDefsResp {
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
caliber_notes: string
config_source: 'db' | 'default'
}
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
export const getEmployees = (params: Record<string, unknown>) =>
getJson<EmployeesResp>('/management/employees', params)
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
@@ -0,0 +1,101 @@
<template>
<el-table
v-loading="loading"
:data="anomalies"
:row-class-name="rowClass"
@row-click="onRowClick"
>
<el-table-column label="级别" width="70" align="center">
<template #default="{ row }">
<el-tag v-if="row.severity === 'critical'" type="danger" size="small">严重</el-tag>
<el-tag v-else type="warning" size="small">关注</el-tag>
</template>
</el-table-column>
<el-table-column label="异常" min-width="240">
<template #default="{ row }">
<div class="cell-title">{{ row.title }}</div>
<div class="cell-sub">{{ row.reason }}</div>
</template>
</el-table-column>
<el-table-column label="当前值" width="150">
<template #default="{ row }">
<div>{{ formatValue(row.current_value, row.unit) }}</div>
<div class="cell-sub">
{{ row.baseline_value === null ? '无基线' : `基线 ${formatValue(row.baseline_value, row.unit)}` }}
</div>
</template>
</el-table-column>
<el-table-column label="样本" prop="sample_size" align="right" width="80" />
<el-table-column label="影响" align="right" width="90">
<template #default="{ row }">
<span :class="{ 'affected-hot': row.affected_count >= 50 }">{{ row.affected_count }}</span>
</template>
</el-table-column>
<el-table-column label="建议动作" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span class="cell-sub">{{ row.suggested_action }}</span>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无异常" :image-size="60" />
</template>
</el-table>
</template>
<script setup lang="ts">
// 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+原因
// 双行/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
// 纯展示+事件,不取数。
import { formatMsDuration, formatRatio } from '@/utils/format'
import type { AnomalyCard } from '@/api/management'
defineProps<{
anomalies: AnomalyCard[]
loading?: boolean
}>()
const emit = defineEmits<{
(e: 'drill', card: AnomalyCard): void
}>()
// 单位感知格式化:ms→时长,0-1→比率,其余千分位(与指标口径字典 unit 对齐)
function formatValue(v: number, unit: string): string {
if (unit === 'ms') return formatMsDuration(v)
if (unit === '0-1') return formatRatio(v)
return v.toLocaleString()
}
function rowClass({ row }: { row: AnomalyCard }): string {
return row.severity === 'critical'
? 'anomaly-critical-row yk-clickable-row'
: 'yk-clickable-row'
}
function onRowClick(row: AnomalyCard): void {
emit('drill', row)
}
</script>
<style scoped>
/* critical 行整行淡红底(优先处置);hover 深一档由 EP 行 hover 自带 */
:deep(.el-table .anomaly-critical-row) {
background-color: var(--el-color-danger-light-9);
}
:deep(.el-table .anomaly-critical-row:hover > td.el-table__cell) {
background-color: var(--el-color-danger-light-8);
}
.cell-title {
font-size: 13px;
color: var(--el-text-color-primary);
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 2px;
}
.affected-hot {
color: var(--el-color-danger);
font-weight: 600;
}
</style>
@@ -0,0 +1,49 @@
<template>
<span class="data-asof">
<el-tag v-if="asOfText" size="small" type="info" effect="plain">数据截至 {{ asOfText }}</el-tag>
<el-tag v-else size="small" type="info">无数据</el-tag>
<el-tag
v-if="metricVersion"
size="small"
effect="plain"
class="asof-tag"
:title="configSource === 'db' ? '口径配置:自定义(数据库)' : '口径配置:默认'"
>口径 {{ metricVersion }}</el-tag>
<el-tag v-if="trial" size="small" type="warning" class="asof-tag">试算</el-tag>
</span>
</template>
<script setup lang="ts">
// 数据截至角标(批次C 驾驶舱/员工页共用):数据时间 + 指标版本 + 试算标记。
// 纯展示——数据由父级从 dashboard/employees 载荷透传(data_as_of/metric_version/
// trial/config_source),不自行请求。configSource 无独立标签(避免噪音),
// 挂在口径版本 tag 的 title 提示上。
import { computed } from 'vue'
import { formatTime } from '@/utils/format'
const props = defineProps<{
dataAsOf?: number | null
metricVersion?: string
trial?: boolean
configSource?: 'db' | 'default'
}>()
// ms 时间戳 → `YYYY-MM-DD HH:mm`(formatTime 去秒);缺失/非法 → 灰 tag「无数据」
const asOfText = computed(() => {
const v = props.dataAsOf
if (v == null || !Number.isFinite(v) || v <= 0) return ''
return formatTime(v).slice(0, 16)
})
</script>
<style scoped>
.data-asof {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.asof-tag {
margin-left: 0;
}
</style>
@@ -0,0 +1,103 @@
<template>
<div class="mgmt-filter-bar">
<el-radio-group :model-value="channel" @change="onChannelChange">
<el-radio-button v-for="c in CHANNELS" :key="c.value" :value="c.value">{{ c.label }}</el-radio-button>
</el-radio-group>
<el-select
v-model="preset"
class="preset-select"
placeholder="近30天"
@change="onPresetChange"
>
<el-option label="近7天" value="last7" />
<el-option label="近30天" value="last30" />
<el-option label="本月" value="thisMonth" />
<el-option label="上月" value="lastMonth" />
<el-option label="自定义" value="custom" />
</el-select>
<!-- 自定义区间:daterange 自保证 start≤end,选完才 emit;value-format
直接给 yyyymmdd 字符串,转 number 走 URL 真源 -->
<el-date-picker
v-if="preset === 'custom'"
v-model="customRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYYMMDD"
:clearable="false"
style="width: 240px"
@change="onCustomRange"
/>
<div class="spacer" />
<el-button text type="primary" @click="emit('open-defs')">指标口径</el-button>
<slot name="aside" />
</div>
</template>
<script setup lang="ts">
// 管理页统一筛选栏(批次C):渠道 + 时间预设/自定义区间 + 指标口径入口。
// 受控组件——channel/start/end 来自父级(useManagementFilters 的 URL 真源
// computed),变更只 emit apply(patch) 由父写回 URL,组件不实例化 composable
// (只 import CHANNELS/presetRange 常量,避免二次 useRoute/useRouter)。
//
// 预设选中值:内部 ref 记用户最近选择,初始 null 显示 placeholder「近30天」,
// 不与 URL 双向同步(URL 直接访问无 start/end 时默认即 last30,placeholder
// 恰好如实)。非 custom 预设把 presetRange 计算出的具体日期 emit 给父——
// URL 真源可分享/刷新,且选中项与实际窗口一致(composable 的默认(last30)
// 无法表达 last7/本月/上月,只清 start/end 会静默回落 30 天窗)。
import { ref } from 'vue'
import { CHANNELS, presetRange, type Preset } from '@/composables/useManagementFilters'
defineProps<{
channel: string
start: number
end: number
/** 预留(对比模式选择器未上),父级透传保持 props 面完整 */
compare?: string
}>()
const emit = defineEmits<{
(e: 'apply', patch: { channel?: string; start?: number; end?: number }): void
(e: 'open-defs'): void
}>()
// 用户最近选择的预设;null = 未选(placeholder 近30天)。custom 选完后保持,
// 日期改动走 onCustomRange 增量 emit。
const preset = ref<Preset | null>(null)
const customRange = ref<[string, string] | null>(null)
function onChannelChange(v: string | number | boolean): void {
emit('apply', { channel: String(v) })
}
function onPresetChange(v: string): void {
if (v === 'custom') return // 等 daterange 选完区间再 emit
emit('apply', presetRange(v as Preset))
}
function onCustomRange(): void {
const r = customRange.value
if (!r?.[0] || !r?.[1]) return // 未选完区间不请求
emit('apply', { start: Number(r[0]), end: Number(r[1]) })
}
</script>
<style scoped>
.mgmt-filter-bar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.preset-select {
width: 120px;
}
.spacer {
flex: 1;
}
</style>
@@ -0,0 +1,63 @@
<template>
<el-card shadow="never" class="kpi-card" :class="{ link: !!to }" @click="onClick">
<div class="kpi-label">{{ label }}</div>
<div class="kpi-value" :class="{ danger }">
{{ valueText }}<span v-if="unit && value !== null" class="kpi-unit">{{ unit }}</span>
</div>
<div v-if="hint" class="kpi-hint">{{ hint }}</div>
</el-card>
</template>
<script setup lang="ts">
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
// + hint 下小灰字。to 存在时整卡可点(hover 手型)跳转——纯展示+导航,
// 不取数。null 值语义(无样本)显 `--`,由父级决定传 null。
import { computed } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps<{
label: string
value: number | null
unit?: string
hint?: string
to?: string
danger?: boolean
}>()
const router = useRouter()
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString()))
function onClick(): void {
if (props.to) router.push(props.to)
}
</script>
<style scoped>
.kpi-card.link {
cursor: pointer;
}
.kpi-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.kpi-value {
font-size: 26px;
font-weight: 600;
margin: 6px 0 2px;
color: var(--el-text-color-primary);
}
.kpi-value.danger {
color: var(--el-color-danger);
}
.kpi-unit {
font-size: 13px;
font-weight: 400;
color: var(--el-text-color-secondary);
margin-left: 2px;
}
.kpi-hint {
font-size: 11px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,199 @@
<template>
<el-drawer
:model-value="modelValue"
title="指标口径"
direction="rtl"
size="480px"
@update:model-value="onVisible"
>
<div v-loading="loading">
<el-alert v-if="error" type="error" :title="error" :closable="false" class="defs-alert" />
<template v-if="defs">
<!-- 三方口径差异总述(管理域 vs 现有看板) -->
<el-alert type="info" :closable="false" show-icon class="defs-alert">
<div class="caliber-text">{{ defs.caliber_notes }}</div>
</el-alert>
<!-- 评分配置摘要:版本/名称/试算/来源 + 样本门槛/等级线 + 维度权重 -->
<div class="cfg-card">
<div class="cfg-head">
<b>{{ defs.score_config.name }}</b>
<el-tag size="small" effect="plain">v{{ defs.score_config.version }}</el-tag>
<el-tag v-if="defs.score_config.trial" size="small" type="warning">试算</el-tag>
<el-tag size="small" :type="defs.config_source === 'db' ? 'primary' : 'info'" effect="plain">
{{ defs.config_source === 'db' ? '自定义配置' : '默认配置' }}
</el-tag>
</div>
<div class="cfg-row">样本门槛:{{ guardsText }}</div>
<div class="cfg-row">等级线:{{ gradesText }}(低于 D 线为 E)</div>
<el-table :data="dims" size="small" class="dims-table">
<el-table-column prop="name" label="维度" min-width="110" />
<el-table-column label="权重" width="70" align="right">
<template #default="{ row }">{{ row.weight }}%</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.status === 'pending_data'" size="small" type="info">待数据</el-tag>
<el-tag v-else size="small" type="success">可用</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<!-- 指标定义:19 项平铺(端点不按维度分组),按 code 字母序逐项展示 -->
<div v-for="[code, d] in defsSorted" :key="code" class="def-item">
<div class="def-head">
<b>{{ d.name }}</b>
<code class="def-code">{{ code }}</code>
<span class="def-unit">{{ d.unit }}</span>
<el-tag size="small" :type="d.direction === 1 ? 'success' : 'warning'">
{{ d.direction === 1 ? '越大越好' : '越小越好' }}
</el-tag>
</div>
<pre v-if="d.formula" class="def-formula">{{ d.formula }}</pre>
<div v-if="d.caliber" class="def-caliber">{{ d.caliber }}</div>
</div>
</template>
</div>
</el-drawer>
</template>
<script setup lang="ts">
// 指标口径抽屉(批次C):口径字典 + 当前评分配置。打开时拉取
// /management/metric-definitions 并缓存一次(口径变化走配置版本,会话内
// 不重拉)。definitions 为平铺 19 项(dimensions 只有名称/权重),不做按
// 维度分组,按 code 字母序逐项列:名称+单位+方向 tag+formula+caliber。
import { computed, ref, watch } from 'vue'
import { getMetricDefs, type MetricDefsResp } from '@/api/management'
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const loading = ref(false)
const error = ref('')
const defs = ref<MetricDefsResp | null>(null)
let loaded = false // 缓存一次:首次打开拉取,后续打开直接用
// 打开时取数;成功后缓存(口径变化走配置版本,会话内不重拉),
// 失败(无 defs 且有 error)则重开抽屉时再试一次
watch(
() => props.modelValue,
(visible) => {
if (visible && (!loaded || (!defs.value && error.value))) load()
},
)
async function load(): Promise<void> {
loaded = true // 请求已发出即视为已加载,失败不反复自动重试
loading.value = true
error.value = ''
try {
defs.value = await getMetricDefs()
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
function onVisible(v: boolean): void {
emit('update:modelValue', v)
}
// 平铺定义按 code 字母序(中文环境 code 恒为 ASCII,localeCompare 稳定)
const defsSorted = computed(() =>
Object.entries(defs.value?.definitions ?? {}).sort(([a], [b]) => a.localeCompare(b)),
)
// 配置维度行(仅名称/权重/状态)
const dims = computed(() =>
Object.entries(defs.value?.score_config.dimensions ?? {}).map(([key, d]) => ({
key,
name: d.name,
weight: d.weight,
status: d.status,
})),
)
const guardsText = computed(() => {
const g = defs.value?.score_config.sample_guards ?? {}
return Object.entries(g).map(([k, v]) => `${k} ≥ ${v}`).join(' · ') || '--'
})
const gradesText = computed(() => {
const gb = defs.value?.score_config.grade_bounds ?? {}
return Object.entries(gb).map(([g, v]) => `${g} ≥ ${v}`).join(' · ') || '--'
})
</script>
<style scoped>
.defs-alert {
margin-bottom: 12px;
}
.caliber-text {
font-size: 12px;
line-height: 1.6;
white-space: normal;
}
.cfg-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 12px;
margin-bottom: 16px;
}
.cfg-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.cfg-row {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-bottom: 4px;
}
.dims-table {
margin-top: 8px;
}
.def-item {
padding: 10px 0;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.def-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.def-code {
font-size: 12px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
padding: 1px 6px;
border-radius: 3px;
}
.def-unit {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.def-formula {
margin: 6px 0 0;
padding: 6px 10px;
font-size: 12px;
background: var(--el-fill-color-light);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
.def-caliber {
margin-top: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
}
</style>
@@ -0,0 +1,369 @@
<template>
<el-drawer
:model-value="modelValue"
:title="card?.title ?? '指标下钻'"
direction="rtl"
size="760px"
@update:model-value="onVisible"
>
<!-- 无 metric_code 或 drilldown.type==='employee':员工维度异常,下钻
明细属批次D 员工分析页——先给原因/建议动作兜底 -->
<template v-if="unsupported">
<el-empty description="员工分析页批次D上线,此类异常先看建议动作" :image-size="80" />
<div v-if="card" class="fallback">
<div class="fallback-label">原因</div>
<div class="fallback-text">{{ card.reason }}</div>
<div class="fallback-label">建议动作</div>
<div class="fallback-text">{{ card.suggested_action }}</div>
</div>
</template>
<template v-else>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="drill-alert" />
<el-alert v-if="resp?.note" type="info" :title="resp.note" :closable="false" class="drill-alert" />
<el-alert
v-if="resp?.truncated"
type="warning"
title="候选超500,列表可能不全"
:closable="false"
class="drill-alert"
/>
<!-- call_followup:期聚合摘要(期通话/接通/跟进) -->
<div v-if="kind === 'calls' && resp?.summary" class="call-summary">
<div class="sum-item">
<span class="sum-num">{{ resp.summary.call_count ?? 0 }}</span>
<span class="sum-label">期通话</span>
</div>
<div class="sum-item">
<span class="sum-num">{{ resp.summary.answered_call_count ?? 0 }}</span>
<span class="sum-label">接通</span>
</div>
<div class="sum-item">
<span class="sum-num">{{ resp.summary.call_followup_count ?? 0 }}</span>
<span class="sum-label">跟进</span>
</div>
</div>
<!-- 首响分位:逐串证据行(后端已按 fr_ms desc),行 click 跳聊天 -->
<el-table
v-if="kind === 'first_reply'"
v-loading="loading"
:data="frRows"
:row-class-name="clickableRow"
@row-click="onFrRowClick"
>
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="渠道" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
{{ chTag(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="串末条时间" width="170">
<template #default="{ row }">{{ formatTime(row.thread_end_ts) }}</template>
</el-table-column>
<el-table-column label="回复时间" width="170">
<template #default="{ row }">{{ formatTime(row.reply_ts) }}</template>
</el-table-column>
<el-table-column label="首响时长" align="right" min-width="110">
<template #default="{ row }">{{ formatMsDuration(row.fr_ms) }}</template>
</el-table-column>
</el-table>
<!-- 未回串:等待中的客户,行 click 跳聊天(带 msgid 定位最后一条客户消息) -->
<el-table
v-else-if="kind === 'unreplied'"
v-loading="loading"
:data="unrepliedRows"
:row-class-name="clickableRow"
@row-click="onUnrepliedRowClick"
>
<el-table-column prop="peer_id" label="客户" min-width="140" show-overflow-tooltip />
<el-table-column label="渠道" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
{{ chTag(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="等待时长" width="140">
<template #default="{ row }">{{ formatMsDuration(row.wait_ms) }}</template>
</el-table-column>
<el-table-column label="预览" min-width="220" show-overflow-tooltip>
<template #default="{ row }">{{ row.preview ?? '' }}</template>
</el-table-column>
</el-table>
<!-- 降温/沉睡客户名单。lifecycle 行没有 owner_account_id(互动史按
(channel,peer) 跨 owner 合并,归属不定)——不提供跳转,批次D 员工
分析页补员工账号维度的直达入口 -->
<el-table v-else-if="kind === 'lifecycle'" v-loading="loading" :data="lifecycleRows">
<el-table-column prop="nickname" label="客户" min-width="160" show-overflow-tooltip />
<el-table-column label="渠道" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.channel === 'whatsapp' ? 'success' : 'primary'">
{{ chTag(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后互动日" width="120">
<template #default="{ row }">{{ fmtDay(row.last_day) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="row.state === 'dormant'" size="small" type="danger">沉睡</el-tag>
<el-tag v-else-if="row.state === 'cooling'" size="small" type="warning">降温</el-tag>
<el-tag v-else size="small" type="info">{{ row.state }}</el-tag>
</template>
</el-table-column>
</el-table>
<!-- 通话跟进:窗口通话列表(通话无渠道过滤维度,后端恒全渠道) -->
<el-table v-else-if="kind === 'calls'" v-loading="loading" :data="callRows">
<el-table-column label="时间" width="180">
<template #default="{ row }">{{ formatTime(row.start_call_time) }}</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">{{ callTypeLabel(row.channel) }}</template>
</el-table-column>
<el-table-column label="号码" min-width="140">
<template #default="{ row }">{{ row.call_number ?? '--' }}</template>
</el-table-column>
<el-table-column label="时长(秒)" align="right" min-width="100">
<template #default="{ row }">{{ formatCallDuration(row.call_duration) }}</template>
</el-table-column>
</el-table>
<el-pagination
:current-page="page"
:page-size="PAGE_SIZE"
:total="resp?.total ?? 0"
layout="total, prev, pager, next"
class="pager"
@current-change="onPageChange"
/>
</template>
</el-drawer>
</template>
<script setup lang="ts">
// 指标下钻抽屉(批次C):点异常卡按 metric_code 调 drilldown 白名单端点,
// 四种明细形态——首响逐串证据/未回串/降温·沉睡名单/通话跟进。抽屉自治
// 加载(打开即取数,后端分页);员工维度异常(drilldown.type==='employee')
// 无 metric_code,给批次D 占位 + 原因/建议动作兜底。行 click 跳聊天:
// WA→ChatTimeline(/chat/whatsapp/{owner}/{peer}),微信→员工聊天窗 single
// tab(/staff-chat/{owner}/single/{peer},项目惯例路由名跳转,路径等价)。
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
getEmployeeDrilldown,
type AnomalyCard,
type DrilldownCode,
type DrilldownResp,
type MgmtChannel,
} from '@/api/management'
import { callTypeLabel, formatCallDuration, formatMsDuration, formatTime } from '@/utils/format'
const props = defineProps<{
modelValue: boolean
userId: string
card: AnomalyCard | null
start: number
end: number
channel: MgmtChannel
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
const router = useRouter()
const PAGE_SIZE = 20
const page = ref(1)
const loading = ref(false)
const error = ref('')
const resp = ref<DrilldownResp | null>(null)
let seq = 0 // 竞态守卫:快速换卡/翻页时旧响应作废(项目惯例)
// drilldown 白名单(与后端 _DRILLDOWN_CODES 一致;白名单外按不支持兜底)
const DRILL_CODES: readonly string[] = [
'first_reply_p50', 'first_reply_p90', 'unreplied',
'cooling_customers', 'dormant_customers', 'call_followup',
]
const metricCode = computed<DrilldownCode | null>(() => {
const c = props.card?.drilldown.query.metric_code
return c && DRILL_CODES.includes(c) ? (c as DrilldownCode) : null
})
// 员工维度异常(无 metric_code / type==='employee')→ 批次D 占位兜底
const unsupported = computed(
() => !metricCode.value || props.card?.drilldown.type === 'employee',
)
type Kind = 'first_reply' | 'unreplied' | 'lifecycle' | 'calls'
const kind = computed<Kind | null>(() => {
switch (metricCode.value) {
case 'first_reply_p50':
case 'first_reply_p90':
return 'first_reply'
case 'unreplied':
return 'unreplied'
case 'cooling_customers':
case 'dormant_customers':
return 'lifecycle'
case 'call_followup':
return 'calls'
default:
return null
}
})
// 打开(或换卡重开)即取数;start/end/channel 变化由父级在抽屉关闭时发生,
// 下次打开经 visible 变化自然重查
watch(
() => [props.modelValue, props.card] as const,
([visible]) => {
if (visible && !unsupported.value) {
page.value = 1
load()
}
},
)
async function load(): Promise<void> {
if (!metricCode.value) return
const my = ++seq
loading.value = true
error.value = ''
try {
const r = await getEmployeeDrilldown(
props.userId, metricCode.value, props.start, props.end,
props.channel, page.value, PAGE_SIZE,
)
if (my !== seq) return
resp.value = r
} catch (e) {
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
// 分页翻页:vue-tsc strict 不推 inline-arrow 参数,单列包装(项目惯例)
function onPageChange(p: number): void {
page.value = p
load()
}
function onVisible(v: boolean): void {
emit('update:modelValue', v)
}
// ---- 各分支行类型(items 是 Record<string,unknown>[],按白名单端点形状收窄) ----
type FrRow = {
day: number; channel: string; owner_account_id: string; peer_id: string
thread_end_ts: number; reply_ts: number; fr_ms: number
}
type UnrepliedRow = {
peer_id: string; channel: string; owner_account_id: string
last_ts: number; wait_ms: number; preview?: string; msg_id?: string
}
type LifecycleRow = { friend_id: string; channel: string; nickname: string; last_day: number; state: string }
type CallRow = { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
const frRows = computed<FrRow[]>(() =>
kind.value === 'first_reply' ? (resp.value?.items ?? []) as FrRow[] : [])
const unrepliedRows = computed<UnrepliedRow[]>(() =>
kind.value === 'unreplied' ? (resp.value?.items ?? []) as UnrepliedRow[] : [])
const lifecycleRows = computed<LifecycleRow[]>(() =>
kind.value === 'lifecycle' ? (resp.value?.items ?? []) as LifecycleRow[] : [])
const callRows = computed<CallRow[]>(() =>
kind.value === 'calls' ? (resp.value?.items ?? []) as CallRow[] : [])
// ---- 展示辅助 ----
// 渠道短标签:异常下钻列窄,微信/WA 两字(全称 channelLabel 留给宽列页面)
const CHANNEL_TAG: Record<string, string> = { wechat: '微信', whatsapp: 'WA' }
const chTag = (c: string): string => CHANNEL_TAG[c] ?? c
// yyyymmdd(20260810)→ 2026-08-10:最后互动日跨月/年,保留完整年份
const fmtDay = (d: number): string =>
`${String(d).slice(0, 4)}-${String(d).slice(4, 6)}-${String(d).slice(6, 8)}`
// ---- 行点击跳聊天 ----
function goChat(owner: string, peer: string, channel: string, msgId?: string): void {
if (channel === 'whatsapp') {
router.push({ name: 'chat', params: { channel: 'whatsapp', ownerId: owner, talkerId: peer } })
return
}
router.push({
name: 'staff-chat',
params: { ownerId: owner, tab: 'single', talkerId: peer },
query: msgId ? { msgid: msgId } : undefined,
})
}
// owner 理论恒非空(fact/未回清单均来自 owner 维度查询),防御判空不亏
function clickableRow({ row }: { row: { owner_account_id?: string } }): string {
return row.owner_account_id ? 'yk-clickable-row' : ''
}
function onFrRowClick(row: FrRow): void {
goChat(row.owner_account_id, row.peer_id, row.channel)
}
// 微信跳转带 ?msgid= 定位最后一条客户消息(同 UnrepliedView 惯例)
function onUnrepliedRowClick(row: UnrepliedRow): void {
if (!row.owner_account_id) return
goChat(row.owner_account_id, row.peer_id, row.channel, row.msg_id)
}
</script>
<style scoped>
.drill-alert {
margin-bottom: 12px;
}
.call-summary {
display: flex;
gap: 32px;
margin-bottom: 12px;
}
.sum-item {
display: flex;
flex-direction: column;
align-items: center;
}
.sum-num {
font-size: 22px;
font-weight: 600;
}
.sum-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.fallback {
margin-top: 8px;
}
.fallback-label {
font-size: 12px;
color: var(--el-text-color-secondary);
margin: 12px 0 4px;
}
.fallback-text {
font-size: 13px;
color: var(--el-text-color-primary);
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,119 @@
// useManagementFilters 单测(vitest)。本文件不进 app 构建/类型检查
// (tsconfig.app.json exclude __tests__,vitest 自行解析类型),
// `npm run test` 执行。
//
// composable 依赖 vue-router 注入,仓库无先例;node 环境(无 DOM),
// 选用 createApp + app.use(router) + app.runWithContext() 提供真实
// route/router 注入,不 mock、不挂载组件。
import { describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import { presetRange, useManagementFilters } from '../useManagementFilters'
/** 建一个带 memory 路由的 filters 实例;query 为初始 URL query。 */
async function setup(query: Record<string, string> = {}) {
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { render: () => null } }],
})
const app = createApp({ render: () => null })
app.use(router)
await router.push({ path: '/', query })
const filters = app.runWithContext(() => useManagementFilters())
return { router, filters }
}
const D = (ymdNum: number) => new Date(Math.floor(ymdNum / 10000), (Math.floor(ymdNum / 100) % 100) - 1, ymdNum % 100)
describe('presetRange(注入 today,确定性日期)', () => {
it('last7 → 含今天共 7 天(2026-09-10 → 20260904..20260910)', () => {
expect(presetRange('last7', D(20260910))).toEqual({ start: 20260904, end: 20260910 })
})
it('last30 → 30 天窗(2026-09-10 → 20260812..20260910)', () => {
expect(presetRange('last30', D(20260910))).toEqual({ start: 20260812, end: 20260910 })
})
it('thisMonth → 月初到今天(2026-09-10 → 20260901..20260910)', () => {
expect(presetRange('thisMonth', D(20260910))).toEqual({ start: 20260901, end: 20260910 })
})
it('lastMonth → 上月整月(2026-08-15 → 20260701..20260731,31 天月)', () => {
expect(presetRange('lastMonth', D(20260815))).toEqual({ start: 20260701, end: 20260731 })
})
it('lastMonth 30 天月(2026-07-15 → 20260601..20260630)', () => {
expect(presetRange('lastMonth', D(20260715))).toEqual({ start: 20260601, end: 20260630 })
})
it('lastMonth 跨年(2026-01-15 → 20251201..20251231)', () => {
expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 })
})
it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => {
expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
})
})
describe('useManagementFilters(URL query 为真源)', () => {
it('空 query → channel=all / start,end=last30 默认 / compare=previous', async () => {
const { filters } = await setup()
expect(filters.channel.value).toBe('all')
expect(filters.compare.value).toBe('previous')
// 默认值与 presetRange('last30')(真实今天)同源,同日执行必一致
expect(filters.start.value).toBe(presetRange('last30').start)
expect(filters.end.value).toBe(presetRange('last30').end)
})
it('合法 query → 原样读出(wechat / 日期 / team_median)', async () => {
const { filters } = await setup({
channel: 'wechat',
start: '20250101',
end: '20250131',
compare: 'team_median',
})
expect(filters.channel.value).toBe('wechat')
expect(filters.start.value).toBe(20250101)
expect(filters.end.value).toBe(20250131)
expect(filters.compare.value).toBe('team_median')
})
it('非法 query 值 → 回退默认(channel=xx / start=abc / end=19990101 早于下限 / compare=foo)', async () => {
const { filters } = await setup({ channel: 'xx', start: 'abc', end: '19990101', compare: 'foo' })
expect(filters.channel.value).toBe('all')
expect(filters.compare.value).toBe('previous')
expect(filters.start.value).toBe(presetRange('last30').start)
expect(filters.end.value).toBe(presetRange('last30').end)
})
it('apply({channel}) → query 更新且其余参数保留', async () => {
const { router, filters } = await setup({ compare: 'team_median' })
filters.apply({ channel: 'whatsapp' })
await vi.waitFor(() => expect(router.currentRoute.value.query.channel).toBe('whatsapp'))
expect(filters.channel.value).toBe('whatsapp')
// 未 patch 的 compare 原样保留
expect(router.currentRoute.value.query.compare).toBe('team_median')
expect(filters.compare.value).toBe('team_median')
})
it('applyPreset(预设) → 清掉 query 里的 start/end,默认值(仍为 last30)生效', async () => {
const { router, filters } = await setup({ start: '20250101', end: '20250131', channel: 'wechat' })
filters.applyPreset('last7')
await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBeUndefined())
expect(router.currentRoute.value.query.end).toBeUndefined()
// channel 不在清理范围,保留
expect(filters.channel.value).toBe('wechat')
// 清掉后回退默认(last30,与真实今天同源)
expect(filters.start.value).toBe(presetRange('last30').start)
expect(filters.end.value).toBe(presetRange('last30').end)
})
it("applyPreset('custom', {...}) → query 带具体日期", async () => {
const { router, filters } = await setup()
filters.applyPreset('custom', { start: 20260101, end: 20260131 })
await vi.waitFor(() => expect(router.currentRoute.value.query.start).toBe('20260101'))
expect(router.currentRoute.value.query.end).toBe('20260131')
expect(filters.start.value).toBe(20260101)
expect(filters.end.value).toBe(20260131)
})
})
@@ -0,0 +1,78 @@
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom'
export const CHANNELS = [
{ value: 'all', label: '全部渠道' },
{ value: 'wechat', label: '微信' },
{ value: 'whatsapp', label: 'WhatsApp' },
] as const
function ymd(d: Date): number {
return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate()
}
function addDays(d: Date, n: number): Date {
const x = new Date(d)
x.setDate(x.getDate() + n)
return x
}
/** 预设 → [start,end] yyyymmdd。 */
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
switch (p) {
case 'last7':
return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
case 'last30':
return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
case 'thisMonth':
return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
case 'lastMonth': {
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
return { start: ymd(first), end: ymd(last) }
}
}
// custom/未知:今天单日
return { start: ymd(t0), end: ymd(t0) }
}
export function useManagementFilters() {
const route = useRoute()
const router = useRouter()
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
const v = route.query.channel
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
})
const start = computed(() => {
const v = Number(route.query.start)
return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').start
})
const end = computed(() => {
const v = Number(route.query.end)
return Number.isFinite(v) && v >= 20000101 && v <= 99991231 ? v : presetRange('last30').end
})
const compare = computed(() => {
const v = route.query.compare
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
})
function apply(patch: Record<string, string | number | undefined>) {
const q: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
}
router.replace({ query: q })
}
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
else apply({ start: undefined, end: undefined })
}
return { channel, start, end, compare, apply, applyPreset, presetRange }
}
+10
View File
@@ -99,6 +99,16 @@ function logout(): void {
全局搜索
</el-menu-item>
<!-- 经营管理 -->
<el-sub-menu index="management">
<template #title>
<span class="yk-sidenav__group">经营管理</span>
</template>
<el-menu-item index="/management/dashboard" @click="goPlain('/management/dashboard')">
管理驾驶舱
</el-menu-item>
</el-sub-menu>
<!-- 数据分析 -->
<el-sub-menu index="analytics">
<template #title>
+7
View File
@@ -92,6 +92,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/analytics/SensitiveView.vue'),
meta: { title: '敏感词' },
},
{
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
path: '/management/dashboard',
name: 'management-dashboard',
component: () => import('@/views/management/ManagementDashboardView.vue'),
meta: { title: '管理驾驶舱' },
},
{
path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat',
+22
View File
@@ -55,6 +55,28 @@ export function formatDuration(seconds?: number | null): string {
return `${m}'${String(rem).padStart(2, '0')}"`
}
// ---------------------------------------------------------------------------
// 经营管理板块共享格式化(批次C)——自 DashboardView 本地 fmtFr/fmtPct 提升,
// 驾驶舱 KPI / 异常榜 / 下钻抽屉与旧看板共用同一展示口径。
// ---------------------------------------------------------------------------
/**
* 毫秒时长 → `<1h 显「X分Y秒」/ ≥1h 显「X小时Y分」`;null(无样本)显 `-`。
* 与 formatDuration(秒,聊天气泡口径)区分:此处是指标口径(首响等)。
*/
export function formatMsDuration(ms?: number | null): string {
if (ms == null) return '-'
const totalSec = Math.round(ms / 1000)
const mins = Math.floor(totalSec / 60)
if (mins < 60) return `${mins}分${totalSec % 60}秒`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
/** 比率(0-1) → 百分比 1 位小数(回复率/接通率等);null 显 `-`。 */
export function formatRatio(r?: number | null): string {
return r == null ? '-' : `${(r * 100).toFixed(1)}%`
}
/** Friendly label for a Channel value. */
export function channelLabel(channel?: string | null): string {
switch (channel) {
+11 -1
View File
@@ -53,6 +53,13 @@ const channelTyped = computed(() => props.channel as Channel)
/** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId)
/**
* 专注模式(?bare=1):群统计等列表入口的「查看详情」——隐藏会话侧栏,
* 对齐云客群详情页(无好友/群列表,纯聊天记录窗)。员工聊天窗口入口
* (不带 bare)侧栏常驻(2026-09-09 修复保持)。
*/
const isBare = computed(() => route.query.bare === '1')
/** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */
const activeType = computed<'friend' | 'group' | null>(() =>
isGroup.value ? 'group' : props.talkerId ? 'friend' : null,
@@ -344,8 +351,11 @@ onMounted(() => {
<div class="yk-chat__body">
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。 -->
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。
bare 模式(群统计「查看详情」入口)例外——纯聊天记录窗无侧栏,
对齐云客群详情页(2026-09-10 用户反馈)。 -->
<ChatSidebar
v-if="!isBare"
:channel="channelTyped"
:owner-id="props.ownerId"
:active-type="activeType"
+5 -27
View File
@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getAccounts, getGroups } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Account, Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
@@ -190,6 +189,9 @@ function viewChat(row: Group): void {
router.push({
name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id },
// bare=专注模式:无会话侧栏,对齐云客群详情页(2026-09-10)。
// 员工聊天窗口入口不带 bare,侧栏常驻。
query: row.channel === 'wechat' ? { bare: '1' } : undefined,
})
}
/** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
@@ -199,28 +201,6 @@ function viewChatFromDrawer(): void {
viewChat(row)
}
/**
* 微信群「导出HTML聊天记录」→ 后端 /api/export/chat-html(room_id)。
* 云客群列表导出按钮直接导出,无时间选择 modal(仅微信客户有「请选择时间」)。
* WA 群 tab 无此操作(云客 WA 群列表无导出)。
*/
async function exportGroupHtml(row: Group): Promise<void> {
if (!row.owner_account_id || !row.room_id) {
ElMessage.warning('该群缺少归属账号/群标识,无法导出')
return
}
try {
await downloadBlob('/export/chat-html', {
channel: row.channel,
owner_account_id: row.owner_account_id,
room_id: row.room_id,
})
ElMessage.success('导出已开始')
} catch (e) {
ElMessage.error(`导出失败,请稍后重试:${(e as Error).message}`)
}
}
// ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
const exporting = ref(false)
@@ -444,11 +424,9 @@ onMounted(() => {
<el-table-column label="沟通消息数" width="105" align="right">
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right" align="center">
<el-table-column label="操作" width="110" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click.stop="openDrawer(row)">查看详情</el-button>
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML</el-button>
<el-button link type="primary" size="small" @click.stop="viewChat(row)">查看详情</el-button>
</template>
</el-table-column>
</el-table>
+6 -14
View File
@@ -143,16 +143,16 @@
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
</el-table-column>
<el-table-column label="首响" align="right">
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
<template #default="{ row }">{{ formatMsDuration(row.fr_avg_ms) }}</template>
</el-table-column>
<el-table-column label="回复率" align="right">
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
<template #default="{ row }">{{ formatRatio(row.reply_rate) }}</template>
</el-table-column>
<el-table-column label="通话时长" align="right">
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
</el-table-column>
<el-table-column label="接通率" align="right">
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
<template #default="{ row }">{{ formatRatio(row.answer_rate) }}</template>
</el-table-column>
<el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
@@ -187,7 +187,7 @@ import {
type StaffRankingRow,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format'
import { formatMsDuration, formatRatio, formatTimeShort } from '@/utils/format'
const router = useRouter()
@@ -295,16 +295,8 @@ const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
// ── 排行新列格式化 ──
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
const fmtFr = (ms: number | null) => {
if (ms == null) return '-'
const totalSec = Math.round(ms / 1000)
const mins = Math.floor(totalSec / 60)
if (mins < 60) return `${mins}分${totalSec % 60}秒`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 百分比 1 位小数(回复率/接通率);null 显 '-'
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
// 首响/百分比用共享版(formatMsDuration/formatRatio,utils/format——批次C
// 驾驶舱组件同口径,原本地 fmtFr/fmtPct 已提升去重)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => {
if (!s) return '-'
@@ -0,0 +1,524 @@
<template>
<div class="mgmt-dash">
<!-- 筛选栏:渠道/时间(URL query 真源)+口径入口;aside=数据截至角标 -->
<ManagementFilterBar
:channel="channel"
:start="start"
:end="end"
:compare="compare"
@apply="apply"
@open-defs="defsVisible = true"
>
<template #aside>
<!-- trial 常显:dashboard 载荷无 trial 键(评分配置层字段),批次C/D
评分处于试算期(v2 §0 全默认+试算),批次E 转正时随配置联动 -->
<DataAsOfBadge
:data-as-of="resp?.data_as_of ?? null"
:metric-version="resp?.metric_version"
:trial="true"
:config-source="resp?.config_source"
/>
</template>
</ManagementFilterBar>
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
<el-alert v-if="loadError" type="error" :closable="false" class="dash-alert">
<template #title>
<span>{{ loadError }}</span>
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
</template>
</el-alert>
<!-- 空态:窗口内无任何业务数据(数据截至角标同步显「无数据」) -->
<el-alert
v-if="noData"
type="info"
title="该窗口无数据——试试调整时间范围"
:closable="false"
class="dash-alert"
/>
<!-- 六 KPI 卡:两行三卡(≥768px 三列/窄屏两列);后三卡行动入口直达处置页 -->
<el-row :gutter="12" class="block-row kpi-row" v-loading="loading">
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="活跃客户"
:value="kpis?.active_customers ?? null"
unit="人"
:hint="threadsHint"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="活跃员工"
:value="kpis?.active_employees ?? null"
unit="人"
hint="期内有数据天数>0 口径"
/>
</el-col>
<el-col :xs="12" :sm="8">
<!-- tooltip 说明混合 vs 1:1 口径差异,hover 才展开,卡面 hint 保持一行 -->
<el-tooltip
placement="top"
content="管理域消息量仅统计 1:1 私聊消息;「数据分析·数据看板」为私聊+群聊混合口径,两者数值不可直接对比"
>
<ManagementKpiCard
label="消息量"
:value="kpis?.msg_total ?? null"
unit="条"
hint="1:1口径·与数据看板口径不同"
/>
</el-tooltip>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="待回复"
:value="actions?.unreplied_2h ?? null"
unit="人"
hint="当前时点指标·不随所选窗口"
:danger="(actions?.unreplied_2h ?? 0) > 0"
to="/analytics/unreplied"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="即将流失"
:value="actions?.churn_real_30d ?? null"
unit="人"
to="/analytics/churn"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="风险待处理"
:value="actions?.sensitive_pending ?? null"
unit="条"
hint="当前时点指标·不随所选窗口"
to="/analytics/sensitive"
/>
</el-col>
</el-row>
<!-- 双趋势图:左沟通量(消息线+通话柱双 y 轴),右服务质量(首响分位线+回复率右轴) -->
<el-row :gutter="12" class="block-row">
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>客户沟通趋势</template>
<div ref="commEl" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>服务质量趋势</template>
<div ref="svcEl" class="chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 双条形图:左生命周期四态(横向),右员工维度均分(0-100,无样本灰) -->
<el-row :gutter="12" class="block-row">
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>生命周期分布</template>
<div ref="lifeEl" class="chart"></div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never" class="chart-card" v-loading="loading">
<template #header>员工维度分布</template>
<div ref="dimsEl" class="chart"></div>
</el-card>
</el-col>
</el-row>
<!-- 异常榜:critical 在前(后端已排);点行开下钻抽屉,两次点击到事实 -->
<el-card shadow="never" class="block-row anomaly-card" v-loading="loading">
<template #header>
异常榜
<span class="anomaly-sub">按严重度排序 · 点行查看明细</span>
</template>
<AnomalyList :anomalies="resp?.anomalies ?? []" :loading="loading" @drill="onDrill" />
</el-card>
<!-- 下钻抽屉:metric 类异常逐串/逐行明细,行点击跳聊天;employee 类批次D 占位 -->
<MetricDrilldownDrawer
v-model="drillVisible"
:card="drillCard"
:user-id="drillCard?.employee_user_id ?? ''"
:start="start"
:end="end"
:channel="channel"
/>
<!-- 指标口径抽屉:19 项定义+评分配置,自缓存 -->
<MetricDefsDrawer v-model="defsVisible" />
</div>
</template>
<script setup lang="ts">
// 管理驾驶舱(经营管理板块批次C首页):筛选(URL query 真源)→ 六 KPI →
// 双趋势(沟通量/服务质量)→ 生命周期四态 + 员工维度均分 → 异常榜(下钻
// 抽屉两次点击到事实)。管理者 5 分钟看整体:先 KPI 判断总体,异常卡
// 直达事实。载荷 /management/dashboard 单往返;compare 仅回显(对比值
// 批次D 员工表格呈现)。seq 守卫/双图 useChart/格式化均沿用项目惯例。
import { computed, ref, watch } from 'vue'
import {
getDashboard,
type AnomalyCard,
type DashboardResp,
} from '@/api/management'
import { useManagementFilters } from '@/composables/useManagementFilters'
import { useChart } from '@/composables/useChart'
import { formatMsDuration, formatRatio } from '@/utils/format'
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
import ManagementKpiCard from '@/components/management/ManagementKpiCard.vue'
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
import AnomalyList from '@/components/management/AnomalyList.vue'
import MetricDrilldownDrawer from '@/components/management/MetricDrilldownDrawer.vue'
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
// ── 筛选(URL query 为真源,刷新/分享保持)与加载 ──
const { channel, start, end, compare, apply } = useManagementFilters()
const resp = ref<DashboardResp | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
let seq = 0 // 竞态守卫:筛选快速切换时旧响应作废(项目惯例)
async function load(): Promise<void> {
const my = ++seq
loading.value = true
loadError.value = null
try {
const r = await getDashboard(channel.value, start.value, end.value, compare.value)
if (my !== seq) return
resp.value = r
} catch (e) {
// 失败保留上次成功数据(resp 不清空),仅标记 error 供 alert+重试
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
// immediate 首帧即取数,替代 onMounted;筛选任一变化重查
watch([channel, start, end, compare], load, { immediate: true })
const kpis = computed(() => resp.value?.kpis ?? null)
const actions = computed(() => resp.value?.action_summary ?? null)
// 活跃客户辅助行:串/被回(口径与 KPI 同载荷,管理域跨日精确串)
const threadsHint = computed(() => {
const k = kpis.value
if (!k) return undefined
return `串 ${k.cust_threads.toLocaleString()} · 被回 ${k.threads_answered.toLocaleString()}`
})
// 空态:核心 KPI 全 0(趋势按日脚手架恒非空,以 KPI 判空才可靠)
const noData = computed(() => {
const k = kpis.value
if (!k || loadError.value) return false
return !k.active_customers && !k.active_employees && !k.msg_total && !k.call_count
})
// ── 异常下钻 ──
const drillVisible = ref(false)
const drillCard = ref<AnomalyCard | null>(null)
const defsVisible = ref(false)
function onDrill(card: AnomalyCard): void {
drillCard.value = card
drillVisible.value = true
}
// ── 图表(四张,容器恒在 DOM——useChart 只 init 一次,不 v-if) ──
const commEl = ref<HTMLElement | null>(null)
const svcEl = ref<HTMLElement | null>(null)
const lifeEl = ref<HTMLElement | null>(null)
const dimsEl = ref<HTMLElement | null>(null)
const { render: renderComm } = useChart(commEl)
const { render: renderSvc } = useChart(svcEl)
const { render: renderLife } = useChart(lifeEl)
const { render: renderDims } = useChart(dimsEl)
// yyyymmdd(20260810)→ 08-10 短格式(轴/tooltip 共用)
const shortDay = (d: number): string => `${String(d).slice(4, 6)}-${String(d).slice(6)}`
// 大数压缩:轴刻度统一 1.1万 口径(与 DashboardView 一致)
const compact = (n: number): string =>
n >= 10000 ? `${(n / 10000).toFixed(1)}万` : n.toLocaleString()
// 服务质量左轴刻度:ms → 紧凑时长(轴上省秒,tooltip 仍用 formatMsDuration)
const fmtMsAxis = (v: number): string =>
v >= 3_600_000 ? `${(v / 3_600_000).toFixed(1)}时` : `${Math.round(v / 60_000)}分`
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
const axisInterval = (n: number): number => Math.max(0, Math.ceil(n / 6) - 1)
const AXIS_LABEL = { color: '#909399' }
const SPLIT_LINE = { lineStyle: { type: 'solid' as const, color: '#f0f1f3' } }
function drawComm(): void {
const t = resp.value?.communication_trend
if (!t || !t.length) return
const labels = t.map((p) => shortDay(p.day))
renderComm({
grid: { left: 56, right: 56, top: 40, bottom: 28 },
tooltip: { trigger: 'axis', valueFormatter: (v: unknown) => compact(Number(v)) },
legend: { data: ['消息量', '通话量'], top: 4, itemWidth: 14, textStyle: { color: '#606266' } },
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
name: '消息量',
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
splitLine: SPLIT_LINE,
},
{
type: 'value',
name: '通话量',
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
splitLine: { show: false },
},
],
series: [
{
name: '消息量',
type: 'line',
smooth: true,
yAxisIndex: 0,
lineStyle: { width: 2, color: '#409EFF' },
itemStyle: { color: '#409EFF' },
areaStyle: { opacity: 0.1, color: '#409EFF' },
showSymbol: false,
data: t.map((p) => p.msg_total),
},
{
name: '通话量',
type: 'bar',
yAxisIndex: 1,
barMaxWidth: 12,
itemStyle: { color: '#67C23A', borderRadius: [3, 3, 0, 0] },
data: t.map((p) => p.call_cnt),
},
],
})
}
// 服务质量 tooltip:ms→时长/率→百分比(null 显 '-',与断点语义一致)
interface TipParam { marker?: string; seriesName?: string; value?: number | null; axisValueLabel?: string }
function svcTooltip(ps: unknown): string {
const arr = (Array.isArray(ps) ? ps : [ps]) as TipParam[]
if (!arr.length) return ''
const rows = arr.map((p) => {
const v = typeof p.value === 'number' ? p.value : null
const text = v === null ? '-' : p.seriesName === '2h回复率' ? formatRatio(v) : formatMsDuration(v)
return `<div>${p.marker ?? ''}${p.seriesName}: ${text}</div>`
})
return `<div>${arr[0].axisValueLabel ?? ''}</div>${rows.join('')}`
}
function drawSvc(): void {
const t = resp.value?.service_trend
if (!t || !t.length) return
const labels = t.map((p) => shortDay(p.day))
renderSvc({
grid: { left: 56, right: 56, top: 40, bottom: 28 },
tooltip: { trigger: 'axis', formatter: svcTooltip },
legend: {
data: ['首响中位', '首响九分位', '2h回复率'],
top: 4, itemWidth: 14, textStyle: { color: '#606266' },
},
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: [
{
type: 'value',
name: '首响',
axisLabel: { formatter: fmtMsAxis, ...AXIS_LABEL },
splitLine: SPLIT_LINE,
},
{
// 右轴 0-1 率:不单列双轴则率线贴底不可读
type: 'value',
name: '2h回复率',
min: 0,
max: 1,
axisLabel: { formatter: (v: number) => formatRatio(v), ...AXIS_LABEL },
splitLine: { show: false },
},
],
series: [
// null(无样本日)=断点:connectNulls:false 显式钉死,不跨缺口连线
{
name: '首响中位',
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
lineStyle: { width: 2 },
data: t.map((p) => p.fr_p50_ms),
},
{
name: '首响九分位',
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
lineStyle: { width: 2 },
data: t.map((p) => p.fr_p90_ms),
},
{
name: '2h回复率',
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
yAxisIndex: 1,
lineStyle: { width: 2, color: '#67C23A' },
itemStyle: { color: '#67C23A' },
data: t.map((p) => p.reply_2h_rate),
},
],
})
}
// 生命周期四态:横向条形(名义类别,状态色——新客蓝/降温橙/沉睡红/回流绿)
const LIFE_META = [
{ key: 'new', label: '新客', color: '#409EFF' },
{ key: 'cooling', label: '降温', color: '#E6A23C' },
{ key: 'dormant', label: '沉睡', color: '#F56C6C' },
{ key: 'reactivated', label: '回流', color: '#67C23A' },
] as const
function drawLife(): void {
const lc = resp.value?.lifecycle
if (!lc) return
renderLife({
grid: { left: 8, right: 48, top: 8, bottom: 8, containLabel: true },
tooltip: { trigger: 'item', valueFormatter: (v: unknown) => compact(Number(v)) },
xAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
splitLine: SPLIT_LINE,
},
yAxis: {
type: 'category',
inverse: true, // 新客在顶部(生命周期顺序)
data: LIFE_META.map((m) => m.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#606266' },
},
series: [
{
type: 'bar',
barWidth: 14,
itemStyle: { borderRadius: [0, 4, 4, 0] },
label: {
show: true,
position: 'right',
formatter: (p: { value: unknown }) => compact(Number(p.value)),
color: '#606266',
fontSize: 11,
},
data: LIFE_META.map((m) => ({ value: lc[m.key], itemStyle: { color: m.color } })),
},
],
})
}
// 员工维度分布:pending_data 维(action/risk)恒无 scored/insufficient 计数,
// 以计数>0 过滤,只留真实参与评分的维度(customer/service/workload)
const dimRows = computed(() =>
Object.entries(resp.value?.dimension_summary ?? {})
.filter(([, d]) => d.scored_count > 0 || d.insufficient_count > 0)
.map(([key, d]) => ({ key, ...d })),
)
function dimsTooltip(p: { dataIndex?: number }): string {
const r = p.dataIndex != null ? dimRows.value[p.dataIndex] : undefined
if (!r) return ''
const avg = r.avg_score === null ? '-' : Math.round(r.avg_score)
return (
`<div>${r.name}</div>` +
`<div>均分 ${avg}(权重 ${r.weight}%)</div>` +
`<div>已评分 ${r.scored_count} · 样本不足 ${r.insufficient_count} · 待数据 ${r.pending_count}</div>`
)
}
function drawDims(): void {
const rows = dimRows.value
if (!rows.length) return
renderDims({
grid: { left: 40, right: 16, top: 24, bottom: 28, containLabel: true },
tooltip: { trigger: 'item', formatter: dimsTooltip },
xAxis: {
type: 'category',
data: rows.map((r) => r.name),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisLabel: { color: '#606266' },
},
yAxis: {
type: 'value',
min: 0,
max: 100, // 维度分定义域 0-100
axisLabel: AXIS_LABEL,
splitLine: SPLIT_LINE,
},
series: [
{
type: 'bar',
barWidth: 28,
itemStyle: { borderRadius: [3, 3, 0, 0] },
label: {
show: true,
position: 'top',
formatter: (p: { dataIndex: number; value: unknown }) =>
(rows[p.dataIndex]?.avg_score ?? null) === null ? '-' : compact(Number(p.value)),
color: '#606266',
fontSize: 11,
},
data: rows.map((r) =>
r.avg_score === null
// 无样本维度(全员未评):0 值灰条——有位置但不冒充分数
? { value: 0, itemStyle: { color: '#c0c4cc' } }
: { value: Math.round(r.avg_score), itemStyle: { color: '#409EFF' } },
),
},
],
})
}
// post:等 DOM 更新后再画,保证首帧容器已挂载;resp 单源,四图同刷
watch(resp, () => { drawComm(); drawSvc(); drawLife(); drawDims() }, { flush: 'post' })
</script>
<style scoped>
.dash-alert {
margin-top: 12px;
}
.retry-btn {
margin-left: 12px;
}
.block-row {
margin-top: 16px;
}
.kpi-row {
min-height: 100px;
}
.chart-card :deep(.el-card__body) {
padding: 12px 16px 8px;
}
.chart {
height: 300px;
}
.anomaly-sub {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
</style>