feat(analytics): A1 数据看板页——卡片/异常警示/趋势折线/数据截至角标
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user