feat(analytics): 看板v2全平台口径——默认双渠道合计/拆分小字/双线趋势/跳转带query预选
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>) =>
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
@@ -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[]>([])
|
||||
|
||||
Reference in New Issue
Block a user