Files
yunke/yunkes/src/views/management/ManagementDashboardView.vue
T

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>