feat(analytics): A1 数据看板页——卡片/异常警示/趋势折线/数据截至角标
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
// ECharts 按需封装(2026-09-04):注册/主题/自适应/销毁一处管——后续所有
|
// ECharts 按需封装(2026-09-04):注册/主题/自适应/销毁一处管——后续所有
|
||||||
// 图表页复用(二批 A2/A4/A5 直接 import 本 composable)。
|
// 图表页复用(二批 A2/A4/A5 直接 import 本 composable)。
|
||||||
|
// 契约:容器须稳定存在(v-show 可,v-if 卸载重建不支持——chart 只 init 一次)。
|
||||||
import { onBeforeUnmount, type Ref } from 'vue'
|
import { onBeforeUnmount, type Ref } from 'vue'
|
||||||
import * as echarts from 'echarts/core'
|
import * as echarts from 'echarts/core'
|
||||||
import { LineChart, BarChart } from 'echarts/charts'
|
import { LineChart, BarChart } from 'echarts/charts'
|
||||||
@@ -14,7 +15,10 @@ export function useChart(el: Ref<HTMLElement | null>) {
|
|||||||
let ro: ResizeObserver | null = null
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
const render = (option: EChartsCoreOption) => {
|
const render = (option: EChartsCoreOption) => {
|
||||||
if (!el.value) return
|
if (!el.value) {
|
||||||
|
console.warn('[useChart] render called before element mounted')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!chart) {
|
if (!chart) {
|
||||||
chart = echarts.init(el.value)
|
chart = echarts.init(el.value)
|
||||||
ro = new ResizeObserver(() => chart?.resize())
|
ro = new ResizeObserver(() => chart?.resize())
|
||||||
|
|||||||
@@ -1,8 +1,245 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="pa4-dashboard">
|
||||||
<h2>数据看板</h2>
|
<div class="toolbar">
|
||||||
<p>实现于后续任务</p>
|
<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>
|
</div>
|
||||||
</template>
|
</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