/** * 设备详情分区的**界面视图**(票 11):把 `domain/equipmentDetail.js` 的领域对象翻成 * 组件可以直接画的行/图,不把「怎么显示」散进模板里。 * * 为什么单独成一个 domain 模块(而不是写在组件里): * 1. 票 11 的核心不是「画六个框」,而是**分区独立**——某分区失败/为空/过期时, * 其余分区照常画。这条判据必须能脱浏览器断言,所以判定与占位措辞都是纯函数; * 2. 组件若要判断「值缺没缺、空没空、过没过期」,就必然去读后端裸字段 * (票 10 的静态守卫已经禁止过一次),那一层判断就会在模板里长出第二份。 * * 两条与票 16 一致的硬约束(在这里收口,组件不再自己判): * · **缺值显示占位符,不显示虚假零值**:`valueText(0)` 是 `'0'`,`valueText(null)` 是 `'—'`; * · **过期与空分开**:`STALE` 是有值但过期(内容照画,另带过期标识), * `EMPTY` 是确实没有值(画占位符)。 * * 两处刻意的范围收边(都是「别抢下游票的活」): * · **只渲染六块面板**(票 11 正文点名的档案 / 指标 / 预警 / 报警 / 工单 / 图片)。 * 聚合契约里的 `alarmWarningRelations` 属票 13(预警-报警关联)——它仍在 store 的八个分区里, * 只是不在本票的渲染计划内; * · 事件只如实列出等级号/状态/时间/量值,**不在这里定事件的四级配色与标签词**: * 那是票 13/19 的视觉语言(`domain/statusTones.js` 的事件四级),本票不预支。 * * 票 12 接手的**唯一一处**:`timelines` 分区(票 11 当时写明「归票 12」)。 * 它不新增第七块面板 —— 状态轨迹是**工单状态面板里每张工单自己的**一条线 * (票 12 验收第 2 条「每张工单有独立时间线」),所以它挂在 `kind: 'orders'` 那一块上, * 由 `timelineSource()` 按工单编码配好再交给组件。 * * 票 46 追加**分类查询的视图函数**(本文件末尾):预警/报警按事件等级、工单按状态字典 * 归组出分类 chips 与过滤结果。判据全是纯函数(可脱浏览器断言),组件只画结论。 * 等级词表复用票 13 的 `eventList.js`(`eventLevelTag`),工单词表复用 `workOrder.js` * —— 都是既有单源,本票不发明第二张表。 */ import { DETAIL_PLACEHOLDER } from './detailFlow.js' import { SECTION_STATE, section } from './sectionState.js' import { WORK_ORDER_STATES, workOrderStateName } from './workOrder.js' import { tracksByOrder } from './workOrderTimeline.js' import { eventLevelTag, shortEventTime } from './eventList.js' /** * 六块面板的渲染计划,顺序即界面顺序(与票 11 正文的点名顺序一致)。 * `kind` 决定用哪一组视图构造函数 —— 组件据此分派,不在模板里认后端字段。 * * `kind` 刻意用自有词(`orders` / `events` / `image`)而不是照抄后端分区名 * (`workOrders` / `warnings` / `latestImage`):分区名是**契约**、只该在 `name` 里出现一次, * 组件里若再散落后端键名,票 05 的静态守卫(页面/store 零后端裸字段名)就挡不住真泄漏了。 * * 票 46 返工(维护者 2026-10-15 第 1 条):`latestImage` 标 `mergedInto: 'archive'` —— * **渲染计划仍是六块**(t11 D1/D9 钉住六块独立成败,分区数据与状态一个都不合并), * 变的只是**呈现归属**:图片不再单独占一个标签页,并进「设备档案」页的键值行下方。 */ export const EQUIPMENT_SECTION_PANELS = [ { name: 'archive', label: '设备档案', kind: 'archive' }, { name: 'metrics', label: '最新指标', kind: 'metrics' }, { name: 'warnings', label: '预警信息', kind: 'events' }, { name: 'alarms', label: '报警信息', kind: 'events' }, { name: 'workOrders', label: '工单状态', kind: 'orders' }, { name: 'latestImage', label: '最新图片', kind: 'image', mergedInto: 'archive' }, ] /** * 设备详情的**标签页**计划(票 46 返工第 1 条)。 * * 维护者原话:弃用「浏览器式的文档切换」(六块竖排、靠一条长滚动条找),改为切换显示 * 设备档案(默认,图片归此页)|最新指标|预警信息|报警信息|工单状态。 * 这里给的是**页签清单**(顺序即界面顺序):`name` 是页签自己的稳定键(events 页签跨 * 预警/报警两个分区,所以不复用分区名),`sections` 是它背后要渲染的分区名。 * 「最新图片」不在页签清单里 —— 它作为 `archive` 页签的一部分渲染(`mergedInto`)。 */ export const EQUIPMENT_TABS = [ { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] }, { name: 'metrics', label: '最新指标', sections: ['metrics'] }, { name: 'warnings', label: '预警信息', sections: ['warnings'] }, { name: 'alarms', label: '报警信息', sections: ['alarms'] }, { name: 'workOrders', label: '工单状态', sections: ['workOrders'] }, ] /** 默认页签(维护者点名「设备档案(默认)」)。 */ export const DEFAULT_EQUIPMENT_TAB = 'archive' /** * 页签 → 该页要渲染的面板(从 `equipmentPanels()` 的结果里挑,**保留渲染计划自己的次序**)。 * 挑不出来(聚合没数据)时回空数组,由调用方按"没内容"处理,不猜。 */ export function panelsForTab(panels, tabName) { const tab = EQUIPMENT_TABS.find((item) => item.name === tabName) if (!tab) return [] const list = Array.isArray(panels) ? panels : [] return tab.sections.map((name) => list.find((panel) => panel.name === name)).filter(Boolean) } /** * 页签计数(维护者要「避免长滚动列表查找困难」——页签上先给出条数,不用点进去才知道有多少)。 * 只数**列表型**分区(预警/报警/工单);档案/指标/图片不是条目集合,回空串不画数字。 */ export function tabCount(panels, tabName) { const tab = EQUIPMENT_TABS.find((item) => item.name === tabName) if (!tab) return '' const list = Array.isArray(panels) ? panels : [] const counts = tab.sections .map((name) => list.find((panel) => panel.name === name)) .filter((panel) => panel && (panel.kind === 'events' || panel.kind === 'orders')) .map((panel) => (Array.isArray(panel.section.data) ? panel.section.data.length : 0)) if (!counts.length) return '' return String(counts.reduce((sum, value) => sum + value, 0)) } /** * 分页视图 —— **已升格到单源 `domain/pagination.js`(票 59,条文 11)**。 * * 这里按原名保留导出兼容(`HISTORY_PAGE_SIZE`/`pagedRows`),实现转调单源: * 翻页算术只允许有一份,组件与旧调用方都不再各写一份(旧 t46 闸门与调用点不受影响)。 */ export { PAGE_SIZE as HISTORY_PAGE_SIZE, pagedRows } from './pagination.js' /** 本票的渲染计划**不含**这一个聚合分区(票 13 的域)。 */ export const DEFERRED_SECTIONS = ['alarmWarningRelations'] /** 有内容可画的分区状态(`STALE` 是"有值但过期",内容照画)。 */ const CONTENT_STATES = [SECTION_STATE.SUCCESS, SECTION_STATE.STALE] /** 该分区有没有内容可画。 */ export function sectionHasContent(value) { return CONTENT_STATES.includes((value && value.state) || '') } /** * 没有内容时那一行占位说明。 * `SUCCESS` 不该走到这里(它一定有内容或有自己的空列表提示),所以回空串。 */ export function sectionPlaceholder(value) { const state = (value && value.state) || SECTION_STATE.FAILED if (state === SECTION_STATE.EMPTY) return '该分区当前没有数据' if (state === SECTION_STATE.FAILED) return '该分区加载失败(其它分区不受影响)' if (state === SECTION_STATE.UNAVAILABLE) return '该分区数据不可用(其它分区不受影响)' return '' } /** * 值 → 文本。缺失一律占位符,**绝不**用 0 冒充(票 16 的验收原句); * 反过来后端真给的 0 是有效读数,照原样显示。 */ export function valueText(value) { if (typeof value === 'number' && Number.isFinite(value)) return String(value) if (typeof value === 'string' && value.trim()) return value.trim() return DETAIL_PLACEHOLDER } /** 数值 + 单位(缺值只显示占位符,不显示「— mg/L」这种半截读数)。 */ export function quantityText(value, unit) { const text = valueText(value) if (text === DETAIL_PLACEHOLDER) return text return unit ? `${text} ${unit}` : text } /** * 收集一段载荷里记下的字段缺失(`domain/fields.js` 的 `missing` 清单)。 * * 为什么要递归找:缺失清单挂在**记它的那一层**上——档案挂在 `archive.data.missing`, * 单个指标挂在 `metrics.data.metrics[i].missing`。只读顶层就会漏掉大部分。 * 去重保序、带环保护(领域对象是纯数据,但契约缺口可能造成自引用形状)。 */ export function collectMissing(value) { const found = [] const seen = new Set() const walk = (node) => { if (!node || typeof node !== 'object' || seen.has(node)) return seen.add(node) if (Array.isArray(node)) { for (const item of node) walk(item) return } if (Array.isArray(node.missing)) { for (const item of node.missing) { if (typeof item === 'string' && item && !found.includes(item)) found.push(item) } } for (const key of Object.keys(node)) { if (key === 'missing') continue walk(node[key]) } } walk(value) return found } /** 档案 → 行视图(键值对)。图片不在这里:它有自己的分区,见 `imageView()`。 */ export function archiveRows(data) { const archive = data || {} return [ { key: 'name', label: '名称', value: valueText(archive.name) }, { key: 'code', label: '编码', value: valueText(archive.code), mono: true }, { key: 'typeName', label: '类型', value: valueText(archive.typeName) }, { key: 'model', label: '型号', value: valueText(archive.model) }, { key: 'spec', label: '规格', value: valueText(archive.spec) }, { key: 'manufacturer', label: '厂商', value: valueText(archive.manufacturer) }, { key: 'assetValue', label: '资产原值', value: valueText(archive.assetValue) }, { key: 'expectedServiceYears', label: '预计年限', value: valueText(archive.expectedServiceYears) }, { key: 'location', label: '安装位置', value: valueText(archive.location) }, { key: 'ownershipUnit', label: '产权单位', value: valueText(archive.ownershipUnit) }, { key: 'maintainer', label: '维护单位', value: valueText(archive.maintainer) }, { key: 'maintainerPhone', label: '维护电话', value: valueText(archive.maintainerPhone) }, { key: 'installTime', label: '安装时间', value: valueText(archive.installTime) }, { key: 'remark', label: '备注', value: valueText(archive.remark) }, ] } /** 指标列表 → 行视图。缺 `value` 的行显示占位符,真 0 照原样。 */ export function metricRows(data) { const list = (data && data.metrics) || [] return list.map((metric) => ({ code: metric.code || '', name: metric.name || metric.code || DETAIL_PLACEHOLDER, value: valueText(metric.value), unit: metric.unit || '', status: metric.status || '', sampledAt: metric.sampledAt || '', missing: metric.missing || [], })) } /** * 后端 `stateReason` → 给人读的说明。 * * **绝不把代号甩给值班人员**(票 05 的真机走查逮到过一次「界面漏出内部原因码」, * 那是本仓已经付过学费的教训):认得出就翻成人话,认不出**回空串**—— * 宁可什么都不说,也不把 `SAMPLE_OUTDATED` 这类接口词汇画在大屏上。 * 「过期」这件事本身已由分区状态标签说明,这里只是补充"为什么"。 */ export function metricsSourceText(data) { const dictionary = { READY: '', SAMPLE_OUTDATED: '采样已超过配置周期', } const reason = (data && data.stateReason) || '' return dictionary[reason] || '' } /** * 记录一次「后端没给这些字段」的缺失。 * * 界面**只报条数**,字段路径(`archive.deviceName` 这类)进 `fields` 供排查与 tooltip: * 路径是接口契约词汇,直接铺在大屏上是给值班人员看噪音,与上面"不漏原因码"同一条立场。 * 排查时仍拿得到完整清单(`title` 属性 / DEV 把手),所以这不是把信息丢掉。 */ export function missingNote(value) { const fields = collectMissing(value) return { count: fields.length, fields, text: fields.length ? `后端未提供 ${fields.length} 项字段` : '', tooltip: fields.join('、'), } } /** * 事件(预警/报警共用)→ 行视图。 * 等级只出「N 级」文本与后端色名(挂在 `data-event-level-color` 上供票 13 接管), * 本票不把事件四级配色画出来(那是票 13/19 的域)。 * * 票 46 追加**分类与展开详情**所需的视图字段(都是既有领域字段的改名/复用,不读新后端字段): * · `levelTag` / `categoryKey` / `categoryLabel`:等级分类(词表复用票 13 的 `eventLevelTag`, * 认不出的等级归「等级未知」,不猜成某个真实等级); * · `timeText`:折叠行的短读法(复用 `shortEventTime`,完整 `eventTime` 保留给展开详情); * · `type` / `deviceCode` / `deviceName`:展开详情要保留「来源与关联对象」(票 46 范围第 3 条)。 */ export function eventRows(data) { const list = Array.isArray(data) ? data : [] return list.map((event) => { const levelNumber = typeof event.level === 'number' && Number.isInteger(event.level) ? event.level : null const levelTag = eventLevelTag(levelNumber) return { code: event.code || DETAIL_PLACEHOLDER, name: event.name || DETAIL_PLACEHOLDER, levelText: typeof event.level === 'number' && Number.isFinite(event.level) ? `${event.level} 级` : DETAIL_PLACEHOLDER, levelColor: event.levelColor || '', status: event.status || '', eventTime: event.eventTime || '', quantity: quantityText(event.value, event.unit), location: event.location || '', levelTag: { tone: levelTag.tone, label: levelTag.label }, categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`, categoryLabel: levelNumber === null ? '等级未知' : levelTag.label, timeText: shortEventTime(event.eventTime), type: event.type || '', deviceCode: event.deviceCode || '', deviceName: event.deviceName || '', } }) } /** 工单列表 → 行视图。状态名缺失时按状态字典(后端 0–7)补(不伪造:字典也认不出就回占位符)。 * * 票 46 追加分类与展开字段: * · `categoryKey` / `categoryLabel`:工单**状态**分类(字典 0–7,单源 `workOrder.js`; * 认不出的码归 `state:unknown`「状态未知」,不并进任何一个真实态); * · `timeText`:折叠行的短读法(创建时间,完整值保留给展开详情); * * `source`:来源(后端 `orderTypeName` 的展示结论 —— 展开详情要能回答"这条从哪来")。 */ export function workOrderRows(data) { const list = Array.isArray(data) ? data : [] return list.map((order) => { const known = WORK_ORDER_STATES.some((item) => item.code === order.stateCode) return { code: order.code || DETAIL_PLACEHOLDER, stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '', // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。 stateCodeNumber: typeof order.stateCode === 'number' ? order.stateCode : null, stateName: order.stateName || workOrderStateName(order.stateCode) || DETAIL_PLACEHOLDER, typeName: order.orderTypeName || '', description: order.description || '', associationText: order.associatedWithCurrentEvent === true ? '已关联当前事件' : order.associatedWithCurrentEvent === false ? '未关联当前事件' : '当前事件关联关系未发布', createTime: order.createTime || '', planFinishTime: order.planFinishTime || '', // 未结案时后端给 null("还没有"),与"字段缺失"一样显示占位符。 realFinishTime: order.realFinishTime || DETAIL_PLACEHOLDER, categoryKey: known ? `state:${order.stateCode}` : 'state:unknown', categoryLabel: known ? workOrderStateName(order.stateCode) : '状态未知', timeText: shortEventTime(order.createTime), source: order.orderTypeName || '', } }) } /** * 图片 → **单张**视图(票 11 验收第 5 条「最新图片只展示后端指定的一张」)。 * * 这里刻意只认一个 `url` 单值:前端不索引数组、不 `find`、不挑"第一张"—— * 挑图就是前端替后端做业务判断,而且"挑错了"没人看得出来。后端给几张是后端的事, * 界面只画后端点名的那一张。 */ export function imageView(data) { const image = data || null const url = image && typeof image.url === 'string' ? image.url.trim() : '' return { hasImage: !!url, url, capturedAt: (image && image.capturedAt) || DETAIL_PLACEHOLDER, } } /** * 工单面板的**时间线来源**(票 12):把 `timelines` 子分区按工单编码配成每张一条。 * * 三个刻意的返回约定,都是为了让组件不做判断: * · `tracks` 一定是对象(哪怕没有时间线分区),组件不必写 `?.` 兜底; * · `state` 带上 `timelines` 自己的分区状态 —— 它**独立于工单列表**成败: * 后端 `loadTimelines()` 会在时间线取失败时给 FAILED(`SOURCE_SECTION_FAILED`), * 而工单列表可能照常 SUCCESS。界面必须能说出"工单在、但它的轨迹没取到", * 不能因为工单列表成功就假装轨迹也在(那是把缺数据画成没有动作)。 * · `placeholder` 与其它分区的措辞同源(`sectionPlaceholder`),不另写一套。 * * 注意**不是**「分区没数据就整块不画」:状态路径所需的当前状态码在**工单列表**分区里, * 与轨迹成败无关,所以调用方在 `hasContent === false` 时改走 `pathOnlyTrack()` * 仍把状态路径铺出来(别因为轨迹没拿到就丢掉"这张工单现在在哪一态")。 * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate` */ export function timelineSource(aggregate) { const data = (aggregate && aggregate.data) || null const sections = (data && data.sections) || {} const value = panelSection(sections, 'timelines') const { byCode, duplicates, orphans } = tracksByOrder(value.data) const hasContent = sectionHasContent(value) // 措辞从 `sectionPlaceholder()` 来(不另写一套),但带上「工单动作轨迹」这个抬头 —— // 这句话会画在**工单面板内部**,光说"该分区"读者不知道指哪一块。 const detail = hasContent ? '' : sectionPlaceholder(value) return { section: value, state: value.state, retained: value.retained === true, hasContent, // 分区没内容时一律给空表:界面据此说占位说明,而不是把上一次的轨迹留在屏幕上。 tracks: hasContent ? byCode : {}, duplicates, orphans, placeholder: detail ? `工单动作轨迹:${detail}` : '', } } /** 分区的空列表提示(后端说 SUCCESS 但列表是空的,仍是"明确 UI"而不是一片空白)。 */ export function emptyListText(label) { return `${label}当前没有记录` } // ================================================================ 票 46:分类查询与历史折叠 /** * 分类 chips 的**固定次序**(不按计数排,也不按行出现次序): * 次序来自既有单源词表 —— 事件等级 1..4(票 13 `statusTones.js`)、工单状态 0..7(`workOrder.js`)。 * 「未知」永远在最后:它是兜底桶,不该插进真实语义序列里。 */ export const EVENT_CATEGORY_ORDER = ['level:1', 'level:2', 'level:3', 'level:4', 'level:unknown'] export const WORK_ORDER_CATEGORY_ORDER = [...WORK_ORDER_STATES.map((item) => `state:${item.code}`), 'state:unknown'] /** 分类 chips 里「全部」那一项的键(空串=不设分类条件)。 */ export const CATEGORY_ALL = '' /** * 行视图 → 分类 chips(`[{ key, label, count }]`,首项恒为「全部」)。 * * 三条判据都收在这里,组件不再自己归组: * · **计数是真实值**:0 计数的词表项**不出现**(chip 条不是字典,铺一排 0 只会挤掉有用的入口); * · 词表外的键(后端加了新等级/新状态码)照样出现并排在已知项之后 —— 不静默丢数据; * · 不排序、不改行次序(与票 10/11「前端不重排」同一条立场)。 * @param {Array<{categoryKey: string, categoryLabel: string}>} rows 已带分类键的行视图 * @param {string[]} order 已知分类的展示次序 */ export function categoryChips(rows, order = []) { const list = Array.isArray(rows) ? rows : [] const counts = new Map() const labels = new Map() for (const row of list) { const key = row.categoryKey || CATEGORY_ALL counts.set(key, (counts.get(key) || 0) + 1) if (!labels.has(key)) labels.set(key, row.categoryLabel || key) } const known = order.filter((key) => counts.has(key)) const extra = [...counts.keys()].filter((key) => !order.includes(key)) return [ { key: CATEGORY_ALL, label: '全部', count: list.length }, ...[...known, ...extra].map((key) => ({ key, label: labels.get(key) || key, count: counts.get(key) })), ] } /** * 按分类过滤行视图。空键=「全部」,原样返回(同一批对象引用,不复制、不改序)。 * 认不出的键回**空数组**而不是全量:分类条件生效却返回未筛数据,是"筛了个寂寞"的假绿。 */ export function filterByCategory(rows, categoryKey) { const list = Array.isArray(rows) ? rows : [] const key = typeof categoryKey === 'string' ? categoryKey.trim() : '' if (!key) return list return list.filter((row) => row.categoryKey === key) } /** 分类生效时那句「当前分类 · 再点取消」的可见回显(与票 37 的 typeNote 同一立场)。 */ export function categoryNote(chips, categoryKey) { const key = typeof categoryKey === 'string' ? categoryKey.trim() : '' if (!key) return '' const found = (chips || []).find((chip) => chip.key === key) return `当前分类:${(found && found.label) || key}(再点同一项取消)` } /** * 展开详情的一行(值缺失一律占位符 —— 票 16「不把没给画成空白/0」)。 * `keepEmpty` 的项是票 46 范围第 3 条点名要**始终保留**的四件事: * 数据时间 / 状态 / 来源 / 关联对象;其余项有值才画(不铺一排 `—` 噪音)。 */ function detailRow(label, value, keepEmpty = false) { const text = valueText(value) if (!keepEmpty && text === DETAIL_PLACEHOLDER) return null return { label, value: text } } /** 一条预警/报警 → 展开后的详情行(时间、状态、来源、关联设备都在,缺值给占位符)。 */ export function eventDetailRows(row) { const current = row || {} return detailRowList([ detailRow('数据时间', current.eventTime, true), detailRow('状态', current.status, true), detailRow('来源', current.type, true), detailRow('关联设备', current.deviceName || current.deviceCode, true), detailRow('编码', current.code), detailRow('等级', current.levelText), detailRow('量值', current.quantity), detailRow('位置', current.location), ]) } /** 一条工单 → 展开后的详情行(同上四件事 + 计划/实际结案时间)。 * `实际结案` 必留:未结案时后端给 null、视图是占位符,折叠行本来就写着「结案 —」, * 展开后若这一行消失就成了"折叠说没有、展开什么都不说"(前后矛盾)。 */ export function workOrderDetailRows(row) { const current = row || {} return detailRowList([ detailRow('数据时间', current.createTime, true), detailRow('状态', current.stateName, true), detailRow('来源', current.source || current.typeName, true), detailRow('关联对象', current.associationText, true), detailRow('编码', current.code), detailRow('计划结案', current.planFinishTime), detailRow('实际结案', current.realFinishTime, true), detailRow('说明', current.description), ]) } /** 详情行清单:剔除「可选项没值」的 null(必选项即使占位符也保留)。 */ function detailRowList(rows) { return rows.filter(Boolean) } /** 聚合分区 → 设备编码(判定"已加载的这份是不是当前这台设备")。 */ export function aggregateDeviceCode(aggregate) { const data = (aggregate && aggregate.data) || null return (data && data.deviceCode) || '' } /** * 已加载的聚合是不是**当前这台设备**的。 * * 用它挡住两类错画:请求还没回来时把上一台设备的数据挂在新的表头下; * 以及失败分区(`data` 为 null)被当成"已经取过"而不再重试。 */ export function equipmentDetailMatches(aggregate, deviceCode) { if (!deviceCode) return false if (!aggregate || aggregate.loaded !== true) return false return aggregateDeviceCode(aggregate) === deviceCode } /** * 整个聚合请求本身就没成功(`FAILED` / `UNAVAILABLE`,`data` 为 null)。 * * 为什么需要单独一个判据:这种情况下六个分区**全都**没有数据, * 若照常渲染六块面板,每块都会说「该分区加载失败(其它分区不受影响)」—— * 那句话在只有一块坏时是真的,在这里是**假的**(六块一起坏了,没有"其它分区"可依赖)。 * 谎报"别的还活着"会让值班人员以为还有可用数据,比直接说"这台设备的详情没取到"更坏。 * * 注意与「后端把每个分区各自标 FAILED」的区别:后者 `data` 仍在、 * `aggregateState: PARTIAL` 且各分区自带 state —— 那才是真正的"部分失败", * 必须照常渲染六块(那是本票验收第 3 条要的形态)。 * * **刻意不比对设备编码**:失败分区 `data` 为 null、带不出是哪台设备(见 `aggregateDeviceCode`)。 * 而它一定属于**当前**选中那台——store 的 token 纪律保证迟到的响应(含失败)会被丢弃, * 且换设备时 `deviceLoading` 为真、界面先走"加载中"分支,落不到这里。 * 所以「失败态 = 当前这台没取到」是构造性的,不需要(也无法)再从载荷里认。 * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate` */ export function aggregateUnavailable(aggregate) { if (!aggregate || aggregate.loaded !== true) return false return [SECTION_STATE.FAILED, SECTION_STATE.UNAVAILABLE].includes(aggregate.state) } /** 取一个聚合子分区;后端整段缺失时给一条失败分区,**不静默当成功**。 */ function panelSection(sections, name) { const found = sections && sections[name] return found || section(SECTION_STATE.FAILED) } /** * 聚合分区 → 六块面板的渲染计划(组件只需遍历它)。 * * 注意**不看** `aggregateState`:后端整体态漏标或标错时,成功分区不能被它拖成不可用 * (票 05 的 B13 已定下这条立场,这里是它的界面侧延续)。 * @param {object} aggregate `stores/detailStore.js` 的 `sections.equipmentAggregate` */ export function equipmentPanels(aggregate) { const data = (aggregate && aggregate.data) || null const sections = (data && data.sections) || {} return EQUIPMENT_SECTION_PANELS.map((panel) => { const value = panelSection(sections, panel.name) return { name: panel.name, label: panel.label, kind: panel.kind, section: value, hasContent: sectionHasContent(value), missing: missingNote(value.data), placeholder: sectionPlaceholder(value), } }) }