feat(analytics): 管理层指标呈现——首响回复率上墙/客户资产总览/自定义日期/排行五新列

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 14:41:08 +08:00
parent feb3b98665
commit 085a9b4fd3
6 changed files with 606 additions and 43 deletions
+29 -4
View File
@@ -4,7 +4,8 @@ import { getJson, requestJson } from './client'
import type { Channel, Page } from './types'
export type { Channel }
export type Scope = 'today' | 'week' | 'month' | 'last30'
// 批2(2026-09-07):'custom'=自定义日期范围,须配合 start/end yyyymmdd
export type Scope = 'today' | 'week' | 'month' | 'last30' | 'custom'
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
export type DashboardChannel = 'all' | Channel
@@ -19,15 +20,35 @@ export interface OverviewCards {
prev_msg_total?: number
msg_total_delta?: number | null
}
// B1 客户资产总览:全库存量快照,不随 scope/channel
export interface CustomerAssets {
total: number
active30: number
silent30_90: number
sleeping90p: number
never: number
wechat: number
whatsapp: number
deleted: number
}
export interface TrendPoint { day: number; msg: number; active_customers: number; call_cnt: number }
export interface AnomalyStaff { user_id: string; yesterday: number; prev7_mean: number; new?: boolean }
export interface Anomaly { total_delta_pct: number; yesterday_total: number; prev7_mean: number; staff: AnomalyStaff[] }
// 员工活跃排行(2026-09-07 管理者反馈补强):active_*=窗内 distinct peer,
// msg_delta 仅周/月且当前/前窗均非 0 才有值
// msg_delta 仅周/月/custom 且当前/前窗均非 0 才有值。
// 批2 五新列:fr_avg_ms/reply_rate(=null 无样本/无串)、first_contact_cnt、
// call_seconds/answer_rate(=null 无通话);deleted/blocked_friends=全库存量
export interface StaffRankingRow {
user_id: string; name: string; unmapped: boolean
msg_total: number; active_friends: number; active_rooms: number
call_cnt: number; msg_delta: number | null
fr_avg_ms: number | null
reply_rate: number | null
first_contact_cnt: number
call_seconds: number
answer_rate: number | null
deleted_friends: number
blocked_friends: number
}
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
@@ -38,6 +59,7 @@ export interface OverviewResp {
channel: Channel | 'all'
days: number[]
cards: OverviewCards
customer_assets: CustomerAssets
trend: TrendPoint[]
anomaly: Anomaly | null
staff_ranking: StaffRankingRow[]
@@ -64,8 +86,11 @@ export interface SensitiveHitRow {
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
}
export const getOverview = (channel: DashboardChannel, scope: Scope) =>
getJson<OverviewResp>('/analytics/overview', { channel, scope })
// scope='custom' 时必须传 start/end(yyyymmdd,后端校验跨度≤366 天)
export const getOverview = (
channel: DashboardChannel, scope: Scope, start?: string, end?: string,
) =>
getJson<OverviewResp>('/analytics/overview', { channel, scope, start, end })
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
export const getChurn = (params: Record<string, unknown>) =>
getJson<Page<ChurnRow>>('/analytics/churn', params)
+131 -4
View File
@@ -11,7 +11,23 @@
<el-radio-button value="week">本周</el-radio-button>
<el-radio-button value="month">本月</el-radio-button>
<el-radio-button value="last30">近30天</el-radio-button>
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<!-- C1 自定义日期范围:daterange 自保证 start≤end,选完触发 load;
跨度>366 天后端 422 走页面 error alert -->
<el-date-picker
v-if="scope === 'custom'"
v-model="customRange"
type="daterange"
size="small"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYYMMDD"
:clearable="false"
style="width: 260px"
@change="load"
/>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
@@ -29,6 +45,21 @@
</el-card>
</div>
<!-- B1 客户资产总览:全库存量快照(不随筛选变)——先看客户盘子,再看
下方当期经营卡片;分层按最近沟通时间(从未激活=加过没聊过) -->
<el-card v-if="assets" shadow="never" class="assets-card">
<div class="assets-row">
<span class="assets-title">客户资产</span>
<span class="asset-item">总客户 <b>{{ fmt(assets.total) }}</b></span>
<span class="asset-item">活跃(30天) <b>{{ fmt(assets.active30) }}</b></span>
<span class="asset-item">沉默(30-90天) <b>{{ fmt(assets.silent30_90) }}</b></span>
<span class="asset-item">沉睡(90天+) <b>{{ fmt(assets.sleeping90p) }}</b></span>
<span class="asset-item">从未激活 <b>{{ fmt(assets.never) }}</b></span>
<span class="asset-item small">微信 {{ fmt(assets.wechat) }} · WA {{ fmt(assets.whatsapp) }}</span>
<span class="asset-item muted">已删好友 {{ fmt(assets.deleted) }}</span>
</div>
</el-card>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" />
<el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly">
@@ -105,6 +136,27 @@
<el-table-column label="通话量" align="right">
<template #default="{ row }">{{ fmt(row.call_cnt) }}</template>
</el-table-column>
<!-- 批2 五新列(2026-09-07 管理者需求):新增客户=当期首沟通;
首响/回复率=消息串口径(null=无样本/无串);
通话时长/接通率=落窗通话;删粉/拉黑=全库存量(「删/拉」) -->
<el-table-column label="新增客户" align="right">
<template #default="{ row }">{{ fmt(row.first_contact_cnt) }}</template>
</el-table-column>
<el-table-column label="首响" align="right">
<template #default="{ row }">{{ fmtFr(row.fr_avg_ms) }}</template>
</el-table-column>
<el-table-column label="回复率" align="right">
<template #default="{ row }">{{ fmtPct(row.reply_rate) }}</template>
</el-table-column>
<el-table-column label="通话时长" align="right">
<template #default="{ row }">{{ fmtDur(row.call_seconds) }}</template>
</el-table-column>
<el-table-column label="接通率" align="right">
<template #default="{ row }">{{ fmtPct(row.answer_rate) }}</template>
</el-table-column>
<el-table-column label="删粉/拉黑" align="right">
<template #default="{ row }">{{ fmtDelBlocked(row) }}</template>
</el-table-column>
<el-table-column label="环比" align="right" width="90">
<template #default="{ row }">
<span
@@ -121,8 +173,9 @@
</template>
<script setup lang="ts">
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片
// → 30 日趋势折线 → 员工活跃排行表。
// A1 数据看板:筛选栏(渠道×时间范围,自定义日期 C1) → 预警行动入口 →
// 客户资产总览(B1 全库快照) → 异常警示条 → 五卡片 → 30 日趋势折线 →
// 员工活跃排行表(批2 五新列:首响/回复率/新增客户/通话时长+接通率/删粉拉黑)。
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
@@ -130,7 +183,8 @@ import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
type DashboardChannel, type OverviewResp, type Scope,
type CustomerAssets, type DashboardChannel, type OverviewResp, type Scope,
type StaffRankingRow,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format'
@@ -163,6 +217,8 @@ interface AnomalyView {
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
const channel = ref<DashboardChannel>('all')
const scope = ref<Scope>('today')
// C1 自定义日期范围:[start, end] yyyymmdd 字符串(value-format)
const customRange = ref<[string, string] | null>(null)
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
const data = ref<OverviewResp | null>(null)
const asOf = ref<number | null>(null)
@@ -174,11 +230,19 @@ const { render } = useChart(chartEl)
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
async function load() {
// custom 未选完区间不请求——保留上一次数据等选择(daterange 保证 start≤end)
if (scope.value === 'custom' && (!customRange.value?.[0] || !customRange.value?.[1])) {
return
}
const my = ++seq
loading.value = true
error.value = ''
try {
const ov = await getOverview(channel.value, scope.value)
const ov = await getOverview(
channel.value, scope.value,
scope.value === 'custom' ? customRange.value?.[0] : undefined,
scope.value === 'custom' ? customRange.value?.[1] : undefined,
)
if (my !== seq) return
data.value = ov
} catch (e) {
@@ -227,6 +291,33 @@ const anomalyView = computed<AnomalyView | null>(() => {
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
// ── B1 客户资产总览(全库存量,随 overview 载荷回来) ──
const assets = computed<CustomerAssets | null>(() => data.value?.customer_assets ?? null)
// ── 排行新列格式化 ──
// 首响:<1h 显「X分Y秒」,≥1h 显「X小时Y分」;null(无样本)显 '-'
const fmtFr = (ms: number | null) => {
if (ms == null) return '-'
const totalSec = Math.round(ms / 1000)
const mins = Math.floor(totalSec / 60)
if (mins < 60) return `${mins}分${totalSec % 60}秒`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 百分比 1 位小数(回复率/接通率);null 显 '-'
const fmtPct = (v: number | null) => (v == null ? '-' : `${(v * 100).toFixed(1)}%`)
// 通话时长(秒):<1h 显「X分」,否则「X小时Y分」;0/null 显 '-'
const fmtDur = (s: number | null) => {
if (!s) return '-'
const mins = Math.round(s / 60)
if (mins < 60) return `${mins}分`
return `${Math.floor(mins / 60)}小时${mins % 60}分`
}
// 删粉/拉黑合并一列「删/拉」;都 0 显 '-'
const fmtDelBlocked = (r: StaffRankingRow) =>
r.deleted_friends || r.blocked_friends
? `${r.deleted_friends}/${r.blocked_friends}`
: '-'
// 趋势摘要:先给数字再看图(合计/日均/峰值及日期)
const trendVals = (): number[] =>
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
@@ -583,6 +674,42 @@ onMounted(() => {
font-size: 22px;
font-weight: 600;
}
/* B1 客户资产总览:轻量一行条(参照 action-card 的密度) */
.assets-card {
margin-bottom: 16px;
}
.assets-card :deep(.el-card__body) {
padding: 10px 14px;
}
.assets-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 20px;
}
.assets-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-right: 4px;
}
.asset-item {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.asset-item b {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
margin-left: 2px;
}
.asset-item.small {
font-size: 12px;
}
.asset-item.muted {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.rank-bar {
height: 480px;
margin-bottom: 8px;