feat(management): 驾驶舱KPI环比(prev_kpis+delta)+异常卡带员工名跳详情+列表进详情带筛选

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-10 12:20:28 +08:00
parent be0d6f844e
commit 422a726ee8
7 changed files with 160 additions and 13 deletions
+29 -1
View File
@@ -184,6 +184,26 @@ async def management_dashboard(
"lifecycle": lifecycle, "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 的维度状态计数) ---- # ---- 维度分布(全员 score_employee 的维度状态计数) ----
dim_summary: dict[str, dict] = {} dim_summary: dict[str, dict] = {}
for a in assembled.values(): for a in assembled.values():
@@ -207,7 +227,13 @@ async def management_dashboard(
) )
# ---- 异常汇总:critical 先、warning 后,同 severity 按 affected desc ---- # ---- 异常汇总: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} sev_rank = {"critical": 0, "warning": 1}
anomalies.sort(key=lambda a: ( anomalies.sort(key=lambda a: (
sev_rank.get(a["severity"], 9), sev_rank.get(a["severity"], 9),
@@ -222,6 +248,8 @@ async def management_dashboard(
"metric_version": METRIC_VERSION, "metric_version": METRIC_VERSION,
"config_source": config_source, "config_source": config_source,
"kpis": kpis, "kpis": kpis,
"prev_kpis": prev_kpis,
"msg_delta": msg_delta,
"communication_trend": comm_trend, "communication_trend": comm_trend,
"service_trend": service_trend, "service_trend": service_trend,
"lifecycle": lifecycle, "lifecycle": lifecycle,
+37 -3
View File
@@ -5,7 +5,8 @@
page_size>100)、404(employees/{ghost}、drilldown 员工不存在); page_size>100)、404(employees/{ghost}、drilldown 员工不存在);
- dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/ - dashboard 各段齐全与关键数值(kpis/communication_trend/service_trend/
lifecycle/dimension_summary/anomalies/action_summary/data_as_of/ 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 时 - employees:默认分页 20/total/critical 优先默认排序/channel=wechat 时
whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample; whatsapp-only 员工消失/keyword/status/min_days 不足→insufficient_sample;
- detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline; - detail:账号清单+metrics 全字段+score 完整明细+anomalies+prev/baseline;
@@ -280,6 +281,15 @@ async def test_dashboard_structure_and_kpis(make_client):
"reactivated": 5} "reactivated": 5}
assert k["lifecycle"] == body["lifecycle"] 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 # 趋势逐日 5 点;0901 = u1(1)+u2(20)+u3(10) 消息,宽表 active 合计 8,通话 2
trend = body["communication_trend"] trend = body["communication_trend"]
assert [t["day"] for t in trend] == [20260901 + i for i in range(5)] 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 d["scored_count"] + d["insufficient_count"] + d["pending_count"] == 3
assert "avg_score" in d 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"] anoms = body["anomalies"]
assert anoms[0]["severity"] == "critical" assert anoms[0]["severity"] == "critical"
assert anoms[0]["employee_user_id"] == "u2" 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"] == "critical" for a in anoms[:1])
assert all(a["severity"] == "warning" 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, assert body["action_summary"] == {"unreplied_2h": 1, "churn_real_30d": 0,
"sensitive_pending": 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 # ---------------------------------------------------------------- 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"]["reply_2h_rate"]["value"] == 0.2
assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000 assert body["score"]["metrics_detail"]["fr_p90_ms"]["baseline"] == 60_000
# anomalies + prev/baseline 原始值(prev 窗 0827-0831 与基线窗重叠: # 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 len(body["anomalies"]) == 2
assert all(a["severity"] == "warning" for a in body["anomalies"]) 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["prev"]["msg_sent"] == 70
assert body["baseline"]["msg_sent"] == 70 assert body["baseline"]["msg_sent"] == 70
assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0) assert body["data_as_of"] == _ts(2026, 9, 3, 9, 0)
+5
View File
@@ -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 DimSummary { [dim: string]: { name: string; weight: number; scored_count: number; pending_count: number; insufficient_count: number; avg_score: number | null } }
export interface AnomalyCard { export interface AnomalyCard {
rule_code: string; severity: 'critical' | 'warning'; employee_user_id: string 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 title: string; current_value: number; baseline_value: number | null; unit: string
sample_size: number; affected_count: number; reason: string sample_size: number; affected_count: number; reason: string
drilldown: { type: string; query: { metric_code?: string; user_id?: string } } drilldown: { type: string; query: { metric_code?: string; user_id?: string } }
@@ -34,6 +36,9 @@ export interface AnomalyCard {
export interface DashboardResp { export interface DashboardResp {
scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default' scope: MgmtScope; data_as_of: number | null; metric_version: string; config_source: 'db' | 'default'
kpis: Kpis 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[] communication_trend: CommTrendPoint[]
service_trend: SvcTrendPoint[] service_trend: SvcTrendPoint[]
lifecycle: Kpis['lifecycle'] lifecycle: Kpis['lifecycle']
@@ -14,6 +14,13 @@
<el-table-column label="异常" min-width="240"> <el-table-column label="异常" min-width="240">
<template #default="{ row }"> <template #default="{ row }">
<div class="cell-title">{{ row.title }}</div> <div class="cell-title">{{ row.title }}</div>
<div v-if="showEmployee" class="cell-emp">
<el-link
type="primary"
:underline="false"
@click.stop="goEmployee(row)"
>{{ row.employee_name || row.employee_user_id.slice(0, 8) }}</el-link>
</div>
<div class="cell-sub">{{ row.reason }}</div> <div class="cell-sub">{{ row.reason }}</div>
</template> </template>
</el-table-column> </el-table-column>
@@ -43,16 +50,17 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+原因 // 异常榜(批次C 驾驶舱):后端 AnomalyCard[] 平铺表——级别 tag/标题+员工行
// 双行/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开 // (showEmployee,批次D:employee_name 链接→员工详情,.stop 防触发行下钻)
// +原因/单位感知当前值+基线/样本/影响(≥50 红显)/建议动作。行 click 打开
// 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。 // 下钻抽屉(emit drill,抽屉自治加载);critical 整行淡红底提示优先处置。
// 纯展示+事件,不取数。 // 纯展示+事件,不取数。
import { useRoute, useRouter } from 'vue-router'
import { formatMsDuration, formatRatio } from '@/utils/format' import { formatMsDuration, formatRatio } from '@/utils/format'
import type { AnomalyCard } from '@/api/management' import type { AnomalyCard } from '@/api/management'
// showEmployee(默认 true):D3 驾驶舱异常榜将在标题下加员工行(employee_name // showEmployee(默认 true):驾驶舱异常榜显示员工行;员工详情页复用本组件
// 链接→员工详情);员工详情页复用本组件时传 false 免自链接——prop 先行落位 // 传 false(自指链接无意义)
// (批次D2),员工行的渲染随 D3 接上。
withDefaults( withDefaults(
defineProps<{ defineProps<{
anomalies: AnomalyCard[] anomalies: AnomalyCard[]
@@ -66,6 +74,18 @@ const emit = defineEmits<{
(e: 'drill', card: AnomalyCard): void (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 对齐) // 单位感知格式化:ms→时长,0-1→比率,其余千分位(与指标口径字典 unit 对齐)
function formatValue(v: number, unit: string): string { function formatValue(v: number, unit: string): string {
if (unit === 'ms') return formatMsDuration(v) if (unit === 'ms') return formatMsDuration(v)
@@ -96,6 +116,14 @@ function onRowClick(row: AnomalyCard): void {
font-size: 13px; font-size: 13px;
color: var(--el-text-color-primary); 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 { .cell-sub {
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -4,14 +4,18 @@
<div class="kpi-value" :class="{ danger }"> <div class="kpi-value" :class="{ danger }">
{{ valueText }}<span v-if="unit && value !== null" class="kpi-unit">{{ unit }}</span> {{ valueText }}<span v-if="unit && value !== null" class="kpi-unit">{{ unit }}</span>
</div> </div>
<div v-if="deltaText" class="kpi-delta" :class="deltaClass">
环比{{ deltaText }}
</div>
<div v-if="hint" class="kpi-hint">{{ hint }}</div> <div v-if="hint" class="kpi-hint">{{ hint }}</div>
</el-card> </el-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
// 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红) // 经营管理 KPI 卡:label 上小灰字 + 主值大号字(千分位/unit 后缀/danger 红)
// + hint 下小灰字。to 存在时整卡可点(hover 手型)跳转——纯展示+导航, // + 环比行(批次D,delta prop)+ hint 下小灰字。to 存在时整卡可点
// 不取数。null 值语义(无样本)显 `--`,由父级决定传 null。 // (hover 手型)跳转——纯展示+导航,不取数。null 值语义(无样本)显 `--`,
// 由父级决定传 null。
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -22,12 +26,26 @@ const props = defineProps<{
hint?: string hint?: string
to?: string to?: string
danger?: boolean danger?: boolean
// 较前窗环比 (cur-prev)/prev:null/undefined/NaN(前窗缺数据)不显示。
// 只支持升好语义(升绿↓红)——本批接入三卡(活跃客户/活跃员工/消息量)
// 均为越高越好;将来若有降好指标(如首响时长)需先加方向语义位
delta?: number | null
}>() }>()
const router = useRouter() const router = useRouter()
const valueText = computed(() => (props.value === null ? '--' : props.value.toLocaleString())) 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 { function onClick(): void {
if (props.to) router.push(props.to) if (props.to) router.push(props.to)
} }
@@ -56,6 +74,19 @@ function onClick(): void {
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
margin-left: 2px; 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 { .kpi-hint {
font-size: 11px; font-size: 11px;
color: var(--el-text-color-secondary); color: var(--el-text-color-secondary);
@@ -83,7 +83,7 @@
<el-table-column type="selection" width="42" reserve-selection /> <el-table-column type="selection" width="42" reserve-selection />
<el-table-column label="姓名" min-width="150"> <el-table-column label="姓名" min-width="150">
<template #default="{ row }"> <template #default="{ row }">
<!-- 详情路由批次D后续任务上线;当前点击走通配回 /customers(记录在案) --> <!-- 带窗进详情:goDetail 随行 channel/start/end/compare(D3) -->
<el-link type="primary" underline="never" @click="goDetail(row)"> <el-link type="primary" underline="never" @click="goDetail(row)">
{{ row.name || row.user_id }} {{ row.name || row.user_id }}
</el-link> </el-link>
@@ -535,8 +535,16 @@ function gradeType(g: string | null): 'success' | 'primary' | 'warning' | 'dange
return (g && GRADE_TYPES[g]) || '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 { function goDetail(row: EmployeeRow): void {
router.push(`/management/employees/${row.user_id}`) 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 跨页/重查保留;行是勾选时快照) ── // ── 多选 → 对比抽屉(reserve-selection 跨页/重查保留;行是勾选时快照) ──
@@ -44,6 +44,7 @@
<ManagementKpiCard <ManagementKpiCard
label="活跃客户" label="活跃客户"
:value="kpis?.active_customers ?? null" :value="kpis?.active_customers ?? null"
:delta="deltaOf('active_customers')"
unit="人" unit="人"
:hint="threadsHint" :hint="threadsHint"
/> />
@@ -52,6 +53,7 @@
<ManagementKpiCard <ManagementKpiCard
label="活跃员工" label="活跃员工"
:value="kpis?.active_employees ?? null" :value="kpis?.active_employees ?? null"
:delta="deltaOf('active_employees')"
unit="人" unit="人"
hint="期内有数据天数>0 口径" hint="期内有数据天数>0 口径"
/> />
@@ -65,6 +67,7 @@
<ManagementKpiCard <ManagementKpiCard
label="消息量" label="消息量"
:value="kpis?.msg_total ?? null" :value="kpis?.msg_total ?? null"
:delta="deltaOf('msg_total')"
unit="条" unit="条"
hint="1:1口径·与数据看板口径不同" hint="1:1口径·与数据看板口径不同"
/> />
@@ -209,6 +212,16 @@ watch([channel, start, end, compare], load, { immediate: true })
const kpis = computed(() => resp.value?.kpis ?? null) const kpis = computed(() => resp.value?.kpis ?? null)
const actions = computed(() => resp.value?.action_summary ?? 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 同载荷,管理域跨日精确串) // 活跃客户辅助行:串/被回(口径与 KPI 同载荷,管理域跨日精确串)
const threadsHint = computed(() => { const threadsHint = computed(() => {
const k = kpis.value const k = kpis.value