稳定提交

This commit is contained in:
Guwan 2025-03-26 21:36:37 +08:00
parent 431888797d
commit 873563de73
2 changed files with 433 additions and 298 deletions

View File

@ -17,184 +17,273 @@
</div>
</template>
<script>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
<script setup>
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import * as echarts from 'echarts'
import debounce from 'lodash/debounce'
export default {
name: 'EChartsComponent',
props: {
//
options: {
type: Object,
required: true
},
//
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)
}
// Props
const props = defineProps({
//
data: {
type: Array,
required: true
},
emits: ['chartReady', 'click', 'legendselectchanged'],
setup(props, { emit }) {
const chartRef = ref(null)
let chartInstance = null
//
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
}
})
//
const processOptions = (options) => {
const defaultOptions = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
grid: {
right: '3%',
left: '3%',
bottom: '3%',
top: '3%',
containLabel: true
},
animation: true,
animationDuration: props.animationDuration
}
// Emits
const emit = defineEmits(['chartReady', 'click', 'legendselectchanged'])
// 线
if (props.chartType === 'line' && options.series) {
options.series = options.series.map(series => ({
...series,
smooth: props.smooth
}))
}
const chartRef = ref(null)
let chartInstance = null
return {
...defaultOptions,
...options
}
//
const defaultConfig = {
xField: 'name',
series: [
{
name: '数值',
field: 'value',
type: 'bar'
}
//
const initChart = () => {
if (!chartRef.value) return
chartInstance = echarts.init(chartRef.value, props.theme)
//
chartInstance.on('click', (params) => {
emit('click', params)
})
chartInstance.on('legendselectchanged', (params) => {
emit('legendselectchanged', params)
})
const processedOptions = processOptions(props.options)
chartInstance.setOption(processedOptions)
emit('chartReady', chartInstance)
],
yAxis: [
{
name: '',
min: 0
}
]
}
//
const updateChart = () => {
if (!chartInstance) return
const processedOptions = processOptions(props.options)
chartInstance.setOption(processedOptions)
}
//
const mergedConfig = computed(() => ({
...defaultConfig,
...props.config
}))
//
const resizeChart = debounce(() => {
if (!chartInstance) return
chartInstance.resize()
}, 100)
//
watch(
() => props.options,
() => updateChart(),
{ deep: true }
)
//
watch(
() => props.theme,
() => {
if (chartInstance) {
chartInstance.dispose()
}
initChart()
//
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,
tooltip: s.tooltip, // tooltip
emphasis: {
focus: 'series'
}
)
}))
//
watch(
() => props.loading,
(val) => {
if (chartInstance) {
val ? chartInstance.showLoading() : chartInstance.hideLoading()
}
return {
title: props.title ? {
text: props.title
} : null,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: (params) => {
// tooltip
const validParams = params.filter(param =>
param.seriesModel.option.tooltip?.show !== false
)
if (validParams.length === 0) return ''
let result = `${validParams[0].axisValue}<br/>`
validParams.forEach(param => {
const unit = param.seriesName.includes('率') ? '%' :
param.seriesName.includes('量') ? '台' : ''
result += `${param.marker}${param.seriesName}: ${param.value}${unit}<br/>`
})
return result
}
)
onMounted(() => {
initChart()
if (props.autoResize) {
window.addEventListener('resize', resizeChart)
},
legend: {
data: series.map(s => s.name)
},
xAxis: {
type: 'category',
data: categories,
axisLabel: {
interval: 0,
rotate: categories.length > 5 ? 30 : 0
}
})
onBeforeUnmount(() => {
if (props.autoResize) {
window.removeEventListener('resize', resizeChart)
}
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
return {
chartRef
},
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>

View File

@ -52,21 +52,44 @@
<!-- 分页区域 -->
<div class="pagination-section">
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="1000"
:page-sizes="[10, 20, 50, 100]"
/>
</div>
<div class="chart-demo">
<!-- 柱状图示例 -->
<ECharts
:options="barChartOptions"
height="300px"
chartType="bar"
/>
<div class="charts-container">
<!-- 销量对比图表 -->
<div class="chart-demo">
<ECharts
:data="salesData"
:config="salesConfig"
title="销量对比"
height="300px"
/>
</div>
<!-- 用户增长趋势图 -->
<div class="chart-demo">
<ECharts
:data="userGrowthData"
:config="userGrowthConfig"
title="近12个月用户增长趋势"
height="300px"
/>
</div>
<!-- 市场份额饼图 -->
<div class="chart-demo">
<ECharts
:data="marketShareData"
:config="marketShareConfig"
title="各品类市场份额"
height="300px"
/>
</div>
</div>
</div>
</template>
@ -229,166 +252,183 @@ const getLeafIds = (node, visited) => {
return ids;
};
//
const chartData = ref({
categories: ['手机', '电脑', '平板', '耳机', '手表'],
// 1.
const salesData = ref([
{
productName: '手机',
lastMonthSales: 120,
currentMonthSales: 140,
growthRate: 16.7
},
{
productName: '电脑',
lastMonthSales: 200,
currentMonthSales: 180,
growthRate: -10
},
{
productName: '平板',
lastMonthSales: 150,
currentMonthSales: 180,
growthRate: 20
},
{
productName: '耳机',
lastMonthSales: 80,
currentMonthSales: 120,
growthRate: 50
},
{
productName: '手表',
lastMonthSales: 70,
currentMonthSales: 90,
growthRate: 28.6
}
])
//
const salesConfig = {
xField: 'productName',
series: [
{
name: '上月销量',
field: 'lastMonthSales',
type: 'bar',
data: [120, 200, 150, 80, 70],
color: '#409EFF',
showLabel: true,
labelFormatter: '{c}台'
labelFormatter: '{c}台',
show: true,
},
{
name: '本月销量',
field: 'currentMonthSales',
type: 'bar',
data: [140, 180, 180, 120, 90],
color: '#67C23A',
showLabel: true,
labelFormatter: '{c}台'
labelFormatter: '{c}台',
show: false,
},
{
name: '环比增长',
field: 'growthRate',
type: 'line',
yAxisIndex: 1,
data: [16.7, -10, 20, 50, 28.6],
color: '#E6A23C',
showLabel: true,
labelFormatter: '{c}%'
labelFormatter: '{c}%',
tooltip: {
show: false
}
}
],
yAxis: [
{
name: '销量',
min: 0,
nameTextStyle: {
padding: [0, 0, 0, 50]
}
min: 0
},
{
name: '增长率',
min: 0,
max: 100,
axisLabel: '{value}%',
nameTextStyle: {
padding: [0, 0, 0, 50]
axisLabel: '{value}%'
}
]
}
// 2.
const userGrowthData = ref([
{ month: '2023-04', newUsers: 1500, activeUsers: 12000, retention: 85 },
{ month: '2023-05', newUsers: 1800, activeUsers: 13200, retention: 87 },
{ month: '2023-06', newUsers: 2200, activeUsers: 14800, retention: 88 },
{ month: '2023-07', newUsers: 2100, activeUsers: 15900, retention: 86 },
{ month: '2023-08', newUsers: 2600, activeUsers: 17200, retention: 89 },
{ month: '2023-09', newUsers: 2800, activeUsers: 18900, retention: 90 },
{ month: '2023-10', newUsers: 3100, activeUsers: 21000, retention: 91 },
{ month: '2023-11', newUsers: 3400, activeUsers: 23500, retention: 89 },
{ month: '2023-12', newUsers: 3800, activeUsers: 26000, retention: 92 },
{ month: '2024-01', newUsers: 4200, activeUsers: 29000, retention: 93 },
{ month: '2024-02', newUsers: 4500, activeUsers: 32000, retention: 91 },
{ month: '2024-03', newUsers: 4800, activeUsers: 35000, retention: 92 }
])
//
const userGrowthConfig = {
xField: 'month',
series: [
{
name: '新增用户',
field: 'newUsers',
type: 'bar',
color: '#67C23A',
showLabel: true,
labelFormatter: '{c}'
},
{
name: '活跃用户',
field: 'activeUsers',
type: 'line',
color: '#409EFF',
showLabel: true,
yAxisIndex: 1,
labelFormatter: '{c}'
},
{
name: '留存率',
field: 'retention',
type: 'line',
color: '#E6A23C',
showLabel: true,
yAxisIndex: 2,
labelFormatter: '{c}%'
}
],
yAxis: [
{
name: '新增用户',
min: 0
},
{
name: '活跃用户',
min: 0,
position: 'right'
},
{
name: '留存率',
min: 80,
max: 100,
position: 'right',
offset: 80,
axisLabel: '{value}%'
}
]
}
// 3.
const marketShareData = ref([
{ category: '手机', value: 35, lastYear: 32 },
{ category: '电脑', value: 25, lastYear: 28 },
{ category: '平板', value: 20, lastYear: 18 },
{ category: '智能手表', value: 12, lastYear: 10 },
{ category: '耳机', value: 8, lastYear: 12 }
])
//
const marketShareConfig = {
series: [
{
type: 'pie',
field: 'value',
name: '市场份额',
radius: ['50%', '70%'],
center: ['50%', '50%'],
showLabel: true,
labelFormatter: '{b}: {c}%',
itemStyle: {
borderRadius: 6
}
}
]
})
//
const generateChartOptions = (chartData) => {
const barSeries = chartData.series.filter(item => item.type === 'bar')
const lineSeries = chartData.series.filter(item => item.type === 'line')
return {
title: {
text: '各类商品销量对比'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
},
formatter: function(params) {
let result = `${params[0].axisValue}<br/>`;
//
const currentValue = params.find(p => p.seriesName === '本月销量')?.value || 0;
const lastValue = params.find(p => p.seriesName === '上月销量')?.value || 0;
const growth = params.find(p => p.seriesName === '环比增长')?.value || 0;
//
params.forEach(param => {
const marker = param.marker;
const seriesName = param.seriesName;
const value = param.value;
const unit = param.seriesName.includes('增长') ? '%' : '台';
result += `${marker}${seriesName}: ${value}${unit}<br/>`;
});
//
if (currentValue && lastValue) {
const diff = currentValue - lastValue;
result += '<br/>';
result += `<span style="color: #666">销量差额: ${diff > 0 ? '+' : ''}${diff}台</span><br/>`;
result += `<span style="color: ${growth >= 0 ? '#67C23A' : '#F56C6C'}">环比: ${growth}%</span>`;
}
return result;
}
},
legend: {
data: chartData.series.map(item => item.name),
selected: chartData.series.reduce((acc, item) => {
acc[item.name] = true;
return acc;
}, {})
},
xAxis: {
type: 'category',
data: chartData.categories,
axisLabel: {
interval: 0,
rotate: chartData.categories.length > 5 ? 30 : 0
}
},
yAxis: chartData.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: chartData.series.map(item => ({
name: item.name,
type: item.type,
yAxisIndex: item.yAxisIndex || 0,
data: item.data,
barMaxWidth: 50, //
barGap: '30%', //
itemStyle: {
color: item.color,
borderRadius: item.type === 'bar' ? [4, 4, 0, 0] : 0
},
label: item.showLabel ? {
show: true,
position: item.type === 'line' ? 'top' : 'inside',
formatter: item.labelFormatter,
fontSize: 12,
color: item.type === 'line' ? item.color : '#fff'
} : undefined,
emphasis: {
focus: 'series',
label: {
show: true,
position: item.type === 'line' ? 'top' : 'inside',
formatter: item.labelFormatter || '{c}',
fontSize: 12,
color: item.type === 'line' ? item.color : '#fff'
}
}
})),
grid: {
top: '15%',
bottom: '15%',
left: '10%',
right: '10%',
containLabel: true
}
}
}
//
const barChartOptions = computed(() => generateChartOptions(chartData.value))
</script>
<style lang="scss" scoped>
@ -451,12 +491,18 @@ const barChartOptions = computed(() => generateChartOptions(chartData.value))
justify-content: center;
}
.charts-container {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
margin-top: 16px;
}
.chart-demo {
background-color: #fff;
padding: 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
}
}
</style>