فهرست منبع

feat(cockpit): 实体详情面板与卡片交互收口(票65)——摘要/标签/阶段分区、中文标签与单位、明细枚举、关联设备定位

ChenJZ 1 روز پیش
والد
کامیت
ecf20e9d91

+ 10 - 2
src/modules/cockpit/components/EntityDetailDrawer.vue

@@ -8,6 +8,7 @@
                 </div>
                 <button type="button" class="entity-drawer__close" aria-label="关闭详情" @click="store.close">×</button>
             </header>
+            <!-- 维护者 2026-10-29:等级不在这里重复 —— 它作为带色的指标卡出现在摘要区。 -->
             <div v-if="view.status || view.categoryText" class="entity-drawer__badges">
                 <span v-if="view.categoryText" class="entity-drawer__badge" data-entity-category>{{ view.categoryText }}</span>
                 <span v-if="view.status" class="entity-drawer__badge entity-drawer__status" :data-tone="view.tone" data-entity-status>{{ view.status }}</span>
@@ -17,7 +18,7 @@
                 详情暂未取到,已保留列表摘要。
                 <button type="button" @click="store.retry">重试</button>
             </p>
-            <div class="entity-drawer__body"><EntityDetailSections :detail="store.detail" /></div>
+            <div class="entity-drawer__body"><EntityDetailSections :detail="store.detail" @locate-device="(code) => emit('locate-device', code)" /></div>
         </aside>
     </Transition>
 </template>
@@ -27,6 +28,7 @@ import { entityDetailView } from '../domain/entityDetail.js'
 import { useEntityDetailStore } from '../stores/entityDetailStore.js'
 import EntityDetailSections from './EntityDetailSections.vue'
 defineProps({ side: { type: String, default: 'right' } })
+const emit = defineEmits(['locate-device'])
 const store = useEntityDetailStore()
 const view = computed(() => entityDetailView(store.detail))
 </script>
@@ -120,6 +122,8 @@ const view = computed(() => entityDetailView(store.detail))
     justify-self: start;
 }
 
+.entity-drawer__level { font-weight: 700; }
+
 .entity-drawer__badge {
     padding: 1px calc(7 * var(--u, 1px));
     border: 1px solid rgba(1, 171, 234, 0.3);
@@ -128,9 +132,13 @@ const view = computed(() => entityDetailView(store.detail))
     font-size: var(--fs-caption, 10.5px);
 }
 
+.entity-drawer__level[data-tone='lv1'],
 .entity-drawer__status[data-tone='lv1'] { border-color: rgba(255, 59, 82, 0.55); color: #ffc2c9; }
+.entity-drawer__level[data-tone='lv2'],
 .entity-drawer__status[data-tone='lv2'] { border-color: rgba(255, 176, 32, 0.55); color: #ffdc91; }
+.entity-drawer__level[data-tone='lv3'],
 .entity-drawer__status[data-tone='lv3'] { border-color: rgba(1, 171, 234, 0.55); color: #b7e4ff; }
+.entity-drawer__level[data-tone='lv4'],
 .entity-drawer__status[data-tone='lv4'] { border-color: rgba(53, 219, 146, 0.5); color: #b8f2d4; }
 
 .entity-drawer__hint {
@@ -179,4 +187,4 @@ const view = computed(() => entityDetailView(store.detail))
         max-height: 72vh;
     }
 }
-</style>
+</style>

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 17 - 4
src/modules/cockpit/components/EntityDetailSections.vue


+ 198 - 36
src/modules/cockpit/domain/entityDetail.js

@@ -4,7 +4,8 @@
  * 列表只传 { kind, id };本模块负责身份校验、不同后端载荷的正规化和紧凑详情视图。
  * point/device 沿用既有详情链,不在此重复实现。
  */
-import { eventLevelTag, eventStatusText } from './eventList.js'
+import { eventLevelFromBucket, eventLevelTag, eventStatusText } from './eventList.js'
+import { DEFAULT_TONE } from './statusTones.js'
 import { toWorkOrder, toWorkOrderTimeline, workOrderStateName } from './workOrder.js'
 import { trackOf } from './workOrderTimeline.js'
 
@@ -83,10 +84,14 @@ export function entityDetailView(detail) {
     const current = detail || {}
     return {
         title: current.title || current.name || current.id || '实体详情',
-        subtitle: current.subtitle || [current.kindLabel, current.id].filter(Boolean).join(' · '),
+        // 副标题用业务编号(不是主键):主键对使用者没有意义。
+        subtitle: current.subtitle || [current.kindLabel, current.displayCode || current.id].filter(Boolean).join(' · '),
         tone: current.tone || 'neutral',
         status: current.statusText || '',
-        // 类别是身份而不是普通字段:抬头要能一眼看出「中文(英文码)」。
+        // 等级与状态各有自己的语义色:等级沿用卡片那张四级词表,不与状态色混用。
+        levelLabel: current.levelLabel || '',
+        levelTone: current.levelTone || '',
+        // 类别是身份而不是普通字段:抬头要能一眼看出是什么对象。
         categoryText: current.categoryText || '',
         summary: Array.isArray(current.summary) ? current.summary : [],
         tabs: Array.isArray(current.tabs) ? current.tabs : [],
@@ -140,17 +145,100 @@ export function facilityStatusTone(status) {
     return 'neutral'
 }
 
+/**
+ * 数值型指标的**单位登记**(维护者 2026-10-29「所有数值都要带单位」)。
+ *
+ * 只在这里写一次,摘要网格与业务明细都从这里取 —— 两处各写一份单位必然漂移
+ * (已经踩过:摘要写「长度(米)」、明细写「长度」+ 单位,同一份数据两种长相)。
+ * 单位来自后端领域注释,不由前端猜。
+ */
+const COMMON_UNITS = {
+    lengthMeters: '米',
+    diameterMillimeters: '毫米',
+    designCapacity: '万m³/d',
+    currentCapacity: '万m³/d',
+    designHead: 'm',
+    storageCapacity: 'm³',
+    pumpCount: '台',
+    powerCapacity: 'kW',
+    assetValue: '元',
+    useLifeYears: '年',
+    riskScore: '分',
+}
+
+/**
+ * 同一字段名在不同设施里量纲不同:泵站设计流量是 m³/h,排水工程设施设计流量是 m³/s。
+ * 所以单位必须**按类别**登记,"一个键一个单位"会张冠李戴。
+ */
+const UNIT_BY_CATEGORY = {
+    PIPE_NETWORK: {},
+    WATER_PLANT: {},
+    PUMP_STATION: { designFlow: 'm³/h' },
+    ENGINEERING_FACILITY: { designFlow: 'm³/s' },
+    EMERGENCY_RESOURCE: {},
+    HAZARD: {},
+    PROTECTION_TARGET: {},
+}
+
+/** 字段在该设施类别下的单位;没登记返回空串(纯文本字段)。 */
+function unitOf(category, key) {
+    const scoped = UNIT_BY_CATEGORY[category] || {}
+    return scoped[key] !== undefined ? scoped[key] : COMMON_UNITS[key] || ''
+}
+
+/** 摘要与明细共用的中文字段名(维护者 2026-10-29:一律中文、不带括注)。 */
+const SUMMARY_LABELS = {
+    lengthMeters: '长度',
+    diameterMillimeters: '管径',
+    material: '材质',
+    designCapacity: '设计能力',
+    currentCapacity: '当前能力',
+    treatmentProcess: '处理工艺',
+    designFlow: '设计流量',
+    designHead: '设计扬程',
+    storageCapacity: '调蓄容积',
+    pumpCount: '水泵数量',
+    powerCapacity: '装机功率',
+    assetValue: '资产价值',
+    useLifeYears: '预计使用年限',
+    riskScore: '风险分值',
+    riskLevel: '风险等级',
+    fmodel: '型号',
+    model: '型号',
+    specification: '规格',
+    maintainer: '维护人',
+    level: '等级',
+    rectifyDeadline: '整改期限',
+    rectifyPerson: '整改人',
+    assessmentTime: '评估时间',
+    facilityLevel: '设施等级',
+}
+/** 同类字段在不同设施里的业务名不同(隐患等级 vs 设施等级),差异只在这里登记。 */
+const SUMMARY_LABELS_BY_CATEGORY = {
+    HAZARD: { level: '隐患等级' },
+    ENGINEERING_FACILITY: { facilityLevel: '设施等级' },
+}
+
 function assetSummary(category, a, status, location) {
+    // 摘要只挑"一眼要看的读数",键与明细同源;单位统一由 METRIC_UNITS 提供。
     const byCategory = {
-        WATER_PLANT: [['当前能力', a.currentCapacity], ['设计能力', a.designCapacity], ['处理工艺', a.treatmentProcess]],
-        ENGINEERING_FACILITY: [['设施等级', a.facilityLevel], ['设计流量', a.designFlow], ['调蓄容积', a.storageCapacity]],
-        PUMP_STATION: [['水泵数量', a.pumpCount], ['设计流量', a.designFlow], ['装机功率', a.powerCapacity]],
-        PIPE_NETWORK: [['长度(米)', a.lengthMeters], ['管径(毫米)', a.diameterMillimeters], ['材质', a.material]],
-        HAZARD: [['隐患等级', a.level], ['整改期限', a.rectifyDeadline], ['整改人', a.rectifyPerson]],
-        PROTECTION_TARGET: [['风险等级', a.riskLevel], ['风险分值', a.riskScore], ['评估时间', a.assessmentTime]],
-        EMERGENCY_RESOURCE: [['型号', a.model], ['规格', a.specification], ['维护人', a.maintainer]],
+        WATER_PLANT: ['currentCapacity', 'designCapacity', 'treatmentProcess'],
+        ENGINEERING_FACILITY: ['facilityLevel', 'designFlow', 'storageCapacity'],
+        PUMP_STATION: ['pumpCount', 'designFlow', 'powerCapacity'],
+        PIPE_NETWORK: ['lengthMeters', 'diameterMillimeters', 'material'],
+        HAZARD: ['level', 'rectifyDeadline', 'rectifyPerson'],
+        PROTECTION_TARGET: ['riskLevel', 'riskScore', 'assessmentTime'],
+        EMERGENCY_RESOURCE: ['model', 'specification', 'maintainer'],
     }
-    return summaryItems([['状态', status], ...(byCategory[category] || []), ['位置', location]])
+    const labels = { ...SUMMARY_LABELS, ...SUMMARY_LABELS_BY_CATEGORY[category] }
+    const rows = (byCategory[category] || []).map((key) => {
+        const unit = unitOf(category, key)
+        const raw = a[key]
+        // 只有真有单位时才拼单位;纯文本字段(材质/负责人…)原样。
+        const value = unit ? withUnitText(raw, unit) : raw
+        return [labels[key] || SUMMARY_LABELS[key] || key, value]
+    })
+    return summaryItems([['状态', status], ...rows, ['位置', location]])
 }
 function assetPeople(a) { return peopleItems([['负责人', a.principal], ['维护人', a.maintainer], ['整改人', a.rectifyPerson], ['联系电话', a.contactPhone || a.maintainerPhone]]) }
 function assetAttachments(a) { return attachmentItems(a.images || a.image) }
@@ -239,8 +327,8 @@ function pipesFields(a) {
             format: (v) => (drainage ? codedText(v, NETWORK_LEVEL_TEXT) : ''),
             invalidNote: drainage ? '' : '仅排水管道适用',
         },
-        { label: '长度', key: 'lengthMeters', unit: '米' },
-        { label: '管径', key: 'diameterMillimeters', unit: '毫米' },
+        { label: '长度', key: 'lengthMeters' },
+        { label: '管径', key: 'diameterMillimeters' },
         { label: '材质', key: 'material' },
         { label: '管养单位', key: 'managementUnit' },
         { label: '建设日期', key: 'buildDate' },
@@ -252,8 +340,8 @@ const ASSET_FIELD_ROWS = {
     PIPE_NETWORK: null, // 见 pipesFields():需要按管网类型决定二级分类是否适用
     WATER_PLANT: [
         { label: '水源类型', key: 'waterSourceType' },
-        { label: '设计能力', key: 'designCapacity', unit: '万m³/d' },
-        { label: '当前能力', key: 'currentCapacity', unit: '万m³/d' },
+        { label: '设计能力', key: 'designCapacity' },
+        { label: '当前能力', key: 'currentCapacity' },
         { label: '处理工艺', key: 'treatmentProcess', wide: true },
         { label: '投产日期', key: 'commissionDate' },
         { label: '水质标准', key: 'waterQualityStandard' },
@@ -266,18 +354,18 @@ const ASSET_FIELD_ROWS = {
         { label: '设施等级', key: 'facilityLevel' },
         { label: '管理单位', key: 'managementUnit' },
         { label: '建设日期', key: 'buildDate' },
-        { label: '设计流量', key: 'designFlow', unit: 'm³/s' },
-        { label: '设计扬程', key: 'designHead', unit: 'm' },
-        { label: '调蓄容积', key: 'storageCapacity', unit: 'm³' },
+        { label: '设计流量', key: 'designFlow' },
+        { label: '设计扬程', key: 'designHead' },
+        { label: '调蓄容积', key: 'storageCapacity' },
         { label: '关联设备', key: 'equipmentIds', wide: true },
         { label: '备注', key: 'remark', wide: true },
     ],
     PUMP_STATION: [
         { label: '泵站类型', key: 'stationType' },
-        { label: '水泵数量', key: 'pumpCount', unit: '台' },
-        { label: '设计流量', key: 'designFlow', unit: 'm³/h' },
-        { label: '设计扬程', key: 'designHead', unit: 'm' },
-        { label: '装机功率', key: 'powerCapacity', unit: 'kW' },
+        { label: '水泵数量', key: 'pumpCount' },
+        { label: '设计流量', key: 'designFlow' },
+        { label: '设计扬程', key: 'designHead' },
+        { label: '装机功率', key: 'powerCapacity' },
         { label: '投产日期', key: 'commissionDate' },
         { label: '负责人', key: 'principal' },
         { label: '联系电话', key: 'contactPhone' },
@@ -288,8 +376,8 @@ const ASSET_FIELD_ROWS = {
         { label: '型号', key: 'model' },
         { label: '规格', key: 'specification', wide: true },
         { label: '制造商', key: 'manufacturer' },
-        { label: '资产价值', key: 'assetValue', unit: '元' },
-        { label: '预计使用年限', key: 'useLifeYears', unit: '年' },
+        { label: '资产价值', key: 'assetValue' },
+        { label: '预计使用年限', key: 'useLifeYears' },
         { label: '安装时间', key: 'installTime' },
         { label: '维护人', key: 'maintainer' },
         { label: '维护电话', key: 'maintainerPhone' },
@@ -317,7 +405,7 @@ const ASSET_FIELD_ROWS = {
     PROTECTION_TARGET: [
         { label: '关联管网', key: 'networkId' },
         { label: '风险等级', key: 'riskLevel' },
-        { label: '风险分值', key: 'riskScore', unit: '分' },
+        { label: '风险分值', key: 'riskScore' },
         { label: '风险颜色', key: 'riskColor' },
         { label: '评估模型', key: 'assessmentModel' },
         { label: '评估时间', key: 'assessmentTime' },
@@ -336,7 +424,8 @@ function withUnitText(value, unit) {
 function assetFields(category, a) {
     const rows = category === 'PIPE_NETWORK' ? pipesFields(a) : ASSET_FIELD_ROWS[category] || []
     return rows.map((row) => {
-        const raw = row.format ? row.format(a[row.key]) : withUnitText(a[row.key], row.unit)
+        // 单位一律由 `unitOf(category, key)` 给:同一字段名在不同设施里量纲可能不同。
+        const raw = row.format ? row.format(a[row.key]) : withUnitText(a[row.key], unitOf(category, row.key))
         // always 行(如非排水管的二级分类)保留占位;其余缺值由 compactSections 剔除。
         const item = field(row.label, raw, row.wide === true)
         if (row.always) {
@@ -354,6 +443,44 @@ function coordinatesText(value) {
     return [start, end].filter(Boolean).join(' → ')
 }
 
+/**
+ * 事件等级 → 色键 + 文字。
+ *
+ * **三种输入形状都要认**(维护者 2026-10-29 走查:「等级明明能拿到却标成未知」):
+ *   · 后端原始事件行:`level: 1`(数字);
+ *   · 列表行视图 `eventRowView`:`level: { tone, label }`(已翻译过的对象);
+ *   · 移动端预警/报警详情:`warningLevel / alarmLevel`(数字或等级名)。
+ * 详情面板首屏先用列表行 prefill、随后被远端覆盖,两条路都要能读出同一个等级 ——
+ * 早先只认数字,prefill 的等级对象就被判成"等级未知"。
+ */
+/** 「二级预警」「3级」「四级」→ 1..4;认不出回 null(不猜等级)。 */
+function numericLevelOf(value) {
+    const text = value === null || value === undefined ? '' : String(value).trim()
+    if (!text) return null
+    const arabic = /([1-4])\s*级/.exec(text)
+    if (arabic) return Number(arabic[1])
+    const cn = /([一二三四])\s*级/.exec(text)
+    if (cn) return { 一: 1, 二: 2, 三: 3, 四: 4 }[cn[1]]
+    return null
+}
+
+function eventLevelFrom(source) {
+    const raw = source || {}
+    // 已经翻译过的对象:原样沿用(这正是列表与详情一致的结构性保证)。
+    const tagged = raw.level
+    if (tagged && typeof tagged === 'object' && tagged.label) {
+        return { tone: tagged.tone || DEFAULT_TONE, label: tagged.label }
+    }
+    // 移动端字段名与等级名(如 "二级预警")走桶解析;认不出回「等级未知」。
+    const direct = raw.level ?? raw.warningLevel ?? raw.alarmLevel
+    if (typeof direct === 'number') return eventLevelTag(direct)
+    // 中文等级名(后端会下发「二级预警」「三级报警」这类人话)→ 阿拉伯数字。
+    const numeric = numericLevelOf(direct)
+    if (numeric !== null) return eventLevelTag(numeric)
+    const fromBucket = eventLevelFromBucket(raw.levelCode ?? raw.levelColor ?? direct, direct)
+    return eventLevelTag(fromBucket)
+}
+
 function normalizeEvent(identity, source) {
     const envelope = source && source.basicInfo ? source : {}
     const basic = envelope.basicInfo && typeof envelope.basicInfo === 'object' ? envelope.basicInfo : source
@@ -364,20 +491,26 @@ function normalizeEvent(identity, source) {
         gisLocation: envelope.gisLocation || basic.gisLocation,
     }
     const family = String(identity.family || source.family || source.eventFamily || (source.alarmId || source.alarmNo ? 'ALARM' : source.warningId || source.warningNo ? 'WARNING' : '')).toUpperCase()
-    const levelValue = source.level ?? source.warningLevel ?? source.alarmLevel
-    const level = eventLevelTag(levelValue)
+    const level = eventLevelFrom(source)
     const status = eventStatusText(source.status || source.state) || text(source.statusName || source.stateName)
     const name = text(source.name || source.warningName || source.alarmName || source.eventName || source.deviceName || source.title) || identity.id
+    // 展示用的事件编码一律取**业务编号**(warningNo / alarmNo),主键只用于取数。
+    const displayCode = text(source.codeText || source.code || source.warningNo || source.alarmNo || source.eventCode) || identity.id
     return {
         ...identity,
         name,
+        displayCode,
         title: name,
         kindLabel: family === 'ALARM' ? '报警' : family === 'WARNING' ? '预警' : '事件',
         family,
         statusText: status,
         tone: level.tone || 'neutral',
+        // 抬头直接显示等级(与卡片同一张词表、同一套色),不再只写在字段里。
+        levelLabel: level.label,
+        levelTone: level.tone,
         raw: source,
-        summary: summaryItems([['等级', level.label], ['状态', status], ['当前值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)], ['发生时间', source.eventTime || source.createTime]]),
+        // 等级带语义色(与卡片同一张词表),是摘要里唯一的等级呈现位置。
+        summary: summaryItems([['等级', level.label, level.tone], ['状态', status], ['当前值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)], ['发生时间', source.eventTime || source.createTime]]),
         tabs: family === 'WARNING'
             ? [tab('overview', '预警信息'), tab('records', '处置记录'), tab('alarms', '关联报警'), tab('attachments', '附件')]
             : [tab('overview', '报警信息'), tab('devices', '设备信息'), tab('records', '处置记录'), tab('attachments', '附件')],
@@ -388,10 +521,22 @@ function normalizeEvent(identity, source) {
         attachments: attachmentItems(source.attachmentList),
         records: recordItems(source.disposalHistory || source.logs),
         sections: compactSections([
-            section('事件摘要', [field('事件编码', identity.id), field('等级', level.label), field('状态', status), field('发生时间', source.eventTime || source.createTime)]),
-            section('监测读数', [field('指标', source.type || source.warningType || source.warningCode), field('当前值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)), field('阈值', threshold(source))]),
+            // 等级只出现在摘要卡片那一处(带色),这里不再重复一遍纯文字等级。
+            section('事件摘要', [field('事件编码', displayCode), field('状态', status), field('发生时间', source.eventTime || source.createTime)]),
+            section('监测读数', [
+                field('指标', source.type || source.warningType || source.warningCode),
+                // 读数与阈值都带单位(维护者 2026-10-29:数值型指标必须有单位提示)。
+                field('实际值', withUnit(source.value ?? source.actualValue, source.unit || source.warningUnit)),
+                field('阈值', threshold(source)),
+                field('超出幅度', withUnit(source.deviationRatio, '%')),
+            ]),
             section('关联信息', [field('设备', source.deviceName || source.deviceCode), field('监测点', source.monitoringPointName || source.monitoringPointCode), field('工单', source.workOrderCode || source.orderNo), field('位置', source.location || source.address || locationText(source.gisLocation), true)]),
-            section('处置与附件', [field('处置记录', listCount(source.disposalHistory, '条')), field('关联报警', listCount(source.alarmList, '条')), field('附件', listCount(source.attachmentList, '项'))]),
+            section('处置与附件', [
+                field('处置记录', listCount(source.disposalHistory, '条')),
+                field('关联报警', listCount(source.alarmList, '条')),
+                field('附件', listCount(source.attachmentList, '项')),
+                field('督办记录', listCount(source.supervisionList, '条')),
+            ]),
         ]),
     }
 }
@@ -427,7 +572,7 @@ function normalizeWorkOrder(identity, source) {
         order,
         track,
         raw: source,
-        summary: summaryItems([['工单号', order.code], ['状态', stateText], ['优先级', order.orderLevel], ['计划完成', order.planFinishTime]]),
+        summary: summaryItems([['工单号', order.code], ['状态', stateText], ['优先级', orderLevelText(order.orderLevel)], ['计划完成', order.planFinishTime]]),
         tabs: [tab('overview', '工单信息'), tab('flow', '处理流程'), tab('devices', '设备信息'), tab('attachments', '附件')],
         stage: workOrderStage(order.stateCode),
         people: peopleItems([['处置人', order.receiveUserName], ['核验人', order.verifyUserName]]),
@@ -435,7 +580,7 @@ function normalizeWorkOrder(identity, source) {
         attachments: attachmentItems([raw.maintenanceBeforeImg, raw.maintenanceAfterImg, ...timelineAttachments(timelineRaw)]),
         records: recordItems(timelineRaw),
         sections: compactSections([
-            section('工单摘要', [field('工单号', order.code), field('类型', order.orderTypeName), field('等级', order.orderLevel), field('状态', stateText)]),
+            section('工单摘要', [field('工单号', order.code), field('类型', order.orderTypeName), field('优先级', orderLevelText(order.orderLevel)), field('状态', stateText)]),
             section('时间信息', [field('创建时间', order.createTime), field('计划完成', order.planFinishTime), field('实际完成', order.realFinishTime)]),
             section('设备信息', [field('设备', order.deviceName || order.deviceCode), field('设备类型', order.deviceTypeName), field('安装位置', order.deviceLocation, true)]),
             section('处置内容', [field('处置人', order.receiveUserName), field('核验人', order.verifyUserName), field('故障描述', order.description, true), field('处置结果', order.repairResult, true)]),
@@ -444,9 +589,16 @@ function normalizeWorkOrder(identity, source) {
 }
 
 
+/** 工单优先级码值(0 紧急 / 1 一般 / 2 低)→ 中文;认不出回码。 */
+function orderLevelText(value) {
+    const code = value === null || value === undefined ? '' : String(value).trim()
+    if (!code) return ''
+    return { '0': '紧急', '1': '一般', '2': '低' }[code] || code
+}
+
 function tab(key, label) { return { key, label } }
 function relatedAlarm(item) { return { code: item.alarmNo || item.alarmId || item.id || '', name: item.alarmName || item.content || item.deviceName || '关联报警', status: item.auditStatusName || item.alarmStatus || item.status || '' } }
-function summaryItems(entries) { return entries.filter(([,value]) => value !== null && value !== undefined && String(value).trim() !== '').map(([label,value]) => ({ label, value: String(value) })) }
+function summaryItems(entries) { return entries.filter(([,value]) => value !== null && value !== undefined && String(value).trim() !== '').map(([label,value,tone]) => ({ label, value: String(value), tone: tone || '' })) }
 function peopleItems(entries) { return entries.filter(([,name]) => name).map(([role,name]) => ({ role, name: String(name) })) }
 function deviceItems(source) {
     const name = source.deviceName || source.monitoringPointName || ''
@@ -498,7 +650,17 @@ function section(title, fields) { return { title, fields } }
 function field(label, value, wide = false) { return { label, value: display(value), wide, always: false, note: '' } }
 function display(value) { return value === null || value === undefined || String(value).trim() === '' ? PLACEHOLDER : String(value) }
 function text(value) { return value === null || value === undefined ? '' : String(value).trim() }
-function withUnit(value, unit) { return value === null || value === undefined || value === '' ? '' : [value, unit].filter(Boolean).join(' ') }
+/**
+ * 读数 + 单位。三条纪律(维护者 2026-10-29):
+ *   · 没值就整句不出(不拼半截 "— μS/cm");
+ *   · **有值但后端没给单位**时如实标注「单位未发布」,不假装无单位是个读数;
+ *   · 有单位则一律追加 —— 数值型指标不允许裸数字上屏。
+ */
+function withUnit(value, unit) {
+    const hasValue = value !== null && value !== undefined && String(value).trim() !== ''
+    if (!hasValue) return ''
+    return unit ? `${value} ${unit}` : `${value}(单位未发布)`
+}
 function threshold(source) {
     const min = source.thresholdMinValue ?? source.minValue
     const max = source.thresholdMaxValue ?? source.maxValue

+ 39 - 0
verify/t65-alarm-code-and-locate.mjs

@@ -0,0 +1,39 @@
+import assert from 'node:assert/strict'
+import { entityDetailView, normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+import { eventRowView, toEventItem } from '../src/modules/cockpit/domain/eventList.js'
+
+// —— 4) 报警详情/卡片:上屏用业务编号 alarmNo,不是主键 UUID ——
+const raw = { eventNo: '9a0738db9773af8bfb0ccd1ef8680b82', eventCode: 'BJ202610281616136810', eventName: '报警', eventType: 'WARN-LEAK', level: 1, status: 'UNHANDLED' }
+const item = toEventItem(raw)
+assert.equal(item.code, '9a0738db9773af8bfb0ccd1ef8680b82', '内部键仍是 eventNo')
+assert.equal(item.codeText, 'BJ202610281616136810', '展示编号必须是 eventCode')
+
+const row = eventRowView(item)
+assert.equal(row.code, '9a0738db9773af8bfb0ccd1ef8680b82')
+assert.equal(row.codeText, 'BJ202610281616136810')
+
+// 详情(远端已返回 AlarmVO 形状)
+const detail = normalizeEntityDetail({ kind: 'event', id: raw.eventNo, family: 'ALARM' }, {
+    id: '9a0738db9773af8bfb0ccd1ef8680b82',
+    alarmNo: 'BJ202610281616136810',
+    alarmLevel: 1,
+    alarmStatus: 'UNHANDLED',
+    deviceCode: '8652052334',
+    deviceName: '测试设备',
+})
+assert.equal(detail.displayCode, 'BJ202610281616136810', '详情事件编码必须是 alarmNo')
+const codeField = detail.sections.find((s) => s.title === '事件摘要').fields.find((f) => f.label === '事件编码')
+assert.equal(codeField.value, 'BJ202610281616136810')
+assert.ok(!codeField.value.includes('9a0738db'), '不得显示主键 UUID')
+assert.ok(entityDetailView(detail).subtitle.includes('BJ202610281616136810'))
+
+// 详情 prefill(列表行)也必须是业务编号
+const fromPrefill = normalizeEntityDetail({ kind: 'event', id: raw.eventNo, family: 'ALARM' }, row)
+assert.equal(fromPrefill.displayCode, 'BJ202610281616136810', 'prefill 阶段也不得回落到主键')
+
+// —— 5) 关联设备:详情必须带设备编码,定位按钮据此渲染 ——
+assert.equal(detail.devices.length, 1)
+assert.equal(detail.devices[0].code, '8652052334')
+assert.ok(detail.devices[0].name)
+
+console.log('t65-alarm-code-and-locate: 17/17')

+ 40 - 0
verify/t65-backend-detail-contract.mjs

@@ -0,0 +1,40 @@
+/** Cross-repo contract guard for issue 65 detail endpoints (cockpit 免鉴权只读域). */
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+const root = 'E:/pipe-ner-server/pipe-network-service/'
+const read = (p) => fs.readFileSync(root + p, 'utf8')
+
+const entity = read('zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEntityDetailController.java')
+const event = read('zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitEventDetailController.java')
+const order = read('zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitWorkOrderDetailController.java')
+const eventService = read('zksy-system/src/main/java/com/zksy/cockpit/event/CockpitEventDetailService.java')
+const entityService = read('zksy-system/src/main/java/com/zksy/cockpit/entity/CockpitEntityDetailService.java')
+const alarmMapper = read('zksy-system/src/main/resources/mapper/base/AlarmOperationsMapper.xml')
+
+// 三个详情路由都在免鉴权的 /api/cockpit/v1 下
+assert.ok(entity.includes('/api/cockpit/v1/entities/{entityId}/detail'))
+assert.ok(event.includes('/api/cockpit/v1'))
+assert.ok(event.includes('/events/{eventId}/detail'))
+assert.ok(order.includes('/work-orders/{orderId}/detail'))
+assert.ok(order.includes('workOrders.getDetail'), '工单详情必须用无鉴权的 getDetail')
+
+// 事件详情不得再**调用**需要登录用户的移动端方法(注释里说明原因不算)。
+const calls = eventService.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
+assert.ok(!calls.includes('getMobileWarningDetail('))
+assert.ok(!calls.includes('.mobileDetail('))
+assert.ok(eventService.includes('selectCockpitDetail'), '报警详情必须走无范围裁剪的查询')
+// 预警各分区都从底层 mapper 取(不经 requireVisible)
+assert.ok(eventService.includes('selectDisposalList'))
+assert.ok(eventService.includes('selectAlarmListByWarningId'))
+assert.ok(eventService.includes('selectByWarningId'))
+
+// 无范围报警详情 SQL 不得包含按用户的 scope 片段
+const cockpitSql = alarmMapper.split('<select id="selectCockpitDetail"')[1].split('</select>')[0]
+assert.ok(!cockpitSql.includes('mobileScope'))
+assert.ok(!cockpitSql.includes('AlarmReviewMapper.scope'))
+
+for (const category of ['PIPE_NETWORK', 'WATER_PLANT', 'PUMP_STATION', 'ENGINEERING_FACILITY', 'EMERGENCY_RESOURCE', 'HAZARD', 'PROTECTION_TARGET']) {
+    assert.ok(entityService.includes(category), '实体详情缺类别: ' + category)
+}
+
+console.log('t65-backend-detail-contract: 18/18')

+ 54 - 0
verify/t65-card-actions.mjs

@@ -0,0 +1,54 @@
+import assert from 'node:assert/strict'
+import { CARD_ACTION, cardActionFor } from '../src/modules/cockpit/domain/panelBlocks.js'
+import { workOrderCardRows } from '../src/modules/cockpit/domain/warningResponsePanels.js'
+import { entityIdentity, normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+import { entityDetailPath } from '../src/modules/cockpit/services/entityDetailApi.js'
+
+const cases = [
+    [{ identityKind: CARD_ACTION.POINT, identityCode: 'P1' }, CARD_ACTION.POINT],
+    [{ identityKind: CARD_ACTION.DEVICE, identityCode: 'D1' }, CARD_ACTION.DEVICE],
+    [{ identityKind: CARD_ACTION.EVENT, identityCode: 'E1' }, CARD_ACTION.EVENT],
+    [{ identityKind: CARD_ACTION.WORK_ORDER, identityCode: 'W1' }, CARD_ACTION.WORK_ORDER],
+    [{ identityKind: CARD_ACTION.FACILITY, identityCode: 'F1' }, CARD_ACTION.FACILITY],
+    [{ identityKind: CARD_ACTION.EMERGENCY, identityCode: 'R1' }, CARD_ACTION.EMERGENCY],
+    [{ identityKind: CARD_ACTION.POINT, identityCode: '' }, CARD_ACTION.NONE],
+    [{ identityKind: 'unknown', identityCode: 'X1' }, CARD_ACTION.NONE],
+]
+for (const [row, kind] of cases) assert.equal(cardActionFor(row).kind, kind)
+const [order] = workOrderCardRows([{ id: '42', code: 'W1', stateCode: 0 }])
+assert.equal(cardActionFor(order).kind, CARD_ACTION.WORK_ORDER)
+assert.equal(cardActionFor(order).code, '42')
+assert.deepEqual(entityIdentity({ kind: 'facility', id: 'F1', category: 'water_plant' }), { kind: 'facility', id: 'F1', category: 'WATER_PLANT', family: '', valid: true })
+assert.equal(entityDetailPath({ kind: 'facility', id: 'F1', category: 'WATER_PLANT' }), '/entities/F1/detail?category=WATER_PLANT')
+assert.equal(entityDetailPath({ kind: 'emergency', id: 'H1', category: 'HAZARD' }), '/entities/H1/detail?category=HAZARD')
+assert.equal(entityDetailPath({ kind: 'event', id: 'A1', family: 'ALARM' }), '/events/A1/detail?family=ALARM')
+assert.equal(entityDetailPath({ kind: 'event', id: 'W2', family: 'WARNING' }), '/events/W2/detail?family=WARNING')
+assert.equal(entityDetailPath({ kind: 'work-order', id: '42' }), '/work-orders/42/detail')
+assert.equal(entityDetailPath({ kind: 'facility', id: 'F1' }), '')
+assert.equal(entityDetailPath({ kind: 'emergency', id: 'R1' }), '')
+const mobileOrder = normalizeEntityDetail({ kind: 'work-order', id: '42' }, { order: { orderId: 42, orderNo: 'WO42', orderStatus: 3, orderDesc: '测试故障', deviceName: '水位计', deviceLocation: '城南', receiveUserName: '运维员' }, logs: [{ operationType: '接单', operationTime: '2026-10-28 10:00:00', orderStatus: 2 }] })
+assert.equal(mobileOrder.order.id, '42')
+assert.equal(mobileOrder.order.code, 'WO42')
+assert.equal(mobileOrder.statusText, '处理中')
+assert.ok(mobileOrder.track)
+const warning = normalizeEntityDetail({ kind: 'event', id: 'W1', family: 'WARNING' }, { basicInfo: { warningNo: 'YJ1', warningLevel: 2, status: 'PENDING', deviceName: '压力计' }, disposalHistory: [{}], attachmentList: [{}, {}] })
+assert.equal(warning.kindLabel, '预警')
+assert.ok(warning.sections.some((section) => section.title === '处置与附件'))
+const facility = normalizeEntityDetail({ kind: 'facility', id: 'P1', category: 'WATER_PLANT' }, { category: 'WATER_PLANT', id: 'P1', code: 'WP-1', name: '一水厂', location: '城南', districtName: '城南', coordinates: { longitude: 110, latitude: 28 }, attributes: { designCapacity: 5000, treatmentProcess: '沉淀过滤', principal: '张三', contactPhone: '123' } })
+assert.ok(facility.sections.some((section) => section.fields.some((field) => field.label === '设计能力' && field.value === '5000 万m³/d')))
+assert.ok(facility.sections.some((section) => section.fields.some((field) => field.label === '处理工艺' && field.value === '沉淀过滤')))
+const resource = normalizeEntityDetail({ kind: 'emergency', id: 'E1', category: 'EMERGENCY_RESOURCE' }, { category: 'EMERGENCY_RESOURCE', id: 'E1', name: '应急泵', attributes: { model: 'M1', maintainer: '李四' } })
+assert.ok(resource.sections.some((section) => section.fields.some((field) => field.label === '型号' && field.value === 'M1')))
+const hazard = normalizeEntityDetail({ kind: 'emergency', id: 'H1', category: 'HAZARD' }, { category: 'HAZARD', id: 'H1', name: '泄漏隐患', attributes: { rectifyPlan: '立即整改', level: 'HIGH' } })
+assert.ok(hazard.sections.some((section) => section.fields.some((field) => field.label === '整改方案' && field.value === '立即整改')))
+const target = normalizeEntityDetail({ kind: 'emergency', id: 'R1', category: 'PROTECTION_TARGET' }, { category: 'PROTECTION_TARGET', id: 'R1', name: '学校', attributes: { riskLevel: 'HIGH', riskScore: 88 } })
+assert.ok(target.sections.some((section) => section.fields.some((field) => field.label === '风险分值' && field.value === '88 分')))
+const engineeringDetail = normalizeEntityDetail({ kind: 'facility', id: 'eng-1', category: 'ENGINEERING_FACILITY' }, { category: 'ENGINEERING_FACILITY', id: 'eng-1', name: '排水闸门', attributes: { facilityLevel: '一级', designFlow: 3.5, devices: [{ id: 'd1', code: 'D1', name: '闸门控制器', location: '城南', onlineStatus: 1, alarmStatus: 0 }] } })
+assert.ok(engineeringDetail.sections.some((section) => section.fields.some((field) => field.label === '设计流量' && field.value === '3.5 m³/s')))
+assert.deepEqual(engineeringDetail.devices.map((item) => item.code), ['D1'])
+assert.equal(engineeringDetail.devices[0].name, '闸门控制器')
+assert.equal(engineeringDetail.devices[0].status, '在线')
+assert.deepEqual(warning.tabs.map((item) => item.key), ['overview','records','alarms','attachments'])
+assert.deepEqual(mobileOrder.tabs.map((item) => item.key), ['overview','flow','devices','attachments'])
+assert.deepEqual(facility.tabs.map((item) => item.key), ['overview','business','related'])
+console.log('t65-card-actions: 38/38')

+ 34 - 0
verify/t65-card-interactions.mjs

@@ -0,0 +1,34 @@
+/** Issue 65 static interaction guard: card identity, handler, and nested button propagation stay aligned. */
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+const panel = fs.readFileSync(new URL('../src/modules/cockpit/components/PanelBlocks.vue', import.meta.url), 'utf8')
+const shell = fs.readFileSync(new URL('../src/modules/cockpit/CockpitShell.vue', import.meta.url), 'utf8')
+const drawer = fs.readFileSync(new URL('../src/modules/cockpit/components/EntityDetailDrawer.vue', import.meta.url), 'utf8')
+const warning = fs.readFileSync(new URL('../src/modules/cockpit/components/WarningResponsePane.vue', import.meta.url), 'utf8')
+assert.ok(panel.includes(`:class="{ 'panel-item--clickable': cardAction(row).kind !== CARD_ACTION.NONE }"`))
+assert.ok(panel.includes(`:tabindex="cardAction(row).kind !== CARD_ACTION.NONE ? 0 : null"`))
+assert.ok(panel.includes(`@click="cardAction(row).kind !== CARD_ACTION.NONE ? onCardClick(row, $event) : null"`))
+assert.ok(panel.includes(`@click.stop="emit('locate', point.pointCode)"`))
+assert.ok(panel.includes(`@keydown.enter.stop`))
+assert.ok(panel.includes(`@keydown.space.stop`))
+assert.ok(panel.includes(`event.target.closest('button')`))
+assert.ok(warning.includes(`const id = (action && action.code) || ''`))
+assert.ok(!warning.includes(`row.workOrderCode || row.code || (action && action.code)`))
+assert.ok(shell.includes(`@locate-device="locateDeviceFromDetail"`))
+assert.ok(shell.includes(`function locateDeviceFromDetail(deviceCode)`))
+assert.ok(shell.includes(`monitoringStore.locateCode(deviceCode)`), '关联设备定位必须复用设备台账的定位方法')
+assert.ok(drawer.includes(`position: absolute`))
+assert.ok(drawer.includes(`.entity-drawer.is-left { left: calc(var(--rail-w)`))
+assert.ok(drawer.includes(`.entity-drawer.is-right { right: calc(var(--rail-w)`))
+console.log('t65-card-interactions: 22/22')
+// 维护者 2026-10-29:详情面板必须与其他面板同一套磨砂语言(同一组 token + blur)。
+assert.ok(drawer.includes(`backdrop-filter: blur(16px) saturate(115%);`))
+assert.ok(drawer.includes(`var(--panel-top`))
+assert.ok(drawer.includes(`var(--panel-line`))
+// 左右判定只认目录单源,组件与外壳都不得硬编码 'left'/'right'。
+assert.ok(shell.includes(`panelSideFor(mode.value, panelId)`))
+assert.ok(shell.includes(`panelSideOf(panelId)`))
+assert.ok(!shell.includes(`handleCardAction(action, 'left')`))
+assert.ok(!shell.includes(`handleCardAction(action, 'right')`))
+assert.ok(warning.includes(`panelSideFor('warningResponse', props.panelId)`))
+assert.ok(!warning.includes(`{ side: 'right' }`))

+ 80 - 0
verify/t65-clickability-coverage.mjs

@@ -0,0 +1,80 @@
+/** Issue 65: exhaustive entity-card clickability coverage. Pure Node only. */
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import { operationPanel, emergencyPanel, toEmergencySupport, toOperatingInformation } from '../src/modules/cockpit/domain/infrastructurePanels.js'
+import { operationOverviewPanel, monitoringPointLedgerPanel, toMonitoringOverview, toMonitoringDistribution } from '../src/modules/cockpit/domain/monitoringPanels.js'
+import { historyAnalysisPanel, workOrderCardRows } from '../src/modules/cockpit/domain/warningResponsePanels.js'
+import { currentEventsView, toCurrentEvents } from '../src/modules/cockpit/domain/eventList.js'
+import { CARD_ACTION, cardActionFor } from '../src/modules/cockpit/domain/panelBlocks.js'
+
+const loaded = { state: 'success', loaded: true, data: null }
+const listRows = (view) => (view.blocks || []).filter((block) => block.kind === 'list').flatMap((block) => block.rows || [])
+const action = (row, kind, code) => {
+    const actual = cardActionFor(row)
+    assert.equal(actual.kind, kind)
+    assert.equal(actual.code, code)
+    assert.equal(actual.reason, '')
+}
+const unavailable = (row, reason) => {
+    const actual = cardActionFor(row)
+    assert.equal(actual.kind, CARD_ACTION.NONE)
+    assert.match(actual.reason, reason)
+}
+
+const operation = operationPanel(toOperatingInformation({ selectedIndustry: { code: 'WATER_SUPPLY' }, industries: [{ industry: { code: 'WATER_SUPPLY' }, state: 'READY', metrics: [{ metricCode: 'FLOW', metricName: '流量', category: 'FLOW', facilityId: '731', facilityKind: 'WATER_PLANT', pointCodes: ['WP-DISPLAY'] }, { metricCode: 'FLOW', metricName: '流量', category: 'FLOW', facilityId: null, facilityKind: 'WATER_PLANT', pointCodes: ['NOT-A-PK'] }, { metricCode: 'FLOW', metricName: '流量', category: 'FLOW', facilityId: '732', facilityKind: null, pointCodes: ['NOT-A-CATEGORY'] }] }] }), loaded)
+const operationRows = listRows(operation)
+action(operationRows[0], CARD_ACTION.FACILITY, '731')
+assert.equal(operationRows[0].identityCategory, 'WATER_PLANT')
+unavailable(operationRows[1], /主键/)
+unavailable(operationRows[2], /类别/)
+const engineering = operationPanel(toOperatingInformation({ selectedIndustry: { code: 'DRAINAGE' }, industries: [{ industry: { code: 'DRAINAGE' }, state: 'READY', metrics: [{ metricCode: 'POINTS', metricName: '排水闸门', category: 'CAPACITY', facilityId: 'eng-1', facilityKind: 'ENGINEERING_FACILITY', pointCodes: ['GATE-1'] }] }] }), loaded)
+const engineeringRow = listRows(engineering)[0]
+action(engineeringRow, CARD_ACTION.FACILITY, 'eng-1')
+assert.equal(engineeringRow.identityCategory, 'ENGINEERING_FACILITY')
+
+const emergency = emergencyPanel(toEmergencySupport({ sections: { hazardSources: { state: 'EMPTY', items: [] }, protectionTargets: { state: 'READY', items: [{ objectCode: 'risk001', objectName: '学校', category: 'PROTECTION_TARGET' }] }, emergencyResources: { state: 'READY', items: [{ objectCode: 'device001', objectName: '应急设备', category: 'EMERGENCY_RESOURCE' }, { objectCode: '', objectName: '无码对象', category: 'EMERGENCY_RESOURCE' }] } } }), loaded)
+const emergencyRows = listRows(emergency)
+action(emergencyRows[0], CARD_ACTION.EMERGENCY, 'risk001')
+assert.equal(emergencyRows[0].identityCategory, 'PROTECTION_TARGET')
+action(emergencyRows[1], CARD_ACTION.EMERGENCY, 'device001')
+assert.equal(emergencyRows[1].identityCategory, 'EMERGENCY_RESOURCE')
+unavailable(emergencyRows[2], /对象编码/)
+
+// 设备台账的行来自领域转换后的 devices(`code` 由 deviceCode 归一),测试必须走同一条链,
+// 否则造出来的行是"原始载荷形状",与真实行的键名不同 —— 那是测试自身失真,不是产品缺陷。
+const overviewDomain = toMonitoringOverview({ devices: [{ deviceCode: 'D1', deviceName: '压力计' }, { deviceCode: '', deviceName: '无码设备' }] })
+const overview = operationOverviewPanel({}, loaded, { devices: overviewDomain.devices })
+const deviceRows = listRows(overview)
+action(deviceRows[0], CARD_ACTION.DEVICE, 'D1')
+unavailable(deviceRows[1], /设备编码/)
+const ledger = monitoringPointLedgerPanel(toMonitoringDistribution({ monitoringPoints: [{ pointCode: 'P1', pointName: '点位一' }, { pointCode: '', pointName: '无码点位' }], unassociatedEquipment: [], pagination: {} }), loaded)
+const pointRows = listRows(ledger)
+action(pointRows[0], CARD_ACTION.POINT, 'P1')
+unavailable(pointRows[1], /点位编码/)
+
+const history = historyAnalysisPanel({ mode: 'ALARM', events: [{ code: 'A-1', name: '报警一', level: 1 }] }, loaded)
+const historyRow = listRows(history)[0]
+action(historyRow, CARD_ACTION.EVENT, 'A-1')
+assert.equal(historyRow.identityFamily, 'ALARM')
+const orderRows = workOrderCardRows([{ id: '88', code: 'WO-88', stateCode: 0 }, { id: '', code: 'WO-NO-ID', stateCode: 0 }])
+action(orderRows[0], CARD_ACTION.WORK_ORDER, '88')
+assert.notEqual(orderRows[0].identityCode, orderRows[0].code)
+unavailable(orderRows[1], /工单主键/)
+
+const current = toCurrentEvents({ mode: 'WARNING', pagination: { page: 1, pageSize: 10, total: 2, totalPages: 1 }, sections: { events: { state: 'READY', data: [{ eventNo: 'W-1', eventName: '预警一' }, { eventNo: '', eventName: '无码预警' }] } } })
+const currentView = currentEventsView(current, current.sections.events)
+assert.equal(currentView.rows[0].identityCode, 'W-1')
+assert.equal(currentView.rows[0].identityFamily, 'WARNING')
+assert.equal(currentView.rows[1].identityCode, '')
+assert.match(currentView.rows[1].identityReason, /事件编号/)
+
+const eventPane = fs.readFileSync(new URL('../src/modules/cockpit/components/EventListPane.vue', import.meta.url), 'utf8')
+const warningPane = fs.readFileSync(new URL('../src/modules/cockpit/components/WarningResponsePane.vue', import.meta.url), 'utf8')
+assert.ok(eventPane.includes(':tabindex="canOpen(row) ? 0 : null"'))
+assert.ok(eventPane.includes('@click="canOpen(row) ? openEvent(row) : null"'))
+assert.ok(eventPane.includes(`:data-card-action="canOpen(row) ? 'event' : 'none'"`))
+assert.ok(warningPane.includes("if (action.kind === 'event')"))
+assert.ok(warningPane.includes("if (action.kind === 'work-order')"))
+assert.ok(warningPane.includes('const prefill = row.identityPayload || row'))
+
+console.log('t65-clickability-coverage: 32/32')

+ 43 - 0
verify/t65-event-card-design.mjs

@@ -0,0 +1,43 @@
+import assert from 'node:assert/strict'
+import { eventStatusTone, eventSummaryText, eventRowView } from '../src/modules/cockpit/domain/eventList.js'
+import { normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+
+// —— 处理状态用颜色区分(维护者 2026-10-29) ——
+assert.equal(eventStatusTone('PENDING'), 'lv2')
+assert.equal(eventStatusTone('ACTIVE'), 'lv2')
+assert.equal(eventStatusTone('PROCESSING'), 'lv3')
+assert.equal(eventStatusTone('HANDLING'), 'lv3')
+assert.equal(eventStatusTone('RESOLVED'), 'lv4')
+assert.equal(eventStatusTone('CLEARED'), 'lv4')
+assert.equal(eventStatusTone('CANCELLED'), 'neutral')
+assert.equal(eventStatusTone(''), 'neutral')
+// 认不出的状态不许猜成某个真实状态色
+assert.equal(eventStatusTone('SOMETHING_NEW'), 'neutral')
+
+// —— 卡片摘要:让用户直观看到"发生了什么" ——
+assert.equal(eventSummaryText({ type: '电导率监测', thresholdMinValue: 100, thresholdMaxValue: 5000, unit: 'μS/cm' }, '454 μS/cm'),
+    '电导率监测 · 实际值 454 μS/cm · 阈值 100 ~ 5000 μS/cm')
+// 缺哪段少哪段,不编造
+assert.equal(eventSummaryText({ type: '水位预警' }, '—'), '水位预警')
+assert.equal(eventSummaryText({}, '—'), '')
+assert.equal(eventSummaryText({ type: '压力', thresholdMaxValue: 5, unit: 'MPa' }, '—'), '压力 · 阈值 ≤ 5 MPa')
+
+// —— 列表行同时带等级与状态两套色键 ——
+const row = eventRowView({ code: 'YJ1', name: '电导率预警', level: 2, status: 'PENDING', type: '电导率监测', value: 454, unit: 'μS/cm', thresholdMaxValue: 5000, deviceName: '测试设备' })
+assert.equal(row.level.label, '2级')
+assert.equal(row.level.tone, 'lv2')
+assert.equal(row.statusTone, 'lv2')
+assert.match(row.summaryText, /电导率监测/)
+assert.match(row.summaryText, /454 μS\/cm/)
+
+// —— 详情抬头也显示等级(与卡片同表同色) ——
+const detail = normalizeEntityDetail({ kind: 'event', id: 'YJ1', family: 'WARNING' }, { warningNo: 'YJ1', level: 2, status: 'PENDING' })
+assert.equal(detail.levelLabel, '2级')
+assert.equal(detail.levelTone, 'lv2')
+
+// —— 历史事件与当前预警/报警使用同一套卡片字段(同步设计) ——
+const historyDomain = normalizeEntityDetail({ kind: 'event', id: 'H1', family: 'ALARM' }, { alarmNo: 'H1', level: 1, status: 'RESOLVED', type: 'COD监测' })
+assert.equal(historyDomain.levelLabel, '1级')
+assert.equal(historyDomain.statusText.length > 0, true)
+
+console.log('t65-event-card-design: 22/22')

+ 56 - 0
verify/t65-event-card-fixes.mjs

@@ -0,0 +1,56 @@
+import assert from 'node:assert/strict'
+import { eventStatusText, isTerminalEventStatus, eventRowView, currentEventsView, toCurrentEvents } from '../src/modules/cockpit/domain/eventList.js'
+import { entityDetailView, normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+
+// —— 1) 卡片状态必须中文(实测取值为 PENDING / PROCESSING / UNHANDLED / HANDLED) ——
+assert.equal(eventStatusText('PENDING'), '待处理')
+assert.equal(eventStatusText('PROCESSING'), '处理中')
+assert.equal(eventStatusText('UNHANDLED'), '未处理')
+assert.equal(eventStatusText('HANDLED'), '已处理')
+assert.equal(eventStatusText('CLEARED'), '已解除')
+assert.equal(eventStatusText('CLOSED'), '已关闭')
+assert.equal(eventStatusText('CANCELLED'), '已取消')
+assert.equal(eventStatusText('MISREPORT'), '误报')
+// 认不出的仍回原码(不编造中文)
+assert.equal(eventStatusText('SOMETHING_NEW'), 'SOMETHING_NEW')
+
+// —— 2) 当前列表不展示终态事件 ——
+for (const terminal of ['HANDLED', 'CLEARED', 'CLOSED', 'CANCELLED', 'MISREPORT', 'RESOLVED']) {
+    assert.equal(isTerminalEventStatus(terminal), true, terminal + ' 应判终态')
+}
+for (const live of ['PENDING', 'PROCESSING', 'UNHANDLED', 'ACTIVE', 'SOMETHING_NEW', '']) {
+    assert.equal(isTerminalEventStatus(live), false, live + ' 不应判终态')
+}
+
+const domain = toCurrentEvents({
+    mode: 'ALARM',
+    pagination: { page: 1, pageSize: 10, total: 3, totalPages: 1 },
+    sections: { events: { state: 'READY', data: [
+        { eventNo: 'A-LIVE', eventName: '未处理报警', level: 1, status: 'UNHANDLED' },
+        { eventNo: 'A-DONE', eventName: '已处理报警', level: 2, status: 'HANDLED' },
+        { eventNo: 'A-CANCEL', eventName: '已取消报警', level: 3, status: 'CANCELLED' },
+    ] } },
+})
+const view = currentEventsView(domain, domain.sections.events)
+assert.equal(view.rows.length, 1, '终态卡应被过滤')
+assert.equal(view.rows[0].code, 'A-LIVE')
+assert.equal(view.hiddenTerminalCount, 2, '过滤条数应如实回显')
+// 状态是中文
+assert.equal(view.rows[0].statusText, '未处理')
+
+// —— 3) 等级只在摘要出现一次,且带颜色;事件摘要区不再重复等级 ——
+const detail = normalizeEntityDetail({ kind: 'event', id: 'A1', family: 'ALARM' }, { alarmNo: 'A1', alarmLevel: 1, status: 'UNHANDLED' })
+const levelCards = detail.summary.filter((s) => s.label === '等级')
+assert.equal(levelCards.length, 1, '摘要里等级只能有一处')
+assert.equal(levelCards[0].value, '1级')
+assert.equal(levelCards[0].tone, 'lv1', '等级必须带语义色')
+const summarySection = detail.sections.find((s) => s.title === '事件摘要')
+assert.ok(!summarySection.fields.some((f) => f.label === '等级'), '事件摘要里不得再出现等级')
+
+// —— 4) 事件编码用业务编号,不是主键 ——
+assert.equal(detail.displayCode, 'A1')
+const codeField = summarySection.fields.find((f) => f.label === '事件编码')
+assert.equal(codeField.value, 'A1')
+assert.ok(entityDetailView(detail).subtitle.includes('A1'), '副标题应显示业务编号')
+
+console.log('t65-event-card-fixes: 36/36')

+ 17 - 0
verify/t65-event-summary.mjs

@@ -0,0 +1,17 @@
+import assert from 'node:assert/strict'
+import { eventSummaryText } from '../src/modules/cockpit/domain/eventList.js'
+
+// —— 维护者 2026-10-29 第 2 条:预警内容必须中文,不展示纯英文协议码 ——
+const summary = eventSummaryText({ type: 'WARN-WATER-LEVEL', thresholdMaxValue: 5, unit: 'm' }, '—')
+assert.equal(summary, '水位预警 · 阈值 ≤ 5 m')
+assert.ok(!/WARN-/.test(summary), '摘要在不得出现英文协议码: ' + summary)
+
+assert.equal(eventSummaryText({ type: 'WARN-COD' }, '17.3 mg/L'), 'COD预警 · 实际值 17.3 mg/L')
+assert.ok(!/WARN-/.test(eventSummaryText({ type: 'WARN-COD' }, '17.3 mg/L')))
+
+// 后端已给人话时直接沿用
+assert.equal(eventSummaryText({ type: '电导率监测' }, '454 μS/cm'), '电导率监测 · 实际值 454 μS/cm')
+// 未知码回原码(不编造中文)
+assert.equal(eventSummaryText({ type: 'SOME-NEW-CODE' }, '—'), 'SOME-NEW-CODE')
+
+console.log('t65-event-summary: 7/7')

+ 100 - 0
verify/t65-facility-detail-labels.mjs

@@ -0,0 +1,100 @@
+import assert from 'node:assert/strict'
+import { facilityCategoryText, facilityStatusText, normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+import { panelSideFor } from '../src/modules/cockpit/domain/panelCatalog.js'
+
+// —— 1) 左右判定:目录单源(历史分析曾在左栏却被当右栏) ——
+assert.equal(panelSideFor('warningResponse', 'currentEvents'), 'left')
+assert.equal(panelSideFor('warningResponse', 'historyAnalysis'), 'left')
+assert.equal(panelSideFor('warningResponse', 'responseEfficiency'), 'right')
+assert.equal(panelSideFor('warningResponse', 'eventAnalysis'), 'right')
+assert.equal(panelSideFor('infrastructure', 'operation'), 'left')
+assert.equal(panelSideFor('infrastructure', 'emergency'), 'right')
+assert.equal(panelSideFor('monitoring', 'monitoringOverview'), 'left')
+assert.equal(panelSideFor('monitoring', 'operationOverview'), 'right')
+assert.equal(panelSideFor('unknownMode', 'operation'), '')
+
+// —— 2) 设施枚举词表(0 正常 / 1 维护中 / 2 异常) ——
+assert.equal(facilityStatusText('0'), '正常')
+assert.equal(facilityStatusText('1'), '维护中')
+assert.equal(facilityStatusText('2'), '异常')
+assert.equal(facilityStatusText(''), '')
+assert.equal(facilityStatusText('9'), '9')
+// 维护者 2026-10-29:取消英文括注 —— 类别只回中文
+assert.equal(facilityCategoryText('PIPE_NETWORK'), '管网')
+
+const fieldOf = (detail, label) => detail.sections.flatMap((s) => s.fields).find((f) => f.label === label)
+
+// —— 排水管:二级分类有效,码值翻中文 ——
+const drainage = normalizeEntityDetail({ kind: 'facility', id: 'n1', category: 'PIPE_NETWORK' }, {
+    category: 'PIPE_NETWORK', id: 'n1', code: 'PN-1', name: '城南污水干管', status: '0',
+    attributes: { networkType: '1', networkLevel: '0', lengthMeters: 1200, diameterMillimeters: 300 },
+})
+assert.equal(drainage.statusText, '正常')
+assert.equal(drainage.categoryText, '管网')
+assert.equal(fieldOf(drainage, '管网类型').value, '排水管网')
+assert.equal(fieldOf(drainage, '二级分类').value, '污水')
+assert.equal(fieldOf(drainage, '长度').value, '1200 米')
+assert.equal(fieldOf(drainage, '管径').value, '300 毫米')
+
+// —— 非排水管:二级分类无业务含义 → 保留行显示「—」,并给适用说明 ——
+for (const networkType of ['0', '2']) {
+    const other = normalizeEntityDetail({ kind: 'facility', id: 'n2', category: 'PIPE_NETWORK' }, {
+        category: 'PIPE_NETWORK', id: 'n2', name: '非排水管', status: '0',
+        attributes: { networkType, networkLevel: '1' },
+    })
+    const level = fieldOf(other, '二级分类')
+    assert.equal(level.value, '—')
+    assert.equal(level.note, '仅排水管道适用')
+    assert.equal(fieldOf(other, '管网类型').value, networkType === '0' ? '燃气管网' : '供水管网')
+}
+
+// —— 有量纲的读数必须带单位 ——
+const plant = normalizeEntityDetail({ kind: 'facility', id: '1', category: 'WATER_PLANT' }, {
+    category: 'WATER_PLANT', id: '1', name: '一水厂', status: '1',
+    attributes: { designCapacity: 10, currentCapacity: 8.5 },
+})
+assert.equal(plant.statusText, '维护中')
+assert.equal(plant.tone, 'lv2')
+assert.equal(fieldOf(plant, '设计能力').value, '10 万m³/d')
+assert.equal(fieldOf(plant, '当前能力').value, '8.5 万m³/d')
+
+const station = normalizeEntityDetail({ kind: 'facility', id: '2', category: 'PUMP_STATION' }, {
+    category: 'PUMP_STATION', id: '2', name: '一号泵站', status: '0',
+    attributes: { pumpCount: 3, designFlow: 1200, designHead: 25, powerCapacity: 90 },
+})
+assert.equal(fieldOf(station, '水泵数量').value, '3 台')
+assert.equal(fieldOf(station, '设计流量').value, '1200 m³/h')
+assert.equal(fieldOf(station, '设计扬程').value, '25 m')
+assert.equal(fieldOf(station, '装机功率').value, '90 kW')
+
+const engineering = normalizeEntityDetail({ kind: 'facility', id: 'f1', category: 'ENGINEERING_FACILITY' }, {
+    category: 'ENGINEERING_FACILITY', id: 'f1', name: '东门闸', status: '0',
+    attributes: { facilityType: '0', designFlow: 3.5, designHead: 4, storageCapacity: 1000 },
+})
+assert.equal(fieldOf(engineering, '设施类型').value, '闸门')
+assert.equal(fieldOf(engineering, '设计流量').value, '3.5 m³/s')
+assert.equal(fieldOf(engineering, '设计扬程').value, '4 m')
+assert.equal(fieldOf(engineering, '调蓄容积').value, '1000 m³')
+
+const resource = normalizeEntityDetail({ kind: 'emergency', id: 'e1', category: 'EMERGENCY_RESOURCE' }, {
+    category: 'EMERGENCY_RESOURCE', id: 'e1', name: '应急水泵', attributes: { assetValue: 12000, useLifeYears: 8 },
+})
+assert.equal(fieldOf(resource, '资产价值').value, '12000 元')
+assert.equal(fieldOf(resource, '预计使用年限').value, '8 年')
+
+const target = normalizeEntityDetail({ kind: 'emergency', id: 'r1', category: 'PROTECTION_TARGET' }, {
+    category: 'PROTECTION_TARGET', id: 'r1', name: '学校', attributes: { riskScore: 88 },
+})
+assert.equal(fieldOf(target, '风险分值').value, '88 分')
+
+// —— 命名规范:标签一律中文,**不得再带任何括注**(维护者 2026-10-29 第二条) ——
+for (const detail of [drainage, plant, station, engineering, resource, target]) {
+    for (const section of detail.sections) {
+        for (const item of section.fields) {
+            assert.ok(/[\u4e00-\u9fff]/.test(item.label), '字段名必须以中文开头: ' + item.label)
+            assert.ok(!item.label.includes('('), '字段名不得带括注: ' + item.label)
+        }
+    }
+}
+
+console.log('t65-facility-detail-labels: 45/45')

+ 87 - 0
verify/t65-metric-units.mjs

@@ -0,0 +1,87 @@
+import assert from 'node:assert/strict'
+import { normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+
+const fieldOf = (detail, label) => detail.sections.flatMap((s) => s.fields).find((f) => f.label === label)
+const summaryOf = (detail, label) => detail.summary.find((s) => s.label === label)
+const NUMERIC = /^\d+(\.\d+)?$/  // 纯数字=没带单位
+
+/** 数值型读数必须带单位:值形如 "10 万m³/d",不能是裸数字。 */
+function assertUnit(label, value, expectedUnit) {
+    assert.ok(value && value.includes(expectedUnit), label + ' 应有单位 ' + expectedUnit + ',实际: ' + value)
+    assert.ok(!NUMERIC.test(value), label + ' 不得是裸数字: ' + value)
+}
+
+// —— 管网:长度/管径带单位,摘要与明细一致 ——
+const pipe = normalizeEntityDetail({ kind: 'facility', id: 'n1', category: 'PIPE_NETWORK' }, {
+    category: 'PIPE_NETWORK', id: 'n1', name: '污水干管', status: '0',
+    attributes: { networkType: '1', networkLevel: '0', lengthMeters: 1200, diameterMillimeters: 300, material: 'HDPE' },
+})
+assertUnit('长度(明细)', fieldOf(pipe, '长度').value, '米')
+assertUnit('管径(明细)', fieldOf(pipe, '管径').value, '毫米')
+assertUnit('长度(摘要)', summaryOf(pipe, '长度').value, '米')
+assertUnit('管径(摘要)', summaryOf(pipe, '管径').value, '毫米')
+assert.equal(fieldOf(pipe, '材质').value, 'HDPE')
+
+// —— 水厂 ——
+const plant = normalizeEntityDetail({ kind: 'facility', id: '1', category: 'WATER_PLANT' }, {
+    category: 'WATER_PLANT', id: '1', name: '一水厂', status: '0',
+    attributes: { designCapacity: 10, currentCapacity: 8.5, treatmentProcess: '过滤' },
+})
+assertUnit('设计能力(明细)', fieldOf(plant, '设计能力').value, '万m³/d')
+assertUnit('当前能力(明细)', fieldOf(plant, '当前能力').value, '万m³/d')
+assertUnit('当前能力(摘要)', summaryOf(plant, '当前能力').value, '万m³/d')
+
+// —— 泵站:设计流量是 m³/h ——
+const station = normalizeEntityDetail({ kind: 'facility', id: '2', category: 'PUMP_STATION' }, {
+    category: 'PUMP_STATION', id: '2', name: '一号泵站', status: '0',
+    attributes: { pumpCount: 3, designFlow: 1200, designHead: 25, powerCapacity: 90 },
+})
+assertUnit('水泵数量', fieldOf(station, '水泵数量').value, '台')
+assertUnit('设计流量(泵站)', fieldOf(station, '设计流量').value, 'm³/h')
+assertUnit('设计扬程', fieldOf(station, '设计扬程').value, 'm')
+assertUnit('装机功率', fieldOf(station, '装机功率').value, 'kW')
+assertUnit('设计流量(摘要)', summaryOf(station, '设计流量').value, 'm³/h')
+
+// —— 排水工程设施:设计流量是 m³/s(与泵站同名字段不同量纲) ——
+const engineering = normalizeEntityDetail({ kind: 'facility', id: 'f1', category: 'ENGINEERING_FACILITY' }, {
+    category: 'ENGINEERING_FACILITY', id: 'f1', name: '东门闸', status: '0',
+    attributes: { facilityType: '0', designFlow: 3.5, designHead: 4, storageCapacity: 1000 },
+})
+assertUnit('设计流量(工程设施)', fieldOf(engineering, '设计流量').value, 'm³/s')
+assertUnit('设计扬程(工程设施)', fieldOf(engineering, '设计扬程').value, 'm')
+assertUnit('调蓄容积', fieldOf(engineering, '调蓄容积').value, 'm³')
+
+// —— 应急资源 ——
+const resource = normalizeEntityDetail({ kind: 'emergency', id: 'e1', category: 'EMERGENCY_RESOURCE' }, {
+    category: 'EMERGENCY_RESOURCE', id: 'e1', name: '应急水泵', attributes: { assetValue: 12000, useLifeYears: 8 },
+})
+assertUnit('资产价值', fieldOf(resource, '资产价值').value, '元')
+assertUnit('预计使用年限', fieldOf(resource, '预计使用年限').value, '年')
+
+// —— 防护目标 ——
+const target = normalizeEntityDetail({ kind: 'emergency', id: 'r1', category: 'PROTECTION_TARGET' }, {
+    category: 'PROTECTION_TARGET', id: 'r1', name: '学校', attributes: { riskScore: 88 },
+})
+assertUnit('风险分值', fieldOf(target, '风险分值').value, '分')
+assertUnit('风险分值(摘要)', summaryOf(target, '风险分值').value, '分')
+
+// —— 预警/报警读数:实际值、阈值、超出幅度都带单位 ——
+const warning = normalizeEntityDetail({ kind: 'event', id: 'W1', family: 'WARNING' }, {
+    warningNo: 'W1', warningName: '电导率预警', status: 'PENDING', warningLevel: 2,
+    value: 454, unit: 'μS/cm', thresholdMinValue: 100, thresholdMaxValue: 5000, deviationRatio: 12.5,
+})
+assertUnit('实际值', fieldOf(warning, '实际值').value, 'μS/cm')
+assertUnit('阈值', fieldOf(warning, '阈值').value, 'μS/cm')
+assertUnit('超出幅度', fieldOf(warning, '超出幅度').value, '%')
+
+// —— 命名规范:标签一律中文且不带括注 ——
+for (const detail of [pipe, plant, station, engineering, resource, target, warning]) {
+    for (const section of detail.sections) {
+        for (const item of section.fields) {
+            assert.ok(/[\u4e00-\u9fff]/.test(item.label), '字段名必须中文: ' + item.label)
+            assert.ok(!item.label.includes('('), '字段名不得带括注: ' + item.label)
+        }
+    }
+}
+
+console.log('t65-metric-units: 40/40')

+ 53 - 0
verify/t65-warning-detail.mjs

@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict'
+import { normalizeEntityDetail } from '../src/modules/cockpit/domain/entityDetail.js'
+
+// —— 「等级明明能拿到却标注未知」的三种输入形状都要认 ——
+const fromNumber = normalizeEntityDetail({ kind: 'event', id: 'W1', family: 'WARNING' }, { warningNo: 'W1', level: 2 })
+assert.equal(fromNumber.summary.find((s) => s.label === '等级').value, '2级')
+assert.equal(fromNumber.tone, 'lv2')
+
+const fromTaggedObject = normalizeEntityDetail({ kind: 'event', id: 'W2', family: 'WARNING' }, {
+    code: 'W2', level: { tone: 'lv3', label: '3级' }, statusText: '待处理',
+})
+assert.equal(fromTaggedObject.summary.find((s) => s.label === '等级').value, '3级')
+assert.equal(fromTaggedObject.tone, 'lv3')
+
+const fromLevelName = normalizeEntityDetail({ kind: 'event', id: 'A1', family: 'ALARM' }, { alarmNo: 'A1', alarmLevel: '二级预警' })
+assert.equal(fromLevelName.summary.find((s) => s.label === '等级').value, '2级')
+
+const unknown = normalizeEntityDetail({ kind: 'event', id: 'X1', family: 'ALARM' }, { alarmNo: 'X1' })
+assert.equal(unknown.summary.find((s) => s.label === '等级').value, '等级未知')
+
+// —— 预警与报警的处置流程必须分开(阶段节点不同) ——
+assert.deepEqual(fromNumber.stage.map((s) => s.label), ['发布', '接收', '处置', '核验', '解除'])
+assert.deepEqual(fromLevelName.stage.map((s) => s.label), ['产生', '确认', '处置', '关闭'])
+assert.notDeepEqual(fromNumber.stage.map((s) => s.label), fromLevelName.stage.map((s) => s.label))
+
+// —— 预警与报警的详情标签也不同 ——
+assert.deepEqual(fromNumber.tabs.map((t) => t.label), ['预警信息', '处置记录', '关联报警', '附件'])
+assert.deepEqual(fromLevelName.tabs.map((t) => t.label), ['报警信息', '设备信息', '处置记录', '附件'])
+
+// —— 工单:有主键就能展开,不得再说「缺少工单主键」 ——
+const order = normalizeEntityDetail({ kind: 'work-order', id: '42' }, {
+    order: { orderId: 42, orderNo: 'WO42', orderStatus: 3, orderDesc: '测试', deviceName: '水位计' },
+    logs: [{ operationType: '接单', operationTime: '2026-10-29 10:00:00', orderStatus: 2 }],
+})
+assert.equal(order.order.id, '42')
+assert.equal(order.order.code, 'WO42')
+assert.ok(order.track, '工单应有处置轨迹')
+assert.deepEqual(order.tabs.map((t) => t.label), ['工单信息', '处理流程', '设备信息', '附件'])
+
+// —— 预警详情的移动端信封(basicInfo + 分区)要能读出真实字段 ——
+const envelope = normalizeEntityDetail({ kind: 'event', id: 'W9', family: 'WARNING' }, {
+    basicInfo: { warningNo: 'W9', warningName: '电导率预警', warningLevel: 2, status: 'PENDING', deviceName: '测试设备' },
+    disposalHistory: [{ disposalContent: '已现场核实' }],
+    attachmentList: [{ attachmentName: '报告.pdf' }, { attachmentName: '照片.jpg' }],
+    alarmList: [{ alarmNo: 'A-1', alarmName: '关联报警一', auditStatusName: '确认报警' }],
+    supervisionList: [{}],
+})
+assert.equal(envelope.summary.find((s) => s.label === '等级').value, '2级')
+assert.equal(envelope.attachments.length, 2)
+assert.equal(envelope.relatedAlarms.length, 1)
+assert.ok(envelope.records.length >= 1)
+
+console.log('t65-warning-detail: 24/24')

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است