/** * 预警处置模式的三块聚合面板(票 19)。 * * 所有转换都是纯函数:组件只消费 view,store 只负责请求与共享 mode/range。 * 预警/报警沿用票 13 的 mode;历史、效率、分析三块不各自维护一套切换状态。 */ import { fieldReader } from './fields.js' import { eventLevelTag, eventListSwitchView, toEventItem } from './eventList.js' import { valueText, workOrderRows } from './equipmentDetailView.js' import { EVENT_FAMILY } from './events.js' import { WORK_ORDER_STATES } from './workOrder.js' // 票 67:全局行业筛选**作用不到**全量工单端点(实测不收该入参),措辞取单一来源。 import { industryGap } from './filterCaliber.js' import { barsBlock, donutBlock, kpiBlock, listBlock, numberish, rowsBlock, sectionHasContent, sectionPlaceholder, sortByCountDesc, stackedBlock, trendBlock, } from './panelBlocks.js' export const WARNING_RESPONSE_PANEL_IDS = ['historyAnalysis', 'responseEfficiency', 'eventAnalysis'] export const EVENT_RANGES = [ { key: '7d', label: '近 7 天', type: 'LAST_7_DAYS' }, { key: '30d', label: '近 30 天', type: 'LAST_30_DAYS' }, { key: '90d', label: '近 90 天', type: 'LAST_90_DAYS' }, ] const RANGE_TYPES = Object.fromEntries(EVENT_RANGES.map((item) => [item.type, item.key])) function list(raw, key) { const value = raw && raw[key] return Array.isArray(value) ? value : [] } function countRows(rows, key = 'count') { return list(rows, key).map((item) => { const reader = fieldReader(item, 'eventAnalysisRow') return { code: reader.optionalStr('code') || reader.optionalStr('name') || reader.optionalStr('label'), label: reader.optionalStr('name') || reader.optionalStr('label') || reader.optionalStr('code'), count: numberish(reader.raw.count) || 0, } }) } function levelRows(rows) { return (Array.isArray(rows) ? rows : []).map((item) => { const level = Number(item && (item.level === undefined ? item.code : item.level)) const tag = eventLevelTag(level) return { code: String(level), label: tag.label, count: numberish(item && item.count) || 0 } }) } function formatDuration(seconds, unit) { const value = numberish(seconds) if (value === null) return '—' if (unit) return `${value} ${{ HOURS: '小时', MINUTES: '分钟', SECONDS: '秒' }[unit] || unit}` if (value >= 3600) return `${(value / 3600).toFixed(1)} 小时` if (value >= 60) return `${(value / 60).toFixed(1)} 分钟` return `${value} 秒` } function durationValue(raw, names) { for (const name of names) { const candidate = raw && raw[name] if (candidate && typeof candidate === 'object') return candidate if (candidate !== undefined && candidate !== null) return { value: candidate } } return null } function durationView(raw, names) { const value = durationValue(raw, names) return value ? formatDuration(value.value, value.unit) : '—' } /** * 处置率 → 界面读数。 * * **分母为 0 不是 0%**(票 18 的 M3/M14 变异测试立的纪律,票 20 沿用到这里): * 后端在没有可算事件时回 `state=EMPTY` 且 `value="0.0000"` —— 那个 0 是「没有分母」的占位, * 照 `value` 直画就是「处置率 0.0%」,而真相是「当前范围内没有可参与计算的事件」。 * 所以先按 `state`/分母收口,不适用时显示占位符。 * * **票 20 导出**:态势总览也要画处置率,两处必须用同一条判据 —— * 各写一份就是"同一件纪律两处各自小心",早晚分叉。 * @param {object} current `toHandlingEfficiency()` 的领域对象(或同形对象) */ export function handlingRateView(current) { const row = current || {} const unavailable = row.handlingState === 'EMPTY' || (typeof row.denominator === 'number' && row.denominator === 0) if (unavailable) return '不适用' if (row.handlingRate === null || row.handlingRate === undefined) return '—' return `${(row.handlingRate * 100).toFixed(1)}%` } /** 处置率不适用时的说明(界面挂在 KPI 的 hint 上,不伪装成 0%)。 */ export function handlingRateHint(current) { const row = current || {} if (row.handlingState === 'EMPTY' || (typeof row.denominator === 'number' && row.denominator === 0)) { return '当前范围没有可计入工单,处置率不适用' } return '' } function rangeView(raw) { const range = (raw && raw.range) || {} const key = RANGE_TYPES[range.type] || '' return { key, type: range.type || '', start: range.startTime || range.start || '', end: range.endTime || range.end || '', } } function sectionView(name, section, blocks, extra = {}) { return { name, label: extra.label || name, section, hasContent: sectionHasContent(section), blocks, range: extra.range || null, rangeNote: extra.rangeNote || '', missing: extra.missing || { count: 0, text: '', tooltip: '' }, placeholder: sectionPlaceholder(section), ...extra, } } /** 历史分析载荷 → 领域对象。历史行沿用当前事件的同一事件行形状。 */ export function toEventHistory(raw) { const reader = fieldReader(raw, 'eventHistory') const source = reader.raw.sections && reader.raw.sections.events ? reader.raw.sections.events.data : reader.raw.data // 票 61:后端新增逐事件行 `events[]`(形状与 events/current 同构); // 旧的 `sections.events.data` / `data` 形状保留兼容(mock 与历史载荷都吃得下)。 const rawEvents = Array.isArray(reader.raw.events) ? reader.raw.events : source return { mode: reader.optionalStr('mode') || reader.optionalStr('eventFamily'), range: rangeView(raw), events: Array.isArray(rawEvents) ? rawEvents.map(toEventItem) : [], trend: Array.isArray(reader.raw.rows) ? reader.raw.rows.map((row) => ({ bucket: row.timeBucket || '', count: numberish(row.eventCount) || 0 })) : list(raw, 'trend').map((item) => ({ bucket: String(item && (item.bucket || item.bucketStart || '')), count: numberish(item && item.count) || 0 })), bucketed: Array.isArray(reader.raw.rows), pagination: reader.raw.pagination || {}, // 票 61 分页口径(单窗服务端 / 多窗已加载合并集合),由 fetchEventHistory 组装。 paging: reader.raw.paging || null, missing: reader.missing, } } /** 效率载荷 → 领域对象。兼容秒数字段和 averageDuration 聚合对象。 */ export function toHandlingEfficiency(raw) { const reader = fieldReader(raw, 'handlingEfficiency') const familyRaw = reader.optionalStr('eventFamily') || reader.optionalStr('mode') const familyCode = familyRaw === EVENT_FAMILY.ALARM || familyRaw === EVENT_FAMILY.WARNING ? familyRaw : '' const family = reader.raw.familyMetrics && typeof reader.raw.familyMetrics === 'object' ? reader.raw.familyMetrics : {} const summary = reader.raw.summary || {} const handling = summary.handlingEfficiency || family.handlingEfficiency || reader.raw.handlingEfficiency const handlingValue = handling && typeof handling === 'object' ? handling : {} const states = Array.isArray(reader.raw.workOrderStateCounts) ? Object.fromEntries(reader.raw.workOrderStateCounts.map((row) => [row.stateCode, row.count])) : family.workOrderStates || reader.raw.workOrderStates || reader.raw.workOrderStateDistribution || {} const averageDuration = summary.averageDuration || reader.raw.averageDuration || {} const distribution = WORK_ORDER_STATES.map((item) => ({ code: String(item.code), label: item.name, count: numberish(states[item.code] === undefined ? states[String(item.code)] : states[item.code]) || 0, })) // 票 65:本端点按**事件族**分流。预警族没有工单口径可画(预警不挂工单), // 但有真实的预警口径读数:条数、逐事件等级、逐桶发生量、工单关联实况; // 处置率/工单状态/结案时长属**报警的工单处置口径**,预警模式下不得上屏。 const trendRowsRaw = list(raw, 'durationTrend') const events = list(raw, 'events') const totalEvents = numberish(summary.totalEvents === undefined ? reader.raw.totalEvents : summary.totalEvents) // 等级分布只统计**已返回的**事件行(本端点分页,未取全时如实标记,不拿半份冒充全量)。 const levelDistribution = [1, 2, 3, 4].map((level) => ({ code: String(level), label: eventLevelTag(level).label, count: events.filter((item) => Number(item && item.level) === level).length, })) return { family: familyCode, handlingRate: numberish(handlingValue.value === undefined ? (handlingValue.rate === undefined ? reader.raw.handlingRate : handlingValue.rate) : handlingValue.value), // 票 20:把 `state` 一并带出 —— 「分母为 0」与「处置率真的是 0%」必须分得开 // (后端在无分母时回 value="0.0000" + state="EMPTY")。 handlingState: handlingValue.state || reader.raw.handlingState || '', handlingReason: handlingValue.reason || reader.raw.handlingReason || '', basis: handlingValue.basis || reader.raw.basis || '', numerator: numberish(handlingValue.numerator === undefined ? reader.raw.numerator : handlingValue.numerator), denominator: numberish(handlingValue.denominator === undefined ? reader.raw.denominator : handlingValue.denominator), totalEvents, handledEvents: numberish(reader.raw.handledEvents), // 票 65:事件回联工单的真实计数(预警族实测 linked=0 / missing=全部)。 linkedWorkOrderCount: numberish(summary.linkedWorkOrderCount === undefined ? reader.raw.linkedWorkOrderCount : summary.linkedWorkOrderCount), missingWorkOrderCount: numberish(summary.missingWorkOrderCount === undefined ? reader.raw.missingWorkOrderCount : summary.missingWorkOrderCount), levelDistribution, // 等级分布的分母是否等于该窗口的事件总数(分页未取全时界面如实说明)。 levelDistributionComplete: totalEvents === null || events.length >= totalEvents, responseDuration: durationValue(raw, ['averageResponseDuration', 'averageResponseSeconds']) || durationValue(averageDuration, ['responseDuration', 'responseSeconds', 'response']), closeDuration: summary.averageDuration ? summary.averageDuration.state === 'EMPTY' ? null : summary.averageDuration : durationValue(raw, ['averageCloseDuration', 'averageCloseSeconds']) || durationValue(averageDuration, ['closeDuration', 'closeSeconds', 'close']), workOrderStates: distribution, // 逐桶**事件发生量**(与下面的结案时长趋势是两个口径,预警族只用这一份)。 eventTrend: trendRowsRaw.map((item) => ({ bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')), count: numberish(item && item.eventCount) || 0, })), trend: trendRowsRaw.map((item) => ({ bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')), count: item && item.averageDuration ? item.averageDuration.state === 'EMPTY' ? null : numberish(item.averageDuration.value) : numberish(item && (item.handlingEfficiency === undefined ? item.count : item.handlingEfficiency)), unit: item && item.averageDuration ? item.averageDuration.unit || '' : '', })), range: rangeView(raw), missing: reader.missing, } } /** 多维分析载荷 → 领域对象。 */ export function toEventAnalysis(raw) { const reader = fieldReader(raw, 'eventAnalysis') const dimensions = reader.raw.dimensions const source = Array.isArray(dimensions) ? Object.fromEntries(dimensions.map((dimension) => [dimension.dimensionCode, dimension.buckets || []])) : dimensions || reader.raw const dimensionRows = (key) => (source[key] || []).map((bucket) => ({ code: bucket.bucketCode, label: bucket.bucketLabel || bucket.bucketCode, count: numberish(bucket.count) || 0 })) const highlights = list(raw, 'highlights').map((item) => { const row = fieldReader(item, 'highlight') return { title: row.optionalStr('title') || row.optionalStr('name'), detail: row.optionalStr('detail') || row.optionalStr('description'), count: numberish(row.raw.count) } }) return { types: Array.isArray(dimensions) ? dimensionRows('EVENT_TYPE') : countRows(source, 'types').concat(countRows(source, 'byType')), levels: Array.isArray(dimensions) ? levelRows(dimensionRows('LEVEL')) : levelRows(source.levels || source.byLevel), industries: Array.isArray(dimensions) ? dimensionRows('INDUSTRY') : countRows(source, 'industries').concat(countRows(source, 'byIndustry')), regions: Array.isArray(dimensions) ? dimensionRows('DISTRICT') : countRows(source, 'regions').concat(countRows(source, 'byRegion')), highlights, range: rangeView(raw), missing: reader.missing, } } function trendRows(rows) { const list = rows || [] const dates = new Set(list.map((row) => String(row.bucket || '').slice(0, 10))) return list.map((row) => { const bucket = String(row.bucket || '') const matched = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/.exec(bucket) const label = matched ? dates.size === 1 && matched[4] ? `${matched[4]}:${matched[5]}` : `${matched[2]}/${matched[3]}` : bucket return { key: row.bucket, label, count: row.count, displayText: row.unit ? formatDuration(row.count, row.unit) : '' } }) } function historyTrendRows(rows = []) { if (rows.length <= 48 || !rows.every((row) => /^\d{4}-\d{2}-\d{2}/.test(String(row.bucket)))) return trendRows(rows) const days = new Map() for (const row of rows) { const day = String(row.bucket).slice(0, 10) days.set(day, (days.get(day) || 0) + (Number(row.count) || 0)) } return trendRows([...days].map(([bucket, count]) => ({ bucket, count }))) } /** * 历史事件清单块(票 61)。 * * 两种分页形态由 `data.paging` 决定,**不在这里猜**: * · `mode:'server'`(单窗,近 7 天):`events` 就是后端返回的当前页, * 块只带 `serverPage` 元数据,翻页由 store 重新请求(PanelBlocks 只画控件); * · `mode:'merged'`(多窗,近 30/90 天):`events` 是已加载合并集合, * 块声明 `paginate` 由渲染层切窗口;页脚如实说明合并了几个窗口/加载了多少条 * (多窗不存在全局页,不伪造页码)。 * 没有 `paging` 元数据(旧 mock/旧载荷)时退化为普通清单块,不画翻页控件。 */ function historyEventListBlock(current) { const events = current.events || [] const rows = events.map((item) => ({ key: item.code, title: item.name || item.code, tone: eventLevelTag(item.level).tone, toneLabel: eventLevelTag(item.level).label, meta: [item.code, item.eventTime, item.deviceName, item.status].filter(Boolean), points: [], })) const emptyText = '当前时间范围没有历史事件' const paging = current.paging && typeof current.paging === 'object' ? current.paging : null if (!paging) return listBlock('历史事件', rows, emptyText) if (paging.mode === 'server') { return listBlock('历史事件', rows, emptyText, { serverPage: { page: paging.page, pageCount: paging.pageCount, total: paging.total }, }) } return listBlock('历史事件', rows, emptyText, { paginate: true, // 页码归属键:范围/族一变就复位(条文 5/6;标量键,不接受对象身份)。 // 范围键由请求侧带出(后端回显的 range.type 是 TREND_WINDOW,不能当预设范围用)。 pageScope: `${current.mode || ''}|${paging.rangeKey || ''}`, pageNote: `已合并 ${paging.windowCount || 0} 个时间窗 · 已加载 ${paging.loaded || 0} 条`, }) } export function historyAnalysisPanel(data, section) { const current = data || {} return sectionView('historyAnalysis', section, [ // 2026-10-13 图表选型:时间趋势用折线。 trendBlock((current.trend || []).length > 48 ? '历史事件趋势 · 每日合计' : '历史事件趋势', historyTrendRows(current.trend || []), { chart: 'line' }), historyEventListBlock(current), ], { label: '历史分析', range: current.range, rangeNote: current.range && current.range.start ? `时间范围:${current.range.start} 至 ${current.range.end}` : '', }) } /** * 预警效率面板的**标题**(票 65):随预警/报警切换一起换,词表复用 * `eventListSwitchView` 的单一词条(「预警」/「报警」),不新增第二张表。 * 外壳(面板外框标题)与本面板的内部标题都读它,两处由构造一致。 */ export function responseEfficiencyTitle(mode) { return `${eventListSwitchView(mode).label}效率` } /** * 预警效率面板的**口径说明**(票 65,预警族)。 * * 背景(2026-10-21 复核取证,维护者要求「确认预警数据是否正确」): * 后端 `CockpitHandlingEfficiencyService` 的 `stateCounts` 来自 `scopeWorkOrders(...)`, * 其源码注释原句是「范围内**全部工单**(不要求关联报警/事件)」——即处置率/状态分布/ * 结案时长都是**工单**口径,与事件族无关。实测两族同样结构: * 预警族 `linkedWorkOrderCount=0`、八态合计 0。因此**预警族确实没有预警自身的处置流程统计**。 * * 这段文字是要给值班人员看的,所以修掉上一版的两处毛病: * ① 「本端点」是接口词汇(项目纪律:接口词汇不上大屏)→ 改成说「后端/当前数据」; * ② 前半句先讲「我们没有字段」,读者要读两遍才知道面板上的数是什么 * → 改成先讲**面板上这些数是什么、能信什么**,再讲为什么没有工单口径。 */ export const WARNING_EFFICIENCY_GAP = '预警不挂工单,所以这里不画工单口径的处置率、工单状态分布与结案时长;' + '下方的条数、等级分布与发生趋势都是本窗口内预警自身的真实读数。' + '预警的处置流程统计(如「预警→处置动作」流转)后端尚未提供,已在需求池登记,上线后此处会自动换成预警自身的口径' /** * 预警族 KPI:预警自身可证实的读数(票 65)。 * 「已关联/未关联工单」是**真实计数**(预警族实测 0 / 全部),不是 0% 假读数。 * 读数缺失走 `valueText` 的占位符(票 22/42 纪律:不把「没有」画成 0)。 */ function warningEfficiencyKpis(current) { return kpiBlock([ { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' }, { key: 'warningLinked', label: '已关联工单', value: valueText(current.linkedWorkOrderCount), unit: '条', hint: '预警不挂工单:该读数是事件回联到工单的实况' }, { key: 'warningMissing', label: '未关联工单', value: valueText(current.missingWorkOrderCount), unit: '条', hint: '预警不挂工单:没有回联工单的预警条数' }, ]) } /** 报警族 KPI:工单口径(工单是报警的处置手段 —— 票 41/42/52 既有裁定,本票不动)。 */ function alarmEfficiencyKpis(current) { return kpiBlock([ { key: 'handlingRate', label: '处置率', value: handlingRateView(current), hint: handlingRateHint(current) }, { key: 'responseDuration', label: '平均响应时长', value: durationView(current, ['responseDuration']) }, { key: 'closeDuration', label: '平均关闭时长', value: durationView(current, ['closeDuration']) }, ]) } /** * 响应效率(「预警效率」)面板(票 19;**票 65 两族语义分流**)。 * * 预警族与报警族的处置对象根本不同:**工单是报警的处置手段**(票 41/42/52), * 而预警不挂工单。所以同一个端点返回的工单口径字段只在报警模式下有意义; * 预警模式下照画就是「用一个不存在的对象统计预警」(2026-10-19 维护者反馈第 6 条)。 * * `extra.mode` 是当前切换族(外壳从 `eventStore.mode` 传入);`data.family` 是后端回显, * 两者都有时以**回显**为准(那份数据确实是哪一族就以哪一族画)。都没有时按报警口径 * (工单口径是既有形态,不因本票对旧调用点改造)。 * * @param {object|null} data `toHandlingEfficiency()` 的领域对象 * @param {object} section 分区 * @param {{mode?: string}} [extra] 当前切换族 */ export function responseEfficiencyPanel(data, section, extra) { const current = data || {} const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM if (family === EVENT_FAMILY.WARNING) { // 发生趋势与历史分析同一条「多桶→按日合并」口径(报复用,不另写一份采样)。 const trend = historyTrendRows(current.eventTrend || []) return sectionView('responseEfficiency', section, [ warningEfficiencyKpis(current), barsBlock('预警等级分布', current.levelDistribution || []), trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }), ], { label: responseEfficiencyTitle(EVENT_FAMILY.WARNING), range: current.range, // 口径说明(走票 56 的折叠摘要,`data-panel-note`):说清「预警不挂工单」与契约缺口。 note: WARNING_EFFICIENCY_GAP, rangeNote: warningRangeNote(current), }) } return sectionView('responseEfficiency', section, [ alarmEfficiencyKpis(current), // 2026-10-13 图表选型:工单状态是多类别占比 → 百分比堆叠条(饼图超过 5 类不再适合)。 stackedBlock('工单状态分布', current.workOrderStates || []), // 时长趋势仍是时间序列 → 折线。 trendBlock('平均结案时长趋势', trendRows((current.trend || []).filter((row) => row.count !== null)), { chart: 'line' }), // 票 65 追加:报警模式下直接列出当前工单(卡片,可点开详情)。 // 放在图之后:读者先看结论(处置率/分布),再看逐张明细。 workOrderListBlock(extra), ], { label: responseEfficiencyTitle(EVENT_FAMILY.ALARM), range: current.range, // 报警族口径说明:工单是报警的处置手段(与预警族那句并列,切换时一起换)。 note: ALARM_EFFICIENCY_BASIS, rangeNote: current.rangeNote === undefined ? (current.denominator === null || current.denominator === undefined ? '' : `可计入工单 ${current.denominator} 张 · 已结案 ${current.numerator === null ? '—' : current.numerator} 张`) : current.rangeNote, }) } /** * 报警族的「当前工单列表」块(票 65)。 * * **数据来自另一个端点**(`GET /api/cockpit/v1/work-orders`),不是 `handling-efficiency`: * 后者只给工单**状态计数**、不返回工单实体(实测报警族八态合计 0,但设备维度能查到 7 张工单)。 * 所以这个块的行来自 `extra.workOrderList`(store 单独取一次)。 * * **两处口径必须是同一批工单**(2026-10-22 维护者反馈「列表没按时间筛」后对齐): * 上方「工单状态分布」的分母来自 `CockpitHandlingEfficiencyService.scopeWorkOrders(...)`, * 那里按 `create_time` 落在窗口内;因此本列表也按 `create_time` + 同一时间窗过滤 * (window 由 store 从 `activeConditions()` 传下去)。这样两处统计的是同一批工单, * 不会再出现「分布说 0 张、列表列出 7 张」的自相矛盾。 * * 三条如实处理: * · 还没取到 → 不给假空块,`emptyText` 说「正在取数」; * · 取到但是 0 张 → 说「当前没有工单」(这是真实业务结论),不是「加载失败」; * · 取失败 → 说「工单列表未取到」,与「确实没有」是两句不同的话(票 11/18 分区纪律)。 * * @param {{workOrderList?: object}} [extra] store 传下来的工单列表分区(`sections.workOrderList`) */ function workOrderListBlock(extra) { const section = (extra && extra.workOrderList) || null const loaded = !!section && section.loaded === true const data = (section && section.data) || null const orders = (data && data.orders) || [] const paging = (data && data.pagination) || null // 票 67:工单列表走全量工单端点,它**不收行业入参**(实测:非法行业也 200、响应逐字节相同), // 所以切行业时上方「工单状态分布」会变、本列表不变。不说明就是一条看不出来的假象 // (同一屏两个数各说各话,正是维护者反馈第 8 条抱怨的形态)。措辞归 domain/filterCaliber.js。 const industryNote = industryGap('workOrderList') const pageNote = (text) => [text, industryNote].filter(Boolean).join(' · ') if (!loaded) { return listBlock('当前工单列表', [], '正在取数…', { pageNote: pageNote('按创建时间落在此窗口,与上方「工单状态分布」是同一批工单') }) } if (section.state !== 'success' && section.state !== 'stale' && orders.length === 0) { // 失败/不可用:明确说没取到,不画成「0 张工单」。 return listBlock('当前工单列表', [], '工单列表未取到(其余读数不受影响)', { pageNote: pageNote('按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'), }) } return listBlock('当前工单列表', workOrderCardRows(orders), '当前没有工单', { serverPage: paging ? { page: paging.page, pageCount: paging.totalPages, total: paging.total } : undefined, pageNote: pageNote(paging ? `本窗口内创建的工单共 ${paging.total} 张 · 与上方「工单状态分布」是同一批工单(均按创建时间落在所选范围)` : '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单'), }) } /** * 工单 → 列表卡片行(票 65 精简形态)。 * * 字段选择只取「一眼判断该不该点开」的信息:工单号、状态、设备、描述、创建时间。 * 行字段名**贴着共享渲染层已认的形状**(`code`/`name`/`tone`/`toneLabel`/`meta`): * `PanelBlocks` 的 list 块见到 `row.code` 就把整张卡做成可点(点击 emit `open-card`), * 见到 `tone`+`toneLabel` 就画状态标签 —— 所以这里不再发明第二套卡片协议。 * * 文本一律复用 `equipmentDetailView.workOrderRows`(单一词表:状态字典 0–7、 * 关联文本、时间短读法都在那儿定过一次),本函数只做**卡片需要的再包装**。 * * @param {Array} orders `toWorkOrder()` 的领域对象数组 */ export function workOrderCardRows(orders) { const rows = workOrderRows(orders) return rows.map((row) => ({ key: row.code, // `code` 是渲染层的可点契约(点开走票 12 的工单时间线)。 code: row.code, // 卡片抬头就是工单号:值班人员按编号对工单,不按故障描述认。 title: row.code, name: row.code, // 色键必带文字(票 06:标签不能只靠颜色)——文案取自 workOrder.js 的词表。 tone: workOrderStateTone(row.stateCodeNumber), toneLabel: row.stateName, stateCode: row.stateCodeNumber, stateName: row.stateName, // 副信息行:来源 / 创建时间 / 关联实况(都有才画,缺的自动不占位)。 meta: [ row.typeName, row.createTime ? `创建 ${row.createTime}` : '', row.associationText, ].filter(Boolean), // 描述可能很长(实测有「监测报警,当前值:14.60(阈值:-~5),请现场核实处理」): // 列表里由 EllipsisText 省略,完整值在时间线弹窗里看 —— 卡片不为一段长文本撑高整块。 description: row.description, reason: row.description, })) } /** * 工单状态 → `StatusTag` 的色键(票 65 卡片用)。 * * **不登记第二张状态词表**:词(待派单…)一律走 `workOrder.js` 的 `WORK_ORDER_STATES`, * 这里只回答「这一态借哪一枚既有色键上色」。 * * 为什么映射到既有键而不是新增键:`StatusTag` 的 tone 是**封闭词表** * (`domain/statusTones.js` 的 `TONES`,未知 tone 会退回中性灰), * 新增 `state-closed` 之类必须同时改那张表与样式,而那属于视觉批次; * 本票只需「一眼看出这张工单是等着办、在办还是已了」,借用四档语义色即可: * · 待派单/待接单 → `lv1`(最需立刻响应,借一级告警色); * · 已接单/处理中/待验收 → `lv3`(在办,借三级信息色); * · 延期中 → `lv2`(旁路,需要盯); * · 已结案 → `lv4`(了结,借正常色); * · 已取消/认不出 → `neutral`(中性,不暗示任何态度)。 */ export function workOrderStateTone(stateCode) { if (stateCode === null || stateCode === undefined) return 'neutral' const state = WORK_ORDER_STATES.find((item) => item.code === stateCode) if (!state) return 'neutral' if (state.code === 0 || state.code === 1) return 'lv1' if (state.code === 6) return 'lv2' if (state.terminal && state.code === 5) return 'lv4' if (state.terminal) return 'neutral' return 'lv3' } /** 报警族的口径说明(与预警族那句成对:切换时口径说明一起换)。 */ export const ALARM_EFFICIENCY_BASIS = '报警以工单为处置手段:处置率=已结案工单 ÷ 可计入工单(状态 0–6,排除已取消),单位为工单张数' /** * 预警族的窗口读数(票 65):条数与工单关联实况都取**本窗口**的计数。 * 后端未给任何一项时回空串(不编造「0 条」——那是在说一件没被告知的事)。 * 等级分布来自**已返回的事件行**,分页未取全时如实说明分母不全(不拿半份冒充全量)。 */ function warningRangeNote(current) { const parts = [] if (numberish(current.totalEvents) !== null) parts.push(`本窗口预警 ${current.totalEvents} 条`) if (numberish(current.linkedWorkOrderCount) !== null) parts.push(`已关联工单 ${current.linkedWorkOrderCount} 条`) if (numberish(current.missingWorkOrderCount) !== null) parts.push(`未关联工单 ${current.missingWorkOrderCount} 条`) if (current.levelDistributionComplete === false) parts.push('等级分布仅统计已返回的事件页,非全量') return parts.join(' · ') } export function eventAnalysisPanel(data, section) { const current = data || {} const highlights = (current.highlights || []).map((item, index) => ({ key: `${item.title || '重点'}-${index}`, title: item.title || '重点识别', meta: [item.detail, item.count === null ? '' : `事件 ${item.count} 条`].filter(Boolean), points: [], })) return sectionView('eventAnalysis', section, [ // 2026-10-13 维护者反馈:条形图四连排视觉疲劳——类别少的构成改环形图(行业=行业色、 // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。 // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序); // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。 donutBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count)), donutBlock( '按行业分布', sortByCountDesc( (current.industries || []).map((row) => ({ ...row, actionKey: row.key || row.code })), (row) => row.count, ), { action: 'industry' }, ), barsBlock('按事件等级', current.levels || []), barsBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count)), listBlock('重点识别', highlights, '当前没有识别到重点'), ], { label: '预警分析', range: current.range, }) } export const WARNING_RESPONSE_PANEL_BUILDERS = { historyAnalysis: historyAnalysisPanel, responseEfficiency: responseEfficiencyPanel, eventAnalysis: eventAnalysisPanel, }