12 ECharts 看板与 SLA:将业务结果可视化
12 ECharts 看板与 SLA:将业务结果可视化
本章你会做出什么
服务主管可以看到工单总量、待受理、处理中、待确认、已关闭和超时数量,并通过饼图、柱状图、折线图了解运营情况。处理人员只统计自己负责的数据。列表和详情还会显示 SLA 风险。
先理解三个概念
1. 后端聚合统计
看板不能让前端取全量分页数据再统计。后端已有数据访问规则,直接按照当前用户可见范围汇总,前端只负责展示。
2. ECharts 配置
图表不是图片,而是由一个配置对象生成:系列类型决定是饼图、柱状图或折线图,data 来自接口。
3. SLA
SLA 是服务处理时限。本项目规则简单且可解释:
| 优先级 | 应处理时限 |
|---|---|
高 HIGH |
4 小时 |
中 MEDIUM |
24 小时 |
低 LOW |
72 小时 |
未结束工单超过截止时间显示“已超时”,距截止不超过 2 小时显示“即将超时”。
本章最终目录变化
server/src/
services/ticket-service.ts # dashboard 汇总
utils.ts # SLA 判断
client/src/
components/
BaseChart.vue
SlaTag.vue
types/chart.ts
utils/business.ts
views/dashboard/DashboardView.vue
一步一步操作
第 1 步:后端加入 SLA 工具
import { SLA_HOURS } from './constants.js'
import type { Ticket, TicketPriority } from './types.js'
export const dueAtForPriority = (priority: TicketPriority, createdAt = new Date()) =>
new Date(createdAt.getTime() + SLA_HOURS[priority] * 60 * 60 * 1000).toISOString()
export const isOverdue = (ticket: Ticket) =>
!['CLOSED', 'CANCELLED'].includes(ticket.status) && new Date(ticket.dueAt).getTime() < Date.now()
第 2 步:编写看板服务方法
在前后端 types 文件加入相同数据形状(后端也可直接复制第 3 章前端已有定义):
export interface DashboardData {
cards: { total: number; pending: number; processing: number; waitingConfirm: number; closed: number; overdue: number }
statusDistribution: Array<{ name: string; value: number }>
categoryRanking: Array<{ name: string; value: number }>
dailyTrend: Array<{ date: string; created: number; closed: number }>
recentTickets: Ticket[]
}
在 TicketService 中复用第 9 章的读取范围,获取可见工单后返回:
async dashboard(user: AuthUser, range: 7 | 30): Promise<DashboardData> {
const result = await this.store.listTickets({ page: 1, pageSize: 10000, ...this.getScope(user, false) })
const start = Date.now() - (range - 1) * 24 * 60 * 60 * 1000
const tickets = result.list.filter((item) => new Date(item.createdAt).getTime() >= start)
const count = (status: TicketStatus) => tickets.filter((item) => item.status === status).length
const category = new Map<string, number>()
tickets.forEach((item) => category.set(item.categoryName ?? '其他', (category.get(item.categoryName ?? '其他') ?? 0) + 1))
const dailyTrend = Array.from({ length: range }, (_, index) => {
const date = new Date()
date.setDate(date.getDate() - range + index + 1)
const key = date.toISOString().slice(0, 10)
return {
date: key.slice(5),
created: tickets.filter((item) => item.createdAt.slice(0, 10) === key).length,
closed: tickets.filter((item) => item.closedAt?.slice(0, 10) === key).length,
}
})
return {
cards: {
total: tickets.length,
pending: count('PENDING'),
processing: count('PROCESSING'),
waitingConfirm: count('WAITING_CONFIRM'),
closed: count('CLOSED'),
overdue: tickets.filter(isOverdue).length,
},
statusDistribution: ['PENDING', 'PROCESSING', 'WAITING_CONFIRM', 'CLOSED', 'CANCELLED'].map((status) => ({
name: STATUS_LABELS[status as TicketStatus],
value: count(status as TicketStatus),
})),
categoryRanking: [...category].map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value),
dailyTrend,
recentTickets: tickets.slice(0, 5),
}
}
在路由加入:
app.get(
'/api/v1/dashboard/overview',
authenticated,
requirePermission('dashboard:view', 'dashboard:view_own'),
async (req, res) => {
success(res, await tickets.dashboard(req.currentUser!, req.query.range === '30d' ? 30 : 7))
}
)
第 3 步:建立图表组件
BaseChart 只负责初始化图表和适应容器变化,每个业务图表的配置仍在看板页面中定义。
先将看板请求加进 client/src/api/modules.ts 的 commonApi:
dashboard: (range: '7d' | '30d') =>
api.get<DashboardData>('/dashboard/overview', { params: { range } }),
关键文件完整代码
client/src/components/BaseChart.vue
<script setup lang="ts">
import { BarChart, LineChart, PieChart } from 'echarts/charts'
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'
import { init, use, type ECharts } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ChartOption } from '../types/chart'
use([BarChart, LineChart, PieChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer])
const props = defineProps<{ option: ChartOption }>()
const container = ref<HTMLDivElement>()
let instance: ECharts | undefined
let observer: ResizeObserver | undefined
onMounted(() => {
if (!container.value) return
instance = init(container.value)
instance.setOption(props.option)
observer = new ResizeObserver(() => instance?.resize())
observer.observe(container.value)
})
watch(
() => props.option,
(option) => instance?.setOption(option, true),
{ deep: true }
)
onBeforeUnmount(() => {
observer?.disconnect()
instance?.dispose()
})
</script>
<template>
<div ref="container" class="chart" />
</template>
<style scoped>
.chart {
width: 100%;
height: 300px;
}
</style>
client/src/types/chart.ts
按需引入 ECharts 系列后,用组合类型约束看板传入的配置对象:
import type { BarSeriesOption, LineSeriesOption, PieSeriesOption } from 'echarts/charts'
import type { GridComponentOption, LegendComponentOption, TooltipComponentOption } from 'echarts/components'
import type { ComposeOption } from 'echarts/core'
export type ChartOption = ComposeOption<
BarSeriesOption | LineSeriesOption | PieSeriesOption | GridComponentOption | LegendComponentOption | TooltipComponentOption
>
client/src/utils/business.ts 中的 SLA 方法
import type { Ticket } from '../types'
export const slaStatus = (ticket: Pick<Ticket, 'status' | 'dueAt'>) => {
if (ticket.status === 'CLOSED' || ticket.status === 'CANCELLED') {
return { label: '已结束', type: 'info' as const }
}
const remaining = new Date(ticket.dueAt).getTime() - Date.now()
if (remaining < 0) return { label: '已超时', type: 'danger' as const }
if (remaining <= 2 * 60 * 60 * 1000) return { label: '即将超时', type: 'warning' as const }
return { label: '正常', type: 'success' as const }
}
client/src/components/SlaTag.vue
<script setup lang="ts">
import { computed } from 'vue'
import type { Ticket } from '../types'
import { slaStatus } from '../utils/business'
const props = defineProps<{ ticket: Pick<Ticket, 'status' | 'dueAt'> }>()
const result = computed(() => slaStatus(props.ticket))
</script>
<template>
<el-tag :type="result.type">{{ result.label }}</el-tag>
</template>
先理解 DashboardView.vue 中的三种空状态
脚本中请求数据并构建配置:
const data = ref<DashboardData>()
const range = ref<'7d' | '30d'>('7d')
const load = async () => {
data.value = await commonApi.dashboard(range.value)
}
const hasStatusData = computed(() => (data.value?.statusDistribution ?? []).some((item) => item.value > 0))
const hasCategoryData = computed(() => (data.value?.categoryRanking ?? []).some((item) => item.value > 0))
const hasTrendData = computed(() => (data.value?.dailyTrend ?? []).some((item) => item.created > 0 || item.closed > 0))
const statusOption = computed(() => ({
tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['48%', '70%'], data: data.value?.statusDistribution ?? [] }]
}))
const categoryOption = computed(() => ({
xAxis: { type: 'value' },
yAxis: { type: 'category', data: data.value?.categoryRanking.map((item) => item.name) ?? [] },
series: [{ type: 'bar', data: data.value?.categoryRanking.map((item) => item.value) ?? [] }]
}))
const trendOption = computed(() => ({
xAxis: { type: 'category', data: data.value?.dailyTrend.map((item) => item.date) ?? [] },
yAxis: { type: 'value' },
series: [
{ name: '新增', type: 'line', data: data.value?.dailyTrend.map((item) => item.created) ?? [] },
{ name: '关闭', type: 'line', data: data.value?.dailyTrend.map((item) => item.closed) ?? [] }
]
}))
onMounted(load)
模板中必须处理空数据,而不是留下空白图:
<el-card v-if="data">
<div class="cards">
<strong>总工单 {{ data.cards.total }}</strong>
<strong>待受理 {{ data.cards.pending }}</strong>
<strong>处理中 {{ data.cards.processing }}</strong>
<strong>已关闭 {{ data.cards.closed }}</strong>
<strong>已超时 {{ data.cards.overdue }}</strong>
</div>
</el-card>
<el-card header="状态分布">
<BaseChart v-if="hasStatusData" :option="statusOption" />
<el-empty v-else description="暂无状态数据" />
</el-card>
<el-card header="分类排行">
<BaseChart v-if="hasCategoryData" :option="categoryOption" />
<el-empty v-else description="暂无分类数据" />
</el-card>
<el-card header="新增与关闭趋势">
<BaseChart v-if="hasTrendData" :option="trendOption" />
<el-empty v-else description="暂无趋势数据" />
</el-card>
client/src/views/dashboard/DashboardView.vue(替换完整文件)
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { commonApi } from '../../api/modules'
import BaseChart from '../../components/BaseChart.vue'
import PriorityTag from '../../components/PriorityTag.vue'
import SlaTag from '../../components/SlaTag.vue'
import StatusTag from '../../components/StatusTag.vue'
import type { DashboardData, Ticket } from '../../types'
import type { ChartOption } from '../../types/chart'
import { formatDate } from '../../utils/business'
const router = useRouter()
const loading = ref(false)
const range = ref<'7d' | '30d'>('7d')
const data = ref<DashboardData>()
const cards = computed(() =>
data.value
? [
{ label: '工单总数', value: data.value.cards.total, color: '#1769e0' },
{ label: '待受理', value: data.value.cards.pending, color: '#e6a23c' },
{ label: '处理中', value: data.value.cards.processing, color: '#409eff' },
{ label: '待确认', value: data.value.cards.waitingConfirm, color: '#7b68ee' },
{ label: '已关闭', value: data.value.cards.closed, color: '#17a673' },
{ label: '已超时', value: data.value.cards.overdue, color: '#e45454' }
]
: []
)
const hasStatusData = computed(() => (data.value?.statusDistribution ?? []).some((item) => item.value > 0))
const hasCategoryData = computed(() => (data.value?.categoryRanking ?? []).some((item) => item.value > 0))
const hasTrendData = computed(() => (data.value?.dailyTrend ?? []).some((item) => item.created > 0 || item.closed > 0))
const statusOption = computed<ChartOption>(() => ({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
type: 'pie',
radius: ['48%', '70%'],
center: ['50%', '44%'],
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 3 },
data: data.value?.statusDistribution ?? []
}
]
}))
const categoryOption = computed<ChartOption>(() => ({
tooltip: { trigger: 'axis' },
grid: { left: 18, right: 22, top: 24, bottom: 16, containLabel: true },
xAxis: { type: 'value', splitLine: { lineStyle: { color: '#eef1f7' } } },
yAxis: { type: 'category', data: (data.value?.categoryRanking ?? []).map((item) => item.name).reverse() },
series: [
{
type: 'bar',
barWidth: 18,
data: (data.value?.categoryRanking ?? []).map((item) => item.value).reverse(),
itemStyle: { color: '#2879ee', borderRadius: [0, 6, 6, 0] }
}
]
}))
const trendOption = computed<ChartOption>(() => ({
tooltip: { trigger: 'axis' },
legend: { data: ['新增工单', '关闭工单'], top: 0 },
grid: { left: 20, right: 20, top: 48, bottom: 16, containLabel: true },
xAxis: { type: 'category', data: data.value?.dailyTrend.map((item) => item.date) ?? [], boundaryGap: false },
yAxis: { type: 'value', minInterval: 1, splitLine: { lineStyle: { color: '#eef1f7' } } },
series: [
{
name: '新增工单',
type: 'line',
smooth: true,
data: data.value?.dailyTrend.map((item) => item.created),
color: '#1769e0'
},
{
name: '关闭工单',
type: 'line',
smooth: true,
data: data.value?.dailyTrend.map((item) => item.closed),
color: '#19a976'
}
]
}))
const load = async () => {
loading.value = true
try {
data.value = await commonApi.dashboard(range.value)
} finally {
loading.value = false
}
}
const openTicket = (ticket: Ticket) => router.push(`/tickets/${ticket.id}`)
onMounted(load)
</script>
<template>
<div v-loading="loading" class="page-container">
<div class="page-header">
<div>
<h1 class="page-title">运营看板</h1>
<p class="page-subtitle">跟踪服务量、处理进度和 SLA 风险</p>
</div>
<el-radio-group v-model="range" @change="load">
<el-radio-button value="7d">近 7 天</el-radio-button>
<el-radio-button value="30d">近 30 天</el-radio-button>
</el-radio-group>
</div>
<div class="cards">
<el-card v-for="card in cards" :key="card.label" shadow="never">
<p>{{ card.label }}</p>
<strong :style="{ color: card.color }">{{ card.value }}</strong>
</el-card>
</div>
<div v-if="data" class="chart-grid">
<el-card shadow="never" class="content-card">
<template #header>工单状态分布</template>
<BaseChart v-if="hasStatusData" :option="statusOption" />
<el-empty v-else description="暂无状态数据" :image-size="72" />
</el-card>
<el-card shadow="never" class="content-card">
<template #header>问题分类排行</template>
<BaseChart v-if="hasCategoryData" :option="categoryOption" />
<el-empty v-else description="暂无分类数据" :image-size="72" />
</el-card>
<el-card shadow="never" class="trend content-card">
<template #header>新增与关闭趋势</template>
<BaseChart v-if="hasTrendData" :option="trendOption" />
<el-empty v-else description="暂无趋势数据" :image-size="72" />
</el-card>
</div>
<el-card v-if="data" shadow="never" class="recent content-card">
<template #header>最近工单</template>
<el-table :data="data.recentTickets" @row-click="openTicket">
<el-table-column prop="ticketNo" label="工单编号" width="160" />
<el-table-column prop="title" label="标题" min-width="220" />
<el-table-column label="优先级" width="90"
><template #default="{ row }"><PriorityTag :priority="row.priority" /></template
></el-table-column>
<el-table-column label="状态" width="110"
><template #default="{ row }"><StatusTag :status="row.status" /></template
></el-table-column>
<el-table-column label="SLA" width="110"
><template #default="{ row }"><SlaTag :ticket="row" /></template
></el-table-column>
<el-table-column label="创建时间" width="175"
><template #default="{ row }">{{ formatDate(row.createdAt) }}</template></el-table-column
>
</el-table>
</el-card>
</div>
</template>
<style scoped>
.cards {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 14px;
margin-bottom: 18px;
}
.cards .el-card {
border: 0;
border-radius: 10px;
}
.cards p {
margin: 0 0 11px;
color: var(--sd-muted);
font-size: 13px;
}
.cards strong {
font-size: 30px;
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.trend {
grid-column: 1 / 3;
}
.recent {
margin-bottom: 24px;
}
</style>
启动并验证
- 用
supervisor登录,看板应统计所有可见工单。 - 用
agent登录,看板应只显示分配给自己的工单数据。 - 给一个无工单的新处理人员账号登录,看板图表区域应显示“暂无数据”,不是空白。
- 创建高优先级待处理工单,调整系统测试时间或种子数据的创建时间,确认“SLA 已超时”数量和列表标签一致。
常见报错与原因
| 现象 | 原因 | 修正 |
|---|---|---|
| 图表容器为空 | 容器无高度 | 给 .chart 设置固定或最小高度 |
| 窗口缩放后图表裁切 | 未监听尺寸变化 | 保留 ResizeObserver |
| 普通用户能请求看板 | 接口遗漏权限中间件 | 加 requirePermission('dashboard:view', 'dashboard:view_own') |
| 看板和列表超时数量不同 | 两处计算规则不同 | 将同一 SLA 规则落实在后端聚合和前端标签中 |
本章完成清单
- 看板接口按当前用户数据范围统计。
- 三类图表均可显示真实接口数据。
- 无数据时显示明确空状态。
- 列表可展示 SLA 标签,看板可统计已超时量。
面试时这一章能怎么讲
看板汇总由后端基于当前用户数据范围计算,避免前端拉取分页列表做不可靠的全量统计;ECharts 被封装为响应式图表组件,并对空数据做明确状态展示。SLA 使用可配置优先级时限,为工单业务增加了可说明的运营指标。