Compare commits

...

7 Commits

Author SHA1 Message Date
Guwan 6719fe8600 稳定提交 2025-03-26 22:06:39 +08:00
Guwan d16e99c63c 稳定提交 2025-03-26 21:58:18 +08:00
Guwan aafd49217c 稳定提交 2025-03-26 21:46:18 +08:00
Guwan 226e4d0020 稳定提交 2025-03-26 21:42:55 +08:00
Guwan 9839a4421f 稳定提交 2025-03-26 21:41:30 +08:00
Guwan 0b783c70c3 稳定提交 2025-03-26 21:39:01 +08:00
Guwan 873563de73 稳定提交 2025-03-26 21:36:37 +08:00
3 changed files with 521 additions and 375 deletions

View File

@ -4,197 +4,294 @@
<div v-if="loading" class="loading-mask">
<div class="loading-spinner"></div>
</div>
<div
ref="chartRef"
:style="{
width: width,
<div
ref="chartRef"
:style="{
width: width,
height: height,
minHeight: '100px'
}"
}"
class="echarts-container"
/>
</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'
// 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]
}
}
},
grid: {
right: '3%',
left: '3%',
bottom: '3%',
top: '3%',
containLabel: true
},
animation: true,
animationDuration: props.animationDuration
result += `${param.marker}${param.seriesName}: ${param.value}${unit}<br/>`
})
return result
}
// 线
if (props.chartType === 'line' && options.series) {
options.series = options.series.map(series => ({
...series,
smooth: props.smooth
}))
},
legend: {
data: series.map(s => s.name)
},
xAxis: {
type: 'category',
data: categories,
axisLabel: {
interval: 0,
rotate: categories.length > 5 ? 30 : 0
}
return {
...defaultOptions,
...options
}
}
//
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)
}
//
const updateChart = () => {
if (!chartInstance) return
const processedOptions = processOptions(props.options)
chartInstance.setOption(processedOptions)
}
//
const resizeChart = debounce(() => {
if (!chartInstance) return
chartInstance.resize()
}, 100)
//
watch(
() => props.options,
() => 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
}
})
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>
@ -233,4 +330,4 @@ export default {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</style>

View File

@ -52,28 +52,32 @@
<!-- 分页区域 -->
<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"
<!-- 图表区域 -->
<div class="chart-section">
<ECharts
:data="salesData"
:config="chartConfig"
title="销量对比"
height="300px"
chartType="bar"
:loading="loading"
/>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import ECharts from '@/components/ECharts/index.vue'
import { useSalesChart } from './composables/useSalesChart'
const value = ref('')
const options = [
@ -229,234 +233,170 @@ const getLeafIds = (node, visited) => {
return ids;
};
//
const chartData = ref({
categories: ['手机', '电脑', '平板', '耳机', '手表'],
//
const { salesData, chartConfig, loading } = useSalesChart()
// 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: '上月销量',
name: '新增用户',
field: 'newUsers',
type: 'bar',
data: [120, 200, 150, 80, 70],
color: '#409EFF',
showLabel: true,
labelFormatter: '{c}台'
},
{
name: '本月销量',
type: 'bar',
data: [140, 180, 180, 120, 90],
color: '#67C23A',
showLabel: true,
labelFormatter: '{c}台'
labelFormatter: '{c}人'
},
{
name: '环比增长',
name: '活跃用户',
field: 'activeUsers',
type: 'line',
color: '#409EFF',
showLabel: true,
yAxisIndex: 1,
data: [16.7, -10, 20, 50, 28.6],
labelFormatter: '{c}'
},
{
name: '留存率',
field: 'retention',
type: 'line',
color: '#E6A23C',
showLabel: true,
yAxisIndex: 2,
labelFormatter: '{c}%'
}
],
yAxis: [
{
name: '销量',
min: 0,
nameTextStyle: {
padding: [0, 0, 0, 50]
}
name: '新增用户',
min: 0
},
{
name: '增长率',
name: '活跃用户',
min: 0,
position: 'right'
},
{
name: '留存率',
min: 80,
max: 100,
axisLabel: '{value}%',
nameTextStyle: {
padding: [0, 0, 0, 50]
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>
.test-center {
padding: 16px;
background-color: #f5f7fa;
min-height: 100vh;
.search-section {
background-color: #fff;
.test-center {
padding: 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
}
background-color: #f5f7fa;
min-height: 100vh;
.operation-section {
background-color: #fff;
padding: 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
.search-section {
background-color: #fff;
padding: 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
}
.left-area {
.operation-section {
background-color: #fff;
padding: 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
.left-area {
display: flex;
gap: 12px;
align-items: center;
}
.right-area {
display: flex;
gap: 8px;
}
}
.right-area {
.table-section {
background-color: #fff;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
:deep(.el-table) {
//
--el-table-border: none;
// padding
--el-table-row-height: 45px;
}
}
.pagination-section {
background-color: #fff;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
display: flex;
gap: 8px;
justify-content: center;
}
.chart-section {
background-color: #fff;
padding: 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
}
}
.table-section {
background-color: #fff;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
margin-bottom: 12px;
:deep(.el-table) {
//
--el-table-border: none;
// padding
--el-table-row-height: 45px;
}
}
.pagination-section {
background-color: #fff;
padding: 12px 16px;
border-radius: 4px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.05);
display: flex;
justify-content: center;
}
.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>

View File

@ -0,0 +1,109 @@
import { ref, onMounted } from 'vue'
export function useSalesChart() {
// 销量对比数据
const salesData = ref([])
const loading = ref(true) // 添加 loading 状态
// 模拟异步数据加载
const fetchData = async () => {
loading.value = true
try {
// 模拟 API 延迟
await new Promise(resolve => setTimeout(resolve, 1500))
salesData.value = [
{
productName: '手机',
lastMonthSales: 120,
currentMonthSales: 140,
growthRate: 16.7
},
{
productName: '耳机',
lastMonthSales: 120,
currentMonthSales: 140,
growthRate: 16.7
},
{
productName: '手机',
lastMonthSales: 120,
currentMonthSales: 140,
growthRate: 16.7
},
{
productName: '手机',
lastMonthSales: 120,
currentMonthSales: 140,
growthRate: 16.7
},
// ... 其他数据
]
} finally {
loading.value = false
}
}
// 组件挂载时加载数据
onMounted(() => {
fetchData()
})
// 图表配置
const chartConfig = {
xField: 'productName',
series: [
{
name: '上月销量',
field: 'lastMonthSales',
type: 'bar',
color: '#409EFF',
showLabel: true,
labelFormatter: '{c}台',
show: true,
},
{
name: '本月销量',
field: 'currentMonthSales',
type: 'bar',
color: '#67C23A',
showLabel: true,
labelFormatter: '{c}台',
show: true,
tooltip: {
show: false
}
},
{
name: '环比增长',
field: 'growthRate',
type: 'line',
yAxisIndex: 1,
color: '#E6A23C',
showLabel: true,
labelFormatter: '{c}%',
tooltip: {
show: true
}
}
],
yAxis: [
{
name: '销量',
min: 0
},
{
name: '增长率',
min: 0,
max: 100,
axisLabel: '{value}%'
}
]
}
return {
salesData,
chartConfig,
loading
}
}