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

1237 lines
41 KiB
Vue
Raw Normal View History

2026-05-12 14:34:58 +08:00
<!-- SidePanelItem.vue -->
<template>
2026-06-01 08:37:38 +08:00
<SidePanelItem title="沿程水质变化" :iconmap="iconmap" :select="select" :datetimePicker="datetimePicker"
@update-values="handlePanelChange1">
<div class="chart-wrapper">
<a-spin :spinning="loading">
<!-- 始终渲染图表容器确保有固定宽高 -->
<div ref="chartRef" class="chart-container"></div>
<!-- 无数据时显示Empty但不影响容器尺寸 -->
<div v-if="!loading && !hasData" class="empty-overlay">
<a-empty description="暂无数据" />
</div>
</a-spin>
</div>
2026-05-12 14:34:58 +08:00
</SidePanelItem>
</template>
<script lang="ts" setup>
2026-06-01 08:37:38 +08:00
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
2026-05-12 14:34:58 +08:00
import * as echarts from 'echarts';
import type { EChartsOption } from 'echarts';
import SidePanelItem from '@/components/SidePanelItem/index.vue';
2026-06-01 08:37:38 +08:00
import { qgcGetKendoListCust, wbsbGetKendoList } from '@/api/sz'
import { useJidiSelectEventStore } from "@/store/modules/jidiSelectEvent";
import { useModelStore } from "@/store/modules/model";
2026-06-02 14:52:44 +08:00
import { WATER_QUALITY_INDICATORS, type WaterQualityIndicator } from '@/modules/waterQuality/padig';
const indicators = WATER_QUALITY_INDICATORS;
2026-05-12 14:34:58 +08:00
// 定义组件名(便于调试和递归)
defineOptions({
name: 'waterQuality'
});
2026-06-01 08:37:38 +08:00
const modelStore = useModelStore();
const JidiSelectEventStore = useJidiSelectEventStore();
2026-05-15 18:08:29 +08:00
const iconmap = ref({
show: true,
2026-06-02 14:52:44 +08:00
value: '',
2026-05-15 18:08:29 +08:00
icon: 'iconfont icon-time',
});
2026-05-12 14:34:58 +08:00
// ==================== 响应式数据 ====================
const chartRef = ref<HTMLElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
2026-06-01 08:37:38 +08:00
// Loading 状态和数据状态
const loading = ref(false);
const hasData = ref(true); // 初始化为 true因为 initChart 会使用 mockData 渲染
2026-05-12 14:34:58 +08:00
// 获取当天早上8:00的时间
const now = new Date();
const todayAtEightAM = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 0, 0);
2026-06-02 14:52:44 +08:00
const defaultValue = `${todayAtEightAM.getFullYear()}-${String(todayAtEightAM.getMonth() + 1).padStart(2, '0')}-${String(todayAtEightAM.getDate()).padStart(2, '0')} ${String(todayAtEightAM.getHours()).padStart(2, '0')}:00`;
// 👆 注意这里分钟固定为 :00与 timeFormat: 'HH' 配置保持一致
2026-05-12 14:34:58 +08:00
const datetimePicker = ref({
show: true,
value: defaultValue,
2026-06-02 14:52:44 +08:00
format: 'YYYY-MM-DD HH:mm', // 显示格式:包含分钟
2026-05-12 14:34:58 +08:00
picker: 'date',
2026-06-02 14:52:44 +08:00
timeFormat: 'HH', // 新增:选择格式:只选择小时
2026-05-12 14:34:58 +08:00
options: []
});
2026-06-01 08:37:38 +08:00
const select = ref({
show: true,
value: '',
options: [],
picker: undefined,
format: undefined,
});
2026-05-12 14:34:58 +08:00
// ==================== 可见系列队列最多2个====================
const visibleSeriesQueue = ref<string[]>([]);
2026-06-02 14:52:44 +08:00
// ==================== 动态指标配置 ====================
const indicatorConfig = ref<Array<{ key: string; name: string; unit: string; color: string; sort: number }>>([]);
// ==================== HSL 随机颜色生成 ====================
const generateRandomColor = (index: number): string => {
// 使用黄金角度确保颜色均匀分布
const goldenAngle = 137.508;
const hue = (index * goldenAngle) % 360;
// 饱和度控制在 45%-65% 之间
const saturation = 45 + Math.random() * 20;
// 亮度控制在 45%-60% 之间,避免太亮或太暗
const lightness = 45 + Math.random() * 15;
// HSL 转 RGB
const c = (1 - Math.abs(2 * lightness / 100 - 1)) * saturation / 100;
const x = c * (1 - Math.abs((hue / 60) % 2 - 1));
const m = lightness / 100 - c / 2;
let r = 0, g = 0, b = 0;
if (hue < 60) {
[r, g, b] = [c, x, 0];
} else if (hue < 120) {
[r, g, b] = [x, c, 0];
} else if (hue < 180) {
[r, g, b] = [0, c, x];
} else if (hue < 240) {
[r, g, b] = [0, x, c];
} else if (hue < 300) {
[r, g, b] = [x, 0, c];
} else {
[r, g, b] = [c, 0, x];
}
const toHex = (value: number) => {
const hex = Math.round((value + m) * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// ==================== 动态指标筛选和配置生成 ====================
const generateIndicatorConfig = (data: StationData[]) => {
if (!data || data.length === 0) {
indicatorConfig.value = [];
return;
}
// 定义需要排除的元数据字段
const excludeKeys = new Set([
'stcd', 'stcd2', 'stnm', 'rstcd', 'sttp', 'tm', 'SORT',
'limits', 'minTm', 'wwqtg', 'wqgrd', 'wqgrdName', 'min', 'max',
'_tls', 'id', 'recordUser', 'recordTime', 'modifyTime',
'displayRecordUser', 'departmentId', 'displayDepartment'
]);
// 收集所有有效指标的 key
const validKeysSet = new Set<string>();
data.forEach(station => {
Object.keys(station).forEach(key => {
// 跳过排除字段
if (excludeKeys.has(key)) return;
const value = station[key as keyof typeof station];
// 检查是否为有效数值(不为 null 且不是 NaN
if (value !== null && value !== undefined && typeof value === 'number' && !isNaN(value)) {
// 在 indicators 中查找匹配的项(小写比较)
const matchedIndicator = indicators.find(ind => ind.key.toLowerCase() === key.toLowerCase());
if (matchedIndicator) {
validKeysSet.add(matchedIndicator.key);
}
}
});
});
// 如果没有有效指标,返回空数组
if (validKeysSet.size === 0) {
indicatorConfig.value = [];
return;
}
// 根据 indicators 中的 sort 排序,并生成配置
const validIndicators = indicators
.filter(ind => validKeysSet.has(ind.key))
.sort((a, b) => a.sort - b.sort);
// 生成最终配置
indicatorConfig.value = validIndicators.map((ind, index) => ({
key: ind.key,
name: ind.name,
unit: ind.description || '',
color: generateRandomColor(index),
sort: ind.sort
}));
console.log('动态生成指标配置:', indicatorConfig.value);
};
2026-05-12 14:34:58 +08:00
// ==================== 模拟数据 ====================
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';
2026-06-02 14:52:44 +08:00
// 模拟限值数据
const mockLimits = {
ph: { min: 6, max: 9 },
dox: { min: 6 },
codmn: { max: 4 },
nh3n: { max: 0.5 },
tp: { max: 0.1 },
tn: { max: 1.0 }
};
2026-05-12 14:34:58 +08: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,
2026-06-02 14:52:44 +08:00
tn: 0.8 + Math.random() * 1.2,
limits: mockLimits // 新增:存储该站点的所有限值
2026-05-12 14:34:58 +08:00
}));
};
2026-06-02 14:52:44 +08:00
// 定义数据类型接口
interface StationData {
stnm: string;
stcd: string;
type: string;
tm: string;
SORT: number;
// 所有水质指标字段(动态)
ph?: number | null;
dox?: number | null;
codmn?: number | null;
codcr?: number | null;
bod5?: number | null;
nh3n?: number | null;
tp?: number | null;
tn?: number | null;
cu?: number | null;
zn?: number | null;
f?: number | null;
se?: number | null;
ars?: number | null;
hg?: number | null;
cd?: number | null;
cr6?: number | null;
pb?: number | null;
cn?: number | null;
vlph?: number | null;
oil?: number | null;
las?: number | null;
s2?: number | null;
fcg?: number | null;
cl?: number | null;
so4?: number | null;
no3?: number | null;
thrd?: number | null;
cond?: number | null;
fe?: number | null;
mn?: number | null;
al?: number | null;
chla?: number | null;
clarity?: number | null;
tu?: number | null;
wtmp?: number | null;
limits: Record<string, { min?: number; max?: number }>;
[key: string]: any; // 允许任意水质指标字段
}
2026-05-12 14:34:58 +08:00
2026-06-02 14:52:44 +08:00
const mockData = ref<StationData[]>([]);
2026-05-12 14:34:58 +08:00
// ==================== 图表配置生成 ====================
const getChartOption = (): EChartsOption => {
const data = mockData.value;
2026-06-01 08:37:38 +08:00
2026-06-02 14:52:44 +08:00
if (!data || data.length === 0 || indicatorConfig.value.length === 0) {
2026-05-12 14:34:58 +08:00
return {};
}
// X轴数据站点名称
2026-06-01 08:37:38 +08:00
const xData = data.map((item, index) => {
// 去除站点名称中的"出库水质监测站"或"坝上水质监测站"后缀
let stnm = item.stnm || '-';
if (stnm.includes('出库水质监测站')) {
stnm = stnm.replace('出库水质监测站', '');
} else if (stnm.includes('坝上水质监测站')) {
stnm = stnm.replace('坝上水质监测站', '');
2026-05-12 14:34:58 +08:00
}
2026-06-01 08:37:38 +08:00
return {
value: stnm,
textStyle: {
padding: index % 2 !== 0 ? [16, 0, 0, 0] : 0
}
};
});
2026-05-12 14:34:58 +08:00
// 电站标记线数据
const markLineData: any[] = [];
data.forEach((item, index) => {
if (item.type === '1') {
markLineData.push({ xAxis: index });
}
});
// 图例数据
2026-06-02 14:52:44 +08:00
const legendData = indicatorConfig.value.map(item => item.name);
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 根据可见队列构建选中状态
const selectedState: Record<string, boolean> = {};
2026-06-02 14:52:44 +08:00
indicatorConfig.value.forEach(config => {
2026-05-12 14:34:58 +08:00
selectedState[config.name] = visibleSeriesQueue.value.includes(config.name);
});
// Series 数据
2026-06-02 14:52:44 +08:00
const seriesData = indicatorConfig.value.map((config, index) => {
2026-05-12 14:34:58 +08:00
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
};
});
2026-06-01 08:37:38 +08:00
// 计算每个Y轴的数据范围用于自适应使用整数
const calculateYAxisRange = (configIndex: number) => {
2026-06-02 14:52:44 +08:00
if (!selectedState[indicatorConfig.value[configIndex].name]) {
2026-06-01 08:37:38 +08:00
return null;
}
const values = data
2026-06-02 14:52:44 +08:00
.map(item => item[indicatorConfig.value[configIndex].key as keyof typeof item])
2026-06-01 08:37:38 +08:00
.filter((val): val is number => typeof val === 'number' && !isNaN(val));
if (values.length === 0) {
return { min: 0, max: 10 };
}
const min = Math.min(...values);
const max = Math.max(...values);
// Y轴使用整数向下取整-1向上取整+1
return {
min: Math.floor(min) - 1,
max: Math.ceil(max) + 1
};
};
2026-06-02 14:52:44 +08:00
// Y轴配置左1右1原则最多显示2个Y轴
const yAxisData = indicatorConfig.value.map((config, index) => {
const isShow = selectedState[config.name];
// 如果不显示,直接返回隐藏配置
if (!isShow) {
return {
type: 'value' as const,
name: config.name,
show: false
};
}
// 计算当前是第几个显示的Y轴
let displayIndex = 0;
for (let i = 0; i < index; i++) {
if (selectedState[indicatorConfig.value[i].name]) {
displayIndex++;
}
}
// 最多只显示2个Y轴索引0和1
if (displayIndex >= 2) {
return {
type: 'value' as const,
name: config.name,
show: false
};
}
const isLeft = displayIndex === 0;
const offset = displayIndex === 1 ? 0 : 0;
2026-06-01 08:37:38 +08:00
const range = calculateYAxisRange(index);
2026-05-12 14:34:58 +08:00
return {
type: 'value' as const,
name: config.name,
position: isLeft ? ('left' as const) : ('right' as const),
offset,
2026-06-01 08:37:38 +08:00
min: range ? range.min : undefined,
max: range ? range.max : undefined,
2026-05-12 14:34:58 +08:00
axisLine: {
show: true,
lineStyle: {
color: config.color
}
},
axisLabel: {
color: config.color,
formatter: (value: number) => {
2026-06-01 08:37:38 +08:00
// Y轴刻度使用整数显示
return Math.round(value).toString();
2026-05-12 14:34:58 +08:00
}
},
splitLine: {
show: true,
lineStyle: {
color: '#e0e0e0',
type: 'solid' as const
}
},
2026-06-02 14:52:44 +08:00
show: true
2026-05-12 14:34:58 +08:00
};
});
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 '';
2026-06-01 08:37:38 +08:00
// 在最上面显示用户选择的时间
let result = `<div style="font-weight: bold; margin-bottom: 8px;">${datetimePicker.value.value}</div>`;
2026-05-12 14:34:58 +08:00
const tm = data.find(item => item.stnm === params[0].name)?.tm;
result += `${params[0].name}<br/>`;
2026-06-01 08:37:38 +08:00
2026-06-02 14:52:44 +08:00
// 判断是否只显示一个系列
const showOnlyOneSeries = visibleSeriesQueue.value.length === 1;
2026-05-12 14:34:58 +08:00
params.forEach((param: any) => {
2026-06-02 14:52:44 +08:00
const config = indicatorConfig.value.find(c => c.name === param.seriesName);
2026-05-12 14:34:58 +08:00
const unit = config?.unit || '';
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
if (param.value !== null && param.value !== undefined) {
2026-06-01 08:37:38 +08:00
// 折线数据保留一位小数
2026-05-12 14:34:58 +08:00
const numValue = Number(param.value);
let displayValue = param.value;
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
if (!isNaN(numValue)) {
2026-06-01 08:37:38 +08:00
displayValue = numValue.toFixed(1);
2026-05-12 14:34:58 +08:00
}
2026-06-01 08:37:38 +08:00
2026-06-02 14:52:44 +08:00
result += `${param.marker}${param.seriesName}${displayValue}${unit}`;
// 如果只显示一个系列,且该指标有限值,则显示限值
if (showOnlyOneSeries && config) {
// 从 data 中获取当前站点的限值
const stationData = data.find(item => item.stnm === param.name);
if (stationData && stationData.limits) {
const limitKey = config.key; // 如 'ph', 'dox'
const limits = stationData.limits[limitKey];
if (limits) {
// 构建限值显示字符串
let limitText = '';
const unit = config.unit || ''; // 获取单位
if (limits.min !== undefined && limits.max !== undefined) {
// 既有上限又有下限
limitText = `&nbsp;&nbsp;&nbsp;&nbsp;限值: ${limits.min}-${limits.max}${unit ? unit : ''}`;
} else if (limits.min !== undefined) {
// 只有下限
limitText = `&nbsp;&nbsp;&nbsp;&nbsp;限值: ${limits.min}${unit ? unit : ''}`;
} else if (limits.max !== undefined) {
// 只有上限
limitText = `&nbsp;&nbsp;&nbsp;&nbsp;限值: ${limits.max}${unit ? unit : ''}`;
}
result += ('<br/>'+limitText);
}
}
}
result += '<br/>';
2026-05-12 14:34:58 +08:00
}
});
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
return result;
}
},
legend: {
type: 'scroll',
top: 0,
2026-06-02 14:52:44 +08:00
right: 10, // 右侧留白,避免箭头按钮被遮挡
2026-05-12 14:34:58 +08:00
data: legendData,
selected: selectedState,
inactiveColor: '#ccc',
itemWidth: 16,
itemHeight: 12,
itemGap: 8,
2026-06-02 14:52:44 +08:00
pageButtonItemGap: 5, // 分页按钮与图例项的间距
pageIconColor: '#2f4554', // 分页按钮颜色
pageIconInactiveColor: '#aaa', // 分页按钮禁用时的颜色
pageIconSize: 12, // 分页按钮大小
pageFormatter: '{current}/{total}', // 分页格式:当前页/总页数
2026-05-12 14:34:58 +08:00
textStyle: {
fontSize: 14
}
},
2026-06-01 08:37:38 +08:00
// 添加 dataZoom 组件实现X轴滚轮缩放仅内置类型无滑动条
dataZoom: [
{
type: 'inside', // 内置型数据区域缩放组件(支持鼠标滚轮)
xAxisIndex: 0, // 控制第一个X轴
start: 0, // 默认显示全部数据
end: 100,
zoomOnMouseWheel: true, // 开启鼠标滚轮缩放
moveOnMouseMove: true, // 开启鼠标移动平移
filterMode: 'filter' // 过滤模式
}
],
2026-05-12 14:34:58 +08:00
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;
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
const { selected, name } = params;
const clickedName = name;
const isNowSelected = selected[clickedName];
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
console.log(`图例点击: ${clickedName}, 当前状态: ${isNowSelected ? '显示' : '隐藏'}`);
console.log('当前可见队列:', visibleSeriesQueue.value);
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
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}`);
}
}
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
console.log('更新后可见队列:', visibleSeriesQueue.value);
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 根据队列构建最终的 selected 状态
const finalSelected: Record<string, boolean> = {};
2026-06-02 14:52:44 +08:00
indicatorConfig.value.forEach(config => {
2026-05-12 14:34:58 +08:00
finalSelected[config.name] = visibleSeriesQueue.value.includes(config.name);
});
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 深拷贝当前配置
const currentOption = JSON.parse(JSON.stringify(chartInstance.getOption()));
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 同步图例选中状态
currentOption.legend[0].selected = finalSelected;
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 根据图例状态更新 Y 轴显隐
const newYAxis = currentOption.yAxis.map((item: any) => {
let isShow = false;
2026-06-02 14:52:44 +08:00
let displayIndex = 0;
// 计算这是第几个显示的Y轴
2026-05-12 14:34:58 +08:00
for (const key in finalSelected) {
2026-06-02 14:52:44 +08:00
if (finalSelected[key]) {
const config = indicatorConfig.value.find(c => c.name === key);
if (config && config.name === item.name) {
isShow = true;
break;
}
displayIndex++;
2026-05-12 14:34:58 +08:00
}
}
2026-06-02 14:52:44 +08:00
// 最多只显示2个Y轴
if (!isShow || displayIndex >= 2) {
return { ...item, show: false };
}
return { ...item, show: true };
2026-05-12 14:34:58 +08:00
});
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
currentOption.yAxis = newYAxis;
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 动态调整 Y 轴布局
yAxisShowDynamic(finalSelected, currentOption);
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
// 完全替换模式更新
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;
}
2026-06-02 14:52:44 +08:00
// 两个Y轴左侧1个右侧1个
2026-05-12 14:34:58 +08:00
if (showCount >= 2) {
2026-06-02 14:52:44 +08:00
let leftAssigned = false;
let rightAssigned = false;
2026-05-12 14:34:58 +08:00
options.yAxis = options.yAxis.map((item: any) => {
if (!item.show) {
return item;
}
// 第一个可见的Y轴放在左侧
2026-06-02 14:52:44 +08:00
if (!leftAssigned) {
leftAssigned = true;
2026-05-12 14:34:58 +08:00
delete options.grid.right;
options.grid.left = '80px';
return {
...item,
position: 'left',
offset: 0
};
2026-06-02 14:52:44 +08:00
} else if (!rightAssigned) {
// 第二个Y轴放在右侧
rightAssigned = true;
options.grid.right = '60px';
return {
...item,
position: 'right',
offset: 0
};
2026-05-12 14:34:58 +08:00
} else {
2026-06-02 14:52:44 +08:00
// 第三个及以后的Y轴隐藏理论上不应该到这里
return {
...item,
show: false
};
2026-05-12 14:34:58 +08:00
}
});
}
};
2026-06-02 14:52:44 +08:00
// ==================== 数据点点击事件处理 ====================
const handleDataPointClick = (params: any) => {
if (!params || !params.dataIndex) return;
2026-05-12 14:34:58 +08:00
2026-06-02 14:52:44 +08:00
// 获取点击的数据点对应的站点信息
const dataIndex = params.dataIndex;
const stationData = mockData.value[dataIndex];
2026-06-01 08:37:38 +08:00
2026-06-02 14:52:44 +08:00
if (stationData && stationData.stcd) {
modelStore.modalVisible = true;
modelStore.params.sttp = "WQFB";
2026-06-12 16:59:35 +08:00
modelStore.title = stationData.stnm ;
2026-06-02 14:52:44 +08:00
// modelStore.isBasicEdit = true;
modelStore.params.stcd = stationData.stcd;
2026-05-12 14:34:58 +08:00
}
};
2026-06-02 14:52:44 +08:00
// ==================== 图表初始化 ====================
2026-06-01 08:37:38 +08:00
//获取图参数
const getecharts = async () => {
if (!select.value.value) {
console.warn('选择器值为空,无法获取图表数据');
loading.value = false;
hasData.value = false;
chartInstance?.clear();
return;
}
console.log('开始获取图表数据select.value:', select.value.value);
// 设置 loading 状态
loading.value = true;
hasData.value = false;
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "baseId",
"operator": "eq",
"dataType": "string",
"value": wbsCode.value
},
{
"field": "rvcd",
"operator": "eq",
"dataType": "string",
"value": select.value.value
},
{
"field": "tm",
"operator": "gte",
"dataType": "date",
"value": getStartTime()
},
{
"field": "tm",
"operator": "lte",
"dataType": "date",
"value": getEndTime()
}
]
},
"sort": [
{
"field": "sort",
"dir": "asc"
}
]
}
try {
console.log('请求参数:', params);
let res = await qgcGetKendoListCust(params);
console.log('接口返回数据:', res);
// 处理返回的数据 - 兼容多种数据结构
let apiData: any[] = [];
if (res && res.data) {
// 情况1: res.data.data 是数组(当前实际情况)
if (Array.isArray(res.data.data)) {
apiData = res.data.data;
}
// 情况2: res.data.data.data 是数组
else if (res.data.data && Array.isArray(res.data.data.data)) {
apiData = res.data.data.data;
}
// 情况3: res.data 直接是数组
else if (Array.isArray(res.data)) {
apiData = res.data;
}
}
console.log('原始数据条数:', apiData.length);
// 过滤出有水质数据的站点sttp === 'WQ'并按sort排序
const filteredData = apiData
.filter((item: any) => item.sttp === 'WQ')
.sort((a: any, b: any) => {
// 如果有sort字段则使用否则按索引排序
return (a.sort || 0) - (b.sort || 0);
});
console.log('过滤后水质数据条数:', filteredData.length);
// 转换为图表所需的数据格式
const chartData = filteredData.map((item: any, index: number) => {
// 去除站点名称中的"出库水质站"后缀
let stnm = item.stnm || '-';
if (stnm.includes('出库水质站')) {
stnm = stnm.replace('出库水质站', '');
}
2026-06-02 14:52:44 +08:00
if (stnm.includes('出库水质_')) {
stnm = stnm.replace('出库水质_', '');
}
// 解析 min 和 max 数组,构建限值对象
const limits: Record<string, { min?: number; max?: number }> = {};
// 处理下限
if (item.min && Array.isArray(item.min)) {
item.min.forEach((limitItem: any) => {
Object.keys(limitItem).forEach(key => {
// key 是大写,如 "PH", "DOX",转为小写以匹配 indicatorConfig
const lowerKey = key.toLowerCase();
limits[lowerKey] = limits[lowerKey] || {};
limits[lowerKey].min = limitItem[key];
});
});
}
// 处理上限
if (item.max && Array.isArray(item.max)) {
item.max.forEach((limitItem: any) => {
Object.keys(limitItem).forEach(key => {
const lowerKey = key.toLowerCase();
limits[lowerKey] = limits[lowerKey] || {};
limits[lowerKey].max = limitItem[key];
});
});
}
2026-06-01 08:37:38 +08:00
return {
stnm: stnm,
stcd: item.stcd || '',
type: item.rstcd ? '1' : '0', // 有rstcd表示是电站出库站
tm: item.tm || '',
SORT: index + 1,
ph: item.ph != null ? Number(item.ph) : null,
dox: item.dox != null ? Number(item.dox) : null,
codmn: item.codmn != null ? Number(item.codmn) : null,
2026-06-02 14:52:44 +08:00
codcr: item.codcr != null ? Number(item.codcr) : null,
bod5: item.bod5 != null ? Number(item.bod5) : null,
2026-06-01 08:37:38 +08:00
nh3n: item.nh3n != null ? Number(item.nh3n) : null,
tp: item.tp != null ? Number(item.tp) : null,
2026-06-02 14:52:44 +08:00
tn: item.tn != null ? Number(item.tn) : null,
cu: item.cu != null ? Number(item.cu) : null,
zn: item.zn != null ? Number(item.zn) : null,
f: item.f != null ? Number(item.f) : null,
se: item.se != null ? Number(item.se) : null,
ars: item.ars != null ? Number(item.ars) : null,
hg: item.hg != null ? Number(item.hg) : null,
cd: item.cd != null ? Number(item.cd) : null,
cr6: item.cr6 != null ? Number(item.cr6) : null,
pb: item.pb != null ? Number(item.pb) : null,
cn: item.cn != null ? Number(item.cn) : null,
vlph: item.vlph != null ? Number(item.vlph) : null,
oil: item.oil != null ? Number(item.oil) : null,
las: item.las != null ? Number(item.las) : null,
s2: item.s2 != null ? Number(item.s2) : null,
fcg: item.fcg != null ? Number(item.fcg) : null,
cl: item.cl != null ? Number(item.cl) : null,
so4: item.so4 != null ? Number(item.so4) : null,
no3: item.no3 != null ? Number(item.no3) : null,
thrd: item.thrd != null ? Number(item.thrd) : null,
cond: item.cond != null ? Number(item.cond) : null,
fe: item.fe != null ? Number(item.fe) : null,
mn: item.mn != null ? Number(item.mn) : null,
al: item.al != null ? Number(item.al) : null,
chla: item.chla != null ? Number(item.chla) : null,
clarity: item.clarity != null ? Number(item.clarity) : null,
tu: item.tu != null ? Number(item.tu) : null,
wtmp: item.wtmp != null ? Number(item.wtmp) : null,
limits: limits // 新增:存储该站点的所有限值
2026-06-01 08:37:38 +08:00
};
});
// 更新模拟数据
mockData.value = chartData;
// 判断是否有数据
hasData.value = chartData.length > 0;
console.log('hasData 设置为:', hasData.value);
2026-06-02 14:52:44 +08:00
if (!hasData.value) {
// 无数据时清空图表
console.log('无数据,清空图表');
chartInstance?.clear();
indicatorConfig.value = [];
loading.value = false;
return;
}
// 动态生成指标配置
generateIndicatorConfig(chartData);
// 如果没有有效指标,显示空状态
if (indicatorConfig.value.length === 0) {
console.log('没有有效指标,显示空状态');
hasData.value = false;
chartInstance?.clear();
loading.value = false;
return;
}
2026-06-01 08:37:38 +08:00
// 重新渲染图表 - 使用 nextTick 确保 DOM 更新完成
await nextTick();
2026-06-02 14:52:44 +08:00
if (chartInstance) {
// 如果图表已存在,更新配置
2026-06-01 08:37:38 +08:00
requestAnimationFrame(() => {
const option = getChartOption();
chartInstance?.setOption(option, true);
chartInstance?.resize();
2026-06-02 14:52:44 +08:00
console.log('图表数据已更新,共', chartData.length, '个站点,', indicatorConfig.value.length, '个指标');
2026-06-01 08:37:38 +08:00
});
2026-06-02 14:52:44 +08:00
} else {
// 如果图表不存在,初始化图表
await initChart();
2026-06-01 08:37:38 +08:00
}
} catch (error) {
console.error('获取图表数据失败:', error);
hasData.value = false;
2026-06-02 14:52:44 +08:00
indicatorConfig.value = [];
2026-06-01 08:37:38 +08:00
} finally {
// 关闭 loading 状态 - 使用 nextTick 确保在下一个 tick 关闭
await nextTick();
console.log('关闭 loading 状态');
loading.value = false;
}
}
// 获取起始时间(当前选择日期前一个月)
const getStartTime = () => {
const currentDate = new Date(datetimePicker.value.value);
const oneMonthAgo = new Date(currentDate);
oneMonthAgo.setMonth(oneMonthAgo.getMonth() - 1);
const year = oneMonthAgo.getFullYear();
const month = String(oneMonthAgo.getMonth() + 1).padStart(2, '0');
const day = String(oneMonthAgo.getDate()).padStart(2, '0');
const hours = String(oneMonthAgo.getHours()).padStart(2, '0');
const minutes = String(oneMonthAgo.getMinutes()).padStart(2, '0');
const seconds = String(oneMonthAgo.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
// 获取结束时间(当前选择日期的当天 23:59:59
const getEndTime = () => {
const currentDate = new Date(datetimePicker.value.value);
const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, '0');
const day = String(currentDate.getDate()).padStart(2, '0');
return `${year}-${month}-${day} 23:59:59`;
};
2026-06-02 14:52:44 +08:00
// ==================== 图表初始化 ====================
const initChart = async () => {
if (!chartRef.value) {
console.warn('图表容器未就绪');
return;
}
// 检查容器尺寸
const rect = chartRef.value.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
console.warn('图表容器尺寸为0等待渲染...');
// 使用 requestAnimationFrame 确保在浏览器下一重绘帧再初始化
return new Promise((resolve) => {
requestAnimationFrame(() => {
setTimeout(() => {
initChart().then(resolve);
}, 50);
});
});
}
// 如果图表实例已存在,先销毁
if (chartInstance) {
chartInstance.dispose();
chartInstance = null;
}
// 如果没有有效指标配置,不初始化图表
if (indicatorConfig.value.length === 0) {
console.warn('没有有效的指标配置,跳过图表初始化');
hasData.value = false;
return;
}
// 初始化可见队列默认只显示第一个系列按sort排序后的第一个
visibleSeriesQueue.value = [indicatorConfig.value[0].name];
console.log('初始化可见队列:', visibleSeriesQueue.value);
// 初始化 ECharts 实例
chartInstance = echarts.init(chartRef.value);
// 设置初始配置
const option = getChartOption();
chartInstance.setOption(option);
// 监听图例选择变化事件
chartInstance.on('legendselectchanged', handleLegendSelectChanged);
// 监听数据点点击事件
chartInstance.on('click', handleDataPointClick);
// 强制 resize 确保正确渲染
setTimeout(() => {
chartInstance?.resize();
}, 50);
console.log('图表初始化成功');
};
// ==================== 窗口大小变化处理 ====================
const handleResize = () => {
chartInstance?.resize();
};
// ==================== 生命周期钩子 ====================
onMounted(async () => {
await nextTick();
// 不再立即初始化图表,等待数据加载完成
window.addEventListener('resize', handleResize);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize);
if (chartInstance) {
chartInstance.off('legendselectchanged', handleLegendSelectChanged);
chartInstance.off('click', handleDataPointClick);
chartInstance.dispose();
chartInstance = null;
}
});
2026-06-01 08:37:38 +08:00
//根据流域获取河段
const getSelect = async () => {
if (!wbsCode.value) {
console.warn('wbsCode 为空,无法获取河段数据');
return;
}
let params = {
"filter": {
"logic": "and",
"filters": [
{
"field": "objId",
"operator": "eq",
"dataType": "string",
"value": wbsCode.value
},
{
"field": "wbsType",
"operator": "eq",
"dataType": "string",
"value": "PSB_RVCD"
}
]
},
"group": [
{
"dir": "asc",
"field": "orderIndex"
},
{
"dir": "des",
"field": "wbsCode"
},
{
"dir": "des",
"field": "wbsName"
}
],
"groupResultFlat": "true"
}
try {
let res = await wbsbGetKendoList(params);
// 处理返回的数据 - 注意数据结构可能是嵌套的
let data;
if (res && res.data && res.data.data) {
// 如果 res.data.data 是数组,直接使用
if (Array.isArray(res.data.data)) {
data = res.data.data;
}
// 如果 res.data.data 是对象且有 data 属性,使用 res.data.data.data
else if (res.data.data.data && Array.isArray(res.data.data.data)) {
data = res.data.data.data;
} else {
console.warn('无法解析河段数据:', res);
return;
}
} else {
console.warn('未获取到河段数据');
return;
}
if (data && data.length > 0) {
select.value.value = data[0].wbsCode;
select.value.options = data.map(item => {
return {
value: item.wbsCode,
label: item.wbsName
};
});
// 获取完选择器数据后,立即加载图表数据
await nextTick();
getecharts();
} else {
console.warn('河段数据为空');
select.value.value = '';
select.value.options = [];
hasData.value = false;
}
} catch (error) {
console.error('获取河段数据失败:', error);
}
}
2026-06-02 14:52:44 +08:00
2026-06-01 08:37:38 +08:00
//监听子组件的数据变化
const handlePanelChange1 = async (data) => {
console.log('当前所有控件状态:', data);
// 当选择器或日期变化时,重新加载图表数据
if (data.datetime || data.select) {
2026-06-02 14:52:44 +08:00
select.value.value = data.select
datetimePicker.value.value = data.datetime
2026-06-01 08:37:38 +08:00
getecharts()
}
}
2026-06-02 14:52:44 +08:00
// 监听 datetimePicker 变化,更新 iconmap 中的时间显示
watch(
() => datetimePicker.value.value,
(newVal) => {
if (newVal) {
iconmap.value.value = `注:最新数据时间为${newVal}`;
}
},
{ immediate: true }
);
2026-06-01 08:37:38 +08:00
const wbsCode = ref('');
watch(
() => JidiSelectEventStore.selectedItem,
(newVal) => {
console.log(newVal);
wbsCode.value = newVal.wbsCode;
getSelect()
},
{ deep: true, immediate: true }
);
2026-06-02 14:52:44 +08:00
// 监听可见系列数量变化,重新设置图表配置以确保 tooltip formatter 使用最新的 visibleSeriesQueue 值
watch(
() => visibleSeriesQueue.value.length,
(newLength, oldLength) => {
// 只有当长度真正变化时才重新设置
if (newLength !== oldLength && chartInstance && indicatorConfig.value.length > 0) {
// 重新生成 option这会创建新的 formatter 闭包,捕获最新的 visibleSeriesQueue
const newOption = getChartOption();
chartInstance.setOption(newOption, true);
}
}
);
2026-06-01 08:37:38 +08:00
2026-05-12 14:34:58 +08:00
</script>
<style lang="scss" scoped>
2026-06-01 08:37:38 +08:00
.chart-wrapper {
2026-05-12 14:34:58 +08:00
width: 100%;
height: 280px;
min-height: 231px;
2026-06-01 08:37:38 +08:00
position: relative;
/* 为empty-overlay提供定位上下文 */
.chart-container {
width: 100% !important;
height: 100% !important;
min-width: 100%;
min-height: 280px;
}
.empty-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.9);
z-index: 10;
}
2026-05-12 14:34:58 +08:00
}
</style>