519 lines
14 KiB
Vue
519 lines
14 KiB
Vue
<template>
|
||
<SidePanelItem title="流量监测数据" :shrink="false">
|
||
<template #title-right-content>
|
||
<a-range-picker
|
||
class="w-[220px]"
|
||
v-model:value="dateRange"
|
||
format="YYYY-MM-DD"
|
||
:show-time="showTimeConfig"
|
||
:allowClear="false"
|
||
:presets="DateSetting.RangeButton.month1"
|
||
:disabled-date="disabledDate"
|
||
size="small"
|
||
@change="handleSearch"
|
||
/>
|
||
</template>
|
||
<a-spin :spinning="isLoading" tip="加载中...">
|
||
<div class="monitor-chart">
|
||
<div ref="chartRef" class="chart-container"></div>
|
||
<a-empty
|
||
v-if="isDataEmpty"
|
||
description="暂无数据"
|
||
class="chart-empty"
|
||
/>
|
||
</div>
|
||
<div class="monitor-table">
|
||
<a-spin :spinning="tableLoading" tip="加载中...">
|
||
<BasicTable
|
||
ref="tableRef"
|
||
:scrollY="240"
|
||
:scrollX="tableScrollX"
|
||
:columns="tableColumns"
|
||
:data="tableData"
|
||
:paginationConfig="{
|
||
showSizeChanger: false,
|
||
showQuickJumper: false
|
||
}"
|
||
@sort-change="handleSortChange"
|
||
/>
|
||
</a-spin>
|
||
</div>
|
||
</a-spin>
|
||
</SidePanelItem>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
import {
|
||
ref,
|
||
watch,
|
||
computed,
|
||
onMounted,
|
||
onBeforeUnmount,
|
||
nextTick
|
||
} from 'vue';
|
||
import dayjs, { Dayjs } from 'dayjs';
|
||
import * as echarts from 'echarts';
|
||
import { useUiStore } from '@/store/modules/ui';
|
||
import { getFlowStationList } from '@/api/DataQueryMenuModule';
|
||
import { useModelStore } from '@/store/modules/model';
|
||
import SidePanelItem from '@/components/SidePanelItem/index.vue';
|
||
import BasicTable from '@/components/BasicTable/index.vue';
|
||
import { useServerSortTableData } from '@/hooks/useServerSortTableData';
|
||
import { DateSetting } from '@/utils/enumeration';
|
||
|
||
const uiStore = useUiStore();
|
||
const modelStore = useModelStore();
|
||
|
||
const isLoading = ref(false);
|
||
const chartData = ref<any[]>([]);
|
||
const tableRef = ref<any>();
|
||
|
||
const isDataEmpty = computed(() => {
|
||
const data = Array.isArray(chartData.value) ? chartData.value : [];
|
||
return data.length === 0;
|
||
});
|
||
|
||
// ==================== 图表 ====================
|
||
const chartRef = ref<HTMLElement>();
|
||
let chartInstance: echarts.ECharts | null = null;
|
||
let resizeObserver: ResizeObserver | null = null;
|
||
|
||
const initChart = () => {
|
||
if (!chartRef.value) return;
|
||
if (chartInstance) chartInstance.dispose();
|
||
chartInstance = echarts.init(chartRef.value);
|
||
updateChart(chartData.value);
|
||
};
|
||
|
||
const updateChart = (data: any[]) => {
|
||
if (!chartInstance) return;
|
||
if (!data || data.length === 0) {
|
||
chartInstance.clear();
|
||
return;
|
||
}
|
||
|
||
const sorted = [...data].sort(
|
||
(a, b) => new Date(a.tm).getTime() - new Date(b.tm).getTime()
|
||
);
|
||
|
||
const xAxisData = sorted.map(item => {
|
||
const hm = dayjs(item.tm).format('HH:mm');
|
||
if (hm === '00:00') {
|
||
const date = dayjs(item.tm).format('MM-DD');
|
||
return `${hm}\n${date}`;
|
||
}
|
||
return hm;
|
||
});
|
||
|
||
const zData = sorted.map(item => item.z);
|
||
const qData = sorted.map(item => item.q);
|
||
const vData = sorted.map(item => item.v);
|
||
|
||
const waterValues = zData.filter(v => v !== null && v !== undefined);
|
||
const waterMin = waterValues.length > 0 ? Math.min(...waterValues) : 0;
|
||
const waterMax = waterValues.length > 0 ? Math.max(...waterValues) : 10;
|
||
|
||
const flowValues = [...qData, ...vData].filter(
|
||
v => v !== null && v !== undefined
|
||
);
|
||
const flowMin = flowValues.length > 0 ? Math.min(...flowValues) : 0;
|
||
const flowMax = flowValues.length > 0 ? Math.max(...flowValues) : 10;
|
||
|
||
const formatRules: Record<
|
||
string,
|
||
{ format: (v: number) => string; unit: string }
|
||
> = {
|
||
水位: { format: v => v.toFixed(2), unit: '(m)' },
|
||
流量: { format: v => String(Math.round(v)), unit: '(m³/s)' },
|
||
流速: { format: v => v.toFixed(2), unit: '(m³/s)' }
|
||
};
|
||
|
||
const option = {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
backgroundColor: 'rgba(50, 50, 50, 0.9)',
|
||
textStyle: { color: '#fff', fontSize: 12 },
|
||
axisPointer: { type: 'cross' },
|
||
formatter: (params: any) => {
|
||
if (!params || params.length === 0) return '';
|
||
const dataIndex = params[0].dataIndex;
|
||
const fullTime = sorted[dataIndex]?.tm
|
||
? dayjs(sorted[dataIndex].tm).format('YYYY-MM-DD HH:mm:ss')
|
||
: '';
|
||
let html = `<div style="font-size:16px;margin-bottom:8px;">${fullTime}</div>`;
|
||
params.forEach((param: any) => {
|
||
if (param.value == null) return;
|
||
const rule = Object.entries(formatRules).find(([key]) =>
|
||
param.seriesName.includes(key)
|
||
);
|
||
const displayValue = rule
|
||
? rule[1].format(Number(param.value))
|
||
: param.value;
|
||
const unit = rule ? rule[1].unit : '';
|
||
html += `
|
||
<div style="display:flex;align-items:center;justify-content:space-between;margin:4px 0;">
|
||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${param.color};margin-right:8px;"></span>
|
||
<span style="flex:1;font-size:14px;text-align:left;margin-right:6px;">${param.seriesName}: </span>
|
||
<span style="font-size:14px;min-width:60px;text-align:right;"><strong>${displayValue}</strong> ${unit}</span>
|
||
</div>
|
||
`;
|
||
});
|
||
return html;
|
||
}
|
||
},
|
||
legend: {
|
||
top: 0,
|
||
data: ['水位', '流量', '流速'],
|
||
textStyle: { fontSize: 12 },
|
||
selected: {
|
||
水位: true,
|
||
流量: true,
|
||
流速: true
|
||
}
|
||
},
|
||
grid: {
|
||
left: 60,
|
||
right: 100,
|
||
top: 60,
|
||
bottom: 50
|
||
},
|
||
xAxis: {
|
||
type: 'category',
|
||
data: xAxisData,
|
||
axisLine: { lineStyle: { color: '#000000' } },
|
||
axisTick: { show: false },
|
||
axisLabel: { fontSize: 12 },
|
||
splitLine: {
|
||
show: true,
|
||
lineStyle: { color: '#bfbfbf', type: 'solid' }
|
||
}
|
||
},
|
||
yAxis: [
|
||
{
|
||
name: '水位(m)',
|
||
type: 'value',
|
||
position: 'left',
|
||
axisLine: { show: true, lineStyle: { color: '#56C2E3' } },
|
||
axisLabel: { color: '#56C2E3' },
|
||
nameTextStyle: { color: '#56C2E3' },
|
||
alignTicks: true,
|
||
scale: true,
|
||
splitNumber: 9
|
||
},
|
||
{
|
||
name: '流量(m³/s)',
|
||
type: 'value',
|
||
position: 'right',
|
||
axisLine: { show: true, lineStyle: { color: '#9556A4' } },
|
||
axisLabel: { color: '#9556A4' },
|
||
nameTextStyle: { color: '#9556A4' },
|
||
alignTicks: true,
|
||
scale: true,
|
||
splitNumber: 9
|
||
},
|
||
{
|
||
name: '流速(m³/s)',
|
||
type: 'value',
|
||
position: 'right',
|
||
axisLine: { show: true, lineStyle: { color: '#78C300' } },
|
||
axisLabel: { color: '#78C300' },
|
||
nameTextStyle: { color: '#78C300' },
|
||
alignTicks: true,
|
||
scale: true,
|
||
splitNumber: 9,
|
||
offset: 60
|
||
}
|
||
],
|
||
series: [
|
||
{
|
||
name: '水位',
|
||
type: 'line',
|
||
yAxisIndex: 0,
|
||
data: zData,
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 4,
|
||
lineStyle: { color: '#56C2E3', width: 2 },
|
||
itemStyle: { color: '#56C2E3' }
|
||
},
|
||
{
|
||
name: '流量',
|
||
type: 'line',
|
||
yAxisIndex: 1,
|
||
data: qData,
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 4,
|
||
lineStyle: { color: '#9556A4', width: 2 },
|
||
itemStyle: { color: '#9556A4' }
|
||
},
|
||
{
|
||
name: '流速',
|
||
type: 'line',
|
||
yAxisIndex: 2,
|
||
data: vData,
|
||
smooth: true,
|
||
symbol: 'circle',
|
||
symbolSize: 4,
|
||
lineStyle: { color: '#78C300', width: 2 },
|
||
itemStyle: { color: '#78C300' }
|
||
}
|
||
],
|
||
dataZoom: [
|
||
{
|
||
type: 'inside',
|
||
xAxisIndex: [0],
|
||
throttle: 50,
|
||
start: 0,
|
||
end: 100
|
||
}
|
||
],
|
||
toolbox: {
|
||
show: true,
|
||
feature: {
|
||
saveAsImage: { title: '保存为图片', type: 'png', pixelRatio: 2 }
|
||
},
|
||
right: 20,
|
||
top: -6
|
||
}
|
||
};
|
||
|
||
chartInstance.setOption(option, true);
|
||
};
|
||
|
||
// 数据变化时动态刷新图表
|
||
watch(chartData, newData => {
|
||
updateChart(newData);
|
||
});
|
||
|
||
const safeResize = () => {
|
||
if (!chartInstance) return;
|
||
// 容器不可见(display:none / 宽度为 0)时跳过,避免收起/切换时做昂贵的同步重排
|
||
if (!chartRef.value || chartRef.value.clientWidth === 0) return;
|
||
chartInstance.resize();
|
||
};
|
||
|
||
const handleResize = () => {
|
||
safeResize();
|
||
};
|
||
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
initChart();
|
||
setTimeout(() => {
|
||
safeResize();
|
||
}, 200);
|
||
if (!resizeObserver && chartRef.value) {
|
||
resizeObserver = new ResizeObserver(() => {
|
||
safeResize();
|
||
});
|
||
resizeObserver.observe(chartRef.value);
|
||
}
|
||
});
|
||
window.addEventListener('resize', handleResize);
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
if (chartInstance) {
|
||
chartInstance.dispose();
|
||
chartInstance = null;
|
||
}
|
||
window.removeEventListener('resize', handleResize);
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect();
|
||
resizeObserver = null;
|
||
}
|
||
});
|
||
|
||
// 时间选择器配置
|
||
const showTimeConfig = {
|
||
format: 'HH:mm',
|
||
hourStep: 1,
|
||
minuteStep: 5,
|
||
secondStep: 60
|
||
};
|
||
const disabledDate = (current: Dayjs) =>
|
||
current && current.isAfter(dayjs(), 'day');
|
||
|
||
const initDateRange = (): [Dayjs, Dayjs] => {
|
||
return [dayjs().subtract(1, 'month'), dayjs()];
|
||
};
|
||
const dateRange = ref<[Dayjs, Dayjs] | undefined>(initDateRange());
|
||
|
||
// ==================== 表格列配置 ====================
|
||
const tableColumns = [
|
||
{
|
||
title: '时间',
|
||
dataIndex: 'tm',
|
||
width: 160,
|
||
fixed: 'left',
|
||
sort: true,
|
||
customRender: ({ text }: any) =>
|
||
text ? dayjs(text).format('YYYY-MM-DD HH:mm:ss') : '-'
|
||
},
|
||
{
|
||
title: '水位(m)',
|
||
dataIndex: 'z',
|
||
width: 90,
|
||
sort: true,
|
||
customRender: ({ text }: any) =>
|
||
text !== undefined && text !== null ? Number(text).toFixed(2) : '-'
|
||
},
|
||
{
|
||
title: '流量(m³/s)',
|
||
dataIndex: 'q',
|
||
width: 100,
|
||
sort: true,
|
||
customRender: ({ text }: any) =>
|
||
text !== undefined && text !== null ? Number(text).toFixed(1) : '-'
|
||
},
|
||
{
|
||
title: '流速(m³/s)',
|
||
dataIndex: 'v',
|
||
width: 100,
|
||
sort: true,
|
||
customRender: ({ text }: any) =>
|
||
text !== undefined && text !== null ? Number(text).toFixed(2) : '-'
|
||
}
|
||
];
|
||
|
||
const tableScrollX = computed(() => {
|
||
const totalWidth = tableColumns.reduce(
|
||
(sum: number, col: any) => sum + (col.width || 100),
|
||
0
|
||
);
|
||
return totalWidth > 600 ? totalWidth : undefined;
|
||
});
|
||
|
||
// ==================== 数据请求 ====================
|
||
// 构造表格/图表请求参数(stcd + 时间范围),排序传入时叠加 sort
|
||
const buildSearchParams = (sort?: { field: string; dir: 'asc' | 'desc' }) => {
|
||
const stcd = modelStore.selectedAnchorPoint?.stcd;
|
||
if (!dateRange.value || !stcd) return null;
|
||
return {
|
||
filter: {
|
||
logic: 'and',
|
||
filters: [
|
||
{
|
||
field: 'stcd',
|
||
operator: 'eq',
|
||
dataType: 'string',
|
||
value: stcd
|
||
},
|
||
{
|
||
field: 'tm',
|
||
operator: 'gte',
|
||
dataType: 'date',
|
||
value: dateRange.value[0].format('YYYY-MM-DD HH:mm:ss')
|
||
},
|
||
{
|
||
field: 'tm',
|
||
operator: 'lte',
|
||
dataType: 'date',
|
||
value: dateRange.value[1].format('YYYY-MM-DD HH:mm:ss')
|
||
}
|
||
]
|
||
},
|
||
sort: sort ? [sort] : [{ field: 'tm', dir: 'asc' }]
|
||
};
|
||
};
|
||
|
||
// data 模式表格:点击排序时重新请求接口(服务端排序)
|
||
const {
|
||
tableData,
|
||
isLoading: tableLoading,
|
||
handleSortChange
|
||
} = useServerSortTableData<any>({
|
||
request: getFlowStationList,
|
||
buildSearchParams
|
||
});
|
||
|
||
// 数据请求(查询参数取 modelStore.selectedAnchorPoint)
|
||
const fetchData = async () => {
|
||
const params = buildSearchParams();
|
||
if (!params) return;
|
||
isLoading.value = true;
|
||
|
||
try {
|
||
const res = await getFlowStationList(params);
|
||
const rawData = res?.data?.data || res?.data?.records || [];
|
||
|
||
tableData.value = [...rawData].reverse();
|
||
chartData.value = rawData;
|
||
} catch (error) {
|
||
console.error('获取数据失败:', error);
|
||
tableData.value = [];
|
||
chartData.value = [];
|
||
} finally {
|
||
isLoading.value = false;
|
||
}
|
||
};
|
||
|
||
// 时间选择器 change 触发查询
|
||
const handleSearch = () => fetchData();
|
||
watch(
|
||
() => uiStore.searchDrawerOpen,
|
||
newVal => {
|
||
if (!newVal) {
|
||
dateRange.value = initDateRange();
|
||
}
|
||
}
|
||
);
|
||
// 选中点位变化时刷新;初始已有点位时立即加载
|
||
watch(
|
||
() => modelStore.selectedAnchorPoint?.stcd,
|
||
(newStcd, oldStcd) => {
|
||
if (!newStcd) return;
|
||
if (newStcd !== oldStcd) {
|
||
dateRange.value = initDateRange();
|
||
fetchData();
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// 全局时间范围变化时同步本地日期选择器(默认参数联动)
|
||
watch(
|
||
() => modelStore.filter.rangeTm,
|
||
newRange => {
|
||
if (Array.isArray(newRange) && newRange.length > 0) {
|
||
dateRange.value = [dayjs(newRange[0]), dayjs(newRange[1])];
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.monitor-chart {
|
||
position: relative;
|
||
width: 100%;
|
||
height: 300px;
|
||
|
||
.chart-container {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
}
|
||
|
||
.chart-empty {
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
|
||
.monitor-table {
|
||
margin-top: 10px;
|
||
box-sizing: border-box;
|
||
// 表格列数多、tableScrollX 较宽时必须限制在抽屉宽度内横向滚动,
|
||
// 否则在 RightDrawer 的 .ant-drawer-content { overflow: visible } 下会溢出抽屉
|
||
width: 410px;
|
||
overflow-x: auto;
|
||
|
||
.table-container {
|
||
border: 1px solid #dcdfe6;
|
||
}
|
||
}
|
||
</style>
|