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
+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;