WholeProcessPlatform/frontend/src/modules/GYZLLB/index.vue

735 lines
20 KiB
Vue
Raw Normal View History

2026-05-09 17:04:48 +08:00
<!-- SidePanelItem.vue -->
<template>
2026-06-08 10:31:19 +08:00
<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">
2026-05-09 17:04:48 +08:00
<!-- 跑马灯轨道容器 -->
2026-06-08 10:31:19 +08:00
<div class="carousel-track" :class="{ 'no-transition': isTransitioning }"
:style="{ transform: `translateX(-${currentIndex * 100}%)` }">
2026-05-09 17:04:48 +08:00
<!-- 遍历所有图表项包含克隆项 -->
2026-06-08 10:31:19 +08:00
<div v-for="(item, index) in renderChartData" :key="index" class="carousel-item">
2026-05-09 17:04:48 +08:00
<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>
2026-06-08 10:31:19 +08:00
import { ref, onMounted, onUnmounted, nextTick, watch } from 'vue';
2026-05-09 17:04:48 +08:00
import * as echarts from 'echarts';
import type { ECharts } from 'echarts';
import SidePanelItem from '@/components/SidePanelItem/index.vue';
2026-06-08 10:31:19 +08:00
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[];
}
2026-05-09 17:04:48 +08:00
// 定义组件名(便于调试和递归)
defineOptions({
name: 'GYZLLB'
});
2026-06-08 10:31:19 +08:00
const JidiSelectEventStore = useJidiSelectEventStore();
const baseid = ref('');
2026-05-09 17:04:48 +08:00
const props = defineProps({
title: { // 标题
type: String,
default: '过鱼总量'
},
});
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
const datetimePicker = ref({
show: true,
2026-05-12 08:47:27 +08:00
value: `${new Date().getFullYear() - 1}`, // 上一年
2026-05-09 17:04:48 +08:00
format: 'YYYY',
picker: 'year' as const,
options: []
});
2026-06-08 10:31:19 +08:00
// 状态管理
const loading = ref<boolean>(false);
const hasData = ref<boolean>(true);
// 数据缓存
const cachedApiData = ref<BasinData[]>([]); // 缓存 API 返回的全量数据
const currentYear = ref<string>(''); // 当前选中的年份
2026-05-09 17:04:48 +08:00
// 图表相关
interface ChartDataItem {
title: string;
data: Array<{ name: string; value: number }>;
}
// 原始图表数据(多组数据用于跑马灯)
2026-06-08 10:31:19 +08:00
const originalChartData = ref<ChartDataItem[]>([]);
2026-05-09 17:04:48 +08:00
// 克隆首尾项后的渲染数组(用于无缝循环)
const renderChartData = ref<ChartDataItem[]>([]);
// 当前显示索引指向renderChartData
const currentIndex = ref(1); // 从1开始跳过克隆的首项
// 定时器引用
let timer: any = null;
2026-06-08 10:31:19 +08:00
/**
* 停止自动轮播
*/
const stopAutoPlay = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
2026-05-09 17:04:48 +08:00
// 鼠标悬停状态
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[] = [];
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
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%
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 将 HSL 转换为 HEX
const h = hue / 360;
const s = saturation / 100;
const l = lightness / 100;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
let r, g, b;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
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;
2026-06-08 10:31:19 +08:00
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;
2026-05-09 17:04:48 +08:00
return p;
};
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
2026-06-08 10:31:19 +08:00
r = hue2rgb(p, q, h + 1 / 3);
2026-05-09 17:04:48 +08:00
g = hue2rgb(p, q, h);
2026-06-08 10:31:19 +08:00
b = hue2rgb(p, q, h - 1 / 3);
2026-05-09 17:04:48 +08:00
}
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
colors.push(`#${toHex(r)}${toHex(g)}${toHex(b)}`);
});
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
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];
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
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})`,
2026-06-08 10:31:19 +08:00
left: '29%',
2026-05-09 17:04:48 +08:00
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}',
2026-06-08 10:31:19 +08:00
formatter: function (name: string) {
2026-05-09 17:04:48 +08:00
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: {
2026-06-08 10:31:19 +08:00
fontSize: 12,
2026-05-09 17:04:48 +08:00
color: '#666'
}
},
series: [
{
type: 'pie',
radius: ['50%', '70%'],
2026-06-08 10:31:19 +08:00
center: ['30%', '50%'],
2026-05-09 17:04:48 +08:00
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;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
renderChartData.value = [
originalChartData.value[length - 1], // 克隆最后一项
...originalChartData.value, // 原始数据
originalChartData.value[0] // 克隆第一项
];
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 初始化图表实例数组
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++;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 延迟检查是否需要无缝跳转
setTimeout(() => {
checkSeamlessJump();
}, 500); // 与transition时间一致
};
// 检查是否需要无缝跳转(克隆项处理)
const checkSeamlessJump = () => {
const realLength = originalChartData.value.length;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 如果到达克隆的最后一项(索引 = realLength + 1
if (currentIndex.value >= realLength + 1) {
// 1. 禁用过渡动画,实现瞬间跳转
isTransitioning.value = true;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 2. 瞬间跳转到真实的第二项索引1用户看不到跳变
currentIndex.value = 1;
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 3. 等待两帧后恢复过渡动画确保DOM已更新
requestAnimationFrame(() => {
requestAnimationFrame(() => {
isTransitioning.value = false;
});
});
}
};
// 处理鼠标进入
const handleMouseEnter = () => {
isHovering.value = true;
};
// 处理鼠标离开
const handleMouseLeave = () => {
isHovering.value = false;
};
// 页面加载时执行
onMounted(async () => {
initRenderData();
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 等待DOM渲染完成
await nextTick();
setTimeout(() => {
// 初始化所有图表
renderChartData.value.forEach((_, index) => {
initChart(index);
});
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 监听窗口大小变化
window.addEventListener('resize', handleResize);
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 启动自动轮播
startAutoPlay();
}, 100);
});
// 组件卸载时清理
onUnmounted(() => {
if (timer) clearInterval(timer);
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 销毁所有图表实例
chartInstances.value.forEach(instance => {
if (instance) {
instance.dispose();
}
});
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
window.removeEventListener('resize', handleResize);
});
2026-06-08 10:31:19 +08:00
/**
* 构建单一流域的图表数据
*/
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 }
);
2026-05-09 17:04:48 +08:00
</script>
<style lang="scss" scoped>
.container {
width: 100%;
height: 290px;
// border: 1px solid #7fd6ff;
border-radius: 5px;
position: relative;
overflow: hidden; // 隐藏超出容器的内容
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 跑马灯轨道
.carousel-track {
display: flex; // 横向排列所有媒体项
width: 100%;
height: 100%;
transition: transform 0.5s ease-in-out; // 0.5秒平滑过渡
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 禁用过渡动画(用于无缝跳转)
&.no-transition {
transition: none;
}
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
// 单个图表项
.carousel-item {
min-width: 100%; // 每个项目占满容器宽度
height: 100%;
position: relative;
flex-shrink: 0; // 防止被压缩
2026-06-08 10:31:19 +08:00
2026-05-09 17:04:48 +08:00
.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;
}
}
}
}
}
2026-06-08 10:31:19 +08:00
// 加载状态样式
.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;
}
2026-05-09 17:04:48 +08:00
</style>