Parcourir la source

fix(cockpit): 展示设备指标真实时间来源与逐项过期标识

消费后端timeSource,区分入库时间与采样时间,不把旧记录当成无数据。
逐项保留过期标识;新增真实详情store到指标行的离线回归,覆盖零值、缺值、设备切换与分区独立。
未构建、启动服务、浏览器调试或部署;现网设备读数待后端发布后复验。
ChenJZ il y a 19 heures
Parent
commit
72d5c83d07

+ 78 - 0
.scratch/cockpit-shell-panel-layout/verify/t56-latest-detail-chain.mjs

@@ -0,0 +1,78 @@
+import assert from 'node:assert/strict'
+import { createPinia, setActivePinia } from 'pinia'
+import { useDetailStore } from '../../../src/modules/cockpit/stores/detailStore.js'
+import { metricRows, metricsSourceText, sectionHasContent, sectionPlaceholder } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
+import { TIME_LABELS } from '../../../src/modules/cockpit/domain/fields.js'
+
+// Literal API-contract fixture, not a reimplementation of the backend selection.
+const readings = [
+    { metricCode: 'ENV_COD', metricName: 'COD', value: 32.5, unit: 'mg/L', sampledAt: '2026-10-29T10:00:00+08:00', timeSource: 'CREATED_AT', freshness: 'STALE' },
+    { metricCode: 'ENV_PH', metricName: 'pH', value: 0, unit: '', sampledAt: '2026-10-28T09:00:00+08:00', timeSource: 'CREATED_AT', freshness: 'CURRENT' },
+    { metricCode: 'ENV_TEMPERATURE', metricName: '温度', value: null, unit: '℃', sampledAt: '2026-10-29T10:00:00+08:00', timeSource: 'CREATED_AT', freshness: 'STALE' },
+]
+function aggregate(code, metrics, state = 'STALE') {
+    return {
+        deviceCode: code, aggregateState: 'READY',
+        archive: { state: 'SUCCESS', data: { deviceCode: code, deviceName: '仁科环境监测器', deviceType: '15', deviceTypeName: '仁科环境监测器' } },
+        metrics: { state, data: { deviceCode: code, state, stateReason: state === 'STALE' ? 'SAMPLE_OUTDATED' : 'READY', metrics } },
+        warnings: { state: 'SUCCESS', data: [] }, alarms: { state: 'SUCCESS', data: [] },
+        workOrders: { state: 'EMPTY', data: null }, timelines: { state: 'EMPTY', data: null },
+        alarmWarningRelations: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null },
+    }
+}
+const paths = []
+setActivePinia(createPinia())
+const store = useDetailStore()
+store.configure({
+    config: { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' } },
+    fetchImpl: async (url) => {
+        paths.push(url)
+        assert.ok(url.endsWith('/equipments/21119740/detail-aggregate'))
+        return Response.json({ code: 200, data: aggregate('21119740', readings) })
+    },
+})
+await store.openDevice('21119740')
+const metrics = store.sections.equipmentAggregate.data.sections.metrics
+assert.ok(sectionHasContent(metrics), sectionPlaceholder(metrics))
+assert.equal(metrics.state, 'stale')
+assert.equal(metrics.retained, true)
+assert.equal(paths.length, 1, 'Populated typed aggregate must not fan out to a guessed trend endpoint')
+const rows = metricRows(metrics.data)
+assert.deepEqual(rows.map(({ name, value, unit, sampledAt }) => ({ name, value, unit, sampledAt })), [
+    { name: 'COD', value: '32.5', unit: 'mg/L', sampledAt: '2026-10-29T10:00:00+08:00' },
+    { name: 'pH', value: '0', unit: '', sampledAt: '2026-10-28T09:00:00+08:00' },
+    { name: '温度', value: '—', unit: '℃', sampledAt: '2026-10-29T10:00:00+08:00' },
+])
+assert.equal(rows[0].sampledAtLabel, TIME_LABELS.ingested, 'A source create_time must not be labeled as physical sampling time')
+assert.equal(metricsSourceText(metrics.data), '指标记录已超过配置周期')
+assert.deepEqual(rows.map((row) => row.freshnessLabel), ['已过期', '', '已过期'], 'Mixed fresh/stale factors need their own stale labels')
+
+// A slow previous device response must never overwrite the newly selected device.
+let releaseA
+const lateA = new Promise((resolve) => { releaseA = resolve })
+store.closeDetail()
+store.configure({ fetchImpl: async (url) => {
+    if (url.endsWith('/equipments/A/detail-aggregate')) return lateA
+    assert.ok(url.endsWith('/equipments/B/detail-aggregate'))
+    return Response.json({ code: 200, data: aggregate('B', [
+        { metricCode: 'PRESSURE', metricName: '压力', value: 0.6, unit: 'MPa', sampledAt: '2026-11-02T10:00:00+08:00', timeSource: 'REPORTED_AT' },
+    ], 'SUCCESS') })
+} })
+const openingA = store.openDevice('A')
+await store.openDevice('B')
+releaseA(Response.json({ code: 200, data: aggregate('A', readings) }))
+await openingA
+assert.equal(store.selection.deviceCode, 'B')
+assert.equal(store.sections.equipmentAggregate.data.deviceCode, 'B')
+const bRows = metricRows(store.sections.equipmentAggregate.data.sections.metrics.data)
+assert.equal(bRows[0].value, '0.6')
+assert.equal(bRows[0].sampledAtLabel, TIME_LABELS.sampled)
+
+store.configure({ fetchImpl: async () => Response.json({ code: 200, data: aggregate('D-failed', [], 'FAILED') }) })
+await store.openDevice('D-failed')
+const failed = store.sections.equipmentAggregate.data.sections
+assert.equal(failed.metrics.state, 'failed')
+assert.equal(failed.archive.state, 'success')
+assert.equal(failed.warnings.state, 'success')
+assert.equal(failed.alarms.state, 'success')
+console.log('PASS real store -> typed metric rows: per-factor time, stale values, zero/null, time source, device race and independent sections')

+ 18 - 0
.scratch/cockpit-shell-panel-layout/verify/t56-latest-live.mjs

@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict'
+import { fetchEquipmentDetailAggregate } from '../../../src/modules/cockpit/services/detailApi.js'
+import { metricRows, sectionHasContent, sectionPlaceholder } from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
+
+const baseUrl = process.argv[2]
+assert.ok(baseUrl, 'Pass the existing cockpit read-only API base URL')
+const deviceCode = process.argv[3] || '21119740'
+const result = await fetchEquipmentDetailAggregate(deviceCode, {
+    config: { dataSource: 'real', api: { baseUrl } },
+    fetchImpl: (url, options) => fetch(url, { ...options, signal: AbortSignal.timeout(15000) }),
+})
+const metrics = result.data?.sections?.metrics
+const rows = metricRows(metrics?.data)
+console.log(JSON.stringify({ deviceCode, aggregate: result.state, metrics: metrics?.state, placeholder: sectionPlaceholder(metrics), rows }, null, 2))
+assert.ok(sectionHasContent(metrics), `${deviceCode}: latest metrics must render content, not ${sectionPlaceholder(metrics)}`)
+assert.ok(rows.some((row) => row.value !== '—'), `${deviceCode}: expected at least one real reading`)
+assert.ok(rows.every((row) => row.name && row.sampledAt), 'Every metric must retain its name and source time')
+console.log('PASS real detail API -> latest metric rows')

+ 8 - 0
CONTEXT.md

@@ -44,6 +44,14 @@ _Avoid_: 真实安装位置(无安装资料时)、管道所属设备(仅
 以井盖与剖切井筒示意模型查看窨井附近地下管道、监测设备及已有状态;未提供接管关系时仅说明空间邻近,不强行将附近管道接入井筒。
 _Avoid_: 井下实测结构(无资料时)、从附近管道推断接管关系
 
+**监测因子**:
+设备采集的具体观测量,例如温度、悬浮物、COD、氨氮、电导率和 pH;同一设备不同节点可以采集不同因子,各自拥有记录和值。
+_Avoid_: 把所有节点的因子统称为湿度、把不同节点当作不同设备
+
+**最新指标**:
+所选监测设备各节点、各监测因子分别对应的最新记录值;各指标的记录时间可以不同。旧记录仍为最后已知值,应保留其时间及过期标识;缺失值与真实零值不同,不补造读数。
+_Avoid_: 用整台设备最后一条记录代表所有节点、将过期记录当作无数据、把入库时间称为物理采样时间
+
 **设备类型指标源**:
 设备详情的最新指标不能把所有设备强行读同一张表;先按档案 `deviceType/deviceTypeName`,缺失时仅按明确设备名称类型词选择专用源(如流量计→供水流量趋势、压力计→压力趋势、漏失→漏失趋势)。专用源未授权或未发布时显示不可用/未提供,不伪造零值;档案、预警等其它分区独立保留。
 _Avoid_: 用通用EMPTY断言设备无指标、用行业猜测设备类型、把401转成正常或零值

+ 1 - 0
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -121,6 +121,7 @@
                         </b>
                         <span class="equipment-section__meta">
                             <span v-if="metric.status">{{ metric.status }}</span>
+                            <span v-if="metric.freshnessLabel" data-metric-stale>{{ metric.freshnessLabel }}</span>
                             <span data-sampled-at>{{ metric.sampledAtLabel }} {{ metric.sampledAt || '—' }}</span>
                         </span>
                     </article>

+ 1 - 0
src/modules/cockpit/domain/equipmentDetail.js

@@ -91,6 +91,7 @@ export function toMetric(raw) {
         unit: reader.optionalStr('unit'),
         status: reader.optionalStr('status'),
         sampledAt: reader.optionalStr('sampledAt'),
+        timeSource: reader.optionalStr('timeSource'),
         freshness: reader.optionalStr('freshness'),
         points: reader.optionalList('points').map(toMetricPoint),
         missing: reader.missing,

+ 6 - 2
src/modules/cockpit/domain/equipmentDetailView.js

@@ -221,9 +221,10 @@ export function metricRows(data) {
         value: valueText(metric.value),
         unit: metric.unit || '',
         status: metric.status || '',
+        freshnessLabel: metric.freshness === 'STALE' ? '已过期' : '',
         sampledAt: metric.sampledAt || '',
-        // 设备采集/上报该指标的时刻——标签取自 `TIME_LABELS` 单点词表(票 62)。
-        sampledAtLabel: TIME_LABELS.sampled,
+        // 兼容旧契约;源记录 create_time 不冒充设备采样时刻。
+        sampledAtLabel: metric.timeSource === 'CREATED_AT' ? TIME_LABELS.ingested : TIME_LABELS.sampled,
         missing: metric.missing || [],
     }))
 }
@@ -242,6 +243,9 @@ export function metricsSourceText(data) {
         SAMPLE_OUTDATED: '采样已超过配置周期',
     }
     const reason = (data && data.stateReason) || ''
+    if (reason === 'SAMPLE_OUTDATED' && (data.metrics || []).some((metric) => metric.timeSource === 'CREATED_AT')) {
+        return '指标记录已超过配置周期'
+    }
     return dictionary[reason] || ''
 }