488 lines
14 KiB
Vue
488 lines
14 KiB
Vue
|
|
<!-- SidePanelItem.vue -->
|
|||
|
|
<template>
|
|||
|
|
<SidePanelItem :title="title" :datetimePicker="datetimePicker">
|
|||
|
|
<div
|
|||
|
|
class="container"
|
|||
|
|
@mouseenter="handleMouseEnter"
|
|||
|
|
@mouseleave="handleMouseLeave"
|
|||
|
|
>
|
|||
|
|
<!-- 跑马灯轨道容器 -->
|
|||
|
|
<div
|
|||
|
|
class="carousel-track"
|
|||
|
|
:class="{ 'no-transition': isTransitioning }"
|
|||
|
|
:style="{ transform: `translateX(-${currentIndex * 100}%)` }"
|
|||
|
|
>
|
|||
|
|
<!-- 遍历所有图表项(包含克隆项) -->
|
|||
|
|
<div
|
|||
|
|
v-for="(item, index) in renderChartData"
|
|||
|
|
:key="index"
|
|||
|
|
class="carousel-item"
|
|||
|
|
>
|
|||
|
|
<div class="pie-chart-container">
|
|||
|
|
<!-- 图表区域 -->
|
|||
|
|
<div :ref="el => setChartRef(el, index)" class="chart"></div>
|
|||
|
|
<!-- 标题 -->
|
|||
|
|
<div class="title">{{ item.title }}</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</SidePanelItem>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script lang="ts" setup>
|
|||
|
|
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
|
|||
|
|
import * as echarts from 'echarts';
|
|||
|
|
import type { ECharts } from 'echarts';
|
|||
|
|
import SidePanelItem from '@/components/SidePanelItem/index.vue';
|
|||
|
|
|
|||
|
|
// 定义组件名(便于调试和递归)
|
|||
|
|
defineOptions({
|
|||
|
|
name: 'GYZLLB'
|
|||
|
|
});
|
|||
|
|
const props = defineProps({
|
|||
|
|
title: { // 标题
|
|||
|
|
type: String,
|
|||
|
|
default: '过鱼总量'
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const datetimePicker = ref({
|
|||
|
|
show: true,
|
|||
|
|
value: undefined,
|
|||
|
|
format: 'YYYY',
|
|||
|
|
picker: 'year' as const,
|
|||
|
|
options: []
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 图表相关
|
|||
|
|
interface ChartDataItem {
|
|||
|
|
title: string;
|
|||
|
|
data: Array<{ name: string; value: number }>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 原始图表数据(多组数据用于跑马灯)
|
|||
|
|
const originalChartData = ref<ChartDataItem[]>([
|
|||
|
|
{
|
|||
|
|
title: '过鱼总量统计 - 2025年',
|
|||
|
|
data: [
|
|||
|
|
{ name: '草鱼', value: 1250 },
|
|||
|
|
{ name: '鲢鱼', value: 980 },
|
|||
|
|
{ name: '鳙鱼', value: 760 },
|
|||
|
|
{ name: '青鱼', value: 540 },
|
|||
|
|
{ name: '鲤鱼', value: 430 },
|
|||
|
|
{ name: '鲫鱼', value: 320 },
|
|||
|
|
{ name: '其他', value: 280 }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '过鱼总量统计 - 2024年',
|
|||
|
|
data: [
|
|||
|
|
{ name: '草鱼1', value: 1250 },
|
|||
|
|
{ name: '鲢鱼1', value: 980 },
|
|||
|
|
{ name: '鳙鱼1', value: 760 },
|
|||
|
|
{ name: '青鱼1', value: 540 },
|
|||
|
|
{ name: '鲤鱼1', value: 430 },
|
|||
|
|
{ name: '鲫鱼1', value: 320 },
|
|||
|
|
{ name: '其他1', value: 280 }
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
title: '过鱼总量统计 - 2023年',
|
|||
|
|
data: [
|
|||
|
|
{ name: '草鱼2', value: 1250 },
|
|||
|
|
{ name: '鲢鱼2', value: 980 },
|
|||
|
|
{ name: '鳙鱼2', value: 760 },
|
|||
|
|
{ name: '青鱼2', value: 540 },
|
|||
|
|
{ name: '鲤鱼2', value: 430 },
|
|||
|
|
{ name: '鲫鱼2', value: 320 },
|
|||
|
|
{ name: '其他2', value: 280 }
|
|||
|
|
]
|
|||
|
|
}
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// 克隆首尾项后的渲染数组(用于无缝循环)
|
|||
|
|
const renderChartData = ref<ChartDataItem[]>([]);
|
|||
|
|
|
|||
|
|
// 当前显示索引(指向renderChartData)
|
|||
|
|
const currentIndex = ref(1); // 从1开始,跳过克隆的首项
|
|||
|
|
|
|||
|
|
// 定时器引用
|
|||
|
|
let timer: any = null;
|
|||
|
|
|
|||
|
|
// 鼠标悬停状态
|
|||
|
|
const isHovering = ref(false);
|
|||
|
|
|
|||
|
|
// 是否正在切换动画中(用于禁用transition)
|
|||
|
|
const isTransitioning = ref(false);
|
|||
|
|
|
|||
|
|
// 图表实例数组
|
|||
|
|
const chartInstances = ref<(ECharts | null)[]>([]);
|
|||
|
|
const chartRefs = ref<(HTMLElement | null)[]>([]);
|
|||
|
|
|
|||
|
|
// 设置图表ref
|
|||
|
|
const setChartRef = (el: any, index: number) => {
|
|||
|
|
if (el) {
|
|||
|
|
chartRefs.value[index] = el;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 单位配置(模拟函数)
|
|||
|
|
const getUnitConfigByCode = (code: string, type: string): { unit: string } => {
|
|||
|
|
if (type === 'FCNT') {
|
|||
|
|
return { unit: '尾' };
|
|||
|
|
} else if (type === 'SL') {
|
|||
|
|
return { unit: '万尾' };
|
|||
|
|
}
|
|||
|
|
return { unit: '尾' };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 生成随机颜色(根据名称)- 生成适中亮度颜色
|
|||
|
|
const generateRandomColor = (names: string[]): string[] => {
|
|||
|
|
const colors: string[] = [];
|
|||
|
|
|
|||
|
|
names.forEach(() => {
|
|||
|
|
// 生成 HSL 颜色,控制饱和度和亮度在适中范围
|
|||
|
|
// H: 0-360 (色相)
|
|||
|
|
// S: 40-70% (饱和度,避免过于鲜艳或灰暗)
|
|||
|
|
// L: 45-65% (亮度,避免过亮或过暗)
|
|||
|
|
const hue = Math.floor(Math.random() * 360);
|
|||
|
|
const saturation = 40 + Math.floor(Math.random() * 30); // 40-70%
|
|||
|
|
const lightness = 45 + Math.floor(Math.random() * 20); // 45-65%
|
|||
|
|
|
|||
|
|
// 将 HSL 转换为 HEX
|
|||
|
|
const h = hue / 360;
|
|||
|
|
const s = saturation / 100;
|
|||
|
|
const l = lightness / 100;
|
|||
|
|
|
|||
|
|
let r, g, b;
|
|||
|
|
|
|||
|
|
if (s === 0) {
|
|||
|
|
r = g = b = l;
|
|||
|
|
} else {
|
|||
|
|
const hue2rgb = (p: number, q: number, t: number) => {
|
|||
|
|
if (t < 0) t += 1;
|
|||
|
|
if (t > 1) t -= 1;
|
|||
|
|
if (t < 1/6) return p + (q - p) * 6 * t;
|
|||
|
|
if (t < 1/2) return q;
|
|||
|
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
|
|||
|
|
return p;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
|||
|
|
const p = 2 * l - q;
|
|||
|
|
|
|||
|
|
r = hue2rgb(p, q, h + 1/3);
|
|||
|
|
g = hue2rgb(p, q, h);
|
|||
|
|
b = hue2rgb(p, q, h - 1/3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const toHex = (x: number) => {
|
|||
|
|
const hex = Math.round(x * 255).toString(16);
|
|||
|
|
return hex.length === 1 ? '0' + hex : hex;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
colors.push(`#${toHex(r)}${toHex(g)}${toHex(b)}`);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return colors;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 计算总量
|
|||
|
|
const getTotalAmount = (data: Array<{ name: string; value: number }>) => {
|
|||
|
|
return data.reduce((sum, item) => sum + item.value, 0);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 获取单位
|
|||
|
|
const getUnit = () => {
|
|||
|
|
const fyunit = getUnitConfigByCode('FPSSRL_R', 'FCNT')?.unit ?? '尾';
|
|||
|
|
return fyunit;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 初始化单个图表
|
|||
|
|
const initChart = (index: number) => {
|
|||
|
|
const chartRef = chartRefs.value[index];
|
|||
|
|
if (!chartRef) return;
|
|||
|
|
|
|||
|
|
const rect = chartRef.getBoundingClientRect();
|
|||
|
|
if (rect.width === 0 || rect.height === 0) {
|
|||
|
|
console.warn(`图表容器尺寸为0,延迟重试... 索引: ${index}`);
|
|||
|
|
setTimeout(() => initChart(index), 100);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const chartInstance = echarts.init(chartRef);
|
|||
|
|
chartInstances.value[index] = chartInstance;
|
|||
|
|
updateChart(index);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 更新单个图表配置
|
|||
|
|
const updateChart = (index: number) => {
|
|||
|
|
const chartInstance = chartInstances.value[index];
|
|||
|
|
const chartData = renderChartData.value[index];
|
|||
|
|
|
|||
|
|
if (!chartInstance || !chartData) return;
|
|||
|
|
|
|||
|
|
const colorNames = chartData.data.map(item => item.name);
|
|||
|
|
const colors = generateRandomColor(colorNames);
|
|||
|
|
const totalAmount = getTotalAmount(chartData.data);
|
|||
|
|
const unit = getUnit();
|
|||
|
|
|
|||
|
|
const option: echarts.EChartsOption = {
|
|||
|
|
tooltip: {
|
|||
|
|
trigger: 'item',
|
|||
|
|
formatter: `{b}: {c} ${unit} ({d}%)`,
|
|||
|
|
backgroundColor: 'rgba(50, 50, 50, 0.9)',
|
|||
|
|
borderColor: 'transparent',
|
|||
|
|
textStyle: {
|
|||
|
|
color: '#fff',
|
|||
|
|
fontSize: 12
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
title: [
|
|||
|
|
{
|
|||
|
|
text: `${totalAmount}`,
|
|||
|
|
subtext: `总量(${unit})`,
|
|||
|
|
left: '34%',
|
|||
|
|
top: '45%',
|
|||
|
|
textAlign: 'center',
|
|||
|
|
textVerticalAlign: 'middle',
|
|||
|
|
textStyle: {
|
|||
|
|
fontSize: 24,
|
|||
|
|
fontWeight: 'bold',
|
|||
|
|
color: '#333',
|
|||
|
|
},
|
|||
|
|
subtextStyle: {
|
|||
|
|
fontSize: 12,
|
|||
|
|
color: '#999',
|
|||
|
|
align: 'center',
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
legend: {
|
|||
|
|
type: 'scroll',
|
|||
|
|
orient: 'vertical',
|
|||
|
|
right: 5,
|
|||
|
|
top: 'middle',
|
|||
|
|
bottom: 25,
|
|||
|
|
itemWidth: 25,
|
|||
|
|
itemHeight: 13,
|
|||
|
|
itemGap: 8,
|
|||
|
|
pageIconColor: '#333',
|
|||
|
|
pageIconInactiveColor: '#ccc',
|
|||
|
|
pageIconSize: 12,
|
|||
|
|
pageFormatter: '{current}/{total}',
|
|||
|
|
formatter: function(name: string) {
|
|||
|
|
const found = chartData.data.find(item => item.name === name);
|
|||
|
|
const value = found ? found.value : '-';
|
|||
|
|
const maxLength = 7;
|
|||
|
|
const truncatedName = name.length > maxLength ? name.substring(0, maxLength) + '...' : name;
|
|||
|
|
return `${truncatedName} ${value}${unit}`;
|
|||
|
|
},
|
|||
|
|
textStyle: {
|
|||
|
|
fontSize: 13,
|
|||
|
|
color: '#666'
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
series: [
|
|||
|
|
{
|
|||
|
|
type: 'pie',
|
|||
|
|
radius: ['50%', '70%'],
|
|||
|
|
center: ['35%', '50%'],
|
|||
|
|
avoidLabelOverlap: false,
|
|||
|
|
itemStyle: {
|
|||
|
|
borderRadius: 4,
|
|||
|
|
borderColor: '#fff',
|
|||
|
|
borderWidth: 2
|
|||
|
|
},
|
|||
|
|
label: {
|
|||
|
|
show: false
|
|||
|
|
},
|
|||
|
|
emphasis: {
|
|||
|
|
label: {
|
|||
|
|
show: false
|
|||
|
|
},
|
|||
|
|
itemStyle: {
|
|||
|
|
shadowBlur: 10,
|
|||
|
|
shadowOffsetX: 0,
|
|||
|
|
shadowColor: 'rgba(0, 0, 0, 0.3)'
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
labelLine: {
|
|||
|
|
show: false
|
|||
|
|
},
|
|||
|
|
data: chartData.data.map((item, idx) => ({
|
|||
|
|
...item,
|
|||
|
|
itemStyle: {
|
|||
|
|
color: colors[idx]
|
|||
|
|
}
|
|||
|
|
}))
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
chartInstance.setOption(option, true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理窗口大小变化
|
|||
|
|
const handleResize = () => {
|
|||
|
|
chartInstances.value.forEach(instance => {
|
|||
|
|
if (instance) {
|
|||
|
|
instance.resize();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 初始化渲染数组(克隆首尾项)
|
|||
|
|
const initRenderData = () => {
|
|||
|
|
const length = originalChartData.value.length;
|
|||
|
|
if (length === 0) return;
|
|||
|
|
|
|||
|
|
renderChartData.value = [
|
|||
|
|
originalChartData.value[length - 1], // 克隆最后一项
|
|||
|
|
...originalChartData.value, // 原始数据
|
|||
|
|
originalChartData.value[0] // 克隆第一项
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// 初始化图表实例数组
|
|||
|
|
chartInstances.value = new Array(renderChartData.value.length).fill(null);
|
|||
|
|
chartRefs.value = new Array(renderChartData.value.length).fill(null);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 启动自动轮播
|
|||
|
|
const startAutoPlay = () => {
|
|||
|
|
if (timer) clearInterval(timer);
|
|||
|
|
timer = setInterval(() => {
|
|||
|
|
if (!isHovering.value && !isTransitioning.value) {
|
|||
|
|
nextSlide();
|
|||
|
|
}
|
|||
|
|
}, 4000);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 切换到下一张
|
|||
|
|
const nextSlide = () => {
|
|||
|
|
currentIndex.value++;
|
|||
|
|
|
|||
|
|
// 延迟检查是否需要无缝跳转
|
|||
|
|
setTimeout(() => {
|
|||
|
|
checkSeamlessJump();
|
|||
|
|
}, 500); // 与transition时间一致
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 检查是否需要无缝跳转(克隆项处理)
|
|||
|
|
const checkSeamlessJump = () => {
|
|||
|
|
const realLength = originalChartData.value.length;
|
|||
|
|
|
|||
|
|
// 如果到达克隆的最后一项(索引 = realLength + 1)
|
|||
|
|
if (currentIndex.value >= realLength + 1) {
|
|||
|
|
// 1. 禁用过渡动画,实现瞬间跳转
|
|||
|
|
isTransitioning.value = true;
|
|||
|
|
|
|||
|
|
// 2. 瞬间跳转到真实的第二项(索引1),用户看不到跳变
|
|||
|
|
currentIndex.value = 1;
|
|||
|
|
|
|||
|
|
// 3. 等待两帧后恢复过渡动画(确保DOM已更新)
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
isTransitioning.value = false;
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理鼠标进入
|
|||
|
|
const handleMouseEnter = () => {
|
|||
|
|
isHovering.value = true;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 处理鼠标离开
|
|||
|
|
const handleMouseLeave = () => {
|
|||
|
|
isHovering.value = false;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// 页面加载时执行
|
|||
|
|
onMounted(async () => {
|
|||
|
|
initRenderData();
|
|||
|
|
|
|||
|
|
// 等待DOM渲染完成
|
|||
|
|
await nextTick();
|
|||
|
|
setTimeout(() => {
|
|||
|
|
// 初始化所有图表
|
|||
|
|
renderChartData.value.forEach((_, index) => {
|
|||
|
|
initChart(index);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 监听窗口大小变化
|
|||
|
|
window.addEventListener('resize', handleResize);
|
|||
|
|
|
|||
|
|
// 启动自动轮播
|
|||
|
|
startAutoPlay();
|
|||
|
|
}, 100);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// 组件卸载时清理
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
if (timer) clearInterval(timer);
|
|||
|
|
|
|||
|
|
// 销毁所有图表实例
|
|||
|
|
chartInstances.value.forEach(instance => {
|
|||
|
|
if (instance) {
|
|||
|
|
instance.dispose();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
window.removeEventListener('resize', handleResize);
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style lang="scss" scoped>
|
|||
|
|
.container {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 290px;
|
|||
|
|
// border: 1px solid #7fd6ff;
|
|||
|
|
border-radius: 5px;
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden; // 隐藏超出容器的内容
|
|||
|
|
|
|||
|
|
// 跑马灯轨道
|
|||
|
|
.carousel-track {
|
|||
|
|
display: flex; // 横向排列所有媒体项
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
transition: transform 0.5s ease-in-out; // 0.5秒平滑过渡
|
|||
|
|
|
|||
|
|
// 禁用过渡动画(用于无缝跳转)
|
|||
|
|
&.no-transition {
|
|||
|
|
transition: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 单个图表项
|
|||
|
|
.carousel-item {
|
|||
|
|
min-width: 100%; // 每个项目占满容器宽度
|
|||
|
|
height: 100%;
|
|||
|
|
position: relative;
|
|||
|
|
flex-shrink: 0; // 防止被压缩
|
|||
|
|
|
|||
|
|
.pie-chart-container {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
padding: 10px;
|
|||
|
|
|
|||
|
|
.chart {
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.title {
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #333;
|
|||
|
|
padding-top: 8px;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|