Просмотр исходного кода

feat(cockpit): align monitoring overview KPI time and grid

ChenJZ 3 дней назад
Родитель
Сommit
6c58b98fb8

+ 164 - 0
.scratch/cockpit-real-data-wayfinding/verify/t54-monitoring-overview-grid.mjs

@@ -0,0 +1,164 @@
+/**
+ * 票 54 离线闸门:监测概览数据时间对齐 + 四统计卡 2×2。
+ *
+ * 票面四条验收逐条对上(不启动 build/dev/browser,按 AGENTS.md):
+ *   G1 数据时间语义:短读法上屏、完整 ISO 进 title、缺值仍是占位符(绝不伪造时间)、
+ *      认不出的时间串原样带出(不丢信息、不编造);两处消费点(监测概览 / 运行概况)
+ *      必须走**同一个** helper——分头手写正是「早晚不一致」的来源。
+ *   G2 布局:`layout` 由 domain 声明、渲染层只认这个通用字段(票 38 纪律:不按 key 特判);
+ *      常规宽度固定 2×2;窄态降级按**容器**判(面板栏宽 280–460px 由 `--column-w` 固定,
+ *      视口 media query 在大屏永不触发=写一条不会生效的规则);两档都 `minmax(0, ·)` 不横溢。
+ *   G3 视觉层级统一:时间读数与其余 KPI 同档 `--fs-kpi`,且专门的降档规则**整条删除**
+ *      (规格 03「无消费点不提交」)。
+ *   G4 不回归:四项 key/label/unit 顺序、在线率 hint、未声明 layout 的面板不受影响。
+ *
+ * 变异闸:node .scratch/cockpit-real-data-wayfinding/verify/t54-mutations.mjs
+ *   node .scratch/cockpit-real-data-wayfinding/verify/t54-monitoring-overview-grid.mjs
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import {
+    monitoringOverviewPanel,
+    operationOverviewPanel,
+    toMonitoringOverview,
+    toMonitoringSituation,
+} from '../../../src/modules/cockpit/domain/monitoringPanels.js'
+import { kpiBlock } from '../../../src/modules/cockpit/domain/panelBlocks.js'
+import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
+import { SECTION_STATE, section } from '../../../src/modules/cockpit/domain/sectionState.js'
+
+const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
+const sv = (data) => section(SECTION_STATE.SUCCESS, { data })
+
+let passed = 0
+let failed = 0
+function check(name, condition, message) {
+    if (condition) {
+        passed += 1
+        console.log(`PASS ${name}`)
+    } else {
+        failed += 1
+        console.error(`FAIL ${name}: ${message}`)
+    }
+}
+
+const ISO = '2026-10-12T15:15:47+08:00'
+
+// 真实适配层灌进去,不手拼领域对象(票 20 的教训:形状骗一次就白测一次)。
+const overviewData = toMonitoringOverview({
+    counts: { monitoringPointCount: 7, equipmentCount: 9 },
+    status: { onlineCount: 3, offlineCount: 6, staleCount: 0, unknownOrMissingCount: 0, denominator: 9 },
+    onlineRate: { value: '0.3333', numerator: 3, denominator: 9, state: 'AVAILABLE' },
+    createdAt: ISO,
+    typeDistribution: [{ deviceType: { code: '12', name: '压力计' }, industry: 'WATER_SUPPLY', deviceCount: 1 }],
+    mapFilter: {},
+    excluded: {},
+    freshness: {},
+})
+const overview = monitoringOverviewPanel(overviewData, sv(overviewData))
+const kpi = overview.blocks.find((b) => b.kind === 'kpi')
+
+// ================================================================ G1 数据时间语义
+
+{
+    const time = kpi.items.find((i) => i.key === 'createdAt')
+    check('G1a-shown-reading-is-short', time.value === '10-12 15:15',
+        `屏上读数必须是短读法(全串在 2×2 半格里必然省略成「${ISO.slice(0, 14)}…」),实得 ${JSON.stringify(time.value)}`)
+    check('G1b-title-carries-full-iso', time.full === ISO,
+        `完整原文必须由 full 带出、交给共用省略方法做 title(票 37 接缝),实得 ${JSON.stringify(time.full)}`)
+    check('G1c-short-is-not-the-only-copy', time.full !== time.value,
+        '显示与原文是两个字段:只有一个字段就是「要么截断、要么挤爆」的老问题')
+
+    // 缺值纪律:占位符照旧,绝不喂给短读法编出时间。
+    const empty = monitoringOverviewPanel(toMonitoringOverview({
+        counts: {}, status: {}, onlineRate: { state: 'EMPTY' }, createdAt: null, mapFilter: {}, excluded: {}, freshness: {},
+    }), sv(null))
+    const emptyTime = empty.blocks.find((b) => b.kind === 'kpi').items.find((i) => i.key === 'createdAt')
+    check('G1d-missing-time-still-placeholder', emptyTime.value === DETAIL_PLACEHOLDER && emptyTime.full === DETAIL_PLACEHOLDER,
+        `缺 createdAt 仍是占位符(value/full 同为「${DETAIL_PLACEHOLDER}」),实得 ${JSON.stringify([emptyTime.value, emptyTime.full])}`)
+
+    // 认不出的时间串原样带出(票 13 同一条纪律:不丢信息、不编造)。
+    const odd = monitoringOverviewPanel(toMonitoringOverview({
+        counts: {}, status: {}, onlineRate: { state: 'EMPTY' }, createdAt: 'record', mapFilter: {}, excluded: {}, freshness: {},
+    }), sv(null))
+    const oddTime = odd.blocks.find((b) => b.kind === 'kpi').items.find((i) => i.key === 'createdAt')
+    check('G1e-unparsable-time-verbatim', oddTime.value === 'record' && oddTime.full === 'record',
+        `解析不了的时间原样带出,实得 ${JSON.stringify([oddTime.value, oddTime.full])}`)
+
+    // 运行概况(同为四卡 + 数据时间)必须走同一条链,不是第二份实现。
+    const situation = toMonitoringSituation({ createdAt: ISO, metricStatusDistribution: [], abnormalEquipment: [] })
+    const opTime = operationOverviewPanel(situation, sv(situation)).blocks.find((b) => b.kind === 'kpi').items.find((i) => i.key === 'createdAt')
+    check('G1f-operation-overview-same-seam', opTime.value === '10-12 15:15' && opTime.full === ISO,
+        `运行概况的数据时间与监测概览同源同形,实得 ${JSON.stringify([opTime.value, opTime.full])}`)
+}
+
+// ================================================================ G2 布局:domain 声明 + 容器降级
+
+{
+    check('G2a-overview-declares-two-by-two', kpi.layout === 'two-by-two',
+        `监测概览必须在 domain 声明 2×2(票 38 纪律:布局意图归 domain),实得 ${JSON.stringify(kpi.layout)}`)
+
+    // 未声明 layout 的调用点不受影响(共享 kpiBlock 的默认值不许改别的 12 处面板)。
+    const plain = kpiBlock([{ key: 'x', label: '甲', value: '1' }])
+    check('G2b-default-layout-is-empty', plain.layout === '', `kpiBlock 默认 layout 必须是空串,实得 ${JSON.stringify(plain.layout)}`)
+
+    const panelVue = read('components/PanelBlocks.vue')
+    const tpl = panelVue.slice(0, panelVue.indexOf('</template>'))
+    check('G2c-renderer-reads-layout-not-key', /'panel-kpis--two-by-two':\s*block\.layout === 'two-by-two'/.test(tpl),
+        '渲染层必须按 block.layout 加类(不认识任何业务 key)')
+    check('G2d-no-key-special-case-in-template', !/item\.key === 'createdAt'/.test(tpl),
+        '票 38 立的纪律:渲染层不按 item.key 特判布局/字号(那是第二份布局语义)')
+
+    const css = panelVue.slice(panelVue.indexOf('<style'))
+    check('G2e-two-columns-fixed-not-autofit', /\.panel-kpis--two-by-two\s*\{[^}]*grid-template-columns:\s*repeat\(2,\s*minmax\(0,\s*1fr\)\)/.test(css),
+        '2×2 必须是固定两列且列宽 minmax(0,·)(auto 列在窄格里会横向溢出)')
+    check('G2f-narrow-fallback-is-container-query', /@container\s+panel-kpi-grid/.test(css) && /container-type:\s*inline-size/.test(css),
+        '窄态降级必须按容器判:面板栏宽固定 280–460px,视口 media query 在大屏永不触发')
+    check('G2g-no-viewport-media-query-for-kpi', !/@media[^{]*\{[^}]*panel-kpis/.test(css),
+        'KPI 降级不许退回视口 media query(那是一条永远不会生效的规则,等于没做验收第 2 条)')
+    check('G2h-fallback-targets-cards', /@container\s+panel-kpi-grid[^{]*\{[^}]*\.panel-kpis--two-by-two\s+\.panel-kpi\s*\{[^}]*grid-column:\s*1\s*\/\s*-1/.test(css),
+        '容器查不了自身尺寸(CSS containment 规则),降级必须写在子卡上横贯两列')
+}
+
+// ================================================================ G3 视觉层级统一
+
+{
+    const css = read('components/PanelBlocks.vue').slice(read('components/PanelBlocks.vue').indexOf('<style'))
+    check('G3a-time-value-rule-removed', !/\.panel-kpi__value--time\s*\{/.test(css),
+        '降档规则必须整条删除(规格 03「无消费点不提交」,留着就是死 CSS)')
+    check('G3b-kpi-values-share-one-size', /\.panel-kpi__value\s*\{[^}]*font-size:\s*var\(--fs-kpi/.test(css),
+        '所有 KPI 数值(含数据时间)共用同一条 --fs-kpi 规则 —— 同一视觉层级由「只有一档」保证')
+    check('G3c-no-time-class-binding-left', !/panel-kpi__value--time/.test(read('components/PanelBlocks.vue')),
+        '时间类名在模板与样式里都不许残留(半拆的改动最容易假绿)')
+}
+
+// ================================================================ G4 不回归
+
+{
+    check('G4a-four-items-order-and-units', kpi.items.length === 4
+        && kpi.items.map((i) => i.key).join(',') === 'monitoringPointCount,equipmentCount,onlineRate,createdAt'
+        && kpi.items[0].unit === '个' && kpi.items[1].unit === '台',
+        `四项顺序/单位是票面既有契约,实得 ${JSON.stringify(kpi.items.map((i) => [i.key, i.unit]))}`)
+    check('G4b-online-rate-hint-intact', (kpi.items.find((i) => i.key === 'onlineRate').hint || '').includes('在线 3 / 设备 9'),
+        `分母摊在在线率 hint 上(票 39 定稿),实得 ${JSON.stringify(kpi.items.find((i) => i.key === 'onlineRate').hint)}`)
+
+    // 完整原文进 title:kpiReading 必须优先取 full,且没有 full 的读数行为不变。
+    const vue = read('components/PanelBlocks.vue')
+    check('G4c-kpireading-prefers-full', /item\.full \|\| item\.value/.test(vue), 'kpiReading 必须优先取 domain 的 full(title 才是完整原文)')
+    check('G4d-unit-still-dropped-on-placeholder', /item\.unit && !isPlaceholder\(item\.value\)/.test(vue),
+        '占位符不画单位这条既有纪律不许动(否则会出「— 台」半截读数)')
+    check('G4e-chart-tip-anchor-untouched', /\.panel-blocks\s*\{[^}]*position:\s*relative/.test(vue),
+        '.chart-tip 的定位基准仍是 .panel-blocks 根:container-type 只加在 KPI 网格上,没有换掉祖先')
+    // containment 有副作用(`contain: layout` 让元素成为绝对定位后代的包含块),
+    // 所以它必须**只**落在本变体上,而且绝不能落在 .panel-blocks 根上(那是 .chart-tip 的基准)。
+    const cssOnly = vue.slice(vue.indexOf('<style'))
+    const rootRule = /\.panel-blocks\s*\{[^}]*\}/.exec(cssOnly)
+    check('G4f-container-type-not-on-root', Boolean(rootRule) && !/container-type/.test(rootRule[0]),
+        '.panel-blocks 根规则不许加 container-type(会把 .chart-tip 的定位基准从 relative 变成带 layout 的容器)')
+    const containmentRules = (cssOnly.match(/container-type:/g) || []).length
+    check('G4g-container-type-declared-once', containmentRules === 1,
+        `container-type 只允许一处(本票的 2×2 变体),实得 ${containmentRules} 处`)
+}
+
+console.log(`\nT54 monitoring overview grid: ${passed} passed, ${failed} failed`)
+if (failed) process.exitCode = 1

+ 143 - 0
.scratch/cockpit-real-data-wayfinding/verify/t54-mutations.mjs

@@ -0,0 +1,143 @@
+/**
+ * 票 54 变异闸:把本票要防的错法逐一注入,确认 t54 真的一致红;
+ * 每条变异后按字节还原并校验哈希。判据只看退出码(不管道捕获子进程输出,票 37 的沙箱教训)。
+ *
+ *   node .scratch/cockpit-real-data-wayfinding/verify/t54-mutations.mjs
+ */
+import { spawnSync } from 'node:child_process'
+import { readFileSync, writeFileSync } from 'node:fs'
+import { createHash } from 'node:crypto'
+import { fileURLToPath } from 'node:url'
+
+const ROOT = fileURLToPath(new URL('../../../', import.meta.url))
+const T54 = '.scratch/cockpit-real-data-wayfinding/verify/t54-monitoring-overview-grid.mjs'
+const PANELS = 'src/modules/cockpit/domain/monitoringPanels.js'
+const RENDERER = 'src/modules/cockpit/components/PanelBlocks.vue'
+
+const MUTATIONS = [
+    {
+        id: 'M1',
+        file: PANELS,
+        from: '    return { key: \'createdAt\', label: \'数据时间\', value: shortEventTime(value) || full, full }',
+        to: '    return { key: \'createdAt\', label: \'数据时间\', value: full, full }',
+        why: '屏上退回 ISO 全串(2×2 半格里必然省略成半截读数,验收第 1 条的原始诉求落空)→ G1a 必须红',
+    },
+    {
+        id: 'M2',
+        file: PANELS,
+        from: '    return { key: \'createdAt\', label: \'数据时间\', value: shortEventTime(value) || full, full }',
+        to: '    return { key: \'createdAt\', label: \'数据时间\', value: shortEventTime(value) || full }',
+        why: '完整原文字段丢失(title 只剩短读法,「语义全部保持」被破坏)→ G1b/G1c 必须红',
+    },
+    {
+        id: 'M3',
+        file: PANELS,
+        from: "    if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full }",
+        to: "    if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full: '' }",
+        why: '缺值分支的完整原文置空(占位符的 title 契约破坏)→ G1d 必须红',
+    },
+    {
+        id: 'M4',
+        file: PANELS,
+        from: '        ], { layout: \'two-by-two\' }),',
+        to: '        ]),',
+        why: 'domain 不再声明 2×2(退回 auto-fit,宽面板又展开成一行)→ G2a 必须红',
+    },
+    {
+        id: 'M5',
+        file: PANELS,
+        from: "            createdAtKpi(current.createdAt),\n            { key: 'offlineCount'",
+        to: "            { key: 'createdAt', label: '数据时间', value: valueText(current.createdAt) },\n            { key: 'offlineCount'",
+        why: '运行概况另起一套写法(两处数据时间早晚漂移)→ G1f 必须红',
+    },
+    {
+        id: 'M6',
+        file: RENDERER,
+        from: '    return `${item.full || item.value}${unit}`',
+        to: '    return `${item.value}${unit}`',
+        why: 'title 退回只显示值(domain 的 full 没人消费,hover 读不到完整 ISO)→ G4c 必须红',
+    },
+    {
+        id: 'M7',
+        file: RENDERER,
+        from: ":class=\"{ 'is-muted': isPlaceholder(item.value) }\"",
+        to: ":class=\"{ 'is-muted': isPlaceholder(item.value), 'panel-kpi__value--time': item.key === 'createdAt' }\"",
+        why: '渲染层重新按 item.key 特判(票 38 明令反对的第二份布局语义)→ G2d/G3c 必须红',
+    },
+    {
+        id: 'M8',
+        file: RENDERER,
+        from: '.panel-kpi__value {\n    min-width: 0;',
+        to: '.panel-kpi__value--time {\n    font-size: var(--fs-caption, 10.5px);\n}\n\n.panel-kpi__value {\n    min-width: 0;',
+        why: '时间降档规则被复活(数据时间又与四卡不同档)→ G3a/G3c 必须红',
+    },
+    {
+        id: 'M9',
+        file: RENDERER,
+        from: '    grid-template-columns: repeat(2, minmax(0, 1fr));\n    container-type: inline-size;',
+        to: '    grid-template-columns: repeat(2, 1fr);\n    container-type: inline-size;',
+        why: '列宽去掉 minmax(0,·)(auto 最小尺寸把长读数顶出横向溢出,验收第 2 条)→ G2e 必须红',
+    },
+    {
+        id: 'M10',
+        file: RENDERER,
+        from: '    grid-template-columns: repeat(2, minmax(0, 1fr));\n    container-type: inline-size;',
+        to: '    grid-template-columns: repeat(auto-fit, minmax(calc(78 * var(--u, 1px)), 1fr));\n    container-type: inline-size;',
+        why: '两列改回 auto-fit(宽面板不固定 2×2)→ G2e 必须红',
+    },
+    {
+        id: 'M11',
+        file: RENDERER,
+        from: '@container panel-kpi-grid (max-width: 260px) {',
+        to: '@media (max-width: 260px) {',
+        why: '降级退回视口 media query(面板栏宽固定 280–460px,这条规则在大屏永不生效=假完成)→ G2f/G2g 必须红',
+    },
+    {
+        id: 'M12',
+        file: RENDERER,
+        from: '    .panel-kpis--two-by-two .panel-kpi {\n        grid-column: 1 / -1;\n    }',
+        to: '    .panel-kpis--two-by-two {\n        grid-template-columns: minmax(0, 1fr);\n    }',
+        why: '把降级写在容器自身上(CSS containment 规定容器查不了自己的尺寸,规则永不命中)→ G2h 必须红',
+    },
+    {
+        id: 'M13',
+        file: RENDERER,
+        from: '.panel-blocks {\n    /* 图表 hover 详情浮层',
+        to: '.panel-blocks {\n    container-type: inline-size;\n    /* 图表 hover 详情浮层',
+        why: '给 .panel-blocks 根加 containment(contain:layout 让它成为绝对定位后代的包含块,.chart-tip 基准被换掉)→ G4f 必须红',
+    },
+]
+
+const hash = (path) => createHash('sha256').update(readFileSync(path)).digest('hex')
+
+/** 变异源文本按 CRLF 归一后再注入(票 37 的教训:锚点含 \n 而源文件是 CRLF 会假未命中)。 */
+const normalizeFor = (text, path) => (readFileSync(path, 'utf8').includes('\r\n') ? text.replace(/\n/g, '\r\n') : text)
+
+let failures = 0
+for (const m of MUTATIONS) {
+    const path = ROOT + m.file.replaceAll('/', process.platform === 'win32' ? '\\' : '/')
+    const before = hash(path)
+    const source = readFileSync(path, 'utf8')
+    const needle = normalizeFor(m.from, path)
+    if (!source.includes(needle)) {
+        console.log(`BAD  ${m.id}: 变异锚点未命中(源码已变,需更新脚本)`)
+        failures += 1
+        continue
+    }
+    writeFileSync(path, source.replace(needle, normalizeFor(m.to, path)))
+    const caught = spawnSync(process.execPath, [T54], { cwd: ROOT, stdio: 'ignore' }).status !== 0
+    writeFileSync(path, source)
+    const restored = hash(path) === before
+    if (!caught) {
+        console.log(`MISS ${m.id}: 注入后闸门仍全绿 → 守卫没有牙(${m.why})`)
+        failures += 1
+    } else if (!restored) {
+        console.log(`BAD  ${m.id}: 还原后哈希不一致,文件状态可疑,立刻人工核对`)
+        failures += 1
+    } else {
+        console.log(`OK   ${m.id} ${m.why} → 闸门转红;还原按字节一致`)
+    }
+}
+
+console.log(`\nT54 MUTATIONS: ${MUTATIONS.length - failures}/${MUTATIONS.length} 全部被抓住`)
+process.exitCode = failures ? 1 : 0

+ 21 - 8
src/modules/cockpit/components/PanelBlocks.vue

@@ -60,7 +60,12 @@
                 <!-- KPI:等宽数字 + 单位,缺值降亮度显示占位符。
                      2026-10-13 维护者要求:每项读数左侧带语义图标(domain 的 KPI_ICONS
                      按 key 映射,未命中不画);图标 aria-hidden 不参与命名。 -->
-                <div v-if="block.kind === 'kpi'" class="panel-kpis" data-kpi-row>
+                <div
+                    v-if="block.kind === 'kpi'"
+                    class="panel-kpis"
+                    :class="{ 'panel-kpis--two-by-two': block.layout === 'two-by-two' }"
+                    data-kpi-row
+                >
                     <!-- 票 38(第 4 条):基础档案的「设施总数 + 管线里程」两项落回同一张
                          auto-fit 网格自然同行。
                          票 38 第二轮(反馈第 1 条):整行独占改由 domain 的通用 `wide` 标志声明
@@ -105,7 +110,7 @@
                             v-else
                             tag="b"
                             class="panel-kpi__value"
-                            :class="{ 'is-muted': isPlaceholder(item.value), 'panel-kpi__value--time': item.key === 'createdAt' }"
+                            :class="{ 'is-muted': isPlaceholder(item.value) }"
                             :text="kpiReading(item)"
                         >
                             {{ item.value }}<em v-if="item.unit && !isPlaceholder(item.value)"> {{ item.unit }}</em>
@@ -613,7 +618,7 @@ function isPlaceholder(text) {
  */
 function kpiReading(item) {
     const unit = item.unit && !isPlaceholder(item.value) ? ` ${item.unit}` : ''
-    return `${item.value}${unit}`
+    return `${item.full || item.value}${unit}`
 }
 
 /**
@@ -889,6 +894,19 @@ onBeforeUnmount(() => {
     gap: var(--space-2, 8px);
 }
 
+/* 票 54:监测概览常规宽度 2×2;窄态按容器降级。 */
+.panel-kpis--two-by-two {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    container-type: inline-size;
+    container-name: panel-kpi-grid;
+}
+
+@container panel-kpi-grid (max-width: 260px) {
+    .panel-kpis--two-by-two .panel-kpi {
+        grid-column: 1 / -1;
+    }
+}
+
 .panel-kpi {
     display: grid;
     grid-template-columns: auto minmax(0, 1fr);
@@ -953,11 +971,6 @@ onBeforeUnmount(() => {
     line-height: 1.2;
 }
 
-.panel-kpi__value--time {
-    min-width: 0;
-    font-size: var(--fs-caption, 10.5px);
-    line-height: 1.4;
-}
 
 .panel-kpi__value em {
     color: var(--muted, #5680b0);

+ 11 - 3
src/modules/cockpit/domain/monitoringPanels.js

@@ -25,6 +25,7 @@
 import { fieldReader } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { valueText } from './equipmentDetailView.js'
+import { shortEventTime } from './eventList.js'
 import { industryLabel, normalizeDistrict, districtLabel, districtFilterLabel } from './filterOptions.js'
 import { legendVisuals, visualForStatus } from './pointStatus.js'
 import { planStatusPie } from './statusPie.js'
@@ -410,6 +411,13 @@ export function toMonitoringSituation(raw) {
 
 // ================================================================ 视图计划
 
+/** 票 54:数据时间屏上短读,完整 ISO 进入 title;缺值先占位。 */
+function createdAtKpi(value) {
+    const full = valueText(value)
+    if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full }
+    return { key: 'createdAt', label: '数据时间', value: shortEventTime(value) || full, full }
+}
+
 /** 该状态在指标状态分布里的计数。 */
 function metricCount(statuses, code) {
     const found = (statuses || []).find((row) => row.status === code)
@@ -443,8 +451,8 @@ export function monitoringOverviewPanel(data, section) {
                     : '当前范围内没有可参与计算的设备,在线率不适用',
             },
             // 采集时间:本端点只有信封级时间(见文件头第 ① 条),措辞与"采集"区分开。
-            { key: 'createdAt', label: '数据时间', value: valueText(current.createdAt) },
-        ]),
+            createdAtKpi(current.createdAt),
+        ], { layout: 'two-by-two' }),
         rowsBlock('设备状态', [
             { key: 'online', label: '在线', value: valueText(deviceStatus.online) },
             { key: 'offline', label: '离线', value: valueText(deviceStatus.offline) },
@@ -648,7 +656,7 @@ export function operationOverviewPanel(data, section) {
 
     const blocks = [
         kpiBlock([
-            { key: 'createdAt', label: '数据时间', value: valueText(current.createdAt) },
+            createdAtKpi(current.createdAt),
             { key: 'offlineCount', label: '离线设备', value: valueText(offlineCount), unit: '台' },
             { key: 'alarmCount', label: '报警中', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
             { key: 'warningCount', label: '预警中', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },

+ 2 - 1
src/modules/cockpit/domain/panelBlocks.js

@@ -262,9 +262,10 @@ export const KPI_ICON_COLORS = {
 /** KPI 块。`value` 为 null 时界面画占位符(`valueText` 负责)。
  *  每项自动带 `icon`(KPI_ICONS 映射,未命中为空串 → 组件不画图标)
  *  与 `iconColor`(KPI_ICON_COLORS,未命中回空串 → 渲染层用主蓝)。 */
-export function kpiBlock(items) {
+export function kpiBlock(items, options = {}) {
     return {
         kind: 'kpi',
+        layout: options.layout || '',
         items: items.map((item) => ({
             ...item,
             icon: KPI_ICONS[item.key] || '',