Compare commits

...

3 Commits

Author SHA1 Message Date
浅念 a9197f696a fix(sync): 同步链路审计修复——水位边界白拉/全量无超时/群活跃窗判定源
全项目数据同步审计发现四漏洞(前项为证据):

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

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

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:08:15 +08:00
浅念 ddeda3fa22 feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-10 11:07:25 +08:00
浅念 2587e4dd08 docs(management): 批次D员工页计划——总览+详情+环比+预设补全(Task D0-D4)
评审3真问题全修:排序白名单四列(余列不排序)/服务页签图改P50P90双线
(员工级无每日率序列)/CSV扩csvExport注释行+rows适配;advisory全采:
异常卡补employee_name(一行后端)/showEmployee复用开关/formula_note归位/
伪卡片下钻衔接/compare仅显示态
2026-09-10 11:02:27 +08:00
8 changed files with 282 additions and 16 deletions
@@ -0,0 +1,172 @@
# 云客经营管理后台·批次D(员工总览+员工详情)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 补上管理闭环最大断点——员工总览(41 人多维一张表)与员工详情(五维/页签/四对比/事实下钻),打通「异常→员工全景→事实」链路;附带驾驶舱 KPI 环比与时间预设补全(体验级小项)。
**Architecture:** 纯前端为主(消费批次B已就绪的 employees/detail/metrics sections/drilldown 端点)+ 两处小后端增强(dashboard kpis 加 prev 块;无新端点)。新页 `EmployeeAnalyticsView`/`EmployeeAnalyticsDetailView` + 对比抽屉;复用 C3 六组件(FilterBar/DataAsOfBadge/AnomalyList/MetricDrilldownDrawer/MetricDefsDrawer)与 useManagementFilters。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §6/§7/§13 + 批次B契约清单。
**Tech Stack:** 同批次C(Vue3+TS+Element Plus+useChart+vitest);后端 FastAPI 小改。
---
## 执行者必读
1. **消费的端点 as-built 形态**(写 client 前已核对 management.py:321-443):
- `GET /employees`(行结构见批次C client 的 EmployeeRow——已含 metrics/comparisons/score 摘要/anomaly_count/weight);
- `GET /employees/{id}`:`{user_id,name,is_on_job,accounts[{channel,account_id,nick_name,user_name}],metrics(22字段dict 含 user_id 键),score{total_score,grade,score_status,trial,dimensions{name,weight,score,status,metrics{code:{value,score,sample_size,sample_status,formula_note}}},metrics_detail{code:{value,prev,baseline,median,sample_size,sample_status,score,unit,name}}},anomalies[],prev(dict|null),baseline(dict|null),scope,data_as_of,metric_version,config_source}`;
- `GET /employees/{id}/metrics?section=`:`{section,metrics(该域字段dict),trend[{day,cols...}](无数据日计数0/分位None),comparisons(源指标名键),scope,data_as_of,metric_version}`;
- drilldown/metric-definitions 已有 client。
2. **前端惯例**同批次C必读(seq 守卫/useChart/URL query/csvExport.ts 全量翻页+中文 BOM/命名路由跳转)。
3. **不做**:辅导/快照(E);员工对比不新开 API(勾选 2-5 行客户端拼数据);「指标解释抽屉」= 复用 MetricDefsDrawer(点击列头 ? 图标打开)——不做每指标单独解释面板(v1 §6.5 的完整版二期)。
4. 仓库并行会话照旧;中文 conventional 提交;master 直提。
## 文件地图
| 文件 | 动作 |
|---|---|
| `yunkes/src/api/management.ts` | 补 getEmployeeDetail/getEmployeeMetrics + types(D0) |
| `yunkes/src/composables/useManagementFilters.ts` | 预设补 今日/昨日/本周/上周(D0) |
| `yunkes/src/views/management/EmployeeAnalyticsView.vue` | 新建(D1) |
| `yunkes/src/components/management/EmployeeCompareDrawer.vue` | 新建(D1,对比抽屉) |
| `yunkes/src/views/management/EmployeeAnalyticsDetailView.vue` | 新建(D2) |
| `yunkes/src/router/index.ts` + `MainLayout.vue` | 两路由+两菜单(D1/D2 各自) |
| `yunke-server/app/api/routers/management.py` + `api_assembly.py` | dashboard kpis 加 prev 块(D3) |
| `yunkes/src/components/management/ManagementKpiCard.vue` + `AnomalyList.vue` + 驾驶舱页 | delta 显示 + 员工跳详情(D3) |
---
### Task D0: client 补全 + 预设扩展
- [ ] `api/management.ts` 追加:
```typescript
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
// formula_note 在 dimensions[dim].metrics[code] 上(scoring.py 维度级明细),不在 metrics_detail
export interface EmployeeDetailResp {
user_id: string; name: string | null; is_on_job: boolean | null
accounts: EmployeeAccount[]
metrics: Record<string, number | string | null>
score: {
total_score: number | null; grade: string | null; score_status: string; trial: boolean
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
metrics_detail: Record<string, ScoreMetricDetail>
}
anomalies: AnomalyCard[]
prev: Record<string, number | string | null> | null
baseline: Record<string, number | string | null> | null
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
}
export interface SectionResp {
section: string; metrics: Record<string, number | null>
trend: Record<string, number | null>[]
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
scope: MgmtScope; data_as_of: number | null; metric_version: string
}
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
```
(写入前 curl 真机抽查 detail 响应键核对——同批次C C1 惯例,以真实响应为准修。)
- [ ] `useManagementFilters.ts` 预设补:`Preset` 加 `'today' | 'yesterday' | 'thisWeek' | 'lastWeek'`;`presetRange` 四分支(today=当天单日窗;yesterday=昨日单日;thisWeek=本周一..今天;lastWeek=上周一..周日,**周一为一周起点**);vitest 补 5 例(today/yesterday/thisWeek 周中+周一/lastWeek 跨月)。FilterBar 的预设选项组同步加(el-select options),分组显示:常用(今日/昨日/近7/近30)/日历(本周/上周/本月/上月)/自定义。
- [ ] vue-tsc + `npm run test`(23+ 过)+ commit `feat(management): client补员工详情/分段接口+时间预设补今日昨日本周上周`
### Task D1: 员工总览页
**Files:** Create `EmployeeAnalyticsView.vue` + `EmployeeCompareDrawer.vue`;改 router(`/management/employees`)+ MainLayout(管理驾驶舱下加「员工分析」菜单项)。
**页面结构**:
```text
ManagementFilterBar(复用;compare 选择器在此页透出——el-select 对比方式:上期/个人基线/团队中位数→apply({compare}))
工具行:keyword el-input(placeholder 姓名/ID搜索) | status el-select(全部/在职/离线) | 视图 el-radio-group×4
(management=概览/workload=负载/service=服务/customer=客户经营——切换列组)| 指标口径按钮 | 导出 CSV | DataAsOfBadge
主表 el-table(服务端排序 sort-change→query;行选择框多选 2-5 人→「对比」按钮开抽屉):
固定列:姓名(链接进详情)+ID 小字、在职 tag
management 视图列:综合分(total_score+grade tag+trial*)、异常(anomaly_count 红数字+weight)、活跃客户、消息量、串/被回、维度三列(customer/service/workload score 各 0-100 或「--」)
workload 视图列:活跃客户、活跃群(日均)、消息收/发、通话量/秒、串
service 视图列:首响P50/P90(formatMsDuration)、2h回复率、24h回复率(formatRatio)、被回/串
customer 视图列:新客/降温/沉睡/回流(期末+期内注)、活跃占比
列头 ? 图标→打开 MetricDefsDrawer(复用)
空数据/样本不足单元格显示「--」灰字(sample_status=insufficient 时 total 亦然)
分页 el-pagination(服务端 page/page_size 20;total)
```
**行为**:query 真源(view/keyword/status/sort_by/sort_order/page 全进 URL);seq 守卫;**服务端排序仅四列**(后端 sort_by 白名单:anomaly_severity[默认]/total_score/active_friends/cust_threads——其余列头不显示排序箭头,tooltip「该列不支持服务端排序」);`sort_by=total_score` 时 null 排最后由后端保证;compare 参数仅客户端显示态(employees 端点无此参数,行恒带全部四对比值);**CSV**:扩展 `utils/csvExport.ts` 支持可选前置注释行(`exportListToCsv(..., preamble?: string[])`——含口径说明如「1:1口径·窗口 20260810-20260908·试算」)+ fetchPage 适配器(/employees 返回 `{rows,total}`,适配为 csvExport 期望的 `{items,total}`),导出当前视图列全量翻页。
**EmployeeCompareDrawer**:props `{ rows: EmployeeRow[] }`(勾选行已有数据,不另发请求);内容:五维分对比条(3 可用维度每人一行,横向 bar 0-100)+ 关键指标表(活跃客户/P90/2h率/异常数 每人一列)+ 顶部说明「同口径同时窗对比,不产生优劣结论」;>5 人禁开。
**路由/菜单**:
```typescript
{ path: '/management/employees', name: 'management-employees',
component: () => import('@/views/management/EmployeeAnalyticsView.vue'),
meta: { title: '员工分析' } },
```
- [ ] vue-tsc/test/build + 真机初查(表格渲染 41 人/切视图列变/排序翻页/keyword/CSV 打开内容对)+ commit `feat(management): 员工总览页——四视图/服务端排序分页/对比抽屉/CSV导出`
### Task D2: 员工详情页
**Files:** Create `EmployeeAnalyticsDetailView.vue`;router 加 `/management/employees/:userId`(props:true;**activeMenu 需高亮父菜单「员工分析」**——MainLayout 的 el-menu :default-active 绑定的是路径,嵌套路由时用 route.meta 或 watch 设 index——看 MainLayout 现有 active 处理方式,最简方案:菜单 index 用路径匹配 + 详情页 route path 前缀匹配高亮,若现结构不支持则 meta.activeMenu='/management/employees' 并在 el-menu :default-active 计算)。
**页面结构**:
```text
头部卡:姓名(+在职 tag)+ID | 账号清单(渠道 tag+account_id+昵称,点击跳 staff-chat/通讯录按渠道)
| 本期试算分大数字+grade tag+score_status(insufficient 时灰注「样本不足·不参与考核」)+trial*
| DataAsOfBadge | 返回列表链接
五维条(v1 §7.2 概览的核心):横向五条 bar——3 可用维度实分数值,action/risk 灰「待数据」占位
(ScoreBreakdown 简版:直接在页内画,不新建组件,el-progress/自定义 div)
el-tabs 五页签(懒加载:激活才调 getEmployeeMetrics):
概览: 六数卡取自**detail.metrics**(msg/活跃/串/通话——section 端点字段有限,期值以 detail 为准)
+ 四对比小表(comparisons 直渲染,源指标名行)
服务质量: 数卡 P50/P90 + 2h/24h回复率(**客户端从 metrics.reply_2h_count/cust_threads 派生,
与 derive_metrics 同式**——section 趋势无每日率序列) + 趋势图=**首响 P50/P90 双线(ms 轴)
±串数(次轴可选)**——不做率右轴(员工级无每日率数据,率的团队级序列在驾驶舱)
客户经营: 期末四态数卡(取自 detail.metrics) + 降温/沉睡下钻按钮(→DrilldownDrawer lifecycle 分支)
工作负载: 数卡取 detail.metrics(收发/活跃客户/群/通话量) + 趋势图用 section=workload 的
active_friends/active_rooms 双线(trend 列为日值 active_rooms)
通话: 通话四数(detail.metrics) + call_followup 下钻按钮
**页签下钻按钮与 MetricDrilldownDrawer 的衔接**:抽屉是 card 驱动——按钮合成最小伪卡片
`{title:'首响P90明细', drilldown:{type:'metric',query:{metric_code:'first_reply_p90',user_id}}}` 传入
异常区:AnomalyList(复用,@drill→MetricDrilldownDrawer 复用)
评分明细折叠面板(el-collapse「评分怎么算出来的」): metrics_detail 表(code/名称/当前/上期/基线/中位/样本/得分)
+ prev/baseline 原始 PeriodMetrics 两个 JSON 折叠(调试用,灰字注明)
```
**行为**:query 真源(channel/start/end/compare/section 进 URL,页签切换 replace);seq 守卫每页签独立(切页签取消旧渲染);404 员工→el-result 错误页+返回链接;「创建辅导记录」按钮**占位禁用**(tooltip「批次E上线」)。
- [ ] vue-tsc/test/build + 真机初查(王云青详情:五维条/五页签懒加载/异常/评分明细展开/账号跳聊天)+ commit `feat(management): 员工详情页——五维条/五页签懒加载/四对比/评分明细/事实下钻复用`
### Task D3: 驾驶舱 KPI 环比 + 异常跳员工
**后端**(`management.py` dashboard + `api_assembly.py`):①dashboard 端点 kpis 加 `prev_kpis` 块(active_customers/active_employees/msg_total/cust_threads——dashboard 走 assemble_employees(with_prev=False),需加一次 prev 窗 period_metrics 全员调用,B5 实测三窗合计 ~0.3s,加一窗 +0.1s 量级)+ `msg_delta`((cur-prev)/prev,prev=0→null)。lifecycle/通话不加环比(期末/全渠道口径环比意义弱,注明)。②**异常卡补 `employee_name`**(assemble_employees 手里已有全员 name——组装 anomalies 时带上,一行);同步 AnomalyCard type。测试补:dashboard 响应含 prev_kpis 且数字=prev 窗聚合 + anomalies[0].employee_name 非空。
**前端**:KpiCard 加 `delta?: number | null` prop(↑绿↓红,方向语义由调用方定——涨=好传正绿;不传不显示);驾驶舱三卡接 delta(活跃客户/活跃员工/消息量);AnomalyList 的 title 下加员工行(`employee_name` 链接→`/management/employees/{user_id}`)+ 新增 `showEmployee?: boolean` prop(默认 true;D2 员工详情页复用时传 false 免自链接)。
- [ ] 后端 pytest(test_management_api.py 补 prev_kpis 断言)+ 前端走查三卡箭头/异常跳员工 + commit `feat(management): 驾驶舱KPI环比(prev_kpis+delta箭头)+异常卡跳员工详情`
### Task D4: 真机走查 + 验收
- [ ] 全链路走查(Playwright):驾驶舱→异常卡→**员工详情**(新链路!)→页签→下钻→聊天;员工总览→对比抽屉→CSV;1366px;刷新保持;
- [ ] 验收对照(v1 §19.1 员工子集):能查看所有员工多维状态 ✓/任一分数展开为指标公式样本事实 ✓(评分明细面板)/四对比 ✓/样本不足不评分显示 ✓/异常两次点击进事实 ✓/默认排序非总分榜 ✓;
- [ ] 四门禁(vue-tsc/vitest/build/后端 pytest);
- [ ] commit 收尾零散 + 走查记录追加核实文档「批次D走查」小节。
---
## 批次D验收清单
- [ ] 员工总览:41 人四视图/服务端排序分页/keyword/对比 2-5 人/CSV 含口径注释;
- [ ] 员工详情:五维条 3+2 占位/五页签懒加载/四对比/评分明细可展开/三处下钻按钮进抽屉/账号跳聊天;
- [ ] 驾驶舱:三 KPI 有环比箭头/异常卡可跳员工详情(闭环打通);
- [ ] 时间预设全套(今日/昨日/近7/近30/本周/上周/本月/上月/自定义);
- [ ] URL 全参数保持;四门禁绿;后端仅 dashboard 一处小改且测试补齐。
## 批次E-F
E:followups 辅导闭环(写端点+schemas_management+详情页按钮激活)+ 考核快照(生成/冻结/列表/导出)。F:调度接入(analytics_tick 第三步+full_sync 13/14 步+scheduler 镜像重建还 9d3cc8e 旧账)+ dashboard 性能修复(unreplied 索引三选一)+ 服务器部署+回测试运行。
+7 -2
View File
@@ -602,7 +602,7 @@ class SyncScheduler:
if not bash: if not bash:
logger.error("page sync 'full': bash not found on PATH") logger.error("page sync 'full': bash not found on PATH")
return return
commands = [([bash, "scripts/full_sync_all.sh"], None)] # (args, timeout)——full 不设超时(全量本身 10-20h) commands = [([bash, "scripts/full_sync_all.sh"], 22 * 3600)] # 22h 上限:防极端情况拖进次日相位(全量常态 10-20h)
elif kind == "messages": elif kind == "messages":
# 2026-09-10 调度根治(用户报「员工列表时间差 2 天」): # 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
# ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据 # ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
@@ -621,6 +621,10 @@ class SyncScheduler:
("wa-friends", None, 2400), ("wa-friends", None, 2400),
("messages", 24, 4800), ("messages", 24, 4800),
("wa-messages", 24, 4800), ("wa-messages", 24, 4800),
# chatroom-stats 全窗(~1min):刷 chat_group.last_chat_time——
# 群消息活跃窗的判定源。不加则窗口用的是昨晚 full 的快照,
# 「昨天安静、今天突然活跃」的群会被窗口漏到明晚(2026-09-10)
("chatroom-stats", None, 1800),
("group-messages", 24, 4800), ("group-messages", 24, 4800),
("wa-group-messages", 24, 2400), ("wa-group-messages", 24, 2400),
("moments", None, 2400), ("moments", None, 2400),
@@ -666,7 +670,8 @@ class SyncScheduler:
"99999", "99999",
"--full-history", "--full-history",
], ],
None, # 每周对齐不设超时 6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
) )
for e in entities for e in entities
] ]
+19 -2
View File
@@ -763,6 +763,9 @@ async def scrape_messages(cookie_str: str, max_conversations: int = 100, full_hi
ts = int(str(ts_raw)) if ts_raw else None ts = int(str(ts_raw)) if ts_raw else None
except (ValueError, TypeError): except (ValueError, TypeError):
ts = None ts = None
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
if watermark and ts is not None and ts <= watermark:
continue
records.append({ records.append({
"msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"), "msg_id": str(row.get("msgId") or row.get("msgSvrId") or row.get("id") or f"{owner_id}:{friend_id}:{i}:{len(records)}"),
"channel": "wechat", "channel": "wechat",
@@ -965,6 +968,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
continue continue
rec = _normalize_group_message(row, owner_id, room_id) rec = _normalize_group_message(row, owner_id, room_id)
if rec is not None: if rec is not None:
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec) records.append(rec)
except Exception as e: except Exception as e:
@@ -2241,7 +2247,14 @@ async def scrape_wa_messages(cookie_str: str, max_conversations: int = 100, full
msgs = data.get("messages") or [] msgs = data.get("messages") or []
for row in msgs: for row in msgs:
if isinstance(row, dict) and not _is_wa_divider(row): if isinstance(row, dict) and not _is_wa_divider(row):
records.append(_normalize_whatsapp_message(row)) rec = _normalize_whatsapp_message(row)
# 增量模式过滤边界旧消息:云客 start 参数是 inclusive
# (返回 ts>=start 含边界),不过滤则每对话白拉数条已
# 入库旧消息(2026-09-10 实测一夜 fetched 53,682 仅
# 321 条真新,99% 白写)。
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
if not data.get("hasLast"): if not data.get("hasLast"):
break break
# 增量触底停:翻到已同步区 # 增量触底停:翻到已同步区
@@ -2385,7 +2398,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
for row in rows: for row in rows:
if not isinstance(row, dict) or _is_wa_divider(row): if not isinstance(row, dict) or _is_wa_divider(row):
continue continue
records.append(_normalize_wa_group_message(row)) rec = _normalize_wa_group_message(row)
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
if watermark and (rec.get("timestamp") or 0) <= watermark:
continue
records.append(rec)
except Exception as e: except Exception as e:
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}") logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
+8 -7
View File
@@ -589,7 +589,7 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
await sched._safe_page_run("messages") await sched._safe_page_run("messages")
assert len(commands) == 9 assert len(commands) == 10
for args in commands: for args in commands:
assert args[1].endswith("scrape_via_page.py") assert args[1].endswith("scrape_via_page.py")
@@ -601,13 +601,14 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
assert entity_of(commands[1]) == "wa-accounts" assert entity_of(commands[1]) == "wa-accounts"
assert entity_of(commands[2]) == "friends" assert entity_of(commands[2]) == "friends"
assert entity_of(commands[3]) == "wa-friends" assert entity_of(commands[3]) == "wa-friends"
# 群消息前刷群活跃(chatroom-stats 全窗——群活跃窗判定源,2026-09-10)
entities_seq = [entity_of(a) for a in commands]
assert entities_seq.index("chatroom-stats") < entities_seq.index("group-messages")
# 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞) # 四类消息:24h 活跃窗(2026-09-10 根治调度堵塞)
for args in commands[4:8]: for args in commands:
assert entity_of(args) in ( if entity_of(args) in ("messages", "wa-messages", "group-messages", "wa-group-messages"):
"messages", "wa-messages", "group-messages", "wa-group-messages", assert args[args.index("--max-conversations") + 1] == "99999"
) assert args[args.index("--active-window-hours") + 1] == "24"
assert args[args.index("--max-conversations") + 1] == "99999"
assert args[args.index("--active-window-hours") + 1] == "24"
# moments 浅扫在序列末尾:每员工最多 3 页 # moments 浅扫在序列末尾:每员工最多 3 页
moments_cmd = commands[-1] moments_cmd = commands[-1]
assert entity_of(moments_cmd) == "moments" assert entity_of(moments_cmd) == "moments"
+30
View File
@@ -70,3 +70,33 @@ export const getEmployees = (params: Record<string, unknown>) =>
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) => export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize }) getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions') export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
// ---- 批次D:员工详情 + 分段指标(契约已真机 8000 抽查核对,见上) ----
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
// formula_note 在 dimensions[dim].metrics[code] 上(scoring 维度级明细),不在 metrics_detail
export interface EmployeeDetailResp {
user_id: string; name: string | null; is_on_job: boolean | null
accounts: EmployeeAccount[]
metrics: Record<string, number | string | null>
score: {
total_score: number | null; grade: string | null; score_status: string; trial: boolean
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
metrics_detail: Record<string, ScoreMetricDetail>
}
anomalies: AnomalyCard[]
prev: Record<string, number | string | null> | null
baseline: Record<string, number | string | null> | null
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
}
export interface SectionResp {
section: string; metrics: Record<string, number | null>
trend: Record<string, number | null>[]
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
scope: MgmtScope; data_as_of: number | null; metric_version: string
}
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
@@ -10,10 +10,18 @@
placeholder="近30天" placeholder="近30天"
@change="onPresetChange" @change="onPresetChange"
> >
<el-option label="近7天" value="last7" /> <el-option-group label="常用">
<el-option label="近30天" value="last30" /> <el-option label="今日" value="today" />
<el-option label="本月" value="thisMonth" /> <el-option label="昨日" value="yesterday" />
<el-option label="上月" value="lastMonth" /> <el-option label="近7天" value="last7" />
<el-option label="近30天" value="last30" />
</el-option-group>
<el-option-group label="日历">
<el-option label="本周" value="thisWeek" />
<el-option label="上周" value="lastWeek" />
<el-option label="本月" value="thisMonth" />
<el-option label="上月" value="lastMonth" />
</el-option-group>
<el-option label="自定义" value="custom" /> <el-option label="自定义" value="custom" />
</el-select> </el-select>
@@ -50,6 +50,26 @@ describe('presetRange(注入 today,确定性日期)', () => {
expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 }) expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 })
}) })
it('today → 今天单日(2026-09-10 → 20260910..20260910)', () => {
expect(presetRange('today', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
})
it('yesterday → 昨日单日(2026-09-10 → 20260909..20260909)', () => {
expect(presetRange('yesterday', D(20260910))).toEqual({ start: 20260909, end: 20260909 })
})
it('thisWeek 周中(2026-07-02 周四 → 周一到今天 20260629..20260702)', () => {
expect(presetRange('thisWeek', D(20260702))).toEqual({ start: 20260629, end: 20260702 })
})
it('thisWeek 周一当天 → 单日(2026-06-29 周一 → 20260629..20260629)', () => {
expect(presetRange('thisWeek', D(20260629))).toEqual({ start: 20260629, end: 20260629 })
})
it('lastWeek 跨月边界(2026-07-02 周四 → 上周一..上周日 20260622..20260628)', () => {
expect(presetRange('lastWeek', D(20260702))).toEqual({ start: 20260622, end: 20260628 })
})
it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => { it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => {
expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 }) expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
}) })
+14 -1
View File
@@ -3,7 +3,7 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom' export type Preset = 'today' | 'yesterday' | 'last7' | 'last30' | 'thisWeek' | 'lastWeek' | 'thisMonth' | 'lastMonth' | 'custom'
export const CHANNELS = [ export const CHANNELS = [
{ value: 'all', label: '全部渠道' }, { value: 'all', label: '全部渠道' },
{ value: 'wechat', label: '微信' }, { value: 'wechat', label: '微信' },
@@ -22,7 +22,20 @@ function addDays(d: Date, n: number): Date {
/** 预设 → [start,end] yyyymmdd。 */ /** 预设 → [start,end] yyyymmdd。 */
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } { export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate()) const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
// 周一为一周起点(getDay 周日=0 → 偏移成周一=0..周日=6)
const dow = (t0.getDay() + 6) % 7
switch (p) { switch (p) {
case 'today':
return { start: ymd(t0), end: ymd(t0) }
case 'yesterday':
return { start: ymd(addDays(t0, -1)), end: ymd(addDays(t0, -1)) }
case 'thisWeek':
// 周一..今天;周一当天即单日
return { start: ymd(addDays(t0, -dow)), end: ymd(t0) }
case 'lastWeek': {
const monday = addDays(t0, -dow - 7)
return { start: ymd(monday), end: ymd(addDays(monday, 6)) }
}
case 'last7': case 'last7':
return { start: ymd(addDays(t0, -6)), end: ymd(t0) } return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
case 'last30': case 'last30':