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

@@ -0,0 +1,121 @@
<template>
<section class="card checksquare-detail">
<div class="detail-header">
<div>
<div class="section-title">连续性详情</div>
<div class="section-description">
{{ selectedItem ? resolveChecksquareRowName(selectedItem) : '请选择总览表中的指标' }}
</div>
</div>
</div>
<el-empty v-if="!selectedItem" description="请选择指标查看缺失区间" />
<template v-else>
<div class="stat-grid">
<div v-for="statType in CHECKSQUARE_STAT_TYPES" :key="statType" class="stat-card">
<span class="stat-name">{{ formatChecksquareStatType(statType) }}</span>
<span class="stat-value">{{ formatStatMissingRate(selectedItem, statType) }}</span>
</div>
</div>
<el-table class="segment-table" :data="segments" size="small" max-height="220" empty-text="暂无缺失区间">
<el-table-column prop="statType" label="统计类型" width="96">
<template #default="{ row }">{{ formatChecksquareStatType(row.statType) }}</template>
</el-table-column>
<el-table-column prop="startTime" label="开始时间" min-width="160" />
<el-table-column prop="endTime" label="结束时间" min-width="160" />
<el-table-column prop="missingPointCount" label="缺失点数" width="100" align="right">
<template #default="{ row }">{{ row.missingPointCount ?? '-' }}</template>
</el-table-column>
<el-table-column prop="durationMinutes" label="持续分钟" width="100" align="right">
<template #default="{ row }">{{ row.durationMinutes ?? '-' }}</template>
</el-table-column>
</el-table>
</template>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import {
CHECKSQUARE_STAT_TYPES,
collectMissingSegments,
formatChecksquareStatType,
formatStatMissingRate,
resolveChecksquareRowName
} from '../utils/checksquareTable'
defineOptions({
name: 'ChecksquareDetailPanel'
})
const props = defineProps<{
selectedItem: SteadyDataView.SteadyChecksquareItem | null
}>()
const segments = computed(() => collectMissingSegments(props.selectedItem))
</script>
<style scoped lang="scss">
.checksquare-detail {
display: flex;
flex: none;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 12px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.section-description {
margin-top: 4px;
font-size: 13px;
color: var(--el-text-color-regular);
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.stat-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
}
.stat-name {
color: var(--el-text-color-secondary);
}
.stat-value {
font-weight: 600;
color: var(--el-text-color-primary);
}
@media (max-width: 1200px) {
.stat-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<section class="table-main card checksquare-summary">
<div class="table-header">
<div class="header-button-lf">
<span class="section-title">指标校验结果</span>
<span v-if="result" class="summary-meta">
<el-tag size="small" effect="plain">{{ result.lineName || result.lineId || '未返回监测点' }}</el-tag>
<el-tag v-if="result.intervalMinutes" size="small" effect="plain">
{{ result.intervalMinutes }} 分钟间隔
</el-tag>
</span>
</div>
<div class="header-button-ri">
<el-button type="primary" plain :icon="Refresh" :loading="loading" @click="emit('refresh')">
刷新
</el-button>
</div>
</div>
<el-table
class="summary-table"
height="100%"
:data="items"
:loading="loading"
row-key="itemKey"
:tree-props="{ children: 'children' }"
highlight-current-row
empty-text="暂无校验结果"
>
<el-table-column prop="indicatorName" label="指标名称" min-width="208">
<template #default="{ row }">
<span class="indicator-name" :title="resolveChecksquareRowName(row)">
{{ resolveChecksquareRowName(row) }}
</span>
</template>
</el-table-column>
<el-table-column prop="hasData" label="是否有数据" min-width="120" align="center">
<template #default="{ row }">
<el-tag v-if="row.hasData !== undefined" :type="row.hasData ? 'success' : 'danger'" effect="plain">
{{ formatBooleanText(row.hasData) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="missingRate" label="总缺失率" min-width="130" align="center">
<template #default="{ row }">
{{ formatMissingRate(row.missingRate, row.missingRateText) }}
</template>
</el-table-column>
<el-table-column label="平均值缺失率" min-width="130" align="center">
<template #default="{ row }">{{ formatStatMissingRate(row, 'AVG') }}</template>
</el-table-column>
<el-table-column label="最大值缺失率" min-width="130" align="center">
<template #default="{ row }">{{ formatStatMissingRate(row, 'MAX') }}</template>
</el-table-column>
<el-table-column label="最小值缺失率" min-width="130" align="center">
<template #default="{ row }">{{ formatStatMissingRate(row, 'MIN') }}</template>
</el-table-column>
<el-table-column label="CP95缺失率" min-width="140" align="center">
<template #default="{ row }">{{ formatStatMissingRate(row, 'CP95') }}</template>
</el-table-column>
<el-table-column prop="maxContinuousMissingMinutes" label="最大连续缺失" min-width="150" align="center">
<template #default="{ row }">
{{ row.maxContinuousMissingMinutes ?? '-' }}
</template>
</el-table-column>
<el-table-column label="操作" width="96" align="center" fixed="right">
<template #default="{ row }">
<el-button type="primary" link :disabled="!hasChecksquareDetail(row)" @click="emit('detail', row)">
详情
</el-button>
</template>
</el-table-column>
</el-table>
</section>
</template>
<script setup lang="ts">
import { Refresh } from '@element-plus/icons-vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import {
formatBooleanText,
formatMissingRate,
formatStatMissingRate,
hasChecksquareDetail,
resolveChecksquareRowName
} from '../utils/checksquareTable'
defineOptions({
name: 'ChecksquareSummaryTable'
})
defineProps<{
result: SteadyDataView.SteadyChecksquareQueryResult | null
items: SteadyDataView.SteadyChecksquareItem[]
loading: boolean
}>()
const emit = defineEmits<{
refresh: []
detail: [row: SteadyDataView.SteadyChecksquareItem]
}>()
</script>
<style scoped lang="scss">
.checksquare-summary {
display: flex;
min-height: 0;
flex-direction: column;
}
.summary-table {
flex: 1;
min-height: 0;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.summary-meta {
display: inline-flex;
margin-left: 15px;
gap: 6px;
vertical-align: middle;
}
.indicator-name {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,392 @@
<template>
<div class="checksquare-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsed }">
<aside class="selector-column">
<div class="ledger-panel-body">
<SteadyLedgerTree
:key="selectorResetKey"
:tree-data="ledgerTree"
:loading="loading.ledger"
:keyword="ledgerKeyword"
:default-checked-keys="defaultLedgerCheckedKeys"
:collapsed="ledgerPanelCollapsed"
@refresh="emit('refreshLedger')"
@search="emit('ledgerSearch', $event)"
@change="emit('ledgerChange', $event)"
@toggle="emit('update:ledgerPanelCollapsed', !ledgerPanelCollapsed)"
/>
</div>
</aside>
<main class="checksquare-main">
<section class="card query-card">
<div class="toolbar-field toolbar-field--time">
<span class="toolbar-field__label">时间</span>
<TimePeriodSearch
class="checksquare-time"
:unit="form.timeUnit"
:model-value="form.timeBaseDate"
:range-value="form.timeRange"
:visible-units="CHECKSQUARE_TIME_PERIOD_UNITS"
@update:unit="handleTimeUnitChange"
@update:model-value="handleTimeBaseDateChange"
@update:range-value="handleTimeRangeChange"
/>
</div>
<div class="toolbar-field indicator-form-item">
<span class="toolbar-field__label">稳态指标</span>
<div class="indicator-select-row">
<el-tree-select
v-model="selectedIndicatorKeys"
class="indicator-tree-select"
:data="indicatorSelectTree"
multiple
show-checkbox
collapse-tags
collapse-tags-tooltip
filterable
clearable
default-expand-all
node-key="treeKey"
value-key="treeKey"
:props="{ label: 'name', children: 'children' }"
placeholder="请选择指标"
@change="handleIndicatorSelectChange"
>
<template #default="{ data }">
<div class="indicator-select-node">
<span class="indicator-select-node__name">{{ data.name }}</span>
<el-tag v-if="data.unit" size="small" effect="plain">{{ data.unit }}</el-tag>
</div>
</template>
</el-tree-select>
<el-button type="primary" plain @click="handleSelectAllIndicators">全选</el-button>
</div>
</div>
<div class="query-actions">
<el-button type="primary" :icon="Search" :loading="loading.query" @click="emit('query')">
查询
</el-button>
<el-button type="primary" plain :icon="RefreshLeft" @click="emit('reset')">重置</el-button>
</div>
</section>
<div class="checksquare-content">
<ChecksquareSummaryTable
class="content-summary"
:result="result"
:items="result?.items || []"
:loading="loading.query"
@refresh="emit('query')"
@detail="openDetailDialog"
/>
</div>
</main>
<el-dialog v-model="detailDialogVisible" title="连续性详情" width="760px" append-to-body>
<ChecksquareDetailPanel :selected-item="selectedItem" />
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { RefreshLeft, Search } from '@element-plus/icons-vue'
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 SteadyLedgerTree from '@/views/steady/steadyDataView/components/SteadyLedgerTree.vue'
import { collectLeafIndicators } from '@/views/steady/steadyDataView/utils/selectionRules'
import type { ChecksquareFormState } from '../utils/checksquarePayload'
import ChecksquareDetailPanel from './ChecksquareDetailPanel.vue'
import ChecksquareSummaryTable from './ChecksquareSummaryTable.vue'
defineOptions({
name: 'ChecksquareWorkbench'
})
const props = defineProps<{
form: ChecksquareFormState
ledgerTree: SteadyDataView.SteadyLedgerNode[]
indicatorTree: SteadyDataView.SteadyIndicatorNode[]
result: SteadyDataView.SteadyChecksquareQueryResult | null
selectedItem: SteadyDataView.SteadyChecksquareItem | null
loading: {
ledger: boolean
indicator: boolean
query: boolean
}
ledgerKeyword: string
defaultLedgerCheckedKeys: string[]
defaultIndicatorCheckedKeys: string[]
ledgerPanelCollapsed: boolean
selectorResetKey: number
}>()
const emit = defineEmits<{
'update:form': [value: ChecksquareFormState]
'update:ledgerPanelCollapsed': [value: boolean]
refreshLedger: []
ledgerSearch: [value: string]
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
query: []
reset: []
selectItem: [item: SteadyDataView.SteadyChecksquareItem]
}>()
const selectedIndicatorKeys = ref<string[]>([])
const detailDialogVisible = ref(false)
const CHECKSQUARE_TIME_PERIOD_UNITS: TimePeriodUnit[] = ['day', 'week', 'month', 'year', 'custom']
const normalizeIndicatorSelectTree = (
nodes: SteadyDataView.SteadyIndicatorNode[],
parentKey = ''
): SteadyDataView.SteadyIndicatorNode[] => {
return nodes.map((node, index) => {
const treeKey = node.id || node.indicatorCode || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
return {
...node,
treeKey,
children: node.children?.length ? normalizeIndicatorSelectTree(node.children, `${treeKey}-`) : undefined
}
})
}
const indicatorSelectTree = computed(() => normalizeIndicatorSelectTree(props.indicatorTree))
const indicatorNodeMap = computed(() => {
const nodeMap = new Map<string, SteadyDataView.SteadyIndicatorNode>()
const collect = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
nodes.forEach(node => {
if (node.treeKey) nodeMap.set(node.treeKey, node)
if (node.children?.length) collect(node.children)
})
}
collect(indicatorSelectTree.value)
return nodeMap
})
const collectAllIndicatorKeys = () => {
return collectLeafIndicators(indicatorSelectTree.value).map(node => node.treeKey).filter(Boolean) as string[]
}
const emitSelectedIndicators = () => {
const selectedNodes = selectedIndicatorKeys.value
.map(key => indicatorNodeMap.value.get(key))
.filter(Boolean) as SteadyDataView.SteadyIndicatorNode[]
emit('indicatorChange', collectLeafIndicators(selectedNodes))
}
const handleIndicatorSelectChange = () => {
emitSelectedIndicators()
}
const handleSelectAllIndicators = () => {
selectedIndicatorKeys.value = collectAllIndicatorKeys()
emitSelectedIndicators()
}
const openDetailDialog = (item: SteadyDataView.SteadyChecksquareItem) => {
emit('selectItem', item)
detailDialogVisible.value = true
}
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
const timeRange = unit === 'custom' ? props.form.timeRange : buildTimePeriodRange(unit, baseDate)
emit('update:form', {
...props.form,
timeUnit: unit,
timeBaseDate: baseDate,
timeRange
})
}
const handleTimeUnitChange = (value: TimePeriodUnit) => {
updateTimeRange(value, props.form.timeBaseDate)
}
const handleTimeBaseDateChange = (value: Date) => {
updateTimeRange(props.form.timeUnit, value)
}
const handleTimeRangeChange = (value: string[]) => {
emit('update:form', {
...props.form,
timeUnit: 'custom',
timeRange: value
})
}
watch(
() => [props.defaultIndicatorCheckedKeys, indicatorSelectTree.value, props.selectorResetKey],
() => {
selectedIndicatorKeys.value = props.defaultIndicatorCheckedKeys.filter(key => indicatorNodeMap.value.has(key))
emitSelectedIndicators()
},
{ immediate: true }
)
</script>
<style scoped lang="scss">
.checksquare-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.checksquare-layout.is-ledger-collapsed {
grid-template-columns: 0 minmax(0, 1fr);
}
.selector-column {
position: relative;
height: 100%;
min-height: 0;
overflow: hidden;
}
.checksquare-layout.is-ledger-collapsed .selector-column {
z-index: 4;
overflow: visible;
}
.ledger-panel-body {
height: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.checksquare-layout.is-ledger-collapsed .ledger-panel-body {
overflow: visible;
}
.ledger-panel-body :deep(.steady-tree-card) {
height: 100%;
}
.checksquare-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.query-card {
display: grid;
grid-template-columns: minmax(430px, 1.35fr) minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
padding: 12px;
}
.toolbar-field {
display: flex;
min-width: 0;
align-items: center;
gap: 6px;
}
.toolbar-field--time {
min-width: 0;
}
.toolbar-field__label {
flex: 0 0 auto;
color: #606266;
font-size: 14px;
white-space: nowrap;
}
.checksquare-time {
flex: 1 1 0;
min-width: 0;
}
.checksquare-time :deep(.time-period-search__unit) {
width: 88px;
flex: 0 0 88px;
}
.checksquare-time :deep(.time-period-search__picker) {
width: 136px;
flex: 0 0 136px;
}
.indicator-form-item {
min-width: 0;
}
.indicator-select-row {
display: flex;
width: 100%;
min-width: 0;
gap: 8px;
}
.indicator-tree-select {
flex: 1;
min-width: 0;
}
.indicator-select-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
min-width: 0;
}
.indicator-select-node__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.query-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.checksquare-content {
position: relative;
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.content-summary {
flex: 1;
}
@media (max-width: 1360px) {
.checksquare-layout:not(.is-ledger-collapsed) {
grid-template-columns: 280px minmax(0, 1fr);
}
}
@media (max-width: 1280px) {
.query-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.query-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -0,0 +1,286 @@
/* 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 rootDir = path.resolve(currentDir, '../../../..')
const files = {
api: path.resolve(rootDir, 'api/steady/steadyDataView/index.ts'),
apiTypes: path.resolve(rootDir, 'api/steady/steadyDataView/interface/index.ts'),
page: path.resolve(rootDir, 'views/steady/checksquare/index.vue'),
workbench: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareWorkbench.vue'),
summaryTable: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareSummaryTable.vue'),
detailPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareDetailPanel.vue'),
payload: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquarePayload.ts'),
table: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareTable.ts')
}
const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '')
const exists = file => fs.existsSync(file)
const checks = [
['checksquare query api exists', () => /querySteadyChecksquare/.test(read(files.api))],
[
'checksquare api posts to expected endpoint',
() => /\/steady\/data-view\/checksquare\/query/.test(read(files.api))
],
[
'checksquare request type uses single lineId',
() => /interface SteadyChecksquareQueryParams[\s\S]*lineId: string/.test(read(files.apiTypes))
],
[
'checksquare request type supports per-order harmonic query only',
() => {
const typeBlock =
read(files.apiTypes).match(/interface SteadyChecksquareQueryParams\s*\{[\s\S]*?\n {4}\}/)?.[0] || ''
return /harmonicOrders\?: number\[\]/.test(typeBlock) && !/qualityFlag|statTypes|phases|lineIds/.test(typeBlock)
}
],
['workbench component exists', () => exists(files.workbench)],
['summary table component exists', () => exists(files.summaryTable)],
['detail panel component exists', () => exists(files.detailPanel)],
['payload utility exists', () => exists(files.payload)],
['table utility exists', () => exists(files.table)],
['page reuses steady ledger tree', () => /SteadyLedgerTree/.test(read(files.workbench))],
['page reuses shared time period search', () => /TimePeriodSearch/.test(read(files.workbench))],
['payload keeps shared time period unit state', () => /timeUnit:\s*TimePeriodUnit/.test(read(files.payload))],
[
'checksquare time search exposes day week month year custom units',
() =>
/CHECKSQUARE_TIME_PERIOD_UNITS\s*:\s*TimePeriodUnit\[\]\s*=\s*\['day',\s*'week',\s*'month',\s*'year',\s*'custom'\]/.test(
read(files.workbench)
) && /:visible-units="CHECKSQUARE_TIME_PERIOD_UNITS"/.test(read(files.workbench))
],
[
'checksquare defaults to day range',
() =>
/timeRange:\s*buildTimePeriodRange\('day',\s*baseDate\)/.test(read(files.payload)) &&
/timeUnit:\s*'day'/.test(read(files.payload))
],
['page no longer tracks floating indicator panel state', () => !/indicatorPanelCollapsed|indicator-panel-collapsed/.test(read(files.page))],
[
'query form uses tree select for steady indicators',
() =>
/<el-tree-select/.test(read(files.workbench)) &&
/v-model="selectedIndicatorKeys"/.test(read(files.workbench)) &&
/multiple/.test(read(files.workbench)) &&
/show-checkbox/.test(read(files.workbench))
],
[
'query form keeps steady indicator immediately after time selector',
() => /class="toolbar-field toolbar-field--time"[\s\S]*class="toolbar-field indicator-form-item"/.test(read(files.workbench))
],
[
'query form supports selecting all steady indicators',
() =>
/@click="handleSelectAllIndicators"/.test(read(files.workbench)) &&
/collectAllIndicatorKeys/.test(read(files.workbench))
],
[
'checksquare no longer renders floating indicator panel',
() => !/SteadyIndicatorFloatingPanel|indicatorPanelCollapsedProxy|is-indicator-expanded/.test(read(files.workbench))
],
['summary table renders unsupported stats as dash', () => /formatStatMissingRate[\s\S]*'-'/.test(read(files.table))],
[
'summary table has localized AVG MAX MIN CP95 columns',
() => /平均值缺失率[\s\S]*最大值缺失率[\s\S]*最小值缺失率[\s\S]*CP95缺失率/.test(read(files.summaryTable))
],
[
'table utility localizes checksquare stat type names',
() => /AVG:\s*'平均值'[\s\S]*MAX:\s*'最大值'[\s\S]*MIN:\s*'最小值'/.test(read(files.table))
],
['detail panel renders missing segments', () => /segments/.test(read(files.detailPanel))]
,
[
'summary table title changed to check result',
() => /指标校验结果/.test(read(files.summaryTable)) && !/指标校验总览/.test(read(files.summaryTable))
],
[
'summary table shows monitor fallback and keeps meta 15px from title',
() => {
const summaryTable = read(files.summaryTable)
return (
/class="summary-meta"/.test(summaryTable) &&
/result\.lineName\s*\|\|\s*result\.lineId\s*\|\|\s*'未返回监测点'/.test(summaryTable) &&
/\.summary-meta\s*\{[\s\S]*margin-left:\s*15px/.test(summaryTable)
)
}
],
[
'summary table uses tree rows for harmonic results',
() =>
/row-key="itemKey"/.test(read(files.summaryTable)) &&
/tree-props/.test(read(files.summaryTable)) &&
/children/.test(read(files.summaryTable))
],
[
'summary table keeps harmonic tree rows collapsed by default',
() => !/default-expand-all/.test(read(files.summaryTable))
],
[
'summary table removes harmonic order column',
() => !/<el-table-column[^>]*prop="harmonicOrder"/.test(read(files.summaryTable))
],
[
'summary table uses balanced column widths for check result',
() => {
const summaryTable = read(files.summaryTable)
const indicatorColumn = summaryTable.match(/<el-table-column[^>]*prop="indicatorName"[^>]*>/)?.[0] || ''
const hasDataColumn = summaryTable.match(/<el-table-column[^>]*prop="hasData"[^>]*>/)?.[0] || ''
const missingRateColumn = summaryTable.match(/<el-table-column[^>]*prop="missingRate"[^>]*>/)?.[0] || ''
const avgColumn = summaryTable.match(/<el-table-column[^>]*label="平均值缺失率"[^>]*>/)?.[0] || ''
const maxColumn = summaryTable.match(/<el-table-column[^>]*label="最大值缺失率"[^>]*>/)?.[0] || ''
const minColumn = summaryTable.match(/<el-table-column[^>]*label="最小值缺失率"[^>]*>/)?.[0] || ''
const cp95Column = summaryTable.match(/<el-table-column[^>]*label="CP95缺失率"[^>]*>/)?.[0] || ''
const maxMissingColumn =
summaryTable.match(/<el-table-column[^>]*prop="maxContinuousMissingMinutes"[^>]*>/)?.[0] || ''
const operationColumn = summaryTable.match(/<el-table-column[^>]*label="操作"[^>]*>/)?.[0] || ''
const stretchColumns = [
hasDataColumn,
missingRateColumn,
avgColumn,
maxColumn,
minColumn,
cp95Column,
maxMissingColumn
]
return (
/min-width="208"/.test(indicatorColumn) &&
/min-width="120"/.test(hasDataColumn) &&
/min-width="130"/.test(missingRateColumn) &&
/min-width="130"/.test(avgColumn) &&
/min-width="130"/.test(maxColumn) &&
/min-width="130"/.test(minColumn) &&
/min-width="140"/.test(cp95Column) &&
/min-width="150"/.test(maxMissingColumn) &&
/width="96"/.test(operationColumn) &&
stretchColumns.every(column => /min-width=/.test(column) && !/\swidth=/.test(column)) &&
stretchColumns.every(column => /align="center"/.test(column)) &&
/align="center"/.test(operationColumn) &&
!/align=/.test(indicatorColumn)
)
}
],
[
'workbench query card follows steady data view toolbar sizing',
() => {
const workbench = read(files.workbench)
return (
/\.query-card\s*\{[\s\S]*display:\s*grid[\s\S]*grid-template-columns:\s*minmax\(430px,\s*1\.35fr\)\s+minmax\(0,\s*1fr\)\s+auto[\s\S]*gap:\s*10px[\s\S]*align-items:\s*center[\s\S]*padding:\s*12px/.test(
workbench
) &&
/\.checksquare-time\s*\{[\s\S]*flex:\s*1\s+1\s+0[\s\S]*min-width:\s*0/.test(workbench) &&
/\.checksquare-time\s*:deep\(\.time-period-search__unit\)\s*\{[\s\S]*width:\s*88px[\s\S]*flex:\s*0\s+0\s+88px/.test(
workbench
) &&
/\.checksquare-time\s*:deep\(\.time-period-search__picker\)\s*\{[\s\S]*width:\s*136px[\s\S]*flex:\s*0\s+0\s+136px/.test(
workbench
) &&
/\.query-actions\s*\{[\s\S]*display:\s*flex[\s\S]*justify-content:\s*flex-end[\s\S]*gap:\s*8px/.test(workbench)
)
}
],
[
'summary table exposes detail action',
() => /详情/.test(read(files.summaryTable)) && /emit\('detail'/.test(read(files.summaryTable))
],
[
'workbench shows detail in dialog instead of inline panel',
() =>
/<el-dialog/.test(read(files.workbench)) &&
/ChecksquareDetailPanel/.test(read(files.workbench)) &&
!/class="content-detail"/.test(read(files.workbench))
],
[
'page builds pending rows from selected indicators',
() => /buildPendingChecksquareResult/.test(read(files.page)) && /refreshPendingResult/.test(read(files.page))
],
[
'page queries indicators sequentially',
() => /for \(const indicator of queryIndicators\)/.test(read(files.page)) && /mergeChecksquareIndicatorResult/.test(read(files.page))
],
[
'page queries harmonic orders with controlled concurrency',
() =>
/CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY\s*=\s*6/.test(read(files.page)) &&
/runChecksquareHarmonicQuery/.test(read(files.page)) &&
/workers = Array\.from\(\{[\s\S]*length: Math\.min\(CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY/.test(read(files.page)) &&
/await Promise\.all\(workers\)/.test(read(files.page)) &&
/const harmonicOrders = \[\.\.\.CHECKSQUARE_HARMONIC_ORDERS\]/.test(read(files.page)) &&
/if \(orderIndex >= harmonicOrders\.length\) return/.test(read(files.page))
],
[
'table pre-creates harmonic rows from second to fiftieth order',
() =>
/CHECKSQUARE_HARMONIC_ORDER_MIN\s*=\s*2/.test(read(files.table)) &&
/CHECKSQUARE_HARMONIC_ORDER_MAX\s*=\s*50/.test(read(files.table)) &&
/CHECKSQUARE_HARMONIC_ORDER_MAX - CHECKSQUARE_HARMONIC_ORDER_MIN \+ 1/.test(read(files.table)) &&
/children: isChecksquareHarmonicIndicator\(indicator\)\s*\?\s*buildPendingChecksquareHarmonicItems/.test(
read(files.table)
)
],
[
'table only merges indicators whose harmonic order range intersects second to fiftieth order',
() => {
const table = read(files.table)
return (
/CHECKSQUARE_HARMONIC_ORDER_MIN/.test(table) &&
/CHECKSQUARE_HARMONIC_ORDER_MAX/.test(table) &&
/hasChecksquareHarmonicOrderRange/.test(table) &&
/isChecksquareHarmonicIndicator[\s\S]*hasChecksquareHarmonicOrderRange\(indicator\)/.test(table) &&
/const shouldMergeHarmonicItems\s*=\s*isChecksquareHarmonicIndicator\(indicator\)/.test(table) &&
/const normalItems\s*=\s*shouldMergeHarmonicItems[\s\S]*resultItems/.test(table) &&
/const harmonicItems\s*=\s*shouldMergeHarmonicItems[\s\S]*\[\]/.test(table)
)
}
],
[
'table summarizes harmonic parent after all orders finish',
() =>
/buildHarmonicParentSummary/.test(read(files.table)) &&
/every\(item => isResolvedChecksquareItem\(item\)\)/.test(read(files.table)) &&
/missingPointCount \/ expectedPointCount/.test(read(files.table))
],
[
'table marks harmonic parent valid when every order child has data',
() =>
/hasData:\s*children\.every\(item => item\.hasData === true\),/.test(read(files.table)) &&
!/children\.every\(item => \(item\.missingPointCount \|\| 0\) === 0\)/.test(read(files.table))
],
[
'table keeps harmonic row keys stable while merging returned order results',
() =>
/normalizeChecksquareResultItemKey/.test(read(files.table)) &&
/normalizeChecksquareResultItemKey\([\s\S]*child\.itemKey/.test(read(files.table)) &&
/resolveChecksquareHarmonicOrder/.test(read(files.table)) &&
/resolveChecksquareHarmonicOrder\(item\) === child\.harmonicOrder/.test(read(files.table))
],
[
'table formats harmonic parent progress before final summary is ready',
() =>
/resolveChecksquareRowName[\s\S]*getHarmonicProgressText/.test(read(files.table)) &&
/已完成 \$\{resolvedCount\}\/\$\{totalCount\}/.test(read(files.table))
],
[
'page keeps selected checksquare detail synced after async row replacement',
() =>
/syncSelectedItemWithLatestResult/.test(read(files.page)) &&
/selectedItem\.value\.itemKey/.test(read(files.page)) &&
/mergeChecksquareIndicatorResult\(queryResult\.value/.test(read(files.page))
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('checksquare feature contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('checksquare feature contract passed')

View File

@@ -0,0 +1,63 @@
/* 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 rootDir = path.resolve(currentDir, '../../../..')
const files = {
page: path.resolve(rootDir, 'views/steady/checksquare/index.vue'),
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
authStore: path.resolve(rootDir, 'stores/modules/auth.ts')
}
const read = file => fs.readFileSync(file, 'utf8')
const exists = file => fs.existsSync(file)
const checks = [
['checksquare page exists', () => exists(files.page)],
['static router registers /checksquare/index', () => /path:\s*'\/checksquare\/index'/.test(read(files.staticRouter))],
['static route name is checksquare', () => /name:\s*'checksquare'/.test(read(files.staticRouter))],
[
'static router imports checksquare page',
() => /@\/views\/steady\/checksquare\/index\.vue/.test(read(files.staticRouter))
],
[
'static router aliases steady check-square to checksquare',
() => /\/steady\/check-square[\s\S]*\/steady\/checksquare/.test(read(files.staticRouter))
],
[
'dynamic router aliases check-square to checksquare',
() => /\/steady\/check-square[\s\S]*\/steady\/checksquare/.test(read(files.dynamicRouter))
],
[
'dynamic router keeps checksquare static route from being overwritten',
() => /STATIC_ROUTE_NAMES[\s\S]*'checksquare'/.test(read(files.dynamicRouter))
],
[
'auth normalizes backend checksquare menu to static entry',
() => /isChecksquareMenu[\s\S]*menu\.path\s*=\s*'\/checksquare\/index'/.test(read(files.authStore))
],
[
'auth treats 数据验证 menu title as checksquare',
() => /isChecksquareMenu[\s\S]*title\.includes\('数据验证'\)/.test(read(files.authStore))
],
[
'business menu path resolver handles checksquare',
() => /isChecksquareMenu\(menu\)[\s\S]*return\s+'\/checksquare\/index'/.test(read(files.authStore))
]
]
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
if (failures.length) {
console.error('checksquare route contract failed:')
for (const failure of failures) {
console.error(`- ${failure}`)
}
process.exit(1)
}
console.log('checksquare route contract passed')

View File

@@ -0,0 +1,263 @@
<template>
<div class="table-box checksquare-page">
<ChecksquareWorkbench
v-model:form="formState"
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
:ledger-tree="ledgerTree"
:indicator-tree="indicatorTree"
:result="queryResult"
:selected-item="selectedItem"
:loading="loading"
:ledger-keyword="ledgerKeyword"
:default-ledger-checked-keys="defaultLedgerCheckedKeys"
:default-indicator-checked-keys="defaultIndicatorCheckedKeys"
:selector-reset-key="selectorResetKey"
@refresh-ledger="loadLedgerTree"
@ledger-search="handleLedgerSearch"
@ledger-change="handleLedgerChange"
@indicator-change="handleIndicatorChange"
@query="handleQuery"
@reset="handleReset"
@select-item="handleSelectItem"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import {
getSteadyTrendIndicatorTree,
getSteadyTrendLedgerTree,
querySteadyChecksquare
} from '@/api/steady/steadyDataView'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import {
collectLeafIndicators,
collectSelectedLineIds,
findFirstLeafIndicator,
findFirstSelectableLedgerNode,
sortSteadyIndicatorTree
} from '@/views/steady/steadyDataView/utils/selectionRules'
import { normalizeSteadyLedgerTree } from '@/views/steady/steadyDataView/utils/ledgerTree'
import ChecksquareWorkbench from './components/ChecksquareWorkbench.vue'
import {
buildSteadyChecksquarePayload,
defaultChecksquareFormState,
validateChecksquareSelection
} from './utils/checksquarePayload'
import {
CHECKSQUARE_HARMONIC_ORDERS,
buildPendingChecksquareResult,
isChecksquareHarmonicIndicator,
mergeChecksquareIndicatorResult
} from './utils/checksquareTable'
defineOptions({
name: 'ChecksquareView'
})
const ledgerTree = ref<SteadyDataView.SteadyLedgerNode[]>([])
const indicatorTree = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const selectedLedgerNodes = ref<SteadyDataView.SteadyLedgerNode[]>([])
const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const queryResult = ref<SteadyDataView.SteadyChecksquareQueryResult | null>(null)
const selectedItem = ref<SteadyDataView.SteadyChecksquareItem | null>(null)
const formState = ref(defaultChecksquareFormState())
const ledgerKeyword = ref('')
const ledgerPanelCollapsed = ref(false)
const selectorResetKey = ref(0)
const defaultLedgerCheckedKeys = ref<string[]>([])
const defaultIndicatorCheckedKeys = ref<string[]>([])
const loading = reactive({
ledger: false,
indicator: false,
query: false
})
let querySerial = 0
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
const CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY = 6
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
const refreshPendingResult = () => {
queryResult.value = buildPendingChecksquareResult(selectedIndicators.value, formState.value)
selectedItem.value = null
}
const unwrapData = <T,>(response: { data: T } | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as { data: T }).data
}
return response as T
}
const cancelCurrentQuery = () => {
querySerial += 1
loading.query = false
}
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
loading.ledger = true
try {
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
// 数据校验沿用稳态趋势台账树,搜索返回扁平节点时仍需恢复固定层级。
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
} finally {
loading.ledger = false
}
}
const loadIndicatorTree = async () => {
loading.indicator = true
try {
const response = await getSteadyTrendIndicatorTree()
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 {
loading.indicator = false
}
}
const handleLedgerSearch = (value: string) => {
ledgerKeyword.value = value
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
}
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
cancelCurrentQuery()
selectedLedgerNodes.value = nodes
}
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
cancelCurrentQuery()
selectedIndicators.value = collectLeafIndicators(nodes)
refreshPendingResult()
}
const handleSelectItem = (item: SteadyDataView.SteadyChecksquareItem) => {
selectedItem.value = item
}
const findChecksquareItemByKey = (
items: SteadyDataView.SteadyChecksquareItem[],
itemKey: string
): SteadyDataView.SteadyChecksquareItem | null => {
for (const item of items) {
if (item.itemKey === itemKey) return item
const childItem = findChecksquareItemByKey(item.children || [], itemKey)
if (childItem) return childItem
}
return null
}
const syncSelectedItemWithLatestResult = () => {
if (!selectedItem.value?.itemKey || !queryResult.value) return
selectedItem.value = findChecksquareItemByKey(queryResult.value.items || [], selectedItem.value.itemKey)
}
const handleReset = () => {
cancelCurrentQuery()
formState.value = defaultChecksquareFormState()
selectedLedgerNodes.value = []
selectedIndicators.value = []
defaultLedgerCheckedKeys.value = []
defaultIndicatorCheckedKeys.value = []
queryResult.value = null
selectedItem.value = null
selectorResetKey.value += 1
}
const runChecksquareHarmonicQuery = async (
indicator: SteadyDataView.SteadyIndicatorNode,
currentQuerySerial: number
) => {
const harmonicOrders = [...CHECKSQUARE_HARMONIC_ORDERS]
let nextOrderIndex = 0
const workers = Array.from({
length: Math.min(CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY, harmonicOrders.length)
}).map(async () => {
while (currentQuerySerial === querySerial) {
const orderIndex = nextOrderIndex
nextOrderIndex += 1
if (orderIndex >= harmonicOrders.length) return
const harmonicOrder = harmonicOrders[orderIndex]
const payload = buildSteadyChecksquarePayload(lineIds.value[0], [indicator], formState.value, harmonicOrder)
const response = await querySteadyChecksquare(payload)
if (currentQuerySerial !== querySerial) return
// 谐波 2-50 次请求耗时差异较大,单次返回后立即合并,避免等待全部次数完成才刷新表格。
queryResult.value = mergeChecksquareIndicatorResult(queryResult.value, indicator, unwrapData(response))
syncSelectedItemWithLatestResult()
}
})
await Promise.all(workers)
}
const handleQuery = async () => {
const selectionError = validateChecksquareSelection({
lineIds: lineIds.value,
indicators: selectedIndicators.value,
timeRange: formState.value.timeRange
})
if (selectionError) {
ElMessage.warning(selectionError)
return
}
const currentQuerySerial = ++querySerial
const queryIndicators = [...selectedIndicators.value]
loading.query = true
refreshPendingResult()
selectedItem.value = null
try {
// 按指标串行校验,保证结果列表能随单个指标完成逐步回填。
for (const indicator of queryIndicators) {
if (currentQuerySerial !== querySerial) return
if (isChecksquareHarmonicIndicator(indicator)) {
await runChecksquareHarmonicQuery(indicator, currentQuerySerial)
continue
}
const payload = buildSteadyChecksquarePayload(lineIds.value[0], [indicator], formState.value)
const response = await querySteadyChecksquare(payload)
if (currentQuerySerial !== querySerial) return
queryResult.value = mergeChecksquareIndicatorResult(queryResult.value, indicator, unwrapData(response))
syncSelectedItemWithLatestResult()
}
} finally {
if (currentQuerySerial === querySerial) {
loading.query = false
}
}
}
onMounted(() => {
loadLedgerTree()
loadIndicatorTree()
})
</script>
<style scoped lang="scss">
.checksquare-page {
height: 100%;
min-height: 0;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,69 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
export interface ChecksquareFormState {
timeRange: string[]
timeUnit: TimePeriodUnit
timeBaseDate: Date
}
export const defaultChecksquareFormState = (): ChecksquareFormState => {
const baseDate = new Date()
return {
timeRange: buildTimePeriodRange('day', baseDate),
timeUnit: 'day',
timeBaseDate: baseDate
}
}
const padTimeValue = (value: number) => `${value}`.padStart(2, '0')
export const formatChecksquareTime = (date: Date) => {
return `${date.getFullYear()}-${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())} ${padTimeValue(
date.getHours()
)}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}`
}
export const collectChecksquareIndicatorCodes = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
return Array.from(new Set(indicators.map(item => item.indicatorCode).filter(Boolean))) as string[]
}
export const buildSteadyChecksquarePayload = (
lineId: string,
indicators: SteadyDataView.SteadyIndicatorNode[],
formState: ChecksquareFormState,
harmonicOrder?: number
): SteadyDataView.SteadyChecksquareQueryParams => {
const payload: SteadyDataView.SteadyChecksquareQueryParams = {
lineId,
indicatorCodes: collectChecksquareIndicatorCodes(indicators),
timeStart: (formState.timeRange[0] || '').replace(/\.[^.]+$/, ''),
timeEnd: (formState.timeRange[1] || '').replace(/\.[^.]+$/, '')
}
if (harmonicOrder) {
payload.harmonicOrders = [harmonicOrder]
}
return payload
}
export const validateChecksquareSelection = (params: {
lineIds: string[]
indicators: SteadyDataView.SteadyIndicatorNode[]
timeRange: string[]
}) => {
const { lineIds, indicators, timeRange } = params
if (!lineIds.length) return '请选择监测点'
if (lineIds.length > 1) return '数据校验一次只能选择一个监测点'
if (!indicators.length) return '请选择指标'
if (!timeRange[0]) return '请选择开始时间'
if (!timeRange[1]) return '请选择结束时间'
if (Date.parse(timeRange[0].replace(' ', 'T')) > Date.parse(timeRange[1].replace(' ', 'T'))) {
return '开始时间不能大于结束时间'
}
return ''
}

View File

@@ -0,0 +1,316 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
export const CHECKSQUARE_STAT_TYPES: SteadyDataView.SteadyTrendStatType[] = ['AVG', 'MAX', 'MIN', 'CP95']
export const CHECKSQUARE_HARMONIC_ORDER_MIN = 2
export const CHECKSQUARE_HARMONIC_ORDER_MAX = 50
export const CHECKSQUARE_HARMONIC_ORDERS = Array.from(
{ length: CHECKSQUARE_HARMONIC_ORDER_MAX - CHECKSQUARE_HARMONIC_ORDER_MIN + 1 },
(_item, index) => index + CHECKSQUARE_HARMONIC_ORDER_MIN
)
const CHECKSQUARE_STAT_LABEL_MAP: Record<SteadyDataView.SteadyTrendStatType, string> = {
AVG: '平均值',
MAX: '最大值',
MIN: '最小值',
CP95: 'CP95'
}
export const formatChecksquareStatType = (statType: SteadyDataView.SteadyTrendStatType | string) => {
return CHECKSQUARE_STAT_LABEL_MAP[statType as SteadyDataView.SteadyTrendStatType] || statType
}
export const formatBooleanText = (value?: boolean | null) => {
if (value === null || value === undefined) return '-'
return value ? '是' : '否'
}
export const formatMissingRate = (value?: number | null, text?: string | null) => {
if (text) return text
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-'
return `${(Number(value) * 100).toFixed(2)}%`
}
export const findStatSummary = (
item: SteadyDataView.SteadyChecksquareItem,
statType: SteadyDataView.SteadyTrendStatType
) => {
return item.statSummaries?.find(summary => summary.statType === statType)
}
export const formatStatMissingRate = (
item: SteadyDataView.SteadyChecksquareItem,
statType: SteadyDataView.SteadyTrendStatType
) => {
const summary = findStatSummary(item, statType)
if (!summary || summary.supported === false) return '-'
return formatMissingRate(summary.missingRate, summary.missingRateText)
}
export const resolveChecksquareRowName = (item: SteadyDataView.SteadyChecksquareItem) => {
const progressText = getHarmonicProgressText(item)
if (progressText) return `${item.indicatorName || item.indicatorCode}${progressText}`
if (!item.harmonicOrder) return item.indicatorName || item.indicatorCode
return `${item.harmonicOrder}`
}
export const getHarmonicProgressText = (item: SteadyDataView.SteadyChecksquareItem) => {
const children = item.children || []
if (!children.length || !children.some(child => child.harmonicOrder)) return ''
const totalCount = children.length
const resolvedCount = children.filter(child => isResolvedChecksquareItem(child)).length
if (resolvedCount >= totalCount) return ''
return `已完成 ${resolvedCount}/${totalCount}`
}
export const collectMissingSegments = (item: SteadyDataView.SteadyChecksquareItem | null) => {
if (!item) return []
return (item.statDetails || []).flatMap(detail =>
(detail.segments || [])
.filter(segment => segment.status === 'MISSING')
.map(segment => ({
...segment,
statType: detail.statType
}))
)
}
export const hasChecksquareDetail = (item: SteadyDataView.SteadyChecksquareItem) => {
return (item.statDetails || []).some(detail => (detail.segments || []).length)
}
const hasChecksquareHarmonicOrderRange = (indicator: SteadyDataView.SteadyIndicatorNode) => {
const start = Number(indicator.harmonicOrderStart)
const end = Number(indicator.harmonicOrderEnd)
if (!Number.isFinite(start) || !Number.isFinite(end)) return false
const rangeStart = Math.min(start, end)
const rangeEnd = Math.max(start, end)
return rangeStart <= CHECKSQUARE_HARMONIC_ORDER_MAX && rangeEnd >= CHECKSQUARE_HARMONIC_ORDER_MIN
}
export const isChecksquareHarmonicIndicator = (indicator: SteadyDataView.SteadyIndicatorNode) => {
// 只有指标目录明确包含 2-50 次谐波范围时,才预建谐波子行并执行逐次合并。
return hasChecksquareHarmonicOrderRange(indicator)
}
export const buildPendingChecksquareItem = (
indicator: SteadyDataView.SteadyIndicatorNode
): SteadyDataView.SteadyChecksquareItem => {
const indicatorCode = indicator.indicatorCode || indicator.id || indicator.treeKey || indicator.name
return {
itemKey: `pending|${indicatorCode}`,
indicatorCode,
indicatorName: indicator.name || indicatorCode,
children: isChecksquareHarmonicIndicator(indicator) ? buildPendingChecksquareHarmonicItems(indicator) : undefined,
statSummaries: [],
statDetails: []
}
}
export const buildPendingChecksquareHarmonicItems = (
indicator: SteadyDataView.SteadyIndicatorNode
): SteadyDataView.SteadyChecksquareItem[] => {
const indicatorCode = indicator.indicatorCode || indicator.id || indicator.treeKey || indicator.name
return CHECKSQUARE_HARMONIC_ORDERS.map(harmonicOrder => ({
itemKey: `pending|${indicatorCode}|${harmonicOrder}`,
indicatorCode,
indicatorName: indicator.name || indicatorCode,
harmonicOrder,
statSummaries: [],
statDetails: []
}))
}
export const buildPendingChecksquareResult = (
indicators: SteadyDataView.SteadyIndicatorNode[],
formState: { timeRange: string[] }
): SteadyDataView.SteadyChecksquareQueryResult => {
return {
lineId: '',
timeStart: formState.timeRange[0] || '',
timeEnd: formState.timeRange[1] || '',
items: indicators.map(buildPendingChecksquareItem)
}
}
const isResolvedChecksquareItem = (item: SteadyDataView.SteadyChecksquareItem) => {
return item.hasData !== undefined || item.expectedPointCount !== undefined || item.actualPointCount !== undefined
}
const normalizeChecksquareResultItemKey = (
item: SteadyDataView.SteadyChecksquareItem,
itemKey: string
): SteadyDataView.SteadyChecksquareItem => ({
...item,
itemKey
})
const isValidChecksquareHarmonicOrder = (value: number) => {
return Number.isInteger(value) && CHECKSQUARE_HARMONIC_ORDERS.includes(value)
}
const parseChecksquareHarmonicOrder = (value?: string | number | null) => {
const order = Number(value)
return isValidChecksquareHarmonicOrder(order) ? order : null
}
const parseChecksquareHarmonicOrderFromText = (value?: string | null) => {
if (!value) return null
const orderText = value.match(/(?:^|[^\d])([2-9]|[1-4]\d|50)(?:次|[^\d]|$)/)?.[1]
return parseChecksquareHarmonicOrder(orderText)
}
const resolveChecksquareHarmonicOrder = (item: SteadyDataView.SteadyChecksquareItem) => {
return (
parseChecksquareHarmonicOrder(item.harmonicOrder) ||
parseChecksquareHarmonicOrderFromText(item.itemKey) ||
parseChecksquareHarmonicOrderFromText(item.indicatorName) ||
parseChecksquareHarmonicOrderFromText(item.indicatorCode)
)
}
const sumNumber = (
items: SteadyDataView.SteadyChecksquareItem[],
getter: (item: SteadyDataView.SteadyChecksquareItem) => unknown
) => {
return items.reduce((total, item) => {
const value = Number(getter(item))
return Number.isFinite(value) ? total + value : total
}, 0)
}
const summarizeStatType = (
items: SteadyDataView.SteadyChecksquareItem[],
statType: SteadyDataView.SteadyTrendStatType
): SteadyDataView.SteadyChecksquareStatSummary => {
const summaries = items
.map(item => findStatSummary(item, statType))
.filter((summary): summary is SteadyDataView.SteadyChecksquareStatSummary => Boolean(summary))
const supportedSummaries = summaries.filter(summary => summary.supported !== false)
const expectedPointCount = supportedSummaries.reduce((total, summary) => total + (summary.expectedPointCount || 0), 0)
const actualPointCount = supportedSummaries.reduce((total, summary) => total + (summary.actualPointCount || 0), 0)
const missingPointCount = supportedSummaries.reduce((total, summary) => total + (summary.missingPointCount || 0), 0)
const maxContinuousMissingMinutes = Math.max(
0,
...supportedSummaries.map(summary => summary.maxContinuousMissingMinutes || 0)
)
return {
statType,
supported: supportedSummaries.length > 0,
hasData: supportedSummaries.length > 0 && supportedSummaries.every(summary => summary.hasData === true),
expectedPointCount,
actualPointCount,
missingPointCount,
missingRate: expectedPointCount ? missingPointCount / expectedPointCount : null,
maxContinuousMissingMinutes
}
}
export const buildHarmonicParentSummary = (
parentItem: SteadyDataView.SteadyChecksquareItem,
children: SteadyDataView.SteadyChecksquareItem[]
): SteadyDataView.SteadyChecksquareItem => {
if (!children.length || !children.every(item => isResolvedChecksquareItem(item))) {
return {
itemKey: parentItem.itemKey,
indicatorCode: parentItem.indicatorCode,
indicatorName: parentItem.indicatorName,
statSummaries: [],
statDetails: [],
children
}
}
const expectedPointCount = sumNumber(children, item => item.expectedPointCount)
const actualPointCount = sumNumber(children, item => item.actualPointCount)
const missingPointCount = sumNumber(children, item => item.missingPointCount)
const maxContinuousMissingMinutes = Math.max(0, ...children.map(item => item.maxContinuousMissingMinutes || 0))
const statSummaries = CHECKSQUARE_STAT_TYPES.map(statType => summarizeStatType(children, statType))
return {
...parentItem,
hasData: children.every(item => item.hasData === true),
expectedPointCount,
actualPointCount,
missingPointCount,
missingRate: expectedPointCount ? missingPointCount / expectedPointCount : null,
missingRateText: expectedPointCount ? undefined : '-',
maxContinuousMissingMinutes,
statSummaries,
statDetails: [],
children
}
}
export const mergeChecksquareIndicatorResult = (
currentResult: SteadyDataView.SteadyChecksquareQueryResult | null,
indicator: SteadyDataView.SteadyIndicatorNode,
indicatorResult: SteadyDataView.SteadyChecksquareQueryResult
): SteadyDataView.SteadyChecksquareQueryResult => {
const pendingResult = currentResult || {
lineId: indicatorResult.lineId || '',
lineName: indicatorResult.lineName,
timeStart: indicatorResult.timeStart || '',
timeEnd: indicatorResult.timeEnd || '',
intervalMinutes: indicatorResult.intervalMinutes,
items: [buildPendingChecksquareItem(indicator)]
}
const indicatorCode = indicator.indicatorCode
const resultItems = indicatorResult.items || []
const shouldMergeHarmonicItems = isChecksquareHarmonicIndicator(indicator)
const normalItems = shouldMergeHarmonicItems
? resultItems.filter(item => !resolveChecksquareHarmonicOrder(item))
: resultItems
const harmonicItems = shouldMergeHarmonicItems
? resultItems.filter(item => resolveChecksquareHarmonicOrder(item))
: []
const currentItem = pendingResult.items.find(item => item.indicatorCode === indicatorCode)
const mergedItem = normalItems[0] || currentItem || buildPendingChecksquareItem(indicator)
const currentChildren = currentItem?.children || mergedItem.children || []
const mergedChildren = currentChildren.length
? currentChildren.map(child => {
const replacement = harmonicItems.find(item => resolveChecksquareHarmonicOrder(item) === child.harmonicOrder)
return replacement
? normalizeChecksquareResultItemKey(
{
...replacement,
harmonicOrder: child.harmonicOrder
},
child.itemKey
)
: child
})
: harmonicItems
const replacement = {
...mergedItem,
itemKey: currentItem?.itemKey || mergedItem.itemKey,
indicatorName: indicator.name || mergedItem.indicatorName || mergedItem.indicatorCode,
children: mergedChildren.length ? mergedChildren : undefined
} as SteadyDataView.SteadyChecksquareItem
const finalReplacement = replacement.children?.length ? buildHarmonicParentSummary(replacement, replacement.children) : replacement
return {
...pendingResult,
lineId: indicatorResult.lineId || pendingResult.lineId,
lineName: indicatorResult.lineName || pendingResult.lineName,
timeStart: indicatorResult.timeStart || pendingResult.timeStart,
timeEnd: indicatorResult.timeEnd || pendingResult.timeEnd,
intervalMinutes: indicatorResult.intervalMinutes ?? pendingResult.intervalMinutes,
items: pendingResult.items.map(item => (item.indicatorCode === indicatorCode ? finalReplacement : item))
}
}