feat(analytics): 看板v2全平台口径——默认双渠道合计/拆分小字/双线趋势/跳转带query预选

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-07 12:20:18 +08:00
parent 9f2dff08d9
commit 1aadc80d6b
8 changed files with 395 additions and 117 deletions
+10 -2
View File
@@ -5,6 +5,9 @@ import type { Channel, Page } from './types'
export type { Channel }
export type Scope = 'today' | 'week' | 'month'
// 看板 v2(2026-09-07):overview 渠道参数支持 'all'=全平台(后端译成
// channel=None);列表页(churn/unreplied)仍是单渠道,沿用 types.ts 的 Channel
export type DashboardChannel = 'all' | Channel
export interface OverviewCards {
msg_total: number
@@ -27,15 +30,20 @@ export interface StaffRankingRow {
call_cnt: number; msg_delta: number | null
}
export interface ActionCounts { unreplied_2h: number; churn_real_30d: number; sensitive_pending: number }
// by_channel/trend_by_channel 仅 channel='all' 时后端才返回(可选字段);
// 渠道趋势序列 call_cnt 恒 0——通话量无渠道语义不拆,总量走 trend
export interface PerChannelCards { msg_total: number; active_customers: number; first_contact: number }
export interface OverviewResp {
scope: Scope
channel: Channel
channel: Channel | 'all'
days: number[]
cards: OverviewCards
trend: TrendPoint[]
anomaly: Anomaly | null
staff_ranking: StaffRankingRow[]
actions: ActionCounts
by_channel?: { wechat: PerChannelCards; whatsapp: PerChannelCards }
trend_by_channel?: { wechat: TrendPoint[]; whatsapp: TrendPoint[] }
}
export interface HealthResp { data_as_of: number | null; entities: { table: string; last_seen: string | null }[] }
export interface ChurnRow {
@@ -56,7 +64,7 @@ export interface SensitiveHitRow {
room_id: string | null; ts: number | null; snippet: string | null; disposition: string
}
export const getOverview = (channel: Channel, scope: Scope) =>
export const getOverview = (channel: DashboardChannel, scope: Scope) =>
getJson<OverviewResp>('/analytics/overview', { channel, scope })
export const getAnalyticsHealth = () => getJson<HealthResp>('/analytics/health')
export const getChurn = (params: Record<string, unknown>) =>
+13 -4
View File
@@ -52,7 +52,7 @@
// 沉默分层(仅真流失) + CSV 导出 + 行点击跳聊天窗。
// 数据来自 /analytics/churn;/analytics/health 挂载拉一次做「数据截至」角标。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAnalyticsHealth, getChurn, type Channel, type ChurnRow } from '@/api/analytics'
import { exportListToCsv } from '@/utils/csvExport'
@@ -68,9 +68,18 @@ const KIND_HINTS: Record<ChurnKind, string> = {
}
const router = useRouter()
const channel = ref<Channel>('wechat')
const kind = ref<ChurnKind>('real')
const tier = ref(30)
// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch)
const route = useRoute()
const qChannel = route.query.channel
const channel = ref<Channel>(
qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat',
)
const qType = route.query.type
const kind = ref<ChurnKind>(
qType === 'real' || qType === 'inactive' || qType === 'handover' ? qType : 'real',
)
const qTier = Number(route.query.tier)
const tier = ref([30, 60, 90].includes(qTier) ? qTier : 30)
const page = ref(1)
const pageSize = 20
const rows = ref<ChurnRow[]>([])
+87 -10
View File
@@ -2,6 +2,7 @@
<div class="pa4-dashboard">
<div class="toolbar">
<el-radio-group v-model="channel" @change="load">
<el-radio-button value="all">全部</el-radio-button>
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
@@ -17,7 +18,7 @@
行动入口」);计数随 channel 切换自然跟随 overview 载荷,0 灰显仍可点 -->
<div class="action-row">
<el-card
v-for="a in actionCards" :key="a.to" shadow="never"
v-for="a in actionCards" :key="a.label" shadow="never"
class="action-card" :class="{ zero: a.count === 0 }"
@click="router.push(a.to)"
>
@@ -112,11 +113,14 @@
<script setup lang="ts">
// A1 数据看板:筛选栏(渠道×时间范围) → 预警行动入口 → 异常警示条 → 五卡片
// → 30 日趋势折线 → 员工活跃排行表。
// 看板 v2(2026-09-07):渠道默认「全部」=全平台口径——卡片合计+微信/WA
// 拆分小字,趋势双线(微信蓝/WA 绿)+legend,行动卡跳转带 query 预选目标页。
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel, type OverviewResp, type Scope,
getAnalyticsHealth, getOverview, type AnomalyStaff, type Channel,
type DashboardChannel, type OverviewResp, type Scope,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
import { formatTimeShort } from '@/utils/format'
@@ -146,7 +150,8 @@ interface AnomalyView {
topStaff: AnomalyStaffItem[]
}
const channel = ref<Channel>('wechat')
// 渠道默认「全部」(看板 v2:管理人员先看全平台,一眼看清再下钻单渠道)
const channel = ref<DashboardChannel>('all')
const scope = ref<Scope>('today')
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
const data = ref<OverviewResp | null>(null)
@@ -216,16 +221,37 @@ const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
const trendVals = (): number[] =>
(data.value?.trend ?? []).map((p) => p[trendMetric.value] as number)
// 预警行动入口:计数来自 overview 载荷 actions(0 灰显仍可点,直达处置页)
// 预警行动入口:计数来自 overview 载荷 actions(0 灰显仍可点,直达处置页)。
// 跳转带 query 预选目标页筛选(评审断裂修复):单渠道跟随当前选择,all 不带
// channel(目标页是单渠道清单,落地默认微信);敏感词无渠道概念只带 disposition
const actionCards = computed(() => {
const a = data.value?.actions
const ch: { channel?: Channel } =
channel.value !== 'all' ? { channel: channel.value } : {}
return [
{ icon: '🔴', label: '该回未回 2h', count: a?.unreplied_2h ?? 0, to: '/analytics/unreplied' },
{ icon: '🟡', label: '流失预警 30天+', count: a?.churn_real_30d ?? 0, to: '/analytics/churn' },
{ icon: '⚠', label: '敏感词待处置', count: a?.sensitive_pending ?? 0, to: '/analytics/sensitive' },
{
icon: '🔴', label: '该回未回 2h', count: a?.unreplied_2h ?? 0,
to: { path: '/analytics/unreplied', query: { ...ch, hours: '2' } },
},
{
icon: '🟡', label: '流失预警 30天+', count: a?.churn_real_30d ?? 0,
to: { path: '/analytics/churn', query: { ...ch, type: 'real', tier: '30' } },
},
{
icon: '⚠', label: '敏感词待处置', count: a?.sensitive_pending ?? 0,
to: { path: '/analytics/sensitive', query: { disposition: 'pending' } },
},
]
})
// channel=all 时的卡片拆分小字(微信 X · WA Y);活跃员工跨渠道重复计数不拆、
// 通话量无渠道语义不拆(后端 by_channel 只给这三项)
function splitHint(key: 'msg_total' | 'active_customers' | 'first_contact'): string | undefined {
const bc = data.value?.by_channel
if (channel.value !== 'all' || !bc) return undefined
return `微信 ${fmt(bc.wechat[key])} · WA ${fmt(bc.whatsapp[key])}`
}
const cardList = computed<CardItem[]>(() => {
const c = data.value?.cards
if (!c) return []
@@ -235,15 +261,16 @@ const cardList = computed<CardItem[]>(() => {
value: fmt(c.msg_total),
// 今日卡片无 msg_total_delta 键;周/月可能有 null(前值缺失)→ 统一 undefined 不展示
delta: scope.value === 'today' ? undefined : (c.msg_total_delta ?? undefined),
hint: splitHint('msg_total'),
},
{ label: '活跃客户', value: fmt(c.active_customers) },
{ label: '活跃客户', value: fmt(c.active_customers), hint: splitHint('active_customers') },
{
label: '活跃员工',
value: fmt(c.active_staff),
hint: c.unmapped_accounts > 0 ? `含未映射账号 ${c.unmapped_accounts}` : undefined,
},
{ label: '通话量', value: fmt(c.call_cnt) },
{ label: '首次沟通客户', value: fmt(c.first_contact) },
{ label: '首次沟通客户', value: fmt(c.first_contact), hint: splitHint('first_contact') },
]
})
@@ -262,13 +289,63 @@ const trendSummary = computed(() => {
return `合计 ${total.toLocaleString()} · 日均 ${Math.round(total / vals.length).toLocaleString()} · 峰值 ${max.toLocaleString()}(${dstr})`
})
// all 模式双系列渠道色:微信=EP 主蓝,WhatsApp=EP success 绿(与单系列默认色系一致)
const CHANNEL_SERIES = [
{ key: 'wechat', name: '微信', color: '#409EFF' },
{ key: 'whatsapp', name: 'WhatsApp', color: '#67C23A' },
] as const
function draw() {
const t = data.value?.trend
if (!t || !t.length) return
const labels = t.map((p) => `${String(p.day).slice(4, 6)}-${String(p.day).slice(6)}`)
const vals = trendVals()
// X 轴 ~6 个刻度(30 个日期全排会挤成一串不可读)
const interval = Math.max(0, Math.ceil(labels.length / 6) - 1)
// all 模式双系列(消息量/活跃客户):两条线+legend(两系列必须有图例)。
// 选择性标注原则——图例+tooltip+摘要行(合计口径)够了,此模式不画
// markPoint/endLabel/markLine。通话量无渠道语义,all 下仍走下方单系列合计。
const tbc = data.value?.trend_by_channel
if (channel.value === 'all' && trendMetric.value !== 'call_cnt' && tbc) {
render({
grid: { left: 56, right: 56, top: 40, bottom: 28 },
tooltip: {
trigger: 'axis',
valueFormatter: (v: unknown) => compact(Number(v)),
},
legend: {
data: CHANNEL_SERIES.map((s) => s.name),
top: 4,
itemWidth: 14,
textStyle: { color: '#606266' },
},
xAxis: {
type: 'category',
data: labels,
axisLabel: { interval, hideOverlap: true, color: '#909399' },
axisLine: { lineStyle: { color: '#dcdfe6' } },
axisTick: { show: false },
},
yAxis: {
type: 'value',
axisLabel: { formatter: (v: number) => compact(v), color: '#909399' },
splitLine: { lineStyle: { type: 'solid', color: '#f0f1f3' } },
},
series: CHANNEL_SERIES.map((s) => ({
name: s.name,
type: 'line' as const,
smooth: true,
lineStyle: { width: 2, color: s.color },
itemStyle: { color: s.color },
areaStyle: { opacity: 0.1, color: s.color },
showSymbol: false,
data: tbc[s.key].map((p) => p[trendMetric.value] as number),
})),
})
return
}
const vals = trendVals()
const maxIdx = vals.indexOf(Math.max(...vals))
const avg = Math.round(vals.reduce((a, b) => a + b, 0) / vals.length)
render({
+15 -3
View File
@@ -136,7 +136,7 @@
// health 挂载拉一次做「数据截至」角标;seq 三路径守卫/分页/tab-change/
// kind-hint 均照 ChurnView/UnrepliedView 定型。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createWord,
@@ -168,6 +168,7 @@ function dispMeta(d: string): { label: string; type: 'warning' | 'danger' | 'inf
}
const router = useRouter()
const route = useRoute()
const tab = ref<TabName>('hits')
const asOf = ref<number | null>(null)
const asOfText = computed(() => formatTimeShort(asOf.value))
@@ -175,8 +176,19 @@ const asOfText = computed(() => formatTimeShort(asOf.value))
// ---- 命中列表 tab ----
// 'all'=默认(pending+confirmed,后端不传 disposition 即此语义);其余必须是
// 枚举单值——乱值后端 Literal 校验直接 422(空串同理,所以用哨兵值)
const disposition = ref('all')
// 枚举单值——乱值后端 Literal 校验直接 422(空串同理,所以用哨兵值)。
// 看板敏感词行动卡跳转带 ?disposition=pending 预选(白名单外回默认,
// 只在初始化读一次,不 watch;敏感词无渠道概念,不带 channel)
type DispFilter = 'all' | 'pending' | 'confirmed' | 'false_positive' | 'ignored'
const DISP_FILTERS: readonly DispFilter[] = [
'all', 'pending', 'confirmed', 'false_positive', 'ignored',
]
const qDisp = route.query.disposition
const disposition = ref<DispFilter>(
typeof qDisp === 'string' && DISP_FILTERS.includes(qDisp as DispFilter)
? (qDisp as DispFilter)
: 'all',
)
const hitPage = ref(1)
const hitPageSize = 20
const hitRows = ref<SensitiveHitRow[]>([])
+9 -3
View File
@@ -50,7 +50,7 @@
// ?msgid= 定位该条消息)。数据来自 /analytics/unreplied;不导出 CSV。
// health 挂载拉一次做「数据截至」角标(seq/分页/health-once 均照 ChurnView)。
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getAnalyticsHealth, getUnreplied, type Channel, type UnrepliedRow } from '@/api/analytics'
import { formatTime, formatTimeShort } from '@/utils/format'
@@ -58,9 +58,15 @@ import { formatTime, formatTimeShort } from '@/utils/format'
const DAY_MS = 24 * 3600 * 1000
const router = useRouter()
const channel = ref<Channel>('wechat')
const route = useRoute()
// 看板行动卡跳转带 query 预选筛选(白名单外回默认;只在初始化读一次,不 watch)
const qChannel = route.query.channel
const channel = ref<Channel>(
qChannel === 'wechat' || qChannel === 'whatsapp' ? qChannel : 'wechat',
)
// 默认 2h(spec §3.4/§8 口径速查表,后端 Query 默认同为 2)——首屏不是 >1h 超集
const hours = ref(2)
const qHours = Number(route.query.hours)
const hours = ref([1, 2, 4, 24].includes(qHours) ? qHours : 2)
const page = ref(1)
const pageSize = 20
const rows = ref<UnrepliedRow[]>([])