| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574 |
- /**
- * 设备详情分区的**界面视图**(票 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),
- }
- })
- }
|