| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708 |
- /**
- * 基础设施模式四个二级面板的**领域与视图层**(票 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)
- }
|