frontend-oj/src/components/ECharts/index.vue

334 lines
6.6 KiB
Vue

<template>
<div class="echarts-wrapper">
<!-- 加载状态 -->
<div v-if="loading" class="loading-mask">
<div class="loading-spinner"></div>
</div>
<div
ref="chartRef"
:style="{
width: width,
height: height,
minHeight: '100px'
}"
class="echarts-container"
/>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import * as echarts from 'echarts'
import debounce from 'lodash/debounce'
// Props 定义
const props = defineProps({
// 图表数据
data: {
type: Array,
required: true
},
// 图表类型配置
config: {
type: Object,
default: () => ({})
},
// 标题
title: {
type: String,
default: ''
},
// 图表主题
theme: {
type: String,
default: ''
},
// 图表宽度
width: {
type: String,
default: '100%'
},
// 图表高度
height: {
type: String,
default: '400px'
},
// 是否自动调整大小
autoResize: {
type: Boolean,
default: true
},
// 加载状态
loading: {
type: Boolean,
default: false
},
// 动画时长
animationDuration: {
type: Number,
default: 1000
},
// 是否平滑曲线(用于折线图)
smooth: {
type: Boolean,
default: false
},
// 图表类型
chartType: {
type: String,
default: 'line',
validator: (value) => ['line', 'bar', 'pie', 'scatter'].includes(value)
},
// tooltip 显示的系列名称数组,为空则显示全部
tooltipSeries: {
type: Array,
default: () => []
},
// 自定义 tooltip 格式化函数
tooltipFormatter: {
type: Function,
default: null
}
})
// Emits 定义
const emit = defineEmits(['chartReady', 'click', 'legendselectchanged'])
const chartRef = ref(null)
let chartInstance = null
// 默认配置
const defaultConfig = {
xField: 'name',
series: [
{
name: '数值',
field: 'value',
type: 'bar'
}
],
yAxis: [
{
name: '',
min: 0
}
]
}
// 合并配置
const mergedConfig = computed(() => ({
...defaultConfig,
...props.config
}))
// 生成图表配置
const generateOptions = computed(() => {
if (!props.data?.length) return {}
const config = mergedConfig.value
const categories = props.data.map(item => item[config.xField])
// 处理系列数据
const series = config.series
.filter(s => s.show !== false) // 过滤掉 show: false 的系列
.map(s => ({
name: s.name,
type: s.type,
yAxisIndex: s.yAxisIndex || 0,
data: props.data.map(item => item[s.field]),
color: s.color,
barMaxWidth: 50,
barGap: '30%',
itemStyle: {
color: s.color,
borderRadius: s.type === 'bar' ? [4, 4, 0, 0] : 0
},
label: s.showLabel ? {
show: true,
position: s.type === 'line' ? 'top' : 'inside',
formatter: s.labelFormatter || '{c}',
fontSize: 12,
color: s.type === 'line' ? s.color : '#fff'
} : undefined,
emphasis: {
focus: 'series'
}
}))
return {
title: props.title ? {
text: props.title
} : null,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: (params) => {
// 找到原始配置中对应的系列配置
const validParams = params.filter(param => {
const seriesConfig = config.series.find(s => s.name === param.seriesName)
return seriesConfig && seriesConfig.tooltip?.show !== false
})
if (validParams.length === 0) return ''
let result = `${validParams[0].axisValue}<br/>`
validParams.forEach(param => {
// 找到对应的系列配置
const seriesConfig = config.series.find(s => s.name === param.seriesName)
// 提取 labelFormatter 中的单位
let unit = ''
if (seriesConfig?.labelFormatter) {
const match = seriesConfig.labelFormatter.match(/\{c\}(.+)/)
if (match) {
unit = match[1]
}
}
result += `${param.marker}${param.seriesName}: ${param.value}${unit}<br/>`
})
return result
}
},
legend: {
data: series.map(s => s.name)
},
xAxis: {
type: 'category',
data: categories,
axisLabel: {
interval: 0,
rotate: categories.length > 5 ? 30 : 0
}
},
yAxis: config.yAxis.map((axis, index) => ({
type: 'value',
name: axis.name,
min: axis.min,
max: axis.max,
position: index === 0 ? 'left' : 'right',
splitLine: {
show: index === 0
},
axisLabel: axis.axisLabel ? {
formatter: axis.axisLabel
} : undefined
})),
series,
grid: {
top: '15%',
bottom: '15%',
left: '10%',
right: '10%',
containLabel: true
}
}
})
// 初始化图表
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value, props.theme)
chartInstance.setOption(generateOptions.value)
emit('chartReady', chartInstance)
}
// 更新图表
const updateChart = () => {
if (!chartInstance) return
chartInstance.setOption(generateOptions.value)
}
// 调整图表大小
const resizeChart = debounce(() => {
if (!chartInstance) return
chartInstance.resize()
}, 100)
// 监听配置变化
watch(
() => [props.data, props.config],
() => updateChart(),
{ deep: true }
)
// 监听主题变化
watch(
() => props.theme,
() => {
if (chartInstance) {
chartInstance.dispose()
}
initChart()
}
)
// 监听加载状态
watch(
() => props.loading,
(val) => {
if (chartInstance) {
val ? chartInstance.showLoading() : chartInstance.hideLoading()
}
}
)
onMounted(() => {
initChart()
if (props.autoResize) {
window.addEventListener('resize', resizeChart)
}
})
onBeforeUnmount(() => {
if (props.autoResize) {
window.removeEventListener('resize', resizeChart)
}
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
</script>
<style scoped>
.echarts-wrapper {
position: relative;
width: 100%;
}
.echarts-container {
position: relative;
}
.loading-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>