Compare commits

..

35 Commits

Author SHA1 Message Date
浅念 682249a3fd perf(management): message补无渠道前缀索引——修驾驶舱channel=all超时34s→1.3s
该回未回决赛圈点查(owner+talker定位末条消息)在channel=None时无渠道
前缀条件,现有索引全以channel打头用不上,服务器100万行实测dashboard
34~37s撞前端15s超时(ECONNABORTED);补(owner,talker,timestamp)索引
后实测1.3~1.8s。服务器已先行手工建索引止血,本迁移幂等兜底对齐。
模型+迁移双声明防autogenerate反DROP
2026-09-10 15:26:25 +08:00
浅念 80b721b84f fix(groups): 字节序排序按方言——MySQL CAST AS BINARY / SQLite COLLATE BINARY
collate("BINARY") 被 SQLAlchemy 反引号成标识符致 MySQL 500;SQLite 的
CAST AS BINARY 语义不符(排序键失效退化 room_id 序,测试失去判别力)。
按 session.bind.dialect 分支,两库都用真字节序。

726 测试绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:37:07 +08:00
浅念 acd6997bf0 fix(groups): 排序终判——owner 二进制字符串序(云客逐位吻合),弃 list_order
上版按 list_order 排不对:云客员工列表序 ≠ 群统计页员工序。真机对照
切换点员工终判:群统计员工顺序 = owner(微信号/wxid 混存)的**区分大小写
二进制字符串升序**(Nancyjade < csf7169708 < wxid_01gv…,大写在小写前,
逐位吻合),组内 room_id 字符串升序。COLLATE BINARY 双库通用,且无需
JOIN wechat_account。

726 测试绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:23:43 +08:00
浅念 253be99eca fix(groups): 微信群排序复刻云客+群详情去掉重复的「成员列表」字段
用户报:①群详情有成员墙后「成员列表」原始串字段重复;②排序与云客不一致。

- 排序:云客「微信群统计」真机判别=员工分组(云客员工列表序)+
  组内 chatRoomId 字符串升序——/api/groups 微信路径改 JOIN
  wechat_account 按 list_order(NULL 垫底按 owner asc)+room_id asc;
  WA 保持最近活跃优先
- 群详情:memberIds 从「其他字段」兜底区排除(成员墙已替代,不再
  以原始 wxid 串重复出现)

726 测试绿(默认排序断言更新为员工分组序)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:11:13 +08:00
浅念 e00d399195 docs(management): 批次F调度接入验证——滚动段耗时/幂等/本地scheduler镜像重建
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 14:11:02 +08:00
浅念 2ac63e3844 feat(management): 夜间全量脚本13/14步——fact与宽表全量自愈+终局快照三表行数 2026-09-10 14:00:34 +08:00
浅念 0dc5993af7 feat(management): 4h分析链接入管理域滚动段——独立事务隔离/fact活跃会话+宽表+通话滚动3天
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:59:44 +08:00
浅念 0b975a8289 docs(management): 批次F调度子批计划——tick管理段+夜间13/14步(Task F1-F3) 2026-09-10 13:55:11 +08:00
浅念 8f9ef747cc fix: prune 改逐行删——asyncmy 拒 tuple IN 的 list 参数
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:54:34 +08:00
浅念 3d2889bcde fix: prune 块漏 import sqlalchemy——NameError 中断对账
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 13:48:06 +08:00
浅念 2c7625c3e4 docs(management): 批次D全链路走查+验收记录——管理者动线七步/清单逐项/门禁全绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:44:38 +08:00
浅念 422a726ee8 feat(management): 驾驶舱KPI环比(prev_kpis+delta)+异常卡带员工名跳详情+列表进详情带筛选
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:20:28 +08:00
浅念 be0d6f844e feat(group-members): 群列表对账云客总数+群详情成员墙(头像+昵称)
用户报两点:①微信群列表与云客群统计总数不一致(实测云客 1,864 vs 本地
2,012——friend_list/es 时代残留的 148 行 owner 组合已不在 getChatroomList
返回里);②群详情成员列表全是 wxid 无法识别。

- chatroom-stats 全窗模式加对账:名单取满(>=声明 total)才删本地不在
  云客名单里的 chat_group(wechat)行——页脚从此与云客一字不差;短页宁
  可不对账防误删
- backfill_group_members 增强:头像列(消息最新一条 sender_head_url)+
  customer 兜底 UPDATE(member_id=friend_id 命中本司好友补昵称/头像,
  覆盖未发言成员);full_sync 8b 原位生效
- 群详情 drawer 成员墙:头像+昵称网格(群昵称>好友昵称>wxid 回退+wxid
  副行),替代原 memberIds 原始串;仅微信 tab(WA 群成员未派生)

722 后端测试绿,前端构建过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:11:42 +08:00
浅念 577617545f feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用
- EmployeeAnalyticsDetailView:头卡(账号跳聊天/试算分grade色/辅导记录批次E占位)
  +五维条(3可用实分bar+2待数据灰条,以metrics键缺失判pending_data) +五页签
  (section懒加载·loaded缓存一次·切页签replace进URL section) +概览四对比表
  (derive_metrics六键,unit感知) +服务/工作负载双线趋势(connectNulls:false)
  +异常区(AnomalyList showEmployee=false) +评分明细(metrics_detail全表
  +prev/baseline原始字典二级折叠) +伪卡片下钻(超时明细/降温沉睡名单/通话明细)
- seq守卫:detail单seq,每页签独立seq;筛选/换人清缓存重取;404→员工不存在结果态
- router /management/employees/:userId(props:true);MainLayout activeMenu
  沿用route.name→父路径先例映射management-employees-detail→/management/employees
- AnomalyList 预挂showEmployee prop(默认true;D3员工行消费,本页先传false)——D3清单项提前

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 12:08:35 +08:00
浅念 4e53a99629 fix(media): upsert_media MySQL 悬空 new.fetched_at——录音本地化停更多日
第二轮同步审计实测复现(最小用例 0.3s 即炸):upsert_media 的 update_cols
取了全部表列,而 values 只含 11 个业务列——server_default 列
(fetched_at/updated_at)不在 INSERT 列集,MySQL 编译 ON DUPLICATE 时生成
new.fetched_at 悬空引用 → 1054 Unknown column → 每夜 full 的
call_recordings 步骤(录音下载本地化)自生产部署起**每夜静默失败**,
新录音一直没本地化(异常被 SyncRun 吞成 failed 日志,序列继续)。

修:update_cols 只取 values 里实际有的列(对齐 _build_upsert_stmt 的
「只更新实际插入列」原则);updated_at 在冲突重下时刷 now()。

SQLite 分支(excluded 引用宽容)同逻辑修正;722 测试绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:54:14 +08:00
浅念 b72e957da2 feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV口径注释导出
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:44:03 +08:00
浅念 602bee8fdf fix(sync): 空对话「已扫」记忆——根除 2,468 个无消息对话的每轮全量翻史
前项审计的深挖:带窗验证发现活跃仅 27 对话,但访问集仍有 2,493 个——
2,468 个「从未扫过」的对话(纯语音/从未聊过客户)水位恒 0,拉完 0 条
仍是 unscanned,下一轮又被全量翻史(~2.6s/个,每 4h×每晚都翻)——
这才是消息任务时长的真正黑洞(昨夜 53,682 条绝大部分源于此)。

修:复用 sync_state 表记扫描标记(entity_type={channel}_conv_empty)——
- 拉到 0 条有效消息即打标(微信/WA 1:1)
- 发现层排除「已扫空且不活跃」的;客户一旦发消息(last_chat_time 进
  活跃窗)自动回到访问集——不会漏新消息
- 周日 full_history 强制全访问兜底(云客补历史场景)
- 空群(115 个,每轮 ~5min)本轮不动,量小

722 测试绿。预期效果:4h 序列访问集 2,493→~30,时长分钟级。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:24:59 +08:00
浅念 a9197f696a fix(sync): 同步链路审计修复——水位边界白拉/全量无超时/群活跃窗判定源
全项目数据同步审计发现四漏洞(前项为证据):

① 消息增量边界白拉(证据:昨夜 wa-messages fetched=53,682 而真实新消息
   仅 321 条,99.4% 是白拉白写的已入库旧消息)——云客 chatRecords/msg-chats
   的 start 参数是 inclusive(返回 ts>=start 含边界),四个消息实体(微信
   1:1/WA 1:1/微信群/WA 群)统一在归一化后过滤 ts<=watermark 的边界行,
   不计数不写库(省 99% 写放大,计数从此反映真实新增)
② full_sync_all.sh 无超时——极端情况(常态 10-20h)拖进次日相位把 02:00
   全量又挤掉(前两天事故同型);加 22h 上限
③ 周日 full-history 无超时——4 实体全历史可能 24h+,必挤周一;单实体
   加 6h 上限
④ 群消息活跃窗判定源是昨晚 full 的 chat_group.last_chat_time 快照——
   「昨天安静、今天突然活跃」的群会被 24h 窗口漏到明晚;4h 序列在
   group-messages 前加 chatroom-stats 全窗(~1min)刷新群活跃判定源

722 测试绿(4h 序列断言 9→10 命令+chatroom-stats 位序)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:08:15 +08:00
浅念 ddeda3fa22 feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:07:25 +08:00
浅念 2587e4dd08 docs(management): 批次D员工页计划——总览+详情+环比+预设补全(Task D0-D4)
评审3真问题全修:排序白名单四列(余列不排序)/服务页签图改P50P90双线
(员工级无每日率序列)/CSV扩csvExport注释行+rows适配;advisory全采:
异常卡补employee_name(一行后端)/showEmployee复用开关/formula_note归位/
伪卡片下钻衔接/compare仅显示态
2026-09-10 11:02:27 +08:00
浅念 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
44 changed files with 7243 additions and 178 deletions
@@ -241,3 +241,171 @@
| 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) | | 9 | 全量测试套件绿 | ✅ | **653 passed in 62s**(Task 8 本机实跑,2026-09-09) |
| 10 | 核实文档完整 | ✅ | 本文补齐 | | 10 | 核实文档完整 | ✅ | 本文补齐 |
| 11 | 写钩子零改动 | ✅ | 批次A全部 17 个 commits(27f91e5→0fce21a)逐一核查,无一触碰 storage/summary.py、storage/repos.py(repos.py 最近一次改动 d428953 属 customer_account_link 工作线,先于批次A) | | 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 |
---
# 批次D走查与验收(Task D4,2026-09-10)
批次D收尾:员工总览(b72e957)+ 员工详情(5776175)+ 驾驶舱环比/异常跳员工闭环(422a726,前置接口/时间预设 ddeda3f)。Playwright 真机七步全链路走查 + v1 §19.1 员工子集与批次D验收清单逐项核对。环境:后端 uvicorn 8000(免鉴权)+ 前端 vite dev 5173,视口 **1366px**。
## 0. 四门禁(全绿)
| 门禁 | 结果 |
|---|---|
| 后端 `pytest tests/ -q` | **723 passed** in 65.29s |
| 前端 `npm run build` | ✓ built in 1.03s(exit 0) |
| 前端 `npm run test`(vitest) | Test Files 2 passed / **Tests 23 passed** |
## 1. 七步动线走查记录(数字为真机实测,关键值已对 API 复核)
**① 驾驶舱默认近30天(20260812~20260910)**:三 KPI 卡环比箭头 ✓(客户端自 prev_kpis 按 (cur-prev)/prev 计算,API 复核数值吻合)——全渠道:活跃客户 **1,992人 ↓12.6%**(prev 2,278)/ 活跃员工 **40人 →0.0%**(40/40)/ 消息量 **37,742条 ↓21.0%**(prev 47,749);切微信:**979人 ↓25.3% / 40人 ↑2.6% / 27,949条 ↓29.6%**。方向与量级合理(等长紧邻前窗)。
**② 异常卡员工名 → 员工详情**:点微信异常榜员工名链接(落点须为 a.el-link;整行点击触发行下钻抽屉,属预期交互)→ 落地 `/management/employees/18B5EE6B1AD94B15A5CD1CDABCECAB96?channel=wechat&start=…&end=…`(驾驶舱当前筛选 query 随行 ✓)。
**③ 员工详情(朱晨曦,综合 21/E)**:五维条 **3 实 + 2 待数据**——合规风险/行动执行 pending 灰条占位,服务质量 0 分/客户经营 0 分/有效负载 100 分(API dimensions 逐项一致;行序随接口返回序,见遗留①)。四对比表(当前/上期/基线/中位)✓(cust_threads 62/114/107+中位)。服务质量页签双折线渲染(canvas 1074×300)→「查看超时明细」→ 首响P90 抽屉 **Total 11** → 点行进聊天:**17 条消息**,时间线与所点明细行吻合(客户 08-31 11:59:12 → 回复 17:24:11 ≈ 5小时24分)。
**④ 客户经营页签**:返回列表再进 → section=customer →「沉睡客户名单」抽屉 **Total 89** ✓。
**⑤ 员工总览对比**:勾选 2 人 → 对比抽屉并列两列指标 ✓(2-5 人为按钮启用门槛),关闭复原。
**⑥ 排序+翻页+CSV(微信渠道)**:综合分降序(服务端 sort)——第 1 页 **55→21**(林峰 55 领头),第 2 页 **11 / 1 / 其余「--」**(18 人 insufficient_sample **空值排尾** ✓)。导出 CSV:**3 行口径前言**(口径/窗口+渠道/试算版本)**+ 表头 + 41 行数据**,toast「已导出 41 条」。
**⑦ 控制台与持久化**:走查全程 console **0 errors**;修复遗留告警后(见 §3)全新加载 **0 warnings**。刷新保持 ✓——列表 view=service 刷新还原(Total 41)、详情返回后 section=service 还原。
**附**:9 档时间预设逐一真机核验:今日 0910~0910 / 昨日 0909 / 近7 0904~0910 / 近30 0812~0910 / 本周 0907~0910(周一周首 ✓)/ 上周 0831~0906 / 本月 0901~0910 / 上月 0801~0831 / 自定义 0901~0907。1366px 驾驶舱与员工总览双页截图目检:KPI 3×2 网阵、双趋势图、表格列均无塌陷/无横向溢出。
## 2. 验收清单核对
**v1 §19.1 员工子集(6 项)**
| # | 项 | 状态 | 依据 |
|---|---|---|---|
| 1 | 能查看所有员工多维状态 | ✅ | total=41(API=列表=CSV 行数三方一致);四视图(管理概览/工作负载/服务质量/客户经营)切换正常 |
| 2 | 任一分数可展开为指标/公式/样本/事实 | ✅ | 表头「?」→ 指标口径抽屉(19 项定义+公式+样本);行/详情下钻按钮 → 事实明细 → 聊天原文(§1 ③) |
| 3 | 四对比 | ✅ | 详情四对比表逐列可见(§1 ③:62/114/107/中位) |
| 4 | 样本不足不评分 | ✅ | 18 人 insufficient_sample 综合分显「--」(§1 ⑥),CSV 同口径 |
| 5 | 异常两次点击进事实 | ✅ | 异常卡员工名(第1击)→ 详情下钻按钮(第2击)→ 事实明细/聊天 |
| 6 | 默认排序非总分/消息量榜 | ✅ | 默认 sort_by=anomaly_severity(异常严重度优先) |
**批次D验收清单(5 项)**
| # | 项 | 状态 | 依据 |
|---|---|---|---|
| 1 | 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释 | ✅ | §1 ⑤⑥;CSV 前言 3 行 |
| 2 | 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/下钻进抽屉/账号跳聊天 | ✅ | §1 ③④;页签懒加载+loaded 缓存+section URL 还原 |
| 3 | 驾驶舱:三 KPI 环比箭头/异常卡可跳员工详情 | ✅ | §1 ①②(闭环打通) |
| 4 | 时间预设全套 9 档 | ✅ | §1 附 |
| 5 | URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐 | ✅ | §1 ⑦ + §0;后端改动=dashboard prev_kpis(D3,随测试提交) |
## 3. 走查发现并修复(随本任务提交)
| 问题 | 修复 |
|---|---|
| `AnomalyList.vue` 员工链接 `:underline="false"` 触发 Element Plus 废弃 API 告警——驾驶舱每次加载刷 **31 条** `[el-link] underline boolean deprecated`(异常榜每行一条) | 改 `underline="never"`(string API);修复后全新加载 0 errors 0 warnings |
## 4. 遗留观察项(不阻塞)
| # | 项 | 归档 |
|---|---|---|
| 1 | 五维条行序随接口返回序 | 合规风险/行动执行(待数据)排在三实维之前——非固定语义序;批次E 接线 risk/action 后自然消解,如需固定序前端按 dim 顺序数组排即可 |
| 2 | 走查任务字面 Step6 措辞偏差 | 「切视图服务质量→点综合分列排序」按字面不可执行(综合分列仅管理概览视图有);实际动线=管理概览排序→切服务质量视图导出,已按实际动线完成走查 |
| 3 | 空值显示 `-` 与 `--` 混用 | format.ts 的 formatMsDuration/formatRatio 按各自注释约定 null 显 `-`,员工总览本地数值/比率格式化显 `--`——同表跨列观感不一;纯观感项,批次E 顺手统一即可 |
---
# 批次F-调度接入验证(Task F3,2026-09-10)
F1(`_analytics_tick` 段2 管理域滚动,0dc5993)/ F2(夜间链管理步,2ac63e3)已提交后,本地真机直调 tick 验证 + 本地 scheduler 镜像重建。方法:临时脚本(已删)按 tests/test_scheduler_analytics_tick.py 的构造方式 `SyncScheduler(engine=create_async_engine(get_settings().db_url), session_maker=...)`,不 start、只 `await scheduler._analytics_tick()`,对六个上游函数挂计时包装(与测试 monkeypatch 同一延迟 import 打点位),连跑两次。
## 1. tick 两段实测(本地真机库,连跑两次)
| 段 | 内容 | 第1次(冷) | 第2次(暖) |
|---|---|---|---|
| 总计 | `_analytics_tick` 全程 | **2.78s** | **0.74s** |
| 段1 | recompute_days(staff 滚动3天) | 0.86s | 0.15s |
| 段1 | scanner.run_incremental(敏感词增量) | 0.14s | 0.02s |
| 段2 | pairs_active_in_days(fact 活跃对发现) | 0.04s | 0.02s |
| 段2 | recompute_pairs(fact 重算) | 0.00s | 0.00s |
| 段2 | recompute_metric_days(宽表滚动3天) | 1.51s | 0.52s |
| 段2 | recompute_call_days(通话滚动3天) | 0.22s | 0.02s |
(段值不含各自 session.commit;日志输出:`staff_days=0 scanned=10 hits=0` + `mgmt fact_pairs=0 wide_rows=168 call_rows=0`,两次一致。)
- **远低于 <60s 预算**。本地镜像已停同步 7 天,滚动窗(0908~0910)内无消息/通话活动——`staff_days=0`、`fact_pairs=0`、`call_rows=0` 属停同步后的数据真空而非缺陷;`wide_rows=168`= 3 天 × 56 行/天(39 员工,wechat 38 + whatsapp 18 行/天,双渠道员工两行),宽表对零活跃员工照常出零值行。段2 主成本=宽表重算,随员工数×天数扩展而非会话数,服务器侧活跃会话数百的形态下同量级。
- **幂等 ✓**:三表行数跑前 fact=68,476 / wide=12,371 / calls=1,163 → 第1次后 68,476 / **12,539** / 1,163 → 第2次后 68,476 / 12,539 / 1,163。wide 的 +168 为首跑补齐:0908~0910 三天行在表内原本不存在(本地夜间链停摆 7 天没写过),第1次 DELETE+INSERT 补齐后第2次行数纹丝不动——滚动窗语义下幂等成立。
## 2. 本地 scheduler 容器重建(范围仅本地;服务器镜像 2026-09-09 已重建不碰)
- `docker compose up -d --build scheduler`:镜像 `yunke-scheduler:latest`(`ded96dfc9625`,2026-09-10 构建)重建成功,容器 recreate + 起来。
- 冒烟:`docker exec yunke-scheduler-1 python -c "import app.sync.scheduler"` → ok;容器日志确认 **4 个 job 全注册**——3×`_safe_page_run`(full/messages/full-history)+ **`SyncScheduler._analytics_tick`(F1 新 job 已进夜间/4h 链)**,"Scheduler started" 正常。
- **调度状态**:验证完即 `docker stop`——重建前容器本就 Exited 7 天(本地不跑调度,避免与服务器调度器双跑同刷云客触发风控),维持 stopped 现状;容器已绑新镜像,用户随时 `docker compose start scheduler` 即以新代码上线。mysql/redis 未动。
## 3. 已知边界(归档)
| # | 项 | 说明 |
|---|---|---|
| 1 | phone_call 未进页面同步链 | 通话小表(employee_call_daily)暂从既有静态源重生成——本地 call_rows=0 即此边界的体现;审计 P0-5 由并行会话领地,不在此处理 |
| 2 | 补同步历史日期 | tick 只滚动当日+前2天;更早日期的修正由夜间全量(full_sync 汇总/管理步)覆盖语义兜底 |
| 3 | 本地数据真空 | 本地镜像停摆 7 天,0908~0910 无消息/通话——fact/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
@@ -190,7 +190,7 @@ DEFAULT_SCORE_CONFIG: dict = {
"workload": {"weight": 15, "name": "有效负载", "metrics": { "workload": {"weight": 15, "name": "有效负载", "metrics": {
"active_friends_band": {"type": "band", "metric": "active_friends", "active_friends_band": {"type": "band", "metric": "active_friends",
"low": 5, "high": 80, "low": 5, "high": 80,
"basis": "period_daily_avg"}, "basis": "period_distinct"},
"call_band": {"type": "band", "metric": "call_count", "call_band": {"type": "band", "metric": "call_count",
"low": 1, "high": 200, "basis": "period_total", "low": 1, "high": 200, "basis": "period_total",
"note": "通话低不扣分仅提示;band 得分下限 80"}, "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"(评分但标注)。 - 样本保护:`cust_threads < min_threads(30)` → 该 service/customer 指标 sample_status="insufficient",不评分;soft(10)~min 之间 "low_confidence"(评分但标注)。
- 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。 - 维度分 = 各指标得分的均值(等权);任一指标 None 时该指标剔除并按剩余归一;**全部 None → 维度 pending**。
- 总分 = Σ(可用维度 weight×dim_score)/Σ(可用维度 weight)×100;可用=pending_data 以外且维度分非 None;全 None → score_status="insufficient_sample"(total None)。 - 总分 = Σ(可用维度 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_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 | | reply_rate_dropped | reply_2h_rate < 基线×0.7 且当前≥soft 样本 | warning | metric_code=unreplied |
| activity_drop | 日均 msg_total < 基线日均×0.5 且基线≥min_days | warning | 员工详情 | | 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 日均。 卡片字段照 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 索引)+部署上线。
@@ -0,0 +1,172 @@
# 云客经营管理后台·批次D(员工总览+员工详情)实施计划
> **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:** 补上管理闭环最大断点——员工总览(41 人多维一张表)与员工详情(五维/页签/四对比/事实下钻),打通「异常→员工全景→事实」链路;附带驾驶舱 KPI 环比与时间预设补全(体验级小项)。
**Architecture:** 纯前端为主(消费批次B已就绪的 employees/detail/metrics sections/drilldown 端点)+ 两处小后端增强(dashboard kpis 加 prev 块;无新端点)。新页 `EmployeeAnalyticsView`/`EmployeeAnalyticsDetailView` + 对比抽屉;复用 C3 六组件(FilterBar/DataAsOfBadge/AnomalyList/MetricDrilldownDrawer/MetricDefsDrawer)与 useManagementFilters。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §6/§7/§13 + 批次B契约清单。
**Tech Stack:** 同批次C(Vue3+TS+Element Plus+useChart+vitest);后端 FastAPI 小改。
---
## 执行者必读
1. **消费的端点 as-built 形态**(写 client 前已核对 management.py:321-443):
- `GET /employees`(行结构见批次C client 的 EmployeeRow——已含 metrics/comparisons/score 摘要/anomaly_count/weight);
- `GET /employees/{id}`:`{user_id,name,is_on_job,accounts[{channel,account_id,nick_name,user_name}],metrics(22字段dict 含 user_id 键),score{total_score,grade,score_status,trial,dimensions{name,weight,score,status,metrics{code:{value,score,sample_size,sample_status,formula_note}}},metrics_detail{code:{value,prev,baseline,median,sample_size,sample_status,score,unit,name}}},anomalies[],prev(dict|null),baseline(dict|null),scope,data_as_of,metric_version,config_source}`;
- `GET /employees/{id}/metrics?section=`:`{section,metrics(该域字段dict),trend[{day,cols...}](无数据日计数0/分位None),comparisons(源指标名键),scope,data_as_of,metric_version}`;
- drilldown/metric-definitions 已有 client。
2. **前端惯例**同批次C必读(seq 守卫/useChart/URL query/csvExport.ts 全量翻页+中文 BOM/命名路由跳转)。
3. **不做**:辅导/快照(E);员工对比不新开 API(勾选 2-5 行客户端拼数据);「指标解释抽屉」= 复用 MetricDefsDrawer(点击列头 ? 图标打开)——不做每指标单独解释面板(v1 §6.5 的完整版二期)。
4. 仓库并行会话照旧;中文 conventional 提交;master 直提。
## 文件地图
| 文件 | 动作 |
|---|---|
| `yunkes/src/api/management.ts` | 补 getEmployeeDetail/getEmployeeMetrics + types(D0) |
| `yunkes/src/composables/useManagementFilters.ts` | 预设补 今日/昨日/本周/上周(D0) |
| `yunkes/src/views/management/EmployeeAnalyticsView.vue` | 新建(D1) |
| `yunkes/src/components/management/EmployeeCompareDrawer.vue` | 新建(D1,对比抽屉) |
| `yunkes/src/views/management/EmployeeAnalyticsDetailView.vue` | 新建(D2) |
| `yunkes/src/router/index.ts` + `MainLayout.vue` | 两路由+两菜单(D1/D2 各自) |
| `yunke-server/app/api/routers/management.py` + `api_assembly.py` | dashboard kpis 加 prev 块(D3) |
| `yunkes/src/components/management/ManagementKpiCard.vue` + `AnomalyList.vue` + 驾驶舱页 | delta 显示 + 员工跳详情(D3) |
---
### Task D0: client 补全 + 预设扩展
- [ ] `api/management.ts` 追加:
```typescript
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
// formula_note 在 dimensions[dim].metrics[code] 上(scoring.py 维度级明细),不在 metrics_detail
export interface EmployeeDetailResp {
user_id: string; name: string | null; is_on_job: boolean | null
accounts: EmployeeAccount[]
metrics: Record<string, number | string | null>
score: {
total_score: number | null; grade: string | null; score_status: string; trial: boolean
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
metrics_detail: Record<string, ScoreMetricDetail>
}
anomalies: AnomalyCard[]
prev: Record<string, number | string | null> | null
baseline: Record<string, number | string | null> | null
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
}
export interface SectionResp {
section: string; metrics: Record<string, number | null>
trend: Record<string, number | null>[]
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
scope: MgmtScope; data_as_of: number | null; metric_version: string
}
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
```
(写入前 curl 真机抽查 detail 响应键核对——同批次C C1 惯例,以真实响应为准修。)
- [ ] `useManagementFilters.ts` 预设补:`Preset` 加 `'today' | 'yesterday' | 'thisWeek' | 'lastWeek'`;`presetRange` 四分支(today=当天单日窗;yesterday=昨日单日;thisWeek=本周一..今天;lastWeek=上周一..周日,**周一为一周起点**);vitest 补 5 例(today/yesterday/thisWeek 周中+周一/lastWeek 跨月)。FilterBar 的预设选项组同步加(el-select options),分组显示:常用(今日/昨日/近7/近30)/日历(本周/上周/本月/上月)/自定义。
- [ ] vue-tsc + `npm run test`(23+ 过)+ commit `feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周`
### Task D1: 员工总览页
**Files:** Create `EmployeeAnalyticsView.vue` + `EmployeeCompareDrawer.vue`;改 router(`/management/employees`)+ MainLayout(管理驾驶舱下加「员工分析」菜单项)。
**页面结构**:
```text
ManagementFilterBar(复用;compare 选择器在此页透出——el-select 对比方式:上期/个人基线/团队中位数→apply({compare}))
工具行:keyword el-input(placeholder 姓名/ID搜索) | status el-select(全部/在职/离线) | 视图 el-radio-group×4
(management=概览/workload=负载/service=服务/customer=客户经营——切换列组)| 指标口径按钮 | 导出 CSV | DataAsOfBadge
主表 el-table(服务端排序 sort-change→query;行选择框多选 2-5 人→「对比」按钮开抽屉):
固定列:姓名(链接进详情)+ID 小字、在职 tag
management 视图列:综合分(total_score+grade tag+trial*)、异常(anomaly_count 红数字+weight)、活跃客户、消息量、串/被回、维度三列(customer/service/workload score 各 0-100 或「--」)
workload 视图列:活跃客户、活跃群(日均)、消息收/发、通话量/秒、串
service 视图列:首响P50/P90(formatMsDuration)、2h回复率、24h回复率(formatRatio)、被回/串
customer 视图列:新客/降温/沉睡/回流(期末+期内注)、活跃占比
列头 ? 图标→打开 MetricDefsDrawer(复用)
空数据/样本不足单元格显示「--」灰字(sample_status=insufficient 时 total 亦然)
分页 el-pagination(服务端 page/page_size 20;total)
```
**行为**:query 真源(view/keyword/status/sort_by/sort_order/page 全进 URL);seq 守卫;**服务端排序仅四列**(后端 sort_by 白名单:anomaly_severity[默认]/total_score/active_friends/cust_threads——其余列头不显示排序箭头,tooltip「该列不支持服务端排序」);`sort_by=total_score` 时 null 排最后由后端保证;compare 参数仅客户端显示态(employees 端点无此参数,行恒带全部四对比值);**CSV**:扩展 `utils/csvExport.ts` 支持可选前置注释行(`exportListToCsv(..., preamble?: string[])`——含口径说明如「1:1口径·窗口 20260810-20260908·试算」)+ fetchPage 适配器(/employees 返回 `{rows,total}`,适配为 csvExport 期望的 `{items,total}`),导出当前视图列全量翻页。
**EmployeeCompareDrawer**:props `{ rows: EmployeeRow[] }`(勾选行已有数据,不另发请求);内容:五维分对比条(3 可用维度每人一行,横向 bar 0-100)+ 关键指标表(活跃客户/P90/2h率/异常数 每人一列)+ 顶部说明「同口径同时窗对比,不产生优劣结论」;>5 人禁开。
**路由/菜单**:
```typescript
{ path: '/management/employees', name: 'management-employees',
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
meta: { title: '员工分析' } },
```
- [ ] vue-tsc/test/build + 真机初查(表格渲染 41 人/切视图列变/排序翻页/keyword/CSV 打开内容对)+ commit `feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV导出`
### Task D2: 员工详情页
**Files:** Create `EmployeeAnalyticsDetailView.vue`;router 加 `/management/employees/:userId`(props:true;**activeMenu 需高亮父菜单「员工分析」**——MainLayout 的 el-menu :default-active 绑定的是路径,嵌套路由时用 route.meta 或 watch 设 index——看 MainLayout 现有 active 处理方式,最简方案:菜单 index 用路径匹配 + 详情页 route path 前缀匹配高亮,若现结构不支持则 meta.activeMenu='/management/employees' 并在 el-menu :default-active 计算)。
**页面结构**:
```text
头部卡:姓名(+在职 tag)+ID | 账号清单(渠道 tag+account_id+昵称,点击跳 staff-chat/通讯录按渠道)
| 本期试算分大数字+grade tag+score_status(insufficient 时灰注「样本不足·不参与考核」)+trial*
| DataAsOfBadge | 返回列表链接
五维条(v1 §7.2 概览的核心):横向五条 bar——3 可用维度实分数值,action/risk 灰「待数据」占位
(ScoreBreakdown 简版:直接在页内画,不新建组件,el-progress/自定义 div)
el-tabs 五页签(懒加载:激活才调 getEmployeeMetrics):
概览: 六数卡取自**detail.metrics**(msg/活跃/串/通话——section 端点字段有限,期值以 detail 为准)
+ 四对比小表(comparisons 直渲染,源指标名行)
服务质量: 数卡 P50/P90 + 2h/24h回复率(**客户端从 metrics.reply_2h_count/cust_threads 派生,
与 derive_metrics 同式**——section 趋势无每日率序列) + 趋势图=**首响 P50/P90 双线(ms 轴)
±串数(次轴可选)**——不做率右轴(员工级无每日率数据,率的团队级序列在驾驶舱)
客户经营: 期末四态数卡(取自 detail.metrics) + 降温/沉睡下钻按钮(→DrilldownDrawer lifecycle 分支)
工作负载: 数卡取 detail.metrics(收发/活跃客户/群/通话量) + 趋势图用 section=workload 的
active_friends/active_rooms 双线(trend 列为日值 active_rooms)
通话: 通话四数(detail.metrics) + call_followup 下钻按钮
**页签下钻按钮与 MetricDrilldownDrawer 的衔接**:抽屉是 card 驱动——按钮合成最小伪卡片
`{title:'首响P90明细', drilldown:{type:'metric',query:{metric_code:'first_reply_p90',user_id}}}` 传入
异常区:AnomalyList(复用,@drill→MetricDrilldownDrawer 复用)
评分明细折叠面板(el-collapse「评分怎么算出来的」): metrics_detail 表(code/名称/当前/上期/基线/中位/样本/得分)
+ prev/baseline 原始 PeriodMetrics 两个 JSON 折叠(调试用,灰字注明)
```
**行为**:query 真源(channel/start/end/compare/section 进 URL,页签切换 replace);seq 守卫每页签独立(切页签取消旧渲染);404 员工→el-result 错误页+返回链接;「创建辅导记录」按钮**占位禁用**(tooltip「批次E上线」)。
- [ ] vue-tsc/test/build + 真机初查(王云青详情:五维条/五页签懒加载/异常/评分明细展开/账号跳聊天)+ commit `feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用`
### Task D3: 驾驶舱 KPI 环比 + 异常跳员工
**后端**(`management.py` dashboard + `api_assembly.py`):①dashboard 端点 kpis 加 `prev_kpis` 块(active_customers/active_employees/msg_total/cust_threads——dashboard 走 assemble_employees(with_prev=False),需加一次 prev 窗 period_metrics 全员调用,B5 实测三窗合计 ~0.3s,加一窗 +0.1s 量级)+ `msg_delta`((cur-prev)/prev,prev=0→null)。lifecycle/通话不加环比(期末/全渠道口径环比意义弱,注明)。②**异常卡补 `employee_name`**(assemble_employees 手里已有全员 name——组装 anomalies 时带上,一行);同步 AnomalyCard type。测试补:dashboard 响应含 prev_kpis 且数字=prev 窗聚合 + anomalies[0].employee_name 非空。
**前端**:KpiCard 加 `delta?: number | null` prop(↑绿↓红,方向语义由调用方定——涨=好传正绿;不传不显示);驾驶舱三卡接 delta(活跃客户/活跃员工/消息量);AnomalyList 的 title 下加员工行(`employee_name` 链接→`/management/employees/{user_id}`)+ 新增 `showEmployee?: boolean` prop(默认 true;D2 员工详情页复用时传 false 免自链接)。
- [ ] 后端 pytest(test_management_api.py 补 prev_kpis 断言)+ 前端走查三卡箭头/异常跳员工 + commit `feat(management): 驾驶舱KPI环比(prev_kpis+delta箭头)+异常卡跳员工详情`
### Task D4: 真机走查 + 验收
- [ ] 全链路走查(Playwright):驾驶舱→异常卡→**员工详情**(新链路!)→页签→下钻→聊天;员工总览→对比抽屉→CSV;1366px;刷新保持;
- [ ] 验收对照(v1 §19.1 员工子集):能查看所有员工多维状态 ✓/任一分数展开为指标公式样本事实 ✓(评分明细面板)/四对比 ✓/样本不足不评分显示 ✓/异常两次点击进事实 ✓/默认排序非总分榜 ✓;
- [ ] 四门禁(vue-tsc/vitest/build/后端 pytest);
- [ ] commit 收尾零散 + 走查记录追加核实文档「批次D走查」小节。
---
## 批次D验收清单
- [ ] 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释;
- [ ] 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/三处下钻按钮进抽屉/账号跳聊天;
- [ ] 驾驶舱:三 KPI 有环比箭头/异常卡可跳员工详情(闭环打通);
- [ ] 时间预设全套(今日/昨日/近7/近30/本周/上周/本月/上月/自定义);
- [ ] URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐。
## 批次E-F
E:followups 辅导闭环(写端点+schemas_management+详情页按钮激活)+ 考核快照(生成/冻结/列表/导出)。F:调度接入(analytics_tick 第三步+full_sync 13/14 步+scheduler 镜像重建还 9d3cc8e 旧账)+ dashboard 性能修复(unreplied 索引三选一)+ 服务器部署+回测试运行。
@@ -0,0 +1,142 @@
# 云客经营管理后台·批次F-调度子批(管理指标接入定时链)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 管理域三张派生表(first_reply_fact / employee_metric_daily / employee_call_daily)进入生产定时链——4h 滚动 + 夜间全量自愈,数据每天活起来(同步审计 2026-09-10 结论 #5 的管理域部分)。
**Architecture:** 两处接线:`scheduler.py _analytics_tick` 加**独立事务**的管理滚动段(近 3 天,失败不回滚既有 staff/敏感词段);`yunke-server/scripts/full_sync_all.sh` 在 11/12 步后加 13/14 步(fact 全量 → 宽表+通话全量)+ 终局快照补三表行数。CLI 已有(first-reply-facts / employee-metrics),无新端点。**边界:不碰审计文档 P0 的脚本加固/实体补抓(并行会话领地)**。
**Tech Stack:** 同批次A(APScheduler + SQLAlchemy async + bash)。
---
## 执行者必读
1. **上游函数签名(as-built)**:`first_reply_facts.pairs_active_in_days(session, days) / recompute_pairs(session, pairs)`;`storage/employee_metrics.recompute_metric_days(session, days) / recompute_call_days(session, days)`;`staff_summary.rolling_days(today, 3)`(返回当日+前2天列表)。全部幂等 delete+insert。
2. **隔离语义(v2 Task 12:指标失败不中断主流程)**:tick 内管理段用**独立 session+commit**——失败只回滚管理段自身,staff/敏感词段已提交不受影响(当前实现是单 try 单事务,改造为两段)。
3. **锁竞争既有结论**(tick docstring 已记录):与夜间 full_sync 重叠时 DELETE+INSERT 可能锁等待,非致命,幂等下轮自愈——管理段沿用同一语义,docstring 注明。
4. 脚本在 `yunke-server/scripts/full_sync_all.sh`(注意路径已从根 scripts/ 迁移);测试先例 `tests/test_scheduler_analytics_tick.py`。
5. 并行会话活动照旧;中文 conventional 提交。
## 任务
### Task F1: _analytics_tick 管理段 + 测试
**Files:** Modify `yunke-server/app/sync/scheduler.py` + `tests/test_scheduler_analytics_tick.py`
改造 `_analytics_tick`(:703)为两段:
```python
async def _analytics_tick(self) -> None:
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
两段独立事务:段1 staff+敏感词;段2 管理域(fact 近3天活跃会话 + 宽表/
通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连段1 已
提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器,不抢
``_page_lock``;与夜间 full_sync 13/14 步重叠时的锁竞争语义同段1
(非致命,幂等下轮自愈)。
"""
# ── 段1:staff 滚动 + 敏感词增量(既有,原样保留) ──
try:
async with self._session_maker() as session:
from app.analytics.sensitive import scanner
from app.storage.staff_summary import recompute_days, rolling_days
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
n = await recompute_days(session, rolling_days(today, 3))
scan = await scanner.run_incremental(session)
await session.commit()
logger.info(
"[analytics-tick] staff_days=%s scanned=%s hits=%s",
n, scan.get("scanned"), scan.get("hits"),
)
except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed")
# ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
try:
async with self._session_maker() as session:
from app.storage.employee_metrics import (
recompute_call_days,
recompute_metric_days,
)
from app.storage.first_reply_facts import (
pairs_active_in_days,
recompute_pairs,
)
from app.storage.staff_summary import rolling_days
from app.storage.summary import day_key
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
pairs = await pairs_active_in_days(session, rd)
f = await recompute_pairs(session, pairs)
w = await recompute_metric_days(session, rd)
c = await recompute_call_days(session, rd)
await session.commit()
logger.info(
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
f, w, c,
)
except Exception: # noqa: BLE001
logger.exception("management metrics tick failed")
```
(导入合并/去重按文件现状微调;today 变量两段各自取或共享只读。注:`recompute_metric_days` 内部会再查一次 `pairs_active_in_days`(与本段显式调用重复但廉价)——非冗余待修,fact 段需要显式拿 pairs 计数记日志。)
**测试追加**(照既有文件 mock 模式):
1. tick 调用顺序:staff recompute_days → 敏感 scan → pairs_active_in_days(rolling3) → recompute_pairs → recompute_metric_days → recompute_call_days;
2. **段2 失败隔离**:recompute_metric_days 抛异常 → staff recompute_days 与敏感 scan 仍被调用且各自 commit 成功、tick 不抛、异常被记(caplog);
3. rolling 窗口传参 = rolling_days(today,3)。
- [ ] `pytest tests/test_scheduler_analytics_tick.py -v` + 全量 -q → commit `feat(management): 4h分析链接入管理域滚动段——独立事务隔离/fact活跃会话+宽表+通话滚动3天`
### Task F2: full_sync_all.sh 13/14 步 + 快照行数
**Files:** Modify `yunke-server/scripts/full_sync_all.sh`
第 12 步之后(COMPLETE 横幅之前)加:
```bash
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
$PY -m app.sync.cli first-reply-facts
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
# 算法各自直算,放 fact 后保持派生链顺序语义)
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
$PY -m app.sync.cli employee-metrics
```
终局快照 TABLES 追加三行(放 daily stats rows 后):
```python
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
```
- [ ] `bash -n yunke-server/scripts/full_sync_all.sh` 语法过 + commit `feat(management): 夜间全量脚本13/14步——fact与宽表全量自愈+终局快照三表行数`
### Task F3: 本地 scheduler 容器重建 + 真机验证 + 归档
1. **真机验证滚动段**(直接跑 tick 逻辑,不等 4h):临时脚本或 `python -c` 用 scheduler 的 session_maker 调 `_analytics_tick()`(或手动等价:按 rolling 3 天调三个 recompute + commit)——记录耗时(预期 fact 活跃会话数十秒级内)+ 三表当日行数变化(recompute 后 updated 行 freshness);
2. **幂等复跑**:连跑两次行数一致;
3. **本地 scheduler 容器重建**(新代码进夜间链):`docker ps` 确认 scheduler 容器名 → `docker compose -f docker-compose.yml up -d --build scheduler`——**范围仅本地**(服务器 scheduler 镜像 2026-09-09 已重建过,服务器侧等部署时随 DEPLOY.md 流程走,本任务不碰);重建前后 `docker exec <scheduler> python -c "import app.sync.scheduler"` 冒烟;(已知后果不阻塞:phone_call 尚未进 4h/每日页面同步链[审计P0-5,并行会话领地]——通话小表滚动重算在补齐前只是从静态源幂等重生成);
4. 归档:耗时/行数/容器重建记录 → 核实文档「批次F-调度」小节 + commit。
- [ ] commit `docs(management): 批次F调度接入验证——滚动段耗时/幂等/容器重建(清9d3cc8e旧账)`
## 验收清单
- [ ] tick 两段隔离测试过(段2 异常不牵连段1);
- [ ] 夜间脚本 13/14 步 + 快照三表行数;
- [ ] 滚动段真机耗时记录(预期 <60s)+ 幂等;
- [ ] 本地 scheduler 容器已重建(新代码),夜间 02:00 链将含管理域;
- [ ] 全量测试绿;
- [ ] 未触碰审计 P0 领地(脚本加固/实体补抓——并行会话负责)。
## 后续(不在本子批)
E:辅导+快照;F 余项:dashboard 性能修复(unreplied 索引)、winsorize 决策、服务器部署(届时服务器 scheduler 镜像重建走 DEPLOY.md)。
+2
View File
@@ -49,6 +49,7 @@ from app.api.routers import (
export, export,
groups, groups,
login, login,
management,
media, media,
messages, messages,
moments, moments,
@@ -101,6 +102,7 @@ app.include_router(sessions.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(voice_clips.router) app.include_router(voice_clips.router)
app.include_router(analytics.router) app.include_router(analytics.router)
app.include_router(management.router)
@app.get("/api/health") @app.get("/api/health")
+30 -7
View File
@@ -101,14 +101,37 @@ async def list_groups(
list_stmt = sa.select(ChatGroup) list_stmt = sa.select(ChatGroup)
if conditions: if conditions:
list_stmt = list_stmt.where(*conditions) list_stmt = list_stmt.where(*conditions)
list_stmt = ( if (channel or "wechat") == "wechat":
list_stmt.order_by( # 云客「微信群统计」同款排序(2026-09-10 真机终判):owner_account_id
sa.nullslast(ChatGroup.last_chat_time.desc()), # (=微信号/wxid 混存,如 Nancyjade < csf7169708 < wxid_01gv…——大写
ChatGroup.room_id.asc(), # 在小写前=区分大小写)**二进制字节序升序**分组,组内 room_id 字符串
# 升序。字节序按方言:MySQL 用 CAST(col AS BINARY)(collate("BINARY")
# 会被 SQLAlchemy 反引号成标识符);SQLite 用 COLLATE BINARY(其 CAST
# AS BINARY 语义不符)。WA 保持最近活跃优先。
dialect = session.bind.dialect.name if session.bind is not None else ""
if dialect == "mysql":
owner_order = sa.literal_column(
"CAST(chat_group.owner_account_id AS BINARY)"
).asc()
else:
owner_order = ChatGroup.owner_account_id.collate("BINARY").asc()
list_stmt = (
list_stmt.order_by(
owner_order,
ChatGroup.room_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
)
else:
list_stmt = (
list_stmt.order_by(
sa.nullslast(ChatGroup.last_chat_time.desc()),
ChatGroup.room_id.asc(),
)
.limit(page_size)
.offset((page - 1) * page_size)
) )
.limit(page_size)
.offset((page - 1) * page_size)
)
rows = (await session.execute(list_stmt)).scalars().all() rows = (await session.execute(list_stmt)).scalars().all()
# ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ---- # ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ----
+571
View File
@@ -0,0 +1,571 @@
"""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,
}
# ---- 前窗环比基线(批次D):prev 窗全员一次 period_metrics,聚合式与
# kpis 完全同式(活跃客户=Σactive_friends、活跃员工=天数>0 计数、
# msg_total=Σ(sent+recv)、cust_threads=Σ)。lifecycle(期末存量)与
# 通话(全渠道口径,表无渠道维)不加环比——期末/混合口径的环比意义弱。
ps, pe = prev_window(start, end)
prev_pms = list((await period_metrics(session, ps, pe, ch)).values())
prev_kpis = {
# 全员求和口径同 kpis(跨员工共享客户可能重复计)
"active_customers": sum(p.active_friends for p in prev_pms),
"active_employees": sum(1 for p in prev_pms if p.days_with_data > 0),
"msg_total": sum(p.msg_sent + p.msg_recv for p in prev_pms),
"cust_threads": sum(p.cust_threads for p in prev_pms),
}
# 前窗无消息(prev=0)不算环比——None 而非 0/负∞
prev_msg = prev_kpis["msg_total"]
msg_delta = (
round((kpis["msg_total"] - prev_msg) / prev_msg, 3)
if prev_msg > 0 else None
)
# ---- 维度分布(全员 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 ----
# dashboard 卡片 enrich employee_name(staff 名,assemble 已在手)供前端
# 跳员工详情;员工详情端点的 anomalies 不带(自指无意义)
anomalies = [
{**a, "employee_name": data["name"]}
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,
"prev_kpis": prev_kpis,
"msg_delta": msg_delta,
"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,
}
+12
View File
@@ -537,6 +537,18 @@ class Message(Base):
"ix_message_fetched_at", "ix_message_fetched_at",
"fetched_at", "fetched_at",
), ),
# 该回未回决赛圈点查(owner+talker 定位末条消息)在 channel=None
# (全平台计数——管理驾驶舱 action_counts 复用)时无渠道前缀条件,
# 上面全部以 channel 打头的索引用不上,逐候选退化扫表+filesort:
# 2026-09-10 服务器实测 dashboard 34~37s 撞前端 15s 超时
# (ECONNABORTED);补无渠道前缀索引后 1.3~1.8s(100 万行在线建
# 索引 49s)。「模型+迁移双声明」防 alembic autogenerate 反 DROP。
Index(
"ix_message_owner_talker_time",
"owner_account_id",
"talker_id",
"timestamp",
),
) )
+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) 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)。 """最近邻秩分位 ceil(q·n) 的样本值,q=q_num/q_den(1/2、9/10)。
整数算术 rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n] 公有供同包 api_assembly(B4 服务趋势)复用;整数算术
(与 math.ceil(q·n) 等价,与批次A _percentile 同口径);空列表→None rank=(q_num*n + q_den - 1)//q_den 对正整数 n 恒落在 [1,n]
(与 0 严格区分)。 (与 math.ceil(q·n) 等价,与批次A storage 层 _percentile 同口径——
后者为不同层单独保留);空列表→None(与 0 严格区分)。
""" """
if not vals: if not vals:
return None return None
@@ -246,8 +247,8 @@ async def period_metrics(
if uid is not None and uid in target: if uid is not None and uid in target:
fr_by_user.setdefault(uid, []).append(int(fr)) fr_by_user.setdefault(uid, []).append(int(fr))
for uid, frs in fr_by_user.items(): for uid, frs in fr_by_user.items():
acc[uid]["fr_p50_ms"] = _percentile(frs, 1, 2) acc[uid]["fr_p50_ms"] = percentile(frs, 1, 2)
acc[uid]["fr_p90_ms"] = _percentile(frs, 9, 10) acc[uid]["fr_p90_ms"] = percentile(frs, 9, 10)
# 2h/24h 计数与分位同源同窗——回复率分子分母口径一致 # 2h/24h 计数与分位同源同窗——回复率分子分母口径一致
acc[uid]["reply_2h_count"] = sum(1 for fr in frs if fr <= 7_200_000) 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) 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 且严格降序 - grade_bounds 须含 A/B/C/D 且严格降序
- 指标 code 跨维度不得重复:metrics_detail 为扁平 dict, - 指标 code 跨维度不得重复:metrics_detail 为扁平 dict,
同名 code 会相互覆盖(后写胜出) 同名 code 会相互覆盖(后写胜出)
- 源指标名跨维度不得重复:employees 行 comparisons 按源名建键
(api_assembly._code_to_source),两个 code 指向同一源名会静默覆盖
""" """
problems: list[str] = [] problems: list[str] = []
dims = config.get("dimensions", {}) 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']})" f"(当前 A={gb['A']} B={gb['B']} C={gb['C']} D={gb['D']})"
) )
seen_codes: dict[str, str] = {} seen_codes: dict[str, str] = {}
seen_sources: dict[str, str] = {}
for key, dim in dims.items(): for key, dim in dims.items():
if dim.get("status") == "pending_data": if dim.get("status") == "pending_data":
continue continue
@@ -133,6 +136,13 @@ def validate_config(config: dict) -> list[str]:
problems.append(f"指标 code 重复定义于多维度: {code}") problems.append(f"指标 code 重复定义于多维度: {code}")
seen_codes[code] = key seen_codes[code] = key
src = spec.get("metric", code) 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: if src not in METRIC_DEFINITIONS:
problems.append(f"{key}.{code} 引用未定义指标 {src}") problems.append(f"{key}.{code} 引用未定义指标 {src}")
mtype = spec.get("type") mtype = spec.get("type")
+9 -2
View File
@@ -803,8 +803,13 @@ async def upsert_media(
dialect = await _dialect_name(session) dialect = await _dialect_name(session)
pk_cols = _pk_col_names(Media) pk_cols = _pk_col_names(Media)
# 只更新实际插入的列(2026-09-10 生产修复):此前取全部表列,server_default
# 列(fetched_at/updated_at)不在 values 里 → MySQL 编译出
# ``new.fetched_at`` 悬空引用 → 每夜 call_recordings 步骤 1054 静默
# 失败,录音本地化停更多日。
update_cols = [ update_cols = [
c.name for c in Media.__table__.columns if c.name not in pk_cols c.name for c in Media.__table__.columns
if c.name not in pk_cols and c.name in values
] ]
media_cols = Media.__table__.c media_cols = Media.__table__.c
@@ -812,7 +817,8 @@ async def upsert_media(
"""Map each updated column to its conflict-set expression. """Map each updated column to its conflict-set expression.
Volatile download artifacts use ``COALESCE(incoming, existing)``; every Volatile download artifacts use ``COALESCE(incoming, existing)``; every
other column is refreshed directly from the incoming row. other column is refreshed directly from the incoming row. ``updated_at``
刷新为 now()(冲突重下时反映最新一次落盘)。
""" """
set_: dict[str, Any] = {} set_: dict[str, Any] = {}
for name in update_cols: for name in update_cols:
@@ -820,6 +826,7 @@ async def upsert_media(
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name]) set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
else: else:
set_[name] = other_cols[name] set_[name] = other_cols[name]
set_["updated_at"] = sa.func.now()
return set_ return set_
if dialect == "sqlite": if dialect == "sqlite":
+122 -51
View File
@@ -602,50 +602,77 @@ class SyncScheduler:
if not bash: if not bash:
logger.error("page sync 'full': bash not found on PATH") logger.error("page sync 'full': bash not found on PATH")
return return
commands: list[list[str]] = [[bash, "scripts/full_sync_all.sh"]] commands = [([bash, "scripts/full_sync_all.sh"], 22 * 3600)] # 22h 上限:防极端情况拖进次日相位(全量常态 10-20h)
elif kind == "messages": elif kind == "messages":
# 先刷客户列表(最近沟通时间是云客列表的实时排序键,friends 快照 # 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
# 滞后会让本地排序与云客第一页完全不同,2026-08-20),再抓全部四类 # ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
# 消息增量(2026-08-21 全接入:水位增量后单对话 1 页,可承受); # 更新时间/最新消息时间」从此最多滞后 4h(此前只在每日
# 末尾追加员工朋友圈浅扫(2026-08-25,max-pages 3 即每员工最多 # full 刷,长任务挤掉 full 就停更);
# 3 页,新帖通常落第一页;全量翻页由每日 full 走 full_sync_all.sh) # ② 消息实体只扫**近 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),
# chatroom-stats 全窗(~1min):刷 chat_group.last_chat_time——
# 群消息活跃窗的判定源。不加则窗口用的是昨晚 full 的快照,
# 「昨天安静、今天突然活跃」的群会被窗口漏到明晚(2026-09-10)
("chatroom-stats", None, 1800),
("group-messages", 24, 4800),
("wa-group-messages", 24, 2400),
("moments", None, 2400),
]
commands = [ 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",
sys.executable, "--entity",
"scripts/scrape_via_page.py", entity,
"--entity", ]
e, + (
"--max-conversations", [
"99999", "--max-conversations",
] "99999",
for e in ("messages", "wa-messages", "group-messages", "wa-group-messages") "--active-window-hours",
] + [ str(window),
[ ]
sys.executable, if window
"scripts/scrape_via_page.py", else (
"--entity", ["--max-pages", "3"]
"moments", if entity == "moments"
"--max-pages", else []
"3", )
] ),
timeout,
)
for entity, window, timeout in msg_entities
] ]
elif kind == "full-history": elif kind == "full-history":
# 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的 # 每周全量对齐(周日 05:00):强制全量翻历史,对齐增量模式抓不到的
# 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21) # 撤回/编辑消息 + 彻底消除 lastChatTime 滞后窗口(2026-08-21)
entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"] entities = ["messages", "group-messages", "wa-messages", "wa-group-messages"]
commands = [ commands = [
[ (
sys.executable, [
"scripts/scrape_via_page.py", sys.executable,
"--entity", "scripts/scrape_via_page.py",
e, "--entity",
"--max-conversations", e,
"99999", "--max-conversations",
"--full-history", "99999",
] "--full-history",
],
6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
)
for e in entities for e in entities
] ]
else: else:
@@ -653,8 +680,8 @@ class SyncScheduler:
return return
rc = 0 rc = 0
for args in commands: for args, timeout in commands:
rc = await self._page_subprocess(args) or rc rc = await self._page_subprocess(args, timeout=timeout) or rc
if rc != 0: if rc != 0:
logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc) logger.warning("page sync %s: command failed rc=%s, skipping rest", kind, rc)
break break
@@ -674,25 +701,27 @@ class SyncScheduler:
print(line, flush=True) print(line, flush=True)
async def _analytics_tick(self) -> None: async def _analytics_tick(self) -> None:
"""4h 分析链(stat_daily_staff 滚动3天 + 敏感词增量)——异常只记日志不断链。 """4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
纯 DB 无浏览器子进程,不抢 ``_page_lock``(DB 写毫秒级; 两段独立事务:段1 staff+敏感词(既有);段2 管理域(fact 近3天活跃会话
``max_instances=1`` 防自身重叠即可)。与夜间 full_sync 的 + 宽表/通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连
staff-daily-stats 步骤重叠时,两侧 DELETE+INSERT 在重叠 PK 范围上 段1 已提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器
可能死锁/锁等待——非致命:tick 吞异常下轮重试,夜间脚本无 set -e 子进程,不抢 ``_page_lock``;``max_instances=1`` 防自身重叠即可。与
次日自愈(UNIQUE/PK 幂等只保证串行提交下不重复,不消锁竞争)。 夜间 full_sync 的汇总/管理步重叠时,两侧 DELETE+INSERT 在重叠 PK
范围可能锁等待——非致命:tick 吞异常下轮重试,夜间脚本幂等自愈。
时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch 时区:``day_key`` 内部钉死 Asia/Shanghai,喂 UTC epoch
(``time.time()*1000``)即无歧义——用 epoch 而非 naive datetime (``time.time()*1000``)即无歧义。
只为读法直接,数学上两者经本地时区往返等价。
""" """
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
# ── 段1:staff 滚动 + 敏感词增量(既有,语义原样) ──
try: try:
async with self._session_maker() as session: async with self._session_maker() as session:
from app.analytics.sensitive import scanner from app.analytics.sensitive import scanner
from app.storage.staff_summary import recompute_days, rolling_days from app.storage.staff_summary import recompute_days, rolling_days
from app.storage.summary import day_key
today = day_key(int(time.time() * 1000))
n = await recompute_days(session, rolling_days(today, 3)) n = await recompute_days(session, rolling_days(today, 3))
scan = await scanner.run_incremental(session) scan = await scanner.run_incremental(session)
await session.commit() await session.commit()
@@ -703,8 +732,39 @@ class SyncScheduler:
except Exception: # noqa: BLE001 — scheduler MUST NOT crash except Exception: # noqa: BLE001 — scheduler MUST NOT crash
logger.exception("analytics tick failed") logger.exception("analytics tick failed")
async def _page_subprocess(self, args: list[str]) -> int: # ── 段2:管理域滚动(fact 活跃会话 + 宽表/通话,当日+滚动3天) ──
"""启动页面子进程并等待结束;继承环境 + 补 DB_URL/PYTHONIOENCODING。""" try:
async with self._session_maker() as session:
from app.storage.employee_metrics import (
recompute_call_days,
recompute_metric_days,
)
from app.storage.first_reply_facts import (
pairs_active_in_days,
recompute_pairs,
)
from app.storage.staff_summary import rolling_days
rd = rolling_days(today, 3)
pairs = await pairs_active_in_days(session, rd)
f = await recompute_pairs(session, pairs)
w = await recompute_metric_days(session, rd)
c = await recompute_call_days(session, rd)
await session.commit()
logger.info(
"[analytics-tick] mgmt fact_pairs=%s wide_rows=%s call_rows=%s",
f, w, c,
)
except Exception: # noqa: BLE001
logger.exception("management metrics tick failed")
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"} env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
settings = get_settings() settings = get_settings()
if settings.db_url and "DB_URL" not in env: if settings.db_url and "DB_URL" not in env:
@@ -714,7 +774,18 @@ class SyncScheduler:
cwd=str(REPO_ROOT), cwd=str(REPO_ROOT),
env=env, 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( async def _safe_run(
self, entity: str, *, log: bool = True self, entity: str, *, log: bool = True
@@ -0,0 +1,41 @@
"""message 补无渠道前缀索引 (owner_account_id, talker_id, timestamp)
Revision ID: d8e9f0a1b2c3
Revises: c9d0e1f2a3b4
Create Date: 2026-09-10
背景:该回未回决赛圈点查在 channel=None(管理驾驶舱 action_counts)时
无渠道前缀条件,现有索引全部以 channel 打头用不上——服务器 100 万行
实测 dashboard 34~37s 撞前端 15s 超时;索引落地后 1.3~1.8s。
幂等:sa.inspect 先查,存在即跳过(服务器已先行手工建同名索引,本迁移
兜底对齐 schema 版本)。「模型+迁移双声明」防 autogenerate 反 DROP。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "d8e9f0a1b2c3"
down_revision: str | Sequence[str] | None = "c9d0e1f2a3b4"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_IDX = "ix_message_owner_talker_time"
def _has_index(bind) -> bool:
insp = sa.inspect(bind)
if not insp.has_table("message"):
return False
return _IDX in {ix["name"] for ix in insp.get_indexes("message")}
def upgrade() -> None:
if not _has_index(op.get_bind()):
op.create_index(_IDX, "message",
["owner_account_id", "talker_id", "timestamp"])
def downgrade() -> None:
if _has_index(op.get_bind()):
op.drop_index(_IDX, table_name="message")
+34 -12
View File
@@ -1,12 +1,15 @@
#!/usr/bin/env python #!/usr/bin/env python
"""从群消息派生 group_member(发言者=成员)。 """从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到, 云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其 2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。 sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖
所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为, 未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。 命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。
幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。
用法: 用法:
cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \ cd yunke-server && DB_URL="sqlite+aiosqlite:///./smoke.db" \
@@ -26,13 +29,14 @@ from app.storage.repos import _upsert_one
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backfill_group_members") log = logging.getLogger("backfill_group_members")
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的 # row_number() 窗口取 timestamp 最新一行的 sender_name/sender_head_url:
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用 # MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为 # 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为
SQL = sa_text(""" SQL = sa_text("""
SELECT room_id, talker_id, sender_name, last_ts, msg_count SELECT room_id, talker_id, sender_name, sender_head_url, last_ts, msg_count
FROM ( FROM (
SELECT room_id, talker_id, sender_name, timestamp AS last_ts, SELECT room_id, talker_id, sender_name, sender_head_url,
timestamp AS last_ts,
count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count, count(*) OVER (PARTITION BY room_id, talker_id) AS msg_count,
row_number() OVER ( row_number() OVER (
PARTITION BY room_id, talker_id ORDER BY timestamp DESC PARTITION BY room_id, talker_id ORDER BY timestamp DESC
@@ -44,6 +48,17 @@ FROM (
WHERE rn = 1 WHERE rn = 1
""") """)
# 未发言成员兜底:member_id 命中 customer(本司好友)时补 NULL 的昵称/头像。
# customer 全局一人一行(PK=friend_id),join 不放大行数;COALESCE 只补不清。
CUSTOMER_FALLBACK = sa_text("""
UPDATE group_member gm
JOIN customer c ON c.friend_id = gm.member_id AND c.channel = gm.channel
SET gm.nickname = COALESCE(gm.nickname, c.friend_nickname),
gm.avatar = COALESCE(gm.avatar, c.head_image_url)
WHERE gm.channel = 'wechat'
AND (gm.nickname IS NULL OR gm.avatar IS NULL)
""")
async def main() -> None: async def main() -> None:
async with async_engine.connect() as conn: async with async_engine.connect() as conn:
@@ -51,12 +66,13 @@ async def main() -> None:
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对") log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
async with async_session_maker() as session: async with async_session_maker() as session:
for room_id, talker_id, sender_name, last_ts, msg_count in rows: for room_id, talker_id, sender_name, head_url, last_ts, msg_count in rows:
await _upsert_one(session, GroupMember, { await _upsert_one(session, GroupMember, {
"channel": "wechat", "channel": "wechat",
"room_id": room_id, "room_id": room_id,
"member_id": talker_id, "member_id": talker_id,
"nickname": sender_name, "nickname": sender_name,
"avatar": head_url,
# raw_json 附加派生统计,抽屉可见 # raw_json 附加派生统计,抽屉可见
"raw_json": { "raw_json": {
"derived_from": "group_messages", "derived_from": "group_messages",
@@ -65,7 +81,13 @@ async def main() -> None:
}, },
}) })
await session.commit() await session.commit()
log.info(f"DONE: group_member upserted={len(rows)}")
# 兜底补未发言成员(独立事务,幂等)
async with async_engine.begin() as conn:
res = await conn.execute(CUSTOMER_FALLBACK)
log.info(f"customer 兜底补全 {res.rowcount} 行昵称/头像")
log.info("DONE: group_member upsert 完成")
if __name__ == "__main__": if __name__ == "__main__":
+12
View File
@@ -59,6 +59,15 @@ $PY -m app.sync.cli daily-stats
# A1 员工指标读路径依赖此表,2026-09-04) # A1 员工指标读路径依赖此表,2026-09-04)
echo "$(date) --- 12/12 Staff-daily-stats rebuild ---" echo "$(date) --- 12/12 Staff-daily-stats rebuild ---"
$PY -m app.sync.cli staff-daily-stats $PY -m app.sync.cli staff-daily-stats
# 13:首响事实表全量重建(管理域,跨日精确串;批次A)——会话发现读
# msg_daily_stats(fact 行直算读 message),故须在 11 步后;与 11/12
# 同为夜间自愈链(绕过钩子的写入由全量重算纠正)
echo "$(date) --- 13/14 First-reply-facts rebuild ---"
$PY -m app.sync.cli first-reply-facts
# 14:管理宽表+通话小表全量重建(驾驶舱/员工页读路径;串类列与 fact 同
# 算法各自直算,放 fact 后保持派生链顺序语义)
echo "$(date) --- 14/14 Employee-metrics rebuild ---"
$PY -m app.sync.cli employee-metrics
echo "==============================================" echo "=============================================="
echo "$(date) === FULL RE-SYNC COMPLETE ===" echo "$(date) === FULL RE-SYNC COMPLETE ==="
@@ -84,6 +93,9 @@ TABLES = [
("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"), ("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"), ("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"), ("daily stats rows", "SELECT COUNT(*) FROM msg_daily_stats"),
("first reply facts", "SELECT COUNT(*) FROM first_reply_fact"),
("employee metric rows", "SELECT COUNT(*) FROM employee_metric_daily"),
("employee call rows", "SELECT COUNT(*) FROM employee_call_daily"),
("voice calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"), ("voice calls", "SELECT COUNT(*) FROM phone_call WHERE channel IN ('voice_call','wa_voice_call')"),
("voice clips", "SELECT COUNT(*) FROM voice_clip"), ("voice clips", "SELECT COUNT(*) FROM voice_clip"),
("moments posts", "SELECT COUNT(*) FROM moments_post"), ("moments posts", "SELECT COUNT(*) FROM moments_post"),
+194 -34
View File
@@ -514,7 +514,50 @@ async def paginate_chats(fetch_page, end_ms: int, max_pages: int = 200, sleep=No
await sleep(1) await sleep(1)
async def _discover_conversations(channel: str, voice_channel: str) -> list[tuple[str, str]]: # ---------------------------------------------------------------------------
# 空对话/空群「已扫」记忆(2026-09-10 同步审计):从未有消息的对话(纯语音/
# 未聊过客户,WA 实测 2,468 个)水位恒 0,每轮都被当 unscanned 全量翻史
# (~2.6s/个)——时长黑洞本体。拉到 0 条有效消息即记 sync_state 扫描标记;
# 发现层排除「已扫空且不活跃」的——客户一旦开始发消息(last_chat_time 进
# 活跃窗)自动回到访问集;周日 full_history 强制全访问兜底(云客补历史)。
# ---------------------------------------------------------------------------
async def _mark_scan_empty(kind: str, scope: str) -> None:
"""记录「该对话/群已扫且无消息」。UPDATE 0 行则 INSERT(sync_state 无唯一键)。"""
import time as _t
from app.db.base import async_engine
from sqlalchemy import text as sa_text
now_ms = int(_t.time() * 1000)
async with async_engine.begin() as conn:
res = await conn.execute(sa_text(
"UPDATE sync_state SET last_timestamp = :ts "
"WHERE entity_type = :et AND scope_id = :sid"
), {"et": kind, "sid": scope, "ts": now_ms})
if not res.rowcount:
await conn.execute(sa_text(
"INSERT INTO sync_state (entity_type, channel, scope_id, last_timestamp) "
"VALUES (:et, NULL, :sid, :ts)"
), {"et": kind, "sid": scope, "ts": now_ms})
async def _load_scan_empty(kind: str) -> set[str]:
from app.db.base import async_engine
from sqlalchemy import text as sa_text
async with async_engine.connect() as conn:
rows = (await conn.execute(sa_text(
"SELECT scope_id FROM sync_state WHERE entity_type = :et"
), {"et": kind})).fetchall()
return {r[0] for r in rows if r[0]}
async def _discover_conversations(
channel: str, voice_channel: str, active_since_ms: int | None = None,
*, exclude_scanned_empty: bool = True,
) -> list[tuple[str, str]]:
"""Conversation-pair discovery for the message scrapers (wechat + WA). """Conversation-pair discovery for the message scrapers (wechat + WA).
Sources (union, dedup): Sources (union, dedup):
@@ -527,6 +570,11 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
Order: pairs with no messages yet FIRST (new coverage survives an Order: pairs with no messages yet FIRST (new coverage survives an
interruption), then the rest by customer last_chat_time DESC. 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 import json as _json
@@ -536,14 +584,14 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
async with async_engine.connect() as conn: async with async_engine.connect() as conn:
cust = ( cust = (
await conn.execute(sa_text( 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 " "WHERE channel=:ch AND owner_account_id IS NOT NULL AND friend_id IS NOT NULL "
"ORDER BY last_chat_time DESC" "ORDER BY last_chat_time DESC"
), {"ch": channel}) ), {"ch": channel})
).fetchall() ).fetchall()
voice = ( voice = (
await conn.execute(sa_text( 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}) ), {"vc": voice_channel})
).fetchall() ).fetchall()
with_msgs = ( with_msgs = (
@@ -554,20 +602,45 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
).fetchall() ).fetchall()
have = {(r[0], r[1]) for r in with_msgs} have = {(r[0], r[1]) for r in with_msgs}
scanned_empty = (
await _load_scan_empty(f"{channel}_conv_empty") if exclude_scanned_empty else set()
)
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
fresh: list[tuple[str, str]] = [] fresh: list[tuple[str, str]] = []
known: 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: if key in seen:
return return
seen.add(key) seen.add(key)
(fresh if key not in have else known).append(key) if key not in have:
# 从未扫过:新覆盖优先,不受窗口限制。但「已扫空且不活跃」的除外
# (2026-09-10:WA 2,468 个无消息对话每轮全翻史的黑洞)——一旦
# 活跃(last_chat_time 进窗)自动回到访问集;full_history 强制全访。
scope = f"{key[0]}:{key[1]}"
if (
exclude_scanned_empty
and scope in scanned_empty
and active_since_ms is not None
and not _active(last_ts)
):
return
fresh.append(key)
elif _active(last_ts):
known.append(key)
# else: 已扫过且陈旧 → 本轮跳过
for owner, friend in cust: for owner, friend, last_ts in cust:
_add((owner, friend)) _add((owner, friend), last_ts)
voice_only = 0 voice_only = 0
for (rj,) in voice: cust_keys = {(r[0], r[1]) for r in cust}
for rj, call_ts in voice:
try: try:
d = _json.loads(rj) d = _json.loads(rj)
except (TypeError, ValueError): except (TypeError, ValueError):
@@ -576,13 +649,15 @@ async def _discover_conversations(channel: str, voice_channel: str) -> list[tupl
if not owner or not talker or str(talker).endswith("@chatroom"): if not owner or not talker or str(talker).endswith("@chatroom"):
continue continue
before = len(seen) before = len(seen)
_add((str(owner), str(talker))) _add((str(owner), str(talker)), call_ts)
if len(seen) > before and (str(owner), str(talker)) not in cust: if len(seen) > before and (str(owner), str(talker)) not in cust_keys:
voice_only += 1 voice_only += 1
skipped = len(cust) + voice_only - len(fresh) - len(known)
logger.info( logger.info(
f"discovery[{channel}]: {len(cust)} customer pairs + {voice_only} voice-only pairs, " 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 return fresh + known
@@ -635,7 +710,7 @@ def _page_min_ts(data: dict) -> int:
return min(ts_list) if ts_list else 0 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. """Scrape wechat messages via browser fetch POST /wechatweb/msg/chats.
**增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带 **增量同步(默认)**:每对话水位 = 本地 max(timestamp);请求带
@@ -648,7 +723,15 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
from app.db.base import async_session_maker from app.db.base import async_session_maker
from app.storage.repos import upsert_messages 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
),
# full_history(周日对齐)强制全访问:云客可能补出历史消息
exclude_scanned_empty=not full_history,
)
watermarks = await _conversation_watermarks("wechat") watermarks = await _conversation_watermarks("wechat")
cust_activity = await _customer_activity("wechat") cust_activity = await _customer_activity("wechat")
@@ -737,6 +820,9 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
ts = int(str(ts_raw)) if ts_raw else None ts = int(str(ts_raw)) if ts_raw else None
except (ValueError, TypeError): except (ValueError, TypeError):
ts = None ts = None
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
if watermark and ts is not None and ts <= watermark:
continue
records.append({ records.append({
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"), "msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
"channel": "wechat", "channel": "wechat",
@@ -786,6 +872,8 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
continue continue
total_fetched += len(records) total_fetched += len(records)
if not records:
await _mark_scan_empty("wechat_conv_empty", f"{owner_id}:{friend_id}")
total_pages += pages total_pages += pages
if records: if records:
@@ -853,7 +941,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. """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 Same endpoint as 1:1 chats — pass the room id as ``friendId`` (confirmed
@@ -868,12 +956,17 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
from app.storage.repos import upsert_messages from app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text 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: async with async_engine.connect() as conn:
result = await conn.execute(sa_text( result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group " "SELECT owner_account_id, room_id FROM chat_group "
"WHERE channel='wechat' AND owner_account_id IS NOT NULL " "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" "ORDER BY last_chat_time DESC"
)) ), {"t": active_since_ms})
groups = [(row[0], row[1]) for row in result.fetchall()] groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks() room_wms = await _room_watermarks()
@@ -934,6 +1027,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
continue continue
rec = _normalize_group_message(row, owner_id, room_id) rec = _normalize_group_message(row, owner_id, room_id)
if rec is not None: if rec is not None:
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec) records.append(rec)
except Exception as e: except Exception as e:
@@ -1683,9 +1779,12 @@ def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
return rec return rec
async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_pages: int = 100): async def _chatroom_rows(
"""翻完 getChatroomList 一个窗,返回全部行(list)。""" page, dept: str, start_str: str, end_str: str, max_pages: int = 100
) -> tuple[list[dict], int]:
"""翻完 getChatroomList 一个窗,返回 (全部行, 云客声明 total)。"""
rows_all: list[dict] = [] rows_all: list[dict] = []
declared = 0
page_num = 1 page_num = 1
while page_num <= max_pages: while page_num <= max_pages:
body = await _browser_fetch( body = await _browser_fetch(
@@ -1698,13 +1797,13 @@ async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_page
data = body.get("data") or {} data = body.get("data") or {}
rows = data.get("overviews") or [] rows = data.get("overviews") or []
rows_all.extend(rows) rows_all.extend(rows)
total = data.get("totalCount") or 0 declared = data.get("totalCount") or declared
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {total})") logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {declared})")
if not rows or page_num * 50 >= (total or 0): if not rows or page_num * 50 >= (declared or 0):
break break
page_num += 1 page_num += 1
await asyncio.sleep(1.2) await asyncio.sleep(1.2)
return rows_all return rows_all, declared or 0
async def _owner_alias_map(conn): async def _owner_alias_map(conn):
@@ -1755,9 +1854,11 @@ async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
group_recs: list[dict] = [] group_recs: list[dict] = []
stats_recs: list[dict] = [] stats_recs: list[dict] = []
declared = 0
async with PageScraper(cookie_str) as scraper: async with PageScraper(cookie_str) as scraper:
page = await _open_fetch_page(scraper) page = await _open_fetch_page(scraper)
for row in await _chatroom_rows(page, dept, start_str, end_str): rows, declared = await _chatroom_rows(page, dept, start_str, end_str)
for row in rows:
rid = row.get("chatRoomId") rid = row.get("chatRoomId")
if not rid: if not rid:
continue continue
@@ -1781,9 +1882,41 @@ async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
await _upsert_all(upsert_chat_group, group_recs) await _upsert_all(upsert_chat_group, group_recs)
if stats_recs: if stats_recs:
await _upsert_all(upsert_chat_group_member_stats, stats_recs) await _upsert_all(upsert_chat_group_member_stats, stats_recs)
# 对账(2026-09-10,全窗模式):删掉本地有而云客名单里没有的 chat_group 行
# ——friend_list/es 时代的残留行(owner 组合已不在 getChatroomList 返回,
# 实测多 148 行致页脚与云客不一致)。名单取满(>=声明 total)才删,防短页
# 误删;只动 wechat(WA 群另一来源);GroupMember 按 room 共享不动。
pruned = 0
if day_key is None and group_recs and declared and len(group_recs) >= declared:
import sqlalchemy as sa
from app.db.base import async_session_maker
from app.db.models import ChatGroup
keep = {(r["owner_account_id"], r["room_id"]) for r in group_recs}
async with async_session_maker() as session:
local = (await session.execute(
sa.select(ChatGroup.owner_account_id, ChatGroup.room_id)
.where(ChatGroup.channel == "wechat")
)).all()
stale = [(o, r) for (o, r) in local if (o, r) not in keep]
for owner, room in stale:
# 逐行删(差集常态 0~几行;不用 tuple IN——asyncmy 对其参数
# 形态挑剔,实测 TypeError)
await session.execute(
sa.delete(ChatGroup).where(
ChatGroup.channel == "wechat",
ChatGroup.owner_account_id == owner,
ChatGroup.room_id == room,
)
)
pruned = len(stale)
await session.commit()
logger.info( logger.info(
f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} " f"DONE: chatroom-stats day={day or '全窗'} groups={len(group_recs)} "
f"stats_rows={len(stats_recs)}" f"stats_rows={len(stats_recs)} pruned={pruned}"
) )
return len(group_recs), len(stats_recs) return len(group_recs), len(stats_recs)
@@ -2143,7 +2276,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 """Scrape WhatsApp chat records via browser fetch POST
/qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}). /qqweb/WhatsAppUser/chatRecords (JSON body {uid, friendUid, start, end}).
@@ -2158,7 +2291,14 @@ 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.scraper.whatsapp import _is_wa_divider, _normalize_whatsapp_message
from app.storage.repos import upsert_messages 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
),
exclude_scanned_empty=not full_history,
)
watermarks = await _conversation_watermarks("whatsapp") watermarks = await _conversation_watermarks("whatsapp")
cust_activity = await _customer_activity("whatsapp") cust_activity = await _customer_activity("whatsapp")
@@ -2204,7 +2344,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
msgs = data.get("messages") or [] msgs = data.get("messages") or []
for row in msgs: for row in msgs:
if isinstance(row, dict) and not _is_wa_divider(row): if isinstance(row, dict) and not _is_wa_divider(row):
records.append(_normalize_whatsapp_message(row)) rec = _normalize_whatsapp_message(row)
# 增量模式过滤边界旧消息:云客 start 参数是 inclusive
# (返回 ts>=start 含边界),不过滤则每对话白拉数条已
# 入库旧消息(2026-09-10 实测一夜 fetched 53,682 仅
# 321 条真新,99% 白写)。
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
if not data.get("hasLast"): if not data.get("hasLast"):
break break
# 增量触底停:翻到已同步区 # 增量触底停:翻到已同步区
@@ -2235,6 +2382,8 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
continue continue
total_fetched += len(records) total_fetched += len(records)
if not records:
await _mark_scan_empty("whatsapp_conv_empty", f"{uid}:{friend_uid}")
if records: if records:
# upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成 # upsert_messages 收整列表(逐条版 _upsert_all 会把 dict 迭代成
# key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因) # key 字符串 → TypeError, 2026-08-19 WA 阶段首条即崩的根因)
@@ -2281,7 +2430,7 @@ def _normalize_wa_group_message(record: dict) -> dict:
return rec 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 """WA 群消息:浏览器 fetch chatRecords,friendUid 直接传 @g.us 群 id
(真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。 (真机确认 2026-08-20,与微信群的 msg/chats+roomId 同套路)。
@@ -2295,11 +2444,16 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
from app.storage.repos import upsert_messages from app.storage.repos import upsert_messages
from sqlalchemy import text as sa_text 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: async with async_engine.connect() as conn:
result = await conn.execute(sa_text( result = await conn.execute(sa_text(
"SELECT owner_account_id, room_id FROM chat_group " "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()] groups = [(row[0], row[1]) for row in result.fetchall()]
room_wms = await _room_watermarks() room_wms = await _room_watermarks()
@@ -2343,7 +2497,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
for row in rows: for row in rows:
if not isinstance(row, dict) or _is_wa_divider(row): if not isinstance(row, dict) or _is_wa_divider(row):
continue continue
records.append(_normalize_wa_group_message(row)) rec = _normalize_wa_group_message(row)
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
except Exception as e: except Exception as e:
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}") logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
@@ -2473,6 +2631,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("--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("--batch-size", type=int, default=50, help="媒体下载批次大小 (media)")
parser.add_argument("--full-history", action="store_true", help="消息类实体强制全量翻历史(默认增量水位)") 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(落按天进出表);缺省=全窗档案基线") parser.add_argument("--day", type=str, default=None, help="chatroom-stats:单日窗 YYYY-MM-DD(落按天进出表);缺省=全窗档案基线")
args = parser.parse_args() args = parser.parse_args()
@@ -2496,9 +2656,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
elif args.entity == "groups": elif args.entity == "groups":
await scrape_groups(cookie, args.max_employees) await scrape_groups(cookie, args.max_employees)
elif args.entity == "messages": 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": 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": elif args.entity == "accounts":
await scrape_accounts(cookie, args.max_pages) await scrape_accounts(cookie, args.max_pages)
elif args.entity == "wa-accounts": elif args.entity == "wa-accounts":
@@ -2514,9 +2674,9 @@ async def _dispatch_entity(args, cookie: str) -> None:
elif args.entity == "voice-clips": elif args.entity == "voice-clips":
await scrape_voice_clips(cookie, args.max_employees) await scrape_voice_clips(cookie, args.max_employees)
elif args.entity == "wa-messages": 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": 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": elif args.entity == "moments":
await scrape_moments(cookie, args.max_pages) await scrape_moments(cookie, args.max_pages)
elif args.entity == "media": elif args.entity == "media":
+5 -3
View File
@@ -64,17 +64,19 @@ async def _seed(session: AsyncSession) -> None:
async def test_default_list_returns_all_ordered_desc(make_client): async def test_default_list_returns_all_ordered_desc(make_client):
"""No filters -> 3 groups, ordered by ``last_chat_time desc`` (nulls last).""" """No filters -> 3 groups;微信排序=owner 二进制字符串升序分组+room_id
升序(云客群统计同款,2026-09-10 终判:区分大小写,大写排小写前)。
owner 序:wa_owner_1 < wx_owner_1 < wx_owner_2 → [wa_room_x, room_a, room_b]。
"""
client = await make_client(_seed) client = await make_client(_seed)
r = await client.get("/api/groups") r = await client.get("/api/groups")
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert body["total"] == 3 assert body["total"] == 3
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
assert [it["room_id"] for it in body["items"]] == [ assert [it["room_id"] for it in body["items"]] == [
"wa_room_x@s.whatsapp.net",
"room_a@chatroom", "room_a@chatroom",
"room_b@chatroom", "room_b@chatroom",
"wa_room_x@s.whatsapp.net",
] ]
for key in ( for key in (
"room_id", "room_id",
+744
View File
@@ -0,0 +1,744 @@
"""管理 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);批次D 补:prev_kpis 环比块+msg_delta(含前窗零数据
→None)与 anomalies employee_name enrich(详情端点不带);
- 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"]
# 前窗环比(批次D):prev 窗 0827-0831 与基线窗种子重叠——
# u1(14+14)×5 + u2(5+5)×5 = 190;宽表无 active_friends/串列 → 0
pk = body["prev_kpis"]
assert set(pk) == {"active_customers", "active_employees",
"msg_total", "cust_threads"}
assert pk == {"active_customers": 0, "active_employees": 2,
"msg_total": 190, "cust_threads": 0}
assert body["msg_delta"] == round((155 - 190) / 190, 3) # -0.184
# 趋势逐日 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;
# dashboard 卡片 enrich employee_name(批次D,前端跳员工详情用)
anoms = body["anomalies"]
assert anoms[0]["severity"] == "critical"
assert anoms[0]["employee_user_id"] == "u2"
assert anoms[0]["employee_name"] == "李四"
assert all(a["severity"] == "critical" for a in anoms[:1])
assert all(a["severity"] == "warning" for a in anoms[1:])
assert all(a["employee_name"] == "张三" for a in anoms[1:])
assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
"sensitive_pending": 0}
async def test_dashboard_prev_zero_no_delta(make_client):
"""前窗无数据(whatsapp 视图前窗零宽表)→ prev_kpis 四键全 0、
msg_delta None(prev=0 不算环比,None 而非 0/负∞)。"""
async def seed(s):
await _seed_common(s)
client = await make_client(seed)
r = await client.get("/api/management/dashboard",
params={"channel": "whatsapp", "start": START,
"end": END})
assert r.status_code == 200
body = r.json()
assert body["kpis"]["msg_total"] == 50 # 仅 u3 的 whatsapp 宽表
assert body["prev_kpis"] == {"active_customers": 0,
"active_employees": 0, "msg_total": 0,
"cust_threads": 0}
assert body["msg_delta"] is None
# ---------------------------------------------------------------- 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);详情端点 anomalies 不带 employee_name
# (自指无意义,仅 dashboard enrich)
assert len(body["anomalies"]) == 2
assert all(a["severity"] == "warning" for a in body["anomalies"])
assert all("employee_name" not in a 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)) 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(): def test_validate_config_default_config_clean():
"""默认配置自身必须全绿(空问题列表)。""" """默认配置自身必须全绿(空问题列表)。"""
assert validate_config(DEFAULT_SCORE_CONFIG) == [] assert validate_config(DEFAULT_SCORE_CONFIG) == []
@@ -1,4 +1,4 @@
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。 """_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的 行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
注册测试里同步更新(那里 spy 断言 add_job 全集)。 注册测试里同步更新(那里 spy 断言 add_job 全集)。
@@ -15,6 +15,7 @@ from sqlalchemy.pool import StaticPool
from app.db.base import Base from app.db.base import Base
from app.db.models import MsgDailyStats, SensitiveWord, StatDailyStaff from app.db.models import MsgDailyStats, SensitiveWord, StatDailyStaff
from app.storage.staff_summary import rolling_days
from app.storage.summary import day_key from app.storage.summary import day_key
from app.sync.scheduler import SyncScheduler from app.sync.scheduler import SyncScheduler
@@ -74,3 +75,110 @@ async def test_analytics_tick_swallows_errors(sched_engine, monkeypatch, caplog)
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"): with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
await sch._analytics_tick() # 不抛 await sch._analytics_tick() # 不抛
assert any("analytics tick failed" in r.getMessage() for r in caplog.records) assert any("analytics tick failed" in r.getMessage() for r in caplog.records)
async def _seed_today(sched_engine) -> int:
"""seed 当日一条 wechat friend 会话 + 敏感词,返回 seed 用的 day 键。"""
_, maker = sched_engine
today = day_key(int(time.time() * 1000))
async with maker() as s:
s.add(MsgDailyStats(channel="wechat", owner_account_id="wx1",
peer_kind="friend", peer_id="t1", day=today,
send_cnt=1, recv_cnt=0, last_ts=1756950000000,
last_is_send=True))
s.add(SensitiveWord(word="私单"))
await s.commit()
return today
async def test_tick_calls_management_chain_in_order(sched_engine, monkeypatch):
"""两段链完整顺序:recompute_days → scan → pairs_active_in_days →
recompute_pairs → recompute_metric_days → recompute_call_days。
记录桩替换全部六个上游——_analytics_tick 段内函数延迟 import,
monkeypatch 模块属性即命中(惯例见 test_analytics_tick_swallows_errors)。
"""
engine, maker = sched_engine
calls: list[str] = []
def rec(name: str, ret):
async def _stub(*args, **kwargs):
calls.append(name)
return ret
return _stub
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
rec("recompute_days", 0))
monkeypatch.setattr("app.analytics.sensitive.scanner.run_incremental",
rec("scan", {"scanned": 0, "hits": 0}))
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
rec("pairs_active_in_days", []))
monkeypatch.setattr("app.storage.first_reply_facts.recompute_pairs",
rec("recompute_pairs", 0))
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
rec("recompute_metric_days", 0))
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
rec("recompute_call_days", 0))
sch = SyncScheduler(engine=engine, session_maker=maker)
await sch._analytics_tick()
assert calls == [
"recompute_days", "scan", # 段1
"pairs_active_in_days", "recompute_pairs", # 段2
"recompute_metric_days", "recompute_call_days",
]
async def test_tick_management_failure_isolated(sched_engine, monkeypatch, caplog):
"""段2 失败隔离:recompute_metric_days 抛 RuntimeError → ①tick 不抛;
②记 "management metrics tick failed";③段1 已提交的 StatDailyStaff
不被段2 回滚(两段独立事务)。
"""
engine, maker = sched_engine
await _seed_today(sched_engine)
async def _boom(*args, **kwargs):
raise RuntimeError("boom")
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days", _boom)
sch = SyncScheduler(engine=engine, session_maker=maker)
with caplog.at_level(logging.ERROR, logger="app.sync.scheduler"):
await sch._analytics_tick() # ①不抛
assert any( # ②段2 独立记日志
"management metrics tick failed" in r.getMessage() for r in caplog.records
)
async with maker() as s: # ③段1 结果已提交
n = (await s.execute(
select(func.count()).select_from(StatDailyStaff)
)).scalar_one()
assert n >= 1
async def test_tick_management_rolling_window(sched_engine, monkeypatch):
"""管理域三函数收到的 days == rolling_days(today, 3)(长度 3,当日+前
两天),且与 staff 段捕获的窗口一致(两段共用函数顶提取的同一 today)。
"""
engine, maker = sched_engine
got: dict[str, list[int]] = {}
def cap(name: str, ret):
async def _stub(session, days, *args, **kwargs):
got[name] = list(days)
return ret
return _stub
monkeypatch.setattr("app.storage.staff_summary.recompute_days",
cap("staff", 0))
monkeypatch.setattr("app.storage.first_reply_facts.pairs_active_in_days",
cap("pairs", []))
monkeypatch.setattr("app.storage.employee_metrics.recompute_metric_days",
cap("wide", 0))
monkeypatch.setattr("app.storage.employee_metrics.recompute_call_days",
cap("call", 0))
sch = SyncScheduler(engine=engine, session_maker=maker)
await sch._analytics_tick()
rd = rolling_days(day_key(int(time.time() * 1000)), 3)
assert len(rd) == 3 # 当日 + 前两天
assert got["staff"] == got["pairs"] == got["wide"] == got["call"] == rd
+20 -11
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): 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) sched = SyncScheduler(engine=engine, max_conversations_per_run=123)
commands: list[list[str]] = [] commands: list[list[str]] = []
@@ -584,22 +589,26 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
await sched._safe_page_run("messages") await sched._safe_page_run("messages")
# 2026-08-21: messages job 是 friends→wa-friends→四类消息的命令序列, assert len(commands) == 10
# 每条都调 scrape_via_page.py;2026-08-25 末尾追加 moments 浅扫
assert len(commands) == 7
for args in commands: for args in commands:
assert args[1].endswith("scrape_via_page.py") assert args[1].endswith("scrape_via_page.py")
def entity_of(args: list[str]) -> str: def entity_of(args: list[str]) -> str:
return args[args.index("--entity") + 1] return args[args.index("--entity") + 1]
assert entity_of(commands[0]) == "friends" # 头部:账号刷新(员工列表「数据更新/最新消息时间」来源)
assert entity_of(commands[1]) == "wa-friends" assert entity_of(commands[0]) == "accounts"
for args in commands[2:6]: assert entity_of(commands[1]) == "wa-accounts"
assert entity_of(args) in ( assert entity_of(commands[2]) == "friends"
"messages", "wa-messages", "group-messages", "wa-group-messages", assert entity_of(commands[3]) == "wa-friends"
) # 群消息前刷群活跃(chatroom-stats 全窗——群活跃窗判定源,2026-09-10)
assert args[args.index("--max-conversations") + 1] == "99999" entities_seq = [entity_of(a) for a in commands]
assert entities_seq.index("chatroom-stats") < entities_seq.index("group-messages")
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
for args in commands:
if 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 浅扫在序列末尾:每员工最多 3 页
moments_cmd = commands[-1] moments_cmd = commands[-1]
assert entity_of(moments_cmd) == "moments" assert entity_of(moments_cmd) == "moments"
+107
View File
@@ -0,0 +1,107 @@
// 经营管理板块 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
// dashboard 端点 enrich(批次D,跳员工详情用);员工详情端点不带
employee_name?: 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
// 前窗环比块(批次D):prev 窗同式聚合;可选防御旧缓存响应
prev_kpis?: Pick<Kpis, 'active_customers' | 'active_employees' | 'msg_total' | 'cust_threads'>
msg_delta?: number | null // (msg_total-prev)/prev,prev=0 → null;客户端三卡自算,此键备用
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')
// ---- 批次D:员工详情 + 分段指标(契约已真机 8000 抽查核对,见上) ----
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
// formula_note 在 dimensions[dim].metrics[code] 上(scoring 维度级明细),不在 metrics_detail
export interface EmployeeDetailResp {
user_id: string; name: string | null; is_on_job: boolean | null
accounts: EmployeeAccount[]
metrics: Record<string, number | string | null>
score: {
total_score: number | null; grade: string | null; score_status: string; trial: boolean
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
metrics_detail: Record<string, ScoreMetricDetail>
}
anomalies: AnomalyCard[]
prev: Record<string, number | string | null> | null
baseline: Record<string, number | string | null> | null
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
}
export interface SectionResp {
section: string; metrics: Record<string, number | null>
trend: Record<string, number | null>[]
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
scope: MgmtScope; data_as_of: number | null; metric_version: string
}
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
@@ -0,0 +1,136 @@
<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 v-if="showEmployee" class="cell-emp">
<el-link
type="primary"
underline="never"
@click.stop="goEmployee(row)"
>{{ row.employee_name || row.employee_user_id.slice(0, 8) }}</el-link>
</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/标题+员工行
// (showEmployee,批次D:employee_name 链接→员工详情,.stop 防触发行下钻)
// +原因/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
// 纯展示+事件,不取数。
import { useRoute, useRouter } from 'vue-router'
import { formatMsDuration, formatRatio } from '@/utils/format'
import type { AnomalyCard } from '@/api/management'
// showEmployee(默认 true):驾驶舱异常榜显示员工行;员工详情页复用本组件
// 传 false(自指链接无意义)
withDefaults(
defineProps<{
anomalies: AnomalyCard[]
loading?: boolean
showEmployee?: boolean
}>(),
{ showEmployee: true },
)
const emit = defineEmits<{
(e: 'drill', card: AnomalyCard): void
}>()
const route = useRoute()
const router = useRouter()
// 员工链接:带当前 URL query(channel/start/end/compare 随行,与列表
// goDetail 带窗进详情同语义);detail 页对白名单外 query 键不敏感
function goEmployee(row: AnomalyCard): void {
router.push({
path: `/management/employees/${row.employee_user_id}`,
query: route.query,
})
}
// 单位感知格式化: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-emp {
font-size: 12px;
margin-top: 2px;
line-height: 1.4;
}
.cell-emp .el-link {
font-size: 12px;
}
.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,169 @@
<template>
<el-drawer
:model-value="modelValue"
title="员工对比"
size="620px"
@update:model-value="emit('update:modelValue', $event)"
>
<!-- v1 §6.6:对比只呈现可验证差异,不产生优劣结论(管理者需被提醒) -->
<el-alert
type="info"
:closable="false"
title="同口径同时窗对比,仅呈现可验证差异,不产生优劣结论"
class="cmp-alert"
/>
<!-- ① 维度对比:三可用维度,每人一行横向 bar(0-100;无样本灰「--」) -->
<div v-for="dim in DIMS" :key="dim.key" class="dim-block">
<div class="dim-title">{{ dim.label }}</div>
<div v-for="r in rows" :key="r.user_id" class="dim-row">
<span class="dim-name" :title="r.user_id">{{ displayName(r) }}</span>
<template v-if="dimScore(r, dim.key) !== null">
<div class="dim-track">
<div class="dim-bar" :style="{ width: `${dimScore(r, dim.key)}%` }" />
</div>
<span class="dim-score">{{ Math.round(dimScore(r, dim.key) ?? 0) }}</span>
</template>
<span v-else class="dim-na">--</span>
</div>
</div>
<!-- ② 关键指标表:行=指标,列=每人 -->
<el-table :data="metricRows" size="small" border class="cmp-table">
<el-table-column prop="label" label="指标" min-width="96" />
<el-table-column
v-for="r in rows"
:key="r.user_id"
align="right"
min-width="88"
>
<template #header>
<span :title="r.user_id">{{ displayName(r) }}</span>
</template>
<template #default="{ row }">{{ row.values[r.user_id] }}</template>
</el-table-column>
</el-table>
</el-drawer>
</template>
<script setup lang="ts">
// 员工对比抽屉(批次D):总览页勾选 2-5 人后并排看维度分与关键指标。
// 只读展示——数据来自已选行快照(EmployeeRow),不二次请求;口径/时窗与
// 总览页完全一致(同载荷),故顶部钉 v1 §6.6 提示:仅呈现可验证差异。
import { computed } from 'vue'
import type { EmployeeRow } from '@/api/management'
import { formatMsDuration, formatRatio } from '@/utils/format'
const props = defineProps<{
modelValue: boolean
/** 已勾选行(2-5,由父级按钮门槛保证);行是勾选时的快照,翻页/重查不影响 */
rows: EmployeeRow[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: boolean): void
}>()
// 一期可用三维度(action/risk pending_data 不参与)
const DIMS = [
{ key: 'customer', label: '客户经营' },
{ key: 'service', label: '服务质量' },
{ key: 'workload', label: '工作负载' },
] as const
function displayName(r: EmployeeRow): string {
return r.name || r.user_id.slice(0, 8)
}
function dimScore(r: EmployeeRow, key: string): number | null {
return r.score.dimensions[key]?.score ?? null
}
// metrics 取数值键(user_id 透传键是字符串,typeof 守卫掉)
function num(r: EmployeeRow, key: string): number | null {
const v = r.metrics[key]
return typeof v === 'number' ? v : null
}
function ratio(numV: number | null, den: number | null): string {
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
return formatRatio(numV == null ? null : numV / den)
}
interface CmpMetricRow {
label: string
values: Record<string, string>
}
const metricRows = computed<CmpMetricRow[]>(() => {
const mk = (label: string, fmt: (r: EmployeeRow) => string): CmpMetricRow => {
const values: Record<string, string> = {}
for (const r of props.rows) values[r.user_id] = fmt(r)
return { label, values }
}
return [
mk('活跃客户', (r) => String(num(r, 'active_friends') ?? '--')),
mk('消息量', (r) => String((num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0))),
mk('首响P90', (r) => formatMsDuration(num(r, 'fr_p90_ms'))),
mk('2h回复率', (r) => ratio(num(r, 'reply_2h_count'), num(r, 'cust_threads'))),
mk('异常数', (r) => String(r.anomaly_count)),
]
})
</script>
<style scoped>
.cmp-alert {
margin-bottom: 16px;
}
.dim-block {
margin-bottom: 16px;
}
.dim-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-bottom: 6px;
}
.dim-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.dim-name {
width: 88px;
flex: none;
font-size: 13px;
color: var(--el-text-color-regular);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim-track {
flex: 1;
height: 12px;
border-radius: 6px;
background: var(--el-fill-color-light);
overflow: hidden;
}
.dim-bar {
height: 100%;
border-radius: 6px;
background: var(--el-color-primary);
}
.dim-score {
width: 28px;
flex: none;
text-align: right;
font-size: 12px;
color: var(--el-text-color-regular);
}
.dim-na {
flex: 1;
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.cmp-table {
margin-top: 4px;
}
</style>
@@ -0,0 +1,111 @@
<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-group label="常用">
<el-option label="今日" value="today" />
<el-option label="昨日" value="yesterday" />
<el-option label="近7天" value="last7" />
<el-option label="近30天" value="last30" />
</el-option-group>
<el-option-group label="日历">
<el-option label="本周" value="thisWeek" />
<el-option label="上周" value="lastWeek" />
<el-option label="本月" value="thisMonth" />
<el-option label="上月" value="lastMonth" />
</el-option-group>
<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,94 @@
<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="deltaText" class="kpi-delta" :class="deltaClass">
环比{{ deltaText }}
</div>
<div v-if="hint" class="kpi-hint">{{ hint }}</div>
</el-card>
</template>
<script setup lang="ts">
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
// + 环比行(批次D,delta prop)+ 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
// 较前窗环比 (cur-prev)/prev:null/undefined/NaN(前窗缺数据)不显示。
// 只支持升好语义(升绿↓红)——本批接入三卡(活跃客户/活跃员工/消息量)
// 均为越高越好;将来若有降好指标(如首响时长)需先加方向语义位
delta?: number | null
}>()
const router = useRouter()
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString()))
const deltaText = computed(() => {
if (props.delta == null || !Number.isFinite(props.delta)) return null
const arrow = props.delta > 0 ? '↑' : props.delta < 0 ? '↓' : '→'
return `${arrow}${(Math.abs(props.delta) * 100).toFixed(1)}%`
})
const deltaClass = computed(() =>
props.delta == null || !Number.isFinite(props.delta)
? ''
: props.delta > 0 ? 'up' : props.delta < 0 ? 'down' : 'flat')
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-delta {
font-size: 12px;
margin-bottom: 2px;
}
.kpi-delta.up {
color: var(--el-color-success);
}
.kpi-delta.down {
color: var(--el-color-danger);
}
.kpi-delta.flat {
color: var(--el-text-color-secondary);
}
.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,139 @@
// 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('today → 今天单日(2026-09-10 → 20260910..20260910)', () => {
expect(presetRange('today', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
})
it('yesterday → 昨日单日(2026-09-10 → 20260909..20260909)', () => {
expect(presetRange('yesterday', D(20260910))).toEqual({ start: 20260909, end: 20260909 })
})
it('thisWeek 周中(2026-07-02 周四 → 周一到今天 20260629..20260702)', () => {
expect(presetRange('thisWeek', D(20260702))).toEqual({ start: 20260629, end: 20260702 })
})
it('thisWeek 周一当天 → 单日(2026-06-29 周一 → 20260629..20260629)', () => {
expect(presetRange('thisWeek', D(20260629))).toEqual({ start: 20260629, end: 20260629 })
})
it('lastWeek 跨月边界(2026-07-02 周四 → 上周一..上周日 20260622..20260628)', () => {
expect(presetRange('lastWeek', D(20260702))).toEqual({ start: 20260622, end: 20260628 })
})
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,91 @@
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type Preset = 'today' | 'yesterday' | 'last7' | 'last30' | 'thisWeek' | 'lastWeek' | '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())
// 周一为一周起点(getDay 周日=0 → 偏移成周一=0..周日=6)
const dow = (t0.getDay() + 6) % 7
switch (p) {
case 'today':
return { start: ymd(t0), end: ymd(t0) }
case 'yesterday':
return { start: ymd(addDays(t0, -1)), end: ymd(addDays(t0, -1)) }
case 'thisWeek':
// 周一..今天;周一当天即单日
return { start: ymd(addDays(t0, -dow)), end: ymd(t0) }
case 'lastWeek': {
const monday = addDays(t0, -dow - 7)
return { start: ymd(monday), end: ymd(addDays(monday, 6)) }
}
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 }
}
+15
View File
@@ -11,9 +11,11 @@ const channelStore = useChannelStore()
// `el-menu` with `router` mode; we drive `default-active` from the current path // `el-menu` with `router` mode; we drive `default-active` from the current path
// so the active item highlights on load and on browser back/forward. // so the active item highlights on load and on browser back/forward.
// 带参路由(路径与菜单 index 不等)按 route.name 映射父菜单路径。
const activeMenu = computed(() => { const activeMenu = computed(() => {
if (route.name === 'chat') return '/customers' if (route.name === 'chat') return '/customers'
if (route.name === 'group-chat') return '/groups' if (route.name === 'group-chat') return '/groups'
if (route.name === 'management-employees-detail') return '/management/employees'
return route.path return route.path
}) })
@@ -99,6 +101,19 @@ function logout(): void {
全局搜索 全局搜索
</el-menu-item> </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-menu-item index="/management/employees" @click="goPlain('/management/employees')">
员工分析
</el-menu-item>
</el-sub-menu>
<!-- 数据分析 --> <!-- 数据分析 -->
<el-sub-menu index="analytics"> <el-sub-menu index="analytics">
<template #title> <template #title>
+25
View File
@@ -92,6 +92,31 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/views/analytics/SensitiveView.vue'), component: () => import('@/views/analytics/SensitiveView.vue'),
meta: { title: '敏感词' }, meta: { title: '敏感词' },
}, },
{
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
path: '/management/dashboard',
name: 'management-dashboard',
component: () => import('@/views/management/ManagementDashboardView.vue'),
meta: { title: '管理驾驶舱' },
},
{
// 员工分析(批次D):四视图总览;详情路由 /management/employees/{user_id}
// 由批次D后续任务补挂(D2)
path: '/management/employees',
name: 'management-employees',
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
meta: { title: '员工分析' },
},
{
// 员工详情(批次D2):总览页姓名下钻;query 真源 channel/start/end/section
// (section=当前页签,刷新回到该页签)。静态路由 /management/employees
// 先注册,带参路由在后——vue-router 按注册序精确匹配,静态优先命中。
path: '/management/employees/:userId',
name: 'management-employees-detail',
component: () => import('@/views/management/EmployeeAnalyticsDetailView.vue'),
props: true,
meta: { title: '员工分析详情' },
},
{ {
path: '/chat/:channel/:ownerId/:talkerId?', path: '/chat/:channel/:ownerId/:talkerId?',
name: 'chat', name: 'chat',
+11 -2
View File
@@ -10,6 +10,9 @@
* ], * ],
* filename: '微信客户列表', * filename: '微信客户列表',
* }) * })
*
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
* the header — each line occupies the first column of its own row.
*/ */
export interface CsvColumn<T> { export interface CsvColumn<T> {
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
pageSize?: number pageSize?: number
/** Max rows to export (safety cap, default 10000) */ /** Max rows to export (safety cap, default 10000) */
maxRows?: number maxRows?: number
/**
* Optional comment lines written before the header row (batch D: management
* employee CSV carries caliber/window notes so the export is self-describing
* outside the app). Each line becomes a single-cell row in the first column.
*/
preamble?: string[]
} }
function escapeCsv(value: string | number | null | undefined): string { function escapeCsv(value: string | number | null | undefined): string {
@@ -43,7 +52,7 @@ function escapeCsv(value: string | number | null | undefined): string {
} }
export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> { export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> {
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000, preamble } = options
// Fetch all pages // Fetch all pages
const allRows: T[] = [] const allRows: T[] = []
@@ -68,7 +77,7 @@ export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ r
return escapeCsv(val as string | number | null | undefined) return escapeCsv(val as string | number | null | undefined)
}).join(',') }).join(',')
) )
const csv = '' + header + '\n' + lines.join('\n') // BOM for Excel Chinese support const csv = '' + [...(preamble ?? []).map((p) => escapeCsv(p)), header, ...lines].join('\n') // BOM for Excel Chinese support
// Trigger download // Trigger download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
+22
View File
@@ -55,6 +55,28 @@ export function formatDuration(seconds?: number | null): string {
return `${m}'${String(rem).padStart(2, '0')}"` 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. */ /** Friendly label for a Channel value. */
export function channelLabel(channel?: string | null): string { export function channelLabel(channel?: string | null): string {
switch (channel) { switch (channel) {
+6 -2
View File
@@ -77,7 +77,7 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
}, },
{ {
title: '成员', title: '成员',
keys: ['memberCount', 'memberIds'], keys: ['memberCount'], // memberIds 原始串由群详情成员墙替代(2026-09-10)
}, },
{ {
title: '归属微信与群主', title: '归属微信与群主',
@@ -105,7 +105,11 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
] ]
/** Every key already surfaced via a group (so 其他 can skip duplicates). */ /** Every key already surfaced via a group (so 其他 can skip duplicates). */
const KNOWN_KEYS = new Set(GROUP_FIELD_GROUPS.flatMap((g) => g.keys)) const KNOWN_KEYS = new Set([
...GROUP_FIELD_GROUPS.flatMap((g) => g.keys),
// 原始 wxid 串由群详情成员墙替代,不在任何区(含「其他」)显示(2026-09-10)
'memberIds',
])
/** Resolve the 中文 label for a raw_json key (falls back to the key). */ /** Resolve the 中文 label for a raw_json key (falls back to the key). */
export function fieldLabel(key: string): string { export function fieldLabel(key: string): string {
+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). */ /** True when this timeline is a group chat (drives the query + title). */
const isGroup = computed(() => !!props.roomId) const isGroup = computed(() => !!props.roomId)
/**
* 专注模式(?bare=1):群统计等列表入口的「查看详情」——隐藏会话侧栏,
* 对齐云客群详情页(无好友/群列表,纯聊天记录窗)。员工聊天窗口入口
* (不带 bare)侧栏常驻(2026-09-09 修复保持)。
*/
const isBare = computed(() => route.query.bare === '1')
/** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */ /** 当前会话类型(高亮 sidebar + 驱动空态)。账号级入口(无 talkerId/roomId)→ null。 */
const activeType = computed<'friend' | 'group' | null>(() => const activeType = computed<'friend' | 'group' | null>(() =>
isGroup.value ? 'group' : props.talkerId ? 'friend' : null, isGroup.value ? 'group' : props.talkerId ? 'friend' : null,
@@ -344,8 +351,11 @@ onMounted(() => {
<div class="yk-chat__body"> <div class="yk-chat__body">
<!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。 <!-- 侧栏常驻(含群聊路由):好友/群列表随时可切,对齐云客聊天窗体验。
旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」 旧版 v-if="!isGroup" 在群路由下藏侧栏,点群后「好友列表/群列表」
消失(2026-09-09 用户报);onSelectSession 本就双类型导航。 --> 消失(2026-09-09 用户报);onSelectSession 本就双类型导航。
bare 模式(群统计「查看详情」入口)例外——纯聊天记录窗无侧栏,
对齐云客群详情页(2026-09-10 用户反馈)。 -->
<ChatSidebar <ChatSidebar
v-if="!isBare"
:channel="channelTyped" :channel="channelTyped"
:owner-id="props.ownerId" :owner-id="props.ownerId"
:active-type="activeType" :active-type="activeType"
+95 -29
View File
@@ -3,9 +3,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue' import { Download } from '@element-plus/icons-vue'
import { getAccounts, getGroups } from '@/api' import { getAccounts, getGroupMembers, getGroups } from '@/api'
import { downloadBlob } from '@/api/client' import type { Account, Channel, Group, GroupMember } from '@/api/types'
import type { Account, Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel' import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format' import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport' import { exportListToCsv } from '@/utils/csvExport'
@@ -190,6 +189,9 @@ function viewChat(row: Group): void {
router.push({ router.push({
name: 'group-chat', name: 'group-chat',
params: { channel: row.channel, ownerId: row.owner_account_id, roomId: row.room_id }, 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 守卫。 */ /** drawer 内「查看聊天记录」入口:drawerGroup 是 ref<Group|null>,模板嵌套不保证窄化,用 wrapper 守卫。 */
@@ -199,28 +201,6 @@ function viewChatFromDrawer(): void {
viewChat(row) 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(含当前员工筛选) ---- // ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
const exporting = ref(false) const exporting = ref(false)
@@ -267,9 +247,35 @@ function clearOwnerFilter(): void {
} }
// ---- drawer ---- // ---- drawer ----
// 群成员墙(2026-09-10):点开群详情拉 members,渲染头像+昵称(消息派生群昵称
// 优先,customer 好友昵称兜底,都没有回退 wxid)。
const drawerMembers = ref<GroupMember[]>([])
const membersLoading = ref(false)
async function loadDrawerMembers(roomId: string): Promise<void> {
drawerMembers.value = []
membersLoading.value = true
try {
drawerMembers.value = await getGroupMembers(roomId)
} catch {
drawerMembers.value = [] // 静默:成员区空态
} finally {
membersLoading.value = false
}
}
function memberLabel(m: GroupMember): string {
return m.nickname || m.display_name || m.member_id
}
function openDrawer(row: Group): void { function openDrawer(row: Group): void {
drawerGroup.value = row drawerGroup.value = row
drawerOpen.value = true drawerOpen.value = true
if (row.channel === 'wechat') {
loadDrawerMembers(row.room_id)
} else {
drawerMembers.value = []
}
} }
const drawerSections = computed(() => const drawerSections = computed(() =>
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [], drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
@@ -444,11 +450,9 @@ onMounted(() => {
<el-table-column label="沟通消息数" width="105" align="right"> <el-table-column label="沟通消息数" width="105" align="right">
<template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template> <template #default="{ row }">{{ row.window_msg_count ?? '-' }}</template>
</el-table-column> </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 }"> <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="viewChat(row)">查看聊天记录</el-button>
<el-button link type="primary" size="small" @click.stop="exportGroupHtml(row)">导出HTML</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -571,6 +575,26 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
<!-- 群成员墙:头像+昵称(群昵称>好友昵称兜底>wxid),替代原始 memberIds 串 -->
<div v-if="drawerGroup.channel === 'wechat'" class="yk-drawer__section">
<el-divider content-position="left">
群成员({{ drawerMembers.length || (drawerGroup.member_count ?? 0) }})
</el-divider>
<div v-loading="membersLoading" class="yk-members">
<div v-if="!membersLoading && drawerMembers.length === 0" class="yk-members__empty">
暂无成员数据(群消息派生,发过言/是本司好友的成员可识别)
</div>
<div v-for="m in drawerMembers" :key="m.member_id" class="yk-members__item">
<el-avatar :size="34" :src="m.avatar || undefined">
{{ memberLabel(m).slice(0, 1) }}
</el-avatar>
<div class="yk-members__meta">
<div class="yk-members__name" :title="memberLabel(m)">{{ memberLabel(m) }}</div>
<div v-if="memberLabel(m) !== m.member_id" class="yk-members__id">{{ m.member_id }}</div>
</div>
</div>
</div>
</div>
<div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section"> <div v-for="section in drawerSections" :key="section.title" class="yk-drawer__section">
<el-divider content-position="left">{{ section.title }}</el-divider> <el-divider content-position="left">{{ section.title }}</el-divider>
<dl class="yk-drawer__kv"> <dl class="yk-drawer__kv">
@@ -619,4 +643,46 @@ onMounted(() => {
.yk-group-owner__meta { .yk-group-owner__meta {
min-width: 0; min-width: 0;
} }
/* 群成员墙:头像+昵称网格(2026-09-10) */
.yk-members {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
max-height: 320px;
overflow-y: auto;
min-height: 48px;
}
.yk-members__empty {
grid-column: 1 / -1;
color: var(--el-text-color-secondary);
font-size: 12px;
padding: 8px 0;
}
.yk-members__item {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.yk-members__meta {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.35;
}
.yk-members__name {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.yk-members__id {
font-size: 11px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style> </style>
+6 -14
View File
@@ -143,16 +143,16 @@
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template> <template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="首响" align="right"> <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>
<el-table-column label="回复率" align="right"> <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>
<el-table-column label="通话时长" align="right"> <el-table-column label="通话时长" align="right">
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template> <template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="接通率" align="right"> <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>
<el-table-column label="删粉/拉黑" align="right"> <el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template> <template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
@@ -187,7 +187,7 @@ import {
type StaffRankingRow, type StaffRankingRow,
} from '@/api/analytics' } from '@/api/analytics'
import { useChart } from '@/composables/useChart' import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format' import { formatMsDuration, formatRatio, formatTimeShort } from '@/utils/format'
const router = useRouter() 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) const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
// ── 排行新列格式化 ── // ── 排行新列格式化 ──
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-' // 首响/百分比用共享版(formatMsDuration/formatRatio,utils/format——批次C
const fmtFr = (ms: number | null) => { // 驾驶舱组件同口径,原本地 fmtFr/fmtPct 已提升去重)
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)}%`)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-' // 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => { const fmtDur = (s: number | null) => {
if (!s) return '-' if (!s) return '-'
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,726 @@
<template>
<div class="emp-analytics">
<!-- 筛选栏:渠道/时间(URL 真源)+口径入口;aside=对比模式选择器。
compare 仅客户端显示态:employees 行恒带四对比(current/prev/
baseline/median),后端 employees 端点也无 compare 入参 -->
<ManagementFilterBar
:channel="channel"
:start="start"
:end="end"
:compare="compare"
@apply="apply"
@open-defs="defsVisible = true"
>
<template #aside>
<el-select
:model-value="compare"
class="compare-select"
@change="onCompareChange"
>
<el-option label="对比:上期" value="previous" />
<el-option label="对比:个人基线" value="personal_baseline" />
<el-option label="对比:团队中位数" value="team_median" />
</el-select>
</template>
</ManagementFilterBar>
<!-- 工具行:关键词(防抖)/在职状态/四视图;口径入口在筛选栏自带 -->
<div class="toolbar">
<el-input
v-model="keywordInput"
placeholder="姓名/ID"
clearable
:prefix-icon="Search"
class="kw-input"
/>
<el-select :model-value="status" class="status-select" @change="onStatusChange">
<el-option label="全部" value="all" />
<el-option label="在职" value="on_job" />
<el-option label="离线" value="offline" />
</el-select>
<el-radio-group :model-value="view" @change="onViewChange">
<el-radio-button value="management">管理概览</el-radio-button>
<el-radio-button value="workload">工作负载</el-radio-button>
<el-radio-button value="service">服务质量</el-radio-button>
<el-radio-button value="customer">客户经营</el-radio-button>
</el-radio-group>
<div class="spacer" />
<el-button
type="primary"
:disabled="selectedRows.length < 2 || selectedRows.length > 5"
@click="compareVisible = true"
>对比已选 ({{ selectedRows.length }})</el-button>
<el-button :loading="exporting" @click="exportCsv">导出CSV</el-button>
<!-- trial 常显:载荷无页面级 trial 键,批次C/D 评分处于试算期(同驾驶舱) -->
<DataAsOfBadge
:data-as-of="resp?.data_as_of ?? null"
:metric-version="resp?.metric_version"
:trial="true"
:config-source="resp?.config_source"
/>
</div>
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
<el-alert v-if="loadError" type="error" :closable="false" class="top-alert">
<template #title>
<span>{{ loadError }}</span>
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
</template>
</el-alert>
<!-- 主表:服务端排序/分页(sortable=custom,数据恒随 URL 查询);
列组随视图切换;多选(跨页保留)供对比抽屉。非排序列头 hover 提示,
关键列头「?」点击开指标口径抽屉 -->
<el-table
ref="tableRef"
v-loading="loading"
:data="rows"
row-key="user_id"
:default-sort="defaultSort"
@selection-change="onSelectionChange"
@sort-change="onSortChange"
>
<el-table-column type="selection" width="42" reserve-selection />
<el-table-column label="姓名" min-width="150">
<template #default="{ row }">
<!-- 带窗进详情:goDetail 随行 channel/start/end/compare(D3) -->
<el-link type="primary" underline="never" @click="goDetail(row)">
{{ row.name || row.user_id }}
</el-link>
<div class="uid">{{ row.user_id }}</div>
</template>
</el-table-column>
<el-table-column width="64">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>在职</span></el-tooltip>
</template>
<template #default="{ row }">
<el-tag v-if="row.is_on_job === true" size="small" type="success">在职</el-tag>
<el-tag v-else-if="row.is_on_job === false" size="small" type="info">离职</el-tag>
<span v-else class="dim">--</span>
</template>
</el-table-column>
<!-- ── 管理概览:评分/异常/规模/维度 ── -->
<template v-if="view === 'management'">
<el-table-column
prop="total_score"
width="120"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #header>
<span>综合分
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">
<el-tooltip
v-if="row.score.score_status === 'insufficient_sample'"
content="样本不足·不参与考核"
placement="top"
>
<span class="dim">--</span>
</el-tooltip>
<span v-else class="score-cell">
{{ Math.round(row.score.total_score ?? 0) }}
<el-tag
v-if="row.score.grade"
size="small"
:type="gradeType(row.score.grade)"
class="grade-tag"
>{{ row.score.grade }}</el-tag>
<span v-if="row.score.trial" class="trial-mark" title="试算期评分">试</span>
</span>
</template>
</el-table-column>
<el-table-column
prop="anomaly_severity"
label="异常"
width="90"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #default="{ row }">
<span :class="row.anomaly_count > 0 ? 'anomaly-hot' : 'dim'">{{ row.anomaly_count }}</span>
<div v-if="row.anomaly_count > 0" class="cell-sub">权重 {{ row.anomaly_weight }}</div>
</template>
</el-table-column>
<el-table-column
prop="active_friends"
width="100"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #header>
<span>活跃客户
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息量</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(msgTotal(row)) }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>串/被回</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'cust_threads')) }} / {{ fmtInt(num(row, 'threads_answered')) }}
</template>
</el-table-column>
<el-table-column min-width="90">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>客户经营</span></el-tooltip>
</template>
<template #default="{ row }">
<span :class="dimScore(row, 'customer') == null ? 'dim' : ''">{{ dimScoreText(row, 'customer') }}</span>
</template>
</el-table-column>
<el-table-column min-width="90">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>服务质量</span></el-tooltip>
</template>
<template #default="{ row }">
<span :class="dimScore(row, 'service') == null ? 'dim' : ''">{{ dimScoreText(row, 'service') }}</span>
</template>
</el-table-column>
<el-table-column min-width="90">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>工作负载</span></el-tooltip>
</template>
<template #default="{ row }">
<span :class="dimScore(row, 'workload') == null ? 'dim' : ''">{{ dimScoreText(row, 'workload') }}</span>
</template>
</el-table-column>
</template>
<!-- ── 工作负载:规模/消息/通话/串 ── -->
<template v-else-if="view === 'workload'">
<el-table-column
prop="active_friends"
width="100"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #header>
<span>活跃客户
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'active_friends')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃群(日均)</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtAvg(num(row, 'active_rooms_avg')) }}</template>
</el-table-column>
<el-table-column min-width="130">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>消息发/收</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'msg_sent')) }} / {{ fmtInt(num(row, 'msg_recv')) }}
</template>
</el-table-column>
<el-table-column min-width="130">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>通话量/时长</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'call_count')) }} / {{ fmtSec(num(row, 'call_seconds')) }}
</template>
</el-table-column>
<el-table-column
prop="cust_threads"
label="串"
width="80"
sortable="custom"
:sort-orders="['descending', 'ascending']"
>
<template #default="{ row }">{{ fmtInt(num(row, 'cust_threads')) }}</template>
</el-table-column>
</template>
<!-- ── 服务质量:首响分位/回复率/被回串 ── -->
<template v-else-if="view === 'service'">
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P50</span></el-tooltip>
</template>
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p50_ms')) }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<span>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>首响P90</span></el-tooltip>
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ formatMsDuration(num(row, 'fr_p90_ms')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>2h回复率</span></el-tooltip>
</template>
<template #default="{ row }">{{ replyRate(row, 'reply_2h_count') }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>24h回复率</span></el-tooltip>
</template>
<template #default="{ row }">{{ replyRate(row, 'reply_24h_count') }}</template>
</el-table-column>
<el-table-column min-width="110">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>被回/串</span></el-tooltip>
</template>
<template #default="{ row }">
{{ fmtInt(num(row, 'threads_answered')) }} / {{ fmtInt(num(row, 'cust_threads')) }}
</template>
</el-table-column>
</template>
<!-- ── 客户经营:生命周期四态+活跃占比 ── -->
<template v-else>
<el-table-column min-width="80">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>新客</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'new_customer')) }}</template>
</el-table-column>
<el-table-column min-width="80">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>降温</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'cooling_customer')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<span>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>沉睡(期末)</span></el-tooltip>
<el-tooltip content="查看指标口径" placement="top">
<el-icon class="hd-q" @click.stop="defsVisible = true"><QuestionFilled /></el-icon>
</el-tooltip>
</span>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'dormant_customer')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>回流(期内)</span></el-tooltip>
</template>
<template #default="{ row }">{{ fmtInt(num(row, 'reactivated_customer')) }}</template>
</el-table-column>
<el-table-column min-width="100">
<template #header>
<el-tooltip content="该列不支持服务端排序" placement="top"><span>活跃占比</span></el-tooltip>
</template>
<template #default="{ row }">{{ activeShare(row) }}</template>
</el-table-column>
</template>
<template #empty>
<el-empty :image-size="72" description="无匹配员工——调整关键词/状态" />
</template>
</el-table>
<el-pagination
:current-page="page"
:page-size="PAGE_SIZE"
:total="total"
layout="total, prev, pager, next"
class="pager"
@current-change="onPageChange"
/>
<!-- 对比抽屉(2-5 行)与指标口径抽屉(自缓存) -->
<EmployeeCompareDrawer v-model="compareVisible" :rows="selectedRows" />
<MetricDefsDrawer v-model="defsVisible" />
</div>
</template>
<script setup lang="ts">
// 员工总览(经营管理批次D):四视图列组共用 /management/employees 行集,
// 服务端排序(仅 综合分/异常/活跃客户/串 四列)+ 服务端分页,query 全量
// 真源(view/keyword/status/sort_by/sort_order/page),刷新/分享保持。
// 多选 2-5 人开对比抽屉;CSV 导出当前视图列并带口径注释前置行。
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type TableInstance } from 'element-plus'
// QuestionFilled 全局已注册(main.ts 全量挂载),显式导入与项目其他页一致
import { QuestionFilled, Search } from '@element-plus/icons-vue'
import {
getEmployees,
type EmployeeRow,
type EmployeesResp,
} from '@/api/management'
import { useManagementFilters } from '@/composables/useManagementFilters'
import { exportListToCsv, type CsvColumn } from '@/utils/csvExport'
import { formatMsDuration, formatRatio } from '@/utils/format'
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
import EmployeeCompareDrawer from '@/components/management/EmployeeCompareDrawer.vue'
const PAGE_SIZE = 20
type EmpView = 'management' | 'workload' | 'service' | 'customer'
type SortBy = 'anomaly_severity' | 'total_score' | 'active_friends' | 'cust_threads'
const VIEW_VALUES: readonly EmpView[] = ['management', 'workload', 'service', 'customer']
const SORT_BY_VALUES: readonly SortBy[] = ['anomaly_severity', 'total_score', 'active_friends', 'cust_threads']
const route = useRoute()
const router = useRouter()
const { channel, start, end, compare, apply } = useManagementFilters()
// ── 页面级 query 真源(白名单外回默认;与后端 Literal 校验同域) ──
const view = computed<EmpView>(() =>
VIEW_VALUES.includes(route.query.view as EmpView) ? (route.query.view as EmpView) : 'management')
const keyword = computed(() => (typeof route.query.keyword === 'string' ? route.query.keyword.trim() : ''))
const status = computed<'all' | 'on_job' | 'offline'>(() =>
route.query.status === 'on_job' || route.query.status === 'offline' ? route.query.status : 'all')
const sortBy = computed<SortBy>(() =>
SORT_BY_VALUES.includes(route.query.sort_by as SortBy) ? (route.query.sort_by as SortBy) : 'anomaly_severity')
const sortOrder = computed<'asc' | 'desc'>(() => (route.query.sort_order === 'asc' ? 'asc' : 'desc'))
const page = computed(() => {
const n = Number(route.query.page)
return Number.isInteger(n) && n >= 1 ? n : 1
})
// ── 加载(seq 守卫沿用驾驶舱/ChurnView 模式) ──
const resp = ref<EmployeesResp | null>(null)
const loading = ref(false)
const loadError = ref<string | null>(null)
let seq = 0
function queryParams(over: Record<string, unknown> = {}): Record<string, unknown> {
return {
channel: channel.value,
start: start.value,
end: end.value,
view: view.value,
keyword: keyword.value || undefined,
status: status.value,
sort_by: sortBy.value,
sort_order: sortOrder.value,
page: page.value,
page_size: PAGE_SIZE,
...over,
}
}
async function load(): Promise<void> {
const my = ++seq
loading.value = true
loadError.value = null
try {
const r = await getEmployees(queryParams())
if (my !== seq) return
resp.value = r
} catch (e) {
// 失败保留上次成功数据,仅标记 error 供 alert+重试
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
} finally {
if (my === seq) loading.value = false
}
}
watch([channel, start, end, view, keyword, status, sortBy, sortOrder, page], load, { immediate: true })
const rows = computed<EmployeeRow[]>(() => resp.value?.rows ?? [])
const total = computed(() => resp.value?.total ?? 0)
// ── 工具行交互(全部写回 URL;筛选变化重置分页) ──
const keywordInput = ref(keyword.value)
let kwTimer: ReturnType<typeof setTimeout> | undefined
watch(keywordInput, (v) => {
if (kwTimer) clearTimeout(kwTimer)
kwTimer = setTimeout(() => {
if ((v.trim() || '') !== keyword.value) apply({ keyword: v.trim() || undefined, page: undefined })
}, 300)
})
// 外部导航(回退/直链)同步输入框显示
watch(keyword, (v) => {
if (v !== keywordInput.value.trim()) keywordInput.value = v
})
function onStatusChange(v: string | number | boolean): void {
apply({ status: v === 'all' ? undefined : String(v), page: undefined })
}
// 切视图:列组变化,排序回默认(异常降序)、回第一页
function onViewChange(v: string | number | boolean): void {
apply({ view: String(v), sort_by: undefined, sort_order: undefined, page: undefined })
}
function onCompareChange(v: string | number | boolean): void {
apply({ compare: v === 'previous' ? undefined : String(v) })
}
function onPageChange(p: number): void {
apply({ page: p === 1 ? undefined : p })
}
// ── 服务端排序:el-table custom 模式(全仓首个),prop 即后端 sort_by 值 ──
const tableRef = ref<TableInstance>()
const defaultSort = computed(() => ({
prop: sortBy.value,
order: sortOrder.value === 'asc' ? ('ascending' as const) : ('descending' as const),
}))
function onSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }): void {
const by = SORT_BY_VALUES.find((v) => v === prop)
if (!by || !order) return // :sort-orders 两态,order 不会 null,防御
apply({ sort_by: by, sort_order: order === 'ascending' ? 'asc' : 'desc', page: undefined })
}
// URL 驱动的排序/视图变化(回退/切视图重置/直链)同步表头指示:sort() 对
// custom 列只设指示不本地排序(EP watcher sortData 对字符串 sortable 直接
// 返回原数据);prop 在当前视图无该列时为无害 no-op
watch([view, sortBy, sortOrder], ([, by, order]) => {
tableRef.value?.sort(by, order === 'asc' ? 'ascending' : 'descending')
}, { flush: 'post' })
// ── 取数/格式化(metrics 数值键;user_id 透传键是字符串,typeof 守卫) ──
function num(r: EmployeeRow, key: string): number | null {
const v = r.metrics[key]
return typeof v === 'number' ? v : null
}
function fmtInt(v: number | null | undefined): string {
return v == null ? '--' : v.toLocaleString()
}
function fmtAvg(v: number | null | undefined): string {
return v == null ? '--' : v.toLocaleString('zh-CN', { maximumFractionDigits: 1 })
}
function fmtSec(s: number | null): string {
return s && s > 0 ? formatMsDuration(s * 1000) : '-'
}
function msgTotal(r: EmployeeRow): number {
return (num(r, 'msg_sent') ?? 0) + (num(r, 'msg_recv') ?? 0)
}
function replyRate(r: EmployeeRow, countKey: 'reply_2h_count' | 'reply_24h_count'): string {
const den = num(r, 'cust_threads')
if (!den) return '--' // 分母 0/null → 无率(严格区分 0%)
return formatRatio((num(r, countKey) ?? 0) / den)
}
function activeShare(r: EmployeeRow): string {
const ea = num(r, 'end_active_friends') ?? 0
const den = ea + (num(r, 'cooling_customer') ?? 0) + (num(r, 'dormant_customer') ?? 0)
return den > 0 ? formatRatio(ea / den) : '--'
}
function dimScore(r: EmployeeRow, key: string): number | null {
return r.score.dimensions[key]?.score ?? null
}
function dimScoreText(r: EmployeeRow, key: string): string {
const s = dimScore(r, key)
return s == null ? '--' : String(Math.round(s))
}
const GRADE_TYPES: Record<string, 'success' | 'primary' | 'warning' | 'danger' | 'info'> = {
A: 'success', B: 'primary', C: 'warning', D: 'danger', E: 'info',
}
function gradeType(g: string | null): 'success' | 'primary' | 'warning' | 'danger' | 'info' {
return (g && GRADE_TYPES[g]) || 'info'
}
// 带窗进详情(D3):channel/start/end/compare 随行——列表口径延续到详情页;
// view/keyword/status/sort_by/sort_order/page 是列表态,不带(详情页只认
// channel/start/end/compare/section,其余键即使带上也被白名单忽略)
function goDetail(row: EmployeeRow): void {
const query: Record<string, string> = {}
for (const key of ['channel', 'start', 'end', 'compare'] as const) {
const v = route.query[key]
if (typeof v === 'string' && v !== '') query[key] = v
}
router.push({ name: 'management-employees-detail', params: { userId: row.user_id }, query })
}
// ── 多选 → 对比抽屉(reserve-selection 跨页/重查保留;行是勾选时快照) ──
const selectedRows = ref<EmployeeRow[]>([])
const compareVisible = ref(false)
// 指标口径抽屉(列头「?」与筛选栏「指标口径」共用)
const defsVisible = ref(false)
function onSelectionChange(sel: EmployeeRow[]): void {
selectedRows.value = sel
}
// ── CSV:当前视图列全量翻页导出,preamble 写口径/窗口注释(Excel 首列) ──
const exporting = ref(false)
const csvColumns = computed<CsvColumn<EmployeeRow>[]>(() => {
const base: CsvColumn<EmployeeRow>[] = [
{ key: 'name', label: '姓名', format: (r) => r.name ?? r.user_id },
{ key: 'user_id', label: 'ID' },
{
key: 'is_on_job',
label: '在职',
format: (r) => (r.is_on_job === true ? '在职' : r.is_on_job === false ? '离职' : '--'),
},
]
const dimCol = (key: string, label: string): CsvColumn<EmployeeRow> => ({
key: `dim_${key}`, label, format: (r) => dimScoreText(r, key),
})
if (view.value === 'management') {
return base.concat([
{
key: 'total_score',
label: '综合分',
format: (r) =>
r.score.score_status === 'insufficient_sample' || r.score.total_score == null
? '--(样本不足)'
: String(Math.round(r.score.total_score)),
},
{ key: 'grade', label: '等级', format: (r) => r.score.grade ?? '--' },
{ key: 'anomaly_count', label: '异常数', format: (r) => String(r.anomaly_count) },
{ key: 'anomaly_weight', label: '异常权重', format: (r) => String(r.anomaly_weight) },
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
{ key: 'msg_total', label: '消息量', format: (r) => fmtInt(msgTotal(r)) },
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
dimCol('customer', '客户经营分'),
dimCol('service', '服务质量分'),
dimCol('workload', '工作负载分'),
])
}
if (view.value === 'workload') {
return base.concat([
{ key: 'active_friends', label: '活跃客户', format: (r) => fmtInt(num(r, 'active_friends')) },
{ key: 'active_rooms_avg', label: '活跃群(日均)', format: (r) => fmtAvg(num(r, 'active_rooms_avg')) },
{ key: 'msg_sent', label: '消息发', format: (r) => fmtInt(num(r, 'msg_sent')) },
{ key: 'msg_recv', label: '消息收', format: (r) => fmtInt(num(r, 'msg_recv')) },
{ key: 'call_count', label: '通话量', format: (r) => fmtInt(num(r, 'call_count')) },
{ key: 'call_seconds', label: '通话秒', format: (r) => String(num(r, 'call_seconds') ?? 0) },
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
])
}
if (view.value === 'service') {
return base.concat([
{ key: 'fr_p50_ms', label: '首响P50', format: (r) => formatMsDuration(num(r, 'fr_p50_ms')) },
{ key: 'fr_p90_ms', label: '首响P90', format: (r) => formatMsDuration(num(r, 'fr_p90_ms')) },
{ key: 'reply_2h_rate', label: '2h回复率', format: (r) => replyRate(r, 'reply_2h_count') },
{ key: 'reply_24h_rate', label: '24h回复率', format: (r) => replyRate(r, 'reply_24h_count') },
{ key: 'threads_answered', label: '被回', format: (r) => fmtInt(num(r, 'threads_answered')) },
{ key: 'cust_threads', label: '串', format: (r) => fmtInt(num(r, 'cust_threads')) },
])
}
return base.concat([
{ key: 'new_customer', label: '新客', format: (r) => fmtInt(num(r, 'new_customer')) },
{ key: 'cooling_customer', label: '降温', format: (r) => fmtInt(num(r, 'cooling_customer')) },
{ key: 'dormant_customer', label: '沉睡(期末)', format: (r) => fmtInt(num(r, 'dormant_customer')) },
{ key: 'reactivated_customer', label: '回流(期内)', format: (r) => fmtInt(num(r, 'reactivated_customer')) },
{ key: 'active_share', label: '活跃占比', format: (r) => activeShare(r) },
])
})
async function exportCsv(): Promise<void> {
if (!total.value) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv<EmployeeRow>({
// 适配器:getEmployees 返回 {rows,total} → csvExport 期望 {items,total}
fetchPage: (p, size) =>
getEmployees(queryParams({ page: p, page_size: size })).then((r) => ({ items: r.rows, total: r.total })),
columns: csvColumns.value,
filename: `员工分析_${view.value}_${channel.value}`,
preamble: [
'口径:1:1私聊·跨日精确串(与数据看板混合口径不同)',
`窗口:${start.value}-${end.value} 渠道:${channel.value}`,
`评分为试算(${resp.value?.metric_version ?? 'v1.0'})·样本不足员工不计分`,
],
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
exporting.value = false
}
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 12px;
}
.kw-input {
width: 200px;
}
.compare-select {
width: 150px;
}
.status-select {
width: 90px;
}
.spacer {
flex: 1;
}
.top-alert {
margin-top: 12px;
}
.retry-btn {
margin-left: 12px;
}
.uid {
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dim {
color: var(--el-text-color-placeholder);
}
.score-cell {
white-space: nowrap;
}
.grade-tag {
margin-left: 4px;
}
.trial-mark {
margin-left: 2px;
font-size: 10px;
color: var(--el-color-warning);
vertical-align: super;
}
.anomaly-hot {
color: var(--el-color-danger);
font-weight: 600;
}
.cell-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.hd-q {
margin-left: 2px;
color: var(--el-text-color-secondary);
cursor: pointer;
vertical-align: -2px;
}
.hd-q:hover {
color: var(--el-color-primary);
}
.pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
</style>
@@ -0,0 +1,537 @@
<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"
:delta="deltaOf('active_customers')"
unit="人"
:hint="threadsHint"
/>
</el-col>
<el-col :xs="12" :sm="8">
<ManagementKpiCard
label="活跃员工"
:value="kpis?.active_employees ?? null"
:delta="deltaOf('active_employees')"
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"
:delta="deltaOf('msg_total')"
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 卡环比(批次D):客户端统一按 (cur-prev)/prev 自 prev_kpis 计算
// (数据同载荷都在;后端 msg_delta 备用未用)。prev_kpis 缺失(旧缓存
// 响应)或 prev=0(前窗无数据)→ null 不显示
function deltaOf(key: 'active_customers' | 'active_employees' | 'msg_total'): number | null {
const k = resp.value?.kpis
const p = resp.value?.prev_kpis
if (!k || !p || !p[key]) return null
return (k[key] - p[key]) / p[key]
}
// 活跃客户辅助行:串/被回(口径与 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>