Compare commits
3 Commits
10b7e5e534
...
a9197f696a
| Author | SHA1 | Date | |
|---|---|---|---|
| a9197f696a | |||
| ddeda3fa22 | |||
| 2587e4dd08 |
@@ -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 索引三选一)+ 服务器部署+回测试运行。
|
||||
@@ -602,7 +602,7 @@ class SyncScheduler:
|
||||
if not bash:
|
||||
logger.error("page sync 'full': bash not found on PATH")
|
||||
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":
|
||||
# 2026-09-10 调度根治(用户报「员工列表时间差 2 天」):
|
||||
# ① 序列头部刷员工账号表(accounts ~1min)——员工列表「数据
|
||||
@@ -621,6 +621,10 @@ class SyncScheduler:
|
||||
("wa-friends", None, 2400),
|
||||
("messages", 24, 4800),
|
||||
("wa-messages", 24, 4800),
|
||||
# chatroom-stats 全窗(~1min):刷 chat_group.last_chat_time——
|
||||
# 群消息活跃窗的判定源。不加则窗口用的是昨晚 full 的快照,
|
||||
# 「昨天安静、今天突然活跃」的群会被窗口漏到明晚(2026-09-10)
|
||||
("chatroom-stats", None, 1800),
|
||||
("group-messages", 24, 4800),
|
||||
("wa-group-messages", 24, 2400),
|
||||
("moments", None, 2400),
|
||||
@@ -666,7 +670,8 @@ class SyncScheduler:
|
||||
"99999",
|
||||
"--full-history",
|
||||
],
|
||||
None, # 每周对齐不设超时
|
||||
6 * 3600, # 单实体 6h 上限(2026-09-10:防周日的全历史
|
||||
# 对齐拖进周一,把 02:00 full 又挤掉——同类事故根治)
|
||||
)
|
||||
for e in entities
|
||||
]
|
||||
|
||||
@@ -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
|
||||
except (ValueError, TypeError):
|
||||
ts = None
|
||||
# 增量模式过滤边界旧消息(云客 start inclusive,2026-09-10)
|
||||
if watermark and ts is not None and ts <= watermark:
|
||||
continue
|
||||
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)}"),
|
||||
"channel": "wechat",
|
||||
@@ -965,6 +968,9 @@ async def scrape_group_messages(cookie_str: str, max_groups: int = 500, full_his
|
||||
continue
|
||||
rec = _normalize_group_message(row, owner_id, room_id)
|
||||
if rec is not None:
|
||||
# 增量模式过滤边界旧消息(同 1:1,2026-09-10)
|
||||
if watermark and (rec.get("timestamp") or 0) <= watermark:
|
||||
continue
|
||||
records.append(rec)
|
||||
|
||||
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 []
|
||||
for row in msgs:
|
||||
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"):
|
||||
break
|
||||
# 增量触底停:翻到已同步区
|
||||
@@ -2385,7 +2398,11 @@ async def scrape_wa_group_messages(cookie_str: str, max_groups: int = 500, full_
|
||||
for row in rows:
|
||||
if not isinstance(row, dict) or _is_wa_divider(row):
|
||||
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:
|
||||
logger.warning(f" [{i+1}] {str(owner_id)[:18]}:{str(room_id)[:18]} failed: {e}")
|
||||
|
||||
@@ -589,7 +589,7 @@ async def test_safe_page_run_messages_builds_scrape_command(engine, monkeypatch)
|
||||
|
||||
await sched._safe_page_run("messages")
|
||||
|
||||
assert len(commands) == 9
|
||||
assert len(commands) == 10
|
||||
for args in commands:
|
||||
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[2]) == "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 根治调度堵塞)
|
||||
for args in commands[4:8]:
|
||||
assert entity_of(args) in (
|
||||
"messages", "wa-messages", "group-messages", "wa-group-messages",
|
||||
)
|
||||
assert args[args.index("--max-conversations") + 1] == "99999"
|
||||
assert args[args.index("--active-window-hours") + 1] == "24"
|
||||
for args in commands:
|
||||
if entity_of(args) in ("messages", "wa-messages", "group-messages", "wa-group-messages"):
|
||||
assert args[args.index("--max-conversations") + 1] == "99999"
|
||||
assert args[args.index("--active-window-hours") + 1] == "24"
|
||||
# moments 浅扫在序列末尾:每员工最多 3 页
|
||||
moments_cmd = commands[-1]
|
||||
assert entity_of(moments_cmd) == "moments"
|
||||
|
||||
@@ -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) =>
|
||||
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
|
||||
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
|
||||
|
||||
// ---- 批次D:员工详情 + 分段指标(契约已真机 8000 抽查核对,见上) ----
|
||||
export interface EmployeeAccount { channel: string; account_id: string; nick_name: string | null; user_name: string | null }
|
||||
export interface ScoreMetricDetail { value: number | null; prev: number | null; baseline: number | null; median: number | null; sample_size: number; sample_status: string; score: number | null; unit: string; name: string }
|
||||
// formula_note 在 dimensions[dim].metrics[code] 上(scoring 维度级明细),不在 metrics_detail
|
||||
export interface EmployeeDetailResp {
|
||||
user_id: string; name: string | null; is_on_job: boolean | null
|
||||
accounts: EmployeeAccount[]
|
||||
metrics: Record<string, number | string | null>
|
||||
score: {
|
||||
total_score: number | null; grade: string | null; score_status: string; trial: boolean
|
||||
dimensions: Record<string, { name: string; weight: number; score: number | null; status: string; metrics?: Record<string, { value: number | null; score: number | null; sample_size: number; sample_status: string; formula_note?: string }> }>
|
||||
metrics_detail: Record<string, ScoreMetricDetail>
|
||||
}
|
||||
anomalies: AnomalyCard[]
|
||||
prev: Record<string, number | string | null> | null
|
||||
baseline: Record<string, number | string | null> | null
|
||||
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
|
||||
}
|
||||
export interface SectionResp {
|
||||
section: string; metrics: Record<string, number | null>
|
||||
trend: Record<string, number | null>[]
|
||||
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
|
||||
scope: MgmtScope; data_as_of: number | null; metric_version: string
|
||||
}
|
||||
export type MetricsSection = 'overview' | 'service' | 'customer' | 'workload' | 'calls'
|
||||
export const getEmployeeDetail = (userId: string, channel: MgmtChannel, start: number, end: number) =>
|
||||
getJson<EmployeeDetailResp>(`/management/employees/${userId}`, { channel, start, end })
|
||||
export const getEmployeeMetrics = (userId: string, section: MetricsSection, channel: MgmtChannel, start: number, end: number) =>
|
||||
getJson<SectionResp>(`/management/employees/${userId}/metrics`, { section, channel, start, end })
|
||||
|
||||
@@ -10,10 +10,18 @@
|
||||
placeholder="近30天"
|
||||
@change="onPresetChange"
|
||||
>
|
||||
<el-option label="近7天" value="last7" />
|
||||
<el-option label="近30天" value="last30" />
|
||||
<el-option label="本月" value="thisMonth" />
|
||||
<el-option label="上月" value="lastMonth" />
|
||||
<el-option-group label="常用">
|
||||
<el-option label="今日" value="today" />
|
||||
<el-option label="昨日" value="yesterday" />
|
||||
<el-option label="近7天" value="last7" />
|
||||
<el-option label="近30天" value="last30" />
|
||||
</el-option-group>
|
||||
<el-option-group label="日历">
|
||||
<el-option label="本周" value="thisWeek" />
|
||||
<el-option label="上周" value="lastWeek" />
|
||||
<el-option label="本月" value="thisMonth" />
|
||||
<el-option label="上月" value="lastMonth" />
|
||||
</el-option-group>
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
|
||||
|
||||
@@ -50,6 +50,26 @@ describe('presetRange(注入 today,确定性日期)', () => {
|
||||
expect(presetRange('lastMonth', D(20260115))).toEqual({ start: 20251201, end: 20251231 })
|
||||
})
|
||||
|
||||
it('today → 今天单日(2026-09-10 → 20260910..20260910)', () => {
|
||||
expect(presetRange('today', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
|
||||
})
|
||||
|
||||
it('yesterday → 昨日单日(2026-09-10 → 20260909..20260909)', () => {
|
||||
expect(presetRange('yesterday', D(20260910))).toEqual({ start: 20260909, end: 20260909 })
|
||||
})
|
||||
|
||||
it('thisWeek 周中(2026-07-02 周四 → 周一到今天 20260629..20260702)', () => {
|
||||
expect(presetRange('thisWeek', D(20260702))).toEqual({ start: 20260629, end: 20260702 })
|
||||
})
|
||||
|
||||
it('thisWeek 周一当天 → 单日(2026-06-29 周一 → 20260629..20260629)', () => {
|
||||
expect(presetRange('thisWeek', D(20260629))).toEqual({ start: 20260629, end: 20260629 })
|
||||
})
|
||||
|
||||
it('lastWeek 跨月边界(2026-07-02 周四 → 上周一..上周日 20260622..20260628)', () => {
|
||||
expect(presetRange('lastWeek', D(20260702))).toEqual({ start: 20260622, end: 20260628 })
|
||||
})
|
||||
|
||||
it('custom → 今天单日兜底(2026-09-10 → 20260910..20260910)', () => {
|
||||
expect(presetRange('custom', D(20260910))).toEqual({ start: 20260910, end: 20260910 })
|
||||
})
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { computed } from 'vue'
|
||||
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 = [
|
||||
{ value: 'all', label: '全部渠道' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
@@ -22,7 +22,20 @@ function addDays(d: Date, n: number): Date {
|
||||
/** 预设 → [start,end] yyyymmdd。 */
|
||||
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
|
||||
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
// 周一为一周起点(getDay 周日=0 → 偏移成周一=0..周日=6)
|
||||
const dow = (t0.getDay() + 6) % 7
|
||||
switch (p) {
|
||||
case 'today':
|
||||
return { start: ymd(t0), end: ymd(t0) }
|
||||
case 'yesterday':
|
||||
return { start: ymd(addDays(t0, -1)), end: ymd(addDays(t0, -1)) }
|
||||
case 'thisWeek':
|
||||
// 周一..今天;周一当天即单日
|
||||
return { start: ymd(addDays(t0, -dow)), end: ymd(t0) }
|
||||
case 'lastWeek': {
|
||||
const monday = addDays(t0, -dow - 7)
|
||||
return { start: ymd(monday), end: ymd(addDays(monday, 6)) }
|
||||
}
|
||||
case 'last7':
|
||||
return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
|
||||
case 'last30':
|
||||
|
||||
Reference in New Issue
Block a user