feat(analytics): 管理层指标呈现——首响回复率上墙/客户资产总览/自定义日期/排行五新列
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user