422a726ee8
Co-Authored-By: Claude Code <noreply@anthropic.com>
538 lines
19 KiB
Vue
538 lines
19 KiB
Vue
<template>
|
|
<div class="mgmt-dash">
|
|
<!-- 筛选栏:渠道/时间(URL query 真源)+口径入口;aside=数据截至角标 -->
|
|
<ManagementFilterBar
|
|
:channel="channel"
|
|
:start="start"
|
|
:end="end"
|
|
:compare="compare"
|
|
@apply="apply"
|
|
@open-defs="defsVisible = true"
|
|
>
|
|
<template #aside>
|
|
<!-- trial 常显:dashboard 载荷无 trial 键(评分配置层字段),批次C/D
|
|
评分处于试算期(v2 §0 全默认+试算),批次E 转正时随配置联动 -->
|
|
<DataAsOfBadge
|
|
:data-as-of="resp?.data_as_of ?? null"
|
|
:metric-version="resp?.metric_version"
|
|
:trial="true"
|
|
:config-source="resp?.config_source"
|
|
/>
|
|
</template>
|
|
</ManagementFilterBar>
|
|
|
|
<!-- 错误态:alert+重试;resp 不清空,保留上次成功数据 -->
|
|
<el-alert v-if="loadError" type="error" :closable="false" class="dash-alert">
|
|
<template #title>
|
|
<span>{{ loadError }}</span>
|
|
<el-button size="small" type="primary" class="retry-btn" @click="load">重试</el-button>
|
|
</template>
|
|
</el-alert>
|
|
|
|
<!-- 空态:窗口内无任何业务数据(数据截至角标同步显「无数据」) -->
|
|
<el-alert
|
|
v-if="noData"
|
|
type="info"
|
|
title="该窗口无数据——试试调整时间范围"
|
|
:closable="false"
|
|
class="dash-alert"
|
|
/>
|
|
|
|
<!-- 六 KPI 卡:两行三卡(≥768px 三列/窄屏两列);后三卡行动入口直达处置页 -->
|
|
<el-row :gutter="12" class="block-row kpi-row" v-loading="loading">
|
|
<el-col :xs="12" :sm="8">
|
|
<ManagementKpiCard
|
|
label="活跃客户"
|
|
:value="kpis?.active_customers ?? null"
|
|
:delta="deltaOf('active_customers')"
|
|
unit="人"
|
|
:hint="threadsHint"
|
|
/>
|
|
</el-col>
|
|
<el-col :xs="12" :sm="8">
|
|
<ManagementKpiCard
|
|
label="活跃员工"
|
|
:value="kpis?.active_employees ?? null"
|
|
:delta="deltaOf('active_employees')"
|
|
unit="人"
|
|
hint="期内有数据天数>0 口径"
|
|
/>
|
|
</el-col>
|
|
<el-col :xs="12" :sm="8">
|
|
<!-- tooltip 说明混合 vs 1:1 口径差异,hover 才展开,卡面 hint 保持一行 -->
|
|
<el-tooltip
|
|
placement="top"
|
|
content="管理域消息量仅统计 1:1 私聊消息;「数据分析·数据看板」为私聊+群聊混合口径,两者数值不可直接对比"
|
|
>
|
|
<ManagementKpiCard
|
|
label="消息量"
|
|
:value="kpis?.msg_total ?? null"
|
|
:delta="deltaOf('msg_total')"
|
|
unit="条"
|
|
hint="1:1口径·与数据看板口径不同"
|
|
/>
|
|
</el-tooltip>
|
|
</el-col>
|
|
<el-col :xs="12" :sm="8">
|
|
<ManagementKpiCard
|
|
label="待回复"
|
|
:value="actions?.unreplied_2h ?? null"
|
|
unit="人"
|
|
hint="当前时点指标·不随所选窗口"
|
|
:danger="(actions?.unreplied_2h ?? 0) > 0"
|
|
to="/analytics/unreplied"
|
|
/>
|
|
</el-col>
|
|
<el-col :xs="12" :sm="8">
|
|
<ManagementKpiCard
|
|
label="即将流失"
|
|
:value="actions?.churn_real_30d ?? null"
|
|
unit="人"
|
|
to="/analytics/churn"
|
|
/>
|
|
</el-col>
|
|
<el-col :xs="12" :sm="8">
|
|
<ManagementKpiCard
|
|
label="风险待处理"
|
|
:value="actions?.sensitive_pending ?? null"
|
|
unit="条"
|
|
hint="当前时点指标·不随所选窗口"
|
|
to="/analytics/sensitive"
|
|
/>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<!-- 双趋势图:左沟通量(消息线+通话柱双 y 轴),右服务质量(首响分位线+回复率右轴) -->
|
|
<el-row :gutter="12" class="block-row">
|
|
<el-col :xs="24" :md="12">
|
|
<el-card shadow="never" class="chart-card" v-loading="loading">
|
|
<template #header>客户沟通趋势</template>
|
|
<div ref="commEl" class="chart"></div>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :xs="24" :md="12">
|
|
<el-card shadow="never" class="chart-card" v-loading="loading">
|
|
<template #header>服务质量趋势</template>
|
|
<div ref="svcEl" class="chart"></div>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<!-- 双条形图:左生命周期四态(横向),右员工维度均分(0-100,无样本灰) -->
|
|
<el-row :gutter="12" class="block-row">
|
|
<el-col :xs="24" :md="12">
|
|
<el-card shadow="never" class="chart-card" v-loading="loading">
|
|
<template #header>生命周期分布</template>
|
|
<div ref="lifeEl" class="chart"></div>
|
|
</el-card>
|
|
</el-col>
|
|
<el-col :xs="24" :md="12">
|
|
<el-card shadow="never" class="chart-card" v-loading="loading">
|
|
<template #header>员工维度分布</template>
|
|
<div ref="dimsEl" class="chart"></div>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
|
|
<!-- 异常榜:critical 在前(后端已排);点行开下钻抽屉,两次点击到事实 -->
|
|
<el-card shadow="never" class="block-row anomaly-card" v-loading="loading">
|
|
<template #header>
|
|
异常榜
|
|
<span class="anomaly-sub">按严重度排序 · 点行查看明细</span>
|
|
</template>
|
|
<AnomalyList :anomalies="resp?.anomalies ?? []" :loading="loading" @drill="onDrill" />
|
|
</el-card>
|
|
|
|
<!-- 下钻抽屉:metric 类异常逐串/逐行明细,行点击跳聊天;employee 类批次D 占位 -->
|
|
<MetricDrilldownDrawer
|
|
v-model="drillVisible"
|
|
:card="drillCard"
|
|
:user-id="drillCard?.employee_user_id ?? ''"
|
|
:start="start"
|
|
:end="end"
|
|
:channel="channel"
|
|
/>
|
|
|
|
<!-- 指标口径抽屉:19 项定义+评分配置,自缓存 -->
|
|
<MetricDefsDrawer v-model="defsVisible" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
// 管理驾驶舱(经营管理板块批次C首页):筛选(URL query 真源)→ 六 KPI →
|
|
// 双趋势(沟通量/服务质量)→ 生命周期四态 + 员工维度均分 → 异常榜(下钻
|
|
// 抽屉两次点击到事实)。管理者 5 分钟看整体:先 KPI 判断总体,异常卡
|
|
// 直达事实。载荷 /management/dashboard 单往返;compare 仅回显(对比值
|
|
// 批次D 员工表格呈现)。seq 守卫/双图 useChart/格式化均沿用项目惯例。
|
|
import { computed, ref, watch } from 'vue'
|
|
import {
|
|
getDashboard,
|
|
type AnomalyCard,
|
|
type DashboardResp,
|
|
} from '@/api/management'
|
|
import { useManagementFilters } from '@/composables/useManagementFilters'
|
|
import { useChart } from '@/composables/useChart'
|
|
import { formatMsDuration, formatRatio } from '@/utils/format'
|
|
import ManagementFilterBar from '@/components/management/ManagementFilterBar.vue'
|
|
import ManagementKpiCard from '@/components/management/ManagementKpiCard.vue'
|
|
import DataAsOfBadge from '@/components/management/DataAsOfBadge.vue'
|
|
import AnomalyList from '@/components/management/AnomalyList.vue'
|
|
import MetricDrilldownDrawer from '@/components/management/MetricDrilldownDrawer.vue'
|
|
import MetricDefsDrawer from '@/components/management/MetricDefsDrawer.vue'
|
|
|
|
// ── 筛选(URL query 为真源,刷新/分享保持)与加载 ──
|
|
const { channel, start, end, compare, apply } = useManagementFilters()
|
|
|
|
const resp = ref<DashboardResp | null>(null)
|
|
const loading = ref(false)
|
|
const loadError = ref<string | null>(null)
|
|
|
|
let seq = 0 // 竞态守卫:筛选快速切换时旧响应作废(项目惯例)
|
|
|
|
async function load(): Promise<void> {
|
|
const my = ++seq
|
|
loading.value = true
|
|
loadError.value = null
|
|
try {
|
|
const r = await getDashboard(channel.value, start.value, end.value, compare.value)
|
|
if (my !== seq) return
|
|
resp.value = r
|
|
} catch (e) {
|
|
// 失败保留上次成功数据(resp 不清空),仅标记 error 供 alert+重试
|
|
if (my === seq) loadError.value = e instanceof Error ? e.message : String(e)
|
|
} finally {
|
|
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
|
|
if (my === seq) loading.value = false
|
|
}
|
|
}
|
|
|
|
// immediate 首帧即取数,替代 onMounted;筛选任一变化重查
|
|
watch([channel, start, end, compare], load, { immediate: true })
|
|
|
|
const kpis = computed(() => resp.value?.kpis ?? 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 同载荷,管理域跨日精确串)
|
|
const threadsHint = computed(() => {
|
|
const k = kpis.value
|
|
if (!k) return undefined
|
|
return `串 ${k.cust_threads.toLocaleString()} · 被回 ${k.threads_answered.toLocaleString()}`
|
|
})
|
|
|
|
// 空态:核心 KPI 全 0(趋势按日脚手架恒非空,以 KPI 判空才可靠)
|
|
const noData = computed(() => {
|
|
const k = kpis.value
|
|
if (!k || loadError.value) return false
|
|
return !k.active_customers && !k.active_employees && !k.msg_total && !k.call_count
|
|
})
|
|
|
|
// ── 异常下钻 ──
|
|
const drillVisible = ref(false)
|
|
const drillCard = ref<AnomalyCard | null>(null)
|
|
const defsVisible = ref(false)
|
|
|
|
function onDrill(card: AnomalyCard): void {
|
|
drillCard.value = card
|
|
drillVisible.value = true
|
|
}
|
|
|
|
// ── 图表(四张,容器恒在 DOM——useChart 只 init 一次,不 v-if) ──
|
|
const commEl = ref<HTMLElement | null>(null)
|
|
const svcEl = ref<HTMLElement | null>(null)
|
|
const lifeEl = ref<HTMLElement | null>(null)
|
|
const dimsEl = ref<HTMLElement | null>(null)
|
|
const { render: renderComm } = useChart(commEl)
|
|
const { render: renderSvc } = useChart(svcEl)
|
|
const { render: renderLife } = useChart(lifeEl)
|
|
const { render: renderDims } = useChart(dimsEl)
|
|
|
|
// yyyymmdd(20260810)→ 08-10 短格式(轴/tooltip 共用)
|
|
const shortDay = (d: number): string => `${String(d).slice(4, 6)}-${String(d).slice(6)}`
|
|
// 大数压缩:轴刻度统一 1.1万 口径(与 DashboardView 一致)
|
|
const compact = (n: number): string =>
|
|
n >= 10000 ? `${(n / 10000).toFixed(1)}万` : n.toLocaleString()
|
|
// 服务质量左轴刻度:ms → 紧凑时长(轴上省秒,tooltip 仍用 formatMsDuration)
|
|
const fmtMsAxis = (v: number): string =>
|
|
v >= 3_600_000 ? `${(v / 3_600_000).toFixed(1)}时` : `${Math.round(v / 60_000)}分`
|
|
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
|
|
const axisInterval = (n: number): number => Math.max(0, Math.ceil(n / 6) - 1)
|
|
|
|
const AXIS_LABEL = { color: '#909399' }
|
|
const SPLIT_LINE = { lineStyle: { type: 'solid' as const, color: '#f0f1f3' } }
|
|
|
|
function drawComm(): void {
|
|
const t = resp.value?.communication_trend
|
|
if (!t || !t.length) return
|
|
const labels = t.map((p) => shortDay(p.day))
|
|
renderComm({
|
|
grid: { left: 56, right: 56, top: 40, bottom: 28 },
|
|
tooltip: { trigger: 'axis', valueFormatter: (v: unknown) => compact(Number(v)) },
|
|
legend: { data: ['消息量', '通话量'], top: 4, itemWidth: 14, textStyle: { color: '#606266' } },
|
|
xAxis: {
|
|
type: 'category',
|
|
data: labels,
|
|
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
|
|
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
|
axisTick: { show: false },
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: '消息量',
|
|
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
|
splitLine: SPLIT_LINE,
|
|
},
|
|
{
|
|
type: 'value',
|
|
name: '通话量',
|
|
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
|
splitLine: { show: false },
|
|
},
|
|
],
|
|
series: [
|
|
{
|
|
name: '消息量',
|
|
type: 'line',
|
|
smooth: true,
|
|
yAxisIndex: 0,
|
|
lineStyle: { width: 2, color: '#409EFF' },
|
|
itemStyle: { color: '#409EFF' },
|
|
areaStyle: { opacity: 0.1, color: '#409EFF' },
|
|
showSymbol: false,
|
|
data: t.map((p) => p.msg_total),
|
|
},
|
|
{
|
|
name: '通话量',
|
|
type: 'bar',
|
|
yAxisIndex: 1,
|
|
barMaxWidth: 12,
|
|
itemStyle: { color: '#67C23A', borderRadius: [3, 3, 0, 0] },
|
|
data: t.map((p) => p.call_cnt),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// 服务质量 tooltip:ms→时长/率→百分比(null 显 '-',与断点语义一致)
|
|
interface TipParam { marker?: string; seriesName?: string; value?: number | null; axisValueLabel?: string }
|
|
function svcTooltip(ps: unknown): string {
|
|
const arr = (Array.isArray(ps) ? ps : [ps]) as TipParam[]
|
|
if (!arr.length) return ''
|
|
const rows = arr.map((p) => {
|
|
const v = typeof p.value === 'number' ? p.value : null
|
|
const text = v === null ? '-' : p.seriesName === '2h回复率' ? formatRatio(v) : formatMsDuration(v)
|
|
return `<div>${p.marker ?? ''}${p.seriesName}: ${text}</div>`
|
|
})
|
|
return `<div>${arr[0].axisValueLabel ?? ''}</div>${rows.join('')}`
|
|
}
|
|
|
|
function drawSvc(): void {
|
|
const t = resp.value?.service_trend
|
|
if (!t || !t.length) return
|
|
const labels = t.map((p) => shortDay(p.day))
|
|
renderSvc({
|
|
grid: { left: 56, right: 56, top: 40, bottom: 28 },
|
|
tooltip: { trigger: 'axis', formatter: svcTooltip },
|
|
legend: {
|
|
data: ['首响中位', '首响九分位', '2h回复率'],
|
|
top: 4, itemWidth: 14, textStyle: { color: '#606266' },
|
|
},
|
|
xAxis: {
|
|
type: 'category',
|
|
data: labels,
|
|
axisLabel: { interval: axisInterval(labels.length), hideOverlap: true, ...AXIS_LABEL },
|
|
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
|
axisTick: { show: false },
|
|
},
|
|
yAxis: [
|
|
{
|
|
type: 'value',
|
|
name: '首响',
|
|
axisLabel: { formatter: fmtMsAxis, ...AXIS_LABEL },
|
|
splitLine: SPLIT_LINE,
|
|
},
|
|
{
|
|
// 右轴 0-1 率:不单列双轴则率线贴底不可读
|
|
type: 'value',
|
|
name: '2h回复率',
|
|
min: 0,
|
|
max: 1,
|
|
axisLabel: { formatter: (v: number) => formatRatio(v), ...AXIS_LABEL },
|
|
splitLine: { show: false },
|
|
},
|
|
],
|
|
series: [
|
|
// null(无样本日)=断点:connectNulls:false 显式钉死,不跨缺口连线
|
|
{
|
|
name: '首响中位',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
lineStyle: { width: 2 },
|
|
data: t.map((p) => p.fr_p50_ms),
|
|
},
|
|
{
|
|
name: '首响九分位',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
lineStyle: { width: 2 },
|
|
data: t.map((p) => p.fr_p90_ms),
|
|
},
|
|
{
|
|
name: '2h回复率',
|
|
type: 'line', smooth: true, showSymbol: false, connectNulls: false,
|
|
yAxisIndex: 1,
|
|
lineStyle: { width: 2, color: '#67C23A' },
|
|
itemStyle: { color: '#67C23A' },
|
|
data: t.map((p) => p.reply_2h_rate),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// 生命周期四态:横向条形(名义类别,状态色——新客蓝/降温橙/沉睡红/回流绿)
|
|
const LIFE_META = [
|
|
{ key: 'new', label: '新客', color: '#409EFF' },
|
|
{ key: 'cooling', label: '降温', color: '#E6A23C' },
|
|
{ key: 'dormant', label: '沉睡', color: '#F56C6C' },
|
|
{ key: 'reactivated', label: '回流', color: '#67C23A' },
|
|
] as const
|
|
|
|
function drawLife(): void {
|
|
const lc = resp.value?.lifecycle
|
|
if (!lc) return
|
|
renderLife({
|
|
grid: { left: 8, right: 48, top: 8, bottom: 8, containLabel: true },
|
|
tooltip: { trigger: 'item', valueFormatter: (v: unknown) => compact(Number(v)) },
|
|
xAxis: {
|
|
type: 'value',
|
|
axisLabel: { formatter: (v: number) => compact(v), ...AXIS_LABEL },
|
|
splitLine: SPLIT_LINE,
|
|
},
|
|
yAxis: {
|
|
type: 'category',
|
|
inverse: true, // 新客在顶部(生命周期顺序)
|
|
data: LIFE_META.map((m) => m.label),
|
|
axisTick: { show: false },
|
|
axisLine: { show: false },
|
|
axisLabel: { color: '#606266' },
|
|
},
|
|
series: [
|
|
{
|
|
type: 'bar',
|
|
barWidth: 14,
|
|
itemStyle: { borderRadius: [0, 4, 4, 0] },
|
|
label: {
|
|
show: true,
|
|
position: 'right',
|
|
formatter: (p: { value: unknown }) => compact(Number(p.value)),
|
|
color: '#606266',
|
|
fontSize: 11,
|
|
},
|
|
data: LIFE_META.map((m) => ({ value: lc[m.key], itemStyle: { color: m.color } })),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// 员工维度分布:pending_data 维(action/risk)恒无 scored/insufficient 计数,
|
|
// 以计数>0 过滤,只留真实参与评分的维度(customer/service/workload)
|
|
const dimRows = computed(() =>
|
|
Object.entries(resp.value?.dimension_summary ?? {})
|
|
.filter(([, d]) => d.scored_count > 0 || d.insufficient_count > 0)
|
|
.map(([key, d]) => ({ key, ...d })),
|
|
)
|
|
|
|
function dimsTooltip(p: { dataIndex?: number }): string {
|
|
const r = p.dataIndex != null ? dimRows.value[p.dataIndex] : undefined
|
|
if (!r) return ''
|
|
const avg = r.avg_score === null ? '-' : Math.round(r.avg_score)
|
|
return (
|
|
`<div>${r.name}</div>` +
|
|
`<div>均分 ${avg}(权重 ${r.weight}%)</div>` +
|
|
`<div>已评分 ${r.scored_count} · 样本不足 ${r.insufficient_count} · 待数据 ${r.pending_count}</div>`
|
|
)
|
|
}
|
|
|
|
function drawDims(): void {
|
|
const rows = dimRows.value
|
|
if (!rows.length) return
|
|
renderDims({
|
|
grid: { left: 40, right: 16, top: 24, bottom: 28, containLabel: true },
|
|
tooltip: { trigger: 'item', formatter: dimsTooltip },
|
|
xAxis: {
|
|
type: 'category',
|
|
data: rows.map((r) => r.name),
|
|
axisTick: { show: false },
|
|
axisLine: { lineStyle: { color: '#dcdfe6' } },
|
|
axisLabel: { color: '#606266' },
|
|
},
|
|
yAxis: {
|
|
type: 'value',
|
|
min: 0,
|
|
max: 100, // 维度分定义域 0-100
|
|
axisLabel: AXIS_LABEL,
|
|
splitLine: SPLIT_LINE,
|
|
},
|
|
series: [
|
|
{
|
|
type: 'bar',
|
|
barWidth: 28,
|
|
itemStyle: { borderRadius: [3, 3, 0, 0] },
|
|
label: {
|
|
show: true,
|
|
position: 'top',
|
|
formatter: (p: { dataIndex: number; value: unknown }) =>
|
|
(rows[p.dataIndex]?.avg_score ?? null) === null ? '-' : compact(Number(p.value)),
|
|
color: '#606266',
|
|
fontSize: 11,
|
|
},
|
|
data: rows.map((r) =>
|
|
r.avg_score === null
|
|
// 无样本维度(全员未评):0 值灰条——有位置但不冒充分数
|
|
? { value: 0, itemStyle: { color: '#c0c4cc' } }
|
|
: { value: Math.round(r.avg_score), itemStyle: { color: '#409EFF' } },
|
|
),
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
// post:等 DOM 更新后再画,保证首帧容器已挂载;resp 单源,四图同刷
|
|
watch(resp, () => { drawComm(); drawSvc(); drawLife(); drawDims() }, { flush: 'post' })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dash-alert {
|
|
margin-top: 12px;
|
|
}
|
|
.retry-btn {
|
|
margin-left: 12px;
|
|
}
|
|
.block-row {
|
|
margin-top: 16px;
|
|
}
|
|
.kpi-row {
|
|
min-height: 100px;
|
|
}
|
|
.chart-card :deep(.el-card__body) {
|
|
padding: 12px 16px 8px;
|
|
}
|
|
.chart {
|
|
height: 300px;
|
|
}
|
|
.anomaly-sub {
|
|
margin-left: 8px;
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
color: var(--el-text-color-secondary);
|
|
}
|
|
</style>
|