14. 图表开发
前端手册图表大约 6 分钟约 1868 字
一句话定位
用 components/scEcharts/index.vue 包一层 ECharts:自带主题 T、跟随系统主题色与暗色、ResizeObserver 自适应、卸载自动 dispose;页面只负责算 option。
代码位置:Web/src/components/scEcharts/(含 echarts-theme-T.js)
结构图
页面 scEcharts 组件 ECharts
───── ────────────── ───────
const option = ref({...}) ──► props.option(watch deep)
├─ mounted: registerTheme('T')
├─ MutationObserver(html class) ──► refreshTheme() → setOption(..., true)
├─ ResizeObserver(容器) ─────────► resize() / draw()
├─ draw(): el.clientWidth/Height 非 0 才 init
├─ myChart.on('click') ──────────► emit('clickData', evt.value)
└─ beforeUnmount: disconnect + dispose
接口数据 ──► option.xxx.data = [...] ──► watch 触发 setOption一、基础用法
<template>
<el-card shadow="hover" :body-style="{ padding: '5px' }">
<scEcharts v-if="state.chartOption.series?.[0]?.data" height="300px" :option="state.chartOption" @clickData="onChartClick" />
</el-card>
</template>
<script lang="ts" setup name="demoChart">
import { defineAsyncComponent, reactive } from 'vue';
const scEcharts = defineAsyncComponent(() => import('/@/components/scEcharts/index.vue'));
const state = reactive({ chartOption: { /* option */ } });
const onChartClick = (value: any) => { console.log(value); };
</script>| Prop | 类型 | 默认 | 说明 |
|---|---|---|---|
height | string | '100%' | 容器高度 |
width | string | '100%' | 容器宽度 |
option | Object | {} | ECharts option,深度 watch 自动 setOption |
nodata | Boolean | false | 无数据标记 |
事件:clickData —— 组件内 myChart.on('click', evt => this.$emit('clickData', evt.value))。
组件是选项式 API 写的
<script>(非 setup),不是<script setup>;用defineAsyncComponent引入即可。
二、option 示例
折线图
const lineOption = {
tooltip: { trigger: 'axis' },
legend: { data: ['访问量', '操作量'] },
grid: { left: 40, right: 20, top: 40, bottom: 30 },
xAxis: { type: 'category', boundaryGap: false, data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
yAxis: { type: 'value' },
series: [
{ name: '访问量', type: 'line', smooth: true, areaStyle: { opacity: 0.15 }, data: [120, 132, 101, 134, 90, 230, 210] },
{ name: '操作量', type: 'line', smooth: true, data: [60, 72, 51, 74, 40, 130, 110] },
],
};柱状图
const barOption = {
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: 40, right: 20, top: 40, bottom: 30 },
xAxis: { type: 'category', data: ['一月', '二月', '三月', '四月', '五月', '六月'] },
yAxis: { type: 'value' },
series: [
{
name: '新增用户',
type: 'bar',
barWidth: '40%',
itemStyle: { borderRadius: [4, 4, 0, 0] },
data: [820, 932, 901, 934, 1290, 1330],
},
],
};饼图(环形)
const pieOption = {
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
legend: { orient: 'vertical', left: 'left' },
series: [
{
name: '来源',
type: 'pie',
radius: ['45%', '70%'],
avoidLabelOverlap: true,
itemStyle: { borderRadius: 6, borderColor: 'var(--el-bg-color)', borderWidth: 2 },
label: { formatter: '{b} {d}%' },
data: [
{ value: 1048, name: 'PC' },
{ value: 735, name: '移动端' },
{ value: 580, name: '小程序' },
{ value: 484, name: '第三方' },
],
},
],
};仪表盘
const gaugeOption = {
series: [
{
type: 'gauge',
startAngle: 210,
endAngle: -30,
min: 0,
max: 100,
progress: { show: true, width: 12 },
axisLine: { lineStyle: { width: 12 } },
axisTick: { show: false },
splitLine: { length: 12, lineStyle: { width: 2 } },
axisLabel: { distance: 18, fontSize: 12 },
pointer: { width: 5 },
detail: { valueAnimation: true, fontSize: 28, offsetCenter: [0, '60%'], formatter: '{value}%' },
data: [{ value: 72 }],
},
],
};三、跟随主题色与暗色
组件内部这两个方法已经处理好,页面一般不用管:
getCssVar(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); }
getThemeColors() {
return ['--el-color-primary-light-9','--el-color-primary-light-7','--el-color-primary-light-5',
'--el-color-primary-light-3','--el-color-primary-light-1','--el-color-primary']
.map(v => this.getCssVar(v));
}
refreshTheme() {
// visualMap.inRange.color / calendar.itemStyle 重新取色,然后 setOption(option, true) + resize()
}触发链:切换暗色 → document.documentElement 的 class 变化 → MutationObserver → requestAnimationFrame(refreshTheme)。
页面侧要做的两件事
- 颜色写 CSS 变量,不写裸色值(这样暗色自动跟随):
const cssVar = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const colors = ['--el-color-primary-light-9', '--el-color-primary-light-7', '--el-color-primary-light-5',
'--el-color-primary-light-3', '--el-color-primary-light-1', '--el-color-primary']
.map((v) => cssVar(v));- 统计卡/图标底色用
--next-accent-N(暗色模式只需在theme/dark.scss覆盖这一组变量)。
真实写法(views/home/widgets/components/oplogChart.vue):
echarts.registerTheme('T', T);
const colors = ['--el-color-primary-light-9', '--el-color-primary-light-7', '--el-color-primary-light-5',
'--el-color-primary-light-3', '--el-color-primary-light-1', '--el-color-primary'].map(
(variable) => getComputedStyle(document.documentElement).getPropertyValue(variable).trim()
);
const cssVar = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const echartsOption = ref({
visualMap: { show: true, inRange: { color: colors }, outOfRange: { color: ['transparent'] }, /* ... */ },
calendar: {
itemStyle: { color: cssVar('--el-fill-color-light'), borderWidth: 2, borderColor: cssVar('--el-bg-color') },
},
series: { type: 'heatmap', coordinateSystem: 'calendar', data: [] },
});若页面自己
echarts.init(不用 scEcharts),必须自己实现refreshTheme与dispose,参考oplogChart.vue的onMounted/onBeforeUnmount。
四、ResizeObserver 与自适应
组件内:
if (typeof ResizeObserver !== 'undefined') {
this.sizeObserver = new ResizeObserver(() => {
if (this.myChart) this.myChart.resize();
else this.draw(); // 容器从 0 尺寸恢复时补一次初始化
});
this.sizeObserver.observe(this.$refs.scEcharts);
}
window.addEventListener('resize', this.handleResize);⚠️ 两条硬性经验(源码注释里写明了):
- 容器宽高为 0 时绝不
init:ECharts 会算出不可逆的坐标变换矩阵,后续setOption抛can't access property 0, m is null。所以draw()里有if (!el.clientWidth || !el.clientHeight) return;。 watch option时若myChart还没建出来要跳过:否则抛unwarp(...) is null。组件已处理,业务侧配合v-if="option.xxx.data"更稳。
五、完整示例:数据来自接口
以真实页面 views/home/widgets/components/oplogChart.vue(操作日志日历热力图)为原型,接口 SysLogOpApi.apiSysLogOpYearDayStatsGet():
<template>
<el-card class="chart-card" shadow="hover" :body-style="{ padding: '5px' }">
<template #header>
<div class="card-header">
<el-icon style="display: inline; vertical-align: middle; margin-right: 5px"><ele-Grid /></el-icon>
<span>操作日志</span>
</div>
</template>
<scEcharts height="300px" :option="state.option" @clickData="onChartClick" />
</el-card>
</template>
<script lang="ts" setup name="demoChart">
import { defineAsyncComponent, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { getAPI } from '/@/utils/axios-utils';
import { SysLogOpApi } from '/@/api-services/system/api';
const scEcharts = defineAsyncComponent(() => import('/@/components/scEcharts/index.vue'));
// 主题色:随 --el-color-primary 变化,暗色模式自动跟随
const cssVar = (name: string) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
const colors = ['--el-color-primary-light-9', '--el-color-primary-light-7', '--el-color-primary-light-5',
'--el-color-primary-light-3', '--el-color-primary-light-1', '--el-color-primary'].map((v) => cssVar(v));
const state = reactive({
maxValue: 1,
option: {
tooltip: {
formatter: (p: any) => `${p.data[0]} 数据量:${p.data[1]}`,
},
visualMap: {
show: true,
min: 0.01,
max: 1000,
type: 'piecewise',
orient: 'horizontal',
left: 'right',
inRange: { color: colors },
outOfRange: { color: ['transparent'] },
},
calendar: {
top: 30, left: 30, right: 30, bottom: 30,
cellSize: ['auto', 20],
range: ['', ''] as string[],
itemStyle: { color: cssVar('--el-fill-color-light'), borderWidth: 2, borderColor: cssVar('--el-bg-color') },
monthLabel: { nameMap: ['一月','二月','三月','四月','五月','六月','七月','八月','九月','十月','十一月','十二月'] },
yearLabel: { show: false },
},
series: { type: 'heatmap', coordinateSystem: 'calendar', data: [] as any[] },
},
});
// 拉取数据:接口失败时业务码由拦截器统一提示,这里只需守卫 result
const loadData = async () => {
const res = await getAPI(SysLogOpApi).apiSysLogOpYearDayStatsGet();
const data: any[] = [];
res.data.result?.forEach((item: any) => {
data.push([item.date, item.count]);
if (item.count > state.maxValue) state.maxValue = item.count;
});
if (data.length === 0) return;
state.option.visualMap.max = state.maxValue;
state.option.series.data = data;
state.option.calendar.range = [data[0][0], data[data.length - 1][0]];
};
// 点击图形:拿到 evt.value(这里是 [日期, 数量])
const onChartClick = (e: any) => {
if (e[1] < 1) return ElMessage.warning('当天没有数据');
const today = new Date(e[0]);
const endTime = new Date(today.setDate(today.getDate() + 1));
// 例:把日期区间塞回查询条件并刷新表格
// state.queryParams.createTimeRange = [e[0], endTime];
// xGrid.value?.commitProxy('query');
};
loadData();
</script>
<style lang="scss" scoped>
.chart-card {
border-radius: var(--next-radius-md);
border: 1px solid var(--el-border-color-lighter);
}
</style>六、步骤清单
const scEcharts = defineAsyncComponent(() => import('/@/components/scEcharts/index.vue'))。- 用
reactive/ref持有option(不要每帧新建对象,改字段即可触发 deep watch)。 - 颜色一律
cssVar('--el-...')或var(--next-accent-N),不写裸色值。 - 接口返回后赋值
option.series.data,并按需更新visualMap.max/calendar.range。 - 模板上加
v-if="option.series.data"之类守卫,避免空数据先渲染。 - 需要交互就监听
@clickData。 - 若图表在
v-if/ 抽屉 / 标签页里,确认父容器有实际高度再去渲染。
⚠️ 注意事项 / 常见坑
- 图表不显示:十有八九是容器高度为 0。
height给固定值(如300px)或保证父级 flex 布局有高度。 - 切标签页/抽屉回来图表空白:
scEcharts的ResizeObserver会兜底;若你自己init,需要在onActivated里myChart.resize()。 - 主题色不更新:把颜色写死成
#5b8def了。改成cssVar('--el-color-primary')或--next-accent-N。 setOption残留旧数据:主题刷新时组件内部用的是setOption(option, true)(notMerge);自己调用时注意第二个参数。- 内存泄漏:用
scEcharts不用担心(beforeUnmount已dispose并移除监听);自己init必须配对dispose()+removeEventListener。 - 大数据量:开
sampling: 'lte'、关掉animation、减少symbol;日历热力图之类用visualMap时注意max要按实际最大值算。 - option 用
reactive包:直接替换整个对象也能触发,但字段级更新更省性能。
小结
scEcharts= ECharts + 主题T+ 主题跟随 + 自适应 + 自动销毁,页面只算 option。- 颜色必须走 CSS 变量(
--el-color-primary-light-N/--el-fill-color-light/--next-accent-N)。 - 容器有尺寸才画,空数据用
v-if守卫。 - 交互通过
@clickData,拿到的是evt.value。