Переглянути джерело

feat(cockpit): 第15轮走查调整入库——面板图型换档、实体详情中文标签、口径弹层不出框

维护者2026-10-29第15轮走查的成套调整:
- 监测概览:设备类型分布由排行条改堆叠条,离线设备改环形对照,异常监测点改指标卡2x2
- 预警处置:预警状态分布改堆叠条、等级分布改环形、事件类型改排行条、成因改堆叠条
- 实体详情:字段标签去英文括注,取消「(code)」等展示噪声,设施状态色键导出复用
- 运行信息:卡片状态与详情同步设施状态码,档案面板删重复环形图
- 面板口径弹层:宽度改按面板自身计算并夹在边界内,修复1920视口下出框
- 排行条与统计条加粗统一,趋势块新增tall档
ChenJZ 1 день тому
батько
коміт
b196b4ade1

+ 13 - 6
src/modules/cockpit/components/CockpitPanel.vue

@@ -462,9 +462,12 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
 }
 
 /* 「?」口径摘要:挂在标题右侧、与状态灯并排;点击弹出完整说明(维护者 2026-10-29)。
-   弹层绝对定位在按钮下方,宽度上限按栏宽收,长句自然换行——不再占面板体一行。 */
+   弹层绝对定位在按钮下方;**宽度必须由面板自己定**(维护者第 15 轮走查:出框)——
+   面板栏宽只有 280–460px,之前写 clamp(220u, 26vw, 420u) 在 1920 视口下算出 499px,
+   再叠加右侧偏移就顶出面板边界。改成相对面板的百分比 + 硬上限,且左右夹在面板内。 */
+/* 注意**没有** position:relative:弹层的包含块必须是标题栏整条(.cockpit-panel__heading),
+   这样 100% 才是"面板宽度"。若挂在这个 18px 宽的按钮容器上,任何宽度单位都可能顶出面板。 */
 .cockpit-panel__summary {
-    position: relative;
     flex: none;
     display: inline-flex;
     align-items: center;
@@ -500,13 +503,16 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
 
 .cockpit-panel__summary-popover {
     position: absolute;
-    top: calc(100% + 6 * var(--u, 1px));
-    right: 0;
+    top: 100%;
+    right: calc(6 * var(--u, 1px));
     z-index: 20;
     display: grid;
     gap: calc(4 * var(--u, 1px));
-    width: clamp(calc(220 * var(--u, 1px)), 26vw, calc(420 * var(--u, 1px)));
-    max-height: calc(180 * var(--u, 1px));
+    /* 宽度只跟**面板**走:100% 现在是标题栏宽度(包含块已上移到 heading)。
+       左右各留 6u,保证弹层永远在面板边界内 —— 维护者第 15 轮走查的「出框」就是
+       旧版 26vw 在 1920 视口下算出 499px(面板栏只有 280–460px)造成的。 */
+    width: min(calc(340 * var(--u, 1px)), calc(100% - 12 * var(--u, 1px)));
+    max-height: calc(240 * var(--u, 1px));
     overflow-y: auto;
     padding: calc(8 * var(--u, 1px)) calc(10 * var(--u, 1px));
     border: 1px solid rgba(1, 171, 234, 0.4);
@@ -520,6 +526,7 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
     letter-spacing: 0;
     text-align: left;
     white-space: normal;
+    /* 维护者第 15 轮:宽度按面板夹取(见上),旧版 26vw 在 1920 下会顶出面板边界。 */
 }
 
 .cockpit-panel__summary-item.is-gap {

+ 67 - 20
src/modules/cockpit/components/PanelBlocks.vue

@@ -334,6 +334,7 @@
                     :class="{
                         'panel-trend--line': block.chart === 'line' && block.linePoints.length >= 2,
                         'panel-trend--compact': block.compact === true,
+                        'panel-trend--tall': block.tall === true,
                     }"
                     :style="block.tone ? { '--trend-tone': barColor(block.tone) } : null"
                     :data-trend-tone="block.tone || null"
@@ -2335,9 +2336,9 @@ onBeforeUnmount(() => {
    与缺省三列格子不同,这里换成两行网格:第一行三段(徽章/名称/计数),第二行条轨道跨两列。 */
 .panel-bars--rank .panel-bar {
     grid-template-columns: calc(18 * var(--u, 1px)) minmax(0, 1fr) auto;
-    row-gap: calc(4 * var(--u, 1px));
+    row-gap: calc(5 * var(--u, 1px));
     align-items: center;
-    padding: calc(3 * var(--u, 1px)) 0;
+    padding: calc(4 * var(--u, 1px)) 0;
 }
 
 .panel-bars--rank .panel-bar__rank {
@@ -2380,7 +2381,8 @@ onBeforeUnmount(() => {
 .panel-bars--rank .panel-bar__track {
     grid-column: 1 / -1;
     grid-row: 2;
-    height: calc(5 * var(--u, 1px));
+    /* 排行条同样加粗(5u→10u):两处共用一个高度档,才是"通用组件"而不是各写一套。 */
+    height: calc(10 * var(--u, 1px));
 }
 
 .panel-bar__count em {
@@ -2398,27 +2400,57 @@ onBeforeUnmount(() => {
 .panel-bar__track {
     position: relative;
     display: block;
-    height: calc(6 * var(--u, 1px));
-    background: linear-gradient(180deg, rgba(1, 171, 234, 0.1), rgba(1, 171, 234, 0.22));
-    box-shadow: inset 0 0 0 1px rgba(1, 171, 234, 0.12);
+    /* 维护者第 15 轮:条太细、质感廉价。轨道加高(6u→10u)+ 圆角端头 + 内阴影:
+       读数条更像"仪表",而不是一根细线。 */
+    height: calc(10 * var(--u, 1px));
+    border-radius: calc(5 * var(--u, 1px));
+    background: linear-gradient(180deg, rgba(1, 171, 234, 0.07), rgba(1, 171, 234, 0.16));
+    box-shadow:
+        inset 0 0 0 1px rgba(1, 171, 234, 0.14),
+        inset 0 2px 6px rgba(0, 0, 0, 0.45);
+    overflow: hidden;
 }
 
 /* 票 44(条段分割):删掉旧的外辉光 `0 0 8px` —— 辉光越过 2u 行距渗进相邻条,
    正是"条段之间没有明确分割"的直接原因。层次改由填充自身的顶部高光线 + 右端头帽给
    (::after 装饰层,pointer-events:none 不挡悬停;行 hover 的增亮反馈仍归 .panel-bar__fill 本体)。 */
+/* 填充:三段式渐变(起亮、中稳、尾深)+ 顶部高光 + 右端头帽,读出"有体积的条"。
+   色相仍走 --cyan 家族(语义色由 tone 决定后注入),不新造色值。 */
 .panel-bar__fill {
     position: relative;
     display: block;
     height: 100%;
-    background: linear-gradient(90deg, var(--cyan-d, #009de9), var(--cyan, #01abea));
+    border-radius: inherit;
+    background: linear-gradient(
+        90deg,
+        color-mix(in srgb, var(--cyan, #01abea) 62%, transparent) 0%,
+        var(--cyan, #01abea) 62%,
+        var(--cyan-hi, #00b8ff) 100%
+    );
+    box-shadow: 0 0 8px -2px color-mix(in srgb, var(--cyan, #01abea) 70%, transparent);
+    transition: filter 0.15s ease, box-shadow 0.15s ease;
 }
 
+/* 顶部高光(体积感):一条渐隐的白色薄层,只在条的上半部 */
 .panel-bar__fill::after {
     content: '';
     position: absolute;
-    inset: 0;
-    background: linear-gradient(180deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 55%);
-    box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.85);
+    inset: 0 0 auto 0;
+    height: 46%;
+    border-radius: inherit;
+    background: linear-gradient(180deg, rgba(255, 255, 255, 0.42), rgba(255, 255, 255, 0));
+    pointer-events: none;
+}
+
+/* 右端头帽:用一条竖线标出"条到这儿为止",比旧版满格白边克制 */
+.panel-bar__fill::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    width: calc(2 * var(--u, 1px));
+    background: rgba(255, 255, 255, 0.72);
     pointer-events: none;
 }
 
@@ -2726,6 +2758,14 @@ onBeforeUnmount(() => {
 .panel-donut--compact .panel-donut__label { font-size: calc(11.5 * var(--u, 1px)); }
 .panel-donut--compact .panel-donut__count { font-size: calc(12 * var(--u, 1px)); }
 
+
+/* 维护者第 15 轮:双线对照图在宽屏显得太挤,拉高一档(多线需要更大竖向空间才读得出走势)。 */
+.panel-trend--tall {
+    min-height: calc(96 * var(--u, 1px));
+    padding-top: calc(14 * var(--u, 1px));
+    padding-bottom: calc(6 * var(--u, 1px));
+}
+
 /* 维护者 2026-10-29:折线「紧凑」版 —— 给块标题留出上方呼吸空间,避免与子标题/点顶数值重叠;
    高度压在 52u,宽屏仍由 fitTrend 接管。 */
 .panel-trend--compact {
@@ -3009,17 +3049,19 @@ onBeforeUnmount(() => {
     line-height: 1.1;
 }
 
-/* 票 14:状态分布饼图。左图右例;未选中的扇区在"有选中"时降透明度(高亮当前选中)。 */
+/* 票 14:状态分布饼图;未选中的扇区在"有选中"时降透明度(高亮当前选中)。
+   维护者第 15 轮:改**上图下例**(左图右例在窄面板里图例被挤成一行一个词),图放大到 72u。 */
 .panel-pie {
     display: flex;
+    flex-direction: column;
     align-items: center;
-    gap: var(--space-3, 12px);
+    gap: calc(8 * var(--u, 1px));
 }
 
 .panel-pie__chart {
     flex: none;
-    width: calc(64 * var(--u, 1px));
-    height: calc(64 * var(--u, 1px));
+    width: calc(72 * var(--u, 1px));
+    height: calc(72 * var(--u, 1px));
 }
 
 .panel-pie__slice {
@@ -3033,8 +3075,11 @@ onBeforeUnmount(() => {
 }
 
 .panel-pie__legend {
+    /* 上图下例:图例横排铺满(每行 2 列),八桶也不会把面板拉得太长。 */
     display: grid;
-    gap: 1px;
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+    gap: calc(2 * var(--u, 1px)) calc(10 * var(--u, 1px));
+    width: 100%;
     margin: 0;
     padding: 0;
     list-style: none;
@@ -3368,12 +3413,14 @@ onBeforeUnmount(() => {
     color: var(--text-strong, #fff);
 }
 
-/* 条形图行:填充条增亮(行高太小,不位移)。 */
-.panel-bar__fill {
-    transition: filter 0.15s;
-}
+/* 条形图行:填充条增亮 + 光晕(行高已加粗到 10u,仍不位移,避免挤邻居)。 */
 .panel-bar:hover .panel-bar__fill {
-    filter: brightness(1.3);
+    filter: brightness(1.35);
+    box-shadow: 0 0 12px -2px color-mix(in srgb, var(--cyan, #01abea) 85%, transparent);
+}
+.panel-bar:hover .panel-bar__label,
+.panel-bar:hover .panel-bar__count {
+    color: var(--text-strong, #fff);
 }
 
 /* 共享详情浮层(票 88 第二版):坐标由 domain/chartTip.js 夹取后写成内联 left/top,

+ 23 - 22
src/modules/cockpit/domain/entityDetail.js

@@ -123,15 +123,16 @@ function normalizeAsset(identity, source) {
         devices: assetDevices(attributes),
         attachments: assetAttachments(attributes),
         sections: compactSections([
-            section('基本信息', [field('设施编码(code)', source.code || identity.id), field('设施类别(category)', facilityCategoryText(category)), field('运行状态(status)', status), field('数据更新时间(updatedAt)', source.updatedAt)]),
-            section('位置与归属', [field('位置描述(location)', location, true), field('所属片区(districtName)', source.districtName), field('坐标(coordinates)', coordinatesText(source.coordinates), true)]),
+            section('基本信息', [field('设施编码', source.code || identity.id), field('设施类别', facilityCategoryText(category)), field('运行状态', status), field('数据更新时间', source.updatedAt)]),
+            section('位置与归属', [field('位置描述', location, true), field('所属片区', source.districtName), field('坐标', coordinatesText(source.coordinates), true)]),
             section('业务详情', fields),
         ]),
     }
 }
 
-/** 设施状态 → 语义色键(0 正常=绿、1 维护中=黄、2 异常=红;认不出走中性)。 */
-function facilityStatusTone(status) {
+/** 设施状态 → 语义色键(0 正常=绿、1 维护中=黄、2 异常=红;认不出走中性)。
+ *  维护者第 15 轮:运行信息的指标行也要按设施状态着色,所以本函数对外导出(原先是模块私有)。 */
+export function facilityStatusTone(status) {
     const code = status === null || status === undefined ? '' : String(status).trim()
     if (code === '0') return 'lv4'
     if (code === '1') return 'lv2'
@@ -201,9 +202,9 @@ function codedText(value, dictionary, withCode = false) {
     return withCode ? `${label}(${code})` : label
 }
 
-/** 类别显示名:「中文(英文码)」;认不出的类别只回码。 */
+/** 类别显示名:一律中文(维护者 2026-10-29:取消英文括注)。 */
 export function facilityCategoryText(category) {
-    return codedText(category, FACILITY_CATEGORY_TEXT, true)
+    return codedText(category, FACILITY_CATEGORY_TEXT)
 }
 
 /** 设施状态(0 正常 / 1 维护中 / 2 异常)→ 中文;空值如实空。 */
@@ -228,20 +229,20 @@ export function facilityStatusText(status) {
 function pipesFields(a) {
     const drainage = String(a.networkType ?? '').trim() === '1'
     return [
-        { label: '管网类型(networkType)', key: 'networkType', format: (v) => codedText(v, NETWORK_TYPE_TEXT, true) },
+        { label: '管网类型', key: 'networkType', format: (v) => codedText(v, NETWORK_TYPE_TEXT) },
         // 二级分类=原来的"管网等级":后端 network_level 只用于区分排水管道(污水/雨水)。
         // 非排水管这个字段没有业务含义,按要求显示「—」而不是隐藏。
         {
-            label: '二级分类(networkLevel)',
+            label: '二级分类',
             key: 'networkLevel',
             always: true,
-            format: (v) => (drainage ? codedText(v, NETWORK_LEVEL_TEXT, true) : ''),
+            format: (v) => (drainage ? codedText(v, NETWORK_LEVEL_TEXT) : ''),
             invalidNote: drainage ? '' : '仅排水管道适用',
         },
         { label: '长度', key: 'lengthMeters', unit: '米' },
         { label: '管径', key: 'diameterMillimeters', unit: '毫米' },
         { label: '材质', key: 'material' },
-        { label: '管养单位(managementUnit)', key: 'managementUnit' },
+        { label: '管养单位', key: 'managementUnit' },
         { label: '建设日期', key: 'buildDate' },
         { label: '备注', key: 'remark', wide: true },
     ]
@@ -250,7 +251,7 @@ function pipesFields(a) {
 const ASSET_FIELD_ROWS = {
     PIPE_NETWORK: null, // 见 pipesFields():需要按管网类型决定二级分类是否适用
     WATER_PLANT: [
-        { label: '水源类型(waterSourceType)', key: 'waterSourceType' },
+        { label: '水源类型', key: 'waterSourceType' },
         { label: '设计能力', key: 'designCapacity', unit: '万m³/d' },
         { label: '当前能力', key: 'currentCapacity', unit: '万m³/d' },
         { label: '处理工艺', key: 'treatmentProcess', wide: true },
@@ -261,9 +262,9 @@ const ASSET_FIELD_ROWS = {
         { label: '备注', key: 'remark', wide: true },
     ],
     ENGINEERING_FACILITY: [
-        { label: '设施类型(facilityType)', key: 'facilityType', format: (v) => codedText(v, ENGINEERING_FACILITY_TYPE_TEXT, true) },
+        { label: '设施类型', key: 'facilityType', format: (v) => codedText(v, ENGINEERING_FACILITY_TYPE_TEXT) },
         { label: '设施等级', key: 'facilityLevel' },
-        { label: '管理单位(managementUnit)', key: 'managementUnit' },
+        { label: '管理单位', key: 'managementUnit' },
         { label: '建设日期', key: 'buildDate' },
         { label: '设计流量', key: 'designFlow', unit: 'm³/s' },
         { label: '设计扬程', key: 'designHead', unit: 'm' },
@@ -272,7 +273,7 @@ const ASSET_FIELD_ROWS = {
         { label: '备注', key: 'remark', wide: true },
     ],
     PUMP_STATION: [
-        { label: '泵站类型(stationType)', key: 'stationType' },
+        { label: '泵站类型', key: 'stationType' },
         { label: '水泵数量', key: 'pumpCount', unit: '台' },
         { label: '设计流量', key: 'designFlow', unit: 'm³/h' },
         { label: '设计扬程', key: 'designHead', unit: 'm' },
@@ -283,7 +284,7 @@ const ASSET_FIELD_ROWS = {
         { label: '备注', key: 'remark', wide: true },
     ],
     EMERGENCY_RESOURCE: [
-        { label: '设备类型(equipmentTypeId)', key: 'equipmentTypeId' },
+        { label: '设备类型', key: 'equipmentTypeId' },
         { label: '型号', key: 'model' },
         { label: '规格', key: 'specification', wide: true },
         { label: '制造商', key: 'manufacturer' },
@@ -292,18 +293,18 @@ const ASSET_FIELD_ROWS = {
         { label: '安装时间', key: 'installTime' },
         { label: '维护人', key: 'maintainer' },
         { label: '维护电话', key: 'maintainerPhone' },
-        { label: '权属单位(ownershipUnit)', key: 'ownershipUnit' },
+        { label: '权属单位', key: 'ownershipUnit' },
         { label: '设备图片', key: 'image' },
         { label: '备注', key: 'remark', wide: true },
     ],
     HAZARD: [
-        { label: '隐患类型(hazardType)', key: 'hazardType' },
+        { label: '隐患类型', key: 'hazardType' },
         { label: '隐患等级', key: 'level' },
         { label: '来源', key: 'source' },
         { label: '隐患描述', key: 'description', wide: true },
-        { label: '关联管网(networkId)', key: 'networkId' },
-        { label: '关联设备(equipmentId)', key: 'equipmentId' },
-        { label: '关联工单(workOrderId)', key: 'workOrderId' },
+        { label: '关联管网', key: 'networkId' },
+        { label: '关联设备', key: 'equipmentId' },
+        { label: '关联工单', key: 'workOrderId' },
         { label: '报告单位', key: 'reportUnit' },
         { label: '报告时间', key: 'reportTime' },
         { label: '整改方案', key: 'rectifyPlan', wide: true },
@@ -314,11 +315,11 @@ const ASSET_FIELD_ROWS = {
         { label: '备注', key: 'remark', wide: true },
     ],
     PROTECTION_TARGET: [
-        { label: '关联管网(networkId)', key: 'networkId' },
+        { label: '关联管网', key: 'networkId' },
         { label: '风险等级', key: 'riskLevel' },
         { label: '风险分值', key: 'riskScore', unit: '分' },
         { label: '风险颜色', key: 'riskColor' },
-        { label: '评估模型(assessmentModel)', key: 'assessmentModel' },
+        { label: '评估模型', key: 'assessmentModel' },
         { label: '评估时间', key: 'assessmentTime' },
         { label: '风险因素', key: 'riskFactors', wide: true },
         { label: '危险源', key: 'hazardSources', wide: true },

+ 11 - 6
src/modules/cockpit/domain/infrastructurePanels.js

@@ -20,6 +20,7 @@
  */
 import { fieldReader } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
+import { facilityStatusText, facilityStatusTone } from './entityDetail.js'
 import { collectMissing, valueText } from './equipmentDetailView.js'
 import { industryLabel, normalizeDistrict, districtLabel } from './filterOptions.js'
 import {
@@ -172,6 +173,8 @@ function toOperatingMetric(raw) {
         pointCodes: reader.optionalList('pointCodes').map((item) => String(item)).filter(Boolean),
         facilityId: reader.optionalStr('facilityId'),
         facilityKind: reader.optionalStr('facilityKind'),
+        // 维护者 2026-10-29:卡片状态必须与详情同步 —— 后端随指标下发设施状态码(0/1/2)。
+        facilityStatus: reader.optionalStr('facilityStatus'),
         missing: reader.missing,
     }
 }
@@ -336,8 +339,7 @@ export function archivePanel(data, section) {
         ]),
         // 构成 → 环形图(类别不多、看占比)。
         donutBlock('行业分类', industryRows, { layout: 'compact' }),
-        // 设施八类:先环后堆叠 —— 环给占比,堆叠条给"八类各多少"的一条连续读数。
-        donutBlock('设施类型分布', typeRows, { layout: 'compact' }),
+        // 维护者第 15 轮:环与堆叠条表达的是同一份八类构成(重复)—— 删环、留堆叠条。
         stackedBlock('设施类型构成', typeRows),
         // 对比分析 → 柱状条(片区之间比大小)。
         barsBlock('片区分布', regionRows),
@@ -523,13 +525,16 @@ export function operationPanel(data, section, extra) {
                 identityKind: CARD_ACTION.FACILITY,
                 identityCode: facilityDetailCategory(metric) ? metric.facilityId || '' : '',
                 identityCategory: facilityDetailCategory(metric),
-                identityPayload: { ...metric, category: facilityDetailCategory(metric), industry: metric.industryCode || scope.code },
+                identityPayload: { ...metric, category: facilityDetailCategory(metric), industry: metric.industryCode || scope.code, status: metric.facilityStatus },
                 identityReason: facilityDetailCategory(metric)
                     ? (metric.facilityId ? '' : '缺少设施主键,无法展开')
                     : '设施类别未发布,无法展开详情',
-                tone: loadTone(metric.loadState),
-                // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。
-                toneLabel: codeText(metric.loadState),
+                // 维护者 2026-10-29:卡片状态与详情**同一份**设施状态(正常/维护中/异常),
+                // 不再各显示各的。后端没给状态时退回负荷态标签(不编造设施状态)。
+                tone: metric.facilityStatus ? facilityStatusTone(metric.facilityStatus) : loadTone(metric.loadState),
+                toneLabel: metric.facilityStatus
+                    ? facilityStatusText(metric.facilityStatus) || codeText(metric.loadState)
+                    : codeText(metric.loadState),
                 meta: [
                     // 票 67:全部行业时逐行补行业名,否则清单看不出这一行属于哪个行业。
                     scope.allView ? metric.industryLabel || '' : '',

+ 92 - 49
src/modules/cockpit/domain/monitoringPanels.js

@@ -532,30 +532,26 @@ export function monitoringOverviewPanel(data, section) {
         //   · 缺值仍是占位符纪律:四态计数经 `numberish` 认不出回 null → 该行**整行不出**
         //     (见 `deviceStatusRows`),绝不把 null 交给 `donutBlock` 补成假 0(票 27 同类)。
         donutBlock('设备状态分布(按设备数)', deviceStatusRows(deviceStatus), { layout: 'compact' }),
-        barsBlock(
+        // 维护者第 15 轮:排行用得太多 —— 设备类型分布改**堆叠条**(一条读出八类各多少与占比),
+        // 与下方「各类型在线 / 离线」两张环图形成"总量 → 分状态"的层级。
+        stackedBlock(
             '设备类型分布',
-            // 票 37:按设备数**从多到少**降序;并列时保持后端给的次序(后端已排好则是恒等变换)。
             sortByCountDesc(
                 (current.typeDistribution || []).map((row) => ({
                     code: row.code,
                     label: row.name || codeText(row.code),
                     count: row.deviceCount || 0,
-                    // 当前生效的类型行:渲染层画选中态,用户据此知道"再点一下就取消"。
-                    selected: Boolean(row.code) && row.code === current.selectedDeviceType,
                 })),
                 (row) => row.count,
             ),
-            // 行可点 → 写全局类型筛选(复用票 20 立的 `panel-action` 接缝)。
-            // 只带得出后端类型码的行才可点:`code` 为空时 `barsBlock` 不给 `actionKey`,
-            // 那一行在渲染层就不是按钮 —— 不拿展示名冒充筛选码去换后端 400。
-            { action: 'deviceType', variant: 'rank' },
         ),
         supplementalBlock(donutBlock('各类型在线设备', (current.typeDistribution || [])
             .filter((row) => numberish(row.onlineCount) !== null)
             .map((row) => ({ code: row.code, label: row.name || codeText(row.code), count: row.onlineCount })), { layout: 'compact' })),
-        supplementalBlock(barsBlock('各类型离线设备', sortByCountDesc((current.typeDistribution || [])
+        // 与「各类型在线设备」同形态:两张环图并排读"在线 / 离线"的对照(排行反而看不出对照)。
+        supplementalBlock(donutBlock('各类型离线设备', (current.typeDistribution || [])
             .filter((row) => numberish(row.offlineCount) !== null)
-            .map((row) => ({ code: row.code, label: row.name || codeText(row.code), count: row.offlineCount })), (row) => row.count), { variant: 'rank' })),
+            .map((row) => ({ code: row.code, label: row.name || codeText(row.code), count: row.offlineCount })), { layout: 'compact' })),
     ]
 
     return {
@@ -608,6 +604,73 @@ function deviceTypeLabel(rows, code) {
  * @param {object} section 分区
  * @param {object} [extra] `{ statusDistribution, selectedStatuses }`(外壳给的共用数据与当前选中)
  */
+/**
+ * 异常监测点 → **指标卡 2×2**(维护者第 15 轮)。
+ *
+ * 为什么不再用横条:这组读数是「当前有多少个异常点位」的**结论型数字**,值班人员要的是
+ * 一眼读出四类各多少,而不是比条长;而且它原本挂在面板中段,被上面的覆盖分布压住了。
+ * 卡片顺序固定为 报警 / 预警 / 工单处置 / 离线(与地图顶部状态栏同一套语义顺序)。
+ *
+ * 缺值纪律不变:`extra.statusDistribution` 不可用时整块不出(调用点已判 available),
+ * 单个桶缺失按「未知」画占位符 —— 不把「没给」画成 0 个异常点。
+ */
+export function abnormalPointCards(counts) {
+    const source = counts && typeof counts === 'object' ? counts : {}
+    const rows = [
+        { key: 'ALARM', label: '报警', tone: 'lv1' },
+        { key: 'WARNING', label: '预警', tone: 'lv2' },
+        { key: 'WORK_ORDER', label: '工单处置', tone: 'lv3' },
+        { key: 'OFFLINE', label: '离线', tone: 'neutral' },
+    ]
+    const countOf = (key) => {
+        const raw = source[key]
+        if (raw === null || raw === undefined || raw === '') return null
+        const value = numberish(raw)
+        return value === null ? null : value
+    }
+    return rows.map((row) => ({
+        key: row.key,
+        label: row.label,
+        value: valueText(countOf(row.key)),
+        unit: '个',
+        valueTone: row.tone,
+        accent: row.tone,
+        caption: '全局未筛',
+    }))
+}
+/**
+ * 「口径 · 生效范围与总数」→ 面板摘要文案(维护者第 15 轮:并入摘要)。
+ *
+ * 为什么搬到摘要:这六条是**抬头读数**(现在按什么条件在看),不是图表,占一整块把面板撑长;
+ * 做过期/空态说明也已经在摘要里,同一族信息收在一处更好读。
+ * 措辞纪律不变:只说范围与口径,不出现接口字段名;缺值照占位符(不编「全部」)。
+ */
+function distributionScopeNote(current) {
+    const total = valueText(current.pagination && current.pagination.total)
+    const industry = current.selectedIndustry
+        ? industryLabel(current.selectedIndustry) || current.selectedIndustry
+        : '全部'
+    const district = districtFilterLabel({ district: current.selectedDistrict, districtFilter: current.districtFilter })
+    const deviceType = current.selectedDeviceType
+        ? deviceTypeLabel(current.deviceTypes, current.selectedDeviceType)
+        : '全部'
+    const pointStatus = current.selectedPointStatus
+        ? String(current.selectedPointStatus)
+              .split(',')
+              .map((code) => POINT_STATUS_FILTER_LABELS[code] || code)
+              .filter(Boolean)
+              .join('、')
+        : '全部'
+    const invalid = valueText(current.excluded && current.excluded.invalidCoordinateCount)
+    return [
+        '监测点总数 ' + total,
+        '生效行业 ' + industry,
+        '生效片区 ' + district,
+        '生效类型 ' + deviceType,
+        '生效状态 ' + pointStatus,
+        '坐标未通过校验 ' + invalid,
+    ].join(' · ')
+}
 export function monitoringDistributionPanel(data, section, extra = {}) {
     const current = data || {}
 
@@ -660,15 +723,15 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
         // 「这条长度量的是什么」;悬停行的 hint 仍给设备数(图与数同源,不另算一份)。
         // 条形/环形的分工沿用 2026-10-13 的既有结论(行业=类别少→环形;
         // 片区/类型=多类别排名→横条),本票不动图形形态,只把指代写清楚。
-        donutBlock('覆盖情况 · 行业(按点位数)', industryRows, { layout: 'compact' }),
-        barsBlock('覆盖情况 · 片区(按点位数)', regionRows, { variant: 'rank' }),
-        barsBlock('覆盖情况 · 设备类型(按点位数)', typeRows, { variant: 'rank' }),
-        // 维护者 2026-10-29:类型构成用堆叠条(占比一眼看完),与上面的排名称互补。
-        stackedBlock('设备类型构成(按点位数)', typeRows),
+        // 维护者第 15 轮:① 覆盖情况 · 片区与覆盖情况 · 行业统一成饼图(同族对照,形态一致才好比);
+        // ② 覆盖情况 · 设备类型与下面的「设备类型构成(按点位数)」内容重叠、意义也不清楚 —— 整块删除;
+        // ③「异常监测点」从横条改**指标卡 2×2**,并提到面板最顶部(它是值班人员最先要看的一组读数)。
         ...(extra.statusDistribution && extra.statusDistribution.available
-            ? [barsBlock('状态分布 · 异常监测点(全局未筛)', abnormalCountRows(
-                Object.entries(extra.statusDistribution.counts || {}).map(([status, count]) => ({ status, count })), 'point'))]
+            ? [kpiBlock(abnormalPointCards(extra.statusDistribution.counts || {}), { layout: 'two-by-two' })]
             : []),
+        donutBlock('覆盖情况 · 行业(按点位数)', industryRows, { layout: 'compact' }),
+        donutBlock('覆盖情况 · 片区(按点位数)', regionRows, { layout: 'compact' }),
+        stackedBlock('设备类型构成(按点位数)', typeRows),
         // 状态分布饼图:消费全局未筛分布并高亮当前选中;**不可点**(筛选入口只在顶部状态栏)。
         {
             kind: 'pie',
@@ -680,29 +743,6 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
             rows: pie.rows,
             note: pie.ready ? '' : STATUS_DISTRIBUTION_UNPUBLISHED,
         },
-        rowsBlock('口径 · 生效范围与总数', [
-            { key: 'pointTotal', label: '监测点总数', value: valueText(current.pagination && current.pagination.total) },
-            { key: 'industry', label: '生效行业', value: current.selectedIndustry ? industryLabel(current.selectedIndustry) || current.selectedIndustry : '全部' },
-            { key: 'district', label: '生效片区', value: districtFilterLabel({ district: current.selectedDistrict, districtFilter: current.districtFilter }) },
-            // 票 37:类型是全局筛选条件之一,生效范围必须与本模式其它条件一样看得见
-            // (否则读者只看到"类型分布少了几行",不知道条件从哪来、怎么撤)。
-            { key: 'deviceType', label: '生效类型', value: current.selectedDeviceType ? deviceTypeLabel(current.deviceTypes, current.selectedDeviceType) : '全部' },
-            { key: 'createdAt', label: TIME_LABELS.latestIngested, value: valueText(current.createdAt) },
-            // 票 14:状态多值化——回显是逗号串,逐桶翻人话;空 = 全部。
-            {
-                key: 'pointStatus',
-                label: '生效状态',
-                value: current.selectedPointStatus
-                    ? String(current.selectedPointStatus)
-                          .split(',')
-                          .map((code) => POINT_STATUS_FILTER_LABELS[code] || code)
-                          .filter(Boolean)
-                          .join('、')
-                    : '全部',
-            },
-            // 坐标准入被拦下的对象数:分布口径只统计可上图对象,这个数解释差值(票 07 的口径)。
-            { key: 'invalidCoordinate', label: '坐标未通过校验', value: valueText(current.excluded && current.excluded.invalidCoordinateCount) },
-        ]),
     ]
 
     return {
@@ -715,6 +755,10 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
         locateNote: '监测分布是聚合分布口径(后端不返回逐点位坐标),本面板没有可定位对象',
         // 范围筛选落点说明保留;「状态筛选不作用到一张图」的缺口说明已随票 14 的契约修订删除。
         gap: REGION_SCOPE_NOTE,
+        // 维护者第 15 轮:原「口径 · 生效范围与总数」块并入摘要(数据时间仍在,见下)。
+        note: distributionScopeNote(current) + (current.createdAt ? ' · ' + TIME_LABELS.latestIngested + ' ' + valueText(current.createdAt) : ''),
+        // 数据时间是唯一带「测量含义」的读数,单独留在摘要尾巴上,不丢。
+        rangeNote: '',
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }
@@ -819,7 +863,13 @@ export function operationOverviewPanel(data, section, extra = {}) {
             { key: 'alarmCount', label: '报警设备', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
             { key: 'abnormalCount', label: '总异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台', hint: '预警 + 报警 + 离线 + 过期' },
         ], { layout: 'three', emphasis: true }),
-        barsBlock('设备/指标异常统计', abnormalCountRows(current.metricStatuses, 'metric'), { variant: 'rank' }),
+        // 维护者第 15 轮:「设备/指标异常统计」与「异常态势 · 指标状态分布」是同一份六态数据的
+        // 两种画法(横条 + 横条)—— 合并成**一张饼图**,一次读出六态占比与读数。
+        donutBlock('指标状态分布(六态)', abnormalCountRows(current.metricStatuses, 'metric').map((row) => ({
+            code: row.key,
+            label: row.label,
+            count: row.count,
+        })), { layout: 'compact' }),
         // 维护者 2026-10-29:预警与报警合成**一张双线折线**(同一时间窗、同一量纲),并给块顶留出
         // 呼吸空间(compact 版式),避免与块标题/点顶数值重叠。两条线仍是两个独立数据源,
         // 只共享坐标轴与图例 —— 不改变任何统计口径。
@@ -831,20 +881,13 @@ export function operationOverviewPanel(data, section, extra = {}) {
         })), {
             chart: 'line',
             compact: true,
+            tall: true,
             aside: '双线对照:预警 / 报警',
             series: [
                 { key: 'warning', label: '预警', tone: 'lv2', counts: mergedTrend.warningCounts },
                 { key: 'alarm', label: '报警', tone: 'lv1', counts: mergedTrend.alarmCounts },
             ],
         }),
-        barsBlock(
-            '异常态势 · 指标状态分布',
-            (current.metricStatuses || []).map((row) => ({
-                code: row.status,
-                label: metricStatusText(row.status),
-                count: row.count || 0,
-            })),
-        ),
         deviceLedgerBlock(devices),
     ]
 

+ 3 - 0
src/modules/cockpit/domain/panelBlocks.js

@@ -1076,6 +1076,9 @@ export function trendBlock(label, points, options = {}) {
         aside: options.aside || '',
         // 维护者 2026-10-29:折线「紧凑」版(更矮、上方留呼吸空间),只由调用方显式声明。
         compact: options.compact === true,
+        // 维护者第 15 轮:「宽屏太挤,应该适当拉高」—— 高度档由调用方声明(不按 panelId 特判)。
+        // `tall` 用于双线对照图(线多、需要更大的竖向空间才读得出走势)。
+        tall: options.tall === true,
         // 折线语义色键(设计稿:报警折线为橙、现有多处折线为主蓝)。渲染层查同一张色表。
         tone: options.tone || '',
         max,

+ 15 - 6
src/modules/cockpit/domain/warningResponsePanels.js

@@ -27,6 +27,7 @@ import {
     kpiBlock,
     listBlock,
     numberish,
+    rankingBarsBlock,
     rowsBlock,
     sectionHasContent,
     sectionPlaceholder,
@@ -553,10 +554,12 @@ export function responseEfficiencyPanel(data, section, extra) {
             // 票 78:预警状态分布(WarningWorkflow 六态 + 脏状态的「未知」桶)。
             // 行的中文名由后端 `stateName` 下发,前端不另造状态词;字段缺失(旧载荷/
             // 报警族)不画假块 —— 缺失不画假 0 的同一纪律。全 0 也是真实分布(缺桶计 0)。
+            // 维护者第 15 轮(预警模式):状态分布改**堆叠条**(六态一条读出流程推进),
+            // 等级分布改**饼图**(四级是固定语义阶梯,环图比排行更直观)。
             ...(Array.isArray(current.warningStates) && current.warningStates.length
-                ? [barsBlock('预警状态分布', current.warningStates)]
+                ? [stackedBlock('预警状态分布', current.warningStates)]
                 : []),
-            barsBlock('预警等级分布', current.levelDistribution || [], { variant: 'rank' }),
+            donutBlock('预警等级分布', current.levelDistribution || [], { layout: 'compact' }),
             trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line', compact: true }),
         ], {
             label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
@@ -781,8 +784,13 @@ export function eventAnalysisPanel(data, section, extra) {
         // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。
         // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序);
         // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。
-        donutBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count), { layout: 'compact' }),
-        ...(causes.length ? [donutBlock('按成因', causes, { layout: 'compact' })] : []),
+        // 维护者第 15 轮:类型是**多类别**,排行(由大到小)比环图更读得清;成因改堆叠条。
+        rankingBarsBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count), {
+            limit: 0,
+            variant: 'rank',
+            unit: '起',
+        }),
+        ...(causes.length ? [stackedBlock('按成因', causes)] : []),
         donutBlock(
             '按行业分布',
             sortByCountDesc(
@@ -791,8 +799,9 @@ export function eventAnalysisPanel(data, section, extra) {
             ),
             { action: 'industry', layout: 'compact' },
         ),
-        barsBlock('按事件等级', current.levels || [], { variant: 'rank' }),
-        barsBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count), { variant: 'rank' }),
+        // 维护者第 15 轮:等级(固定四级阶梯)与片区(类别少)都改饼图。
+        donutBlock('按事件等级', current.levels || [], { layout: 'compact' }),
+        donutBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count), { layout: 'compact' }),
         listBlock('重点识别', highlights, '当前没有识别到重点'),
     ], {
         label: '预警分析',