WholeProcessPlatform/frontend/src/modules/churukushuiwenMod/index.vue
2026-06-18 09:29:15 +08:00

790 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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 -->
<!--
出入库水温图表组件
功能展示水库入库水温和出库水温的时序变化曲线
数据来源inOutOneGetKendoListCust接口
特性支持月份选择站点筛选数据缩放空值断开显示
-->
<template>
<SidePanelItem
title="出入库水温"
:moreSelect="select"
:scopeDate="scopeDate"
@update-values="handlePanelChange1"
>
<!-- ECharts图表容器 -->
<a-spin :spinning="loading" tip="加载中...">
<div
v-show="showemit && !loading"
ref="chartContainer"
class="chart-container"
></div>
<div v-show="!showemit && !loading" class="chart-container">
<a-empty />
</div>
</a-spin>
<!-- 数据点详情弹框 -->
<a-modal
v-model:open="modalVisible"
:title="`${stationName}出入库水温`"
:width="1536"
:footer="null"
@cancel="handleModalClose"
>
<!-- 弹框内容区域 - 待完善 -->
<div class="modal-content">
<churukushuiwen
v-if="clickDataInfo"
:tm="[clickDataInfo.date, clickDataInfo.date]"
:stcd="paramsOne.value"
/>
</div>
</a-modal>
</SidePanelItem>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import SidePanelItem from '@/components/SidePanelItem/index.vue';
import { getVmsstbprpt, inOutOneGetKendoListCust } from '@/api/sw';
import { useJidiSelectEventStore } from '@/store/modules/jidiSelectEvent';
import churukushuiwen from './churukushuiwen.vue';
import { MapClass } from '@/components/gis/map.class';
// ==================== 组件基础配置 ====================
// 定义组件名(便于调试和递归)
defineOptions({
name: 'churukushuiwenMod'
});
// ==================== 状态管理初始化 ====================
const JidiSelectEventStore = useJidiSelectEventStore(); // 基地选择事件Store
// ==================== DOM引用与图表实例 ====================
const chartContainer = ref<HTMLDivElement | null>(null); // 图表容器DOM引用
let chartInstance: echarts.ECharts | null = null; // ECharts实例
const currentMonth = new Date().toISOString().slice(0, 7); // 当前月份(格式: YYYY-MM)
const chartDataLoaded = ref(false); // 数据加载状态标志,防止重复请求
// ==================== 加载状态与显示控制 ====================
const showemit = ref(false); // 控制图表是否显示
const loading = ref(false); // 控制加载状态
// ==================== 下拉框配置(站点选择) ====================
const select = ref({
show: true, // 是否显示下拉框
value: '', // 当前选中值(站点编码)
options: [], // 下拉选项列表
picker: undefined, // 选择器类型(未使用)
format: undefined // 格式化字符串(未使用)
});
// ==================== 日期范围选择器配置 ====================
const scopeDate = ref({
show: true, // 是否显示日期选择器
value: [currentMonth, currentMonth] as any, // 默认选中当前月份范围(使用 as any 绕过类型检查)
format: 'YYYY-MM', // 显示格式
picker: 'month' as const, // 选择器类型:月份选择
options: [] // 可选日期范围(未使用)
});
// ==================== 业务变量 ====================
const baseid = ref(''); // 当前选中的基地ID
const modalVisible = ref(false); // 弹框显示状态
const clickDataInfo = ref<any>(null); // 点击的数据点信息
const stationName = ref(''); // 站点名称
// ==================== 数据处理函数 ====================
/**
* 转换图表数据格式
* 将后端返回的扁平数据结构转换为ECharts所需的双线数据格式
* @param rawData - 原始数据数组,包含tm(时间)、dwtp(类型:IWT/DWT)、wt(水温)字段
* @returns 包含日期数组、入库水温数组、出库水温数组的对象
*/
function transformChartData(rawData: any[]) {
// 1. 提取所有唯一日期并排序
const dateSet = new Set<string>();
rawData.forEach(item => {
const date = item.tm?.split(' ')[0]; // "2026-04-01 00:00:00" -> "2026-04-01"
if (date) dateSet.add(date);
});
const dates = Array.from(dateSet).sort();
// 2. 构建两条线的数据(入库水温和出库水温)
const iwtData: (number | null)[] = []; // 入库水温(Inlet Water Temperature)
const dwtData: (number | null)[] = []; // 出库水温(Discharge Water Temperature)
dates.forEach(date => {
// 查找该日期的入库水温(dwtp='IWT')
const iwtItem = rawData.find(
item => item.tm?.startsWith(date) && item.dwtp === 'IWT'
);
// 保留一位小数,缺失则填null(曲线断开)
iwtData.push(iwtItem ? Number(iwtItem.wt.toFixed(1)) : null);
// 查找该日期的出库水温(dwtp='DWT')
const dwtItem = rawData.find(
item => item.tm?.startsWith(date) && item.dwtp === 'DWT'
);
// 保留一位小数,缺失则填null(曲线断开)
dwtData.push(dwtItem ? Number(dwtItem.wt.toFixed(1)) : null);
});
return { dates, iwtData, dwtData };
}
/**
* 计算Y轴显示范围
* 基于数据最小值和最大值,上下各扩展0.2的缓冲空间
* @param data - 水温数据数组(可能包含null值)
* @returns Y轴的最小值和最大值对象
*/
function calculateYAxisRange(data: (number | null)[]) {
// 过滤出有效数值
const validValues = data.filter(v => v !== null) as number[];
if (validValues.length === 0) return { min: 0, max: 10 }; // 无数据时返回默认范围
const min = Math.min(...validValues);
const max = Math.max(...validValues);
// 上下各扩展0.2,避免曲线贴边
return {
min: Number((min - 0.2).toFixed(1)), // 最小值减0.2
max: Number((max + 0.1).toFixed(1)) // 最大值加0.2
};
}
/**
* 计算X轴刻度间隔
* 确保最多显示4个刻度标签,避免标签重叠
* @param dateCount - 日期总数
* @returns X轴刻度间隔值(interval属性)
*/
function calculateXAxisInterval(dateCount: number): number {
if (dateCount <= 4) return 0; // 数据点≤4时,显示所有刻度
return Math.ceil(dateCount / 4) - 1; // 动态计算间隔,保证最多4个刻度
}
const paramsOne: any = ref({
value: '',
tm: [currentMonth, currentMonth]
});
/**
* 获取图表数据
* 调用后端接口获取出入库水温数据,并触发图表更新
*/
async function fetchChartData() {
if (loading.value) return; // 防止重复请求
loading.value = true; // 开始请求前设置loading
try {
// 动态计算时间范围(考虑不同月份的实际天数)
const startDate = paramsOne.value.tm[0] + '-01 00:00:00';
const endDate =
dayjs(paramsOne.value.tm[1]).endOf('month').format('YYYY-MM-DD') +
' 23:59:59';
// 构建查询参数(固定参数,后续可扩展为动态)
const params = {
filter: {
logic: 'and',
filters: [
{
// OR逻辑:查询入库或出库水温
logic: 'or',
filters: [
{
field: 'engDwtCode',
operator: 'eq',
value: paramsOne.value.value
}, // 出库站点编码
{
field: 'engIwtCode',
operator: 'eq',
value: paramsOne.value.value
} // 入库站点编码
]
},
// 时间范围过滤
{ field: 'dt', operator: 'gte', dataType: 'date', value: startDate },
{ field: 'dt', operator: 'lte', dataType: 'date', value: endDate }
]
}
};
// 调用API获取数据
const res = await inOutOneGetKendoListCust(params);
const rawData = res?.data?.data || [];
console.log('原始数据:', rawData);
// 根据数据长度决定showemit
if (rawData.length > 0) {
showemit.value = true; // 有数据则显示图表
// 转换数据格式
const { dates, iwtData, dwtData } = transformChartData(rawData);
console.log('转换后的数据:', { dates, iwtData, dwtData });
// 数据就绪后初始化或更新图表
setTimeout(() => {
if (!chartInstance) {
initChartWithData(dates, iwtData, dwtData); // 带数据初始化
} else {
updateChart(dates, iwtData, dwtData, 0); // 已有实例则更新
}
// 强制重绘,确保图表尺寸正确适配容器
setTimeout(() => {
if (chartInstance) {
chartInstance.resize();
}
}, 50);
}, 50);
} else {
showemit.value = false; // 无数据显示空状态
}
chartDataLoaded.value = true; // 标记数据已加载
} catch (error) {
console.error('获取图表数据失败:', error);
showemit.value = false; // 出错也显示空状态
chartDataLoaded.value = true; // 即使失败也标记为已加载,避免重复请求
} finally {
loading.value = false; // 无论成功失败都关闭loading
}
}
/**
* 带数据初始化图表
* @param dates - 日期数组
* @param iwtData - 入库水温数据
* @param dwtData - 出库水温数据
*/
const initChartWithData = (
dates: string[],
iwtData: (number | null)[],
dwtData: (number | null)[]
) => {
if (!chartContainer.value) return;
const containerHeight = chartContainer.value.offsetHeight;
if (containerHeight === 0) {
setTimeout(() => initChartWithData(dates, iwtData, dwtData), 100);
return;
}
chartInstance = echarts.init(chartContainer.value);
updateChart(dates, iwtData, dwtData, 0);
};
/**
* 更新图表配置
* 根据处理后的数据动态生成ECharts配置项
* @param dates - 日期数组(X轴数据)
* @param iwtData - 入库水温数据数组
* @param dwtData - 出库水温数据数组
* @param yAxisMax - 废弃参数,保留以兼容旧代码
*/
function updateChart(
dates: string[],
iwtData: (number | null)[],
dwtData: (number | null)[],
yAxisMax: number
) {
if (!chartInstance) {
console.warn('图表实例未初始化');
return;
}
try {
// ==================== 动态计算坐标轴范围 ====================
const xAxisInterval = calculateXAxisInterval(dates.length); // X轴刻度间隔
const { min: yAxisMin, max: yAxisMaxValue } = calculateYAxisRange([
...iwtData,
...dwtData
]); // Y轴范围
console.log('X轴间隔:', xAxisInterval, 'Y轴范围:', {
min: yAxisMin,
max: yAxisMaxValue
});
// ==================== ECharts配置项 ====================
const option = {
// 提示框配置
tooltip: {
trigger: 'axis', // 触发类型:坐标轴触发
backgroundColor: 'rgba(50, 50, 50, 0.9)', // 背景色
borderColor: 'transparent', // 边框透明
textStyle: {
color: '#fff', // 文字颜色
fontSize: 14 // 字体大小
},
axisPointer: {
type: 'line', // 指示器类型:直线
lineStyle: {
color: 'rgba(91, 143, 249, 0.15)', // 指示线颜色
width: 30, // 指示线宽度
type: 'solid' // 线型:实线
}
},
// 自定义提示框内容
formatter: function (params: any) {
if (!params || params.length === 0) return '';
const date = params[0].name; // 获取日期
let result = `<div style="font-weight: bold; margin-bottom: 8px; font-size: 14px;">${date}</div>`;
params.forEach((item: any) => {
// 空值显示'-',符合项目规范
const value =
item.value !== null && item.value !== undefined
? `${item.value}°C`
: '-';
result += `<div style="display: flex; align-items: center; gap: 6px; margin: 4px 0; font-size: 14px;">
<span style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background-color: ${item.color};"></span>
<span>${item.seriesName}</span>
<span style="font-weight: bold; margin-left: auto;">${value}</span>
</div>`;
});
return result;
}
},
// 标题配置
title: {
text: '水温(°C)', // 标题文本
left: 17, // 左侧位置
top: 13, // 顶部位置
textStyle: {
fontSize: 13,
color: '#000000',
fontWeight: 'normal'
}
},
// 图例配置
legend: {
data: ['入库水温', '出库水温'], // 图例项
top: 13, // 顶部对齐
left: 'center', // 水平居中
itemWidth: 10, // 图例图标宽度
itemHeight: 10, // 图例图标高度
itemGap: 20, // 图例间距
textStyle: {
fontSize: 13,
color: '#000000'
}
},
// 网格配置(图表区域边距)
grid: {
left: '10%',
right: '5%',
top: '15%',
bottom: '10%'
},
// X轴配置
xAxis: {
type: 'category', // 类目轴
data: dates, // 日期数据
boundaryGap: true, // 两侧留白
interval: xAxisInterval, // 动态刻度间隔,最多显示4个标签
axisLine: {
show: true,
lineStyle: {
color: '#000000' // 轴线颜色
}
},
axisTick: {
show: false // 隐藏刻度线
},
axisLabel: {
color: '#000000', // 标签颜色
fontSize: 13, // 字体大小
margin: 10 // 标签与轴线距离
},
splitLine: {
show: true, // 显示分割线
lineStyle: {
color: '#bfbfbf', // 分割线颜色
type: 'solid' // 实线
}
}
},
// 数据缩放配置(鼠标滚轮缩放)
dataZoom: [
{
type: 'inside', // 内置型数据区域缩放
start: 0, // 起始位置0%
end: 100, // 结束位置100%
minValueSpan: 2, // 最小显示2个数据点
zoomOnMouseWheel: true, // 允许滚轮缩放
moveOnMouseMove: true, // 允许鼠标拖拽平移
moveOnMouseWheel: false, // 滚轮不触发平移
zoomLock: false, // 不锁定缩放比例
throttle: 50 // 节流50ms
}
],
// Y轴配置
yAxis: {
type: 'value', // 数值轴
min: yAxisMin, // 动态最小值(数据最小值-0.2)
max: yAxisMaxValue, // 动态最大值(数据最大值+0.2)
scale: true, // 自适应缩放,不强制从0开始
axisLine: {
show: true,
lineStyle: {
color: '#000000',
width: 1
}
},
axisTick: {
show: true, // 显示刻度线
lineStyle: {
color: '#000000',
width: 1
},
length: 4 // 刻度线长度
},
axisLabel: {
color: '#000000',
fontSize: 12,
formatter: '{value}' // 标签格式
},
splitLine: {
show: true, // 显示网格线
lineStyle: {
color: '#bfbfbf',
type: 'solid'
}
}
},
// 系列数据配置(两条折线)
series: [
{
name: '入库水温', // 系列名称
type: 'line', // 图表类型:折线图
smooth: true, // 平滑曲线
symbol: 'circle', // 数据点形状:圆形
symbolSize: 4, // 数据点大小
connectNulls: true, // 关键:不连接空值,实现曲线断开效果
lineStyle: {
width: 2, // 线宽
color: '#4b79ab' // 线条颜色:蓝色
},
itemStyle: {
color: '#4b79ab' // 数据点颜色
},
data: iwtData // 入库水温数据
},
{
name: '出库水温', // 系列名称
type: 'line', // 图表类型:折线图
smooth: true, // 平滑曲线
symbol: 'circle', // 数据点形状:圆形
symbolSize: 4, // 数据点大小
connectNulls: true, // 不连接空值,实现断开效果
lineStyle: {
width: 2, // 线宽
color: '#78c300' // 线条颜色:绿色
},
itemStyle: {
color: '#78c300' // 数据点颜色
},
data: dwtData // 出库水温数据
}
]
};
// 应用配置到图表实例
chartInstance.setOption(option, true); // true表示完全替换配置
// 绑定点击事件监听器
chartInstance.off('click'); // 先移除旧的事件监听
chartInstance.on('click', handleChartClick);
console.log('图表更新成功');
// 强制重绘,确保图表尺寸正确适配容器
setTimeout(() => {
if (chartInstance) {
chartInstance.resize(); // 重新计算图表尺寸
}
}, 50);
} catch (error) {
console.error('更新图表失败:', error);
}
}
// ==================== 生命周期钩子 ====================
/**
* 组件挂载时执行
* 初始化图表并获取数据
*/
onMounted(() => {
// 延迟100ms初始化,确保DOM渲染完成
setTimeout(() => {
if (chartContainer.value) {
// fetchChartData(); // 先获取真实数据
window.addEventListener('resize', handleResize); // 监听窗口大小变化
}
}, 100);
});
/**
* 组件卸载时执行
* 清理资源,防止内存泄漏
*/
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose(); // 销毁图表实例
chartInstance = null;
}
window.removeEventListener('resize', handleResize); // 移除事件监听
});
/**
* 窗口大小变化处理函数
* 自适应调整图表尺寸
*/
const handleResize = () => {
if (chartInstance) {
chartInstance.resize(); // 重新计算图表尺寸
}
};
// ==================== 事件处理函数 ====================
/**
* 图表数据点点击事件处理
* @param params - ECharts点击事件参数
*/
const handleChartClick = (params: any) => {
console.log('图表点击事件:', params);
// 只处理数据系列的点击
if (params.componentType === 'series') {
const { seriesName, name, value, dataIndex } = params;
// 保存点击数据信息
clickDataInfo.value = {
date: name, // 日期
temperature: value, // 水温值
type: seriesName, // 类型:入库水温/出库水温
dataIndex: dataIndex // 数据索引
};
// 获取站点名称(从下拉选项中查找)
// console.log('站点名称:', select.value.options, paramsOne.value.value);
const selectedStation = select.value.options
.flatMap((base: any) => base.children || [])
.find((station: any) => station.stcd === paramsOne.value.value);
stationName.value = selectedStation?.title || '未知站点';
// 打开弹框
modalVisible.value = true;
}
};
/**
* 弹框关闭处理
*/
const handleModalClose = () => {
modalVisible.value = false;
clickDataInfo.value = null;
};
/**
* SidePanelItem面板变化回调
* 处理下拉框和日期选择器的值变化
* @param data - 包含moreSelect(下拉框值)和datetime(日期范围)的对象
*/
const handlePanelChange1 = (data: any, type: string) => {
// TODO: 后续可扩展联动逻辑
if (data.moreSelect && data.scopeDate) {
paramsOne.value.value = data.moreSelect;
paramsOne.value.tm = data.scopeDate;
fetchChartData();
}
if (data?.selectedNodeExtra?.lttd && data?.selectedNodeExtra?.lgtd) {
const mapClass = MapClass.getInstance();
mapClass.flyTopanto(
[
Number(data.selectedNodeExtra.lgtd),
Number(data.selectedNodeExtra.lttd)
],
13
);
}
};
/**
* 获取站点下拉选项数据
* 根据选中的基地ID查询对应的站点列表
*/
const getselsectData = async () => {
loading.value = true; // 开始加载站点数据
try {
let params = {
filter: {
logic: 'and',
filters: [
// 如果baseid不是'all',则添加基地过滤条件
baseid.value != 'all'
? {
field: 'baseId',
operator: 'eq',
dataType: 'string',
value: baseid.value
}
: null,
// 站点类型过滤:只查询ENG类型站点
{
field: 'sttpCode',
operator: 'contains',
dataType: 'string',
value: 'ENG'
},
// 只查询有效站点(dtin=1)
{
field: 'dtin',
operator: 'eq',
dataType: 'string',
value: 1
}
].filter(Boolean) // 过滤掉null值
},
sort: [
// 如果是'all',按基地排序;否则不排序
baseid.value == 'all'
? {
field: 'baseStepSort',
dir: 'asc'
}
: null,
{
field: 'rvcdStepSort',
dir: 'asc'
},
{
field: 'siteStepSort',
dir: 'asc'
}
].filter(Boolean), // 过滤掉null值
select: [
'stcd', // 站点编码
'stnm', // 站点名称
'lgtd', // 经度
'lttd', // 纬度
'siteStepSort', // 站点排序
'baseId', // 基地ID
'baseName' // 基地名称
]
};
// 调用API获取站点数据
const res = await getVmsstbprpt(params);
console.log('res', res);
const data = res?.data?.data || res?.data;
const obj: any = {};
if (data) {
// 将扁平数据转换为树形结构(按基地分组)
data.map(e => {
if (obj[e.baseId]) {
// 如果基地已存在,添加子节点
obj[e.baseId] = {
...obj[e.baseId],
children: [
...obj[e.baseId].children,
{ ...e, value: e.stcd, title: e.stnm }
]
};
} else {
// 创建新的基地节点
obj[e.baseId] = {
value: e.baseId,
title: e.baseName,
selectable: false,
key: e.baseId,
children: [{ ...e, value: e.stcd, title: e.stnm }]
};
}
});
// 转换为数组并按jiDiList顺序排序
let treeData: any = [];
const dataMapNameArr: any = Object.entries(obj).map(([, v]) => v);
treeData = dataMapNameArr?.sort((a: any, b: any) => {
const indexA = JidiSelectEventStore.jidiData.findIndex(
item => item.wbsCode === a.value
);
const indexB = JidiSelectEventStore.jidiData.findIndex(
item => item.wbsCode === b.value
);
return indexA - indexB;
});
console.log('treeData', treeData);
// 更新下拉框选项
select.value.options = treeData;
// 设置默认选中值
if (baseid.value == 'all') {
select.value.value = '008640203800000001'; // 默认选中第一个站点
} else {
select.value.value = treeData?.[0]?.children[0]?.value; // 选中当前基地的第一个站点
}
}
} catch (error) {
console.error('获取站点数据失败:', error);
} finally {
loading.value = false; // 关闭loading
}
};
// ==================== Watch监听器 ====================
/**
* 监听基地选择变化
* 当用户切换基地时,重新加载站点下拉选项
*/
watch(
() => JidiSelectEventStore.selectedItem, // 监听Store中的选中项
newVal => {
baseid.value = newVal.wbsCode; // 更新基地ID
getselsectData(); // 重新获取站点数据
},
{ deep: true, immediate: true } // 深度监听,立即执行
);
</script>
<style lang="scss" scoped>
/* 图表容器样式 */
.chart-container {
width: 100%;
height: 252px;
display: flex;
justify-content: center;
align-items: center;
/* 固定高度252px */
}
:deep(.ant-spin-nested-loading) {
height: 252px !important;
}
/* 弹框内容样式 */
.modal-content {
min-height: 100px;
padding: 16px 0;
}
</style>