/** * 基础设施模式四个二级面板的**领域与视图层**(票 17)。 * * 四块面板:基础档案 `archive` / 运行信息 `operation` / 管理信息 `management` / 应急保障 `emergency`。 * 本模块做两件事,两件都是纯函数(不 import service、不碰 DOM、不自己发请求): * 1. **契约适配**:后端四个面板级聚合读模型的载荷 → 领域对象(字段改名、缺失记账、码翻人话); * 2. **视图计划**:领域对象 → 组件可以直接画的块(KPI / 分布 / 键值行 / 条目列表 / 可定位对象)。 * * 票 18 起,**「块长什么样」与「码怎么翻」搬到 `domain/panelBlocks.js`**(票 17 判定四块共用一层 * 视图计划,票 18/19/20 沿用同一形状 —— 于是"构造一个块"只有一份实现,各票只写"放哪些块")。 * 本文件保留票 17 的全部对外导出名(含从 `panelBlocks.js` 转出的那几个), * 所以票 17 的 `verify/t17-infrastructure-panels.mjs` 一行都不用改。 * * 后端形状依据(`E:\pipe-ner-server`,逐类读过响应类与演示载荷): * `CockpitInfrastructureBaseArchivesResponse` / `...OperatingInformationResponse` / * `...ManagementInformationResponse` / `...EmergencySupportResponse`, * 演示载荷 `docs/cockpit/infrastructure-*-demo.html`。 * 注意四个切片的 BigDecimal 在演示载荷里是**字符串**(`"0.32"`),所以本层用 `numberish` * 宽进严出:数字与数字串都认,真 0 是有效值,认不出的回 null(占位符)。 */ import { fieldReader } from './fields.js' import { DETAIL_PLACEHOLDER } from './detailFlow.js' import { collectMissing, valueText } from './equipmentDetailView.js' import { industryLabel, normalizeDistrict, districtLabel } from './filterOptions.js' import { barsBlock, CARD_ACTION, childHasContent, codeText, dimensionBarsBlock, donutBlock, kpiBlock, listBlock, locateEntries, numberish, numberText, panelLocateEntries, panelMissingNote, ratioText, regionText, rowsBlock, sortByCountDesc, sectionHasContent, sectionPlaceholder, sectionPending, supplementalBlock, } from './panelBlocks.js' // 票 18:这些工具已搬到 `panelBlocks.js`,这里**原样转出**,票 17 的既有导入点不受影响。 export { codeText, numberish, numberText, panelLocateEntries, ratioText, regionText, sectionHasContent, sectionPlaceholder, sectionPending, } /** 基础设施模式的四个 panelId(与 `domain/panelCatalog.js` 的目录一致,顺序即界面顺序)。 */ export const INFRASTRUCTURE_PANEL_IDS = ['archive', 'operation', 'management', 'emergency'] /** 码 → 色键(负荷状态用语义色,让"超载"在扫一眼时就能看见;认不出的回中性)。 */ function loadTone(loadState) { if (loadState === 'OVERLOAD') return 'lv1' if (loadState === 'WATCH') return 'lv2' if (loadState === 'NORMAL') return 'lv4' return 'neutral' } // ================================================================ 契约适配 /** * 分布行 → 视图行。 * * 注意 `Distribution` 是**三类维度共用**的一个结构(`category` / `industry` / `area` 三选一有值), * 所以取键的字段名必须按维度给,不能一律读 `code` —— 后端 `industryDistribution` 的行里 * `code` 是 null、值在 `industry` 上(见 `CockpitInfrastructureEmergencySupportService` * 的三个构造函数)。 * @param {Array} list 后端分布数组 * @param {string} keyField 该维度取值的字段名(`code` / `industry` / `area`) * @param {(value: string) => string} [toLabel] 取值 → 展示名 */ function distributionRows(list, keyField, toLabel) { return (Array.isArray(list) ? list : []).map((item) => { const reader = fieldReader(item, 'distribution') const value = reader.optionalStr(keyField) || reader.optionalStr('code') return { code: value, label: toLabel ? toLabel(value) : codeText(value), count: numberish(reader.raw.count) || 0 } }) } /** * 基础档案(`/infrastructure/base-archives`)→ 领域对象。 * 票 17 的四项:设施规模、行业分类、区域分布、档案摘要。 */ export function toBaseArchives(raw) { const reader = fieldReader(raw, 'baseArchives') const totals = fieldReader(reader.raw.totals, 'baseArchives.totals') const ageReference = fieldReader(reader.raw.ageReference, 'baseArchives.ageReference') return { updatedAt: reader.optionalStr('updatedAt'), rangeType: (reader.optionalObj('range') || {}).type || '', // 设施底数与里程(票 38:`totals.scale` 不再解析——真实后端恒 null,「规模」读数已删除) facilityCount: numberish(totals.raw.facilityCount), lengthKm: numberish(totals.raw.lengthKm), // 行业分类 / 区域分布 / 档案摘要(这三个分布的行都取值在 `code` 上) industries: distributionRows(reader.raw.industryDistribution, 'code', industryLabel), regions: reader.optionalList('districtDistribution').map((row) => ({ code: normalizeDistrict(row.district), label: districtLabel(row.district), count: numberish(row.count) })), ages: distributionRows(reader.raw.ageDistribution, 'code'), facilityTypes: (reader.raw.facilityTypeDistribution || []).map((item) => { const type = fieldReader(item, 'facilityType') return { code: type.optionalStr('code'), name: type.optionalStr('name') || type.optionalStr('code'), count: numberish(type.raw.count) || 0 } }), ageReference: { asOfDate: ageReference.optionalStr('asOfDate'), commissionedCount: numberish(ageReference.raw.commissionedCount), builtCount: numberish(ageReference.raw.builtCount), unknownCount: numberish(ageReference.raw.unknownCount), }, // 口径剔除:后端把"为什么没算进来"如实给出,界面照实说明(票 17 的"档案摘要"要能解释口径)。 excluded: { unknownTypeCount: numberish((reader.raw.excluded || {}).unknownTypeCount), typeKindMismatchCount: numberish((reader.raw.excluded || {}).typeKindMismatchCount), }, selectedIndustry: reader.optionalObj('selectedFilters') ? reader.raw.selectedFilters.industry || '' : '', missing: reader.missing, } } /** 单个运行指标 → 领域对象。 */ function toOperatingMetric(raw) { const reader = fieldReader(raw, 'operatingMetric') const freshness = fieldReader(reader.raw.freshness, 'operatingMetric.freshness') return { code: reader.optionalStr('metricCode'), name: reader.optionalStr('metricName') || reader.optionalStr('metricCode'), category: reader.optionalStr('category'), unit: reader.optionalStr('unit'), value: numberish(reader.raw.value), capacityValue: numberish(reader.raw.capacityValue), loadRatio: numberish(reader.raw.loadRatio), loadState: reader.optionalStr('loadState'), sampledAt: reader.optionalStr('sampledAt'), freshness: freshness.optionalStr('status'), ageSeconds: numberish(freshness.raw.ageSeconds), abnormal: reader.bool('abnormal', false), // 票 49 裁定:这里装的是**源设施编码**(水厂 plantCode / 泵站 stationCode / 管网 networkCode / // 排水设施 id),与一张图的监测点/设备编码空间结构性不重叠(后端将改名 `facilityCodes`)。 // 字段照契约透传(实测恒单元素),但**不再喂定位**——票 50 起运行信息面板不产定位入口。 pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean), missing: reader.missing, } } /** * 运行信息(`/infrastructure/operating-information`)→ 领域对象。 * 票 17 要求「供应、输送、承载能力和运行指标」—— 三类能力对应后端的 * `SUPPLY` / `FLOW` / `CAPACITY` 三个 `category`。(票 64:原措辞「指标占位」已按真实内容纠正。) */ export function toOperatingInformation(raw) { const reader = fieldReader(raw, 'operatingInformation') return { updatedAt: reader.optionalStr('updatedAt'), rangeType: (reader.optionalObj('range') || {}).type || '', rangeStart: (reader.optionalObj('range') || {}).start || '', rangeEnd: (reader.optionalObj('range') || {}).end || '', // 票 51:回显只留**码**(`{code,key,label}` 里 code 是稳定锚)—— 块选择用它定位对应块; // 未筛行业时后端回 null,这里如实回空串。 selectedIndustry: (reader.optionalObj('selectedIndustry') || {}).code || '', // 按行业分组:后端本来就是按行业聚合的(一个行业一块指标),这里保持后端顺序,不重排。 industries: reader .optionalList('industries') .map((item) => { const block = fieldReader(item, 'industryBlock') const industry = fieldReader(block.raw.industry, 'industryBlock.industry') return { code: industry.optionalStr('code'), label: industry.optionalStr('label') || industryLabel(industry.optionalStr('code')), state: block.optionalStr('state'), // 认不出的原因码回空串(接口词汇不上大屏,票 05/11 的教训)。 stateText: '', abnormalMetricCount: numberish(block.raw.abnormalMetricCount), metrics: (block.raw.metrics || []).map(toOperatingMetric), } }), excluded: { unknownIndustryCount: numberish((reader.raw.excluded || {}).unknownIndustryCount), nonFormalIndustryCount: numberish((reader.raw.excluded || {}).nonFormalIndustryCount), }, missing: reader.missing, } } /** 管理信息读模型:片区编码与行业计数,不消费已退役的机构/权属维度。 */ export function toManagementInformation(raw) { const reader = fieldReader(raw, 'managementInformation') const totals = fieldReader(reader.raw.totals, 'managementInformation.totals') return { updatedAt: reader.optionalStr('updatedAt'), facilityCount: numberish(totals.raw.facilityCount), equipmentCount: numberish(totals.raw.equipmentCount), districtIndustry: reader.optionalList('districtIndustry').map((item) => ({ district: normalizeDistrict(item.district), industry: typeof item.industry === 'string' ? item.industry : '', facilityCount: numberish(item.facilityCount), equipmentCount: numberish(item.equipmentCount), })), missing: reader.missing, } } /** 应急保障的一个条目 → 领域对象。 */ function toEmergencyItem(raw) { const reader = fieldReader(raw, 'emergencyItem') const associations = fieldReader(reader.raw.eventAssociations, 'emergencyItem.eventAssociations') return { code: reader.optionalStr('objectCode'), name: reader.optionalStr('objectName') || reader.optionalStr('objectCode'), category: reader.optionalStr('category'), industry: reader.optionalStr('industry'), area: reader.optionalStr('area'), pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean), warningCount: numberish(associations.raw.warningCount), alarmCount: numberish(associations.raw.alarmCount), workOrderCount: numberish(associations.raw.workOrderCount), missing: reader.missing, } } /** 应急保障的一个子分区(危险源 / 防护目标 / 应急资源各一块)。 */ function toEmergencySection(raw) { const reader = fieldReader(raw, 'emergencySection') return { state: reader.optionalStr('state'), stateReason: reader.optionalStr('stateReason'), items: (reader.raw.items || []).map(toEmergencyItem), } } /** * 应急保障(`/infrastructure/emergency-support`)→ 领域对象。 * 票 17 要求「危险源、防护目标和应急资源」—— 正好是后端的三个 section。 * **过期与不可用分得开**:后端 `UNAVAILABLE`(该段没取到)与 `EMPTY`(确实没有)是两回事。 */ export function toEmergencySupport(raw) { const reader = fieldReader(raw, 'emergencySupport') const totals = fieldReader(reader.raw.totals, 'emergencySupport.totals') const sections = fieldReader(reader.raw.sections, 'emergencySupport.sections') return { updatedAt: reader.optionalStr('updatedAt'), totals: { emergencyResourceCount: numberish(totals.raw.emergencyResourceCount), hazardSourceCount: numberish(totals.raw.hazardSourceCount), protectionTargetCount: numberish(totals.raw.protectionTargetCount), totalObjectCount: numberish(totals.raw.totalObjectCount), }, sections: { hazardSources: toEmergencySection(sections.raw.hazardSources), protectionTargets: toEmergencySection(sections.raw.protectionTargets), emergencyResources: toEmergencySection(sections.raw.emergencyResources), }, eventSummary: { warningCount: numberish((reader.raw.eventSummary || {}).warningCount), alarmCount: numberish((reader.raw.eventSummary || {}).alarmCount), workOrderCount: numberish((reader.raw.eventSummary || {}).workOrderCount), }, // 分布行按维度取键:行业族值在 `industry` 上,区域族值在 `area` 上(`code` 为 null)。 industries: distributionRows(reader.raw.industryDistribution, 'industry', industryLabel), areas: distributionRows(reader.raw.areaDistribution, 'area', regionText), selectedCategory: (reader.raw.selectedFilters || {}).category || '', missing: reader.missing, } } // ================================================================ 视图计划 /** * 基础档案面板视图。 * 验收四项:设施规模 / 行业分类 / 区域分布 / 档案摘要。 */ export function archivePanel(data, section) { const current = data || {} const blocks = [ // 票 38(第 2、4 条):删除「规模」KPI(真实后端 totals.scale 恒 null,是空读数); // 设施总数与管线里程回到**同一行**呈现 —— 渲染层不再有 lengthKm 独占整行的特判, // KPI 网格 auto-fit 让两项并排,不改变其它读数。 kpiBlock([ { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' }, { key: 'lengthKm', label: '管线里程', value: valueText(current.lengthKm), unit: 'km' }, ]), // 2026-10-13 维护者反馈:条形图太多易疲劳——行业分类改环形图(行业语义色), // 片区/年限保留条形(条形适合作排名比较与多类别)。 // 票 37 走查扩展:行业是排名类别按计数降序;年限是**固定分桶**(<5/5–10/10–20/20+ // 是时间轴语义),降序会把时间轴打乱,保持后端字典序;片区按计数降序。 donutBlock('行业分类', sortByCountDesc(current.industries || [], (row) => row.count)), barsBlock('片区分布', sortByCountDesc(current.regions || [], (row) => row.count)), barsBlock('档案摘要 · 建成年限', current.ages || []), supplementalBlock(barsBlock('设施类型分布', sortByCountDesc((current.facilityTypes || []).map((row) => ({ code: row.code, label: row.name, count: row.count, })), (row) => row.count))), ] const reference = current.ageReference || {} blocks.push( rowsBlock('档案摘要 · 口径', [ { key: 'asOfDate', label: '统计基准日', value: valueText(reference.asOfDate) }, { key: 'commissionedCount', label: '已投产', value: valueText(reference.commissionedCount) }, { key: 'builtCount', label: '已建成', value: valueText(reference.builtCount) }, { key: 'unknownCount', label: '年限未知', value: valueText(reference.unknownCount) }, ]), ) return { name: 'archive', label: '基础档案', section, hasContent: sectionHasContent(section), blocks, // 基础档案端点**不给逐设施的编码与坐标**(只有聚合分布),所以本面板没有可定位对象。 locate: [], locateNote: '基础档案是聚合分布口径(后端不返回逐设施坐标),本面板没有可定位对象', missing: panelMissingNote(current), placeholder: sectionPlaceholder(section), } } /** * 票 64:运行指标列表**页码归属的筛选条件标识**(翻页复位判据的输入,不是页码本身)。 * * 为什么要有这个串:规范条文 5/6 要求「条件变化 → 页码回第 1 页;定时刷新不复位」, * 而视图计划在这两种情况下**都会**重算出新块对象——按对象身份判条件变没变, * 会把每次刷新都读成换筛选(票 43 第四轮记过的同一条教训:别拿对象当键)。 * 所以由 domain 给一个跨刷新稳定的标量:当前行业块 + 全局片区键。 * 换行业/换片区 ⇒ 串变 ⇒ 渲染层清掉该块页码(回第 1 页);周期刷新(同一筛选)⇒ 串不变 ⇒ 页码保持。 * **切走再切回也算一次条件变化**(同样回第 1 页)——所以这个串只当**判据**用, * 不编进页码归属键:编进键会让切回时读到上次留下的旧槽位(条文 6 不许)。 * * `extra.scope` 由外壳传入(与台账徽标、饼图选中态同一个 extra 通道); * 缺失或不是字符串时回空串——不拿脏值造出一个会漂移的假作用域。 */ function operationPageScope(active, extra) { const district = extra && typeof extra.scope === 'string' ? extra.scope : '' return `${active.code || ''}|${district}` } /** * 票 73(维护者 2026-10-23 裁定 A):指标行标题带设施编码。 * * 为什么必须带:同页 10 行 `metricName` 全同(燃气首屏第 2–10 行连读数都是 1.00 m、 * `loadState`/`freshness` 也全同),行上唯一能分开它们的身份就是设施编码 * (票 49 裁定的 `pointCodes`,实测 6362 条恒单元素)。标题仍是**单源**:只在本函数拼, * 渲染层不拼第二份。缺编码时退回指标名——不留「 · 」悬空分隔符。 * * **编码在前、指标名在后**(维护者 2026-10-23 追加裁定):`.panel-item__title` 是单行 * nowrap + 共用省略,省略号切的是**文本尾部**——把 16 字符编码放在指标名后面, * 恰好把唯一可辨识的尾号切掉(票 73 首次实施实测反馈「最后几位隐藏,显示不直观」)。 * 编码放最前则无论栏多窄都先保住身份,指标名放后面(同页同名时它本就重复,被省略不损失信息)。 */ function metricTitle(metric) { const facilityCode = (metric.pointCodes || [])[0] || '' return facilityCode ? `${facilityCode} · ${metric.name}` : metric.name } /** * 运行信息面板视图。 * 验收四项:供应 / 输送 / 承载能力 + **运行指标**(票 64:原措辞「指标占位」是占位期遗留词, * 实际内容是真实指标清单;真实数据 6772 条/2.3 MB 已证伪「契约有界」,本块接入统一分页)。 */ export function operationPanel(data, section, extra) { const current = data || {} // 票 51:按**选中行业**选块,不按下标 —— 后端恒定按 GAS/WATER_SUPPLY/DRAINAGE/MANHOLE 四块返回, // 筛选只把未选中块置空(EMPTY/NO_METRICS)不删块、不改变块序,块序恒以燃气打头; // 只读 industries[0] 会把"选了供水/排水"说成"燃气没有指标"(数据就在同一响应里)。 // 定位用后端回显的 selectedIndustry(码);未筛行业时回显为空,保持既有"取第一块"的口径 // (与后端回显一致,不自己合并多块)。industryNote 与 metrics 都由这一个 active 推出 // —— 两处真相不漂移(票 50 后 locate 不再随 active 变化:定位入口整体撤下,恒为空)。 const industryBlocks = current.industries || [] const selected = current.selectedIndustry || '' const matched = selected ? industryBlocks.find((block) => block.code === selected) : null const active = matched // 回显的码在 industries 里定位不到(契约外形状)时,归因仍用回显自身(码翻人话),不说成"燃气"或"全部"。 || (selected ? { code: selected, label: industryLabel(selected) || selected, metrics: [] } : null) || industryBlocks[0] || { code: '', label: '', metrics: [] } const metrics = active.metrics || [] /** 按 category 取一类能力指标(验收的"供应/输送/承载"就是这三个 category)。 */ const byCategory = (code) => metrics.filter((metric) => metric.category === code) /** 一类能力的"当前读数":取该类第一条(后端同一类下通常只有一条主指标)。 */ const headline = (code) => { const found = byCategory(code)[0] if (!found) return { key: code, label: codeText(code), value: DETAIL_PLACEHOLDER, hint: '后端未提供该类指标' } return { key: code, label: codeText(code), value: numberText(found.value, found.unit), hint: found.capacityValue === null ? '无能力基准' : `能力基准 ${numberText(found.capacityValue, found.unit)} · 负荷 ${ratioText(found.loadRatio)}`, tone: loadTone(found.loadState), } } const blocks = [ kpiBlock([headline('SUPPLY'), headline('FLOW'), headline('CAPACITY')]), listBlock( // 票 64(维护者 2026-10-19 反馈第 2 条):块标题如实 —— 「指标占位」是占位期遗留词, // 这里一直是真实运行指标清单。 '运行指标', metrics.map((metric, index) => ({ // 票 64 顺带修的键缺陷(分页把它从「理论问题」变成「每帧都碰的问题」): // 旧键 `${active.code}|${metric.code}` 在真实载荷里**整块碰撞**——燃气 699 条 // 全是同一个 metricCode(太常片区燃气长度),699 行共用一个键, // 而清单卡是 keyed v-for(`:key="row.key"`)。实测翻页内容仍正确, // 但节点身份已经不存在(换筛选/刷新时靠下标兜底重排),所以加序号后缀 // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场), // 所以序号在同一条件下稳定。 key: `${active.code || ''}|${metric.code}|${index}`, // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。 title: metricTitle(metric), identityKind: CARD_ACTION.FACILITY, identityCode: (metric.pointCodes || [])[0] || '', identityReason: (metric.pointCodes || [])[0] ? '设施无详情' : '缺少设施编码,无法展开', tone: loadTone(metric.loadState), // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。 toneLabel: codeText(metric.loadState), meta: [ `当前 ${numberText(metric.value, metric.unit)}`, `能力 ${numberText(metric.capacityValue, metric.unit)}`, `负荷 ${ratioText(metric.loadRatio)}`, codeText(metric.loadState), codeText(metric.freshness), metric.abnormal ? '异常' : '', ].filter(Boolean), // 票 50:指标行**不产出定位点**(`points` 恒空)。`pointCodes` 实为源设施编码(票 49 裁定), // 不进一张图对象序列,画按钮就是「不可达做成常态」+ 撞名假阳性(PS001/PS002 会飞向 // 不相干的排水监测点)。「没有可定位对象就不画按钮」由这里的构造保证(应急保障三段 // 的既有形态),不再靠渲染层过滤。 points: [], })), '当前行业没有运行指标', // 票 64:本块接入统一分页(规范条文 1/2/4 + 跳页节)。**为什么这里必须声明**: // 同一个 list 渲染分支服务五处面板,其中管理信息明细(实测 9 行)与应急保障三段 // 是规范排除项或另有专票——只有 domain 逐块声明,渲染层才知道该不该切窗口。 // 行序不改(票 10/11 立场:前端不重排),分页只切展示窗口。 // `pageScope`=页码归属的筛选条件标识:换行业/换片区时它变,页码自然回第 1 页; // 定时刷新时它不变,页码保持(条文 6)。翻页算术与控件都在单源 // (`domain/pagination.js` + `PagerNav.vue`),本层只声明「这一块要分页、按什么条件归页」。 { paginate: true, pageScope: operationPageScope(active, extra) }, ), ] return { name: 'operation', label: '运行信息', section, hasContent: sectionHasContent(section), blocks, // 当前选中行业(后端回显为空时不写"未选择",那会把"全部行业"说成缺陷)。 industryNote: active.label ? `当前行业:${active.label}` : '当前行业:全部', // 票 64 第 4 条:如实说明数据来源与口径(票 49/50/51 既有裁定,不重开、不发明合并口径)。 sourceNote: '运行指标由后端按设施实体逐条生成(水厂/泵站/管网/排水设施四类适配器),一条指标对应一个设施;当前行业取后端回显,不跨行业合并', // 票 50:定位入口整体撤下(locate 恒空)。该面板的关联编码是**设施**编码、不进一张图对象序列, // 对它跑 `locateEntries` 就是「不可达做成常态 + 撞名假阳性」;正确的对象交互**在图上**—— // 管段/设施构件拾取走既有「重点对象局部增强」(pipeEnhancement,票 32),本票不动那条链路。 locate: [], locateNote: '指标关联的是设施编码(水厂/泵站/管网/排水设施),不在一张图的点位序列里,本面板没有可定位对象;管段与设施构件请在图上拾取(地下管线详情视图)', missing: panelMissingNote(current), placeholder: sectionPlaceholder(section), } } /** * 管理信息面板(票 38 第 6 条):片区 × 行业**图表化**。 * * 主图是**可切换维度的分类柱状图**(按片区 / 按行业两维,domain 各聚合一遍,切换纯展示、 * 不回后端);下方保留紧凑**卡片清单**(逐 片区×行业 行给设施/设备计数,图看不出的组合明细看它)。 * 只消费 `districtIndustry[]`;已退役的 `organizations`/`ownershipDistribution`/`unmaintained` 不再读取。 * 「责任区域」=片区别名(决策 06),不新增独立维度。 */ export function managementPanel(data, section) { const current = data || {} const rows = current.districtIndustry || [] const blocks = [ kpiBlock([ { key: 'facilityCount', label: '设施总数', value: valueText(current.facilityCount), unit: '处' }, { key: 'equipmentCount', label: '设备总数', value: valueText(current.equipmentCount), unit: '台' }, ]), // 主图:按片区 / 按行业切换的分类柱状图(主计数=设施数,设备数进悬停说明)。 dimensionBarsBlock('片区 × 行业', rows, { countOf: (row) => row.facilityCount, extras: { equipmentCount: 'equipmentCount' }, hintOf: (row) => `设备 ${valueText(row.equipmentCount)} 台`, unit: '处', emptyText: '当前范围没有片区与行业计数', dimensions: [ { key: 'district', label: '按片区', field: 'district', emptyLabel: '未分区', labelOf: (value) => districtLabel(value) }, { key: 'industry', label: '按行业', field: 'industry', emptyLabel: '行业未知', labelOf: (value) => industryLabel(value) || codeText(value) }, ], }), // 下方紧凑清单:片区×行业逐组合的设施/设备计数(图看合计,清单看明细)。 listBlock( '片区 × 行业明细', rows.map((row) => ({ key: `${row.district ?? 'UNASSIGNED'}|${row.industry}`, title: `${districtLabel(row.district)} · ${industryLabel(row.industry) || '行业未知'}`, meta: [`设施 ${valueText(row.facilityCount)} 处`, `设备 ${valueText(row.equipmentCount)} 台`], points: [], })), '当前范围没有片区与行业计数', ), ] return { name: 'management', label: '管理信息', section, hasContent: sectionHasContent(section), blocks, // 票 68(维护者 2026-10-19 反馈第 3 条):公开「这些数从哪来、覆盖什么范围、与别处为何对得上」。 // 三条必须说清(措辞纪律:只说业务含义与范围,**不出现接口字段名**——读者是值班人员): // 1. 计数来源:设施=基础设施档案目录(与「基础档案」**同一个目录、同一个适配器**, // 后端 BusinessCockpitInfrastructureBaseArchivesAdapter 同时实现基础档案与设施目录); // 设备=设备台账里能归入四个正式行业的监测设备; // 2. 「片区 × 行业」是什么:一行一个组合,两维不再拆第二遍; // 3. 与「基础档案」的设施总数**同源同口径**(2026-10-22 实测两处均 6776)—— // 同一批设施实体,不各自维护一套总数。 // 还要解释一处**看起来像缺陷的真实现象**:设施没有片区来源,设施全部落「未分区」, // 于是城南/城北会出现「有设备、没有设施」的行(票 34/38 片区契约迁移后的既知事实)。 sourceNote: '设施计数取自基础设施档案目录(与「基础档案」是同一份目录、同一批设施实体),设备计数取自设备台账中能归入燃气/供水/排水/井盖四类的监测设备;「片区 × 行业」一行就是一个组合,只有既属于该片区又属于该行业的对象才计入,两个维度不重复拆分。设施的片区取档案目录登记的片区(当前设施档案没有片区来源,所以设施都记在「未分区」),设备的片区取设备自身登记 —— 因此会出现「有设备、没有设施」的片区行。设施总数与「基础档案」同源,两处不各自维护一套。', locate: [], locateNote: '片区与行业计数为聚合口径,本面板没有可定位对象', missing: panelMissingNote(current), placeholder: sectionPlaceholder(section), } } /** * 应急保障三段 → 条目列表块(每段的 state 决定它是内容、空还是不可用)。 * * **票 63(批次五)**:三段接入统一分页(规范条文 1/2/4 的前端集合形态)。 * 真实数据已证明这不是空转——实测 `protectionTargets` **20 条**(两页)、 * `emergencyResources` 9 条、`hazardSources` 0 条;20 条那一段此前是一屏 20 张卡。 * * 三条纪律落在这里: * 1. **三段各自独立页码**:`pageScope` 带上段标识(`hazardSources`/`protectionTargets`/ * `emergencyResources`),翻危险源的页不会动到防护目标 —— 渲染层的页码槽按 * 「面板名 + 块标题」寻址,标题本来就不同,但把段码写进 `pageScope` 才是 * 真正按段复位的依据(换筛选时三段一起回第 1 页)。 * 2. **段内行次序不动**:`pagedRows` 只切窗口,不排序(票 10/11 立场)。 * 3. **定位按钮不受影响**:`points` 挂在被窗口化之后的行上,翻到第 2 页看到的是 * 第 11–20 条的定位按钮,行为与翻页前完全一致。 * * `stateText` 语义不变(`UNAVAILABLE`/`EMPTY` 分得开,且**优先于**空列表提示渲染)。 * @param {string} label 段标题(也是渲染层页码槽的键之一) * @param {object} child 该段的后端分区 * @param {string} emptyText 空态文案 * @param {string} sectionKey 段码(`sections` 上的键;用于 `pageScope` 与断言面) */ function emergencySectionBlock(label, child, emptyText, sectionKey) { const items = (child && child.items) || [] const rows = items.map((item) => ({ key: item.code, title: item.name, identityKind: CARD_ACTION.FACILITY, identityCode: item.code, identityReason: item.code ? '设施无详情' : '缺少设施编码,无法展开', meta: [ codeText(item.category), industryLabel(item.industry) || codeText(item.industry), regionText(item.area), item.alarmCount ? `报警 ${item.alarmCount}` : '', item.warningCount ? `预警 ${item.warningCount}` : '', item.workOrderCount ? `工单 ${item.workOrderCount}` : '', ].filter(Boolean), points: item.pointCodes.map((pointCode) => ({ key: `${item.code}|${pointCode}`, label: item.name, pointCode, objectCode: item.code, objectName: item.name, })), })) return { ...listBlock(label, childHasContent(child && child.state) ? rows : [], emptyText, { // 前端集合分页:数据已一次装齐,pager 只切窗口、零请求。 paginate: true, // 页码归属键=段码 + 全局条件:换行业/片区/类别时三段一起回第 1 页, // 定时刷新(条件不变)页码保持(条文 5/6)。标量键,不接受对象身份(票 43 教训)。 pageScope: emergencySectionScope(sectionKey, child), }), // 该段自己的成败(后端 `UNAVAILABLE` 与 `EMPTY` 分得开,界面也说得出区别)。 state: (child && child.state) || '', stateText: child && child.state === 'UNAVAILABLE' ? '该段数据未取到' : child && child.state === 'EMPTY' ? '该段当前没有对象' : '', // 断层面:段码 + 该段真实条数(走查与闸门按段取数,不靠标题字符串猜)。 sectionKey, total: rows.length, } } /** * 应急保障某一段的**页码归属键**(票 63)。 * * 只用**标量**:段码 + 该段收到的条数 + 筛选回显(类别/行业/片区)。 * 不把行内容/对象身份编进来——视图计划每次重算都是新对象,按对象记会让每次刷新 * 都读成"换了筛选"(票 43 第四轮记过的教训,票 64 又踩过一次)。 * 条数也进键:同一段的数据**变短**时(例如换类别后从 20 条降到 3 条), * 页码槽跟着换,越界自然回到第 1 页,不用另外写一条夹回逻辑。 * @param {string} sectionKey 段码 * @param {object} child 该段分区(提供条数与 state) */ function emergencySectionScope(sectionKey, child) { const count = ((child && child.items) || []).length return `${sectionKey || ''}|${count}|${(child && child.state) || ''}` } /** * 应急保障面板视图。 * 验收三项:危险源 / 防护目标 / 应急资源 —— 后端的三个 section 正好对应。 */ export function emergencyPanel(data, section) { const current = data || {} const sections = current.sections || {} const blocks = [ kpiBlock([ { key: 'hazardSourceCount', label: '危险源', value: valueText(current.totals && current.totals.hazardSourceCount), unit: '处' }, { key: 'protectionTargetCount', label: '防护目标', value: valueText(current.totals && current.totals.protectionTargetCount), unit: '处' }, { key: 'emergencyResourceCount', label: '应急资源', value: valueText(current.totals && current.totals.emergencyResourceCount), unit: '项' }, ]), emergencySectionBlock('危险源', sections.hazardSources, '没有危险源对象', 'hazardSources'), emergencySectionBlock('防护目标', sections.protectionTargets, '没有防护目标', 'protectionTargets'), emergencySectionBlock('应急资源', sections.emergencyResources, '没有应急资源', 'emergencyResources'), barsBlock('区域分布', sortByCountDesc(current.areas || [], (row) => row.count)), ] const allItems = ['hazardSources', 'protectionTargets', 'emergencyResources'].flatMap((key) => ((sections[key] || {}).items || []).map((item) => ({ item, key })), ) // 票 68(维护者 2026-10-19 反馈第 3 条):应急保障也要说清「这些对象从哪来」。 // 关联编码的说明是**条件句**(不只写当前实测):真实接口的三类对象目前一律不带编码 // (后端适配器对三类对象都下发空列表,票 49 已记),所以界面此刻没有可定位对象—— // 但这句话不能写成恒真的「没有编码」,否则后端日后真给出编码时文案就开始撒谎。 const anyPointCode = allItems.some(({ item }) => (item.pointCodes || []).length > 0) const pointCodeNote = anyPointCode ? '对象带的关联编码是设施编码(不是一张图的监测点编码),可据此在图上查找对应设施。' : '对象带的关联编码是设施编码(不是一张图的监测点编码);当前三类对象都没有带编码,所以本面板没有可定位对象。' return { name: 'emergency', label: '应急保障', section, hasContent: sectionHasContent(section), blocks, locate: allItems.flatMap(({ item }) => locateEntries(item.code, item.name, { code: item.code, name: item.name }, item.pointCodes), ), // 票 68 更正:原文案「关联点位可定位」在两处站不住——① 真实数据没有可定位对象(点了没东西); // ② 这些编码是**设施**编码(票 49 裁定),叫「点位」会把设施说成监测点。改为按实测条件如实说。 locateNote: anyPointCode ? '三类对象带的关联编码是设施编码(不是一张图的监测点编码),定位按该编码在图上查找对应设施' : '三类对象当前都没有关联设施编码,本面板没有可定位对象', // 票 68:三段各自的业务来源 + 关联编码语义 + 关联事件口径(三段各一句,逐句可核)。 // 措辞纪律:只说业务含义与范围,**不出现接口字段名**(读者是值班人员,不是接口调用方)。 sourceNote: [ '三类对象来自各自的业务台账、不合并:应急资源=设备台账里的监测设备;防护目标=燃气风险评估里登记的防护目标;危险源=隐患台账里能关联到设备的隐患(关联不到设备的不计入,所以这一段为空不等于隐患库为空)。', pointCodeNote, '关联事件=对象所在设备上尚未解除的预警、尚未关闭的报警与未结束的工单,按对象逐条加总(同一台设备的事件可能同时计在设备对象与它关联的危险源上),不是全城去重后的事件总数;只有能回联到设备的对象才有这类计数,防护目标不挂设备,它的关联事件恒为 0。', ].join(''), // 关联事件总量是后端给的口径,界面照实带出(不自己加总)。 eventNote: current.eventSummary ? `关联事件:预警 ${valueText(current.eventSummary.warningCount)} · 报警 ${valueText(current.eventSummary.alarmCount)} · 工单 ${valueText(current.eventSummary.workOrderCount)}` : '', missing: panelMissingNote(current), placeholder: sectionPlaceholder(section), } } /** panelId → 视图构造函数(本模式自己的四块)。 */ export const INFRASTRUCTURE_PANEL_BUILDERS = { archive: archivePanel, operation: operationPanel, management: managementPanel, emergency: emergencyPanel, } /** * 一个面板的视图。`data` 为 null(失败/空)时仍返回结构完整的视图(块为空), * 组件据此画占位说明 —— 「失败」不该让整块面板消失,那会连面板标题都没了。 */ export function infrastructurePanelView(panelId, data, section) { const build = INFRASTRUCTURE_PANEL_BUILDERS[panelId] if (!build) return null return build(data, section) } /** 收集一个面板视图里真实的字段缺失(含嵌套层,票 05 的 `missing` 清单口径)。 */ export function panelMissingFields(data) { return collectMissing(data) }