| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604 |
- /**
- * 预警处置模式的三块聚合面板(票 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,
- }
|