/** * 两级详情(票 10;CONTEXT.md「两级详情」):地图/台账 → 点位摘要 + 设备卡片 → 设备详情。 * * 为什么单独成 domain 模块:这是一条**导航状态机**(先点位、再设备),与「取数成没成」 * (`domain/sectionState.js`)和「地图上画什么」(`domain/pointStatus.js`)都不是一回事。 * 把它放这里,store 只管取数、组件只管画,逻辑脚本能脱浏览器把每一级与每一次返回都走一遍。 * * 「关联设备不作为独立地图标记重复渲染」在这里是**结构性**的:本模块只产出选择与卡片视图, * 没有任何一条路径会把设备写回地图标记计划 —— 地图对象永远只来自 `mapStore` 的地图摘要 * (后端只给「监测点 + 未关联设备」,关联设备根本不在那份载荷里)。 */ import { legendIconFor, visualForStatus } from './pointStatus.js' // 票 44:设备状态必须走**独立**的设备词表,不得借用点位表(决策票 07/10 的边界)。 import { deviceVisualForStatus } from './deviceStatus.js' /** 详情层级(也是 `data-detail-level` 的取值)。 */ export const DETAIL_LEVEL = { NONE: 'none', POINT: 'point', EQUIPMENT: 'equipment', } /** 缺失值的占位符:界面显示它,而不是把「没给」画成 0 或空白(票 16 的立场)。 */ export const DETAIL_PLACEHOLDER = '—' /** 地图对象的两种来源(与 `domain/mapSummary.js` 的 `MAP_OBJECT_KIND` 同名,见那里的定义)。 */ const KIND_MONITORING_POINT = 'monitoring-point' const KIND_UNASSOCIATED_EQUIPMENT = 'unassociated-equipment' /** 空选择:什么都没打开(界面据此不渲染详情浮层)。 */ export function emptyDetailSelection() { return { level: DETAIL_LEVEL.NONE, pointCode: '', deviceCode: '' } } /** * 地图对象 → 详情选择。 * * 监测点进**第一级**(点位摘要 + 设备卡片);未关联设备没有点位可摘要, * 直接进**第二级**(设备详情)——「优先以监测点作为可点击地图对象」(CONTEXT.md) * 不等于「别的对象点了没反应」。认不出的对象返回 `null`:不猜、也不打开一张空卡片。 * @param {object} object 地图对象(`domain/mapSummary.js` 的形状) */ export function selectionFromMapObject(object) { const item = object || {} const code = item.code || '' if (!code) return null if (item.kind === KIND_MONITORING_POINT) { return { level: DETAIL_LEVEL.POINT, pointCode: code, deviceCode: '' } } if (item.kind === KIND_UNASSOCIATED_EQUIPMENT) { return { level: DETAIL_LEVEL.EQUIPMENT, pointCode: '', deviceCode: code } } return null } /** 打开某个监测点:回到第一级,并把设备选择清掉(换点位不该留着上一台的选中态)。 */ export function openPoint(selection, pointCode) { if (!pointCode) return selection || emptyDetailSelection() return { level: DETAIL_LEVEL.POINT, pointCode, deviceCode: '' } } /** * 选择设备卡片:进第二级。点位编码**保留**(返回第一级要靠它), * 从地图上的未关联设备进来时 `pointCode` 本来就是空的。 */ export function openDevice(selection, deviceCode) { const current = selection || emptyDetailSelection() if (!deviceCode) return current return { level: DETAIL_LEVEL.EQUIPMENT, pointCode: current.pointCode || '', deviceCode } } /** * 返回第一级。没有点位可回(未关联设备直接进的第二级)时收成空选择 —— * 「返回点位摘要」不该把用户送到一个并不存在的点位。 */ export function backToPoint(selection) { const current = selection || emptyDetailSelection() if (!current.pointCode) return emptyDetailSelection() return { level: DETAIL_LEVEL.POINT, pointCode: current.pointCode, deviceCode: '' } } /** * 关闭详情:回到空选择(界面据此收掉浮层)。 */ export function closeDetail() { return emptyDetailSelection() } /** * 同一个点位目标是否已经打开(含已经选到设备的那一级)。 * store 用它决定「点同一个点位不必再取一次数」,纯函数便于断言。 */ export function isSamePointTarget(selection, pointCode) { const current = selection || emptyDetailSelection() if (!pointCode) return false return current.pointCode === pointCode && current.level !== DETAIL_LEVEL.NONE } /** * 后端状态 + 后端标签 → 标签视图(**监测点**用;标签优先用后端给的词)。 * * 注意与 `deviceStatusView()` 的分工:本函数读的是**点位**状态词表, * 给设备配色必须走 `deviceStatusView()` —— 拿点位表给设备配色是决策票 07/10 点名的越界。 * * `icon`(票 46 返工第 2 条):走 `legendIconFor` **单源接缝** —— 详情里的状态标签与 * 一张图顶部状态栏共用同一份图标登记与同一张词表(色键/形状/文字同源)。 */ export function statusView(status, labelOverride = '') { const visual = visualForStatus(status) return { key: visual.key, tone: visual.tone, shape: visual.shape, label: labelOverride || visual.label, icon: legendIconFor(visual.key), } } /** * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。 * * 为什么必须另立一个函数:早先设备卡片也走 `statusView()`,于是**设备状态被拿点位表解释** —— * 而两张表的取值集不同(点位有 `EMPTY`「无设备」,设备五态**没有**), * 一旦后端给设备回一个点位专有的状态,界面会画出一个设备根本不可能有的态。 * 现在设备的色/形/文字一律来自 `domain/deviceStatus.js`(独立平行表)。 * * `icon`(票 46 返工第 2 条):与顶部状态栏同源(`legendIconFor`,设备与点位词表的键同名, * 五态 + 未发布都有登记);**取值仍走设备表**,只借图标登记,不借状态语义。 */ export function deviceStatusView(status, labelOverride = '') { const visual = deviceVisualForStatus(status) return { key: visual.key, tone: visual.tone, shape: visual.shape, label: labelOverride || visual.label, icon: legendIconFor(visual.key), } } /** * 状态原因 → 一句话。 * * 两种形状都吃:点位详情是 `statusReasons[{status,deviceCode,reason}]`, * 设备卡片是 `statusReasons: string[]`。去重保序(后端顺序即优先级顺序,前端不重排)。 * 一个原因都没有时回空串 —— 界面显示占位符,**不**写成「正常」。 */ export function reasonText(reasons) { const list = Array.isArray(reasons) ? reasons : [] const texts = list .map((item) => (typeof item === 'string' ? item : (item && item.reason) || '')) .map((text) => String(text).trim()) .filter(Boolean) return [...new Set(texts)].join(';') } /** * 关联设备数量。 * * 后端 `deviceStatistics.totalDeviceCount` 是权威值(含「确实 0 台」); * 整段统计缺失(`statistics.missing` 记着,见 `domain/pointDetail.js`)时退回 * 「已返回的卡片数」,而不是把「没给统计」显示成 0 台。 * @param {object} detail `toMonitoringPointDetail()` 的领域对象 */ export function associatedDeviceCount(detail) { const current = detail || {} const statistics = current.statistics || null const statisticsGiven = statistics && Array.isArray(statistics.missing) && statistics.missing.length === 0 if (statisticsGiven && Number.isFinite(statistics.total)) return statistics.total return Array.isArray(current.devices) ? current.devices.length : 0 } /** * 设备卡片 → 卡片视图。顺序由调用方保持(`deviceCards()` 不排序)。 * @param {object} card `toDeviceCard()` 的领域对象 */ export function deviceCardView(card) { const current = card || {} return { code: current.code || DETAIL_PLACEHOLDER, name: current.name || DETAIL_PLACEHOLDER, typeName: current.typeName || '', // 票 44 修正越界:**设备**状态走设备词表(原先是 `statusView`,即拿点位表解释设备)。 status: deviceStatusView(current.status, current.statusLabel), reason: reasonText(current.statusReasons), statusTime: current.statusTime || '', } } /** * 设备卡片列表 —— **原样保持后端返回顺序**(票 10 验收第 3 条)。 * 这里刻意不排序、不按状态优先级重排:顺序是后端的业务语义(谁重要由后端说)。 * @param {object} detail `toMonitoringPointDetail()` 的领域对象 */ export function deviceCards(detail) { const devices = (detail && detail.devices) || [] return devices.map(deviceCardView) } /** * 点位摘要(第一级的五行):名称 / 编码 / 状态 / 原因 / 关联设备数量。 * @param {object} detail `toMonitoringPointDetail()` 的领域对象 */ export function pointSummary(detail) { const current = detail || {} return { name: current.name || DETAIL_PLACEHOLDER, code: current.code || DETAIL_PLACEHOLDER, status: statusView(current.status, current.statusLabel), reason: reasonText(current.statusReasons), deviceCount: associatedDeviceCount(current), } } /** * 点位设备计数 KPI 行(票 46 范围第 1 条:能用 KPI/状态卡表达的不再是大段纯文字)。 * * 数据来源是后端 `deviceStatistics`(票 05 已适配成 `statistics`),前端**不重算**: * · 统计整段缺失(`statistics.missing` 非空)→ 回 `null`,界面**不画 KPI 行**, * 而不是画一排 0 ——「没给统计」与「各态都是 0 台」是两件事(票 16 的虚假零值立场); * · 计数 0 是真实值(「确实没有报警设备」),照原样画。 * 色键/文字走**设备词表**(`deviceStatusView`),不借点位表 —— 这里数的是设备。 * @param {object} detail `toMonitoringPointDetail()` 的领域对象 */ export function pointDeviceCountsView(detail) { const statistics = (detail && detail.statistics) || null if (!statistics || !Array.isArray(statistics.missing) || statistics.missing.length > 0) return null const rows = [ { key: 'ALARM', label: '报警', count: statistics.alarm }, { key: 'WARNING', label: '预警', count: statistics.warning }, { key: 'WORK_ORDER', label: '工单处置', count: statistics.workOrder }, { key: 'NORMAL', label: '正常', count: statistics.normal }, { key: 'OFFLINE', label: '离线', count: statistics.offline }, ] return rows.map((row) => ({ ...row, // 色键走**设备词表**(数的是设备,不借点位表 —— 决策票 07/10 的边界,票 44 已收口)。 tone: deviceStatusView(row.key).tone, })) } /** 从已加载的点位详情里取某台设备的卡片视图(第二级的表头用它;找不到回 null)。 */ export function deviceCardOf(detail, deviceCode) { if (!deviceCode) return null const cards = deviceCards(detail) return cards.find((card) => card.code === deviceCode) || null }