feat(steady): 完善稳态数据视图功能
- 更新纵坐标刻度算法,优化小数趋势图范围显示 - 添加稳态趋势图全屏模式和共享工具组件 - 实现多图联动的鼠标悬停竖线同步功能 - 调整主线线宽分档策略,降低最大线宽限制 - 重构稳态趋势工具栏,优化谐波次数选择逻辑 - 添加周时间周期搜索支持和自定义时间范围选择 - 完善稳态数据表格和指示器浮动面板功能 - 优化稳态趋势图性能,添加LTB采样和动画控制 - 修复数据表格打开前的趋势数据验证问题 - 统一时间轴标签格式化和网格对齐处理
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
@@ -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')
|
||||
263
frontend/src/views/steady/checksquare/index.vue
Normal file
263
frontend/src/views/steady/checksquare/index.vue
Normal 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>
|
||||
@@ -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 ''
|
||||
}
|
||||
316
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
316
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user