Переглянути джерело

fix(cockpit): route device metrics by equipment type

ChenJZ 1 день тому
батько
коміт
147bcedf48

+ 42 - 0
.scratch/cockpit-shell-panel-layout/issues/56-untitled.md

@@ -0,0 +1,42 @@
+<!-- DSH-IDEMPOTENCY-KEY: real-water-flow-device-metrics-v1 -->
+<!-- parentKey: 00 -->
+# 真实供水流量计可见性与按设备类型最新指标
+
+## What to build
+
+维护者反馈:真实设备 TEST-TCLLJ002215(测试流量计)距管线 TCGC202609080910 约1米,但地下管线详情视图仍看不到;其通用指标端点返回 EMPTY/NO_METRICS,设备最新指标需要按设备类型从对应专用设备表/接口读取;最低视距和平移平滑感继续优化。
+
+## Confirmed live facts
+
+2026-10-19 只读接口:`/infrastructure/geometry` 返回 TCGC202609080910(WATER_SUPPLY,diameter=100,坐标约110.3636833,28.4522936—110.3636217,28.4522799);`/map-snapshot` 返回 TEST-TCLLJ002215(测试流量计,坐标110.363649,28.452295,VALID,约1m邻近,industry/typeCode为空,WARNING);设备档案和聚合详情成功但 `deviceType/deviceTypeName` 为空,通用 `metrics/current` 和聚合 metrics 都是 `EMPTY/NO_METRICS`。Swagger存在供水专用 `/waterSupply/monitoring/flow/trend/{equipmentId}`,当前只读入口返回401,不能当作已取到指标。
+
+## Acceptance criteria
+
+- [x] 真实管线/设备用例保证无行业/类型设备仍显示于合法坐标邻域;模型强可见、清单可见、设备点选详情可达。
+- [x] 指标读取按archive/deviceType/deviceTypeName/设备名映射专用源;流量计优先供水流量趋势/设备表,压力/漏失等各自映射;generic EMPTY不遮蔽专用源失败原因。
+- [x] 专用源401/未提供时指标页显示真实不可用/未提供状态,不伪造零值;通用聚合其它分区继续展示。
+- [x] 最低视距、命中点滚轮和左拖平滑回归,现场参数待维护者确认。
+- [x] 真实响应计划、Three可见、指标分发、Vue详情和既有12脚本离线回归通过;不自动build/dev/browser。
+
+## 进度
+
+代码完成,待真实接口权限和现场画面验收。详情计划对真实坐标设备已验证:1米邻近管段、默认50米范围、无行业/类型仍生成 standalone 设备模型;设备层使用高可见渲染并可点选。设备来源区分缺失清单与显式空清单,逐点渐进呈现。
+
+设备聚合指标若通用端点为 EMPTY,会按档案设备类型/类型名/名称选择专用源:流量→`/waterSupply/monitoring/flow/trend/{equipmentId}`,压力/漏失对应专用趋势路径。专用源返回401时保留 `UNAVAILABLE` 分区状态,不伪造指标或零值;真实设备当前专用接口权限边界已实测记录。档案领域对象已保留deviceType类型码;没有已确认专用液位路径时不宣称level支持。
+
+验证:t56-real-water-device、t56-device-metrics、t55-device-loading、t55-detail-usability 8/8、t50-53-local-enhancement 12个脚本、t54物理/主图材质、t23-click-flow、t23-sfc-compile全部通过。按AGENTS未运行build/dev/browser/GPU,现场视觉和后端专用接口权限仍待维护者验收。
+
+## Comments
+
+### local — 2026-10-19
+已完成真实响应核对和前端修复;缺少后端设备类型字段时按设备名称识别流量/压力/漏失类型,仅作为指标源路由,不推导设备绑定关系。
+
+## Answer
+
+代码完成;当前真实专用流量接口返回401,因此最新指标仍需后端授权或公开cockpit代理后才能显示。
+
+Status: ready-for-human
+Assignee: codex
+Type: task
+Blocked by:
+Labels: ready-for-human, wayfinder:task

+ 43 - 0
.scratch/cockpit-shell-panel-layout/verify/t56-device-metrics.mjs

@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict'
+import { deviceMetricKind, deviceMetricPath, toTypedMetricsPayload } from '../../../src/modules/cockpit/domain/deviceMetricSources.js'
+import { fetchEquipmentDetailAggregate } from '../../../src/modules/cockpit/services/detailApi.js'
+assert.equal(deviceMetricKind({ name: '测试流量计' }), 'flow')
+assert.equal(deviceMetricPath('flow', 'TEST-TCLLJ002215'), '/waterSupply/monitoring/flow/trend/TEST-TCLLJ002215')
+const typed = toTypedMetricsPayload({ data: [{ flow: 12.5, unit: 'm³/h', timestamp: '2026-10-19T10:00:00+08:00' }] }, 'D', 'flow')
+assert.equal(typed.metrics[0].value, 12.5)
+assert.equal(typed.metrics[0].unit, 'm³/h')
+const paths = []
+const options = {
+    config: { dataSource: 'real', api: { baseUrl: 'http://test.invalid/api/cockpit/v1' } },
+    fetchImpl: async (url) => {
+        paths.push(url)
+        if (url.endsWith('/detail-aggregate')) return new Response(JSON.stringify({ code: 200, data: {
+            deviceCode: 'TEST-TCLLJ002215', aggregateState: 'READY',
+            archive: { state: 'SUCCESS', data: { deviceCode: 'TEST-TCLLJ002215', deviceName: '测试流量计', deviceTypeName: '流量计' } },
+            metrics: { state: 'EMPTY', reason: 'NO_METRICS', data: null }, warnings: { state: 'SUCCESS', data: [] }, alarms: { state: 'SUCCESS', data: [] },
+            alarmWarningRelations: { state: 'SUCCESS', data: [] }, workOrders: { state: 'EMPTY', data: null }, timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null },
+        } }))
+        if (url.includes('/waterSupply/monitoring/flow/trend/')) return new Response(JSON.stringify({ code: 200, data: [{ flow: 12.5, unit: 'm³/h', timestamp: '2026-10-19T10:00:00+08:00' }] }))
+        throw new Error(`unexpected ${url}`)
+    },
+}
+const result = await fetchEquipmentDetailAggregate('TEST-TCLLJ002215', options)
+assert.equal(paths.length, 2)
+assert.equal(result.data.sections.metrics.data.metrics[0].value, 12.5)
+assert.equal(result.data.sections.metrics.data.metrics[0].unit, 'm³/h')
+assert.equal(deviceMetricKind({ type: 'FLOW_METER', name: '设备' }), 'flow')
+assert.equal(deviceMetricKind({ type: 'LEVEL_METER', name: '设备' }), '')
+const failedPaths = []
+const failed = await fetchEquipmentDetailAggregate('D-401', { config: options.config, fetchImpl: async (url) => {
+    failedPaths.push(url)
+    if (url.endsWith('/detail-aggregate')) return new Response(JSON.stringify({ code: 200, data: {
+        deviceCode: 'D-401', aggregateState: 'READY', archive: { state: 'SUCCESS', data: { deviceCode: 'D-401', deviceType: 'FLOW_METER', deviceName: '设备' } },
+        metrics: { state: 'EMPTY', reason: 'NO_METRICS', data: null }, warnings: { state: 'SUCCESS', data: [] }, alarms: { state: 'SUCCESS', data: [] },
+        alarmWarningRelations: { state: 'SUCCESS', data: [] }, workOrders: { state: 'EMPTY', data: null }, timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null },
+    } }))
+    return new Response(JSON.stringify({ code: 401, msg: 'unauthorized' }), { status: 401 })
+} })
+assert.equal(failedPaths.length, 2)
+assert.equal(failed.data.sections.metrics.state, 'unavailable')
+assert.equal(failed.data.sections.warnings.state, 'success')
+console.log('PASS 流量计按设备类型切专用指标源,通用EMPTY不覆盖专用读数')

+ 43 - 0
src/modules/cockpit/domain/deviceMetricSources.js

@@ -0,0 +1,43 @@
+const SOURCES = Object.freeze({
+    flow: '/waterSupply/monitoring/flow/trend',
+    pressure: '/waterSupply/monitoring/pressure/trend',
+    leakage: '/waterSupply/monitoring/leakage/trend',
+})
+
+export function deviceMetricKind(archive = {}) {
+    const text = [archive.type, archive.deviceType, archive.typeName, archive.name, archive.model, archive.spec, archive.remark]
+        .filter(Boolean).join(' ').toLowerCase()
+    if (/流量|flow/.test(text)) return 'flow'
+    if (/压力|pressure/.test(text)) return 'pressure'
+    if (/漏失|漏损|leak/.test(text)) return 'leakage'
+    return ''
+}
+
+export function deviceMetricPath(kind, deviceCode) {
+    const base = SOURCES[kind]
+    return base && deviceCode ? `${base}/${encodeURIComponent(deviceCode)}` : ''
+}
+
+function metricValue(row) {
+    for (const key of ['value', 'flow', 'flowRate', 'pressure', 'leakage', 'level', 'waterLevel', 'currentValue', 'measurementValue']) {
+        if (row && row[key] !== undefined && row[key] !== null && row[key] !== '') return row[key]
+    }
+    return null
+}
+
+export function toTypedMetricsPayload(raw, deviceCode, kind) {
+    const body = raw && raw.data && !Array.isArray(raw.data) ? raw.data : raw
+    const rows = Array.isArray(body) ? body : Array.isArray(body?.metrics) ? body.metrics
+        : Array.isArray(body?.records) ? body.records : Array.isArray(body?.data) ? body.data : []
+    const metricName = kind === 'flow' ? '流量' : kind === 'pressure' ? '压力' : kind === 'leakage' ? '漏失' : '液位'
+    const metrics = rows.map((row) => ({
+        metricCode: row.metricCode || kind,
+        metricName: row.metricName || metricName,
+        value: metricValue(row),
+        unit: row.unit || (kind === 'flow' ? 'm³/h' : kind === 'pressure' ? 'MPa' : ''),
+        status: row.status || '',
+        sampledAt: row.sampledAt || row.timestamp || row.time || row.monitorTime || '',
+        freshness: row.freshness || 'CURRENT',
+    })).filter((row) => row.value !== null || row.sampledAt)
+    return { deviceCode, state: metrics.length ? 'CURRENT' : 'EMPTY', stateReason: metrics.length ? 'READY' : 'NO_METRICS', metrics }
+}

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

@@ -43,6 +43,7 @@ export function toEquipmentArchive(raw) {
         model: reader.optionalStr('deviceModel'),
         spec: reader.optionalStr('deviceSpec'),
         manufacturer: reader.optionalStr('manufacturer'),
+        type: reader.optionalStr('deviceType'),
         typeName: reader.optionalStr('deviceTypeName'),
         assetValue: reader.optionalNum('assetValue'),
         expectedServiceYears: reader.optionalNum('expectedServiceYears'),

+ 16 - 3
src/modules/cockpit/services/detailApi.js

@@ -10,6 +10,7 @@ import { toEquipmentArchive, toEquipmentDetailAggregate, toDeviceFreshnessSectio
 import { toEventRead } from '../domain/events.js'
 import { toWorkOrder, toWorkOrderTimeline } from '../domain/workOrder.js'
 import { isEmptyBy, fetchSection } from './domainRequest.js'
+import { deviceMetricKind, deviceMetricPath, toTypedMetricsPayload } from '../domain/deviceMetricSources.js'
 import { SECTION_STATE } from '../domain/sectionState.js'
 
 function missingCode(kind, code) {
@@ -68,14 +69,26 @@ export function fetchEquipmentMetrics(deviceCode, options = {}) {
  * 设备详情聚合(票 11 的主路径):一次拿到档案/指标/预警/报警/工单/时间线/图片,
  * 每个分区独立成败。
  */
-export function fetchEquipmentDetailAggregate(deviceCode, options = {}) {
+export async function fetchEquipmentDetailAggregate(deviceCode, options = {}) {
     if (!deviceCode) return missingCode('设备', deviceCode)
-
-    return fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/detail-aggregate`, {
+    const aggregate = await fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/detail-aggregate`, {
         toData: toEquipmentDetailAggregate,
         config: options.config,
         fetchImpl: options.fetchImpl,
     })
+    const metrics = aggregate.data?.sections?.metrics
+    const archive = aggregate.data?.sections?.archive?.data
+    const kind = deviceMetricKind(archive)
+    const path = deviceMetricPath(kind, deviceCode)
+    if (!path || !metrics || metrics.state !== SECTION_STATE.EMPTY) return aggregate
+    const typed = await fetchSection(path, {
+        toData: (payload) => toMetricsPayload(toTypedMetricsPayload(payload, deviceCode, kind)),
+        isEmpty: isEmptyBy((payload) => toTypedMetricsPayload(payload, deviceCode, kind).metrics.length === 0),
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+    if (!aggregate.data || typed.state === SECTION_STATE.EMPTY) return aggregate
+    return { ...aggregate, data: { ...aggregate.data, sections: { ...aggregate.data.sections, metrics: typed } } }
 }
 
 /** 设备新鲜度(票 16)。 */

+ 9 - 2
src/modules/cockpit/services/threePipeEnhancement.js

@@ -127,11 +127,15 @@ function buildNeighborhood(plan) {
         group.userData.device = device
         group.position.copy(project(device.position, center, frame.scale))
         const radius = device.pipeDisplayRadiusM || 0
-        const deviceScale = Math.max(2, Math.min(6, radius * 2))
+        const deviceScale = Math.max(3.5, Math.min(7, radius * 2))
         group.scale.setScalar(deviceScale)
+        group.renderOrder = 20
         group.position.y = ((frame.depthM || 0) - device.depthM) * frame.scale + Math.max(radius * 1.06, device.mountHeightM || 0) + deviceScale * 0.35
         const stem = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.25, 8), material('#b9c5d0'))
         stem.position.y = -0.16
+        stem.renderOrder = 21
+        stem.material.depthTest = false
+        stem.material.depthWrite = false
         group.add(stem)
         const color = (PIPE_PALETTE[device.colorKey] || PIPE_PALETTE.UNKNOWN).color
         const shape = device.industry === 'GAS' ? new THREE.BoxGeometry(0.22, 0.32, 0.18)
@@ -139,11 +143,14 @@ function buildNeighborhood(plan) {
             : device.industry === 'MANHOLE' ? new THREE.BoxGeometry(0.3, 0.1, 0.22)
             : new THREE.ConeGeometry(0.17, 0.3, 12)
         const body = new THREE.Mesh(shape, new THREE.MeshStandardMaterial({ color, metalness: 0.25, roughness: 0.3,
-            emissive: color, emissiveIntensity: 0.28 }))
+            emissive: color, emissiveIntensity: 0.28, depthTest: false, depthWrite: false }))
         body.name = 'device-body'
         group.add(body)
         const indicator = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.07, 0.035), new THREE.MeshBasicMaterial({ color: '#edffff' }))
         indicator.position.set(0, 0.05, 0.16)
+        indicator.material.depthTest = false
+        indicator.material.depthWrite = false
+        indicator.renderOrder = 22
         group.add(indicator)
         root.add(group)
     }