Compare commits
6 Commits
602bee8fdf
...
2c7625c3e4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c7625c3e4 | |||
| 422a726ee8 | |||
| be0d6f844e | |||
| 577617545f | |||
| 4e53a99629 | |||
| b72e957da2 |
@@ -303,3 +303,72 @@ 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 顺手统一即可 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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,37 @@ 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:
|
||||
from sqlalchemy import tuple_ as sa_tuple
|
||||
|
||||
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}
|
||||
keep_list = list(keep)
|
||||
async with async_session_maker() as session:
|
||||
for i in range(0, len(keep_list), 500):
|
||||
chunk = keep_list[i:i + 500]
|
||||
res = await session.execute(
|
||||
sa.delete(ChatGroup).where(
|
||||
ChatGroup.channel == "wechat",
|
||||
sa.tuple_(
|
||||
ChatGroup.owner_account_id, ChatGroup.room_id
|
||||
).not_in(sa_tuple(chunk)),
|
||||
)
|
||||
)
|
||||
pruned += int(res.rowcount or 0)
|
||||
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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '归属微信与群主',
|
||||
|
||||
@@ -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