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

480 lines
15 KiB
Vue
Raw Normal View History

2026-05-12 14:34:58 +08:00
<!-- SidePanelItem.vue -->
<template>
<SidePanelItem title="沿程水质变化" :datetimePicker="datetimePicker">
<div class="chart-container" ref="chartRef"></div>
</SidePanelItem>
</template>
<script lang="ts" setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import * as echarts from 'echarts';
import type { EChartsOption } from 'echarts';
import SidePanelItem from '@/components/SidePanelItem/index.vue';
// 定义组件名(便于调试和递归)
defineOptions({
name: 'waterQuality'
});
// ==================== 响应式数据 ====================
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
// 获取当天早上8:00的时间
const now = new Date();
const todayAtEightAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
const defaultValue = `${todayAtEightAM.getFullYear()}-${String(todayAtEightAM.getMonth() + 1).padStart(2, '0')}-${String(todayAtEightAM.getDate()).padStart(2, '0')} ${String(todayAtEightAM.getHours()).padStart(2, '0')}:${String(todayAtEightAM.getMinutes()).padStart(2, '0')}`;
const datetimePicker = ref({
show: true,
value: defaultValue,
format: 'YYYY-MM-DD hh:mm',
picker: 'date',
options: []
});
// ==================== 可见系列队列最多2个====================
const visibleSeriesQueue = ref<string[]>([]);
// ==================== 模拟数据 ====================
const generateMockData = () => {
const stations = [
{ stnm: '两河口', stcd: 'ST001', type: '0' },
{ stnm: '锦屏一级', stcd: 'ST002', type: '1' },
{ stnm: '桐子林', stcd: 'ST003', type: '0' },
{ stnm: '溪洛渡', stcd: 'ST004', type: '0' },
{ stnm: '向家坝', stcd: 'ST005', type: '1' },
{ stnm: '三峡', stcd: 'ST006', type: '0' },
{ stnm: '葛洲坝', stcd: 'ST007', type: '0' }
];
const tm = '2024-01-15 08:00:00';
return stations.map((station, index) => ({
...station,
tm,
SORT: index + 1,
ph: 7.2 + Math.random() * 0.8,
dox: 6.5 + Math.random() * 2.5,
codmn: 1.5 + Math.random() * 2.0,
nh3n: 0.15 + Math.random() * 0.35,
tp: 0.05 + Math.random() * 0.15,
tn: 0.8 + Math.random() * 1.2
}));
};
const mockData = ref(generateMockData());
// ==================== 指标配置 ====================
const indicatorConfig = [
{ key: 'ph', name: 'pH', unit: '', color: '#5470C6' },
{ key: 'dox', name: '溶解氧', unit: 'mg/L', color: '#91CC75' },
{ key: 'codmn', name: '高锰酸盐指数', unit: 'mg/L', color: '#FAC858' },
{ key: 'nh3n', name: '氨氮', unit: 'mg/L', color: '#EE6666' },
{ key: 'tp', name: '总磷', unit: 'mg/L', color: '#73C0DE' },
{ key: 'tn', name: '总氮', unit: 'mg/L', color: '#3BA272' }
];
// ==================== 图表配置生成 ====================
const getChartOption = (): EChartsOption => {
const data = mockData.value;
if (!data || data.length === 0) {
return {};
}
// X轴数据站点名称
const xData = data.map((item, index) => ({
value: item.stnm,
textStyle: {
padding: index % 2 !== 0 ? [16, 0, 0, 0] : 0
}
}));
// 电站标记线数据
const markLineData: any[] = [];
data.forEach((item, index) => {
if (item.type === '1') {
markLineData.push({ xAxis: index });
}
});
// 图例数据
const legendData = indicatorConfig.map(item => item.name);
// 根据可见队列构建选中状态
const selectedState: Record<string, boolean> = {};
indicatorConfig.forEach(config => {
selectedState[config.name] = visibleSeriesQueue.value.includes(config.name);
});
// Series 数据
const seriesData = indicatorConfig.map((config, index) => {
const _key = config.key === 'dox' ? 'do' : config.key;
return {
name: config.name,
type: 'line' as const,
yAxisIndex: index,
smooth: true,
connectNulls: true,
symbol: 'circle' as const,
symbolSize: 6,
itemStyle: {
color: config.color
},
data: data.map(item => {
const value = item[config.key as keyof typeof item];
return typeof value === 'number' && !isNaN(value) ? value : null;
}),
markLine: index === 0 ? {
symbol: ['none', 'none'],
label: { show: false },
lineStyle: {
color: '#ccc',
type: 'dashed' as const
},
data: markLineData
} : undefined
};
});
// Y轴配置左1右N原则
const yAxisData = indicatorConfig.map((config, index) => {
const isLeft = index === 0;
const offset = index < 2 ? 0 : (index - 1) * 60;
return {
type: 'value' as const,
name: config.name,
position: isLeft ? ('left' as const) : ('right' as const),
offset,
axisLine: {
show: true,
lineStyle: {
color: config.color
}
},
axisLabel: {
color: config.color,
formatter: (value: number) => {
const decimalCount = (value.toString().split('.')[1] || '').length;
return decimalCount >= 2 ? value.toFixed(1) : value;
}
},
splitLine: {
show: true,
lineStyle: {
color: '#e0e0e0',
type: 'solid' as const
}
},
show: selectedState[config.name]
};
});
const option: EChartsOption = {
tooltip: {
trigger: 'axis',
confine: true,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderColor: 'transparent',
textStyle: {
color: '#ffffff'
},
formatter: (params: any) => {
if (!params || params.length === 0) return '';
const tm = data.find(item => item.stnm === params[0].name)?.tm;
let result = tm ? `${new Date(tm).toLocaleString('zh-CN')}<br/>` : '';
result += `${params[0].name}<br/>`;
params.forEach((param: any) => {
const config = indicatorConfig.find(c => c.name === param.seriesName);
const unit = config?.unit || '';
if (param.value !== null && param.value !== undefined) {
// 将数值转换为数字类型并判断小数位数
const numValue = Number(param.value);
let displayValue = param.value;
// 如果是数字且小数位超过2位则保留两位小数
if (!isNaN(numValue)) {
const decimalPart = param.value.toString().split('.')[1];
if (decimalPart && decimalPart.length > 2) {
displayValue = numValue.toFixed(2);
}
}
result += `${param.marker}${param.seriesName}${displayValue}${unit}<br/>`;
}
});
return result;
}
},
legend: {
type: 'scroll',
top: 0,
data: legendData,
selected: selectedState,
inactiveColor: '#ccc',
itemWidth: 16,
itemHeight: 12,
itemGap: 8,
textStyle: {
fontSize: 14
}
},
grid: {
top: 60,
bottom: 50,
left: '20px',
right: '20px',
containLabel: true
},
xAxis: {
type: 'category',
data: xData,
axisLabel: {
interval: 0,
color: '#333',
fontSize: 12
},
axisLine: {
lineStyle: {
color: '#8f8f8f'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#e0e0e0',
type: 'solid' as const
}
}
},
yAxis: yAxisData,
series: seriesData
};
return option;
};
// ==================== 图例选择事件处理最多2个系列====================
const handleLegendSelectChanged = (params: any) => {
if (!chartInstance) return;
const { selected, name } = params;
const clickedName = name;
const isNowSelected = selected[clickedName];
console.log(`图例点击: ${clickedName}, 当前状态: ${isNowSelected ? '显示' : '隐藏'}`);
console.log('当前可见队列:', visibleSeriesQueue.value);
if (isNowSelected) {
// 用户想要显示这个系列
if (visibleSeriesQueue.value.length >= 2) {
// 已满2个移除最早的队列头部
const removed = visibleSeriesQueue.value.shift();
console.log(`已达上限,移除最早系列: ${removed}`);
}
// 添加到队列尾部
if (!visibleSeriesQueue.value.includes(clickedName)) {
visibleSeriesQueue.value.push(clickedName);
}
} else {
// 用户想要隐藏这个系列
const index = visibleSeriesQueue.value.indexOf(clickedName);
if (index > -1) {
visibleSeriesQueue.value.splice(index, 1);
console.log(`从队列中移除: ${clickedName}`);
}
}
console.log('更新后可见队列:', visibleSeriesQueue.value);
// 根据队列构建最终的 selected 状态
const finalSelected: Record<string, boolean> = {};
indicatorConfig.forEach(config => {
finalSelected[config.name] = visibleSeriesQueue.value.includes(config.name);
});
// 深拷贝当前配置
const currentOption = JSON.parse(JSON.stringify(chartInstance.getOption()));
// 同步图例选中状态
currentOption.legend[0].selected = finalSelected;
// 根据图例状态更新 Y 轴显隐
const newYAxis = currentOption.yAxis.map((item: any) => {
let isShow = false;
for (const key in finalSelected) {
if (key === item.name) {
isShow = finalSelected[key];
break;
}
}
return { ...item, show: isShow };
});
currentOption.yAxis = newYAxis;
// 动态调整 Y 轴布局
yAxisShowDynamic(finalSelected, currentOption);
// 完全替换模式更新
try {
chartInstance.setOption(currentOption, true);
console.log('图表配置已更新');
} catch (error) {
console.error('图表更新失败:', error);
}
};
// ==================== Y轴动态布局调整 ====================
const yAxisShowDynamic = (selected: Record<string, boolean>, options: any) => {
const allShow = options.yAxis?.filter((item: any) => item.show);
const showCount = allShow?.length || 0;
// 没有显示的Y轴
if (showCount === 0) {
return;
}
// 只有一个Y轴统一置于左侧
if (showCount === 1) {
delete options.grid.right;
options.grid.left = '80px';
options.yAxis = options.yAxis.map((item: any) => {
if (item.show) {
return {
...item,
position: 'left',
offset: 0
};
}
return item;
});
return;
}
// 两个及以上Y轴左侧1个右侧其余
if (showCount >= 2) {
let leftIndex = 0;
let rightIndex = 0;
options.yAxis = options.yAxis.map((item: any) => {
if (!item.show) {
return item;
}
// 第一个可见的Y轴放在左侧
if (leftIndex === 0) {
leftIndex++;
delete options.grid.right;
options.grid.left = '80px';
return {
...item,
position: 'left',
offset: 0
};
} else {
// 其余Y轴放在右侧
rightIndex++;
if (rightIndex > 1) {
options.grid.right = '100px';
return {
...item,
position: 'right',
offset: 60
};
} else {
options.grid.right = '60px';
return {
...item,
position: 'right',
offset: 0
};
}
}
});
}
};
// ==================== 图表初始化 ====================
const initChart = async () => {
if (!chartRef.value) {
console.warn('图表容器未就绪');
return;
}
// 检查容器尺寸
const rect = chartRef.value.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn('图表容器尺寸为0等待渲染...');
await new Promise(resolve => setTimeout(resolve, 50));
return initChart();
}
// 初始化可见队列:默认只显示第一个系列
visibleSeriesQueue.value = [indicatorConfig[0].name];
console.log('初始化可见队列:', visibleSeriesQueue.value);
// 初始化 ECharts 实例
chartInstance = echarts.init(chartRef.value);
// 设置初始配置
const option = getChartOption();
chartInstance.setOption(option);
// 监听图例选择变化事件
chartInstance.on('legendselectchanged', handleLegendSelectChanged);
// 强制 resize 确保正确渲染
setTimeout(() => {
chartInstance?.resize();
}, 50);
console.log('图表初始化成功');
};
// ==================== 窗口大小变化处理 ====================
const handleResize = () => {
chartInstance?.resize();
};
// ==================== 生命周期钩子 ====================
onMounted(async () => {
await nextTick();
// 增加额外延迟确保容器完全渲染
setTimeout(() => {
initChart();
}, 50);
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
if (chartInstance) {
chartInstance.off('legendselectchanged', handleLegendSelectChanged);
chartInstance.dispose();
chartInstance = null;
}
});
</script>
<style lang="scss" scoped>
.chart-container {
width: 100%;
height: 280px;
min-height: 231px;
}
</style>