feat(analytics): A1 数据看板页——卡片/异常警示/趋势折线/数据截至角标

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-04 21:48:55 +08:00
parent 5fb19dd3ac
commit 4cec162f53
2 changed files with 246 additions and 5 deletions
+5 -1
View File
@@ -1,5 +1,6 @@
// ECharts 按需封装(2026-09-04):注册/主题/自适应/销毁一处管——后续所有
// 图表页复用(二批 A2/A4/A5 直接 import 本 composable)。
// 契约:容器须稳定存在(v-show 可,v-if 卸载重建不支持——chart 只 init 一次)。
import { onBeforeUnmount, type Ref } from 'vue'
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
@@ -14,7 +15,10 @@ export function useChart(el: Ref<HTMLElement | null>) {
let ro: ResizeObserver | null = null
const render = (option: EChartsCoreOption) => {
if (!el.value) return
if (!el.value) {
console.warn('[useChart] render called before element mounted')
return
}
if (!chart) {
chart = echarts.init(el.value)
ro = new ResizeObserver(() => chart?.resize())
+241 -4
View File
@@ -1,8 +1,245 @@
<template>
<div class="page">
<h2>数据看板</h2>
<p>实现于后续任务</p>
<div class="pa4-dashboard">
<div class="toolbar">
<el-radio-group v-model="channel" @change="load">
<el-radio-button value="wechat">微信</el-radio-button>
<el-radio-button value="whatsapp">WhatsApp</el-radio-button>
</el-radio-group>
<el-radio-group v-model="scope" @change="load">
<el-radio-button value="today">今日</el-radio-button>
<el-radio-button value="week">本周</el-radio-button>
<el-radio-button value="month">本月</el-radio-button>
</el-radio-group>
<span v-if="asOfText" class="as-of">数据截至 {{ asOfText }}</span>
</div>
<el-alert v-if="error" type="error" :title="error" :closable="false" class="anomaly" />
<el-alert v-if="anomalyView" type="warning" :closable="false" class="anomaly">
<template #title>
昨日消息量较前 7 日均值{{ anomalyView.dir }} {{ anomalyView.pct }}%({{ anomalyView.yesterday }} vs 均值
{{ anomalyView.mean }})
<span v-if="anomalyView.staffCount">— 涉及 {{ anomalyView.staffCount }} 名员工</span>
</template>
<div v-if="anomalyView.staffCount" class="anomaly-staff">
<div v-for="s in anomalyView.topStaff" :key="s.user_id">
{{ s.user_id }}:{{ s.yesterday }}(均值 {{ s.prev7_mean }}){{ s.isNew ? '(新)' : '' }}
</div>
</div>
</el-alert>
<div v-loading="loading" class="cards">
<el-card v-for="c in cardList" :key="c.label" shadow="never">
<div class="card-label">{{ c.label }}</div>
<div class="card-value">{{ c.value }}</div>
<div v-if="c.delta !== undefined && c.delta !== null" class="card-delta" :class="{ down: c.delta < 0 }">
环比 {{ (c.delta * 100).toFixed(1) }}%
</div>
<div v-else-if="c.hint" class="card-hint">{{ c.hint }}</div>
</el-card>
</div>
<el-card v-loading="loading" shadow="never" class="trend-card">
<template #header>
<el-radio-group v-model="trendMetric" size="small">
<el-radio-button value="msg">消息量</el-radio-button>
<el-radio-button value="active_customers">活跃客户</el-radio-button>
<el-radio-button value="call_cnt">通话量</el-radio-button>
</el-radio-group>
</template>
<div ref="chartEl" class="trend-chart"></div>
</el-card>
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
// A1 数据看板:筛选栏(渠道×时间范围) → 异常警示条 → 五卡片 → 30 日趋势折线。
// 数据来自 /analytics/overview + /analytics/health(数据截至角标)。
import { computed, onMounted, ref, watch } from 'vue'
import {
getAnalyticsHealth, getOverview, type Channel, type OverviewResp, type Scope,
} from '@/api/analytics'
import { useChart } from '@/composables/useChart'
interface CardItem {
label: string
value: string
delta?: number
hint?: string
}
interface AnomalyStaffItem {
user_id: string
yesterday: number
prev7_mean: number
isNew: boolean
}
interface AnomalyView {
dir: string
pct: number
yesterday: number
mean: number
staffCount: number
topStaff: AnomalyStaffItem[]
}
const channel = ref<Channel>('wechat')
const scope = ref<Scope>('today')
const trendMetric = ref<'msg' | 'active_customers' | 'call_cnt'>('msg')
const data = ref<OverviewResp | null>(null)
const asOf = ref<number | null>(null)
const loading = ref(false)
const error = ref('')
const chartEl = ref<HTMLElement | null>(null)
const { render } = useChart(chartEl)
let seq = 0 // 竞态守卫:channel/scope 快速切换时旧响应作废(项目惯例)
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const [ov, h] = await Promise.all([getOverview(channel.value, scope.value), getAnalyticsHealth()])
if (my !== seq) return
data.value = ov
asOf.value = h.data_as_of
} catch (e) {
if (my === seq) error.value = e instanceof Error ? e.message : String(e)
} finally {
// 过期请求不清 loading(新一轮还在飞)也不覆盖 error
if (my === seq) loading.value = false
}
}
const asOfText = computed(() => {
if (!asOf.value) return ''
const d = new Date(asOf.value)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getMonth() + 1}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
})
// 异常警示展示模型:判空/格式化收在 computed,模板只读定型字段(slot 闭包里
// 无法沿用 v-if 的空值窄化,预拍平最稳)
const anomalyView = computed<AnomalyView | null>(() => {
const a = data.value?.anomaly
if (!a) return null
return {
dir: a.total_delta_pct > 0 ? '高' : '低',
pct: Math.abs(Math.round(a.total_delta_pct * 100)),
yesterday: a.yesterday_total,
mean: a.prev7_mean,
staffCount: a.staff.length,
topStaff: a.staff.slice(0, 5).map((s) => ({
user_id: s.user_id,
yesterday: s.yesterday,
prev7_mean: s.prev7_mean,
isNew: !!s.new,
})),
}
})
const fmt = (n: number | undefined | null) => (n ?? 0).toLocaleString()
const cardList = computed<CardItem[]>(() => {
const c = data.value?.cards
if (!c) return []
return [
{
label: '消息量',
value: fmt(c.msg_total),
// 今日卡片无 msg_total_delta 键;周/月可能有 null(前值缺失)→ 统一 undefined 不展示
delta: scope.value === 'today' ? undefined : (c.msg_total_delta ?? undefined),
},
{ label: '活跃客户', value: fmt(c.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) },
]
})
function draw() {
const t = data.value?.trend
if (!t) return
render({
grid: { left: 48, right: 16, top: 24, bottom: 28 },
tooltip: { trigger: 'axis' },
xAxis: {
type: 'category',
// day 是 yyyymmdd int(20260904 → "09-04")
data: t.map((p) => `${String(p.day).slice(4, 6)}-${String(p.day).slice(6)}`),
},
yAxis: { type: 'value' },
series: [
{
type: 'line',
smooth: true,
areaStyle: { opacity: 0.12 },
data: t.map((p) => p[trendMetric.value]),
},
],
})
}
// post:等 DOM 更新后再画,保证首帧 chartEl 已挂载(趋势图恒 30 天,随 data 重画)
watch([data, trendMetric], draw, { flush: 'post' })
onMounted(load)
</script>
<style scoped>
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.as-of {
color: var(--el-text-color-secondary);
font-size: 12px;
margin-left: auto;
}
.anomaly {
margin-bottom: 16px;
}
.anomaly-staff {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 16px;
min-height: 60px;
}
.card-label {
color: var(--el-text-color-secondary);
font-size: 13px;
}
.card-value {
font-size: 26px;
font-weight: 600;
margin: 6px 0 2px;
}
.card-delta {
font-size: 12px;
color: var(--el-color-success);
}
.card-delta.down {
color: var(--el-color-danger);
}
.card-hint {
font-size: 11px;
color: var(--el-text-color-secondary);
}
.trend-chart {
height: 320px;
}
</style>