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,5 +1,5 @@
<template>
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': collapsed }">
<aside class="indicator-floating-panel" :class="[`is-${mode}`, { 'is-collapsed': collapsed }]">
<el-button
class="indicator-toggle"
type="primary"
@@ -11,9 +11,7 @@
<SteadyIndicatorTree
:key="selectorResetKey"
:tree-data="treeData"
:loading="loading"
:default-checked-keys="defaultCheckedKeys"
@refresh="emit('refresh')"
@change="emit('change', $event)"
/>
</div>
@@ -29,17 +27,21 @@ defineOptions({
name: 'SteadyIndicatorFloatingPanel'
})
defineProps<{
collapsed: boolean
treeData: SteadyDataView.SteadyIndicatorNode[]
loading: boolean
defaultCheckedKeys: string[]
selectorResetKey: number
}>()
withDefaults(
defineProps<{
collapsed: boolean
treeData: SteadyDataView.SteadyIndicatorNode[]
defaultCheckedKeys: string[]
selectorResetKey: number
mode?: 'floating' | 'inline'
}>(),
{
mode: 'floating'
}
)
const emit = defineEmits<{
'update:collapsed': [value: boolean]
refresh: []
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
}>()
</script>
@@ -59,6 +61,19 @@ const emit = defineEmits<{
width: 0;
}
.indicator-floating-panel.is-inline {
position: relative;
top: auto;
right: auto;
bottom: auto;
width: 300px;
height: 100%;
}
.indicator-floating-panel.is-inline.is-collapsed {
width: 0;
}
.indicator-toggle {
position: absolute;
top: 12px;
@@ -84,5 +99,9 @@ const emit = defineEmits<{
.indicator-floating-panel {
width: 280px;
}
.indicator-floating-panel.is-inline {
width: 280px;
}
}
</style>

View File

@@ -2,7 +2,6 @@
<section class="card steady-tree-card indicator-tree">
<div class="panel-header">
<span class="panel-title">稳态指标</span>
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
</div>
<el-scrollbar class="tree-scrollbar">
@@ -31,7 +30,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { collectLeafIndicators } from '../utils/selectionRules'
@@ -42,12 +40,10 @@ defineOptions({
const props = defineProps<{
treeData: SteadyDataView.SteadyIndicatorNode[]
loading: boolean
defaultCheckedKeys: string[]
}>()
const emit = defineEmits<{
refresh: []
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
}>()

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;

View File

@@ -0,0 +1,61 @@
<template>
<div class="trend-tool-groups">
<div v-for="group in toolGroups" :key="group.key" class="trend-tool-group">
<el-tooltip v-for="item in group.items" :key="item.action" :content="getToolTooltip(item)" placement="top">
<el-button
:type="isToolActive(item.action) ? 'primary' : 'default'"
:icon="item.icon"
:disabled="isToolDisabled(item.action)"
circle
@click="emit('tool-action', item.action)"
/>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
defineOptions({
name: 'SteadyTrendChartTools'
})
defineProps<{
toolGroups: SteadyTrendToolGroup[]
isToolActive: (action: SteadyTrendToolAction) => boolean
isToolDisabled: (action: SteadyTrendToolAction) => boolean
getToolTooltip: (item: SteadyTrendToolItem) => string
}>()
const emit = defineEmits<{
'tool-action': [action: SteadyTrendToolAction]
}>()
</script>
<style scoped lang="scss">
.trend-tool-groups {
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;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="steady-trend-fullscreen">
<header class="steady-trend-fullscreen__header">
<span class="steady-trend-fullscreen__title">趋势图全屏展示</span>
<el-button class="steady-trend-fullscreen__close" :icon="Close" text circle @click="closeFullscreen" />
</header>
<main class="steady-trend-fullscreen__body">
<div class="steady-trend-fullscreen__tool-row">
<SteadyTrendChartTools
class="steady-trend-fullscreen__tools"
:tool-groups="toolGroups"
:is-tool-active="isToolActive"
:is-tool-disabled="isToolDisabled"
:get-tool-tooltip="getToolTooltip"
@tool-action="emit('tool-action', $event)"
/>
</div>
<div
v-if="chartGroups.length"
class="steady-trend-fullscreen__chart-list"
:style="{ '--steady-trend-visible-chart-count': visibleChartCount }"
>
<div v-for="group in chartGroups" :key="group.key" class="steady-trend-fullscreen__chart-group">
<div class="steady-trend-fullscreen__chart-body">
<LineChart
:options="group.options"
:group="group.group"
@chart-data-zoom="handleChartDataZoom"
/>
</div>
</div>
</div>
<el-empty v-else class="steady-trend-fullscreen__empty" description="请选择监测点和指标后查询趋势" />
</main>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { Close } from '@element-plus/icons-vue'
import { onBeforeUnmount, onMounted } from 'vue'
import LineChart from '@/components/echarts/line/index.vue'
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
import type { SteadyTrendChartGroup, SteadyTrendZoomRange } from '../utils/trendOptions'
defineOptions({
name: 'SteadyTrendFullscreen'
})
const props = defineProps<{
modelValue: boolean
chartGroups: SteadyTrendChartGroup[]
visibleChartCount: number
toolGroups: SteadyTrendToolGroup[]
isToolActive: (action: SteadyTrendToolAction) => boolean
isToolDisabled: (action: SteadyTrendToolAction) => boolean
getToolTooltip: (item: SteadyTrendToolItem) => string
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'chart-data-zoom': [value: SteadyTrendZoomRange]
'tool-action': [action: SteadyTrendToolAction]
}>()
const closeFullscreen = () => {
emit('update:modelValue', false)
}
const handleKeydown = (event: KeyboardEvent) => {
if (!props.modelValue || event.key !== 'Escape') return
closeFullscreen()
}
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
emit('chart-data-zoom', value)
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleKeydown)
})
</script>
<style scoped lang="scss">
.steady-trend-fullscreen {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--el-bg-color);
}
.steady-trend-fullscreen__header {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 10px;
color: #ffffff;
background: var(--el-color-primary);
}
.steady-trend-fullscreen__title {
min-width: 0;
overflow: hidden;
font-size: 16px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.steady-trend-fullscreen__close {
flex: none;
color: #ffffff;
}
.steady-trend-fullscreen__body {
--steady-trend-chart-gap: 8px;
--steady-trend-visible-chart-count: 6;
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 8px;
min-height: 0;
padding: 12px;
overflow: hidden;
}
.steady-trend-fullscreen__tool-row {
position: static;
display: flex;
flex: none;
align-items: center;
justify-content: flex-start;
min-width: 0;
}
.steady-trend-fullscreen__tools {
flex: 0 1 auto;
min-width: 0;
}
.steady-trend-fullscreen__chart-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--steady-trend-chart-gap);
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
}
.steady-trend-fullscreen__chart-group {
box-sizing: border-box;
display: flex;
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: 0;
overflow: hidden;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--cn-color-canvas-bg);
}
.steady-trend-fullscreen__chart-body {
flex: 1;
min-height: 0;
padding: 0 8px 8px;
}
.steady-trend-fullscreen__empty {
flex: 1;
}
</style>

View File

@@ -23,16 +23,18 @@
</div>
<div class="toolbar-field">
<span class="toolbar-field__label">数据</span>
<el-select
:model-value="modelValue.qualityFlag"
clearable
placeholder="选择数据质量"
@update:model-value="updateField('qualityFlag', $event)"
>
<el-option label="仅有效数据" :value="0" />
<el-option label="仅无效数据" :value="1" />
</el-select>
<span class="toolbar-field__label">数据质量</span>
<el-switch
:model-value="modelValue.qualityFlag ?? 0"
class="quality-switch"
width="72"
inline-prompt
active-text="有效"
inactive-text="无效"
:active-value="0"
:inactive-value="1"
@update:model-value="handleQualityFlagChange"
/>
</div>
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
@@ -40,10 +42,8 @@
<el-select
:model-value="modelValue.harmonicOrders"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择谐波次数"
@update:model-value="updateField('harmonicOrders', $event)"
@update:model-value="handleHarmonicOrdersChange"
>
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
</el-select>
@@ -57,9 +57,11 @@
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
import { MAX_HARMONIC_ORDER_COUNT } from '../utils/selectionRules'
import type { SteadyTrendFormState } from '../utils/trendPayload'
defineOptions({
@@ -94,6 +96,40 @@ const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: Stea
})
}
const handleQualityFlagChange = (value: string | number | boolean) => {
updateField('qualityFlag', Number(value))
}
const normalizeHarmonicOrders = (orders: number[]) => {
return Array.from(
new Set(
orders
.map(item => Number(item))
.filter(item => Number.isInteger(item) && item >= 2 && item <= 50)
)
).sort((left, right) => left - right)
}
const updateHarmonicOrders = (orders: number[]) => {
const nextOrders = normalizeHarmonicOrders(orders)
if (nextOrders.length > MAX_HARMONIC_ORDER_COUNT) {
ElMessage.warning(`谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT}`)
const currentOrders = normalizeHarmonicOrders(props.modelValue.harmonicOrders)
updateField(
'harmonicOrders',
currentOrders.length ? currentOrders : nextOrders.slice(0, MAX_HARMONIC_ORDER_COUNT)
)
return
}
updateField('harmonicOrders', nextOrders)
}
const handleHarmonicOrdersChange = (value: number[]) => {
updateHarmonicOrders(value)
}
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
emit('update:modelValue', {
...props.modelValue,
@@ -144,6 +180,10 @@ const handleTimeBaseDateChange = (value: Date) => {
min-width: 0;
}
.quality-switch {
min-width: 72px;
}
.trend-toolbar__time {
flex: 1 1 0;
min-width: 0;

View File

@@ -34,9 +34,7 @@
v-model:collapsed="indicatorPanelCollapsedProxy"
:selector-reset-key="selectorResetKey"
:tree-data="indicatorTree"
:loading="loading.indicator"
:default-checked-keys="defaultIndicatorCheckedKeys"
@refresh="emit('refreshIndicator')"
@change="emit('indicatorChange', $event)"
/>
</div>
@@ -83,7 +81,6 @@ const emit = defineEmits<{
refreshLedger: []
ledgerSearch: [value: string]
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
refreshIndicator: []
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
queryTrend: []
resetTrend: []

View File

@@ -0,0 +1,26 @@
import type { Component } from 'vue'
export 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'
| 'missing-data'
export interface SteadyTrendToolItem {
action: SteadyTrendToolAction
label: string
icon: Component
}
export interface SteadyTrendToolGroup {
key: string
items: SteadyTrendToolItem[]
}

View File

@@ -5,10 +5,20 @@ import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const viewDir = path.join(currentDir, '..')
const read = file => fs.readFileSync(path.join(viewDir, file), 'utf8')
const read = file => {
const filePath = path.join(viewDir, file)
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
}
const toolbarSource = read('components/SteadyTrendToolbar.vue')
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
const fullscreenSource = read('components/SteadyTrendFullscreen.vue')
const chartToolsSource = read('components/SteadyTrendChartTools.vue')
const lineChartSource = fs.readFileSync(
path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'),
'utf8'
)
const trendPayloadSource = read('utils/trendPayload.ts')
const trendOptionsSource = read('utils/trendOptions.ts')
const selectionRulesSource = read('utils/selectionRules.ts')
@@ -46,14 +56,89 @@ const expectations = [
/v-for="group in chartGroups"[\s\S]*:options="group\.options"/,
chartPanelSource
],
[
'chart loading mask stays outside the fullscreen dialog tree',
/<section\s+class="card trend-chart-panel"(?![^>]*v-loading)[\s\S]*<div[^>]*class="chart-panel-body"[^>]*v-loading="loading"[\s\S]*<SteadyTrendFullscreen/,
chartPanelSource
],
[
'normal chart list caps visible charts at three while smaller counts fill the viewport',
/(?=[\s\S]*normalVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*3\))(?=[\s\S]*:style="\{\s*'--steady-trend-visible-chart-count':\s*normalVisibleChartCount\s*\}")/,
chartPanelSource
],
[
'fullscreen chart list caps visible charts at six while smaller counts fill the viewport',
/(?=[\s\S]*fullscreenVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*6\))(?=[\s\S]*:visible-chart-count="fullscreenVisibleChartCount")/,
chartPanelSource
],
[
'chart groups divide the current viewport by visible chart count',
/\.chart-group\s*\{[\s\S]*flex:\s*0 0\s*calc\(\s*\(100% - var\(--steady-trend-chart-gap\)\s*\*\s*\(var\(--steady-trend-visible-chart-count\)\s*-\s*1\)\)\s*\/\s*var\(--steady-trend-visible-chart-count\)\s*\)/,
chartPanelSource
],
[
'chart groups include their border in the divided height to avoid false scrollbars',
/\.chart-group\s*\{[\s\S]*box-sizing:\s*border-box/,
chartPanelSource
],
[
'chart panel delegates fullscreen rendering to a dedicated component',
/<SteadyTrendFullscreen[\s\S]*v-model="fullscreenVisible"[\s\S]*:chart-groups="chartGroups"[\s\S]*:visible-chart-count="fullscreenVisibleChartCount"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
chartPanelSource
],
[
'chart panel passes shared trend tools into fullscreen',
/<SteadyTrendFullscreen[\s\S]*:tool-groups="fullscreenToolGroups"[\s\S]*:is-tool-active="isTrendToolActive"[\s\S]*:is-tool-disabled="isTrendToolDisabled"[\s\S]*:get-tool-tooltip="getTrendToolTooltip"[\s\S]*@tool-action="handleTrendToolAction"/,
chartPanelSource
],
[
'fullscreen tool groups omit nested fullscreen action',
/fullscreenToolGroups[\s\S]*item\.action\s*!==\s*'fullscreen'/,
chartPanelSource
],
[
'chart panel does not use Element Plus dialog for steady fullscreen',
/^(?![\s\S]*<el-dialog[\s\S]*steady-trend-fullscreen)(?![\s\S]*fullscreen-chart-body)[\s\S]*$/,
chartPanelSource
],
[
'fullscreen component renders through a fixed body teleport',
/<Teleport\s+to="body">[\s\S]*class="steady-trend-fullscreen"[\s\S]*position:\s*fixed[\s\S]*inset:\s*0/,
fullscreenSource
],
[
'fullscreen component has an explicit close button and Esc handling',
/@click="closeFullscreen"[\s\S]*event\.key\s*!==\s*'Escape'[\s\S]*window\.addEventListener\('keydown',\s*handleKeydown\)/,
fullscreenSource
],
[
'fullscreen component gives chart body a concrete flex viewport',
/\.steady-trend-fullscreen__body\s*\{[\s\S]*display:\s*flex[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*min-height:\s*0[\s\S]*\.steady-trend-fullscreen__chart-list\s*\{[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*overflow-y:\s*auto/,
fullscreenSource
],
[
'fullscreen keeps trend tool groups in a real workspace row below the title bar',
/<main class="steady-trend-fullscreen__body">[\s\S]*<div class="steady-trend-fullscreen__tool-row">[\s\S]*<SteadyTrendChartTools[\s\S]*<\/div>[\s\S]*class="steady-trend-fullscreen__chart-list"/,
fullscreenSource
],
[
'fullscreen title header does not contain trend tool groups',
/<header class="steady-trend-fullscreen__header">(?:(?!SteadyTrendChartTools)[\s\S])*<\/header>/,
fullscreenSource
],
[
'fullscreen tool row reserves layout space instead of floating over charts',
/\.steady-trend-fullscreen__tool-row\s*\{(?=[\s\S]*position:\s*static)(?=[\s\S]*display:\s*flex)(?=[\s\S]*flex:\s*none)[\s\S]*\}/,
fullscreenSource
],
[
'chart panel syncs grouped charts through LineChart group and dataZoom events',
/<LineChart[\s\S]*:options="group\.options"[\s\S]*:group="group\.group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
chartPanelSource
],
[
'chart panel only shows trend point count without bucket prefix',
/trendResult\.displayPointCount\s*\|\|\s*0[\s\S]*点/,
'chart panel shows total point count with explicit label',
/class="panel-meta"[\s\S]*trendResult\.displayPointCount\s*\|\|\s*0/,
chartPanelSource
],
[
@@ -62,38 +147,50 @@ const expectations = [
chartPanelSource
],
[
'chart panel renders waveform-style trend tool groups',
/trend-tool-groups[\s\S]*trendToolGroups[\s\S]*handleTrendToolAction/,
'chart panel renders shared trend tool groups',
/<SteadyTrendChartTools[\s\S]*:tool-groups="trendToolGroups"[\s\S]*@tool-action="handleTrendToolAction"/,
chartPanelSource
],
[
'chart panel keeps trend toolbar next to point count on the left',
/\.panel-header\s*\{[\s\S]*justify-content:\s*flex-start/,
'chart panel keeps total point count on the right of the trend toolbar with 15px spacing',
/\.panel-meta\s*\{[\s\S]*margin-left:\s*15px/,
chartPanelSource
],
[
'shared chart tools component renders the trend tool buttons',
/trend-tool-groups[\s\S]*v-for="group in toolGroups"[\s\S]*v-for="item in group\.items"[\s\S]*@click="emit\('tool-action', item\.action\)"/,
chartToolsSource
],
[
'fullscreen renders shared trend tool groups',
/<SteadyTrendChartTools[\s\S]*:tool-groups="toolGroups"[\s\S]*@tool-action="emit\('tool-action', \$event\)"/,
fullscreenSource
],
[
'chart panel exposes core trend toolbar actions except marker and data export',
/x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*fullscreen[\s\S]*download-image/,
chartPanelSource
],
['chart panel exposes a wheel zoom toggle action', /wheel-zoom/, chartPanelSource],
[
'chart panel exposes a wheel zoom toggle action',
/wheel-zoom/,
chartPanelSource
],
[
'chart panel defaults wheel zoom disabled',
/const\s+wheelZoomEnabled\s*=\s*ref\(false\)/,
'chart panel places wheel zoom after pan in the viewport toolbar',
/items:\s*\[[\s\S]*action:\s*'pan'[\s\S]*action:\s*'wheel-zoom'/,
chartPanelSource
],
['chart panel defaults wheel zoom disabled', /const\s+wheelZoomEnabled\s*=\s*ref\(false\)/, chartPanelSource],
[
'chart panel passes wheel zoom state into grouped options',
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*wheelZoomEnabled:\s*wheelZoomEnabled\.value/,
chartPanelSource
],
[
'chart panel defaults to first tenth of the x range',
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*10\s*\}/,
'chart panel keeps full range as fallback x zoom range',
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*100\s*\}/,
chartPanelSource
],
[
'chart panel defaults x zoom range by 20 30 60 day thresholds',
/STEADY_TREND_HALF_RANGE_DAYS\s*=\s*20[\s\S]*STEADY_TREND_QUARTER_RANGE_DAYS\s*=\s*30[\s\S]*STEADY_TREND_TENTH_RANGE_DAYS\s*=\s*60[\s\S]*resolveSteadyTrendDefaultZoomRange[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_TENTH_RANGE_DAYS[\s\S]*end:\s*10[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_QUARTER_RANGE_DAYS[\s\S]*end:\s*25[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_HALF_RANGE_DAYS[\s\S]*end:\s*50/,
chartPanelSource
],
[
@@ -102,8 +199,8 @@ const expectations = [
chartPanelSource
],
[
'chart panel compares reset state against the default x zoom range',
/const isDefaultTrendXZoomRange[\s\S]*DEFAULT_STEADY_TREND_X_ZOOM_RANGE[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
'chart panel compares reset state against the current data-derived x zoom range',
/const defaultTrendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>[\s\S]*const isDefaultTrendXZoomRange[\s\S]*defaultTrendXZoomRange\.value[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
chartPanelSource
],
[
@@ -117,8 +214,8 @@ const expectations = [
chartPanelSource
],
[
'chart panel resets x zoom range to the default first tenth when trend result changes',
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*resetTrendToolState\(\)/,
'chart panel resets x zoom range to the current data-derived default when trend result changes',
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.defaultTrendXZoomRange\.value\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*defaultTrendXZoomRange\.value\s*=\s*resolveSteadyTrendDefaultZoomRange\(props\.trendResult\)[\s\S]*resetTrendToolState\(\)/,
chartPanelSource
],
[
@@ -162,6 +259,31 @@ const expectations = [
/dataZoom:\s*\[[\s\S]*start:\s*zoomRange\.start[\s\S]*end:\s*zoomRange\.end/,
trendOptionsSource
],
[
'chart options enlarge slider dataZoom handles for easier horizontal dragging',
/handleSize:\s*'300%'/,
trendOptionsSource
],
[
'chart options highlight slider dataZoom handles on hover',
/emphasis:\s*\{[\s\S]*handleStyle:\s*\{[\s\S]*borderColor:\s*'#409eff'[\s\S]*shadowBlur:\s*6/,
trendOptionsSource
],
[
'chart options show pointer cursor on slider dataZoom handles',
/handleSize:\s*'300%'[\s\S]*cursor:\s*'pointer'[\s\S]*handleStyle:/,
trendOptionsSource
],
[
'chart slider dataZoom only syncs after dragging settles for dense trend charts',
/handleSize:\s*'300%'[\s\S]*realtime:\s*false[\s\S]*cursor:\s*'pointer'/,
trendOptionsSource
],
[
'line chart overrides slider dataZoom handle cursor to pointer on hover',
/isSliderDataZoomResizeHandle[\s\S]*target\?\.type\s*===\s*'path'[\s\S]*viewportRoot\.style\.cursor\s*=\s*'pointer'/,
lineChartSource
],
[
'chart options accept wheel zoom option',
/interface\s+SteadyTrendChartBuildOptions[\s\S]*wheelZoomEnabled\?:\s*boolean/,
@@ -177,6 +299,21 @@ const expectations = [
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'/,
trendOptionsSource
],
[
'chart options hide hover x-axis pointer labels to avoid overlapping time axis labels',
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'[\s\S]*label:\s*\{[\s\S]*show:\s*false/,
trendOptionsSource
],
[
'chart tooltip avoids heavy immediate transitions on dense trend charts',
/tooltip:\s*\{[\s\S]*showDelay:\s*80[\s\S]*hideDelay:\s*80[\s\S]*transitionDuration:\s*0/,
trendOptionsSource
],
[
'chart options register hidden toolbox dataZoom for external box zoom',
/toolbox:\s*\{[\s\S]*show:\s*true[\s\S]*itemSize:\s*0[\s\S]*left:\s*-100[\s\S]*feature:\s*\{[\s\S]*dataZoom:\s*\{[\s\S]*yAxisIndex:\s*'none'[\s\S]*brushStyle:/,
trendOptionsSource
],
[
'chart options calculate visible point count from shared zoom range',
/resolveSteadyTrendVisiblePointCount[\s\S]*zoomRange\.end\s*-\s*zoomRange\.start[\s\S]*Math\.ceil/,
@@ -184,7 +321,7 @@ const expectations = [
],
[
'chart series line width uses visible point count after x zoom',
/width:\s*resolveSteadyTrendLineWidth\(\s*resolveSteadyTrendVisiblePointCount\(series\.points\?\.length\s*\|\|\s*0,\s*zoomRange\)\s*\)/,
/const\s+pointCount\s*=\s*series\.points\?\.length\s*\|\|\s*0[\s\S]*const\s+visiblePointCount\s*=\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)[\s\S]*width:\s*resolveSteadyTrendLineWidth\(visiblePointCount\)/,
trendOptionsSource
],
[
@@ -233,13 +370,28 @@ const expectations = [
trendOptionsSource
],
[
'chart x axis only keeps first and last labels like waveform',
/buildSteadyTimeAxisLabelFormatter[\s\S]*index\s*!==\s*0\s*&&\s*index\s*!==\s*lastIndex[\s\S]*return\s*''/,
'chart x axis formats time axis labels by range span',
/STEADY_TREND_ONE_DAY_MS[\s\S]*buildSteadyTimeAxisLabelFormatter[\s\S]*isRangeOverOneDay[\s\S]*formatSteadyTimeAxisDailyLabel[\s\S]*formatSteadyTimeAxisHourMinuteLabel/,
trendOptionsSource
],
[
'chart x axis keeps only the first year for same-year daily labels',
/formatSteadyTimeAxisDailyLabel[\s\S]*date\.getFullYear\(\)\s*===\s*firstYear[\s\S]*formatSteadyTimeAxisShortDateLabel[\s\S]*const\s+firstYear/,
trendOptionsSource
],
[
'chart x axis first label uses rich padding to avoid touching the y axis line',
/formatSteadyTimeAxisFirstLabel[\s\S]*return `\{first\|\$\{label\}\}`[\s\S]*rich:\s*\{[\s\S]*first:\s*\{[\s\S]*padding:\s*\[0,\s*0,\s*0,\s*6\]/,
trendOptionsSource
],
[
'chart x axis label layout follows waveform except unit display',
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*false[\s\S]*interval:\s*0[\s\S]*margin:\s*showTimeAxis\s*\?\s*10\s*:\s*0[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeLabels\)/,
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*true[\s\S]*showMinLabel:\s*true[\s\S]*showMaxLabel:\s*false[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0[\s\S]*alignMinLabel:\s*'left'[\s\S]*alignMaxLabel:\s*'right'[\s\S]*width:\s*72[\s\S]*overflow:\s*'truncate'[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeRange\)/,
trendOptionsSource
],
[
'chart x axis label moves down without increasing bottom grid space',
/bottom:\s*showTimeAxis\s*\?\s*40\s*:\s*8[\s\S]*axisLabel:\s*\{[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0/,
trendOptionsSource
],
[
@@ -264,8 +416,33 @@ const expectations = [
],
['chart options read phase colors from shared theme utility', /resolvePhaseThemeColor/, trendOptionsSource],
[
'chart legend only uses phase name',
/const formatSeriesName[\s\S]*return series\.phase \|\| series\.seriesKey/,
'chart legend combines harmonic order and phase when harmonic order exists',
/const formatSeriesName[\s\S]*const harmonicOrder = resolveHarmonicOrder\(series\)[\s\S]*return harmonicOrder \? `\$\{harmonicOrder\}次_\$\{phaseLabel\}` : phaseLabel/,
trendOptionsSource
],
[
'chart series are sorted by harmonic order before phase so same orders stay together in the legend',
/const sortSteadyTrendSeries[\s\S]*resolveHarmonicOrder\(left\.series\)[\s\S]*resolvePhaseOrder\(left\.series\)[\s\S]*const sortedSeriesList = sortSteadyTrendSeries\((?:seriesList|displaySeriesList)\)[\s\S]*series: sortedSeriesList\.map/,
trendOptionsSource
],
[
'chart options keep phase colors while styling harmonic orders by line type',
/lineStyle:\s*\{[\s\S]*type:\s*resolveHarmonicLineType\(series,\s*pointCount\)[\s\S]*opacity:\s*resolveHarmonicLineOpacity\(series\)/,
trendOptionsSource
],
[
'chart options force solid harmonic lines for large point counts',
/STEADY_TREND_LARGE_POINT_COUNT\s*=\s*20000[\s\S]*resolveHarmonicLineType[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT[\s\S]*return 'solid'/,
trendOptionsSource
],
[
'chart series disable animation and use lttb sampling for large point counts',
/resolveSteadyTrendSampling[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT\s*\?\s*'lttb'[\s\S]*animation:\s*false[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount,\s*visiblePointCount\)/,
trendOptionsSource
],
[
'chart disables lttb sampling after x zoom reduces visible points',
/STEADY_TREND_RAW_VISIBLE_POINT_COUNT[\s\S]*resolveSteadyTrendSampling\s*=\s*\([^)]*visiblePointCount[\s\S]*visiblePointCount\s*<=\s*STEADY_TREND_RAW_VISIBLE_POINT_COUNT[\s\S]*return undefined[\s\S]*const\s+visiblePointCount\s*=\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount,\s*visiblePointCount\)/,
trendOptionsSource
],
[

View File

@@ -0,0 +1,82 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pageFile = path.resolve(currentDir, '../index.vue')
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/interface/index.ts')
const read = file => fs.readFileSync(file, 'utf8')
const pageSource = read(pageFile)
const chartPanelSource = read(chartPanelFile)
const trendOptionsSource = read(trendOptionsFile)
const payloadSource = read(payloadFile)
const interfaceSource = read(interfaceFile)
const checks = [
[
'steadyDataView imports the day query endpoint for chunked loading',
/import\s*\{[^}]*querySteadyTrendDay[^}]*\}\s*from\s*'@\/api\/steady\/steadyDataView'/,
pageSource
],
[
'steadyDataView routes long ranges through chunked trend loading',
/isSteadyTrendRangeOverChunkLimit\(payload\.timeStart,\s*payload\.timeEnd\)[\s\S]*querySteadyTrendInChunks/,
pageSource
],
[
'steadyDataView prevents stale chunk responses from overwriting newer queries',
/trendQuerySerial[\s\S]*currentQuerySerial[\s\S]*currentQuerySerial\s*!==\s*trendQuerySerial/,
pageSource
],
['chunk helper uses a three day maximum window', /STEADY_TREND_CHUNK_DAYS\s*=\s*3/, payloadSource],
[
'long range chunk query initializes a full time range result before data arrives',
/buildEmptySteadyTrendQueryResult\(payload\.timeStart,\s*payload\.timeEnd\)/,
pageSource
],
[
'long range chunk query avoids trend loading overlay',
/loading\.trend\s*=\s*!isSteadyTrendRangeOverChunkLimit\(payload\.timeStart,\s*payload\.timeEnd\)/,
pageSource
],
['chunk helper exports a long range predicate', /export\s+const\s+isSteadyTrendRangeOverChunkLimit/, payloadSource],
['chunk helper exports query chunk builder', /export\s+const\s+buildSteadyTrendQueryChunks/, payloadSource],
['chunk helper exports incremental result merger', /export\s+const\s+mergeSteadyTrendQueryResult/, payloadSource],
[
'chunk helper exports empty range result builder',
/export\s+const\s+buildEmptySteadyTrendQueryResult/,
payloadSource
],
['chunk helper exports no data predicate', /export\s+const\s+hasSteadyTrendResultData/, payloadSource],
[
'trend result carries full query time range metadata',
/queryTimeStart\?:\s*string[\s\S]*queryTimeEnd\?:\s*string/,
interfaceSource
],
['chart panel enables missing data by default', /const\s+missingDataEnabled\s*=\s*ref\(true\)/, chartPanelSource],
[
'chart panel shows no data state after completed empty query',
/hasQueriedWithoutData[\s\S]*description="暂无数据"/,
chartPanelSource
],
[
'chart options use full query time range for x axis min and max',
/queryTimeStart[\s\S]*queryTimeEnd[\s\S]*xAxis[\s\S]*min:[\s\S]*max:/,
trendOptionsSource
]
]
const failures = checks.filter(([_name, pattern, source]) => !pattern.test(source))
if (failures.length) {
console.error('steadyDataView chunked query contract failed:')
failures.forEach(([name]) => console.error(`- ${name}`))
process.exit(1)
}
console.log('steadyDataView chunked query contract passed')

View File

@@ -1,4 +1,4 @@
/* eslint-env node */
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -28,7 +28,7 @@ const expectations = [
],
[
'chart panel warns before opening data table without series data',
/ElMessage\.warning\('请先查询趋势数据'\)/,
/case\s+'query-data'[\s\S]*if\s*\(!hasSeries\.value\)[\s\S]*ElMessage\.warning/,
chartPanelSource
],
[
@@ -61,7 +61,7 @@ const expectations = [
['data table dialog uses missing value placeholder', /row\[column\.prop\]\s*\?\?\s*'-'/, dialogSource],
[
'trend table utility carries indicator units into labels',
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*\$\{unit\}/,
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*unit/,
tableUtilSource
],
[

View File

@@ -0,0 +1,26 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
const harmonicSelectSource = toolbarSource.match(
/<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">[\s\S]*?<\/div>/
)?.[0] || ''
const checks = [
['harmonic order select exists', /class="toolbar-field harmonic-select"/],
['harmonic order select keeps multiple mode', /<el-select[\s\S]*multiple/],
['harmonic order select shows every selected tag', /^(?![\s\S]*collapse-tags)[\s\S]*$/]
]
const failed = checks.filter(([, pattern]) => !pattern.test(harmonicSelectSource)).map(([message]) => message)
if (failed.length) {
console.error('steadyDataView harmonic tags contract failed:')
failed.forEach(message => console.error(`- ${message}`))
process.exit(1)
}
console.log('steadyDataView harmonic tags contract passed')

View File

@@ -55,8 +55,77 @@ const flatNodes = [
{ id: 'line-2', parentId: 'device-1', name: '监测点_2', level: 3, lineCount: 1, selectable: true }
]
const aliasedNodes = [
{ engineeringId: 'engineering-alias', engineeringName: '别名工程', level: 0, deviceCount: 1, lineCount: 1 },
{
projectId: 'project-alias',
parentId: 'engineering-alias',
projectName: '别名项目',
level: 1,
deviceCount: 1,
lineCount: 1
},
{
equipmentId: 'device-alias',
parentId: 'project-alias',
equipmentName: '别名设备',
level: 2,
lineCount: 1
},
{
lineId: 'line-alias',
deviceId: 'device-alias',
lineName: '别名监测点',
level: 3,
selectable: true
}
]
const snakeCaseNodes = [
{
engineering_id: 'engineering-snake',
engineering_name: 'Snake Engineering',
level: 0,
equipment_count: 1,
monitor_count: 1,
childrenList: [
{
project_id: 'project-snake',
parent_id: 'engineering-snake',
project_name: 'Snake Project',
level: 1,
equipment_count: 1,
monitor_count: 1,
childrenList: [
{
device_id: 'device-snake',
parent_id: 'project-snake',
device_name: 'Snake Device',
level: 2,
monitor_count: 1,
childrenList: [
{
line_id: 'line-snake',
device_id: 'device-snake',
line_name: 'Snake Line',
level: 3,
selectable: true
}
]
}
]
}
]
}
]
const normalized = normalizeSteadyLedgerTree(flatNodes)
const expectedPath = normalized[0]?.children?.[0]?.children?.[0]?.children?.map(item => item.name)
const aliasedNormalized = normalizeSteadyLedgerTree(aliasedNodes)
const aliasedLine = aliasedNormalized[0]?.children?.[0]?.children?.[0]?.children?.[0]
const snakeCaseNormalized = normalizeSteadyLedgerTree(snakeCaseNodes)
const snakeCaseDevice = snakeCaseNormalized[0]?.children?.[0]?.children?.[0]
const snakeCaseLine = snakeCaseDevice?.children?.[0]
const expectations = [
['flat nodes rebuild to one root engineering node', normalized.length === 1 && normalized[0].id === 'engineering-1'],
@@ -71,11 +140,38 @@ const expectations = [
'unselectable line nodes are removed from steady query tree',
!normalized[0]?.children?.[0]?.children?.[0]?.children?.some(item => item.id === 'line-disabled')
],
[
'backend alias fields keep monitor point nodes',
aliasedLine?.id === 'line-alias' && aliasedLine?.name === '别名监测点' && aliasedLine?.parentId === 'device-alias'
],
[
'snake case backend fields and childrenList keep the full ledger hierarchy',
snakeCaseNormalized[0]?.id === 'engineering-snake' &&
snakeCaseNormalized[0]?.deviceCount === 1 &&
snakeCaseNormalized[0]?.lineCount === 1 &&
snakeCaseDevice?.id === 'device-snake' &&
snakeCaseDevice?.lineCount === 1 &&
snakeCaseLine?.id === 'line-snake' &&
snakeCaseLine?.name === 'Snake Line' &&
snakeCaseLine?.parentId === 'device-snake'
],
[
'ledger tree component hides count text on monitor point leaves',
/shouldShowLedgerCount[\s\S]*Number\(data\.level\)\s*<\s*3/.test(read(ledgerTreeComponentFile))
],
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))]
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))],
[
'page assigns normalized ledger tree data outside comments',
read(pageFile)
.split(/\r?\n/)
.some(line => /^\s*ledgerTree\.value\s*=\s*normalizeSteadyLedgerTree/.test(line))
],
[
'page assigns default selected ledger node outside comments',
read(pageFile)
.split(/\r?\n/)
.some(line => /^\s*selectedLedgerNodes\.value\s*=\s*firstLedgerNode/.test(line))
]
]
const failures = expectations.filter(([, matched]) => !matched)

View File

@@ -0,0 +1,57 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
const chartPanelSource = fs.readFileSync(chartPanelFile, 'utf8')
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
const checks = [
['chart panel defines missing data tool action', /'missing-data'/, chartPanelSource],
['chart panel labels missing data action', /action:\s*'missing-data'[\s\S]*label:/, chartPanelSource],
[
'chart panel defaults missing data enabled for every query',
/const\s+missingDataEnabled\s*=\s*ref\(true\)/,
chartPanelSource
],
[
'chart panel marks missing data action active only when enabled',
/action\s*===\s*'missing-data'[\s\S]*return\s+missingDataEnabled\.value/,
chartPanelSource
],
[
'chart panel passes missing data state into chart options',
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*showMissingData:\s*missingDataEnabled\.value/,
chartPanelSource
],
['chart options accept missing data option', /showMissingData\?:\s*boolean/, trendOptionsSource],
[
'chart options only fills missing data when enabled',
/chartOptions\.showMissingData\s*===\s*true\s*\?\s*fillSteadyTrendMissingPoints\(/,
trendOptionsSource
],
[
'missing data filler inserts null values',
/fillSteadyTrendMissingPoints[\s\S]*value:[\s\S]*:\s*null/,
trendOptionsSource
],
[
'missing data filler infers interval from existing time gaps',
/resolveSteadyTrendPointIntervalMs[\s\S]*gaps[\s\S]*Math\.min/,
trendOptionsSource
]
]
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
if (failed.length) {
console.error('steadyDataView missing data contract failed:')
failed.forEach(message => console.error(`- ${message}`))
process.exit(1)
}
console.log('steadyDataView missing data contract passed')

View File

@@ -0,0 +1,33 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
const payloadSource = fs.readFileSync(payloadFile, 'utf8')
const checks = [
['toolbar labels the filter as data quality', /toolbar-field__label">数据质量:<\/span>/],
['toolbar renders quality flag with switch', /<el-switch[\s\S]*:model-value="modelValue\.qualityFlag \?\? 0"/],
['quality switch maps valid data to zero', /active-text="有效"[\s\S]*:active-value="0"/],
['quality switch maps invalid data to one', /inactive-text="无效"[\s\S]*:inactive-value="1"/],
['quality switch updates qualityFlag', /@update:model-value="handleQualityFlagChange"/],
['quality switch reserves enough prompt width', /\.quality-switch\s*\{[\s\S]*min-width:\s*72px/],
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
['utilities send quality flag in trend query payload', /qualityFlag:\s*formState\.qualityFlag/]
]
const failed = checks
.filter(([, pattern], index) => !pattern.test(index < 6 ? toolbarSource : payloadSource))
.map(([message]) => message)
if (failed.length) {
console.error('steadyDataView quality switch contract failed:')
failed.forEach(message => console.error(`- ${message}`))
process.exit(1)
}
console.log('steadyDataView quality switch contract passed')

View File

@@ -0,0 +1,48 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
const readFunctionBody = name => {
const match = pageSource.match(
new RegExp(`const\\s+${name}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]*?)\\n\\}`)
)
return match?.[1] || ''
}
const expectations = [
[
'ledger selection change keeps current trend result until next query',
source => !/trendResult\.value\s*=\s*null/.test(source),
readFunctionBody('handleLedgerChange')
],
[
'indicator selection change keeps current trend result until next query',
source => !/trendResult\.value\s*=\s*null/.test(source),
readFunctionBody('handleIndicatorChange')
],
[
'new trend query clears stale trend result before deciding loading mode',
source =>
/trendResult\.value\s*=\s*null[\s\S]*useChunkedQuery[\s\S]*loading\.trend\s*=\s*!isSteadyTrendRangeOverChunkLimit/.test(
source
),
readFunctionBody('handleQueryTrend')
]
]
const failures = expectations.filter(([, check, source]) => !check(source))
if (failures.length) {
console.error('steadyDataView query state contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView query state contract passed')

View File

@@ -0,0 +1,53 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(currentDir, '../../../..')
const files = {
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
authStore: path.resolve(rootDir, 'stores/modules/auth.ts'),
page: path.resolve(rootDir, 'views/steady/steadyTrend/index.vue'),
api: path.resolve(rootDir, 'api/steady/steadyTrend/index.ts'),
apiInterface: path.resolve(rootDir, 'api/steady/steadyTrend/interface/index.ts')
}
const read = file => fs.readFileSync(file, 'utf8')
const exists = file => fs.existsSync(file)
const checks = [
['steadyTrend page exists', () => exists(files.page)],
['steadyTrend API exists', () => exists(files.api)],
['steadyTrend API interface exists', () => exists(files.apiInterface)],
['static router registers /steadyTrend/index', () => /path:\s*'\/steadyTrend\/index'/.test(read(files.staticRouter))],
['static route name is steadyTrend', () => /name:\s*'steadyTrend'/.test(read(files.staticRouter))],
['static router imports steadyTrend page', () => /@\/views\/steady\/steadyTrend\/index\.vue/.test(read(files.staticRouter))],
[
'dynamic router aliases steady-trend to steadyTrend',
() => /\/steady\/steady-trend[\s\S]*\/steady\/steadyTrend/.test(read(files.dynamicRouter))
],
[
'dynamic router keeps steadyTrend static route from being overwritten',
() => /STATIC_ROUTE_NAMES[\s\S]*'steadyTrend'/.test(read(files.dynamicRouter))
],
[
'auth normalizes backend steadyTrend menu to static entry',
() => /isSteadyTrendMenu[\s\S]*menu\.path\s*=\s*'\/steadyTrend\/index'/.test(read(files.authStore))
],
[
'business menu path resolver handles steadyTrend',
() => /isSteadyTrendMenu\(menu\)[\s\S]*return\s+'\/steadyTrend\/index'/.test(read(files.authStore))
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('steadyTrend route contract failed:')
failures.forEach(name => console.error(`- ${name}`))
process.exit(1)
}
console.log('steadyTrend route contract passed')

View File

@@ -0,0 +1,67 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const viewDir = path.join(currentDir, '..')
const read = file => fs.readFileSync(path.join(viewDir, file), 'utf8')
const trendOptionsSource = read('utils/trendOptions.ts')
const lineChartSource = fs.readFileSync(
path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'),
'utf8'
)
const checks = [
[
'steady trend x axis uses ECharts time axis',
/xAxis:\s*\{[\s\S]*type:\s*'time'/,
trendOptionsSource
],
[
'steady trend chart no longer builds global timeLabels for default rendering',
/^(?![\s\S]*const\s+timeLabels\s*=\s*Array\.from[\s\S]*new Set\(displaySeriesList\.flatMap)[\s\S]*$/,
trendOptionsSource
],
[
'steady trend series passes timestamp value pairs directly to ECharts',
/data:\s*buildSteadyTrendSeriesData\(series\)/,
trendOptionsSource
],
[
'steady trend default rendering no longer pads every series by shared time labels',
/^(?![\s\S]*data:\s*timeLabels\.map\(time\s*=>\s*pointMap\.get\(time\)\s*\?\?\s*null\))[\s\S]*$/,
trendOptionsSource
],
[
'steady trend series enables progressive rendering for dense data',
/progressive:\s*STEADY_TREND_PROGRESSIVE_CHUNK_SIZE[\s\S]*progressiveThreshold:\s*STEADY_TREND_PROGRESSIVE_THRESHOLD/,
trendOptionsSource
],
[
'time axis label formatter formats timestamp values without timeLabels',
/buildSteadyTimeAxisLabelFormatter\s*=\s*\([^)]*\)\s*=>[\s\S]*value:\s*string\s*\|\s*number/,
trendOptionsSource
],
[
'one-day time axis labels use compact hour-minute text to avoid truncating repeated dates',
/formatSteadyTimeAxisMinuteLabel[\s\S]*formatSteadyTimeAxisHourMinuteLabel[\s\S]*isRangeOverOneDay[\s\S]*formatSteadyTimeAxisHourMinuteLabel/,
trendOptionsSource
],
[
'line chart can resolve dataZoom startValue and endValue without category xAxis data',
/resolveZoomRangeFromTimeAxisValues[\s\S]*getSeriesTimeRange[\s\S]*resolveChartDataZoomRange/,
lineChartSource
]
]
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
if (failed.length) {
console.error('steadyDataView time axis contract failed:')
failed.forEach(message => console.error(`- ${message}`))
process.exit(1)
}
console.log('steadyDataView time axis contract passed')

View File

@@ -41,6 +41,7 @@ const expectations = [
['page does not import data table panel', /SteadyDataTablePanel/],
['components render floating indicator panel', /indicator-floating-panel/],
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
['page restores default harmonic order when harmonic filter becomes visible', /DEFAULT_HARMONIC_ORDERS[\s\S]*showHarmonicOrders\.value[\s\S]*trendForm\.value\.harmonicOrders\.length[\s\S]*DEFAULT_HARMONIC_ORDERS/],
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
@@ -55,16 +56,27 @@ const expectations = [
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
['components reuse LineChart', /<LineChart/],
['toolbar uses shared time period search', /TimePeriodSearch/],
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据:/],
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据质量/],
['toolbar does not render bucket selector', /modelValue\.bucket|bucketOptions|粒度:|选择时间粒度/],
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
['toolbar binds valid quality flag to zero', /<el-option\s+label="有效数据"\s+:value="0"\s*\/>/],
['toolbar renders quality flag with switch', /<el-switch[\s\S]*@update:model-value="handleQualityFlagChange"/],
['toolbar maps valid quality flag to zero', /active-text="有效"[\s\S]*:active-value="0"/],
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
['utilities default harmonic order to second harmonic', /DEFAULT_HARMONIC_ORDERS\s*=\s*\[2\]/],
['utilities collect selected line ids', /export const collectSelectedLineIds/],
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
['utilities cap harmonic order count at three', /MAX_HARMONIC_ORDER_COUNT\s*=\s*3/],
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个/],
['utilities count harmonic orders as one indicator in selection estimates', /const harmonicMultiplier\s*=\s*1/],
[
'toolbar does not provide harmonic quick groups',
/HARMONIC_ORDER_QUICK_GROUPS|harmonic-select__quick|appendHarmonicQuickOrders/
],
[
'toolbar warns when harmonic selection exceeds three instead of using silent multiple-limit',
/(?=[\s\S]*MAX_HARMONIC_ORDER_COUNT)(?=[\s\S]*ElMessage\.warning\(`谐波次数最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个`\))(?![\s\S]*multiple-limit)/
],
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
['utilities do not send bucket in trend query payload', /bucket:\s*formState\.bucket/],
@@ -86,6 +98,7 @@ const sourceByExpectation = [
pageSource,
componentSource,
pageSource,
pageSource,
apiSource,
apiSource,
apiSource,
@@ -113,9 +126,14 @@ const sourceByExpectation = [
utilitySource,
utilitySource,
utilitySource,
componentSource,
componentSource,
utilitySource,
utilitySource,
utilitySource,
interfaceSource,
interfaceSource,
utilitySource,
utilitySource
]

View File

@@ -50,7 +50,11 @@ const forbiddenPatterns = [
],
['chart panel title text is removed', /panel-title/, chartPanelSource],
['collapsed indicator vertical trigger is removed', /indicator-collapsed-trigger/, floatingPanelSource],
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource]
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource],
['indicator tree refresh button is removed', /@click="emit\('refresh'\)"|:icon="Refresh"/, indicatorTreeSource],
['floating indicator panel refresh passthrough is removed', /@refresh="emit\('refresh'\)"|refresh:\s*\[\]/, floatingPanelSource],
['workbench indicator refresh passthrough is removed', /@refresh="emit\('refreshIndicator'\)"|refreshIndicator:\s*\[\]/, workbenchSource],
['page indicator refresh binding is removed', /@refresh-indicator="loadIndicatorTree"/, source]
]
const requiredPatterns = [
@@ -85,7 +89,6 @@ const requiredPatterns = [
['floating indicator toggle keeps enough distance from title', /left:\s*-28px/, floatingPanelSource],
['floating indicator toggle uses primary theme color', /class="indicator-toggle"[\s\S]*type="primary"/, floatingPanelSource],
['ledger collapse buttons use primary theme color', /class="panel-toggle"[\s\S]*type="primary"/, ledgerTreeSource],
['indicator tree header separates title and refresh icon', /justify-content:\s*flex-start/, indicatorTreeSource],
['page tracks collapsed ledger panel state', /ledgerPanelCollapsed\s*=\s*ref\(false\)/, source],
['page passes collapsed ledger state to workbench', /v-model:ledger-panel-collapsed="ledgerPanelCollapsed"/, source],
['query collapses floating indicator panel', /indicatorPanelCollapsed\.value\s*=\s*true[\s\S]*querySteadyTrend/, source],

View File

@@ -17,7 +17,6 @@
@refresh-ledger="loadLedgerTree"
@ledger-search="handleLedgerSearch"
@ledger-change="handleLedgerChange"
@refresh-indicator="loadIndicatorTree"
@indicator-change="handleIndicatorChange"
@query-trend="handleQueryTrend"
@reset-trend="resetTrendState"
@@ -28,7 +27,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
import {
getSteadyTrendIndicatorTree,
getSteadyTrendLedgerTree,
querySteadyTrend,
querySteadyTrendDay
} from '@/api/steady/steadyDataView'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
import {
@@ -37,10 +41,20 @@ import {
findFirstSelectableLedgerNode,
hasHarmonicIndicator,
resolveAvailableStats,
sortSteadyIndicatorTree,
validateTrendSelection
} from './utils/selectionRules'
import { normalizeSteadyLedgerTree } from './utils/ledgerTree'
import { buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
import {
DEFAULT_HARMONIC_ORDERS,
buildEmptySteadyTrendQueryResult,
buildSteadyTrendQueryChunks,
buildSteadyTrendQueryPayload,
defaultTrendFormState,
hasSteadyTrendResultData,
isSteadyTrendRangeOverChunkLimit,
mergeSteadyTrendQueryResult
} from './utils/trendPayload'
defineOptions({
name: 'SteadyDataView'
@@ -63,6 +77,7 @@ const loading = reactive({
indicator: false,
trend: false
})
let trendQuerySerial = 0
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
@@ -86,7 +101,7 @@ const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
// 台账树接口在搜索场景可能返回扁平节点,前端统一恢复工程、项目、设备、监测点层级。
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
// 台账树首次加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
// 台账树加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
} finally {
@@ -98,10 +113,10 @@ const loadIndicatorTree = async () => {
loading.indicator = true
try {
const response = await getSteadyTrendIndicatorTree()
indicatorTree.value = unwrapData(response) || []
indicatorTree.value = sortSteadyIndicatorTree(unwrapData(response) || [])
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
// 指标树首次加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
// 指标树加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
} finally {
@@ -118,13 +133,16 @@ const handleLedgerSearch = (value: string) => {
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
selectedLedgerNodes.value = nodes
// 监测点切换只更新待查询条件,趋势数据保留到用户再次点击查询时再清理。
}
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
selectedIndicators.value = nodes
// 指标切换只更新待查询条件,趋势数据保留到用户再次点击查询时再清理。
}
const resetTrendState = () => {
trendQuerySerial += 1
trendForm.value = defaultTrendFormState()
selectedLedgerNodes.value = []
selectedIndicators.value = []
@@ -134,6 +152,29 @@ const resetTrendState = () => {
selectorResetKey.value += 1
}
const querySteadyTrendInChunks = async (payload: SteadyDataView.SteadyTrendQueryParams, currentQuerySerial: number) => {
const chunks = buildSteadyTrendQueryChunks(payload)
trendResult.value = buildEmptySteadyTrendQueryResult(payload.timeStart, payload.timeEnd)
for (const chunkPayload of chunks) {
if (currentQuerySerial !== trendQuerySerial) return
const trendResponse = await querySteadyTrendDay(chunkPayload)
if (currentQuerySerial !== trendQuerySerial) return
trendResult.value = mergeSteadyTrendQueryResult(trendResult.value, unwrapData(trendResponse))
}
if (currentQuerySerial === trendQuerySerial && trendResult.value) {
trendResult.value = {
...trendResult.value,
queryCompleted: true
}
}
}
const handleQueryTrend = async () => {
indicatorPanelCollapsed.value = true
@@ -153,14 +194,40 @@ const handleQueryTrend = async () => {
}
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
trendResult.value = null
const currentQuerySerial = ++trendQuerySerial
const useChunkedQuery = isSteadyTrendRangeOverChunkLimit(payload.timeStart, payload.timeEnd)
loading.trend = true
loading.trend = !isSteadyTrendRangeOverChunkLimit(payload.timeStart, payload.timeEnd)
try {
if (useChunkedQuery) {
// 超过 3 天的趋势查询拆成小窗口逐段加载,先撑起完整横坐标框架再增量更新。
await querySteadyTrendInChunks(payload, currentQuerySerial)
return
}
if (currentQuerySerial !== trendQuerySerial) return
// 趋势查询只驱动主图,右侧指标树作为筛选面板独立加载,避免额外请求拖慢页面响应。
const trendResponse = await querySteadyTrend(payload)
trendResult.value = unwrapData(trendResponse)
if (currentQuerySerial !== trendQuerySerial) return
const nextResult = unwrapData(trendResponse)
trendResult.value = {
...nextResult,
queryTimeStart: payload.timeStart,
queryTimeEnd: payload.timeEnd,
queryCompleted: true
}
if (!hasSteadyTrendResultData(trendResult.value)) {
trendResult.value = buildEmptySteadyTrendQueryResult(payload.timeStart, payload.timeEnd, true)
}
} finally {
loading.trend = false
if (currentQuerySerial === trendQuerySerial) {
loading.trend = false
}
}
}
@@ -172,7 +239,11 @@ watch(
trendForm.value = {
...trendForm.value,
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
harmonicOrders: showHarmonicOrders.value
? trendForm.value.harmonicOrders.length
? trendForm.value.harmonicOrders
: [...DEFAULT_HARMONIC_ORDERS]
: []
}
},
{ deep: true }

View File

@@ -69,10 +69,37 @@ const flattenLedgerNodes = (
) => {
nodes.forEach(node => {
const rawNode = node as RawLedgerNode
const children = Array.isArray(node.children) ? node.children : []
const id = resolveText(rawNode, 'id', 'Id')
const parentId = resolveText(rawNode, 'parentId', 'pid', 'Pid') || inheritedParentId
const rawChildren = rawNode.children ?? rawNode.Children ?? rawNode.childrenList ?? rawNode.childList
const children = Array.isArray(rawChildren) ? (rawChildren as SteadyDataView.SteadyLedgerNode[]) : []
const level = normalizeLevel(rawNode.level ?? rawNode.Level)
const id = resolveText(
rawNode,
'id',
'Id',
'lineId',
'line_id',
'equipmentId',
'equipment_id',
'deviceId',
'device_id',
'projectId',
'project_id',
'engineeringId',
'engineering_id'
)
const parentId =
resolveText(
rawNode,
'parentId',
'parent_id',
'pid',
'Pid',
level === 3 ? 'deviceId' : '',
level === 3 ? 'device_id' : '',
level === 2 ? 'projectId' : '',
level === 2 ? 'project_id' : ''
) ||
inheritedParentId
const rawSelectable = resolveBoolean(rawNode, 'selectable', 'Selectable')
if (!id) return
@@ -82,11 +109,27 @@ const flattenLedgerNodes = (
id,
parentId,
parentIds: resolveText(rawNode, 'parentIds', 'pids', 'Pids'),
name: resolveText(rawNode, 'name', 'Name') || id,
name:
resolveText(
rawNode,
'name',
'Name',
'lineName',
'line_name',
'equipmentName',
'equipment_name',
'deviceName',
'device_name',
'projectName',
'project_name',
'engineeringName',
'engineering_name'
) ||
id,
level,
sort: resolveNumber(rawNode, 'sort', 'Sort'),
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount'),
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount'),
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount', 'equipmentCount', 'equipment_count'),
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount', 'monitorCount', 'monitor_count'),
selectable: level === 3 ? rawSelectable !== false : rawSelectable === true,
children: [],
__order: output.length

View File

@@ -3,7 +3,8 @@ import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
export const MAX_TREND_SERIES_COUNT = 24
export const MAX_SELECTED_LINE_COUNT = 6
export const MAX_SELECTED_INDICATOR_COUNT = 6
export const MAX_HARMONIC_ORDER_COUNT = 6
export const MAX_HARMONIC_ORDER_COUNT = 3
const STEADY_INDICATOR_GROUP_ORDER = ['电压趋势', '电流趋势']
const isSelectableLineNode = (node: SteadyDataView.SteadyLedgerNode) => {
return node.level === 3 && node.selectable !== false
@@ -47,6 +48,30 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
return indicators
}
const resolveSteadyIndicatorGroupOrder = (node: SteadyDataView.SteadyIndicatorNode) => {
const orderName = String(node.name || '').trim()
const orderIndex = STEADY_INDICATOR_GROUP_ORDER.findIndex(name => orderName === name)
return orderIndex === -1 ? STEADY_INDICATOR_GROUP_ORDER.length : orderIndex
}
export const sortSteadyIndicatorTree = (
nodes: SteadyDataView.SteadyIndicatorNode[]
): SteadyDataView.SteadyIndicatorNode[] => {
return nodes
.map((node, index) => ({ node, index }))
.sort((left, right) => {
const leftOrder = resolveSteadyIndicatorGroupOrder(left.node)
const rightOrder = resolveSteadyIndicatorGroupOrder(right.node)
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder
})
.map(({ node }) => ({
...node,
children: node.children?.length ? sortSteadyIndicatorTree(node.children) : node.children
}))
}
export const findFirstSelectableLedgerNode = (
nodes: SteadyDataView.SteadyLedgerNode[]
): SteadyDataView.SteadyLedgerNode | null => {
@@ -100,7 +125,8 @@ export const estimateTrendSeriesCount = (
statType: SteadyDataView.SteadyTrendStatType,
harmonicOrders: number[]
) => {
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
void harmonicOrders
const harmonicMultiplier = 1
return indicators.reduce((count, indicator) => {
const phaseCount = indicator.phaseCodes?.length || 1
@@ -113,7 +139,7 @@ export const estimateTrendSeriesCount = (
export const validateHarmonicOrders = (indicators: SteadyDataView.SteadyIndicatorNode[], harmonicOrders: number[]) => {
if (!hasHarmonicIndicator(indicators)) return ''
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return '谐波次数最多选择 6 个'
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return `谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT}`
return ''
}

View File

@@ -18,7 +18,10 @@ export type SteadyTrendActiveTool = 'none' | 'box-zoom' | 'pan'
export interface SteadyTrendChartBuildOptions {
activeTool?: SteadyTrendActiveTool
wheelZoomEnabled?: boolean
showMissingData?: boolean
yZoomScale?: number
queryTimeStart?: string
queryTimeEnd?: string
}
const STEADY_TREND_CHART_GROUP = 'steady-trend-chart-sync'
@@ -38,6 +41,11 @@ const STEADY_TREND_GRID_LEFT = '64px'
const STEADY_TREND_GRID_RIGHT = '28px'
const STEADY_TREND_GRID_TOP = 28
const STEADY_TREND_LINE_MAX_WIDTH = 1.3
const STEADY_TREND_LARGE_POINT_COUNT = 20000
const STEADY_TREND_RAW_VISIBLE_POINT_COUNT = 50000
const STEADY_TREND_PROGRESSIVE_CHUNK_SIZE = 5000
const STEADY_TREND_PROGRESSIVE_THRESHOLD = 10000
const STEADY_TREND_ONE_DAY_MS = 24 * 60 * 60 * 1000
const STEADY_AXIS_TEXT_COLOR = 'var(--el-text-color-regular)'
const STEADY_AXIS_LINE_COLOR = 'var(--el-border-color)'
@@ -51,6 +59,8 @@ const fallbackTrendColors = [
'#14b8a6',
'#f97316'
]
const harmonicLineTypes = ['solid', 'dashed', 'dotted'] as const
const harmonicLineOpacities = [1, 0.86, 0.72, 0.58]
interface ReadableAxisRangeOptions {
preferCompact?: boolean
@@ -93,8 +103,83 @@ const resolveGroupTitle = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
)
}
const isValidHarmonicOrder = (value: number) => Number.isInteger(value) && value >= 2 && value <= 50
const parseHarmonicOrderToken = (value: string) => {
const orderText =
value.match(/^([2-9]|[1-4]\d|50)$/)?.[1] ||
value.match(/^[a-z]+_([2-9]|[1-4]\d|50)$/i)?.[1] ||
value.match(/([2-9]|[1-4]\d|50)\s*次/)?.[1]
const order = Number(orderText)
return isValidHarmonicOrder(order) ? order : null
}
const resolveHarmonicOrder = (series: SteadyDataView.SteadyTrendSeries) => {
if (isValidHarmonicOrder(Number(series.harmonicOrder))) return Number(series.harmonicOrder)
const harmonicText = [series.indicatorCode, series.indicatorName, series.seriesName, series.seriesKey].join('|')
if (!/HARMONIC|谐波/i.test(harmonicText)) return null
for (const value of [series.seriesName, series.indicatorName].filter(Boolean) as string[]) {
const order = parseHarmonicOrderToken(value)
if (order) return order
}
const keyTokens = String(series.seriesKey || '').split(/[|_\s-]+/)
for (const value of keyTokens.reverse()) {
const order = parseHarmonicOrderToken(value)
if (order) return order
}
return null
}
const formatPhaseLabel = (series: SteadyDataView.SteadyTrendSeries) => {
const phase = String(series.phase || '').trim()
if (!phase) return series.seriesName || series.seriesKey
return phase.endsWith('相') ? phase : `${phase}`
}
const resolvePhaseOrder = (series: SteadyDataView.SteadyTrendSeries) => {
const phaseOrderMap: Record<string, number> = {
A: 1,
B: 2,
C: 3
}
const phase = String(series.phase || '')
.replace(/相$/, '')
.toUpperCase()
return phaseOrderMap[phase] || 99
}
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
return series.phase || series.seriesKey
const harmonicOrder = resolveHarmonicOrder(series)
const phaseLabel = formatPhaseLabel(series)
return harmonicOrder ? `${harmonicOrder}次_${phaseLabel}` : phaseLabel
}
const sortSteadyTrendSeries = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
return seriesList
.map((series, index) => ({ series, index }))
.sort((left, right) => {
const leftOrder = resolveHarmonicOrder(left.series)
const rightOrder = resolveHarmonicOrder(right.series)
if (!leftOrder && !rightOrder) return left.index - right.index
if (!leftOrder) return 1
if (!rightOrder) return -1
if (leftOrder !== rightOrder) return leftOrder - rightOrder
const leftPhaseOrder = resolvePhaseOrder(left.series)
const rightPhaseOrder = resolvePhaseOrder(right.series)
return leftPhaseOrder === rightPhaseOrder ? left.index - right.index : leftPhaseOrder - rightPhaseOrder
})
.map(item => item.series)
}
const getAxisPrecision = (step: number) => {
@@ -149,15 +234,153 @@ const formatAxisLabel = (value: number, precision: number) => {
return `${normalizeAxisValue(value, precision)}`
}
const buildSteadyTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1
const parseSteadyTrendPointTime = (value?: string) => {
if (!value) return null
return (value: string | number, index: number) => {
if (index !== 0 && index !== lastIndex) return ''
return `${value}`
const timestamp = Date.parse(value.replace(' ', 'T'))
return Number.isFinite(timestamp) ? timestamp : null
}
const padSteadyTrendTimePart = (value: number) => `${value}`.padStart(2, '0')
const formatSteadyTimeAxisDateLabel = (timestamp: number) => {
const date = new Date(timestamp)
return [
date.getFullYear(),
padSteadyTrendTimePart(date.getMonth() + 1),
padSteadyTrendTimePart(date.getDate())
].join('-')
}
const formatSteadyTimeAxisShortDateLabel = (timestamp: number) => {
const date = new Date(timestamp)
return [padSteadyTrendTimePart(date.getMonth() + 1), padSteadyTrendTimePart(date.getDate())].join('-')
}
const formatSteadyTimeAxisMinuteLabel = (timestamp: number) => {
const date = new Date(timestamp)
return `${formatSteadyTimeAxisDateLabel(timestamp)} ${[
padSteadyTrendTimePart(date.getHours()),
padSteadyTrendTimePart(date.getMinutes())
].join(':')}`
}
const formatSteadyTimeAxisHourMinuteLabel = (timestamp: number) => {
const date = new Date(timestamp)
return [padSteadyTrendTimePart(date.getHours()), padSteadyTrendTimePart(date.getMinutes())].join(':')
}
const formatSteadyTimeAxisFirstLabel = (label: string) => {
return `{first|${label}}`
}
const formatSteadyTimeAxisDailyLabel = (timestamp: number, firstYear?: number) => {
const date = new Date(timestamp)
return date.getFullYear() === firstYear
? formatSteadyTimeAxisShortDateLabel(timestamp)
: formatSteadyTimeAxisDateLabel(timestamp)
}
const buildSteadyTimeAxisLabelFormatter = (timeRange: { min?: number; max?: number } = {}) => {
const firstYear = timeRange.min === undefined ? undefined : new Date(timeRange.min).getFullYear()
const isRangeOverOneDay =
timeRange.min !== undefined &&
timeRange.max !== undefined &&
timeRange.max - timeRange.min > STEADY_TREND_ONE_DAY_MS
return (value: string | number) => {
const numericValue = Number(value)
const timestamp = Number.isFinite(numericValue) ? numericValue : parseSteadyTrendPointTime(`${value}`)
if (timestamp === null) {
return `${value}`
}
return isRangeOverOneDay
? formatSteadyTimeAxisDailyLabel(timestamp, firstYear)
: formatSteadyTimeAxisHourMinuteLabel(timestamp)
}
}
const formatSteadyTrendPointTime = (timestamp: number, sampleTime: string) => {
const date = new Date(timestamp)
const separator = sampleTime.includes('T') ? 'T' : ' '
return (
[date.getFullYear(), padSteadyTrendTimePart(date.getMonth() + 1), padSteadyTrendTimePart(date.getDate())].join(
'-'
) +
separator +
[
padSteadyTrendTimePart(date.getHours()),
padSteadyTrendTimePart(date.getMinutes()),
padSteadyTrendTimePart(date.getSeconds())
].join(':')
)
}
const resolveSteadyTrendPointIntervalMs = (points: SteadyDataView.SteadyTrendPoint[]) => {
const timestamps = Array.from(
new Set(
points
.map(point => parseSteadyTrendPointTime(point.time))
.filter((timestamp): timestamp is number => timestamp !== null)
)
).sort((left, right) => left - right)
const gaps = timestamps
.slice(1)
.map((timestamp, index) => timestamp - timestamps[index])
.filter(gap => gap > 0)
return gaps.length ? Math.min(...gaps) : 0
}
const fillSteadyTrendMissingPoints = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
return seriesList.map(series => {
const points = series.points || []
const intervalMs = resolveSteadyTrendPointIntervalMs(points)
if (points.length < 2 || intervalMs <= 0) return series
const pointEntries = points
.map(point => ({
timestamp: parseSteadyTrendPointTime(point.time),
point
}))
.filter(
(item): item is { timestamp: number; point: SteadyDataView.SteadyTrendPoint } => item.timestamp !== null
)
.sort((left, right) => left.timestamp - right.timestamp)
if (pointEntries.length < 2) return series
const pointMap = new Map(pointEntries.map(item => [item.timestamp, item.point.value]))
const firstTimestamp = pointEntries[0].timestamp
const lastTimestamp = pointEntries[pointEntries.length - 1].timestamp
const sampleTime = pointEntries[0].point.time
const filledPoints: SteadyDataView.SteadyTrendPoint[] = []
// 缺失数据只在用户点亮按钮后补齐,默认不走该路径以避免放大点数和渲染成本。
for (let timestamp = firstTimestamp; timestamp <= lastTimestamp; timestamp += intervalMs) {
filledPoints.push({
time: formatSteadyTrendPointTime(timestamp, sampleTime),
value: pointMap.has(timestamp) ? pointMap.get(timestamp)! : null
})
}
return {
...series,
points: filledPoints
}
})
}
const getReadableAxisIntervalCandidates = (value: number) => {
return Array.from(new Set([getReadableAxisInterval(value), getReadableAxisInterval(value / 2)])).filter(
item => Number.isFinite(item) && item > 0
@@ -504,6 +727,67 @@ const resolveSeriesColor = (series: SteadyDataView.SteadyTrendSeries, index: num
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
}
const resolveHarmonicLineType = (series: SteadyDataView.SteadyTrendSeries, pointCount: number) => {
if (pointCount >= STEADY_TREND_LARGE_POINT_COUNT) return 'solid'
const harmonicOrder = resolveHarmonicOrder(series)
if (!harmonicOrder) return 'solid'
return harmonicLineTypes[(harmonicOrder - 2) % harmonicLineTypes.length]
}
const resolveHarmonicLineOpacity = (series: SteadyDataView.SteadyTrendSeries) => {
const harmonicOrder = resolveHarmonicOrder(series)
if (!harmonicOrder) return 1
return harmonicLineOpacities[
Math.floor((harmonicOrder - 2) / harmonicLineTypes.length) % harmonicLineOpacities.length
]
}
const resolveSteadyTrendSampling = (pointCount: number, visiblePointCount: number) => {
if (visiblePointCount <= STEADY_TREND_RAW_VISIBLE_POINT_COUNT) return undefined
return pointCount >= STEADY_TREND_LARGE_POINT_COUNT ? 'lttb' : undefined
}
const buildSteadyTrendSeriesData = (series: SteadyDataView.SteadyTrendSeries) => {
return (series.points || [])
.map(point => {
const timestamp = parseSteadyTrendPointTime(point.time)
return timestamp === null ? null : [timestamp, point.value]
})
.filter((point): point is [number, number | null] => point !== null)
}
const resolveSteadyTrendTimeRange = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
let min = Number.POSITIVE_INFINITY
let max = Number.NEGATIVE_INFINITY
seriesList.forEach(series => {
;(series.points || []).forEach(point => {
const timestamp = parseSteadyTrendPointTime(point.time)
if (timestamp === null) return
min = Math.min(min, timestamp)
max = Math.max(max, timestamp)
})
})
return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : {}
}
const resolveSteadyTrendQueryTimeRange = (chartOptions: SteadyTrendChartBuildOptions = {}) => {
const queryTimeStart = parseSteadyTrendPointTime(chartOptions.queryTimeStart)
const queryTimeEnd = parseSteadyTrendPointTime(chartOptions.queryTimeEnd)
return queryTimeStart !== null && queryTimeEnd !== null && queryTimeEnd > queryTimeStart
? { min: queryTimeStart, max: queryTimeEnd }
: {}
}
export const buildSteadyTrendChartOptions = (
seriesList: SteadyDataView.SteadyTrendSeries[],
zoomRange: SteadyTrendZoomRange,
@@ -511,15 +795,19 @@ export const buildSteadyTrendChartOptions = (
chartOptions: SteadyTrendChartBuildOptions = {},
chartTitle = ''
): Record<string, unknown> => {
const timeLabels = Array.from(
new Set(seriesList.flatMap(series => (series.points || []).map(point => point.time)))
).sort()
const values = seriesList.flatMap(series =>
const displaySeriesList =
chartOptions.showMissingData === true ? fillSteadyTrendMissingPoints(seriesList) : seriesList
const sortedSeriesList = sortSteadyTrendSeries(displaySeriesList)
const values = displaySeriesList.flatMap(series =>
(series.points || [])
.map(point => point.value)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
)
const unit = seriesList.find(series => series.unit)?.unit || ''
const unit = displaySeriesList.find(series => series.unit)?.unit || ''
const timeRange = {
...resolveSteadyTrendTimeRange(displaySeriesList),
...resolveSteadyTrendQueryTimeRange(chartOptions)
}
return {
activeTool: chartOptions.activeTool || 'none',
@@ -535,12 +823,18 @@ export const buildSteadyTrendChartOptions = (
},
tooltip: {
trigger: 'axis',
showDelay: 80,
hideDelay: 80,
transitionDuration: 0,
axisPointer: {
type: 'line',
snap: true,
lineStyle: {
color: 'rgba(24, 144, 255, 0.55)',
width: 1
},
label: {
show: false
}
}
},
@@ -557,8 +851,9 @@ export const buildSteadyTrendChartOptions = (
containLabel: false
},
xAxis: {
type: 'category',
data: timeLabels,
type: 'time',
min: timeRange.min,
max: timeRange.max,
boundaryGap: false,
axisLine: {
show: false,
@@ -569,11 +864,21 @@ export const buildSteadyTrendChartOptions = (
},
axisLabel: {
show: showTimeAxis,
hideOverlap: false,
interval: 0,
margin: showTimeAxis ? 10 : 0,
hideOverlap: true,
showMinLabel: true,
showMaxLabel: false,
margin: showTimeAxis ? 16 : 0,
alignMinLabel: 'left',
alignMaxLabel: 'right',
width: 72,
overflow: 'truncate',
color: STEADY_AXIS_TEXT_COLOR,
formatter: buildSteadyTimeAxisLabelFormatter(timeLabels)
rich: {
first: {
padding: [0, 0, 0, 6]
}
},
formatter: buildSteadyTimeAxisLabelFormatter(timeRange)
},
axisTick: {
show: false,
@@ -594,24 +899,65 @@ export const buildSteadyTrendChartOptions = (
{
start: zoomRange.start,
height: 13,
handleSize: '300%',
realtime: false,
cursor: 'pointer',
handleStyle: {
color: '#f5f9ff',
borderColor: '#9bbcf3',
borderWidth: 1
},
emphasis: {
handleStyle: {
color: '#e8f2ff',
borderColor: '#409eff',
borderWidth: 2,
shadowBlur: 6,
shadowColor: 'rgba(64, 158, 255, 0.35)'
}
},
bottom: '20px',
end: zoomRange.end
}
],
color: seriesList.map(resolveSeriesColor),
series: seriesList.map((series, index) => {
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
toolbox: {
// 外部工具栏通过 takeGlobalCursor 激活框选放大ECharts 仍需要注册 toolbox.dataZoom 才会创建框选控制器。
show: true,
showTitle: false,
itemSize: 0,
itemGap: 0,
left: -100,
top: -100,
feature: {
dataZoom: {
yAxisIndex: 'none',
brushStyle: {
color: 'rgba(64, 158, 255, 0.18)',
borderColor: 'rgba(64, 158, 255, 0.65)',
borderWidth: 1
}
}
}
},
color: sortedSeriesList.map(resolveSeriesColor),
series: sortedSeriesList.map((series, index) => {
const pointCount = series.points?.length || 0
const visiblePointCount = resolveSteadyTrendVisiblePointCount(pointCount, zoomRange)
return {
name: formatSeriesName(series),
type: 'line',
animation: false,
sampling: resolveSteadyTrendSampling(pointCount, visiblePointCount),
progressive: STEADY_TREND_PROGRESSIVE_CHUNK_SIZE,
progressiveThreshold: STEADY_TREND_PROGRESSIVE_THRESHOLD,
showSymbol: false,
connectNulls: false,
data: timeLabels.map(time => pointMap.get(time) ?? null),
data: buildSteadyTrendSeriesData(series),
lineStyle: {
width: resolveSteadyTrendLineWidth(
resolveSteadyTrendVisiblePointCount(series.points?.length || 0, zoomRange)
),
width: resolveSteadyTrendLineWidth(visiblePointCount),
type: resolveHarmonicLineType(series, pointCount),
opacity: resolveHarmonicLineOpacity(series),
color: resolveSeriesColor(series, index)
},
itemStyle: {
@@ -627,6 +973,17 @@ export const buildSteadyTrendChartGroups = (
zoomRange: SteadyTrendZoomRange,
chartOptions: SteadyTrendChartBuildOptions = {}
): SteadyTrendChartGroup[] => {
if (!seriesList.length && chartOptions.queryTimeStart && chartOptions.queryTimeEnd) {
return [
{
key: 'steady-trend-empty',
title: '趋势图',
group: STEADY_TREND_CHART_GROUP,
options: buildSteadyTrendChartOptions([], zoomRange, true, chartOptions, '趋势图')
}
]
}
if (!seriesList.length) return []
const groupMode = resolveGroupMode(seriesList)

View File

@@ -10,6 +10,11 @@ export interface SteadyTrendFormState {
harmonicOrders: number[]
}
export const DEFAULT_HARMONIC_ORDERS = [2]
const STEADY_TREND_CHUNK_DAYS = 3
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
const STEADY_TREND_CHUNK_MS = STEADY_TREND_CHUNK_DAYS * STEADY_TREND_DAY_MS
export const defaultTrendFormState = (): SteadyTrendFormState => {
const baseDate = new Date()
@@ -19,15 +24,33 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
timeBaseDate: baseDate,
statType: 'AVG',
qualityFlag: 0,
harmonicOrders: []
harmonicOrders: [...DEFAULT_HARMONIC_ORDERS]
}
}
const formatSteadyTrendQueryTime = (value: string) => {
// 后端趋势接口只接 yyyy-MM-dd HH:mm:ss公共时间组件生成的毫秒需要在入参层收敛。
// 后端趋势接口只接 yyyy-MM-dd HH:mm:ss公共时间组件生成的毫秒需要在入参层收敛。
return value.replace(/\.[^.]+$/, '')
}
const parseSteadyTrendQueryTime = (value: string) => {
const timestamp = Date.parse(value.replace(' ', 'T'))
return Number.isFinite(timestamp) ? timestamp : null
}
const padSteadyTrendTimeValue = (value: number) => `${value}`.padStart(2, '0')
const formatSteadyTrendChunkTime = (timestamp: number) => {
const date = new Date(timestamp)
return `${date.getFullYear()}-${padSteadyTrendTimeValue(date.getMonth() + 1)}-${padSteadyTrendTimeValue(
date.getDate()
)} ${padSteadyTrendTimeValue(date.getHours())}:${padSteadyTrendTimeValue(date.getMinutes())}:${padSteadyTrendTimeValue(
date.getSeconds()
)}`
}
export const buildSteadyTrendQueryPayload = (
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
@@ -43,3 +66,125 @@ export const buildSteadyTrendQueryPayload = (
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
}
}
export const isSteadyTrendRangeOverChunkLimit = (timeStart: string, timeEnd: string) => {
const startTimestamp = parseSteadyTrendQueryTime(timeStart)
const endTimestamp = parseSteadyTrendQueryTime(timeEnd)
if (startTimestamp === null || endTimestamp === null) return false
return endTimestamp - startTimestamp > STEADY_TREND_CHUNK_MS
}
export const buildSteadyTrendQueryChunks = (
payload: SteadyDataView.SteadyTrendQueryParams
): SteadyDataView.SteadyTrendQueryParams[] => {
const startTimestamp = parseSteadyTrendQueryTime(payload.timeStart)
const endTimestamp = parseSteadyTrendQueryTime(payload.timeEnd)
if (startTimestamp === null || endTimestamp === null || endTimestamp <= startTimestamp) return [payload]
const chunks: SteadyDataView.SteadyTrendQueryParams[] = []
let currentStart = startTimestamp
while (currentStart <= endTimestamp) {
const currentEnd = Math.min(currentStart + STEADY_TREND_CHUNK_MS - 1000, endTimestamp)
chunks.push({
...payload,
timeStart: formatSteadyTrendChunkTime(currentStart),
timeEnd: formatSteadyTrendChunkTime(currentEnd)
})
currentStart = currentEnd + 1000
}
return chunks
}
export const buildEmptySteadyTrendQueryResult = (
queryTimeStart: string,
queryTimeEnd: string,
queryCompleted = false
): SteadyDataView.SteadyTrendQueryResult => {
return {
sampled: false,
sourcePointCount: 0,
displayPointCount: 0,
loadableDays: [],
queryTimeStart,
queryTimeEnd,
queryCompleted,
series: []
}
}
export const hasSteadyTrendResultData = (result: SteadyDataView.SteadyTrendQueryResult | null) => {
return Boolean(
result?.series?.some(series =>
(series.points || []).some(point => typeof point.value === 'number' && Number.isFinite(point.value))
)
)
}
const getSteadyTrendPointTimestamp = (point: SteadyDataView.SteadyTrendPoint) => parseSteadyTrendQueryTime(point.time)
export const mergeSteadyTrendQueryResult = (
currentResult: SteadyDataView.SteadyTrendQueryResult | null,
nextResult: SteadyDataView.SteadyTrendQueryResult | null
): SteadyDataView.SteadyTrendQueryResult => {
const seriesMap = new Map<string, SteadyDataView.SteadyTrendSeries>()
const appendSeries = (seriesList: SteadyDataView.SteadyTrendSeries[] = []) => {
seriesList.forEach(series => {
const currentSeries = seriesMap.get(series.seriesKey)
if (!currentSeries) {
seriesMap.set(series.seriesKey, {
...series,
points: [...(series.points || [])]
})
return
}
currentSeries.points = [...(currentSeries.points || []), ...(series.points || [])]
})
}
appendSeries(currentResult?.series)
appendSeries(nextResult?.series)
const series = Array.from(seriesMap.values()).map(item => {
const pointMap = new Map<string, SteadyDataView.SteadyTrendPoint>()
;(item.points || []).forEach(point => {
pointMap.set(point.time, point)
})
return {
...item,
points: Array.from(pointMap.values()).sort((left, right) => {
const leftTimestamp = getSteadyTrendPointTimestamp(left)
const rightTimestamp = getSteadyTrendPointTimestamp(right)
if (leftTimestamp === null || rightTimestamp === null) return left.time.localeCompare(right.time)
return leftTimestamp - rightTimestamp
})
}
})
const displayPointCount = series.reduce((total, item) => total + (item.points?.length || 0), 0)
return {
sampled: Boolean(currentResult?.sampled || nextResult?.sampled),
bucket: nextResult?.bucket || currentResult?.bucket,
sourcePointCount: (currentResult?.sourcePointCount || 0) + (nextResult?.sourcePointCount || 0),
displayPointCount,
loadableDays: Array.from(
new Set([...(currentResult?.loadableDays || []), ...(nextResult?.loadableDays || [])])
),
queryTimeStart: currentResult?.queryTimeStart || nextResult?.queryTimeStart,
queryTimeEnd: currentResult?.queryTimeEnd || nextResult?.queryTimeEnd,
queryCompleted: Boolean(currentResult?.queryCompleted || nextResult?.queryCompleted),
series
}
}