小体积远距蓝牙模组 LE6626S,智能家居 / 工业传感一站式无线解决方案
2026/8/1 8:46:01
效果如图:
悬浮X轴刻度显示tooltip,echarts版本5.4.2
关键代码是:
// 绑定柱状图的鼠标悬浮事件 _bindChartHoverEvents() { if (!this.chartInstance) return this.chartInstance.on('mouseover', (params) => { if (params.componentType === 'xAxis' && params.targetType === 'axisLabel') { this.chartInstance.setOption({ tooltip: { triggerOn: 'none' } }) this.chartInstance.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: params.dataIndex }) } }) this.chartInstance.on('mouseout', (params) => { if (params.componentType === 'xAxis') { this.chartInstance.dispatchAction({ type: 'hideTip' }) this.chartInstance.setOption({ tooltip: { triggerOn: 'mousemove|click' } }) } }) },完整代码:
<template> <div class="bar-chart-container" ref="bar-chart" v-loading="loading"></div> </template> <script> import echarts from "@/plugin/echarts" export default { name: 'BarChart', props: { // 加载状态 loading: { type: Boolean, default: false }, // 目标条件数据 targetData: { type: Array, default: () => [] }, // 对比项数据列表 comparisonDataList: { type: Array, default: () => [] }, // 目标条件参数 targetCondition: { type: Object, default: () => ({}) }, // 对比项列表 comparisonList: { type: Array, default: () => [] }, // 目标条件颜色 targetColor: { type: String, default: '#ff0000' }, // 可用颜色列表 availableColors: { type: Array, default: () => [] }, // 枚举映射配置 enumMaps: { type: Object, default: () => ({}) }, // 国家列表 countryList: { type: Array, default: () => [] }, // 渠道选项列表 channelOptions: { type: Array, default: () => [] }, // 渠道多选哨兵值 channelAllMultiUi: { type: String, default: '__POPO_DATA_ANALYSIS_ALL_CHANNELS__' }, // 目标条件的指标数据 targetMetrics: { type: Object, default: () => ({ income: 0, payUser: 0, arppu: 0, arpu: 0, roi: 0 }) }, // 对比项的指标数据列表 comparisonMetrics: { type: Array, default: () => [] } }, data() { return { chartInstance: null } }, watch: { // 监听数据变化,重新绘制图表 targetData: { handler() { this.drawChart() }, deep: true }, comparisonDataList: { handler() { this.drawChart() }, deep: true }, targetMetrics: { handler() { this.drawChart() }, deep: true }, comparisonMetrics: { handler() { this.drawChart() }, deep: true } }, mounted() { // 延迟初始化,确保容器完全渲染且可见 this.$nextTick(() => { this.initChart() this.drawChart() }) // 添加窗口 resize 监听 window.addEventListener('resize', this.handleResize) }, beforeDestroy() { window.removeEventListener('resize', this.handleResize) if (this.chartInstance) { this.chartInstance.dispose() this.chartInstance = null } }, methods: { // 格式化人数显示(大于1000显示为1K) formatPersonCount(num) { const number = Number(num) if (isNaN(number)) return '0' if (number >= 1000) { return (number / 1000).toFixed(1).replace(/\.0$/, '') + 'K' } return String(Math.round(number)) }, // 根据 countryIds 获取名称列表 getCountryNames(countryIds) { if (!countryIds || !Array.isArray(countryIds) || countryIds.length === 0) { return [] } const names = [] countryIds.forEach(id => { const country = this.countryList.find(c => c.countryId === id) if (country) { const displayName = country.memo names.push(displayName) } }) return names }, // 格式化渠道显示文本(统一处理逻辑) formatChannelsText(channels) { if (!channels || !Array.isArray(channels) || channels.length === 0) { return '渠道:所有渠道' } // 过滤掉哨兵值 const realChannels = channels.filter(v => v !== this.channelAllMultiUi) if (realChannels.length === 0) { return '渠道:所有渠道' } // 查找渠道名称 const channelLabels = realChannels.map(channelValue => { const channel = this.channelOptions.find(opt => opt.value === channelValue) return channel ? channel.label : channelValue }) return `渠道:${channelLabels.join(', ')}` }, // 获取条件的各项显示文本(公共方法) getConditionDisplayTexts(conditionData) { if (!conditionData) { return { channelText: '', platformLabel: '', dbTypeLabel: '', countryText: '' } } // 获取渠道文本 const channelText = this.formatChannelsText(conditionData.channels).replace('渠道:', '') // 获取注册平台文本 const platform = conditionData.platform !== undefined ? conditionData.platform : '' const platformLabel = this.enumMaps.platform?.[platform] || '全部平台' // 获取数据库类型文本 const dbTypeLabel = this.enumMaps.dbType?.[conditionData.dbType] || '使用ES' // 获取详细数据文本 - 显示国家名称 let countryText = '全部' if (conditionData.countryIds && conditionData.countryIds.length > 0) { const names = this.getCountryNames(conditionData.countryIds) if (names.length > 0) { if (names.length <= 6) { countryText = names.join(', ') } else { countryText = names.slice(0, 6).join(', ') + '...' } } else { countryText = '全部' } } return { channelText, platformLabel, dbTypeLabel, countryText } }, // 构建完整的条件文本(公共方法) formatConditionText(condition, prefix = '') { const { channelText, platformLabel, dbTypeLabel, countryText } = this.getConditionDisplayTexts(condition) let conditionText = prefix if (condition.begin && condition.end) { conditionText += `${condition.begin} ~ ${condition.end} ` } else if (condition.dateRange && condition.dateRange.length === 2) { conditionText += `${condition.dateRange[0]} ~ ${condition.dateRange[1]} ` } conditionText += `渠道: ${channelText} ` conditionText += `注册平台: ${platformLabel} ` conditionText += `数据库: ${dbTypeLabel} ` conditionText += `详细数据:${countryText}` return conditionText }, // 初始化图表实例 initChart() { const chartDom = this.$refs['bar-chart'] if (chartDom) { if (this.chartInstance) { this.chartInstance.dispose() } this.chartInstance = echarts.init(chartDom) this._bindChartHoverEvents() } }, // 绑定柱状图的鼠标悬浮事件 _bindChartHoverEvents() { if (!this.chartInstance) return this.chartInstance.on('mouseover', (params) => { if (params.componentType === 'xAxis' && params.targetType === 'axisLabel') { this.chartInstance.setOption({ tooltip: { triggerOn: 'none' } }) this.chartInstance.dispatchAction({ type: 'showTip', seriesIndex: 0, dataIndex: params.dataIndex }) } }) this.chartInstance.on('mouseout', (params) => { if (params.componentType === 'xAxis') { this.chartInstance.dispatchAction({ type: 'hideTip' }) this.chartInstance.setOption({ tooltip: { triggerOn: 'mousemove|click' } }) } }) }, // 窗口 resize 处理 handleResize() { if (this.chartInstance) { this.chartInstance.resize() } }, // 公共方法:重新调整图表大小并重绘 resize() { if (this.chartInstance) { this.chartInstance.resize() // 重新绘制以更新动态计算的宽度 this.drawChart() } }, // 绘制柱状图 async drawChart() { if (!this.chartInstance) return // 检查容器是否可见(宽度大于 0) const chartDom = this.$refs['bar-chart'] if (!chartDom || chartDom.clientWidth === 0) { // 图表容器不可见,跳过绘制 // console.warn('图表容器不可见,跳过绘制') return } // 柱状图检查指标数据是否存在 if (!this.targetMetrics) { console.warn('柱状图指标数据不存在,跳过绘制') return } // 调试日志:查看传入的数据 // console.log('柱状图绘制 - targetMetrics:', this.targetMetrics) // console.log('柱状图绘制 - comparisonMetrics:', this.comparisonMetrics) // console.log('柱状图绘制 - comparisonList:', this.comparisonList) try { // 构建所有条件列表:目标条件 + 对比项列表 const allConditions = [ { ...this.targetCondition, label: '目标条件', colorIndex: -1 }, ...this.comparisonList.map((item, index) => ({ ...item, begin: item.dateRange?.[0] || item.begin, end: item.dateRange?.[1] || item.end, label: `对比项${index + 1}`, colorIndex: item.colorIndex })) ] // 使用传入的指标数据 const allMetrics = [this.targetMetrics, ...this.comparisonMetrics] // 直接使用接口返回的汇总数据(不再自己计算) const summaryData = allConditions.map((condition, index) => { const metrics = allMetrics[index] || { income: 0, payUser: 0, arppu: 0, arpu: 0, roi: 0 } // 直接使用后端返回的字段 return { label: condition.label, condition: condition, income: Number(metrics.income || 0), // 充值金额 payUser: Number(metrics.payUser || 0), // 充值人数 arppu: Number(metrics.arppu || 0), // ARPPU arpu: Number(metrics.arpu || 0), // ARPU roi: Number(metrics.roi || 0) // ROI } }) // 准备X轴数据(条件标签,省略显示) const xAxisData = summaryData.map((_, index) => { if (index === 0) return '目标条件...' return `对比${index}...` }) // 准备各指标的数据 const incomeData = summaryData.map(item => item.income) const payUserData = summaryData.map(item => item.payUser) const arppuData = summaryData.map(item => item.arppu) const arpuData = summaryData.map(item => item.arpu) const roiData = summaryData.map(item => item.roi) // 动态计算柱宽与类目间距 const SERIES_COUNT = 5 const FIXED_GROUP_GAP = 20 const RESERVED_LEFT = 60 const RESERVED_RIGHT = 260 const containerW = (this.$refs['bar-chart'] && this.$refs['bar-chart'].clientWidth) || 1200 const usableW = Math.max(200, containerW - RESERVED_LEFT - RESERVED_RIGHT) const categoryW = usableW / summaryData.length const rawBarW = Math.floor((categoryW - FIXED_GROUP_GAP) / SERIES_COUNT) const barWidth = Math.max(4, Math.min(30, rawBarW)) const barCategoryGap = Math.min(80, Math.max(10, Math.round((FIXED_GROUP_GAP / categoryW) * 100))) + '%' // 统一的颜色配置 const COLORS = { recharge: '#f00', person: '#ff9903', arppu: '#67C23A', arpu: '#409EFF', roi: '#9B59B6' } const option = { color: [COLORS.recharge, COLORS.person, COLORS.arppu, COLORS.arpu, COLORS.roi], legend: { data: ['充值金额', '充值人数', 'ARPPU', 'ARPU', 'ROI'], top: 10, textStyle: { fontSize: 12 } }, tooltip: { trigger: 'axis', triggerOn: 'mousemove|click', axisPointer: { type: 'shadow', label: { show: true, backgroundColor: 'rgba(50, 50, 50, 0.8)', borderColor: 'unset', borderWidth: 1, color: '#fff', formatter: (params) => { const seriesData = params.seriesData if (!seriesData || seriesData.length === 0) return params.value const dataIndex = seriesData[0].dataIndex const summary = summaryData[dataIndex] if (!summary) return params.value const condition = summary.condition return this.formatConditionText(condition, `${summary.label} `).replace(/:/g, ':') } } }, backgroundColor: 'rgba(255, 255, 255, 0.95)', borderColor: '#ddd', borderWidth: 1, textStyle: { color: '#333' }, formatter: (params) => { if (!params || params.length === 0) return '' const dataIndex = params[0].dataIndex const summary = summaryData[dataIndex] const condition = summary.condition const conditionText = this.formatConditionText(condition) let result = `<div style="padding: 10px; min-width: 350px; max-width: 620px; word-wrap: break-word;"> <div style="margin-bottom: 10px; font-size: 12px; color: #666; line-height: 1.6; word-wrap: break-word; white-space: normal; word-break: break-all;"> ${summary.label} ${conditionText} </div><div style="border-top: 1px solid #eee; padding-top: 8px;">` params.forEach((item) => { if (item.data === null || item.data === undefined) return let valueText = item.data if (item.seriesName === 'ROI') { valueText = `${item.data}%` } else if (['充值金额', 'ARPPU', 'ARPU'].includes(item.seriesName)) { valueText = `${item.data} USD` } result += `<div style="display: flex; align-items: center; margin-bottom: 6px;"> <span style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: ${item.color}; margin-right: 8px;"></span> <span>${item.seriesName}: ${valueText}</span> </div>` }) result += '</div></div>' return result } }, grid: { left: '0px', right: '100px', bottom: '3%', containLabel: true }, xAxis: { type: 'category', data: xAxisData, triggerEvent: true, barCategoryGap, axisPointer: { type: 'shadow', label: { show: true } }, axisLabel: { interval: 0, rotate: 0, color: (_, index) => { if (index === 0) { return this.targetColor } else { const compIndex = index - 1 if (compIndex < this.comparisonList.length) { return this.availableColors[this.comparisonList[compIndex].colorIndex] } return '#333' } }, fontSize: 12, fontWeight: 'bold' } }, yAxis: [ { type: 'value', name: '金额($)', position: 'left', nameTextStyle: { color: COLORS.recharge }, axisLine: { show: true, lineStyle: { color: COLORS.recharge } }, axisLabel: { color: COLORS.recharge }, splitLine: { show: true } }, { type: 'value', name: '人数', position: 'right', offset: 0, nameTextStyle: { color: COLORS.person }, axisLine: { show: true, lineStyle: { color: COLORS.person } }, axisLabel: { formatter: (value) => this.formatPersonCount(value), color: COLORS.person }, splitLine: { show: false } }, { type: 'value', name: 'ARPPU', position: 'right', offset: 55, nameTextStyle: { color: COLORS.arppu }, axisLine: { show: true, lineStyle: { color: COLORS.arppu } }, axisLabel: { color: COLORS.arppu }, splitLine: { show: false } }, { type: 'value', name: 'ARPU', position: 'right', offset: 110, nameTextStyle: { color: COLORS.arpu }, axisLine: { show: true, lineStyle: { color: COLORS.arpu } }, axisLabel: { color: COLORS.arpu }, splitLine: { show: false } }, { type: 'value', name: 'ROI', position: 'right', offset: 165, nameTextStyle: { color: COLORS.roi }, axisLine: { show: true, lineStyle: { color: COLORS.roi } }, axisLabel: { color: COLORS.roi }, splitLine: { show: false } } ], series: [ { name: '充值金额', type: 'bar', data: incomeData, yAxisIndex: 0, barWidth, itemStyle: { color: COLORS.recharge }, label: { show: true, position: 'top', formatter: '{c}', fontSize: 10 } }, { name: '充值人数', type: 'bar', data: payUserData, yAxisIndex: 1, barWidth, itemStyle: { color: COLORS.person }, label: { show: true, position: 'top', formatter: (params) => this.formatPersonCount(params.value), fontSize: 10 } }, { name: 'ARPPU', type: 'bar', data: arppuData, yAxisIndex: 2, barWidth, itemStyle: { color: COLORS.arppu }, label: { show: true, position: 'top', formatter: '{c}', fontSize: 10 } }, { name: 'ARPU', type: 'bar', data: arpuData, yAxisIndex: 3, barWidth, itemStyle: { color: COLORS.arpu }, label: { show: true, position: 'top', formatter: '{c}', fontSize: 10 } }, { name: 'ROI', type: 'bar', data: roiData, yAxisIndex: 4, barWidth, itemStyle: { color: COLORS.roi }, label: { show: true, position: 'top', formatter: '{c}%', fontSize: 10 } } ] } this.chartInstance.setOption(option, true) } catch (error) { console.error('绘制柱状图失败:', error) } } } } </script> <style scoped lang="scss"> .bar-chart-container { min-height: 400px; height: 400px; width: 100%; margin-top: 10px; } </style>