Files
yunke/docs/superpowers/plans/2026-09-09-management-console-batch-c-frontend-plan.md
T
浅念 2b10c07fb7 docs(management): 批次C前端计划——驾驶舱+导航(Task C0-C6)
评审4真问题全修:drilldown行补channel字段(C0后端契约补全)/DimSummary
补name+weight/端口3000→8000;advisory全采:服务图双轴/共享格式化提升
format.ts/去硬编码41/client契约字段补全
2026-09-10 09:44:39 +08:00

306 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 云客经营管理后台·批次C(前端导航+管理驾驶舱)实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 交付经营管理后台的第一个可视化页面——管理驾驶舱(`/management/dashboard`)+「经营管理」导航分组;管理者 5 分钟看整体、异常两次点击进事实。
**Architecture:** 前端纯增量:`api/management.ts`(六端点 typed client,契约=批次B终评冻结面)+ `composables/useManagementFilters.ts`(URL query 为真源)+ `components/management/` 六个组件 + `ManagementDashboardView.vue`。复用 useChart/api client/format utils/seq 守卫惯例;不引新 UI 框架、不引雷达图(维度分布用条形图)。依据 [v2 计划](./2026-09-08-yunke-management-console-implementation-v2.md) §4/§5/§7/§13 与批次B契约清单;员工总览/详情页是批次D。
**Tech Stack:** Vue3 + TS + Element Plus + ECharts(useChart 按需已有 Line/Bar)+ vitest(若仓库已配)。
---
## 执行者必读
1. **后端就绪**(本地 8000 已跑或按 DEPLOY 起):六端点见 `/tmp` 之外的真实路径 `/api/management/*`;**契约以批次B终评清单为准**(None≠0;`scope.channel` 回显原值;drilldown 白名单六值;异常卡片 12 字段;`config_source=db`)。
2. **前端惯例**:路由扁平 + `meta.title` + 登录守卫(public 才放行);API 走 `getJson/requestJson`(client.ts 已带 Bearer+baseURL=/api);图表用 `useChart`(容器稳定,不 v-if);请求序号 seq 守卫防旧响应覆盖(DashboardView 现有模式照抄);格式化放 `utils/format.ts` 不重复实现;el-select 空串 value 显示英文占位用 `'all'` 哨兵。
3. **不做**:员工总览/详情页、辅导、快照(D/E 批次);`/analytics/dashboard` 不重定向(并存过渡);二期菜单不渲染不放空页。
4. **验收基准**:v2 §5——5 分钟判总体;异常两次点击到事实;全部卡片显示数据截至与对比窗口;`npm run build` 过。
5. 仓库有并行会话活动——`git status` 后只 add 自己文件;提交中文 conventional;master 直提。
## 文件地图
| 文件 | 职责 | 动作 |
|---|---|---|
| `yunkes/src/api/management.ts` | 六端点 typed client | 新建 |
| `yunkes/src/composables/useManagementFilters.ts` | URL query ↔ 筛选(channel/start/end/compare + 预设窗) | 新建 |
| `yunkes/src/components/management/DataAsOfBadge.vue` | 数据截至/版本/试算角标 | 新建 |
| `yunkes/src/components/management/ManagementKpiCard.vue` | KPI 卡(主值+辅助+点击跳转) | 新建 |
| `yunkes/src/components/management/ManagementFilterBar.vue` | 筛选栏(渠道/时间预设/自定义/口径入口按钮) | 新建 |
| `yunkes/src/components/management/AnomalyList.vue` | 异常榜(severity 标签+数字+下钻事件) | 新建 |
| `yunkes/src/components/management/MetricDrilldownDrawer.vue` | 下钻抽屉(drilldown 端点行清单+聊天跳转) | 新建 |
| `yunkes/src/components/management/MetricDefsDrawer.vue` | 指标口径抽屉(metric-definitions 渲染) | 新建 |
| `yunkes/src/views/management/ManagementDashboardView.vue` | 驾驶舱页 | 新建 |
| `yunkes/src/router/index.ts` | /management/dashboard 路由 | 修改 |
| `yunkes/src/layouts/MainLayout.vue` | 「经营管理」分组 | 修改 |
---
### Task C0: 后端契约补全——drilldown 行补 channel 字段
**Files:** Modify `yunke-server/app/management/api_assembly.py` + `yunke-server/tests/test_management_api.py`
批次B已提交但未部署,前端冻结前补齐(评审发现:fr_fact_rows/lifecycle_customers 的行无 channel 键,前端 channel=all 时 WA 行会跳错聊天路由):
1. `fr_fact_rows` 每行加 `channel`(SELECT 已有该列,透传即可);
2. `lifecycle_customers` 每行加 `channel`(c.channel 透传);
3. 测试断言补两行(drilldown p90 与 cooling/dormant 的响应行含 channel)。
- [ ] `cd yunke-server && .venv/Scripts/python.exe -m pytest tests/test_management_api.py -q`(23 passed)+ 全量 -q
- [ ] commit `fix(management): drilldown行补channel字段——前端聊天路由判渠道用(契约冻结前补齐)`
### Task C1: api/management.ts(typed client)
**Files:** Create `yunkes/src/api/management.ts`
完整代码(契约=批次B as-built;先对照后端 router 响应键核对再落笔):
```typescript
// 经营管理板块 API(批次C)。契约以批次B终评冻结面为准:
// None≠0(无样本分位/率=null);scope.channel 回显原值;drilldown 白名单六值。
import { getJson } from './client'
import type { Channel } from './types'
export type MgmtChannel = 'all' | Channel
export type CompareMode = 'previous' | 'personal_baseline' | 'team_median'
export type DrilldownCode =
| 'first_reply_p90' | 'first_reply_p50' | 'unreplied'
| 'cooling_customers' | 'dormant_customers' | 'call_followup'
export interface MgmtScope { channel: MgmtChannel; start: number; end: number; compare?: CompareMode; view?: string; user_id?: string; metric_code?: string }
export interface Kpis {
active_customers: number; active_employees: number
msg_total: number; cust_threads: number; threads_answered: number
call_count: number; call_seconds: number; answered_call: number
lifecycle: { new: number; cooling: number; dormant: number; reactivated: number }
}
export interface CommTrendPoint { day: number; msg_total: number; active_customers: number; call_cnt: number }
export interface SvcTrendPoint { day: number; fr_p50_ms: number | null; fr_p90_ms: number | null; reply_2h_rate: number | null }
export interface DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
export interface AnomalyCard {
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string
title: string; current_value: number; baseline_value: number | null; unit: string
sample_size: number; affected_count: number; reason: string
drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
suggested_action: string
}
export interface DashboardResp {
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
kpis: Kpis
communication_trend: CommTrendPoint[]
service_trend: SvcTrendPoint[]
lifecycle: Kpis['lifecycle']
dimension_summary: DimSummary
anomalies: AnomalyCard[]
action_summary: { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
}
export interface EmployeeRow {
user_id: string; name: string | null; is_on_job: boolean | null
metrics: Record<string, number | null>
comparisons: Record<string, { current: number | null; prev: number | null; baseline: number | null; median: number | null }>
score: { total_score: number | null; grade: string | null; score_status: string; trial: boolean; dimensions: Record<string, { score: number | null; status: string }> }
anomaly_count: number; anomaly_weight: number
}
export interface EmployeesResp { total: number; page: number; page_size: number; rows: EmployeeRow[]; scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' }
export interface FactDrillRow { day: number; channel: string; owner_account_id: string; peer_id: string; thread_end_ts: number; reply_ts: number; fr_ms: number }
export interface LifecycleDrillRow { friend_id: string; channel: string; nickname: string | null; last_day: number; state: string }
export interface CallDrillRow { call_id: string; channel: string; call_number: string | null; start_call_time: number; call_duration: number }
export interface DrilldownResp { total: number; items: Record<string, unknown>[]; note?: string; truncated?: boolean; summary?: Record<string, number> }
export interface MetricDefsResp {
definitions: Record<string, { name: string; unit: string; direction: number; caliber?: string; formula?: string }>
score_config: { version: string; name: string; trial: boolean; normalization: string; sample_guards: Record<string, number>; grade_bounds: Record<string, number>; dimensions: Record<string, { name: string; weight: number; status?: string }> }
caliber_notes: string
config_source: 'db' | 'default'
}
export const getDashboard = (channel: MgmtChannel, start: number, end: number, compare: CompareMode = 'previous') =>
getJson<DashboardResp>('/management/dashboard', { channel, start, end, compare })
export const getEmployees = (params: Record<string, unknown>) =>
getJson<EmployeesResp>('/management/employees', params)
export const getEmployeeDrilldown = (userId: string, metricCode: DrilldownCode, start: number, end: number, channel: MgmtChannel, page = 1, pageSize = 20) =>
getJson<DrilldownResp>(`/management/employees/${userId}/drilldown`, { metric_code: metricCode, start, end, channel, page, page_size: pageSize })
export const getMetricDefs = () => getJson<MetricDefsResp>('/management/metric-definitions')
```
(员工详情/sections 两个端点的 client 函数批次D 再加——本期页面不消费,YAGNI;本期 client 为四函数。)
- [ ] 步骤:写文件 → `cd yunkes && npx vue-tsc --noEmit`(或 build)类型过 → 后端(8000,dev 5173 的 /api 代理已指向它)冒烟一次 getDashboard 类型与真实响应吻合(console 对比关键字段)→ commit `feat(management): 前端API client——管理端点typed契约(批次B冻结面)`
### Task C2: useManagementFilters composable
**Files:** Create `yunkes/src/composables/useManagementFilters.ts`(+ vitest 若仓库已配测试脚本,先 `grep '"test"' yunkes/package.json` 确认)
完整代码:
```typescript
// 管理页统一筛选(URL query 为真源——刷新/分享保持)。批次C:驾驶舱用;
// 批次D 员工页复用(view 等额外参数由页面自行挂 query)。
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
export type Preset = 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'custom'
export const CHANNELS = [
{ value: 'all', label: '全部渠道' },
{ value: 'wechat', label: '微信' },
{ value: 'whatsapp', label: 'WhatsApp' },
] as const
function ymd(d: Date): number { return d.getFullYear() * 10000 + (d.getMonth() + 1) * 100 + d.getDate() }
function addDays(d: Date, n: number): Date { const x = new Date(d); x.setDate(x.getDate() + n); return x }
/** 预设 → [start,end] yyyymmdd(上海自然日由本地时区近似——与后端一致的公历日)。 */
export function presetRange(p: Preset, today = new Date()): { start: number; end: number } {
const t0 = new Date(today.getFullYear(), today.getMonth(), today.getDate())
switch (p) {
case 'last7': return { start: ymd(addDays(t0, -6)), end: ymd(t0) }
case 'last30': return { start: ymd(addDays(t0, -29)), end: ymd(t0) }
case 'thisMonth': return { start: ymd(new Date(t0.getFullYear(), t0.getMonth(), 1)), end: ymd(t0) }
case 'lastMonth': {
const first = new Date(t0.getFullYear(), t0.getMonth() - 1, 1)
const last = new Date(t0.getFullYear(), t0.getMonth(), 0)
return { start: ymd(first), end: ymd(last) }
}
}
// custom 由调用方直传
return { start: ymd(t0), end: ymd(t0) }
}
export function useManagementFilters() {
const route = useRoute()
const router = useRouter()
const channel = computed<'all' | 'wechat' | 'whatsapp'>(() => {
const v = route.query.channel
return v === 'wechat' || v === 'whatsapp' ? v : 'all'
})
const start = computed(() => {
const v = Number(route.query.start)
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').start
})
const end = computed(() => {
const v = Number(route.query.end)
return Number.isFinite(v) && v >= 20000101 ? v : presetRange('last30').end
})
const compare = computed(() => {
const v = route.query.compare
return v === 'personal_baseline' || v === 'team_median' ? v : 'previous'
})
function apply(patch: Record<string, string | number | undefined>) {
const q: Record<string, string> = {}
for (const [k, v] of Object.entries({ ...route.query, ...patch })) {
if (v !== undefined && v !== null && v !== '') q[k] = String(v)
}
router.replace({ query: q })
}
// 预设切换:清掉 start/end 让默认值生效,custom 则带具体日期
function applyPreset(p: Preset, custom?: { start: number; end: number }) {
if (p === 'custom' && custom) apply({ start: custom.start, end: custom.end })
else apply({ start: undefined, end: undefined })
}
return { channel, start, end, compare, apply, applyPreset, presetRange }
}
```
- [ ] vitest(若已配):presetRange 五预设的日期算术(含月末/跨月)+ query 缺省回退 last30 + apply 写回 query。未配则手测并在报告注明。
- [ ] commit `feat(management): useManagementFilters——URL query 真源/预设窗/渠道哨兵`
### Task C3: 六个基础组件
**Files:** Create `components/management/` 下六件。规格(完整 SFC 代码进实施,要点钉死):
1. **DataAsOfBadge**:props `{ dataAsOf: number | null; metricVersion?: string; trial?: boolean; configSource?: string }`;渲染 el-tag 组:数据截至(`data_as_of` ms→`YYYY-MM-DD HH:mm`,null 显示「无数据」灰 tag)+ 指标版本 + 试算(warning 色,trial=true 才显示)。格式化用 utils/format.ts 现有函数,无则组件内小 helper。
2. **ManagementKpiCard**:props `{ label: string; value: number | null; unit?: string; hint?: string; to?: string }`;主值大字(null 显示 `--`),hint 小字,有 to 则整卡 clickable `router.push`。不做趋势箭头(对比值批次D 表格里呈现)。
3. **ManagementFilterBar**:props modelValue 透传 filters composable 的值 + `@apply`;内容:el-radio-group 渠道(CHANNELS)+ el-select 预设(四预设+自定义)+ 自定义时 el-date-picker type=daterange(value-format YYYYMMDD 字符串转 number)+「指标口径」文字按钮(emit open-defs)+ 插槽放 DataAsOfBadge。改即 apply(防抖 300ms 不需要——change 事件天然低频)。
4. **AnomalyList**:props `{ anomalies: AnomalyCard[] }`;el-table 或列表卡片:severity(critical=红 danger tag「严重」/warning=橙 warning tag「关注」)、title、current vs baseline(unit 感知格式化:ms→时长/0-1→%/其他千分位)、sample_size、affected_count、suggested_action(灰小字);行 click → emit `drill(card)`。空态「暂无异常」。
5. **MetricDrilldownDrawer**:props `{ userId: string; card: AnomalyCard | null; start: number; end: number; channel: MgmtChannel }`(v-model:visible);打开时按 card.drilldown.query.metric_code 调 getEmployeeDrilldown:
- `first_reply_p90/p50` → 表格:客户(peer_id)/串末条时间/回复时间/首响时长(fr_ms 降序已由后端);行点击跳聊天:`/staff-chat/{owner_account_id}/single/{peer_id}`(channel=whatsapp 时 `/chat/whatsapp/{owner}/{peer}`)。
- `unreplied` → 表格 peer/等待时长/预览;note/truncated 提示。
- `cooling/dormant_customers` → 表格 客户/最后互动日/状态;行点击跳该客户聊天(同上映射)。
- `call_followup` → 表格 通话(时间/号码/时长)+ summary 卡。
分页 el-pagination(后端分页);员工姓名/标题显示 card.employee_user_id 对应名字由父传(dashboard anomalies 无 name——显示 user_id 短版即可,批次D 带名字)。
6. **MetricDefsDrawer**:v-model:visible;打开调 getMetricDefs;el-collapse 按 维度/单指标 两组渲染:每指标 name+unit+direction(越大越好/越小越好)+formula+caliber;顶部 score_config 摘要(版本/试算标记/样本门槛/各维度权重与 pending 状态)+ caliber_notes 段落。
- [ ] 步骤:六组件逐个写 + vue-tsc 过 → 冒烟渲染(C5 页面里真用)→ commit `feat(management): 驾驶舱六组件——KPI卡/筛选栏/异常榜/下钻抽屉/口径抽屉/数据截至角标`
### Task C4: 路由 + 导航
- router/index.ts 在 analytics 四页之后加:
```typescript
{
// 经营管理板块(批次C):驾驶舱;员工分析/考核快照批次D/E再加
path: '/management/dashboard',
name: 'management-dashboard',
component: () => import('@/views/management/ManagementDashboardView.vue'),
meta: { title: '管理驾驶舱' },
},
```
- MainLayout「数据分析」el-sub-menu 之前加「经营管理」分组(单项:管理驾驶舱 → goPlain('/management/dashboard'))——分组在最上(管理优先级高于查看)。
- 验证:登录态点菜单进页面、刷新保持、activeMenu 高亮正确。
- [ ] commit `feat(management): 经营管理导航分组+驾驶舱路由`
### Task C5: ManagementDashboardView
**Files:** Create `yunkes/src/views/management/ManagementDashboardView.vue`
结构(完整 SFC 进实施,要点钉死):
```text
<ManagementFilterBar>(含 DataAsOfBadge + 口径按钮)
六个 KPI 卡(el-row/gutter,1366px 两行三卡):
活跃客户(kpis.active_customers,hint=串/被回)→无跳转(批次D员工页)
活跃员工(kpis.active_employees,hint=期内有数据天数>0 口径)
消息量(msg_total,hint=1:1口径·与数据看板口径不同)
待回复(action_summary.unreplied_2h,红点>0)→/analytics/unreplied
即将流失(action_summary.churn_real_30d)→/analytics/churn
风险待处理(action_summary.sensitive_pending)→/analytics/sensitive
双图表(el-row 两列,useChart×2):
客户沟通趋势:Line msg_total + Bar call_cnt 双 y 轴(x=day)
服务质量趋势:双 y 轴——左轴 ms(Line fr_p50_ms/fr_p90_ms,tooltip 时长格式化)、
右轴 0-1(Line reply_2h_rate,%)——不注明双轴则率线贴底
——图内空数据/None 点处理:reply_2h_rate null 断点(connectNulls=false)
双条形图(el-row 两列):
生命周期分布:Bar new/cooling/dormant/reactivated 四类
员工维度分布:Bar dimension_summary 三维 avg_score(0-100)+pending 数 tooltip
异常榜(AnomalyList @drill → MetricDrilldownDrawer)
seq 守卫:filters 变化 → seq++ → 请求 → 回来校验 seq 才渲染(照 DashboardView 模式)
loading:el-skeleton 首屏;错误:el-alert + 重试按钮(保留上次数据)
```
要点:
- `watch([channel, start, end, compare], load)`;`onMounted(load)`;
- day 轴 `20260810`→`08-10` 短格式;
- **共享格式化**:把 DashboardView 里的 `fmtFr`(ms→时长)/`fmtPct`(0-1→%) 提升为 `utils/format.ts` 的 `formatMsDuration`/`formatRatio`(AnomalyList/抽屉/图表 tooltip 三处消费,不复制第三份);
- 异常榜点卡片 → drawer(metric_code 白名单外 type=employee 的卡片——activity_drop/overload 无 drilldown 端点,drawer 显示「员工分析批次D上线」占位说明——**两类 drilldown.type=employee 的卡片点击行为:显示 el-empty 占位**);
- 试算角标常显(config_source 透传)。
- [ ] 步骤:写页面 → `npm run build` 过 → 本地前后端真机走查(起 8000 后端 + `npm run dev` 5173,Playwright 或手动):六卡有数、双图渲染、异常卡点开抽屉、抽屉行跳聊天、口径抽屉渲染、刷新 URL 保持、1366px 布局不塌 → commit `feat(management): 管理驾驶舱页——KPI/双趋势/生命周期/维度分布/异常两次点击到事实`
### Task C6: 收尾验收
- [ ] `cd yunkes && npm run build`(0 error);
- [ ] `npm run test --silent`(若配了 vitest)或记录未配;
- [ ] 验收清单核对(v2 §5.3/§19.1 驾驶舱子集):5 分钟判总体 ✓/异常两次点击进事实 ✓/口径入口 ✓/数据截至+对比窗口显示 ✓/渠道筛选 ✓/URL 保持 ✓/试算标记 ✓/空态与错误态 ✓;
- [ ] 全量后端测试仍绿(前端不动后端,确认无意外);
- [ ] commit(若剩零散修正)。
---
## 批次C验收清单
- [ ] 「经营管理」分组出现且仅含管理驾驶舱;
- [ ] 六 KPI 卡数字与后端响应一致(抽查待回复/风险与 analytics 页对得上);
- [ ] 双趋势图渲染 30 点、None 断点正确;
- [ ] 异常卡 critical 红标在前;点击 metric 类卡片→抽屉→行→聊天定位(两次点击到事实);
- [ ] employee 类异常卡占位不报错;
- [ ] 口径抽屉含试算标记与三方口径差异说明;
- [ ] URL query 刷新/分享保持筛选;
- [ ] `npm run build` 过;后端 722 测试不受影响。
## 批次D-F
D:员工总览+详情页(消费 employees/detail/sections 端点+指标解释抽屉);E:辅导闭环+考核快照(写端点);F:调度接入+dashboard 性能修复(unreplied 索引)+部署上线。