/** * 事件列表与预警/报警切换(票 13)的领域层。 * * 数据源是**跨设备** `/events/current`(后端 `CockpitCurrentEventsResponse`, * 见 `E:\pipe-ner-server\docs\cockpit\README.md` 与 `CockpitCurrentEventsService`): * `mode`(WARNING|ALARM)必填并校验;`sections.events` 是 * `CockpitSectionResult>`(**预警与报警永不混合**,mode 决定取哪一族); * 行内另带 `sections.workOrders`(工单摘要分区,本票不画、留给票 19)。 * * 与设备维度 `/equipments/{code}/events/current`(`CockpitEventReadResponse`)不是 * 同一个契约:设备维度一次带回 warnings+alarms 两族,本层只认跨设备的这一份。 * `domain/events.js` 的 `toEventRead` 留在设备维度用,不在这里重复实现。 * * 四条硬纪律(都是本票验收原句,收口在这里、组件不再自己判): * 1. **预警与报警可切换,列表字段和状态标签同步变化**(验收第 1 条): * `eventListSwitchView(mode)` 是**唯一**的切换视图 —— 字段标签(预警编号/报警编号) * 与状态标签名(预警状态/报警状态)从同一个对象取,切换时必然一起变, * 不会出现"字段是报警的、标签还是预警的"(结构上不可能)。 * 2. **事件等级使用 1-4 级颜色并有文字辅助**(验收第 3 条): * `eventLevelTag(level)` 走 `statusTones.js` 的 `LEVEL_TAGS`(lv1..lv4 + '1级'..'4级'), * 与点位状态色(`pointStatus.js`)不是同一张表;认不出的等级落中性灰 + 文字,不猜。 * 3. **事件不创建独立地图标记**(验收第 4 条):本层不产生任何标记计划、不 import * 引擎/地图服务;事件只带 `deviceCode` / `monitoringPointCode` 供票 14 按设备回联位置。 * 4. **不判断预警真伪或误报**(验收第 5 条):后端契约明确「系统不判断预警真伪, * 没有任何准确率、误报率字段」(README 第 2 节),本层不发明任何判定,状态照原样带出。 * * 事件 `status` 是后端**未冻结**的自由字符串(现网取值含 PENDING/PROCESSING/HANDLING 等, * 见 `BusinessCockpitDeviceFactResolver.OPEN_WARNING_STATUS`;契约测试用 ACTIVE/PENDING/ * PROCESSING)。与票 17 的 `scale` 同一处境:直译已知取值,**认不出的回原码**(它是数据值 * 不是原因码,照原样显示才是诚实的,CODE_TEXT 的立场),并由逻辑闸门守「登记码不得等于原码」。 * * 本文件是纯函数层:不 import service、不碰 DOM、不自己发请求(t05 的 D3/D4 守卫盯着)。 */ import { fieldReader, toLocation } from './fields.js' import { SECTION_STATE, section, sectionStateFromBackend } from './sectionState.js' import { EVENT_FAMILY } from './events.js' import { LEVEL_TAGS, DEFAULT_TONE } from './statusTones.js' import { DETAIL_PLACEHOLDER } from './detailFlow.js' import { workOrderStateName } from './workOrder.js' /** 事件列表默认展示的最新条数(验收第 2 条「默认展示最新 10-15 条」的中值)。 */ export const DEFAULT_EVENT_LIMIT = 12 /** 事件 `status` 的直译表(已知取值,来源见文件头;认不出的回原码)。 */ export const EVENT_STATUS_TEXT = { ACTIVE: '进行中', PENDING: '待处理', PROCESSING: '处理中', HANDLING: '处置中', } /** 预警/报警的切换视图(**唯一**一份:字段标签与状态标签从同一对象取,切换必然同步)。 */ export function eventListSwitchView(mode) { if (mode === EVENT_FAMILY.ALARM) { return { key: EVENT_FAMILY.ALARM, label: '报警', title: '当前报警', codeLabel: '报警编号', nameLabel: '报警名称', statusLabel: '报警状态', } } return { key: EVENT_FAMILY.WARNING, label: '预警', title: '当前预警', codeLabel: '预警编号', nameLabel: '预警名称', statusLabel: '预警状态', } } /** 两个切换选项(组件遍历它画分段按钮;顺序固定:预警在前、报警在后)。 */ export function eventListSwitchOptions() { return [eventListSwitchView(EVENT_FAMILY.WARNING), eventListSwitchView(EVENT_FAMILY.ALARM)] } /** 当前切换键是否合法(不合法就不该拿去换后端 400)。 */ export function isKnownEventMode(mode) { return mode === EVENT_FAMILY.WARNING || mode === EVENT_FAMILY.ALARM } /** * 事件等级 → 色键 + 文字(验收第 3 条:颜色 + 文字辅助)。 * * 事件等级 1-4 对应 `LEVEL_TAGS`(lv1..lv4,标签 '1级'..'4级')—— 与点位状态色 * 分离是结构性的:本函数只从 `statusTones.js` 的等级表取,不碰 `pointStatus.js`。 * 认不出的等级落 `DEFAULT_TONE`(中性灰)+ 文字「等级未知」,不猜成某个真实等级。 * @param {number|null|undefined} level 后端 `EventItem.level`(1-4) */ export function eventLevelTag(level) { const tag = Number.isInteger(level) && level >= 1 && level <= 4 ? LEVEL_TAGS[level - 1] : null return tag ? { tone: tag.tone, label: tag.label } : { tone: DEFAULT_TONE, label: '等级未知' } } /** * 事件 `status` → 展示文本。已知取值直译;**认不出的回原码**(数据值照原样显示才是 * 诚实的,与 `CODE_TEXT` 同一立场);空串回空串(不把"没有状态"画成某个状态)。 */ export function eventStatusText(status) { const value = typeof status === 'string' ? status.trim() : '' if (!value) return '' return EVENT_STATUS_TEXT[value] || value } /** * ISO 时间戳 → 列表列的短读法(2026-10-13 维护者反馈:全串把右侧列挤出可视区被裁)。 * `2026-09-02T10:00:00+08:00` → `09-02 10:00`;解析不了回原串(不丢信息、不编造)。 * 只影响显示;排序仍走 `eventTime` 原值(字典序即时间序,ISO 偏移一致时成立)。 */ export function shortEventTime(value) { const text = typeof value === 'string' ? value.trim() : '' if (!text) return '' const matched = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(text) if (!matched) return text const [, , month, day, hour, minute] = matched return `${month}-${day} ${hour}:${minute}` } /** 有内容可画的分区状态(过期是有值,内容照画)。 */ const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE] /** 该事件分区有没有内容可画。 */ export function eventSectionHasContent(section) { return CONTENT_STATES.includes((section && section.state) || '') } /** * 一条 `EventItem` → 领域行(票 13 的列表行;票 14 的事件详情在其上扩展)。 * * 位置只以 `deviceCode` / `monitoringPointCode` 带出,**不在这里做任何地图标记**。 * 经纬度与 `coordinate` 仍保留给票 14(按坐标回联/详情),但本票不消费 renderable。 * @param {object} raw 后端 `CockpitCurrentEventsResponse.EventItem` */ export function toEventItem(raw) { const reader = fieldReader(raw, 'eventItem') return { // 统一键:预警与报警都用 eventNo(契约里两族同一字段名,不存在同名冲突)。 code: reader.str('eventNo'), name: reader.optionalStr('eventName'), type: reader.optionalStr('eventType'), level: reader.optionalNum('level'), levelColor: reader.optionalStr('levelColor'), status: reader.optionalStr('status'), eventTime: reader.optionalStr('eventTime'), updatedTime: reader.optionalStr('updatedTime'), value: reader.optionalNum('value'), unit: reader.optionalStr('unit'), // 设备编码是回联位置的键(票 14);缺失时留空,不伪造。 deviceCode: reader.optionalStr('deviceCode'), deviceName: reader.optionalStr('deviceName'), monitoringPointCode: reader.optionalStr('monitoringPointCode'), monitoringPointName: reader.optionalStr('monitoringPointName'), location: reader.optionalStr('location'), industry: reader.optionalStr('industry'), thresholdMinValue: reader.optionalNum('thresholdMinValue'), thresholdMaxValue: reader.optionalNum('thresholdMaxValue'), // 坐标校验(BT-4 后事件行携带;本票不画地图,只留作票 14 的输入)。 coordinate: toLocation(raw && raw.coordinateValidation), // 工单摘要(`CockpitCurrentEventsResponse.WorkOrderSummary`;列表行只带编码与状态名, // 其余留给票 14/19 的详情)。 workOrder: toWorkOrderBrief(raw && raw.workOrderSummary), missing: reader.missing, } } /** 工单摘要 → 领域短行(缺整段就 null,不画空对象冒充)。 */ export function toWorkOrderBrief(raw) { if (!raw || typeof raw !== 'object') return null const reader = fieldReader(raw, 'workOrderSummary') return { code: reader.optionalStr('workOrderCode'), stateCode: reader.optionalNum('stateCode'), stateName: reader.optionalStr('stateName') || workOrderStateName(reader.optionalNum('stateCode')), } } /** * 后端 `CockpitSectionResult>` → 前端 `section()`(分区状态 1:1)。 * 失败/空分区 `data` 为 null;成功分区 `data` 是领域行数组。 */ export function toEventSection(raw) { const reader = fieldReader(raw, 'eventsSection') const state = sectionStateFromBackend(reader.optionalStr('state')) const reason = reader.optionalStr('reason') const message = reader.optionalStr('message') const payload = reader.raw.data const hasPayload = Array.isArray(payload) return section(state, { reason: message || reason, data: hasPayload ? payload.map(toEventItem) : null, }) } /** * `/events/current` 响应 → 领域对象(票 13 唯一入口)。 * * `sections` 刻意不含后端裸键名:`workOrders` 分区在领域里叫 `orders` * (票 05 的静态守卫只认 `workOrders` 为后端字段名,组件/页面里出现即视为泄漏)。 * * @param {object} raw 后端 `CockpitCurrentEventsResponse` */ export function toCurrentEvents(raw) { const reader = fieldReader(raw, 'currentEvents') const sections = reader.obj('sections') const filters = reader.optionalObj('filters') || {} const pagination = reader.optionalObj('pagination') || {} const mapFilter = reader.optionalObj('mapFilter') || {} return { mode: reader.optionalStr('mode') || EVENT_FAMILY.WARNING, displayName: reader.optionalStr('displayName'), updatedAt: reader.optionalStr('updatedAt'), filters: { mode: filters.mode || '', level: typeof filters.level === 'number' ? filters.level : null, industry: filters.industry || '', status: filters.status || '', }, pagination: { page: pagination.page, pageSize: pagination.pageSize, total: pagination.total, totalPages: pagination.totalPages, hasMore: pagination.hasMore === true, }, mapFilter: { mode: mapFilter.mode || '', level: typeof mapFilter.level === 'number' ? mapFilter.level : null, industry: mapFilter.industry || '', status: mapFilter.status || '', deviceCodes: Array.isArray(mapFilter.deviceCodes) ? mapFilter.deviceCodes : [], }, sections: { // 事件分区(本票渲染它;`orders` 留给票 19 的「当前预警」工单块)。 events: toEventSection(sections.events), orders: toEventSection(sections && sections.workOrders), }, missing: reader.missing, } } /** * 事件列表行视图(组件只遍历它,不认后端字段)。 * * 等级标签与状态文本都从领域行现算 —— 组件里不再出现第二份映射。 * @param {object} eventItem `toEventItem()` 的结果 */ export function eventRowView(eventItem) { const item = eventItem || {} const tag = eventLevelTag(item.level) const quantity = typeof item.value === 'number' && Number.isFinite(item.value) ? item.unit ? `${item.value} ${item.unit}` : String(item.value) : DETAIL_PLACEHOLDER return { key: item.code || DETAIL_PLACEHOLDER, // 切换后的字段标签(预警/报警各自一套,来自切换视图 —— 与状态标签同步变化)。 code: item.code, name: item.name || item.code || DETAIL_PLACEHOLDER, type: item.type || '', level: { tone: tag.tone, label: tag.label }, statusText: eventStatusText(item.status), eventTime: item.eventTime || '', // 列表列的短读法(2026-10-13 维护者反馈:ISO 全串把右侧列挤出可视区被裁)。 // 只截断显示、不改排序键(排序仍用 eventTime 原值);解析不了回原串。 eventTimeText: shortEventTime(item.eventTime), updatedTime: item.updatedTime || '', quantity, value: item.value, unit: item.unit || '', deviceCode: item.deviceCode || '', deviceName: item.deviceName || '', monitoringPointCode: item.monitoringPointCode || '', monitoringPointName: item.monitoringPointName || '', location: item.location || '', industry: item.industry || '', thresholdMinValue: item.thresholdMinValue, thresholdMaxValue: item.thresholdMaxValue, coordinate: item.coordinate || null, workOrderCode: (item.workOrder && item.workOrder.code) || '', workOrderStateName: (item.workOrder && item.workOrder.stateName) || '', } } /** 刷新未返回所选事件时保留身份,但明确标注旧数据,不据缺页推断已处置。 */ export function refreshedEventSelection(selected, domain) { if (!selected) return null const events = domain && domain.sections && domain.sections.events const found = events && Array.isArray(events.data) && events.data.find((row) => row.code === selected.code) if (found && eventSectionHasContent(events)) return { ...eventRowView(found), refreshNotice: '' } return { ...selected, refreshNotice: '当前列表未返回该事件,详情为上次读取的数据' } } /** * 事件分区 → 列表行(最新默认条数在前端收口:`latestEventRows` 取排序后的前 N 条)。 * @param {object} eventsSection `toCurrentEvents().sections.events` */ export function eventListRows(eventsSection) { const list = (eventsSection && eventsSection.data) || [] return list.map(eventRowView) } /** * 取最新 N 条(验收第 2 条)。排序口径与后端 `CockpitCurrentEventsService.eventOrdering()` * 一致:eventTime 倒序(缺时间的排最后),同时间按 eventNo 升序 —— 前端不发明第二套排序。 * @param {Array} items 领域事件数组(未排序) * @param {number} [limit] 条数上限(默认 `DEFAULT_EVENT_LIMIT`) */ export function latestEventRows(items, limit = DEFAULT_EVENT_LIMIT) { const sorted = [...(items || [])].sort((left, right) => { const l = (left && left.eventTime) || '' const r = (right && right.eventTime) || '' if (l && r && l !== r) return l > r ? -1 : 1 if (l && !r) return -1 if (!l && r) return 1 const lc = (left && left.code) || '' const rc = (right && right.code) || '' if (lc && rc && lc !== rc) return lc < rc ? -1 : 1 return 0 }) const cap = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_EVENT_LIMIT return sorted.slice(0, cap) } /** * 当前预警面板的视图计划(票 13 在 `currentEvents` 面板的挂载内容)。 * * `eventsSection` 是**事件分区**(不是整个响应分区):状态栏/占位措辞说的都是"这批事件" * 有没有数据,工单分区成败不影响它(票 11/18 的分区独立纪律)。 * * 两个入参的分工:`domain` 携带已转换的事件数组(成功时),`eventsSection` 携带 * 分区自己的状态(pending / empty / failed / unavailable)。失败/空时 `domain` 为 * null,但分区状态仍在 —— 单独传分区才能说对"为什么没有数据"。 * @param {object|null} domain `toCurrentEvents()` 的结果(成功时有值) * @param {object} [eventsSection] 事件分区(`section()`;缺省从 domain 里取) */ export function currentEventsView(domain, eventsSection) { const current = domain || null const sectionValue = eventsSection || (current && current.sections && current.sections.events) || null const switchView = eventListSwitchView((current && current.mode) || EVENT_FAMILY.WARNING) const hasContent = eventSectionHasContent(sectionValue) // 还没取过数(分区未 loaded)时说「正在取数」,不说「没有数据」—— // 那是在报告一件还没问过的事(与 panelBlocks 的 sectionPending 同一立场)。 const pending = !!sectionValue && sectionValue.loaded === false const items = (sectionValue && sectionValue.data) || [] // 行必须转成**视图行**(`eventRowView`:等级 tag、状态文本、量值、设备名), // 组件只认视图行 —— 直接把领域行交给组件会让 level.tone/statusText/quantity 全是 // undefined(逻辑闸门只数行数抓不到,真机走查才会红;这里收口)。 const rows = latestEventRows(items).map(eventRowView) const placeholder = hasContent ? '' : pending ? '正在取数…' : !sectionValue ? '该分区加载失败(其它分区不受影响)' : sectionValue.state === SECTION_STATE.EMPTY ? '当前没有事件' : sectionValue.state === SECTION_STATE.UNAVAILABLE ? '该分区数据不可用(其它分区不受影响)' : '该分区加载失败(其它分区不受影响)' return { switchView, section: sectionValue, hasContent, rows, total: (sectionValue && sectionValue.data ? sectionValue.data.length : 0) || (current && current.pagination && current.pagination.total) || 0, limited: !!sectionValue && hasContent && items.length > rows.length, placeholder, pagination: (current && current.pagination) || null, updatedAt: (current && current.updatedAt) || '', } }