WholeProcessPlatform/frontend/src/modules/GYZLLB/index.vue
2026-06-08 10:31:19 +08:00

735 lines
20 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- SidePanelItem.vue -->
<template>
<SidePanelItem :title="title" :datetimePicker="datetimePicker" @update-values="handlePanelChange1">
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<a-spin tip="加载中..." />
</div>
<!-- 无数据状态 -->
<div v-else-if="!hasData" class="empty-container">
<a-empty description="暂无数据" />
</div>
<!-- 正常显示图表 -->
<div v-else 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, watch } from 'vue';
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import SidePanelItem from '@/components/SidePanelItem/index.vue';
import { yearGetYearFpStatistics } from '@/api/gyss';
import { useJidiSelectEventStore } from "@/store/modules/jidiSelectEvent";
// API 数据类型定义
interface FpFtpStatisticsVo {
baseId: string;
baseName: string;
ftp: string;
fishName: string;
fcnt: number;
}
interface BasinData {
fpCount: number;
baseId: string;
baseName: string;
fpFtpStatitcsVos: FpFtpStatisticsVo[];
}
// 定义组件名(便于调试和递归)
defineOptions({
name: 'GYZLLB'
});
const JidiSelectEventStore = useJidiSelectEventStore();
const baseid = ref('');
const props = defineProps({
title: { // 标题
type: String,
default: '过鱼总量'
},
});
const datetimePicker = ref({
show: true,
value: `${new Date().getFullYear() - 1}`, // 上一年
format: 'YYYY',
picker: 'year' as const,
options: []
});
// 状态管理
const loading = ref<boolean>(false);
const hasData = ref<boolean>(true);
// 数据缓存
const cachedApiData = ref<BasinData[]>([]); // 缓存 API 返回的全量数据
const currentYear = ref<string>(''); // 当前选中的年份
// 图表相关
interface ChartDataItem {
title: string;
data: Array<{ name: string; value: number }>;
}
// 原始图表数据(多组数据用于跑马灯)
const originalChartData = ref<ChartDataItem[]>([]);
// 克隆首尾项后的渲染数组(用于无缝循环)
const renderChartData = ref<ChartDataItem[]>([]);
// 当前显示索引指向renderChartData
const currentIndex = ref(1); // 从1开始跳过克隆的首项
// 定时器引用
let timer: any = null;
/**
* 停止自动轮播
*/
const stopAutoPlay = () => {
if (timer) {
clearInterval(timer);
timer = 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: '29%',
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: 12,
color: '#666'
}
},
series: [
{
type: 'pie',
radius: ['50%', '70%'],
center: ['30%', '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);
});
/**
* 构建单一流域的图表数据
*/
const buildSingleBasinChartData = (basin: BasinData, year: string): ChartDataItem => {
return {
title: `${basin.baseName}`,
data: basin.fpFtpStatitcsVos.map((fish: FpFtpStatisticsVo) => ({
name: fish.fishName,
value: fish.fcnt
}))
};
};
/**
* 构建全部流域的图表数据(包含流域对比图和各流域鱼类分布图)
*/
const buildAllBasinsChartData = (apiData: BasinData[], year: string): ChartDataItem[] => {
const charts: ChartDataItem[] = [];
// 1⃣ 第一张:各流域总量对比图(使用 fpCount
const basinComparisonData = apiData
.filter(basin => basin.fpFtpStatitcsVos && basin.fpFtpStatitcsVos.length > 0)
.map(basin => ({
name: basin.baseName,
value: basin.fpCount
}));
if (basinComparisonData.length > 0) {
charts.push({
title: `总量`,
data: basinComparisonData
});
}
// 2⃣ 后续:每个流域的鱼类分布图(使用 fcnt
apiData.forEach(basin => {
if (basin.fpFtpStatitcsVos && basin.fpFtpStatitcsVos.length > 0) {
charts.push(buildSingleBasinChartData(basin, year));
}
});
return charts;
};
/**
* 重新构建图表实例(用于完全重建)
*/
const rebuildCharts = async () => {
// 销毁旧图表实例
chartInstances.value.forEach(instance => {
if (instance) {
instance.dispose();
}
});
chartInstances.value = [];
// 重新初始化渲染数据
initRenderData();
// 等待 DOM 更新
await nextTick();
// 重新初始化所有图表
setTimeout(() => {
renderChartData.value.forEach((_, index) => {
initChart(index);
});
}, 100);
};
/**
* 控制跑马灯的启停
*/
const controlCarousel = () => {
// 判断是否需要停止跑马灯
const shouldStop = baseid.value !== 'all' || originalChartData.value.length <= 1;
if (shouldStop) {
stopAutoPlay(); // 停止轮播,但不重置 currentIndex
} else {
startAutoPlay(); // 启动轮播
}
};
/**
* 根据当前 baseid 更新图表显示(不请求 API
*/
const updateChartByBaseid = () => {
if (!cachedApiData.value.length) {
hasData.value = false;
originalChartData.value = [];
renderChartData.value = []; // 清空渲染数据
stopAutoPlay(); // 停止轮播
return;
}
if (baseid.value === 'all') {
// === 全部流域模式 ===
originalChartData.value = buildAllBasinsChartData(cachedApiData.value, currentYear.value);
} else {
// === 单一流域模式 ===
const targetBasin = cachedApiData.value.find(item => item.baseId === baseid.value);
if (!targetBasin || !targetBasin.fpFtpStatitcsVos || targetBasin.fpFtpStatitcsVos.length === 0) {
hasData.value = false;
originalChartData.value = [];
renderChartData.value = []; // 清空渲染数据
stopAutoPlay(); // 停止轮播
return;
}
originalChartData.value = [buildSingleBasinChartData(targetBasin, currentYear.value)];
}
// 先构建渲染数据
initRenderData();
// 再设置为有数据状态
hasData.value = true;
// 等待 DOM 更新后初始化图表
nextTick(() => {
// 销毁旧图表实例
chartInstances.value.forEach(instance => {
if (instance) {
instance.dispose();
}
});
chartInstances.value = new Array(renderChartData.value.length).fill(null);
chartRefs.value = new Array(renderChartData.value.length).fill(null);
setTimeout(() => {
renderChartData.value.forEach((_, index) => {
initChart(index);
});
}, 100);
});
controlCarousel(); // 控制跑马灯启停
};
/**
* 获取并处理图表数据
*/
const getechartsdata = async (yearOne: string) => {
if (!baseid.value) {
hasData.value = false;
originalChartData.value = [];
renderChartData.value = [];
stopAutoPlay();
return;
}
// 如果年份没变,且有缓存数据,直接使用缓存
if (yearOne === currentYear.value && cachedApiData.value.length > 0) {
updateChartByBaseid();
return;
}
loading.value = true;
try {
const params = { year: yearOne };
const res = await yearGetYearFpStatistics(params);
// axios 返回的结构res.data 才是真正的业务数据
const responseData = res.data;
if (!responseData || responseData.length === 0) {
console.warn('无有效数据');
hasData.value = false;
originalChartData.value = [];
renderChartData.value = [];
cachedApiData.value = [];
currentYear.value = '';
stopAutoPlay();
return;
}
// debugger
// 缓存全量数据
cachedApiData.value = responseData;
currentYear.value = yearOne;
// 根据 baseid 更新显示
updateChartByBaseid();
} catch (error) {
console.error('获取过鱼统计数据失败:', error);
hasData.value = false;
originalChartData.value = [];
renderChartData.value = [];
cachedApiData.value = [];
currentYear.value = '';
stopAutoPlay();
} finally {
loading.value = false;
}
};
const handlePanelChange1 = (data: any) => {
if (data.datetime) {
getechartsdata(data.datetime); // 年份变化,请求 API
} else {
// 置空图表数据,并显示暂无数据
hasData.value = false;
originalChartData.value = [];
renderChartData.value = []; // 清空渲染数据
cachedApiData.value = []; // 清空缓存
currentYear.value = '';
stopAutoPlay(); // 停止轮播
}
};
watch(
() => JidiSelectEventStore.selectedItem,
(newVal) => {
if (newVal && newVal.wbsCode) {
baseid.value = newVal.wbsCode;
// 切换流域时,不请求 API只从缓存中筛选更新显示
if (cachedApiData.value.length > 0) {
updateChartByBaseid();
}
}
},
{ deep: true, immediate: true }
);
</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;
}
}
}
}
}
// 加载状态样式
.loading-container {
width: 100%;
height: 290px;
display: flex;
align-items: center;
justify-content: center;
}
// 空状态样式
.empty-container {
width: 100%;
height: 290px;
display: flex;
align-items: center;
justify-content: center;
}
</style>