需求与实现结构
原文场景是后台数据分析系统里的商品价格历史趋势图:弹窗承载图表,ECharts 渲染折线图,接口返回历史记录,前端完成数据清洗、按小时聚合、去重、排序与展示。代码采用 Vue2 选项式 API 与 Element UI 的 el-dialog 写法,图表使用 import * as echarts from 'echarts' 全量导入。组件 props 包括 visible、storeGoodsId、useMockData;computed 中通过 dialogVisible 的 get/set 配合 update:visible 控制弹窗;真实数据调用 crudMethodProfit.getHistoryList({ storeGoodsId: this.storeGoodsId }),Mock 数据用于后端接口未完成时调试。
接口返回数组,每项核心字段为 queryTime 和 totalCost。原文示例数据包括 queryTime 为 2026-04-09 08:48:12、totalCost 为 3143.78,queryTime 为 2026-04-09 10:02:57、totalCost 为 3145.38,以及 11:17:54 的 3142.13、12:35:43 的 3139.6。组件还会在价格为 0 且存在 errMsg 时,把失败原因放进 Tooltip,因此图表不仅展示趋势,也承担异常解释。
初始化与销毁:Dialog 动画是尺寸问题的根源
原文在 watch 中监听 visible,只有弹窗打开且 storeGoodsId 存在时才初始化图表,并用 setTimeout 延迟 300ms,再进入 this.$nextTick 调用 initChart。原因很明确:Dialog 有打开动画,立即初始化时容器宽高还没计算完成,ECharts 可能出现显示异常或尺寸错误。initChart 内先调用 disposeChart,再 echarts.init(this.$refs.chartContainer),随后 showLoading,并根据 useMockData 选择 loadMockData 或 loadRealData。
- watch: {
- visible(newVal) {
- if (newVal && this.storeGoodsId) {
- setTimeout(() => {
- this.$nextTick(() => {
- this.initChart()
- })
- }, 300)
- }
- }
- },
- beforeDestroy() {
- this.disposeChart()
- },
- methods: {
- initChart() {
- this.disposeChart()
- this.chart = echarts.init(this.$refs.chartContainer)
- this.chart.showLoading()
- this.useMockData ? this.loadMockData() : this.loadRealData()
- },
- disposeChart() {
- if (this.chart) {
- this.chart.dispose()
- this.chart = null
- }
- }
- }
复制代码
真实数据加载成功后先 hideLoading;返回空数组时提示“暂无历史数据”并返回;请求失败时 hideLoading 后提示“获取历史数据失败”,同时 console.error(err)。组件在 beforeDestroy 和弹窗 close 时销毁图表,避免内存泄漏和多实例叠加。
核心数据处理:向上取整、分组保留最新、排序
数据处理是这篇文章最值得复用的部分。第一步把每条记录的 queryTime 向上取整到整点:如果分钟或秒大于 0,就把小时加 1 并清零分秒;否则直接设为当前整点。这样可以统一时间粒度,避免分钟级噪声影响趋势可读性。第二步用 Map 按 roundedTime.getTime() 分组,同一整点只保留原始 queryTime 最大的一条记录,也就是该时间段最新数据。第三步按 roundedTime 升序排序,保证 X 轴时间线正确。
- const processedData = data.map(item => {
- const queryTime = new Date(item.queryTime)
- const roundedHour = new Date(queryTime)
- if (queryTime.getMinutes() > 0 || queryTime.getSeconds() > 0) {
- roundedHour.setHours(queryTime.getHours() + 1, 0, 0, 0)
- } else {
- roundedHour.setHours(queryTime.getHours(), 0, 0, 0)
- }
- return {
- ...item,
- roundedTime: roundedHour,
- totalCost: item.totalCost ? Number(item.totalCost) : 0
- }
- })
- const timeGroupMap = new Map()
- processedData.forEach(item => {
- const timeKey = item.roundedTime.getTime()
- if (!timeGroupMap.has(timeKey) || new Date(item.queryTime) > new Date(timeGroupMap.get(timeKey).queryTime)) {
- timeGroupMap.set(timeKey, item)
- }
- })
- const sortedData = Array.from(timeGroupMap.values())
- .sort((a, b) => a.roundedTime - b.roundedTime)
- const times = sortedData.map(item => {
- const date = item.roundedTime
- const month = date.getMonth() + 1
- const day = date.getDate()
- const hour = String(date.getHours()).padStart(2, '0')
- return `${month}/${day} ${hour}:00`
- })
- const prices = sortedData.map(item => item.totalCost.toFixed(2))
复制代码
这里去重条件比较的是原始 queryTime,而不是取整后的时间,否则无法判断同一小时内哪条更新。X 轴最终格式类似 04/08 14:00,既保留日期也保留小时。
Y 轴动态范围与 Tooltip 异常解释
Y 轴没有写死范围,而是从 prices 中过滤大于 0 的值并转成数字,计算最小价、最大价和区间 range,再上下各留 10% 余量;下限用 Math.max(0, minPrice - priceRange * 0.1) 防止出现负价格。这样图表不会贴边,能适配不同价格区间。
- const validPrices = prices.filter(p => p > 0).map(Number)
- const minPrice = validPrices.length > 0 ? Math.min(...validPrices) : 0
- const maxPrice = validPrices.length > 0 ? Math.max(...validPrices) : 0
- const priceRange = maxPrice - minPrice
- const yAxisMin = Math.max(0, minPrice - priceRange * 0.1)
- const yAxisMax = maxPrice + priceRange * 0.1
复制代码
Tooltip 通过 trigger: 'axis' 触发,formatter 中取出 params[0],用 dataIndex 回查 sortedData,显示预估采购价和格式化后的查询时间。若 price === 0 且 itemData.errMsg 存在,则追加失败原因。这样正常数据看价格,异常数据看原因,定位问题更直接。
- formatter: function(params) {
- const data = params[0]
- const itemData = sortedData[data.dataIndex]
- const price = parseFloat(data.value)
- let queryTimeStr = ''
- if (itemData && itemData.queryTime) {
- const queryTime = new Date(itemData.queryTime)
- const year = queryTime.getFullYear()
- const month = String(queryTime.getMonth() + 1).padStart(2, '0')
- const day = String(queryTime.getDate()).padStart(2, '0')
- const hour = String(queryTime.getHours()).padStart(2, '0')
- const minute = String(queryTime.getMinutes()).padStart(2, '0')
- const second = String(queryTime.getSeconds()).padStart(2, '0')
- queryTimeStr = `<br/>查询时间: ${year}-${month}-${day} ${hour}:${minute}:${second}`
- }
- if (price === 0 && itemData && itemData.errMsg) {
- return `${data.axisValue}<br/>预估采购价: ¥${data.value}${queryTimeStr}<br/>失败原因: ${itemData.errMsg}`
- }
- return `${data.axisValue}<br/>预估采购价: ¥${data.value}${queryTimeStr}`
- }
复制代码
图表配置与视觉增强
折线图配置中 xAxis 为 category、boundaryGap: false,axisLabel 旋转 45 度并设置 interval: 'auto'、fontSize: 10;yAxis 为 value,name 为价格(¥),min/max 使用动态值,axisLabel formatter 为 '¥{value}';grid 使用 containLabel: true,left 3%、right 4%、bottom 15%。series 设置 smooth: true 让曲线平滑,itemStyle 颜色 #409EFF,lineStyle 宽度 2,areaStyle 使用 LinearGradient 从 rgba(64, 158, 255, 0.5) 过渡到 rgba(64, 158, 255, 0.1),markPoint 标记最高价和最低价,markLine 标记平均值。
- series: [{
- name: '预估采购价',
- type: 'line',
- smooth: true,
- data: prices,
- itemStyle: { color: '#409EFF' },
- lineStyle: { width: 2 },
- areaStyle: {
- color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
- { offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
- { offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
- ])
- },
- markPoint: { data: [{ type: 'max', name: '最高价' }, { type: 'min', name: '最低价' }] },
- markLine: { data: [{ type: 'average', name: '平均值' }] }
- }]
复制代码
Mock 数据、resize 与稳定性细节
Mock 模式用 setTimeout 500ms 模拟网络延迟,基础价格 basePrice 为 150 + Math.random() * 50,即 150 到 200;波动 fluctuation 为 (Math.random() - 0.5) * 30,即上下约 15,再叠加 (Math.random() - 0.5) * 10 的额外波动。生成过去 7 天数据,每天按 hour 为 0、6、12、18 生成 4 个时间点,queryTime 用 toISOString,totalCost 用 Number(price.toFixed(2))。这适合后端接口未完成时调试 UI 和交互。
- const basePrice = 150 + Math.random() * 50
- const fluctuation = (Math.random() - 0.5) * 30
- const price = basePrice + fluctuation + (Math.random() - 0.5) * 10
复制代码
渲染完成后原文在 this.$nextTick 中调用 this.chart.resize(),再用 setTimeout 延迟 200ms 再次 resize。原因是 Dialog 动画和宽度变化可能让首次尺寸不准确,多次调用可防止图表错位。排查图表空白或尺寸异常时,可按顺序检查:初始化是否在弹窗动画完成后执行、容器宽高是否可用、是否重复创建实例、resize 是否在 nextTick 和延迟后触发、关闭时是否 dispose。若出现内存泄漏或多实例叠加,优先检查 disposeChart 是否在 beforeDestroy 和弹窗 close 中执行。
适用场景与可扩展方向
这套实现适合后台管理系统中按小时聚合的价格趋势、指标趋势和异常记录展示,重点是前端数据建模与工程细节,而不是单纯堆 ECharts 配置。原文列出的扩展方向包括时间范围筛选(7 天/30 天)、多商品对比趋势、dataZoom 数据缩放、异常点红点高亮以及 WebSocket 实时刷新。落地时建议保持“时间归一、同段保留最新、时间线排序、异常原因可解释”这四个约束,否则趋势图容易在数据层失真。 |