Compare commits
16 Commits
602bee8fdf
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 682249a3fd | |||
| 80b721b84f | |||
| acd6997bf0 | |||
| 253be99eca | |||
| e00d399195 | |||
| 2ac63e3844 | |||
| 0dc5993af7 | |||
| 0b975a8289 | |||
| 8f9ef747cc | |||
| 3d2889bcde | |||
| 2c7625c3e4 | |||
| 422a726ee8 | |||
| be0d6f844e | |||
| 577617545f | |||
| 4e53a99629 | |||
| b72e957da2 |
@@ -303,3 +303,109 @@ action_counts 内部:`unreplied(hours=2)` **2.38s**;`churn(real,30d)` 0.12s
|
||||
| 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/敏感词扫描的真实活跃量级须在服务器侧(夜间链已跑)观察 |
|
||||
|
||||
@@ -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)。
|
||||
@@ -101,14 +101,37 @@ async def list_groups(
|
||||
list_stmt = sa.select(ChatGroup)
|
||||
if conditions:
|
||||
list_stmt = list_stmt.where(*conditions)
|
||||
list_stmt = (
|
||||
list_stmt.order_by(
|
||||
sa.nullslast(ChatGroup.last_chat_time.desc()),
|
||||
ChatGroup.room_id.asc(),
|
||||
if (channel or "wechat") == "wechat":
|
||||
# 云客「微信群统计」同款排序(2026-09-10 真机终判):owner_account_id
|
||||
# (=微信号/wxid 混存,如 Nancyjade < csf7169708 < wxid_01gv…——大写
|
||||
# 在小写前=区分大小写)**二进制字节序升序**分组,组内 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()
|
||||
|
||||
# ---- 员工微信/员工信息列(wechat_account/staff 关联,本页行集) ----
|
||||
|
||||
@@ -184,6 +184,26 @@ async def management_dashboard(
|
||||
"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():
|
||||
@@ -207,7 +227,13 @@ async def management_dashboard(
|
||||
)
|
||||
|
||||
# ---- 异常汇总:critical 先、warning 后,同 severity 按 affected desc ----
|
||||
anomalies = [a for data in assembled.values() for a in data["anomalies"]]
|
||||
# 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),
|
||||
@@ -222,6 +248,8 @@ async def management_dashboard(
|
||||
"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,
|
||||
|
||||
@@ -537,6 +537,18 @@ class Message(Base):
|
||||
"ix_message_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",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -803,8 +803,13 @@ async def upsert_media(
|
||||
|
||||
dialect = await _dialect_name(session)
|
||||
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 = [
|
||||
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
|
||||
|
||||
@@ -812,7 +817,8 @@ async def upsert_media(
|
||||
"""Map each updated column to its conflict-set expression.
|
||||
|
||||
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] = {}
|
||||
for name in update_cols:
|
||||
@@ -820,6 +826,7 @@ async def upsert_media(
|
||||
set_[name] = sa.func.coalesce(other_cols[name], media_cols[name])
|
||||
else:
|
||||
set_[name] = other_cols[name]
|
||||
set_["updated_at"] = sa.func.now()
|
||||
return set_
|
||||
|
||||
if dialect == "sqlite":
|
||||
|
||||
@@ -701,25 +701,27 @@ class SyncScheduler:
|
||||
print(line, flush=True)
|
||||
|
||||
async def _analytics_tick(self) -> None:
|
||||
"""4h 分析链(stat_daily_staff 滚动3天 + 敏感词增量)——异常只记日志不断链。
|
||||
"""4h 分析链(staff 滚动3天 + 敏感词增量 + 管理域滚动)——异常只记日志不断链。
|
||||
|
||||
纯 DB 无浏览器子进程,不抢 ``_page_lock``(DB 写毫秒级;
|
||||
``max_instances=1`` 防自身重叠即可)。与夜间 full_sync 的
|
||||
staff-daily-stats 步骤重叠时,两侧 DELETE+INSERT 在重叠 PK 范围上
|
||||
可能死锁/锁等待——非致命:tick 吞异常下轮重试,夜间脚本无 set -e
|
||||
次日自愈(UNIQUE/PK 幂等只保证串行提交下不重复,不消锁竞争)。
|
||||
两段独立事务:段1 staff+敏感词(既有);段2 管理域(fact 近3天活跃会话
|
||||
+ 宽表/通话 滚动3天,批次F 2026-09-10)。段2 失败只回滚自身,不牵连
|
||||
段1 已提交结果(v2 Task12:指标失败不中断主分析链)。纯 DB 无浏览器
|
||||
子进程,不抢 ``_page_lock``;``max_instances=1`` 防自身重叠即可。与
|
||||
夜间 full_sync 的汇总/管理步重叠时,两侧 DELETE+INSERT 在重叠 PK
|
||||
范围可能锁等待——非致命:tick 吞异常下轮重试,夜间脚本幂等自愈。
|
||||
|
||||
时区:``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:
|
||||
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()
|
||||
@@ -730,6 +732,32 @@ class SyncScheduler:
|
||||
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
|
||||
|
||||
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。
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env python
|
||||
"""从群消息派生 group_member(发言者=成员)。
|
||||
"""从群消息派生 group_member(发言者=成员)+ customer 兜底补全昵称/头像。
|
||||
|
||||
云客 API 不单独给群成员列表(chatroom payload 的 memberIds 我们抓不到,
|
||||
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name 是其
|
||||
群昵称——368k 群消息足够派生出高质量成员表(覆盖所有发过言的成员)。
|
||||
2026-08-20 确认),但群消息的 talker 就是发言成员的 wxid、sender_name/
|
||||
sender_head_url 是其群昵称/头像——群消息足够派生出高质量成员表(覆盖
|
||||
所有发过言的成员)。昵称/头像取该成员在群里**最新一条**消息的值。
|
||||
|
||||
昵称取该成员在群里最新一条消息的 sender_name(SQLite 的 max()+裸列行为,
|
||||
取 max(timestamp) 同行的值)。幂等可重跑;建议在 group-messages 之后执行。
|
||||
未发言的成员(被拉进群从没说话)从 customer 表兜底:member_id=friend_id
|
||||
命中本司好友时补好友昵称/头像(2026-09-10 群详情成员墙)。
|
||||
|
||||
幂等可重跑;建议在 group-messages 之后执行(full_sync_all.sh 8b 已挂)。
|
||||
|
||||
用法:
|
||||
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")
|
||||
log = logging.getLogger("backfill_group_members")
|
||||
|
||||
# row_number() 窗口取 timestamp 最新一行的 sender_name:MySQL 8 的
|
||||
# only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口写法两库通用
|
||||
# 且语义严格(最新一条的昵称),不依赖方言碰巧行为
|
||||
# row_number() 窗口取 timestamp 最新一行的 sender_name/sender_head_url:
|
||||
# MySQL 8 的 only_full_group_by 拒绝 SQLite 式「max()+裸列」(1055),窗口
|
||||
# 写法两库通用且语义严格(最新一条的昵称/头像),不依赖方言碰巧行为
|
||||
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 (
|
||||
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,
|
||||
row_number() OVER (
|
||||
PARTITION BY room_id, talker_id ORDER BY timestamp DESC
|
||||
@@ -44,6 +48,17 @@ FROM (
|
||||
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 with async_engine.connect() as conn:
|
||||
@@ -51,12 +66,13 @@ async def main() -> None:
|
||||
log.info(f"派生出 {len(rows)} 个 (群, 成员) 对")
|
||||
|
||||
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, {
|
||||
"channel": "wechat",
|
||||
"room_id": room_id,
|
||||
"member_id": talker_id,
|
||||
"nickname": sender_name,
|
||||
"avatar": head_url,
|
||||
# raw_json 附加派生统计,抽屉可见
|
||||
"raw_json": {
|
||||
"derived_from": "group_messages",
|
||||
@@ -65,7 +81,13 @@ async def main() -> None:
|
||||
},
|
||||
})
|
||||
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__":
|
||||
|
||||
@@ -59,6 +59,15 @@ $PY -m app.sync.cli daily-stats
|
||||
# A1 员工指标读路径依赖此表,2026-09-04)
|
||||
echo "$(date) --- 12/12 Staff-daily-stats rebuild ---"
|
||||
$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 "$(date) === FULL RE-SYNC COMPLETE ==="
|
||||
@@ -84,6 +93,9 @@ TABLES = [
|
||||
("wechat messages", "SELECT COUNT(*) FROM message WHERE channel='wechat'"),
|
||||
("whatsapp messages", "SELECT COUNT(*) FROM message WHERE channel='whatsapp'"),
|
||||
("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 clips", "SELECT COUNT(*) FROM voice_clip"),
|
||||
("moments posts", "SELECT COUNT(*) FROM moments_post"),
|
||||
|
||||
@@ -1779,9 +1779,12 @@ def _normalize_chatroom_stat_row(row: dict, owner_wxid: str) -> dict:
|
||||
return rec
|
||||
|
||||
|
||||
async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_pages: int = 100):
|
||||
"""翻完 getChatroomList 一个窗,返回全部行(list)。"""
|
||||
async def _chatroom_rows(
|
||||
page, dept: str, start_str: str, end_str: str, max_pages: int = 100
|
||||
) -> tuple[list[dict], int]:
|
||||
"""翻完 getChatroomList 一个窗,返回 (全部行, 云客声明 total)。"""
|
||||
rows_all: list[dict] = []
|
||||
declared = 0
|
||||
page_num = 1
|
||||
while page_num <= max_pages:
|
||||
body = await _browser_fetch(
|
||||
@@ -1794,13 +1797,13 @@ async def _chatroom_rows(page, dept: str, start_str: str, end_str: str, max_page
|
||||
data = body.get("data") or {}
|
||||
rows = data.get("overviews") or []
|
||||
rows_all.extend(rows)
|
||||
total = data.get("totalCount") or 0
|
||||
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {total})")
|
||||
if not rows or page_num * 50 >= (total or 0):
|
||||
declared = data.get("totalCount") or declared
|
||||
logger.info(f" chatroom-list page {page_num}: {len(rows)} rows (total {declared})")
|
||||
if not rows or page_num * 50 >= (declared or 0):
|
||||
break
|
||||
page_num += 1
|
||||
await asyncio.sleep(1.2)
|
||||
return rows_all
|
||||
return rows_all, declared or 0
|
||||
|
||||
|
||||
async def _owner_alias_map(conn):
|
||||
@@ -1851,9 +1854,11 @@ async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
||||
|
||||
group_recs: list[dict] = []
|
||||
stats_recs: list[dict] = []
|
||||
declared = 0
|
||||
async with PageScraper(cookie_str) as 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")
|
||||
if not rid:
|
||||
continue
|
||||
@@ -1877,9 +1882,41 @@ async def scrape_chatroom_stats(cookie_str: str, day: str | None = None):
|
||||
await _upsert_all(upsert_chat_group, group_recs)
|
||||
if 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(
|
||||
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)
|
||||
|
||||
|
||||
@@ -64,17 +64,19 @@ async def _seed(session: AsyncSession) -> None:
|
||||
|
||||
|
||||
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)
|
||||
r = await client.get("/api/groups")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["total"] == 3
|
||||
# 2000, 1000, null -> [room_a, room_b, wa_room_x]
|
||||
assert [it["room_id"] for it in body["items"]] == [
|
||||
"wa_room_x@s.whatsapp.net",
|
||||
"room_a@chatroom",
|
||||
"room_b@chatroom",
|
||||
"wa_room_x@s.whatsapp.net",
|
||||
]
|
||||
for key in (
|
||||
"room_id",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
|
||||
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
|
||||
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/
|
||||
metric_version);
|
||||
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;
|
||||
@@ -280,6 +281,15 @@ async def test_dashboard_structure_and_kpis(make_client):
|
||||
"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)]
|
||||
@@ -306,17 +316,39 @@ async def test_dashboard_structure_and_kpis(make_client):
|
||||
assert d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
|
||||
assert "avg_score" in d
|
||||
|
||||
# 异常:critical 在前(u2),warning 其后按 affected_count desc
|
||||
# 异常: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
|
||||
|
||||
|
||||
@@ -501,9 +533,11 @@ async def test_employee_detail_full(make_client):
|
||||
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)
|
||||
# 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)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""_analytics_tick:滚动刷新 + 增量扫描 + 异常吞掉(数据分析板块 v1, T11)。
|
||||
"""_analytics_tick:滚动刷新 + 增量扫描 + 管理域滚动段 + 异常吞掉(v1 T11;批次F F1)。
|
||||
|
||||
行为测试是底线——start() 的 job 注册断言在 test_sync_scheduler.py 的
|
||||
注册测试里同步更新(那里 spy 断言 add_job 全集)。
|
||||
@@ -15,6 +15,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db.base import Base
|
||||
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.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"):
|
||||
await sch._analytics_tick() # 不抛
|
||||
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
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_m
|
||||
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 } }
|
||||
@@ -34,6 +36,9 @@ export interface AnomalyCard {
|
||||
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']
|
||||
|
||||
@@ -14,6 +14,13 @@
|
||||
<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>
|
||||
@@ -43,22 +50,42 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+原因
|
||||
// 双行/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
|
||||
// 异常榜(批次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'
|
||||
|
||||
defineProps<{
|
||||
anomalies: AnomalyCard[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
// 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)
|
||||
@@ -89,6 +116,14 @@ function onRowClick(row: AnomalyCard): void {
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
@@ -4,14 +4,18 @@
|
||||
<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 红)
|
||||
// + hint 下小灰字。to 存在时整卡可点(hover 手型)跳转——纯展示+导航,
|
||||
// 不取数。null 值语义(无样本)显 `--`,由父级决定传 null。
|
||||
// + 环比行(批次D,delta prop)+ hint 下小灰字。to 存在时整卡可点
|
||||
// (hover 手型)跳转——纯展示+导航,不取数。null 值语义(无样本)显 `--`,
|
||||
// 由父级决定传 null。
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -22,12 +26,26 @@ const props = defineProps<{
|
||||
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)
|
||||
}
|
||||
@@ -56,6 +74,19 @@ function onClick(): void {
|
||||
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);
|
||||
|
||||
@@ -11,9 +11,11 @@ const channelStore = useChannelStore()
|
||||
|
||||
// `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.
|
||||
// 带参路由(路径与菜单 index 不等)按 route.name 映射父菜单路径。
|
||||
const activeMenu = computed(() => {
|
||||
if (route.name === 'chat') return '/customers'
|
||||
if (route.name === 'group-chat') return '/groups'
|
||||
if (route.name === 'management-employees-detail') return '/management/employees'
|
||||
return route.path
|
||||
})
|
||||
|
||||
@@ -107,6 +109,9 @@ function logout(): void {
|
||||
<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>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
|
||||
@@ -99,6 +99,24 @@ const routes: RouteRecordRaw[] = [
|
||||
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?',
|
||||
name: 'chat',
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
* ],
|
||||
* filename: '微信客户列表',
|
||||
* })
|
||||
*
|
||||
* Optional `preamble: string[]` writes comment lines (口径/窗口注释等) before
|
||||
* the header — each line occupies the first column of its own row.
|
||||
*/
|
||||
|
||||
export interface CsvColumn<T> {
|
||||
@@ -30,6 +33,12 @@ export interface ExportOptions<T> {
|
||||
pageSize?: number
|
||||
/** Max rows to export (safety cap, default 10000) */
|
||||
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 {
|
||||
@@ -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 }> {
|
||||
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options
|
||||
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000, preamble } = options
|
||||
|
||||
// Fetch all pages
|
||||
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)
|
||||
}).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
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
|
||||
@@ -77,7 +77,7 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
|
||||
},
|
||||
{
|
||||
title: '成员',
|
||||
keys: ['memberCount', 'memberIds'],
|
||||
keys: ['memberCount'], // memberIds 原始串由群详情成员墙替代(2026-09-10)
|
||||
},
|
||||
{
|
||||
title: '归属微信与群主',
|
||||
@@ -105,7 +105,11 @@ export const GROUP_FIELD_GROUPS: GroupFieldGroup[] = [
|
||||
]
|
||||
|
||||
/** 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). */
|
||||
export function fieldLabel(key: string): string {
|
||||
|
||||
@@ -3,8 +3,8 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Download } from '@element-plus/icons-vue'
|
||||
import { getAccounts, getGroups } from '@/api'
|
||||
import type { Account, Channel, Group } from '@/api/types'
|
||||
import { getAccounts, getGroupMembers, getGroups } from '@/api'
|
||||
import type { Account, Channel, Group, GroupMember } from '@/api/types'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import { channelLabel, formatTime } from '@/utils/format'
|
||||
import { exportListToCsv } from '@/utils/csvExport'
|
||||
@@ -247,9 +247,35 @@ function clearOwnerFilter(): void {
|
||||
}
|
||||
|
||||
// ---- 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 {
|
||||
drawerGroup.value = row
|
||||
drawerOpen.value = true
|
||||
if (row.channel === 'wechat') {
|
||||
loadDrawerMembers(row.room_id)
|
||||
} else {
|
||||
drawerMembers.value = []
|
||||
}
|
||||
}
|
||||
const drawerSections = computed(() =>
|
||||
drawerGroup.value ? buildGroupSections(drawerGroup.value) : [],
|
||||
@@ -549,6 +575,26 @@ onMounted(() => {
|
||||
</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">
|
||||
<el-divider content-position="left">{{ section.title }}</el-divider>
|
||||
<dl class="yk-drawer__kv">
|
||||
@@ -597,4 +643,46 @@ onMounted(() => {
|
||||
.yk-group-owner__meta {
|
||||
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>
|
||||
|
||||
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>
|
||||
@@ -44,6 +44,7 @@
|
||||
<ManagementKpiCard
|
||||
label="活跃客户"
|
||||
:value="kpis?.active_customers ?? null"
|
||||
:delta="deltaOf('active_customers')"
|
||||
unit="人"
|
||||
:hint="threadsHint"
|
||||
/>
|
||||
@@ -52,6 +53,7 @@
|
||||
<ManagementKpiCard
|
||||
label="活跃员工"
|
||||
:value="kpis?.active_employees ?? null"
|
||||
:delta="deltaOf('active_employees')"
|
||||
unit="人"
|
||||
hint="期内有数据天数>0 口径"
|
||||
/>
|
||||
@@ -65,6 +67,7 @@
|
||||
<ManagementKpiCard
|
||||
label="消息量"
|
||||
:value="kpis?.msg_total ?? null"
|
||||
:delta="deltaOf('msg_total')"
|
||||
unit="条"
|
||||
hint="1:1口径·与数据看板口径不同"
|
||||
/>
|
||||
@@ -209,6 +212,16 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user