/** * 统一实体详情领域边界(issue 65)。 * * 列表只传 { kind, id };本模块负责身份校验、不同后端载荷的正规化和紧凑详情视图。 * point/device 沿用既有详情链,不在此重复实现。 */ 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' export const ENTITY_DETAIL_KIND = Object.freeze({ FACILITY: 'facility', EMERGENCY: 'emergency', EVENT: 'event', WORK_ORDER: 'work-order', }) const KINDS = Object.freeze(Object.values(ENTITY_DETAIL_KIND)) const PLACEHOLDER = '—' export function entityIdentity(input, fallbackKind = '') { const source = input && typeof input === 'object' ? input : {} const kind = String(source.kind || source.identityKind || fallbackKind || '').trim() const id = String(source.id ?? source.code ?? source.identityCode ?? '').trim() const category = String(source.category || source.identityCategory || '').trim().toUpperCase() const family = String(source.family || source.eventFamily || source.identityFamily || '').trim().toUpperCase() return { kind: KINDS.includes(kind) ? kind : '', id, category, family, valid: KINDS.includes(kind) && !!id } } export function sameEntity(left, right) { const a = entityIdentity(left) const b = entityIdentity(right) return a.valid && b.valid && a.kind === b.kind && a.id === b.id && a.category === b.category && a.family === b.family } /** AjaxResult / page / list 三种常见外形收成同一份有效载荷。 */ export function unwrapEntityPayload(raw) { let value = raw if (value && typeof value === 'object' && typeof value.code === 'number') { if (value.code !== 200) return null value = value.data } if (value && typeof value === 'object' && value.data !== undefined && !Array.isArray(value.data)) { const keys = Object.keys(value) if (keys.every((key) => ['data', 'msg', 'message', 'success', 'timestamp'].includes(key))) value = value.data } return value } export function normalizePage(raw) { const payload = unwrapEntityPayload(raw) if (Array.isArray(payload)) return { rows: payload, page: 1, pageSize: payload.length, total: payload.length, totalPages: 1 } const source = payload && typeof payload === 'object' ? payload : {} const rows = [source.rows, source.records, source.list, source.items, source.content].find(Array.isArray) || [] const paging = source.pagination && typeof source.pagination === 'object' ? source.pagination : source const page = positive(paging.page ?? paging.current ?? paging.pageNum, 1) const pageSize = positive(paging.pageSize ?? paging.size ?? paging.limit, rows.length) const total = nonNegative(paging.total, rows.length) const totalPages = positive(paging.totalPages ?? paging.pages, pageSize ? Math.ceil(total / pageSize) : 1) return { rows, page, pageSize, total, totalPages } } export function normalizeEntityDetail(identityInput, raw, prefill = null) { const identity = entityIdentity(identityInput) if (!identity.valid) return null const payload = unwrapEntityPayload(raw) const page = normalizePage(payload) let source = payload if (Array.isArray(payload)) source = payload.find((row) => matchesId(row, identity.id)) || null else if (payload && typeof payload === 'object' && page.rows.length) source = page.rows.find((row) => matchesId(row, identity.id)) || null const seed = prefill && typeof prefill === 'object' ? prefill : {} source = source && typeof source === 'object' ? { ...seed, ...source } : { ...seed } if (identity.kind === ENTITY_DETAIL_KIND.WORK_ORDER) return normalizeWorkOrder(identity, source) if (identity.kind === ENTITY_DETAIL_KIND.EVENT) return normalizeEvent(identity, source) return normalizeAsset(identity, source) } export function prefilledEntityDetail(identity, prefill) { return normalizeEntityDetail(identity, {}, prefill) } export function entityDetailView(detail) { const current = detail || {} return { title: current.title || current.name || current.id || '实体详情', // 副标题用业务编号(不是主键):主键对使用者没有意义。 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 : [], relatedAlarms: Array.isArray(current.relatedAlarms) ? current.relatedAlarms : [], stage: Array.isArray(current.stage) ? current.stage : [], sections: Array.isArray(current.sections) ? current.sections : [], people: Array.isArray(current.people) ? current.people : [], devices: Array.isArray(current.devices) ? current.devices : [], sectionMap: current.sectionMap && typeof current.sectionMap === 'object' ? current.sectionMap : {}, attachments: Array.isArray(current.attachments) ? current.attachments : [], records: Array.isArray(current.records) ? current.records : [], track: current.track || null, } } function normalizeAsset(identity, source) { const emergency = identity.kind === ENTITY_DETAIL_KIND.EMERGENCY const category = text(source.category || identity.category).toUpperCase() const attributes = source.attributes && typeof source.attributes === 'object' ? source.attributes : {} const name = text(source.name || source.facilityName || source.resourceName || source.title) || identity.id // 状态是码值时翻成中文(0 正常 / 1 维护中 / 2 异常);后端若直接给人话则原样用。 const statusRaw = text(source.statusName || source.status || source.stateName || source.state) const status = source.statusName || source.stateName ? statusRaw : facilityStatusText(statusRaw) || statusRaw const location = text(source.address || source.location || source.position) const fields = assetFields(category, attributes) return { ...identity, category, name, title: name, kindLabel: emergency ? '应急对象' : '设施', statusText: status, tone: facilityStatusTone(source.status), raw: source, categoryText: facilityCategoryText(category), coordinates: source.coordinates || null, summary: assetSummary(category, attributes, status, location), tabs: [tab('overview', '基本信息'), tab('business', '业务详情'), tab('related', '关联信息')], people: assetPeople(attributes), devices: assetDevices(attributes), attachments: assetAttachments(attributes), sections: compactSections([ section('基本信息', [field('设施编码', source.code || identity.id), field('设施类别', facilityCategoryText(category)), field('运行状态', status), field('数据更新时间', source.updatedAt)]), section('位置与归属', [field('位置描述', location, true), field('所属片区', source.districtName), field('坐标', coordinatesText(source.coordinates), true)]), section('业务详情', fields), ]), } } /** 设施状态 → 语义色键(0 正常=绿、1 维护中=黄、2 异常=红;认不出走中性)。 * 维护者第 15 轮:运行信息的指标行也要按设施状态着色,所以本函数对外导出(原先是模块私有)。 */ export function facilityStatusTone(status) { const code = status === null || status === undefined ? '' : String(status).trim() if (code === '0') return 'lv4' if (code === '1') return 'lv2' if (code === '2') return 'lv1' 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: ['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'], } 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) } function assetDevices(a) { if (Array.isArray(a.devices)) return a.devices.map((item) => ({ id: item.id || '', code: item.code || item.id || '', name: item.name || item.code || item.id || '设备', type: item.type || '', location: item.location || '', status: deviceStatusText(item), })) return String(a.equipmentIds || a.equipmentId || '').split(',').map((code) => code.trim()).filter(Boolean).map((code) => ({ name: code, code, type: '', location: '', status: '状态未发布' })) } function deviceStatusText(item) { if (item.onlineStatus === 0) return '离线' if (item.alarmStatus === 1) return '报警' if (item.currentStatus === 1) return '维修' if (item.currentStatus === 2) return '报废' if (item.onlineStatus === 1) return '在线' return '状态未发布' } /** * 设施枚举词表(维护者 2026-10-29 走查逐条点名的口径)。 * * 后端这些字段是**码值**(status/networkType/networkLevel),直接印数字就是漏码; * 但认不出的值也不能编一个中文,所以统一走 `codedText()`:命中词表回「中文」, * 未命中回「码」本身(与 CODE_TEXT 的立场一致)。 */ const FACILITY_STATUS_TEXT = { '0': '正常', '1': '维护中', '2': '异常' } const NETWORK_TYPE_TEXT = { '0': '燃气管网', '1': '排水管网', '2': '供水管网' } const NETWORK_LEVEL_TEXT = { '0': '污水', '1': '雨水' } /** 排水工程设施类型(0 闸门 / 1 调蓄设施 / 2 泵站):同样是码值,一并直译。 */ const ENGINEERING_FACILITY_TYPE_TEXT = { '0': '闸门', '1': '调蓄设施', '2': '泵站' } /** 类别中文名:界面上用「中文 + 英文码」,只印英文等于把接口词汇漏上大屏。 */ const FACILITY_CATEGORY_TEXT = { PIPE_NETWORK: '管网', WATER_PLANT: '水厂', PUMP_STATION: '泵站', ENGINEERING_FACILITY: '排水工程设施', EMERGENCY_RESOURCE: '应急资源', HAZARD: '危险源', PROTECTION_TARGET: '防护目标', } /** 码 → 显示值;给出词表时「中文(码)」,未命中回码本身(不猜、不留空)。 */ function codedText(value, dictionary, withCode = false) { const code = value === null || value === undefined ? '' : String(value).trim() if (!code) return '' const label = dictionary && dictionary[code] if (!label) return code return withCode ? `${label}(${code})` : label } /** 类别显示名:一律中文(维护者 2026-10-29:取消英文括注)。 */ export function facilityCategoryText(category) { return codedText(category, FACILITY_CATEGORY_TEXT) } /** 设施状态(0 正常 / 1 维护中 / 2 异常)→ 中文;空值如实空。 */ export function facilityStatusText(status) { return codedText(status, FACILITY_STATUS_TEXT) } /** * 设施详情字段表(维护者 2026-10-29 设计规范)。 * * 三条口径写死在这一处,别的层不再重复判断: * 1. **命名一律中文**,必要时用括号补英文码/后端字段名(英文只作补充,不作主名); * 2. **凡有量纲的读数必带单位**;单位来自后端领域注释(m³/d、m³/h、m、mm、m³、kW…), * 不从前端猜; * 3. 某些指标只在特定子类型下有效(如管网「二级分类」只对**排水管**有意义), * 无效时**保留该行并显示「—」**,不是把它删掉 —— 删掉会让读者以为这个设施没这个属性。 * * 每项形如 { label, key, unit?, format?, always? }: * · `format` 把码值翻成中文(枚举词表只在这里登记); * · `always` 表示"无效也占位"(与 compactSections 的缺值剔除相反)。 */ function pipesFields(a) { const drainage = String(a.networkType ?? '').trim() === '1' return [ { label: '管网类型', key: 'networkType', format: (v) => codedText(v, NETWORK_TYPE_TEXT) }, // 二级分类=原来的"管网等级":后端 network_level 只用于区分排水管道(污水/雨水)。 // 非排水管这个字段没有业务含义,按要求显示「—」而不是隐藏。 { label: '二级分类', key: 'networkLevel', always: true, format: (v) => (drainage ? codedText(v, NETWORK_LEVEL_TEXT) : ''), invalidNote: drainage ? '' : '仅排水管道适用', }, { label: '长度', key: 'lengthMeters' }, { label: '管径', key: 'diameterMillimeters' }, { label: '材质', key: 'material' }, { label: '管养单位', key: 'managementUnit' }, { label: '建设日期', key: 'buildDate' }, { label: '备注', key: 'remark', wide: true }, ] } const ASSET_FIELD_ROWS = { PIPE_NETWORK: null, // 见 pipesFields():需要按管网类型决定二级分类是否适用 WATER_PLANT: [ { label: '水源类型', key: 'waterSourceType' }, { label: '设计能力', key: 'designCapacity' }, { label: '当前能力', key: 'currentCapacity' }, { label: '处理工艺', key: 'treatmentProcess', wide: true }, { label: '投产日期', key: 'commissionDate' }, { label: '水质标准', key: 'waterQualityStandard' }, { label: '负责人', key: 'principal' }, { label: '联系电话', key: 'contactPhone' }, { label: '备注', key: 'remark', wide: true }, ], ENGINEERING_FACILITY: [ { label: '设施类型', key: 'facilityType', format: (v) => codedText(v, ENGINEERING_FACILITY_TYPE_TEXT) }, { label: '设施等级', key: 'facilityLevel' }, { label: '管理单位', key: 'managementUnit' }, { label: '建设日期', key: 'buildDate' }, { 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' }, { label: '设计流量', key: 'designFlow' }, { label: '设计扬程', key: 'designHead' }, { label: '装机功率', key: 'powerCapacity' }, { label: '投产日期', key: 'commissionDate' }, { label: '负责人', key: 'principal' }, { label: '联系电话', key: 'contactPhone' }, { label: '备注', key: 'remark', wide: true }, ], EMERGENCY_RESOURCE: [ { label: '设备类型', key: 'equipmentTypeId' }, { label: '型号', key: 'model' }, { label: '规格', key: 'specification', wide: true }, { label: '制造商', key: 'manufacturer' }, { label: '资产价值', key: 'assetValue' }, { label: '预计使用年限', key: 'useLifeYears' }, { label: '安装时间', key: 'installTime' }, { label: '维护人', key: 'maintainer' }, { label: '维护电话', key: 'maintainerPhone' }, { label: '权属单位', key: 'ownershipUnit' }, { label: '设备图片', key: 'image' }, { label: '备注', key: 'remark', wide: true }, ], HAZARD: [ { label: '隐患类型', key: 'hazardType' }, { label: '隐患等级', key: 'level' }, { label: '来源', key: 'source' }, { label: '隐患描述', key: 'description', wide: true }, { label: '关联管网', key: 'networkId' }, { label: '关联设备', key: 'equipmentId' }, { label: '关联工单', key: 'workOrderId' }, { label: '报告单位', key: 'reportUnit' }, { label: '报告时间', key: 'reportTime' }, { label: '整改方案', key: 'rectifyPlan', wide: true }, { label: '整改期限', key: 'rectifyDeadline' }, { label: '整改人', key: 'rectifyPerson' }, { label: '整改反馈', key: 'rectifyFeedback', wide: true }, { label: '隐患图片', key: 'images', wide: true }, { label: '备注', key: 'remark', wide: true }, ], PROTECTION_TARGET: [ { label: '关联管网', key: 'networkId' }, { label: '风险等级', key: 'riskLevel' }, { label: '风险分值', key: 'riskScore' }, { label: '风险颜色', key: 'riskColor' }, { label: '评估模型', key: 'assessmentModel' }, { label: '评估时间', key: 'assessmentTime' }, { label: '风险因素', key: 'riskFactors', wide: true }, { label: '危险源', key: 'hazardSources', wide: true }, { label: '备注', key: 'remark', wide: true }, ], } /** 读数 + 单位(认不出的值留给 field() 变占位符,不拼半截 "— m")。 */ function withUnitText(value, unit) { if (value === null || value === undefined || String(value).trim() === '') return '' return unit ? `${value} ${unit}` : String(value) } function assetFields(category, a) { const rows = category === 'PIPE_NETWORK' ? pipesFields(a) : ASSET_FIELD_ROWS[category] || [] return rows.map((row) => { // 单位一律由 `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) { item.always = true if (row.invalidNote && item.value === PLACEHOLDER) item.note = row.invalidNote } return item }) } function coordinatesText(value) { if (!value || typeof value !== 'object') return '' const start = [value.longitude, value.latitude].filter((item) => item !== null && item !== undefined).join(', ') const end = [value.endLongitude, value.endLatitude].filter((item) => item !== null && item !== undefined).join(', ') 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 source = { ...basic, disposalHistory: envelope.disposalHistory || basic.disposalHistory, attachmentList: envelope.attachmentList || basic.attachmentList, alarmList: envelope.alarmList || basic.alarmList, 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 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, 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', '附件')], relatedAlarms: Array.isArray(source.alarmList) ? source.alarmList.map(relatedAlarm) : [], stage: eventStage(family, status, source.disposalHistory), people: peopleItems([['发布人', source.publisherName || source.publisher], ['处置人', source.handlerName || source.handler], ['核验人', source.reviewerName || source.reviewer]]), devices: deviceItems(source), attachments: attachmentItems(source.attachmentList), records: recordItems(source.disposalHistory || source.logs), sections: compactSections([ // 等级只出现在摘要卡片那一处(带色),这里不再重复一遍纯文字等级。 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, '项')), field('督办记录', listCount(source.supervisionList, '条')), ]), ]), } } function normalizeWorkOrder(identity, source) { const detailSource = source && source.order ? source : { order: source, logs: source.logs || [] } const raw = { ...(detailSource.order || {}) } if (!raw.workOrderCode && !raw.orderNo) raw.workOrderCode = identity.id if (raw.stateCode === undefined && raw.orderStatus === undefined && raw.status !== undefined) raw.stateCode = Number(raw.status) const order = toWorkOrder(raw) const timelineRaw = detailSource.logs || source.timeline || source.timelineEntries || source.records let track = null if (Array.isArray(timelineRaw)) { track = trackOf(toWorkOrderTimeline({ workOrder: raw, timelineEntries: timelineRaw.map((item) => ({ operationName: item.operationName || item.operationType || item.action, operationDesc: item.operationDesc || item.remark || item.content, operatorName: item.operatorName || item.operator || item.createByName, operatedAt: item.operatedAt || item.operationTime || item.createTime, stateCode: item.stateCode ?? item.orderStatus, stateName: item.stateName || item.orderStatusName, })) })) } else if (source.workOrder && Array.isArray(source.timelineEntries)) { track = trackOf(toWorkOrderTimeline(source)) } const stateText = order.stateName || workOrderStateName(order.stateCode) return { ...identity, name: order.code || identity.id, title: order.orderTypeName || order.code || identity.id, kindLabel: '工单', statusText: stateText, tone: workOrderTone(order.stateCode), order, track, raw: source, 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]]), devices: order.deviceCode || order.deviceName ? [{ name: order.deviceName || order.deviceCode, code: order.deviceCode, type: order.deviceTypeName, location: order.deviceLocation }] : [], attachments: attachmentItems([raw.maintenanceBeforeImg, raw.maintenanceAfterImg, ...timelineAttachments(timelineRaw)]), records: recordItems(timelineRaw), sections: compactSections([ 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)]), ]), } } /** 工单优先级码值(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,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 || '' const code = source.deviceCode || source.monitoringPointCode || '' return name || code ? [{ name: name || code, code, location: source.location || source.address || '' }] : [] } function attachmentItems(value) { const list = Array.isArray(value) ? value : value ? String(value).split(',') : [] return list.flatMap((item) => { if (!item) return [] if (typeof item === 'object') return [{ name: item.attachmentName || item.fileName || item.name || '附件', url: item.attachmentUrl || item.url || '', type: item.attachmentType || item.type || '' }] const url = String(item).trim(); return url ? [{ name: url.split('/').pop() || '附件', url, type: '' }] : [] }) } function recordItems(value) { return (Array.isArray(value) ? value : []).map((item) => ({ title: item.operationName || item.operationType || item.action || item.disposalStatus || '处置记录', time: item.operatedAt || item.operationTime || item.createTime || item.disposalTime || '', operator: item.operatorName || item.operator || item.createByName || item.disposalUser || '', description: item.operationDesc || item.remark || item.content || item.disposalContent || '', })) } function eventStage(family, status, records) { const labels = family === 'ALARM' ? ['产生', '确认', '处置', '关闭'] : ['发布', '接收', '处置', '核验', '解除'] const normalized = String(status || '').toUpperCase() let active = normalized.includes('RESOLV') || normalized.includes('CLEAR') || normalized.includes('CLOSE') ? labels.length - 1 : normalized.includes('PROCESS') || normalized.includes('HANDL') ? Math.min(2, labels.length - 1) : normalized.includes('RECEIV') || normalized.includes('CONFIRM') ? 1 : 0 if (Array.isArray(records) && records.length) active = Math.max(active, Math.min(2, labels.length - 1)) return labels.map((label,index) => ({ label, state: index < active ? 'done' : index === active ? 'active' : 'pending' })) } function workOrderStage(code) { const labels = ['创建','派单','接单','处置','验收','完成'] const n = Number(code); const active = n === 6 ? 3 : n === 7 ? 0 : Number.isInteger(n) ? Math.max(0, Math.min(n, 5)) : 0 return labels.map((label,index) => ({ label, state: index < active ? 'done' : index === active ? 'active' : 'pending' })) } function timelineAttachments(records) { return (Array.isArray(records) ? records : []).flatMap((item) => item.attachList || item.attachments || []) } /** * 缺值剔除:always 行例外 —— 业务上"这个设施没有该属性"时要显式显示占位符 * (维护者 2026-10-29:非排水管的二级分类显示「—」,不是把整行藏掉)。 */ function compactSections(sections) { return sections .map((group) => ({ ...group, fields: group.fields.filter((item) => item.always || item.value !== PLACEHOLDER) })) .filter((group) => group.fields.length) } 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() } /** * 读数 + 单位。三条纪律(维护者 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 if (min === undefined && max === undefined) return '' return withUnit(min !== undefined && max !== undefined ? `${min} ~ ${max}` : min !== undefined ? `≥ ${min}` : `≤ ${max}`, source.unit) } function positive(value, fallback) { const n = Number(value); return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback } function nonNegative(value, fallback) { const n = Number(value); return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback } function listCount(value, unit) { return Array.isArray(value) ? `${value.length} ${unit}` : '' } function locationText(value) { if (!value || typeof value !== 'object') return '' return value.location || value.address || [value.longitude, value.latitude].filter((item) => item !== undefined && item !== null).join(', ') } function matchesId(row, id) { return !!row && [row.id, row.code, row.workOrderCode, row.warningNo, row.alarmId, row.facilityCode, row.resourceId].some((value) => String(value ?? '').trim() === id) } function workOrderTone(code) { if (code === 5) return 'lv4'; if (code === 7) return 'lv1'; if (code === 6) return 'lv2'; return 'lv3' }