查看: 187|回复: 0

Vue ECharts价格历史趋势图整点聚合去重与弹窗初始化优化

[复制链接]
发表于 1 小时前 | 显示全部楼层 |阅读模式
需求与实现结构
原文场景是后台数据分析系统里的商品价格历史趋势图:弹窗承载图表,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。
  1. watch: {
  2.   visible(newVal) {
  3.     if (newVal && this.storeGoodsId) {
  4.       setTimeout(() => {
  5.         this.$nextTick(() => {
  6.           this.initChart()
  7.         })
  8.       }, 300)
  9.     }
  10.   }
  11. },
  12. beforeDestroy() {
  13.   this.disposeChart()
  14. },
  15. methods: {
  16.   initChart() {
  17.     this.disposeChart()
  18.     this.chart = echarts.init(this.$refs.chartContainer)
  19.     this.chart.showLoading()
  20.     this.useMockData ? this.loadMockData() : this.loadRealData()
  21.   },
  22.   disposeChart() {
  23.     if (this.chart) {
  24.       this.chart.dispose()
  25.       this.chart = null
  26.     }
  27.   }
  28. }
复制代码

真实数据加载成功后先 hideLoading;返回空数组时提示“暂无历史数据”并返回;请求失败时 hideLoading 后提示“获取历史数据失败”,同时 console.error(err)。组件在 beforeDestroy 和弹窗 close 时销毁图表,避免内存泄漏和多实例叠加。

核心数据处理:向上取整、分组保留最新、排序
数据处理是这篇文章最值得复用的部分。第一步把每条记录的 queryTime 向上取整到整点:如果分钟或秒大于 0,就把小时加 1 并清零分秒;否则直接设为当前整点。这样可以统一时间粒度,避免分钟级噪声影响趋势可读性。第二步用 Map 按 roundedTime.getTime() 分组,同一整点只保留原始 queryTime 最大的一条记录,也就是该时间段最新数据。第三步按 roundedTime 升序排序,保证 X 轴时间线正确。
  1. const processedData = data.map(item => {
  2.   const queryTime = new Date(item.queryTime)
  3.   const roundedHour = new Date(queryTime)
  4.   if (queryTime.getMinutes() > 0 || queryTime.getSeconds() > 0) {
  5.     roundedHour.setHours(queryTime.getHours() + 1, 0, 0, 0)
  6.   } else {
  7.     roundedHour.setHours(queryTime.getHours(), 0, 0, 0)
  8.   }
  9.   return {
  10.     ...item,
  11.     roundedTime: roundedHour,
  12.     totalCost: item.totalCost ? Number(item.totalCost) : 0
  13.   }
  14. })
  15. const timeGroupMap = new Map()
  16. processedData.forEach(item => {
  17.   const timeKey = item.roundedTime.getTime()
  18.   if (!timeGroupMap.has(timeKey) || new Date(item.queryTime) > new Date(timeGroupMap.get(timeKey).queryTime)) {
  19.     timeGroupMap.set(timeKey, item)
  20.   }
  21. })
  22. const sortedData = Array.from(timeGroupMap.values())
  23.   .sort((a, b) => a.roundedTime - b.roundedTime)
  24. const times = sortedData.map(item => {
  25.   const date = item.roundedTime
  26.   const month = date.getMonth() + 1
  27.   const day = date.getDate()
  28.   const hour = String(date.getHours()).padStart(2, '0')
  29.   return `${month}/${day} ${hour}:00`
  30. })
  31. 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) 防止出现负价格。这样图表不会贴边,能适配不同价格区间。
  1. const validPrices = prices.filter(p => p > 0).map(Number)
  2. const minPrice = validPrices.length > 0 ? Math.min(...validPrices) : 0
  3. const maxPrice = validPrices.length > 0 ? Math.max(...validPrices) : 0
  4. const priceRange = maxPrice - minPrice
  5. const yAxisMin = Math.max(0, minPrice - priceRange * 0.1)
  6. const yAxisMax = maxPrice + priceRange * 0.1
复制代码

Tooltip 通过 trigger: 'axis' 触发,formatter 中取出 params[0],用 dataIndex 回查 sortedData,显示预估采购价和格式化后的查询时间。若 price === 0 且 itemData.errMsg 存在,则追加失败原因。这样正常数据看价格,异常数据看原因,定位问题更直接。
  1. formatter: function(params) {
  2.   const data = params[0]
  3.   const itemData = sortedData[data.dataIndex]
  4.   const price = parseFloat(data.value)
  5.   let queryTimeStr = ''
  6.   if (itemData && itemData.queryTime) {
  7.     const queryTime = new Date(itemData.queryTime)
  8.     const year = queryTime.getFullYear()
  9.     const month = String(queryTime.getMonth() + 1).padStart(2, '0')
  10.     const day = String(queryTime.getDate()).padStart(2, '0')
  11.     const hour = String(queryTime.getHours()).padStart(2, '0')
  12.     const minute = String(queryTime.getMinutes()).padStart(2, '0')
  13.     const second = String(queryTime.getSeconds()).padStart(2, '0')
  14.     queryTimeStr = `<br/>查询时间: ${year}-${month}-${day} ${hour}:${minute}:${second}`
  15.   }
  16.   if (price === 0 && itemData && itemData.errMsg) {
  17.     return `${data.axisValue}<br/>预估采购价: ¥${data.value}${queryTimeStr}<br/>失败原因: ${itemData.errMsg}`
  18.   }
  19.   return `${data.axisValue}<br/>预估采购价: ¥${data.value}${queryTimeStr}`
  20. }
复制代码

图表配置与视觉增强
折线图配置中 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 标记平均值。
  1. series: [{
  2.   name: '预估采购价',
  3.   type: 'line',
  4.   smooth: true,
  5.   data: prices,
  6.   itemStyle: { color: '#409EFF' },
  7.   lineStyle: { width: 2 },
  8.   areaStyle: {
  9.     color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
  10.       { offset: 0, color: 'rgba(64, 158, 255, 0.5)' },
  11.       { offset: 1, color: 'rgba(64, 158, 255, 0.1)' }
  12.     ])
  13.   },
  14.   markPoint: { data: [{ type: 'max', name: '最高价' }, { type: 'min', name: '最低价' }] },
  15.   markLine: { data: [{ type: 'average', name: '平均值' }] }
  16. }]
复制代码

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 和交互。
  1. const basePrice = 150 + Math.random() * 50
  2. const fluctuation = (Math.random() - 0.5) * 30
  3. 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 实时刷新。落地时建议保持“时间归一、同段保留最新、时间线排序、异常原因可解释”这四个约束,否则趋势图容易在数据层失真。
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-25 13:32 , Processed in 0.026585 second(s), 28 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部