feat(steady): 完善稳态数据视图功能

- 更新纵坐标刻度算法,优化小数趋势图范围显示
- 添加稳态趋势图全屏模式和共享工具组件
- 实现多图联动的鼠标悬停竖线同步功能
- 调整主线线宽分档策略,降低最大线宽限制
- 重构稳态趋势工具栏,优化谐波次数选择逻辑
- 添加周时间周期搜索支持和自定义时间范围选择
- 完善稳态数据表格和指示器浮动面板功能
- 优化稳态趋势图性能,添加LTB采样和动画控制
- 修复数据表格打开前的趋势数据验证问题
- 统一时间轴标签格式化和网格对齐处理
This commit is contained in:
2026-05-27 08:06:12 +08:00
parent b9ddfb5275
commit 055e69fff7
83 changed files with 9616 additions and 226 deletions

View File

@@ -1,40 +1,23 @@
<template>
<section class="card trend-chart-panel" v-loading="loading">
<section class="card trend-chart-panel">
<div class="panel-header">
<span v-if="trendResult" class="panel-meta">{{ trendResult.displayPointCount || 0 }} </span>
<span v-else class="panel-meta">趋势图</span>
<div class="trend-tool-groups">
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
<el-tooltip
v-for="item in group.items"
:key="item.action"
:content="getTrendToolTooltip(item)"
placement="top"
>
<el-button
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
:icon="item.icon"
:disabled="isTrendToolDisabled(item.action)"
circle
@click="handleTrendToolAction(item.action)"
/>
</el-tooltip>
</div>
</div>
<SteadyTrendChartTools
:tool-groups="trendToolGroups"
:is-tool-active="isTrendToolActive"
:is-tool-disabled="isTrendToolDisabled"
:get-tool-tooltip="getTrendToolTooltip"
@tool-action="handleTrendToolAction"
/>
<span v-if="trendResult" class="panel-meta">总点数{{ trendResult.displayPointCount || 0 }}</span>
</div>
<div v-if="hasSeries" ref="chartExportTargetRef" class="chart-list steady-trend-export-target">
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
<div class="chart-body">
<LineChart :options="group.options" :group="group.group" @chart-data-zoom="handleChartDataZoom" />
</div>
</div>
</div>
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
<el-dialog v-model="fullscreenVisible" title="趋势图全屏展示" fullscreen append-to-body destroy-on-close>
<div v-if="hasSeries" class="fullscreen-chart-body">
<div class="chart-panel-body" v-loading="loading">
<div
v-if="hasChartFrame"
ref="chartExportTargetRef"
class="chart-list steady-trend-export-target"
:style="{ '--steady-trend-visible-chart-count': normalVisibleChartCount }"
>
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
<div class="chart-body">
<LineChart
@@ -45,8 +28,21 @@
</div>
</div>
</div>
<el-empty v-else-if="hasQueriedWithoutData" class="chart-empty" description="暂无数据" />
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
</el-dialog>
</div>
<SteadyTrendFullscreen
v-model="fullscreenVisible"
:chart-groups="chartGroups"
:visible-chart-count="fullscreenVisibleChartCount"
:tool-groups="fullscreenToolGroups"
:is-tool-active="isTrendToolActive"
:is-tool-disabled="isTrendToolDisabled"
:get-tool-tooltip="getTrendToolTooltip"
@chart-data-zoom="handleChartDataZoom"
@tool-action="handleTrendToolAction"
/>
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
</section>
@@ -60,6 +56,7 @@ import {
ArrowUpBold,
Crop,
DataAnalysis,
DataLine,
FullScreen,
Mouse,
Picture,
@@ -72,8 +69,10 @@ import { computed, nextTick, ref, watch } from 'vue'
import LineChart from '@/components/echarts/line/index.vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
import type { Component } from 'vue'
import SteadyTrendFullscreen from './SteadyTrendFullscreen.vue'
defineOptions({
name: 'SteadyTrendChartPanel'
@@ -85,43 +84,24 @@ const props = defineProps<{
}>()
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
type SteadyTrendToolAction =
| 'x-zoom-in'
| 'x-zoom-out'
| 'y-zoom-in'
| 'y-zoom-out'
| 'box-zoom'
| 'wheel-zoom'
| 'reset'
| 'pan'
| 'fullscreen'
| 'download-image'
| 'query-data'
const trendToolGroups: Array<{
key: string
items: Array<{
action: SteadyTrendToolAction
label: string
icon: Component
}>
}> = [
const trendToolGroups: SteadyTrendToolGroup[] = [
{
key: 'viewport',
items: [
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse },
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
{ action: 'box-zoom', label: '框选放大', icon: Crop },
{ action: 'reset', label: '恢复', icon: RefreshLeft },
{ action: 'pan', label: '平移', icon: Pointer }
{ action: 'pan', label: '平移', icon: Pointer },
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse }
]
},
{
key: 'view',
items: [
{ action: 'missing-data', label: '缺失数据', icon: DataLine },
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
{ action: 'download-image', label: '下载图片', icon: Picture },
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
@@ -130,22 +110,51 @@ const trendToolGroups: Array<{
]
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 10 }
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 100 }
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
const STEADY_TREND_HALF_RANGE_DAYS = 20
const STEADY_TREND_QUARTER_RANGE_DAYS = 30
const STEADY_TREND_TENTH_RANGE_DAYS = 60
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
const defaultTrendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
const trendYZoomScale = ref(1)
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
const wheelZoomEnabled = ref(false)
const missingDataEnabled = ref(true)
const fullscreenVisible = ref(false)
const dataTableVisible = ref(false)
const chartExportTargetRef = ref<HTMLElement>()
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
const hasQueryTimeRange = computed(() => Boolean(props.trendResult?.queryTimeStart && props.trendResult?.queryTimeEnd))
const hasDataPoints = computed(() =>
Boolean(
props.trendResult?.series?.some(series =>
(series.points || []).some(point => typeof point.value === 'number' && Number.isFinite(point.value))
)
)
)
const hasChartFrame = computed(() => hasSeries.value || (hasQueryTimeRange.value && !props.trendResult?.queryCompleted))
const hasQueriedWithoutData = computed(() => Boolean(props.trendResult?.queryCompleted && !hasDataPoints.value))
const chartGroups = computed(() =>
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
activeTool: activeTrendInteractionMode.value,
wheelZoomEnabled: wheelZoomEnabled.value,
yZoomScale: trendYZoomScale.value
showMissingData: missingDataEnabled.value,
yZoomScale: trendYZoomScale.value,
queryTimeStart: props.trendResult?.queryTimeStart,
queryTimeEnd: props.trendResult?.queryTimeEnd
})
)
const normalVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 3), 1))
const fullscreenVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 6), 1))
const fullscreenToolGroups = computed(() =>
trendToolGroups
.map(group => ({
...group,
items: group.items.filter(item => item.action !== 'fullscreen')
}))
.filter(group => group.items.length)
)
const canPanTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
@@ -153,19 +162,60 @@ const canPanTrendChart = computed(() => {
})
const isDefaultTrendXZoomRange = computed(() => {
const { start, end } = trendXZoomRange.value
const defaultRange = defaultTrendXZoomRange.value
return start === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.start && end === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.end
return start === defaultRange.start && end === defaultRange.end
})
const canResetTrendChart = computed(() => {
const changedYZoom = trendYZoomScale.value !== 1
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
const changedWheelZoom = wheelZoomEnabled.value
return hasSeries.value && (!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
return (
hasSeries.value &&
(!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
)
})
const parseSteadyTrendTime = (value?: string) => {
if (!value) return null
const timestamp = Date.parse(value.replace(' ', 'T'))
return Number.isFinite(timestamp) ? timestamp : null
}
const resolveSteadyTrendTimeRangeMs = (trendResult: SteadyDataView.SteadyTrendQueryResult | null) => {
let minTime = Number.POSITIVE_INFINITY
let maxTime = Number.NEGATIVE_INFINITY
trendResult?.series?.forEach(series => {
series.points?.forEach(point => {
const timestamp = parseSteadyTrendTime(point.time)
if (timestamp === null) return
minTime = Math.min(minTime, timestamp)
maxTime = Math.max(maxTime, timestamp)
})
})
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime ? maxTime - minTime : 0
}
const resolveSteadyTrendDefaultZoomRange = (trendResult: SteadyDataView.SteadyTrendQueryResult | null) => {
const timeRangeMs = resolveSteadyTrendTimeRangeMs(trendResult)
const timeRangeDays = timeRangeMs / STEADY_TREND_DAY_MS
if (timeRangeDays > STEADY_TREND_TENTH_RANGE_DAYS) return { start: 0, end: 10 }
if (timeRangeDays > STEADY_TREND_QUARTER_RANGE_DAYS) return { start: 0, end: 25 }
if (timeRangeDays > STEADY_TREND_HALF_RANGE_DAYS) return { start: 0, end: 50 }
return { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
}
const resetTrendToolState = () => {
trendXZoomRange.value = { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
trendXZoomRange.value = { ...defaultTrendXZoomRange.value }
trendYZoomScale.value = 1
activeTrendInteractionMode.value = 'none'
wheelZoomEnabled.value = false
@@ -194,6 +244,7 @@ const zoomTrendXAxis = (ratio: number) => {
const isTrendToolActive = (action: SteadyTrendToolAction) => {
if (action === 'fullscreen') return fullscreenVisible.value
if (action === 'wheel-zoom') return wheelZoomEnabled.value
if (action === 'missing-data') return missingDataEnabled.value
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
return false
@@ -201,14 +252,14 @@ const isTrendToolActive = (action: SteadyTrendToolAction) => {
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
if (action === 'query-data') return false
if (!hasSeries.value) return true
if (!hasChartFrame.value) return true
if (action === 'pan') return !canPanTrendChart.value
if (action === 'reset') return !canResetTrendChart.value
return false
}
const getTrendToolTooltip = (item: { action: SteadyTrendToolAction; label: string }) => {
const getTrendToolTooltip = (item: SteadyTrendToolItem) => {
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
return '请先放大 X 轴或框选局部区域后再平移'
}
@@ -219,7 +270,9 @@ const getTrendToolTooltip = (item: { action: SteadyTrendToolAction; label: strin
const downloadSteadyTrendImage = async () => {
await nextTick()
const targetElement = chartExportTargetRef.value
const targetElement = fullscreenVisible.value
? (document.querySelector('.steady-trend-fullscreen__chart-list') as HTMLElement | null)
: chartExportTargetRef.value
if (!targetElement) {
ElMessage.warning('暂无可下载的趋势图')
@@ -266,6 +319,9 @@ const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
case 'wheel-zoom':
wheelZoomEnabled.value = !wheelZoomEnabled.value
break
case 'missing-data':
missingDataEnabled.value = !missingDataEnabled.value
break
case 'pan':
if (!canPanTrendChart.value) {
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
@@ -309,7 +365,8 @@ const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
watch(
() => props.trendResult,
() => {
// 新查询结果应回到完整时间范围,避免沿用上一批数据的局部缩放窗口
// 新查询结果按当前数据量重置默认窗口,避免沿用上一批数据的局部缩放范围
defaultTrendXZoomRange.value = resolveSteadyTrendDefaultZoomRange(props.trendResult)
resetTrendToolState()
}
)
@@ -317,6 +374,9 @@ watch(
<style scoped lang="scss">
.trend-chart-panel {
--steady-trend-chart-gap: 8px;
--steady-trend-visible-chart-count: 3;
display: flex;
flex-direction: column;
min-width: 0;
@@ -330,63 +390,44 @@ watch(
flex: none;
align-items: center;
justify-content: flex-start;
gap: 12px;
gap: 0;
margin-bottom: 10px;
}
.panel-meta {
margin-left: 15px;
color: var(--el-text-color-secondary);
font-size: 12px;
white-space: nowrap;
}
.trend-tool-groups {
.chart-panel-body {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
min-width: 0;
}
.trend-tool-group {
display: flex;
align-items: center;
gap: 4px;
}
.trend-tool-group + .trend-tool-group {
padding-left: 8px;
border-left: 1px dashed var(--el-border-color);
}
.trend-tool-group :deep(.el-button.is-circle) {
width: 28px;
height: 28px;
padding: 6px;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.chart-list {
display: flex;
flex: 1;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
gap: var(--steady-trend-chart-gap);
min-height: 0;
overflow: auto;
}
.fullscreen-chart-body {
display: flex;
flex-direction: column;
gap: 8px;
height: calc(100vh - 96px);
min-height: 0;
overflow: auto;
overflow-x: hidden;
overflow-y: auto;
}
.chart-group {
box-sizing: border-box;
display: flex;
flex: 1 0 240px;
flex: 0 0
calc(
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
var(--steady-trend-visible-chart-count)
);
flex-direction: column;
min-height: 220px;
min-height: 0;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;