瀏覽代碼

feat(cockpit): 完善预警处置与共享面板交互

ChenJZ 15 小時之前
父節點
當前提交
3d881d55d9

+ 8 - 4
src/modules/cockpit/components/CockpitPanel.vue

@@ -508,10 +508,14 @@ const panelIconSize = 'calc(17 * var(--u, 1px))'
     z-index: 20;
     display: grid;
     gap: calc(4 * 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)));
+    /* 宽度**只跟面板走**:left/right 同时给 → 宽度=面板宽 − 两侧边距,
+       无论 --u 取多少、面板多宽,都不可能越出面板边界。
+       (旧版 26vw 在 1920 视口算出 499px,而面板栏只有 280–460px —— 这就是维护者
+        第 15 轮看到的出框。改成 min(340u, 100%−12u) 仍不稳:百分比要看包含块是谁,
+        所以这里干脆不给百分比,左右夹死。) */
+    left: calc(6 * var(--u, 1px));
+    right: calc(6 * var(--u, 1px));
+    width: auto;
     max-height: calc(240 * var(--u, 1px));
     overflow-y: auto;
     padding: calc(8 * var(--u, 1px)) calc(10 * var(--u, 1px));

+ 178 - 25
src/modules/cockpit/components/PanelBlocks.vue

@@ -70,12 +70,12 @@
                     与面板右上角小字(PANEL_SUBTITLES,面板身份)分开:一个说块、一个说面板。
                     文案全部由 domain 给,渲染层不合成、不改写。
                 -->
-                <div v-if="block.label || block.aside" class="panel-block__head">
+                <div v-if="block.label" class="panel-block__head">
                     <h4 v-if="block.label" class="panel-block__title" :data-block-title="block.label">
                         <i class="panel-block__title-bar" aria-hidden="true"></i>
                         <EllipsisText tag="span" :text="block.label" />
                     </h4>
-                    <EllipsisText v-if="block.aside" tag="small" class="panel-block__aside" :text="block.aside" data-block-aside />
+                    <!-- 维护者 2026-10-30:块级小字统一收进面板标题栏的「?」摘要(见 domain/panelViews 的 liftBlockNotes)。 -->
                 </div>
 
                 <!-- KPI:等宽数字 + 单位,缺值降亮度显示占位符。
@@ -89,6 +89,7 @@
                         'panel-kpis--value-first': block.layout === 'value-first',
                         'panel-kpis--two': block.layout === 'two',
                         'panel-kpis--three': block.layout === 'three',
+                        'panel-kpis--three-plus-two': block.layout === 'three-plus-two',
                         'is-emphasis': block.emphasis === true,
                     }"
                     data-kpi-row
@@ -110,7 +111,8 @@
                         :data-kpi-accent="item.accent || null"
                         :data-kpi-value-tone="item.valueTone || null"
                         :data-kpi-fit="item.fitVars ? '' : null"
-                        :style="item.fitVars"
+                        :data-kpi-span="item.span || null"
+                        :style="[item.fitVars, item.span ? { '--kpi-span': String(item.span) } : null]"
                     >
                         <!-- 票 37 走查扩展:读数图标走 mask 染语义色(KPI_ICON_COLORS,未命中主蓝)。
                              色画在容器上(currentColor),图标本体是白剪影。 -->
@@ -346,7 +348,7 @@
                         (会互相压),改在图例里给每条线的名字与色块,悬停仍读具体日期与数值。
                         Y 轴按所有线的联合最大值归一(domain 已算好),所以两条线的高低可比。
                     -->
-                    <template v-if="block.multiSeries">
+<template v-if="block.multiSeries">
                         <div class="panel-trend__chart" data-trend-chart data-trend-multi="yes">
                             <div
                                 v-for="tick in block.yTicks"
@@ -403,7 +405,7 @@
                                 :key="`lg-${series.key}`"
                                 class="panel-trend__legend-item"
                                 :data-trend-legend-item="series.key"
-                                @mouseenter="showChartTip($event, series.label, series.points.reduce((s, p) => s + p.count, 0), barColor(series.tone))"
+                                @mouseenter="showChartTip($event, series.label, `${series.summaryLabel} ${series.summaryValue}`, barColor(series.tone))"
                                 @mouseleave="hideChartTip"
                             >
                                 <i class="panel-trend__legend-swatch" :style="{ background: barColor(series.tone) }" aria-hidden="true"></i>
@@ -474,7 +476,8 @@
                             <EllipsisText class="panel-trend__label" :text="point.label" />
                         </div>
                     </template>
-                    <p v-if="!block.points.length && !view.designOnly" class="panel-block__empty" data-trend-empty>该窗口没有事件记录</p>
+                    <!-- 票 71:块级空态文案由 domain 给(缺省沿用既有那句措辞,既有调用点零改动)。 -->
+                    <p v-if="!block.points.length && !view.designOnly" class="panel-block__empty" data-trend-empty>{{ block.emptyText || '该窗口没有事件记录' }}</p>
                 </div>
 
                 <!--
@@ -491,11 +494,14 @@
                 -->
                 <div v-else-if="block.kind === 'stacked'" class="panel-stacked" data-stacked-list>
                     <div class="panel-stacked__bar" role="img" :aria-label="`${block.label}(合计 ${block.total})`">
+                        <!-- 维护者第 16 轮:条段 ⇄ 图例**双向联动**(与环形图/饼图同一套 chartHover 状态):
+                             悬停任一段,同色图例行同时亮;反之亦然。原先两处各自只弹浮层,不互亮。 -->
                         <i
                             v-for="row in block.rows"
                             :key="row.key"
                             v-show="row.ratio > 0"
                             class="panel-stacked__seg"
+                            :class="{ 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
                             :data-stacked-seg="row.key"
                             :style="{
                                 left: `${row.startPct}%`,
@@ -504,8 +510,8 @@
                                 '--seg-color': stackedColor(row),
                             }"
                             :title="`${row.label} ${row.count}`"
-                            @mouseenter="showChartTip($event, row.label, row.count, stackedColor(row))"
-                            @mouseleave="hideChartTip"
+                            @mouseenter="showChartTip($event, row.label, row.count, stackedColor(row)); setChartHover(chartScope(index, block), row.key)"
+                            @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
                         ></i>
                     </div>
                     <ul class="panel-stacked__legend" data-stacked-legend>
@@ -513,9 +519,10 @@
                             v-for="row in block.rows"
                             :key="row.key"
                             class="panel-stacked__row"
+                            :class="{ 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
                             :data-stacked="row.key"
-                            @mouseenter="showChartTip($event, row.label, row.count, stackedColor(row))"
-                            @mouseleave="hideChartTip"
+                            @mouseenter="showChartTip($event, row.label, row.count, stackedColor(row)); setChartHover(chartScope(index, block), row.key)"
+                            @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
                         >
                             <i class="panel-stacked__swatch" :style="{ '--seg-color': stackedColor(row) }" aria-hidden="true"></i>
                             <EllipsisText class="panel-stacked__label" :text="row.label" />
@@ -590,6 +597,10 @@
                             >
                                 <i class="panel-donut__swatch" :style="{ background: barColor(row.tone) }" aria-hidden="true"></i>
                                 <EllipsisText class="panel-donut__label" :text="row.label" />
+                                <!-- 维护者第 16 轮:图例带**横置柱状条**,占比可直接比长短(只有数字要心算)。 -->
+                                <span v-if="block.legendBar" class="panel-donut__meter" data-donut-meter aria-hidden="true">
+                                    <i :style="{ width: Math.round((row.ratio || 0) * 100) + '%', background: barColor(row.tone) }"></i>
+                                </span>
                                 <b class="panel-donut__count" data-donut-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}</b>
                             </component>
                         </ul>
@@ -646,6 +657,24 @@
                                 <figcaption class="panel-donut-cell__label" data-donut-group-label>{{ group.label }}</figcaption>
                             </figure>
                         </div>
+                        <!-- 维护者第 16 轮:可选底部**堆叠条**(与各环共用同一份图例与色序):
+                             「同一批类别的总量构成」放在环下面一条读出,比再画第三张环更省高度、
+                             也不再需要第二份图例(重复图例正是本轮要消掉的观感问题)。 -->
+                        <div v-if="block.stack && block.stack.rows && block.stack.rows.length" class="panel-stacked panel-donuts__stack" data-donut-grid-stack>
+                            <div class="panel-stacked__bar" role="img" :aria-label="`${block.stack.label}(合计 ${block.stack.total})`">
+                                <i
+                                    v-for="row in block.stack.rows"
+                                    :key="row.key"
+                                    v-show="row.ratio > 0"
+                                    class="panel-stacked__seg"
+                                    :class="{ 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
+                                    :data-stacked-seg="row.key"
+                                    :style="{ left: row.startPct + '%', width: row.widthPct + '%', '--seg-color': barColor(row.tone) }"
+                                    @mouseenter="showChartTip($event, row.label, row.count, barColor(row.tone)); setChartHover(chartScope(index, block), row.key)"
+                                    @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
+                                ></i>
+                            </div>
+                        </div>
                         <ul class="panel-donuts__legend" data-donut-grid-legend>
                             <li
                                 v-for="row in block.legend"
@@ -676,6 +705,7 @@
                 -->
                 <div v-else-if="block.kind === 'pie'" class="panel-pie" data-pie>
                     <template v-if="(block.ready && block.total > 0) || block.placeholder">
+                        <div class="panel-pie__wrap">
                         <svg class="panel-pie__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
                             <path
                                 v-for="slice in block.slices"
@@ -703,6 +733,9 @@
                                 @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
                             ></path>
                         </svg>
+                        <!-- 维护者第 16 轮:环心显示**总数**(上下分布时图放大后,中心空着反而更空)。 -->
+                        <b class="panel-pie__total" data-pie-total>{{ block.total }}</b>
+                        </div>
                         <ul class="panel-pie__legend" data-pie-legend>
                             <li
                                 v-for="row in block.rows"
@@ -761,11 +794,14 @@
                         @click="cardAction(row).kind !== CARD_ACTION.NONE ? onCardClick(row, $event) : null"
                     >
                         <div class="panel-item__head">
-                            <!-- 整卡是否可点只认 domain 的显式 identity;标题与说明共用省略方法。 -->
-                            <EllipsisText class="panel-item__title" :text="row.name || row.title" />
-                            <!-- 色键必带文字(票 06:标签不能只靠颜色);文案由 domain 给 -->
+                            <!-- 等级(带色)+ 标题 + 处理状态(带色):与「当前预警/报警」卡同一版式。
+                                 整卡是否可点只认 domain 的显式 identity。 -->
                             <StatusTag v-if="row.tone && row.toneLabel" :tone="row.tone" :label="row.toneLabel" />
+                            <EllipsisText class="panel-item__title" :text="row.name || row.title" />
+                            <StatusTag v-if="row.statusTone && row.statusLabel" :tone="row.statusTone" :label="row.statusLabel" />
                         </div>
+                        <!-- 内容摘要:直接回答"这张卡大概发生了什么"(domain 不给就不画)。 -->
+                        <EllipsisText v-if="row.summaryText" tag="p" class="panel-item__summary" :text="row.summaryText" data-item-summary />
                         <EllipsisText v-if="row.meta && row.meta.length" tag="p" class="panel-item__meta" :text="row.meta.join(' · ')" />
                         <!-- data-data-time 是走查断言面(票 15 的 t15 闸门按源码钉住这个形状,
                              属性与正文必须同行);文本走插槽,title 仍拿完整原文(票 37 的共用省略方法)。
@@ -833,7 +869,7 @@
                         已加载 M 条」)。它是口径不是控件——一页不到、控件不画时也要出现,
                         所以不挂在 PagerNav 的 v-if 里。
                     -->
-                    <p v-if="block.pageNote" class="panel-block__page-note" data-list-page-note>{{ block.pageNote }}</p>
+                    <!-- 维护者 2026-10-30:页脚小字统一收进「?」摘要(同 liftBlockNotes)。 -->
                 </div>
 
                 <!--
@@ -899,7 +935,7 @@
 <script setup>
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
 import { chartTipMaxWidth, clampChartTip } from '../domain/chartTip.js'
-import { fitTrendBlock, fitTrendLayout, trendFitInputs } from '../domain/chartFit.js'
+import { fitTrendBlock, fitTrendLayout, trendFitInputs, trendHeightBounds } from '../domain/chartFit.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import { CARD_ACTION, cardActionFor, sectionPending } from '../domain/panelBlocks.js'
 import { registerPanelSummary, unregisterPanelSummary } from '../stores/panelSummaryStore.js'
@@ -984,6 +1020,7 @@ watch(
 )
 
 onBeforeUnmount(() => {
+    if (wideQuery && typeof wideQuery.removeEventListener === "function") wideQuery.removeEventListener("change", syncWideScreen)
     unregisterPanelSummary(props.view && props.view.name)
 })
 
@@ -1366,8 +1403,17 @@ const BAR_TONE_COLORS = {
     'chart:6': 'var(--chart-c6, #ffb020)',
     'chart:7': 'var(--chart-c7, #a970ff)',
     'chart:8': 'var(--chart-c8, #5680b0)',
+    // 维护者第 16 轮:堆叠条要「对不同颜色做出更明显的区分」——八个**互不相近**的色相
+    // (蓝/橙/青/绿/紫/品红/黄/灰),只用于声明了 `distinct` 的堆叠块。
+    'seg:1': 'var(--seg-c1, #01abea)',
+    'seg:2': 'var(--seg-c2, #ffb020)',
+    'seg:3': 'var(--seg-c3, #14c2ff)',
+    'seg:4': 'var(--seg-c4, #27d17a)',
+    'seg:5': 'var(--seg-c5, #a970ff)',
+    'seg:6': 'var(--seg-c6, #ff5fa2)',
+    'seg:7': 'var(--seg-c7, #ffd84d)',
+    'seg:8': 'var(--seg-c8, #7f9dc4)',
 }
-
 /**
  * 多序列折线里 X 轴标签的横向位置(%)。
  *
@@ -1444,6 +1490,17 @@ function observeCards() {
 /** 折线布局预算:高度 + 真实落到图表内部的上/下 padding。0 高度 = 正常屏不接管。 */
 const trendFitLayout = ref({ heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 })
 const trendFitHeight = computed(() => trendFitLayout.value.heightPx)
+// 维护者 2026-10-30 第 1 条:区间下限(面板高的 1/8)也要下发到 CSS ——
+// 否则 CSS 自己的 min-height 会压住规格下限,窄屏就看不到「八分之一」。
+const trendMinHeight = ref(0)
+// 维护者 2026-10-30 第 3 次反馈:折线高度分**宽屏 / 普通屏**两套 —— 宽屏 1/6~1/3、普通屏固定 1/3。
+// 判据与 CockpitShell / PanelBlocks 的超宽断点**同源**(aspect ≥ 3456/1239),不另造一个阈值。
+const WIDE_MEDIA = '(min-aspect-ratio: 3456 / 1239)'
+const isWideScreen = ref(false)
+let wideQuery = null
+function syncWideScreen(event) {
+    isWideScreen.value = event ? event.matches === true : !!(wideQuery && wideQuery.matches)
+}
 
 /** 每块折线图的实测盒(供 domain 重算刻度):**渲染顺序**序号 → { heightPx, widthPx }。 */
 const trendBoxes = ref({})
@@ -1490,6 +1547,7 @@ const renderBlocks = computed(() => {
  * 上方小标题与点顶数值更易拥挤,故上多下少。不是按 panelId 定高;比例归 domain。 */
 const trendFitStyle = computed(() => trendFitHeight.value > 0 ? {
     '--trend-fit-height': trendFitHeight.value + 'px',
+    '--trend-fit-min-height': trendMinHeight.value + 'px',
     '--trend-fit-padding-top': trendFitLayout.value.paddingTopPx + 'px',
     '--trend-fit-padding-bottom': trendFitLayout.value.paddingBottomPx + 'px',
 } : null)
@@ -1522,10 +1580,17 @@ function syncTrendHeight(charts, root) {
             contentHeightPx,
             chartHeightsPx: charts.map((element) => element.offsetHeight),
         }),
-        // min-height 是 CSS 的单一来源(64u 设计高度):算出来不比它高时不接管,
-        // 否则 CSS 下限会赢、块被撑到下限却按更小的预算算 → 溢出。
-        minHeightPx: parseFloat(getComputedStyle(charts[0]).minHeight),
+// 维护者 2026-10-30 第 1 条:不再取 CSS 的固定 64u 下限 —— 那个下限比窄屏的 1/8 面板高还大,
+        // 会把规格压住。区间下限由 domain 的 trendHeightBounds 给(面板高的 1/8),
+        // CSS 侧只留一个极小兜底(24u),并由 --trend-fit-min-height 下发。
+        minHeightPx: 0,
+        // 维护者 2026-10-30 第 3 次反馈:宽屏 1/6~1/3、普通屏固定 1/3。
+        wide: isWideScreen.value,
     })
+// 维护者 2026-10-30 第 1 条:区间下限(面板高的 1/8)下发到 CSS,
+    // 否则 CSS 自己的 min-height 会压住规格下限(窄屏看不到「八分之一」)。
+    const bounds = trendHeightBounds(host.clientHeight - padTop - padBottom, isWideScreen.value)
+    if (Math.abs(bounds.minPx - trendMinHeight.value) > 1) trendMinHeight.value = bounds.minPx
     const current = trendFitLayout.value
     if (Math.abs(next.heightPx - current.heightPx) > 1
         || Math.abs(next.paddingTopPx - current.paddingTopPx) > 1
@@ -1597,6 +1662,12 @@ onMounted(() => {
         const host = trendRoot.closest('.cockpit-panel__body')
         if (host) trendObserver.observe(host)
     }
+    // 维护者 2026-10-30:宽屏判定跟着视口比例走(窗口缩放 / 换屏都要重算)。
+    if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
+        wideQuery = window.matchMedia(WIDE_MEDIA)
+        syncWideScreen()
+        if (typeof wideQuery.addEventListener === "function") wideQuery.addEventListener("change", syncWideScreen)
+    }
     syncTrendFit()
     syncSupplementalVisibility()
     if (typeof ResizeObserver === 'function' && trendRoot) {
@@ -1780,6 +1851,20 @@ onBeforeUnmount(() => {
     grid-template-columns: repeat(3, minmax(0, 1fr));
 }
 
+/* 3+2 版式(票 70):统一的 **6 栏栅格**。
+   上排三卡各占 2 栏(2+2+2),下排两卡各占 3 栏(3+3)——
+   于是下排两卡「填满整行且与上排对齐」,宽度天然是上排单卡的 1.5 倍。
+
+   跨度由 domain 给(`item.span`),渲染层只照着摆:卡片带 `--kpi-span` 时
+   占 `span / 6` 的列宽。**渲染层不认识任何指标键**,两族共用这一条规则。 */
+.panel-kpis--three-plus-two {
+    grid-template-columns: repeat(6, minmax(0, 1fr));
+}
+
+.panel-kpis--three-plus-two .panel-kpi {
+    grid-column: span var(--kpi-span, 2);
+}
+
 @container panel-kpi-value-first (max-width: 300px) {
     .panel-kpis--value-first {
         grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2487,7 +2572,11 @@ onBeforeUnmount(() => {
        旧实现只把 14% 从 height 扣掉,CSS grid 把剩余空间放在块后面,标题→绘图区间距仍是固定 6u,
        所以目视毫无差别;真机反馈环已实测该根因。正常屏仍回退 6u / 1u,不改原观感。 */
     height: var(--trend-fit-height, calc(64 * var(--u, 1px)));
-    min-height: calc(64 * var(--u, 1px));
+    /* 维护者 2026-10-30 第 1 条:min-height 原来写死 64u,窄屏下比 1/8 面板高还大,
+       于是「最小八分之一」被 CSS 压住 —— 规格只在宽屏生效、窄屏不生效。
+       现在 CSS 只保一个**极小兜底**(不至于塌成一条线),真正的下限由 domain 的
+       trendHeightBounds 按面板高给(1/8),并由 --trend-fit-min-height 下发。 */
+    min-height: var(--trend-fit-min-height, calc(24 * var(--u, 1px)));
     padding: var(--trend-fit-padding-top, calc(6 * var(--u, 1px))) 0
         var(--trend-fit-padding-bottom, calc(1 * var(--u, 1px)));
 }
@@ -2677,6 +2766,20 @@ onBeforeUnmount(() => {
     background: var(--seg-color, var(--cyan, #01abea));
 }
 
+
+/* 维护者第 16 轮:堆叠条 ⇄ 图例联动高亮(悬停任一侧,另一侧同时亮)。 */
+.panel-stacked__seg.is-hovered {
+    filter: brightness(1.35);
+}
+
+.panel-stacked__row.is-hovered .panel-stacked__label,
+.panel-stacked__row.is-hovered .panel-stacked__count {
+    color: var(--text-strong, #fff);
+}
+
+.panel-stacked__row.is-hovered .panel-stacked__swatch {
+    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.5);
+}
 .panel-stacked__legend {
     display: grid;
     grid-template-columns: repeat(auto-fill, minmax(calc(96 * var(--u, 1px)), 1fr));
@@ -2766,9 +2869,11 @@ onBeforeUnmount(() => {
 .panel-donut--compact .panel-donut__count { font-size: calc(12 * var(--u, 1px)); }
 
 
-/* 维护者第 15 轮:双线对照图在宽屏显得太挤,拉高一档(多线需要更大竖向空间才读得出走势)。 */
+/* 维护者第 15 轮:双线对照图在宽屏显得太挤,拉高一档(多线需要更大竖向空间才读得出走势)。
+   维护者第 16 轮:**保底再抬高**——96u 在宽屏(--u≈1.33)只有 ~128px,两条线几乎贴在一起;
+   140u 给到 ~186px,走势与刻度才读得出来。`min-height` 是保底,宽屏仍由 fitTrend 接管。 */
 .panel-trend--tall {
-    min-height: calc(96 * var(--u, 1px));
+    min-height: calc(140 * var(--u, 1px));
     padding-top: calc(14 * var(--u, 1px));
     padding-bottom: calc(6 * var(--u, 1px));
 }
@@ -2844,6 +2949,26 @@ onBeforeUnmount(() => {
     font-size: var(--fs-body, 14px);
 }
 
+
+/* 图例横置柱状条(维护者第 16 轮):左右式饼图/环形图的图例给出占比长短。
+   条走剩余空间(图例是 flex 行),颜色同扇区,高度与字号同档。 */
+.panel-donut__meter {
+    flex: 1 1 auto;
+    min-width: calc(14 * var(--u, 1px));
+    max-width: calc(120 * var(--u, 1px));
+    height: calc(6 * var(--u, 1px));
+    border-radius: calc(3 * var(--u, 1px));
+    background: rgba(86, 128, 176, 0.22);
+    overflow: hidden;
+}
+
+.panel-donut__meter i {
+    display: block;
+    height: 100%;
+    border-radius: inherit;
+    transition: width 0.2s ease;
+}
+
 .panel-donut__legend {
     display: grid;
     gap: calc(2 * var(--u, 1px));
@@ -3065,10 +3190,31 @@ onBeforeUnmount(() => {
     gap: calc(8 * var(--u, 1px));
 }
 
-.panel-pie__chart {
+/* 维护者第 16 轮:上下分布时把饼**放大**(118u → 150u)—— 图小、例多,读起来才不憋屈。 */
+.panel-pie__wrap {
+    position: relative;
     flex: none;
-    width: calc(72 * var(--u, 1px));
-    height: calc(72 * var(--u, 1px));
+    width: calc(150 * var(--u, 1px));
+    height: calc(150 * var(--u, 1px));
+}
+
+.panel-pie__chart {
+    display: block;
+    width: 100%;
+    height: 100%;
+}
+
+/* 环心总数(维护者第 16 轮:上下分布把图放大后,中心读数让占比一眼有量级)。 */
+.panel-pie__total {
+    position: absolute;
+    inset: 0;
+    display: grid;
+    place-items: center;
+    pointer-events: none;
+    color: var(--text-strong, #fff);
+    font-family: var(--num, monospace);
+    font-size: calc(24 * var(--u, 1px));
+    letter-spacing: 0.02em;
 }
 
 .panel-pie__slice {
@@ -3170,6 +3316,13 @@ onBeforeUnmount(() => {
 
 /* 可点卡(台账有 code、异常设备有定位点):交互语言与事件卡同一套 ——
    hover/focus 高亮边框、底色抬亮、左缘压 3px 竖条。 */
+.panel-item__summary {
+    margin: 0;
+    color: var(--text-2);
+    font-size: var(--fs-caption, 10.5px);
+    line-height: 1.4;
+}
+
 .panel-item--clickable {
     cursor: pointer;
     transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;

+ 17 - 3
src/modules/cockpit/components/WarningResponsePane.vue

@@ -60,29 +60,43 @@ const view = computed(() => {
     // 族是标量、跨重算稳定;`data.family`(后端回显)仍优先于它,见
     // `domain/warningResponsePanels.js` 的 `responseEfficiencyPanel`。
     // `workOrderList` 是报警模式「当前工单列表」块的数据源(另一个端点,见 store 注释)。
+    // 票 71 返工:预警分析的「按事件类型」对报警族要用**事件名称**归类
+    // (后端 EVENT_TYPE 对报警恒回未知桶,见 domain 的 alarmEventTypeRows),
+    // 所以把已取到的历史事件行一并交给面板 —— 同窗口、同族、不发额外请求。
+    const historyEvents = eventStore.sections.historyAnalysis && eventStore.sections.historyAnalysis.data
+        ? eventStore.sections.historyAnalysis.data.events
+        : null
     return panelView(props.panelId, current.data, current, {
         mode: eventStore.mode,
         workOrderList: eventStore.sections.workOrderList,
+        historyEvents,
     })
 })
 
 /**
  * 服务端页码翻页出口(PanelBlocks 的 `serverPage` 块)。
  *
- * 两块面板各有一个 `serverPage` 块,翻页去**各自的分区**——别按「面板名」一刀切:
+ * **按块标签分派,不按面板名一刀切**:同一个面板里现在有两块服务端分页块,
+ * 它们背后是**两个不同端点、两个不同页码**——
  *   · 历史分析:单窗范围的历史事件(多窗合并范围走块内前端集合分页,不经过这里);
- *   · 报警效率:底部「当前工单列表」(票 65)。
+ *   · 报警效率:「报警列表」(票 71,随效率端点分页);
+ *   · 预警效率:「预警列表」(票 71,同一端点、同一个页码);
+ *   · 报警效率:「当前工单列表」(票 65,另一个端点 `/work-orders`)。
  * 早先这里只认 `historyAnalysis`,导致工单列表画出了翻页控件、点了却什么都没发生
  * —— 事件被 `return` 掉,页码根本没进 store(t65 的 H 组 + 变异 M27 钉住)。
+ * 票 71 新增两块列表后,同样的坑会以「列表翻了工单页」的形式复现,所以这里改按标签判。
  */
 function onListPage(payload) {
     const target = payload && payload.page
+    const label = String((payload && payload.label) || '')
     if (props.panelId === 'historyAnalysis') {
         eventStore.setHistoryPage(target)
         return
     }
     if (props.panelId === 'responseEfficiency') {
-        eventStore.setWorkOrderPage(target)
+        // 工单列表的块标签是「当前工单列表」;其余两块是两族的事件列表(同一个页码)。
+        if (label === '当前工单列表') eventStore.setWorkOrderPage(target)
+        else eventStore.setEfficiencyPage(target)
     }
 }
 

+ 53 - 7
src/modules/cockpit/domain/chartFit.js

@@ -64,21 +64,67 @@ export const TREND_SAFETY_SLACK_PX = 10
  *      块还是被撑到 min-height,而预算只按更小的值算,于是必然溢出
  *      (真机实测:3440×1440 概览面板 fit=79 但 min-height=114 → 溢出 34px)。
  */
-export function fitTrendLayout({ budgetPx, othersPx, count, minHeightPx = 0 } = {}) {
+/**
+ * 折线高度占**面板**的比例区间(维护者 2026-10-29)。
+ *
+ * 原话:「限制最小和最大高度范围,避免太窄看不清,太高观感怪(最小大约占面板的八分之一,
+ * 最大占面板的三分之一,其他的折线图也按这样相同规格去规定)」。
+ * 所以区间按**面板体可用高度**取比例,而不是写死像素:换屏幕/换面板分配都不用重算,
+ * 且全仓所有折线(预警处置三块、概览、运行概况…)共用这一条规格。
+ */
+// 维护者 2026-10-30 第 2 次反馈:1/8 与旧默认 64u(--u=2px 时即 128px)几乎重合,
+// 屏上看不出变化;改 1/6 让「最小高度」真正可见。
+export const TREND_HEIGHT_MIN_RATIO = 1 / 6
+export const TREND_HEIGHT_MAX_RATIO = 1 / 3
+
+/**
+ * 面板高度 → 折线高度的上下界(px)。面板高度未知(≤0)时回 0,表示「不设界」。
+ * @param {number} panelHeightPx 面板体可用高度
+ */
+export function trendHeightBounds(panelHeightPx, wide = false) {
+    const height = Math.max(0, Number(panelHeightPx) || 0)
+    if (!(height > 0)) return { minPx: 0, maxPx: 0 }
+    const maxPx = Math.floor(height * TREND_HEIGHT_MAX_RATIO)
+    // 维护者 2026-10-30 第 3 次反馈:宽屏 1/6~1/3;**普通屏固定 1/3**(「再×2」)。
+    // 普通屏面板更高、内容更满,1/6 的下限会让折线显得过矮,所以直接钉在 1/3。
+    // `wide` 由渲染层按与 CockpitShell 同源的超宽断点(aspect ≥ 3456/1239)判定后传入 ——
+    // 领域层不碰视口,只认这个布尔。
+    const minPx = wide ? Math.floor(height * TREND_HEIGHT_MIN_RATIO) : maxPx
+    return { minPx, maxPx }
+}
+
+export function fitTrendLayout({ budgetPx, othersPx, count, minHeightPx = 0, wide = false } = {}) {
     const budget = Number(budgetPx) || 0
     const others = Number(othersPx) || 0
     const charts = Math.max(1, Math.floor(Number(count) || 0))
     const floor = Math.max(0, Number(minHeightPx) || 0)
     const idle = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
+    if (!(budget > 0)) return idle
     const available = (budget - others) / charts
-    if (!(available > 0)) return idle
-    const heightPx = Math.floor(available) - TREND_SAFETY_SLACK_PX
-    // 交还 CSS 下限:算出来不比 min-height 高时接管毫无收益,还会因为"下限赢"而溢出。
-    if (heightPx <= floor) return idle
+    const room = Math.floor(available)
+    // 维护者 2026-10-30 第 1 条:「1/8 ~ 1/3 好像没有成功生效,希望宽屏和窄屏同时生效」。
+    // 旧实现只有**有富余**时才接管,而真实面板几乎总是内容更高 ⇒ 恒走 CSS 的 64u 定高,
+    // 比例区间等于没生效。现在改成:**只要量到面板高度就按比例出手** ——
+    //   · 有富余:取富余(再夹进区间);
+    //   · 没富余:取区间下限(1/8)—— 这就是「不低于八分之一」的字面含义,窄屏也成立。
+    const bounds = trendHeightBounds(budget, wide)
+    let heightPx = room > 0 ? room - TREND_SAFETY_SLACK_PX : bounds.minPx
+    // 富余极小(或负数)时回落规格下限 —— 普通屏「固定 1/3」必须真的兑现。
+    if (!(heightPx > 0)) heightPx = bounds.minPx
+    if (bounds.maxPx > 0 && heightPx > bounds.maxPx) heightPx = bounds.maxPx
+    // 下限是否强制:宽屏按 1/6 保底、**普通屏单张折线严格 1/3**;
+    // 同面板多张时不强制(否则 N 张就是 N/3,必然把面板挤出滚动条)——多张按可用空间均分。
+    const enforceMin = wide || charts === 1 || bounds.minPx <= room
+    if (bounds.minPx > 0 && heightPx < bounds.minPx && enforceMin) heightPx = bounds.minPx
+    // 唯一例外:CSS 的 min-height 比规格下限还高时以它为准(否则 CSS 会赢、而预算按更小的值算 → 溢出);
+    // 若这样得到的高度又超出规格上限,说明面板小到装不下一条合规折线,交还 CSS。
+    if (heightPx < floor) heightPx = floor
+    if (bounds.maxPx > 0 && heightPx > bounds.maxPx) return idle
+    // 呼吸留白按**夹取后**的高度算(按夹取前的净预算算会把绘图区挤没)。
     return {
         heightPx,
-        paddingTopPx: Math.floor(available * TREND_TOP_BREATHING_RATIO),
-        paddingBottomPx: Math.floor(available * TREND_BOTTOM_BREATHING_RATIO),
+        paddingTopPx: Math.floor(heightPx * TREND_TOP_BREATHING_RATIO),
+        paddingBottomPx: Math.floor(heightPx * TREND_BOTTOM_BREATHING_RATIO),
     }
 }
 

+ 126 - 0
src/modules/cockpit/domain/eventStatusVocab.js

@@ -0,0 +1,126 @@
+/**
+ * 预警/报警**状态词表**(票 71 返工,维护者 2026-10-29 第 1 条反馈)。
+ *
+ * ── 为什么必须分族,不能共用 ──
+ * 维护者原话:「预警和报警状态是不一样的,不能完全通用(样式可以通用)」。
+ * 后端口径逐条核实过,两族是**两套互不相通的生命周期**:
+ *
+ *   预警(`WarningWorkflow` / `EarlyWarningStatusEnum`,六态,线性推进):
+ *     DRAFT 草稿 → PENDING 待接收 → RECEIVED 已接收 → PROCESSING 处置中
+ *       → HANDLED 已处置 → CLOSED 已解除(**唯一终态**)
+ *     后端注释原句:「已处置仍需核查,只有『已解除』才是终态」。
+ *
+ *   报警(`alarm_data.alarm_status` 经后端归一,二态):
+ *     UNHANDLED 未处理 → HANDLED 已处理(处置手段是工单)
+ *     见 `BusinessCockpitEventAdapter` / `CockpitOverviewWarningSettingsService`。
+ *
+ * 共用一张表的实际后果(返工前就踩了):
+ *   · `PENDING` 在预警里是「待接收」,旧表却译成「待处理」—— 同一屏两种含义;
+ *   · 报警的 `UNHANDLED`/`HANDLED` 在旧表里**根本没有词条**,直接漏英文码上大屏;
+ *   · 预警的 RECEIVED/PROCESSING/CLOSED 同理漏码。
+ *
+ * ── 共用的是**形状**,不是词条 ──
+ * 两个族的词表都走同一个 `statusVocabulary()` 构造函数(同一套字段:label/tone/terminal/
+ * unhandled),所以「怎么画一个状态标签」只有一份实现;而「某个码是什么意思」各族自述。
+ * 认不出的码一律**回原码**(数据值照原样显示,不猜 —— 与 `eventList.localizeCodeText` 同一立场)。
+ */
+
+/** 状态标签的语义色键(沿用 `statusTones.js` 的四档,不新造色)。 */
+const TONE = { PENDING: 'lv2', ACTIVE: 'lv3', DONE: 'lv4', NEUTRAL: 'neutral' }
+
+/**
+ * 预警六态(`EarlyWarningStatusEnum`,码与中文名逐条照抄后端枚举)。
+ *
+ * `terminal` 只标 **CLOSED**:HANDLED(已处置)仍需核查,不是终态 ——
+ * 这是后端注释明写的口径,也是「处置数」与「已解除数」必须分开算的依据。
+ */
+export const WARNING_STATUS = {
+    DRAFT: { label: '草稿', tone: TONE.NEUTRAL, terminal: false, unhandled: true },
+    PENDING: { label: '待接收', tone: TONE.PENDING, terminal: false, unhandled: true },
+    RECEIVED: { label: '已接收', tone: TONE.ACTIVE, terminal: false, unhandled: false },
+    PROCESSING: { label: '处置中', tone: TONE.ACTIVE, terminal: false, unhandled: false },
+    HANDLED: { label: '已处置', tone: TONE.ACTIVE, terminal: false, unhandled: false },
+    CLOSED: { label: '已解除', tone: TONE.DONE, terminal: true, unhandled: false },
+}
+
+/** 报警二态(后端归一后的取值)。 */
+export const ALARM_STATUS = {
+    UNHANDLED: { label: '未处理', tone: TONE.PENDING, terminal: false, unhandled: true },
+    HANDLED: { label: '已处理', tone: TONE.DONE, terminal: true, unhandled: false },
+}
+
+/** 族 → 词表。认不出的族回空表(调用方据此只说原码,不冒充)。 */
+const VOCABULARY_BY_FAMILY = {
+    WARNING: WARNING_STATUS,
+    ALARM: ALARM_STATUS,
+}
+
+/**
+ * 某一族的**状态词表**(唯一入口;组件与卡片都不直接认码)。
+ *
+ * @param {string} family `WARNING`/`ALARM`(大小写不敏感)
+ * @returns {object} 码 → `{label, tone, terminal, unhandled}`;族认不出回空对象
+ */
+export function statusVocabulary(family) {
+    const key = String(family || '').trim().toUpperCase()
+    // 返回浅拷贝:调用方只读,但也不该拿到能改到这张表内部的原对象。
+    return { ...(VOCABULARY_BY_FAMILY[key] || {}) }
+}
+
+/**
+ * 该族里「已了结」的状态码集合(算**处置数**用)。
+ *
+ * 预警只有 CLOSED;报警只有 HANDLED。用集合而不是单个字符串,
+ * 是因为将来任一族加终态时改这一处即可,不用去改算法。
+ * @param {string} family
+ * @returns {string[]}
+ */
+export function terminalStatuses(family) {
+    return Object.entries(statusVocabulary(family))
+        .filter(([, meta]) => meta.terminal === true)
+        .map(([code]) => code)
+}
+
+/**
+ * 状态码 → 展示文本。
+ *
+ * 三条判定顺序(与 `localizeCodeText` 同一条思路):
+ *   1. 本族词表命中 → 中文;
+ *   2. 认不出 → **回原码**(数据值照原样;空码回空串,不把「没有」画成某个状态)。
+ *
+ * @param {string} family `WARNING`/`ALARM`
+ * @param {string} status 后端状态码
+ */
+export function statusText(family, status) {
+    const code = typeof status === 'string' ? status.trim() : ''
+    if (!code) return ''
+    const meta = statusVocabulary(family)[code]
+    return meta ? meta.label : code
+}
+
+/**
+ * 状态码 → 语义色键(标签必须带文字,色只是辅助 —— 票 06 的纪律)。
+ * 认不出的码走中性灰,**不猜**成「处理中」。
+ */
+export function statusTone(family, status) {
+    const code = typeof status === 'string' ? status.trim() : ''
+    if (!code) return 'neutral'
+    const meta = statusVocabulary(family)[code]
+    return meta ? meta.tone : 'neutral'
+}
+
+/** 该状态是不是本族的「已了结」终态(算处置数用)。 */
+export function isTerminalStatus(family, status) {
+    const code = typeof status === 'string' ? status.trim() : ''
+    return code !== '' && terminalStatuses(family).includes(code)
+}
+
+
+
+/** 该状态是不是本族的「还没人接手」(未处理/待接收一类)。 */
+export function isUnhandledStatus(family, status) {
+    const code = typeof status === 'string' ? status.trim() : ''
+    if (!code) return false
+    const meta = statusVocabulary(family)[code]
+    return !!(meta && meta.unhandled === true)
+}

+ 176 - 0
src/modules/cockpit/domain/incidentTrend.js

@@ -0,0 +1,176 @@
+/**
+ * 预警/报警「发生 · 处置」逐日趋势(票 71 返工,维护者 2026-10-29 第 2 条反馈)。
+ *
+ * ── 为什么推翻上一版 ──
+ * 上一版画的是「平均结案时长折线」,实测**画不出线**:后端 `durationTrend` 是**小时**桶、
+ * 且只有「有样本的小时」才有值 —— 近 7 天 169 个桶里只有 1 个有样本,按日聚合后
+ * 整个窗口只有**一个点**,一个点连不成线(维护者原话「只有点没有线」)。
+ * 根本问题不是画法,是**素材**:平均时长变化慢、样本稀,本来就不适合当日频走势。
+ *
+ * 维护者改判的口径(本文件的唯一职责):
+ *   「统计所选时间范围内,每一日的**总预警/报警数**、**新增数**及**处置数**」。
+ *
+ * ── 三条线各自的定义(都要能逐日复算,不许互相顶替)──
+ *   · **新增数** = 当天**发布**的条数(`eventTime` 落在当天);
+ *   · **处置数** = 当天**走到本族终态**的条数(预警 CLOSED / 报警 HANDLED,
+ *     取该事件的 `updatedTime` 作为处置时刻);
+ *   · **总数** = **截至当天结束**的累计条数(`eventTime` ≤ 当天结束)。
+ *     它必然 ≥ 新增数,且单调不减 —— 与「新增」是两个不同的问题(累计规模 vs 当日增量)。
+ *
+ * ── 两处如实说明(都必须上屏,不能只活在这段注释里)──
+ *  1. **处置时刻的精度**:后端事件端点没有独立的「处置完成时刻」字段,
+ *     只能用 `updatedTime`。它是**最近更新时间**,对已终态的事件是处置时刻的**上界**
+ *     (研究实测见 `research/alarm-backlog-data.md`:未处理记录的 updatedTime 可晚于
+ *     eventTime 数十小时)。所以「处置数」的日期归属可能有偏差,块上必须写明。
+ *  2. **总数为累计口径**,不是「当天存在过的总数」。
+ *
+ * ── 不补 0 的边界 ──
+ * 事件行取全(分页取到底)才算得准。取不全 ⇒ 整块返回 `null` 序列 + 原因码,
+ * 由视图层如实说「这条线为什么不画」,**不拿半份数据画一条偏小的假线**。
+ */
+import { terminalStatuses, statusText } from './eventStatusVocab.js'
+import { DEFAULT_BACKLOG_TIME_ZONE, parseInstant } from './alarmBacklog.js'
+
+/** 算不出逐日趋势的原因码(视图层据此给文案;空串=正常)。 */
+export const INCIDENT_TREND_GAP = {
+    NONE: '',
+    NO_EVENTS: 'NO_EVENTS',
+    PARTIAL: 'PARTIAL',
+    BAD_WINDOW: 'BAD_WINDOW',
+    UNKNOWN_STATUS: 'UNKNOWN_STATUS',
+}
+
+/** `YYYY-MM-DD` 在某时区的当天起点(epoch ms);解析不了回 NaN。 */
+function startOfDayEpoch(dateKey, timeZone) {
+    const matched = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(dateKey == null ? '' : dateKey).trim())
+    if (!matched) return NaN
+    const naive = Date.UTC(Number(matched[1]), Number(matched[2]) - 1, Number(matched[3]), 0, 0, 0, 0)
+    // 用当日中午反解偏移,避免夏令时切换日 00:00 可能不存在的问题。
+    const probe = naive + 12 * 3600 * 1000
+    const offsetMinutes = offsetOf(probe, timeZone)
+    return Number.isFinite(offsetMinutes) ? naive - offsetMinutes * 60000 : NaN
+}
+
+/** 复用 alarmBacklog 的偏移反解(不在这里另写一份时区算术)。 */
+function offsetOf(epochMs, timeZone) {
+    const parts = new Intl.DateTimeFormat('en-US', {
+        timeZone,
+        hourCycle: 'h23',
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+        hour: '2-digit',
+        minute: '2-digit',
+        second: '2-digit',
+    }).formatToParts(new Date(epochMs))
+    const map = Object.fromEntries(parts.map((part) => [part.type, part.value]))
+    const asUtc = Date.UTC(Number(map.year), Number(map.month) - 1, Number(map.day), Number(map.hour) % 24, Number(map.minute), Number(map.second))
+    return Math.round((asUtc - epochMs) / 60000)
+}
+
+/**
+ * 某 epoch 时刻落在哪个业务日(`YYYY-MM-DD`)。用 `Intl` 而不是本地时区,
+ * 保证与后端 `Asia/Shanghai` 的分桶同一口径(跨时区部署不会整体错一天)。
+ */
+export function businessDate(epochMs, timeZone = DEFAULT_BACKLOG_TIME_ZONE) {
+    if (!Number.isFinite(epochMs)) return ''
+    const parts = new Intl.DateTimeFormat('en-CA', {
+        timeZone,
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+    }).formatToParts(new Date(epochMs))
+    const map = Object.fromEntries(parts.map((part) => [part.type, part.value]))
+    return `${map.year}-${map.month}-${map.day}`
+}
+
+/**
+ * 窗口内的逐日发生/处置趋势。
+ *
+ * @param {object} input
+ * @param {Array<{eventNo?: string, status?: string, eventTime?: string, updatedTime?: string}>} input.events 事件行(需带 status)
+ * @param {string} input.family `WARNING`/`ALARM`(决定哪一态算终态)
+ * @param {string} input.startTime ISO 窗口起点(含)
+ * @param {string} input.endTime ISO 窗口终点(含)
+ * @param {string} [input.timeZone] 业务时区
+ * @param {boolean} [input.complete] 事件行是否**取全**(分页截断时 false)
+ * @returns {{days: Array<{date: string, total: number|null, added: number, handled: number}>|null, reason: string}}
+ */
+export function dailyIncidentTrend({ events, family, startTime, endTime, timeZone = DEFAULT_BACKLOG_TIME_ZONE, complete = true } = {}) {
+    const start = parseInstant(startTime, timeZone)
+    const end = parseInstant(endTime, timeZone)
+    if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
+        return { days: null, reason: INCIDENT_TREND_GAP.BAD_WINDOW }
+    }
+    // 取不全 ⇒ 不画线:少一页会让「新增/处置」整段偏低,而偏低看不出来。
+    if (!complete) return { days: null, reason: INCIDENT_TREND_GAP.PARTIAL }
+    const list = Array.isArray(events) ? events : []
+    if (!list.length) return { days: null, reason: INCIDENT_TREND_GAP.NO_EVENTS }
+
+    // 票 71 返工(维护者 2026-10-29 第 3 条):第三条线由「累计」改为「**未处置**」。
+    // 口径:某条事件「已处置」=它的当前状态属于本族的**终态**(预警已解除 CLOSED、
+    // 报警已处理 HANDLED),处置时刻取 `updatedTime`。于是:
+    //     未处置(D) = 创建于 D 及以前 且 处置时刻晚于 D(或从未处置)
+    // 三条线因此满足**可断言的恒等式**:未处置(D) = 未处置(D-1) + 新增(D) − 处置(D)。
+    // 维护者 2026-10-30 第 3 次反馈:处置必须与 KPI 卡同口径 —— 预警的处置率分子是「已解除」、
+    // 报警是「已处理」,都是**终态**。此前用 handledStatuses(预警把已处置也算进去)导致
+    // 趋势的处置合计(6)与 KPI 处置率分子(已解除 4)对不上 —— 这就是「图例与数据对不上」。
+    const handledSet = new Set(terminalStatuses(family))
+    const added = new Map()
+    const handled = new Map()
+    // 每条事件只留两个时刻:创建时刻 at、处置时刻 handledAt(未处置为 null)。
+    const facts = []
+    for (const event of list) {
+        const status = String((event && event.status) || '').trim().toUpperCase()
+        // 认不出的状态:不进处置(不知道它是不是处置过)—— 但**必须**在返回值里报出来,
+        // 否则「处置数偏低」会被当成真实业务数据(词表出现新值的典型症状)。
+        const known = status !== '' && statusText(family, status) !== status
+        const at = parseInstant(event && event.eventTime, timeZone)
+        const handledAtRaw = parseInstant(event && (event.updatedTime || event.eventTime), timeZone)
+        const handledAt = known && handledSet.has(status) && Number.isFinite(handledAtRaw) ? handledAtRaw : null
+        if (!Number.isFinite(at)) continue
+        facts.push({ at, handledAt })
+        if (at >= start && at <= end) {
+            const day = businessDate(at, timeZone)
+            added.set(day, (added.get(day) || 0) + 1)
+        }
+        if (handledAt !== null && handledAt >= start && handledAt <= end) {
+            const day = businessDate(handledAt, timeZone)
+            handled.set(day, (handled.get(day) || 0) + 1)
+        }
+    }
+
+    // 逐日骨架:窗口覆盖的每一个业务日都要在位(缺的日子是**真实 0**,不是缺失 ——
+    // 「当天没有新增」是可断言的业务事实,与「这条线没取到数据」是两件事)。
+    const days = []
+    for (let cursor = startOfDayEpoch(businessDate(start, timeZone), timeZone); cursor <= end; cursor += 24 * 3600 * 1000) {
+        const date = businessDate(cursor, timeZone)
+        if (!date) break
+        if (days.length && days[days.length - 1].date === date) continue
+        const dayEnd = cursor + 24 * 3600 * 1000 - 1
+        // 未处置:创建于当日结束前、且(此刻仍未处置 或 处置时刻晚于当日结束)。
+        // 窗口起点之前创建、当日仍未处置的也计入 —— 那才是「截至当日还压着多少」。
+        const pending = facts.filter((fact) => fact.at <= dayEnd && (fact.handledAt === null || fact.handledAt > dayEnd)).length
+        days.push({
+            date,
+            added: added.get(date) || 0,
+            handled: handled.get(date) || 0,
+            pending,
+        })
+    }
+    const unknown = list.some((event) => {
+        const status = String((event && event.status) || '').trim().toUpperCase()
+        return status === '' || statusText(family, status) === status
+    })
+    return { days, reason: unknown ? INCIDENT_TREND_GAP.UNKNOWN_STATUS : INCIDENT_TREND_GAP.NONE }
+}
+
+/** 原因码 → 界面那句话(缺数据时说清「为什么这条线不画」)。 */
+export function incidentTrendGapText(reason, family) {
+    const label = String(family || '').toUpperCase() === 'ALARM' ? '报警' : '预警'
+    if (reason === INCIDENT_TREND_GAP.PARTIAL) return `事件未取全(分页截断),逐日${label}趋势不画,避免给出偏低的假线`
+    if (reason === INCIDENT_TREND_GAP.NO_EVENTS) return `本窗口没有${label}记录`
+    if (reason === INCIDENT_TREND_GAP.BAD_WINDOW) return '统计窗口不合法,逐日趋势不画'
+    if (reason === INCIDENT_TREND_GAP.UNKNOWN_STATUS) return `出现未登记的状态码,处置数可能偏低(已按原码显示)`
+    return ''
+}

+ 47 - 20
src/modules/cockpit/domain/monitoringPanels.js

@@ -35,6 +35,7 @@ import {
     CARD_ACTION,
     codeText,
     donutBlock,
+    donutGridBlock,
     kpiBlock,
     listBlock,
     numberish,
@@ -492,6 +493,20 @@ export function monitoringOverviewPanel(data, section) {
     const rateAvailable = current.onlineState !== 'EMPTY' && current.onlineDenominator !== null && current.onlineDenominator > 0
     const rate = rateAvailable ? current.onlineRate : null
 
+    // 设备类型三块合成一块(维护者第 16 轮):类型总量 / 各类型在线 / 各类型离线
+    // 共用一份图例 —— 三份图例是「重复图例」的根源(同一批类型名写三遍)。
+    const typeRows = (current.typeDistribution || []).map((row) => ({
+        code: row.code,
+        label: row.name || codeText(row.code),
+        count: row.deviceCount || 0,
+    }))
+    const onlineTypeRows = (current.typeDistribution || [])
+        .filter((row) => numberish(row.onlineCount) !== null)
+        .map((row) => ({ ...row, label: row.name || codeText(row.code) }))
+    const offlineTypeRows = (current.typeDistribution || [])
+        .filter((row) => numberish(row.offlineCount) !== null)
+        .map((row) => ({ ...row, label: row.name || codeText(row.code) }))
+
     const blocks = [
         kpiBlock([
             { key: 'monitoringPointCount', label: '监测点', value: valueText(current.monitoringPointCount), unit: '个' },
@@ -532,26 +547,33 @@ export function monitoringOverviewPanel(data, section) {
         //   · 缺值仍是占位符纪律:四态计数经 `numberish` 认不出回 null → 该行**整行不出**
         //     (见 `deviceStatusRows`),绝不把 null 交给 `donutBlock` 补成假 0(票 27 同类)。
         donutBlock('设备状态分布(按设备数)', deviceStatusRows(deviceStatus), { layout: 'compact' }),
-        // 维护者第 15 轮:排行用得太多 —— 设备类型分布改**堆叠条**(一条读出八类各多少与占比),
-        // 与下方「各类型在线 / 离线」两张环图形成"总量 → 分状态"的层级。
-        stackedBlock(
-            '设备类型分布',
-            sortByCountDesc(
-                (current.typeDistribution || []).map((row) => ({
-                    code: row.code,
-                    label: row.name || codeText(row.code),
-                    count: row.deviceCount || 0,
-                })),
-                (row) => row.count,
-            ),
+        // 维护者第 16 轮:设备类型分布 + 各类型在线 + 各类型离线**合成一块**「设备类型状态分布」——
+        // 上面两张环(在线 / 离线,环心分别是总在线、总离线),下面一条堆叠条(各类型设备总量构成),
+        // **共用同一份图例**(类型即图例项)。原先三块各带一份图例,重复图例才是真正的观感问题。
+        donutGridBlock(
+            '设备类型状态分布',
+            [
+                {
+                    key: 'online',
+                    label: '在线',
+                    unit: '台',
+                    slices: onlineTypeRows.map((row) => ({ key: row.code, label: row.label, count: row.onlineCount })),
+                },
+                {
+                    key: 'offline',
+                    label: '离线',
+                    unit: '台',
+                    slices: offlineTypeRows.map((row) => ({ key: row.code, label: row.label, count: row.offlineCount })),
+                },
+            ],
+            {
+                unit: '台',
+                legend: typeRows.map((row) => ({ key: row.code, label: row.label, count: row.count })),
+                // 底部堆叠条与图例同源:各类型的**设备总量**构成。
+                stack: { label: '设备类型构成', rows: typeRows.map((row) => ({ key: row.code, count: row.count })) },
+                emptyText: '当前范围没有设备类型与状态计数',
+            },
         ),
-        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(donutBlock('各类型离线设备', (current.typeDistribution || [])
-            .filter((row) => numberish(row.offlineCount) !== null)
-            .map((row) => ({ code: row.code, label: row.name || codeText(row.code), count: row.offlineCount })), { layout: 'compact' })),
     ]
 
     return {
@@ -865,6 +887,8 @@ export function operationOverviewPanel(data, section, extra = {}) {
         ], { layout: 'three', emphasis: true }),
         // 维护者第 15 轮:「设备/指标异常统计」与「异常态势 · 指标状态分布」是同一份六态数据的
         // 两种画法(横条 + 横条)—— 合并成**一张饼图**,一次读出六态占比与读数。
+        // 六态环单独一块(compact 版,环径已缩):右侧运行概况栏只有约 280px,
+        // 与趋势并排会把两者都压得读不出来(实测标题都叠在一起),所以仍是上下排。
         donutBlock('指标状态分布(六态)', abnormalCountRows(current.metricStatuses, 'metric').map((row) => ({
             code: row.key,
             label: row.label,
@@ -872,7 +896,7 @@ export function operationOverviewPanel(data, section, extra = {}) {
         })), { layout: 'compact' }),
         // 维护者 2026-10-29:预警与报警合成**一张双线折线**(同一时间窗、同一量纲),并给块顶留出
         // 呼吸空间(compact 版式),避免与块标题/点顶数值重叠。两条线仍是两个独立数据源,
-        // 只共享坐标轴与图例 —— 不改变任何统计口径。
+        // 只共享坐标轴与图例 —— 不改变任何统计口径。维护者第 15 轮:宽屏太挤 → 加 tall 档拉高。
         trendBlock('预警与报警趋势', mergedTrend.points.map((row) => ({
             key: row.bucket,
             label: bucketLabel(row.bucket, daily),
@@ -888,6 +912,9 @@ export function operationOverviewPanel(data, section, extra = {}) {
                 { key: 'alarm', label: '报警', tone: 'lv1', counts: mergedTrend.alarmCounts },
             ],
         }),
+        // 维护者 2026-10-29 走查:设备台账清单此前被移除,导致台账卡片交互整体消失。
+        // 恢复为**面板最后一块**(KPI / 六态环 / 趋势之后),整卡点击=定位 + 进详情,
+        // 与监测点台账同一交互;它是本面板唯一逐台可下钻的入口,不能缺。
         deviceLedgerBlock(devices),
     ]
 

+ 9 - 3
src/modules/cockpit/domain/overviewPanels.js

@@ -541,10 +541,14 @@ export function perceptionDevicesPanel(data, section, extra) {
                 // 选中态:当前行业筛选正好是这张卡代表的行业(再点一次即取消,走外壳既有 toggle 语义)。
                 selected: Boolean(activeIndustry) && TYPE_FILTER_KEYS[row.code] === activeIndustry,
             })),
+            // 维护者第 15 轮补充:还原 2x2(一行四张在正常分辨率下把卡压得太窄;
+            // 「取消滚动条」不是正常分辨率的要求 —— 宽屏尽量减少即可)。
             { fixed: true, layout: 'two-by-two', action: 'industry', aside: '点击按行业筛选' },
         ),
         // 片区卡:城南/城北/太常固定三项,无数据画 0(规格 §6.2 第 3 条);一排三张。
         cardsBlock('按设备片区', current.districts || [], { fixed: true, layout: 'three', aside: '片区在网设备' }),
+        // 维护者第 15 轮补充:还原为上下两块(并排在这种窄面板里把环图压得太小,
+        // 环与排行都不好读)。高度问题按「宽屏尽量减少」处理,不追求任何分辨率都无滚动条。
         supplementalBlock(donutBlock('各类型在线设备', (current.types || [])
             .filter((row) => numberish(row.onlineCount) !== null)
             .map((row) => ({ code: row.code, label: row.label, count: row.onlineCount })), { layout: 'compact' })),
@@ -771,6 +775,8 @@ export function alarmDisposalPanel(data, section) {
             {
                 chart: 'line',
                 compact: true,
+                // 维护者第 16 轮:下方口径块压到两行后腾出的高度给这张折线(保底拉高一档)。
+                tall: true,
                 aside: '未处理=报警状态未解除,非工单口径',
                 series: [
                     { key: 'newAlarms', label: '当天新增', tone: 'lv2', counts: trendOf(current.sevenDayTrend) },
@@ -786,12 +792,12 @@ export function alarmDisposalPanel(data, section) {
         ], { layout: 'compact' })),
         // 工单口径读数(只放上方还没有的读数:转单率与已核实报警总数)。
         // 处置率/平均时长**已经在上方 KPI 里**,这里重复就是噪音(同一屏说两遍)。
+        // 维护者第 16 轮:这组读数「不那么重要」,压到**两行**即可 —— 原五行占了将近一块图的高度,
+        // 把上面的「近7日报警数」折线挤成了细条。转单率与已核实总数保留(另两行是它们的分解,
+        // 已在上方环图里表达),口径说明并进提示,不再单占一行。
         supplementalBlock(rowsBlock('工单转化口径', [
             { key: 'alarmConvertRate', label: '报警转单率', value: conversionRateView(conversion) },
-            { key: 'convertedCount', label: '已转工单', value: valueText(conversion.convertedCount) + ' 条' },
-            { key: 'unconvertedCount', label: '未转工单', value: valueText(conversion.unconvertedCount) + ' 条' },
             { key: 'verifiedAlarmCount', label: '已核实报警', value: valueText(conversion.totalCount) + ' 条' },
-            { key: 'convertHint', label: '转单口径', value: conversionRateHint(conversion) },
         ])),
     ]
 

+ 89 - 5
src/modules/cockpit/domain/panelBlocks.js

@@ -395,6 +395,8 @@ function placeholderRows(rows) {
 
 // ================================================================ 四种块构造器
 
+/**
+
 /**
  * KPI 读数 → 图标语义名(2026-10-13 维护者要求「每一项都可以在左侧增加一个图标」)。
  *
@@ -426,6 +428,14 @@ export const KPI_ICONS = {
     // 语义图标复用既有词表:已解除率=resolved(同「预警-handled」),时长=time(同结案时长)。
     warningResolvedRate: 'resolved',
     warningDuration: 'time',
+    // 票 70:3+2 版式新增的两族成员。语义图标复用既有词表,不新造切图:
+    //   · 预警族「未解除」「未处置」同属「预警族读数」(warning);
+    //   · 报警族「未关联工单」沿用 industryCount(素材清单原义就是「行业数/未关联工单」);
+    //   · 报警族「已关闭报警」=已了结(resolved)。
+    warningUnresolved: 'warning',
+    warningUnhandled: 'warning',
+    alarmUnlinked: 'industryCount',
+    alarmClosed: 'resolved',
     alarmCount: 'alarm',
     abnormalCount: 'abnormalCount',
     offlineCount: 'offline',
@@ -487,6 +497,12 @@ export const KPI_ICON_COLORS = {
     warningCount: 'var(--lv2, #ffb020)',
     // 票 78:已解除率走「已解除」的语义色(与「预警-rate」同源);时长不另配色(同响应/结案时长)。
     warningResolvedRate: 'var(--lv4, #27d17a)',
+    // 票 70:3+2 新增成员的语义色 —— 未解除/未处置是「还没了结」的预警(黄),
+    // 未关联工单是待补的关联(黄),已关闭报警是已了结(绿)。
+    warningUnresolved: 'var(--lv2, #ffb020)',
+    warningUnhandled: 'var(--lv2, #ffb020)',
+    alarmUnlinked: 'var(--lv2, #ffb020)',
+    alarmClosed: 'var(--lv4, #27d17a)',
     '预警-total': 'var(--lv2, #ffb020)',
     '预警-pending': 'var(--lv2, #ffb020)',
     '预警-rate': 'var(--lv4, #27d17a)',
@@ -548,6 +564,9 @@ export function kpiBlock(items, options = {}) {
                 accent: item.accent || '',
                 caption: item.caption || '',
                 detail: item.detail || '',
+                // 票 70:栅格跨度(3+2 版式)。domain 给的是**意图**(占几栏),
+                // 渲染层只照着摆(6 栏栅格下 span 2 / span 3),两边不各写一份排版规则。
+                span: numberish(item.span) === null ? '' : numberish(item.span),
                 // 宽度预算要用**算完的** icon:有图标时标签列被多吃掉 19u(图标 14u + 列间距 5u)。
                 fitVars: kpiFitVars({
                     label: item.label,
@@ -818,6 +837,8 @@ export function donutBlock(label, rows, options = {}) {
         // 版式:缺省=环形在左、图例在右;`stack-below` =环形居中、图例在下横排
         // (设计稿的「管道分类占比」形态,且分区标题带「· 共 N 根」)。
         layout: options.layout || '',
+        // 图例带横置柱状条(维护者第 16 轮):左右式饼图靠它给出直观的占比对比。
+        legendBar: options.legendBar === true,
         total,
         action: options.action || '',
         slices,
@@ -825,6 +846,9 @@ export function donutBlock(label, rows, options = {}) {
             key: row.code ?? row.label,
             label: row.label,
             count: Number(row.count) || 0,
+            // 占比:图例里的**横置柱状条**按它定量(维护者第 16 轮:左右式饼图的图例
+            // 只有数字、没有长度对比,读者要比大小只能心算)。
+            ratio: total > 0 ? (Number(row.count) || 0) / total : 0,
             tone: barTone(row.label, index),
             actionKey: row.actionKey || row.code || row.key,
         })),
@@ -902,6 +926,36 @@ export function donutGridBlock(label, groups, options = {}) {
         // 环的固定轴(`[{key,label}]`):只在**没有任何数据**时用来保设计骨架(票 72)。
         // 有数据时该画几个环由 `groups` 决定(全 0 的行业不出环)。
         axis: (Array.isArray(options.axis) ? options.axis : []).map((item) => ({ key: item.key, label: item.label })),
+        // 可选底部堆叠条(维护者第 16 轮):与各环**共用同一份图例与色序**,
+        // 用来表达「同一批类别的总量构成」——不必再画第三张环,也不用第二份图例。
+        stack: buildLegendStack(legend, options.stack),
+    }
+}
+
+/**
+ * 共用图例的**底部堆叠条**(维护者第 16 轮):环形网格下面那条「总量构成」。
+ *
+ * 关键约束:条段色与图例**同一份**(`legend` 的 tone),几何用与 `stackedBlock` 完全相同的
+ * 整数接力算法 —— 否则同一批类别在环里一个色、在条上另一个色,读者会当成两组数据。
+ * `spec` 是调用方给的计数来源(缺省用图例行自带的 count)。
+ */
+function buildLegendStack(legend, spec) {
+    if (!spec) return null
+    const counts = new Map((Array.isArray(spec.rows) ? spec.rows : []).map((row) => [row.key, Number(row.count) || 0]))
+    const rows = legend.map((row) => ({ ...row, count: counts.has(row.key) ? counts.get(row.key) : Number(row.count) || 0 }))
+    const total = rows.reduce((sum, row) => sum + row.count, 0)
+    let acc = 0
+    let prevPct = 0
+    return {
+        label: spec.label || '',
+        total,
+        rows: rows.map((row) => {
+            acc += row.count
+            const endPct = total > 0 ? Math.round((acc / total) * 100) : 0
+            const startPct = prevPct
+            prevPct = endPct
+            return { ...row, ratio: total > 0 ? row.count / total : 0, startPct, widthPct: endPct - startPct }
+        }),
     }
 }
 
@@ -1054,9 +1108,10 @@ export function trendBlock(label, points, options = {}) {
     // SVG 只画拉伸无碍的折线 + 面积(preserveAspectRatio=none)。
     const xPct = (i) => (n > 1 ? (i / (n - 1)) * 100 : 50)
     const yPct = (count) => (max > 0 ? ((Number(count) || 0) / max) * 100 : 0)
+    const kept = rows.map((row, i) => ({ row, i }))
     const linePoints =
         chart === 'line' && n >= 2
-            ? rows.map((row, i) => ({
+            ? kept.map(({ row, i }) => ({
                   key: row.key,
                   label: row.label,
                   count: row.count || 0,
@@ -1079,6 +1134,9 @@ export function trendBlock(label, points, options = {}) {
         // 维护者第 15 轮:「宽屏太挤,应该适当拉高」—— 高度档由调用方声明(不按 panelId 特判)。
         // `tall` 用于双线对照图(线多、需要更大的竖向空间才读得出走势)。
         tall: options.tall === true,
+        // 票 71:块级空态文案(缺数据时渲染层那一行说什么)。缺省由渲染层沿用既有措辞,
+        // 既有调用点一个都不用改;只有「没有事件」与「没有时长样本」说得不同的块才传。
+        emptyText: options.emptyText || '',
         // 折线语义色键(设计稿:报警折线为橙、现有多处折线为主蓝)。渲染层查同一张色表。
         tone: options.tone || '',
         max,
@@ -1096,7 +1154,7 @@ export function trendBlock(label, points, options = {}) {
         areaPoints:
             linePoints.length >= 2
                 ? `${linePoints.map((p) => `${p.x},${p.y}`).join(' ')} 100,100 0,100`
-                : '',
+                  : '',
         // Y 轴刻度与几何比例一致;零序列只画底部 0。
         yTicks:
             chart === 'line' && n >= 2
@@ -1153,6 +1211,10 @@ function multiSeries(rows, options, { chart, n, xPct, yPct }) {
             key: series.key,
             label: series.label,
             tone: series.tone || '',
+            // 维护者 2026-10-30 第 2 条:图例悬停的读数按序列语义给。
+            // 流量(新增/处置)合计有意义;存量(未处置)只有「最新一天的余额」有意义 ——
+            // 原先一律求和,于是图例显示 60 而线上只有 1,正是「数据和图例提示对不上」。
+            summary: series.summary === 'last' ? 'last' : 'sum',
             // 每条线自己的点列(x 与标签共用主轴,y 按联合最大值归一);缺数据的桶直接跳过。
             points: series.counts
                 .map((count, index) => ({ count, index }))
@@ -1167,6 +1229,8 @@ function multiSeries(rows, options, { chart, n, xPct, yPct }) {
                     xPct: xPct(index),
                     yPct: yOf(count),
                 })),
+            // 图例悬停读数:流量算合计、存量算末日(维护者 2026-10-30 第 2 条)。
+            ...seriesSummary(series),
         })),
         // 多序列时 Y 轴刻度按联合最大值给(单序列的 yTicks 仍按自己的 max)。
         yTicks: (seriesMax > 0 ? [0, 0.5, 1] : [0]).map((f) => ({ value: seriesMax * f, pct: f * 100 })),
@@ -1191,7 +1255,7 @@ function multiSeries(rows, options, { chart, n, xPct, yPct }) {
  * @param {string} label 块标题
  * @param {Array<{key?, label: string, count: number}>} rows
  */
-export function stackedBlock(label, rows) {
+export function stackedBlock(label, rows, options = {}) {
     const list = Array.isArray(rows) ? rows : []
     const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
     // 同块内同语义 tone 的出现次数(如工单四个待办态都是 lv3):>1 时组件用 shade 深浅区分。
@@ -1214,7 +1278,10 @@ export function stackedBlock(label, rows) {
             const endPct = total > 0 ? Math.round((acc / total) * 100) : 0
             const startPct = prevPct
             prevPct = endPct
-            const tone = barTone(row.label, i)
+            // 维护者第 16 轮:堆叠条要「对不同颜色做出更明显的区分」——调用方声明 `distinct` 时
+            // 不再走语义色(排水绿/供水蓝/燃气红之外的其余行会落进同一族蓝),改用
+            // **八个互不相近的色相**按行序分配(渲染层 `seg:N` 查同一张色表)。
+            const tone = options.distinct === true ? `seg:${(i % 8) + 1}` : barTone(row.label, i)
             return {
                 key: row.key ?? row.label,
                 label: row.label,
@@ -1227,7 +1294,7 @@ export function stackedBlock(label, rows) {
                 widthPct: endPct - startPct,
                 tone,
                 // 该语义在块内是否重复(组件据此决定是否用 shade 深浅区分)。
-                toneShared: (toneCount.get(tone) || 0) > 1,
+                toneShared: options.distinct === true ? false : (toneCount.get(tone) || 0) > 1,
                 shade: list.length > 1 ? i / (list.length - 1) : 1,
             }
         }),
@@ -1273,3 +1340,20 @@ export function panelMissingNote(data) {
 export function industryText(code) {
     return industryLabel(code) || ''
 }
+
+
+/**
+ * 一条多序列线的**图例读数**(维护者 2026-10-30 第 2 条)。
+ *
+ * 流量序列(新增/处置)报合计;存量序列(未处置)报**最后一天**的余额。
+ * 之前一律求和:未处置线上只有 1-2,图例却显示 60 —— 那正是「数据和图例提示对不上」。
+ * 这里只算数与词,措辞交给渲染层拼(domain 不碰 DOM,渲染层不重算语义)。
+ */
+function seriesSummary(series) {
+    const values = series.counts.filter((value) => value !== null && value !== undefined).map((value) => Number(value) || 0)
+    if (!values.length) return { summaryValue: 0, summaryLabel: '' }
+    if (series.summary === 'last') {
+        return { summaryValue: values[values.length - 1], summaryLabel: '末日' }
+    }
+    return { summaryValue: values.reduce((sum, value) => sum + value, 0), summaryLabel: '合计' }
+}

+ 5 - 0
src/modules/cockpit/domain/panelSummary.js

@@ -24,6 +24,11 @@ export const PANEL_SUMMARY_SOURCES = Object.freeze([
     { key: 'excluded', field: 'excludedNote', hook: 'data-panel-excluded-note' },
     { key: 'link', field: 'linkNote', hook: 'data-panel-link-note' },
     { key: 'note', field: 'note', hook: 'data-panel-note' },
+    // 维护者 2026-10-30 第 2/3 条:原先画在面板体里的块级小字,统一收进这个「?」摘要。
+    { key: 'trend', field: 'trendNote', hook: 'data-panel-trend-note' },
+    { key: 'filter', field: 'filterNote', hook: 'data-panel-filter-note' },
+    { key: 'snapshot', field: 'snapshotNote', hook: 'data-panel-snapshot-note' },
+    { key: 'scope', field: 'scopeNote', hook: 'data-panel-scope-note' },
     // 票 64:运行信息的数据来源与口径说明。
     { key: 'source', field: 'sourceNote', hook: 'data-panel-source-note' },
 ])

+ 35 - 1
src/modules/cockpit/domain/panelViews.js

@@ -84,5 +84,39 @@ export function panelView(panelId, data, section, extra) {
     // 票 72(维护者 2026-10-27 反馈第 2 条):空/失败/不可用时**保留面板设计** ——
     // 结构照画、数值位占位符,而不是整块换成一句"该面板当前没有数据"。
     // 与票 67 的口径缺口同一位置:出口统一收,面板新增时不会漏(闸门按注册表正向普查)。
-    return emptyDesignView(withFilterCaliber(build(data, section, extra), panelId), section)
+    return emptyDesignView(withFilterCaliber(liftBlockNotes(build(data, section, extra)), panelId), section)
+}
+
+/**
+ * 把**块级小字**(`block.aside`、`block.pageNote`)折成面板级摘要字段(维护者 2026-10-30 第 2 条)。
+ *
+ * 原话:「不需要『未处置=截至当日仍未处置 · 处置按更新时间…』『已加载 2 条…』
+ * 『本窗口内创建的工单共 2 张…』等的小字提示,非要提示就统一放到右上角『?』摘要里」。所以:
+ *   · 块上不再画这两行(渲染层也一并停画,见 `PanelBlocks.vue`);
+ *   · 文案本身**不丢**:按出现顺序去重后并进 `view.trendNote` / `view.pageNote`;
+ *   · 仍然只有一份来源(各面板 domain 写的原文),这里只做搬运与拼接,不新造措辞。
+ *
+ * @param {object} view 面板视图(`build()` 的产出)
+ */
+function liftBlockNotes(view) {
+    if (!view || !Array.isArray(view.blocks)) return view
+    const collect = (field) => {
+        const texts = view.blocks
+            .map((block) => (block ? block[field] : ''))
+            .filter((text) => typeof text === 'string' && text)
+        return [...new Set(texts)].join(' · ')
+    }
+    const trendNote = collect('aside')
+    const pageNote = collect('pageNote')
+    const blocks = view.blocks.map((block) => {
+        if (!block || (!block.aside && !block.pageNote)) return block
+        const { aside, pageNote: dropped, ...rest } = block
+        return rest
+    })
+    return {
+        ...view,
+        blocks,
+        ...(trendNote ? { trendNote } : {}),
+        ...(pageNote ? { pageNote: view.pageNote ? `${view.pageNote} · ${pageNote}` : pageNote } : {}),
+    }
 }

+ 467 - 66
src/modules/cockpit/domain/warningResponsePanels.js

@@ -16,6 +16,9 @@ import {
     toEventItem,
 } from './eventList.js'
 import { valueText, workOrderRows } from './equipmentDetailView.js'
+import { serverPageView } from './pagination.js'
+import { statusText, statusTone } from './eventStatusVocab.js'
+import { incidentTrendGapText } from './incidentTrend.js'
 import { EVENT_FAMILY } from './events.js'
 import { WORK_ORDER_STATES } from './workOrder.js'
 import { industryLabel } from './filterOptions.js'
@@ -290,6 +293,11 @@ export function toHandlingEfficiency(raw) {
             bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
             count: numberish(item && item.eventCount) || 0,
         })),
+        // 票 70:已关闭报警数(**事件级**口径:多少条报警的回联工单已结案)。
+        // 来源是逐桶的 `handledCount` 求和(后端 `durationTrend` 按事件回联口径给)。
+        // 与「工单状态分布」里的已结案**工单张数**不是同一口径,两者都在屏上,各带口径说明。
+        // 桶缺该字段(预警族/旧载荷)⇒ null:**不知道不是 0**。
+        closedAlarmCount: closedAlarmCount(trendRowsRaw),
         trend: trendRowsRaw.map((item) => ({
             bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
             count: item && item.averageDuration
@@ -297,6 +305,24 @@ export function toHandlingEfficiency(raw) {
                 : numberish(item && (item.handlingEfficiency === undefined ? item.count : item.handlingEfficiency)),
             unit: item && item.averageDuration ? item.averageDuration.unit || '' : '',
         })),
+        // 票 71 返工(维护者 2026-10-29 第 2 条):逐日「总数 / 新增数 / 处置数」。
+        // 数据源是**事件行**(需带 status + eventTime + updatedTime),由 store 取全后
+        // 注入 raw.incidentTrend(见 stores/eventStore.js 的 incidentTrendFor)——
+        // 算它要事件行取全,而 domain 转换只该看形状、不该再发请求。
+        // 缺这一段(旧载荷/事件行没取全)⇒ null:界面据此说「不画」的原因,不画假线。
+        incidentTrend: reader.raw.incidentTrend && typeof reader.raw.incidentTrend === 'object' ? reader.raw.incidentTrend : null,
+        // 票 71:本窗口的**事件行**(后端已按事件时间倒序、分页下发)。
+        // 报警效率/预警效率面板的「报警列表 / 预警列表」块就画这一份 —— 与 KPI
+        // 「统计数」(`summary.totalEvents`)是**同一次过滤**的结果(同一请求、同一范围),
+        // 分页只切窗口不改口径。行的投影在 `eventListRowView`(与票 13/65 的卡片同一套形状)。
+        events: events.map(toEventItem),
+        // 票 71:后端分页回显(页码/页大小/总数/总页数)。前端不自行推算(票 61 纪律)。
+        pagination: reader.raw.pagination && typeof reader.raw.pagination === 'object' ? reader.raw.pagination : null,
+        // 票 71:**多窗合并**的分页形态(`{mode:'merged', windowCount, loaded, rangeKey}`,
+        // 由 `mergeEfficiencyPayloads` 给)。单窗载荷没有这一段 ⇒ null(走服务端页码)。
+        // 少了这条透传,多窗合并后会拿第一窗的 `pagination` 当全局页 —— 列表会画出
+        // 「共 total 条」但只列出首页行(t71 的 F6/F7 钉住这件事)。
+        paging: reader.raw.paging && typeof reader.raw.paging === 'object' ? reader.raw.paging : null,
         range: rangeView(raw),
         missing: reader.missing,
     }
@@ -338,6 +364,13 @@ export function toEventAnalysis(raw) {
         levels: Array.isArray(dimensions) ? levelRows(dimensionRows('LEVEL')) : levelRows(source.levels || source.byLevel),
         industries: Array.isArray(dimensions) ? dimensionRows('INDUSTRY', industryText) : countRows(source, 'industries', industryText).concat(countRows(source, 'byIndustry', industryText)),
         causes: Array.isArray(dimensions) ? dimensionRows('CAUSE', causeText) : countRows(source, 'causes', causeText).concat(countRows(source, 'byCause', causeText)),
+        // 维护者 2026-10-30 第 4 条:后端 CAUSE 维**只有 UNKNOWN_CAUSE 一个桶**
+        // (实测报警 2 条 / 预警 1 条全部落在「未知/缺失成因」——数据库里没有成因字段),
+        // 所以「按成因」是一块永远说「未知」的假数据。改用 DEVICE_TYPE:
+        // 两族都有值且后端已给中文名(漏失监测仪 / 仁科环境监测器 / 平升液位流量终端…)。
+        deviceTypes: Array.isArray(dimensions)
+            ? dimensionRows('DEVICE_TYPE')
+            : countRows(source, 'deviceTypes').concat(countRows(source, 'byDeviceType')),
         // 片区不上翻译层:后端给的就是中文名(城南/城北/太常/未分区),码是 '0'/'1'/'2'。
         regions: Array.isArray(dimensions) ? dimensionRows('DISTRICT') : countRows(source, 'regions').concat(countRows(source, 'byRegion')),
         highlights,
@@ -380,21 +413,55 @@ function historyTrendRows(rows = []) {
  *     (多窗不存在全局页,不伪造页码)。
  * 没有 `paging` 元数据(旧 mock/旧载荷)时退化为普通清单块,不画翻页控件。
  */
+/**
+ * 事件行 → **卡片行**(票 71 抽出;历史事件、报警列表、预警列表三处共用这一份)。
+ *
+ * 为什么抽出来:票 71 要求「报警/预警两族共用同一套列表块形态」以及「卡片形态与既有
+ * 事件卡一致」。三处各写一份投影必然漂移(改了等级色却漏了状态色这种),
+ * 而「共用」这件事**只能由同一份代码保证**,不能靠抄得像。
+ *
+ * 字段一律来自 `eventRowView`(票 13/65 立的事件行唯一投影):等级与状态双色键、
+ * 内容摘要、设备/编号/时间/工单。本函数只做**卡片再包装**,不新造第二套字段口径。
+ *
+ * @param {Array} events `toEventItem()` 的领域行
+ * @param {string} family `WARNING`/`ALARM`(身份族,决定详情抽屉取哪一族)
+ */
+export function eventCardRows(events, family) {
+    const code = String(family || '').toUpperCase()
+    return (Array.isArray(events) ? events : []).map((item) => {
+        const view = eventRowView({ ...item, family: code })
+        return {
+            key: view.key,
+            title: view.name,
+            identityKind: CARD_ACTION.EVENT,
+            identityCode: view.identityCode,
+            identityFamily: view.identityFamily || family,
+            identityPayload: view,
+            identityReason: view.identityReason,
+            // 卡片主行:等级(带色)+ 名称 + 处理状态(带色)
+            tone: view.level.tone,
+            toneLabel: view.level.label,
+            // 状态**按族取词**(维护者 2026-10-29 第 1 条):预警六态、报警二态是两套词表,
+            // 共用一张会把 PENDING 译成「待处理」(实为待接收)、让 UNHANDLED 漏英文码上屏。
+            // 该端点未下发 status 时如实说「状态未发布」,不猜成某个状态。
+            statusTone: statusText(code, item.status) ? statusTone(code, item.status) : 'neutral',
+            statusLabel: statusText(code, item.status) || '状态未发布',
+            // 内容摘要:让用户直接看到"大概发生了什么"
+            summaryText: view.summaryText,
+            // 副信息:设备 / 编号 / 时间 / 工单
+            meta: [
+                view.deviceLabel ? `设备 ${view.deviceLabel}` : '',
+                view.code,
+                view.eventTimeText || view.eventTime,
+                view.workOrderStateName ? `工单${view.workOrderStateName}` : '',
+            ].filter(Boolean),
+            points: [],
+        }
+    })
+}
+
 function historyEventListBlock(current) {
-    const events = current.events || []
-    const rows = events.map((item) => ({
-        key: item.code,
-        title: item.name || item.code,
-        identityKind: CARD_ACTION.EVENT,
-        identityCode: item.code,
-        identityFamily: String(current.mode || item.family || '').toUpperCase(),
-        identityPayload: { ...eventRowView(item), family: String(current.mode || item.family || '').toUpperCase() },
-        identityReason: 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 rows = eventCardRows(current.events, String(current.mode || '').toUpperCase())
     const emptyText = '当前时间范围没有历史事件'
     const paging = current.paging && typeof current.paging === 'object' ? current.paging : null
     if (!paging) return listBlock('历史事件', rows, emptyText)
@@ -443,6 +510,113 @@ export function responseEfficiencyTitle(mode) {
     return `${eventListSwitchView(mode).label}效率`
 }
 
+/**
+ * 预警/报警**「发生 · 处置」逐日趋势块**(票 71 返工,维护者 2026-10-29 第 2 条)。
+ *
+ * ── 推翻上一版的原因(记在这里,免得下一个人又画回去)──
+ * 上一版画「平均结案时长」:后端逐桶样本极稀(7 天 169 个小时桶里只有 1 个有样本),
+ * 按日聚合后整个窗口**只剩一个点** —— 一个点连不成线,屏上「只有点没有线」。
+ * 维护者改判口径:**每一日的总数 / 新增数 / 处置数**(条数口径,每天都有值,画得出走势)。
+ *
+ * ── 三条线的语义(算法见 `domain/incidentTrend.js`)──
+ *   · 新增数 = 当天发布的条数(`eventTime` 落在当天);
+ *   · 处置数 = 当天走到**本族终态**的条数(预警 CLOSED / 报警 HANDLED,按 `updatedTime` 归日);
+ *   · 总数   = 截至当天结束的**累计**条数。
+ *
+ * **两族共用这一个构造函数**(票 71 要求 4):差异只在词表与终态码,
+ * 而「哪一态算终态」由 `eventStatusVocab.terminalStatuses(family)` 回答。
+ *
+ * @param {object} current 领域对象(需含 `incidentTrend`,由 `toHandlingEfficiency` 产出)
+ * @param {string} family `WARNING`/`ALARM`
+ */
+export function efficiencyIncidentTrendBlock(current, family) {
+    const view = eventListSwitchView(family)
+    const label = `${view.label}发生处置趋势`
+    const trend = current.incidentTrend || null
+    const days = (trend && trend.days) || null
+    // 取不全/无记录/窗口不合法 ⇒ **不画线**,由块级文案说清原因(不画偏低的假线)。
+    const emptyText = incidentTrendGapText(trend && trend.reason, view.key)
+    const points = (days || []).map((day) => ({
+        key: day.date,
+        // X 轴短读法 MM/DD;完整日期与三个读数进悬停。
+        label: `${day.date.slice(5, 7)}/${day.date.slice(8, 10)}`,
+        // 主轴读数用「新增」(每日增量是这个问题的主角);处置数与未处置数走各自序列。
+        count: day.added,
+        hint: `${day.date} · 新增 ${day.added} · 处置 ${day.handled} · 未处置 ${day.pending}`,
+    }))
+    return trendBlock(label, points, {
+        chart: 'line',
+        compact: true,
+        emptyText,
+        // 多序列(沿用维护者 2026-10-23 的双线成例):三条线共用 x 轴与联合最大值 + 图例。
+        // 三个色键各表一个语义:新增=lvl2 关注、处置=lvl4 了结、累计=lvl3 规模。
+        series: days
+            ? [
+                  // 维护者 2026-10-30 第 2 条:图例悬停原先显示「整段求和」,对存量(未处置)毫无意义,
+                  // 也和线上的读数对不上。改成每条序列自己的口径:流量算合计、存量算末日。
+                  { key: 'added', label: `新增${view.label}`, tone: 'lv2', counts: days.map((day) => day.added), summary: 'sum' },
+                  { key: 'handled', label: `处置${view.label}`, tone: 'lv4', counts: days.map((day) => day.handled), summary: 'sum' },
+                  // 维护者 2026-10-29 第 3 条:原「累计」改为「未处置」(截至当日仍压着的条数)。
+                  // 标签按族:预警的终态是「已解除」⇒ 补集叫「未解除」(与 KPI 卡「未解除预警」同词),
+                  // 报警的终态是「已处理」⇒ 补集叫「未处置」。用词跟 KPI 卡一致,数字才能互相对上。
+                  { key: 'pending', label: `${family === 'WARNING' ? '未解除' : '未处置'}${view.label}`, tone: 'lv1', counts: days.map((day) => day.pending), summary: 'last' },
+              ]
+            : [],
+        // 口径必须上屏(不能只活在这段注释里):处置时刻用的是更新时间,累计是截至当日。
+        aside: family === 'WARNING' ? '未解除=截至当日仍未解除 · 处置按更新时间归日' : '未处置=截至当日仍未处置 · 处置按更新时间归日',
+    })
+}
+
+/**
+ * 报警/预警**列表块**(票 71)—— 同样只有这一份,参数化 family。
+ *
+ * ── 口径三件事 ──
+ *   ① 行来自**同一次筛选**的事件集合,与 KPI「统计数」同源,不另取一套口径;
+ *   ② 分页走服务端页码(单窗)或前端集合分页(多窗合并),回显一律经 `serverPageView`;
+ *   ③ 卡片形态与既有事件卡一致(`eventCardRows` → `eventRowView`),可点开详情。
+ *
+ * **状态必须按族取词**(维护者 2026-10-29 第 1 条反馈):预警六态、报警二态,
+ * 由 `eventStatusVocab` 回答 —— 共用一张表会把 `PENDING` 译成「待处理」(实为待接收)、
+ * 让报警的 `UNHANDLED` 直接漏英文码上屏。样式(卡片/标签形态)两族共用,词条各自定义。
+ *
+ * @param {object} current 领域对象
+ * @param {string} family `WARNING`/`ALARM`
+ */
+export function efficiencyEventListBlock(current, family) {
+    const view = eventListSwitchView(family)
+    const label = `${view.label}列表`
+    const rows = eventCardRows(current.events, view.key)
+    const page = current.pagination || null
+    const total = numberish(current.totalEvents)
+    // 多窗(近 30/90 天):`events` 是各窗合并后的**已加载集合**,没有全局页。
+    const paging = current.paging && typeof current.paging === 'object' ? current.paging : null
+    // 页脚口径:列表条数的分母就是 KPI 那张「统计数」卡,写明同源。
+    const basisNote = total === null
+        ? `与上方「统计${view.label}数」同源(同一次筛选结果)`
+        : `本窗口${view.label}共 ${total} 条 · 与上方「统计${view.label}数」同源(同一次筛选结果)`
+    if (paging && paging.mode === 'merged') {
+        return listBlock(label, rows, `当前时间范围没有${view.label}`, {
+            paginate: true,
+            pageScope: `${view.key}|${paging.rangeKey || ''}`,
+            pageNote: (paging.windowCount || 0) > 1
+                ? `已合并 ${paging.windowCount} 个时间窗 · 已加载 ${paging.loaded || 0} 条(多窗范围不对单页做服务端分页)· ${basisNote}`
+                : `已加载 ${paging.loaded || 0} 条(清单取自本窗口全部事件,前端分页)· ${basisNote}`,
+        })
+    }
+    // 分页回显**必须走 `serverPageView`**(票 61 单源):越界夹回/缺 totalPages 补算/空集 1 页
+    // 都在那里定义。自己拼字段会漏掉夹回 —— 实测直传 `page:99` 会画出「第 99/3 页」。
+    // 兜底总数只用**后端回显的 total**,不拿 `rows.length` 顶:本块的行可能是多窗合并的
+    // 全量集合(92 条),而 `pagination` 是单窗某页的回显 —— 两者混用会画出
+    // 「共 92 条 / 19 页」却只对应一页的行(E4 钉住)。
+    const serverPage = page ? serverPageView(page, total === null ? 0 : total) : undefined
+
+    return listBlock(label, rows, `当前时间范围没有${view.label}`, {
+        serverPage,
+        pageNote: basisNote,
+    })
+}
+
+
 /**
  * 预警效率面板的**口径说明**(票 65 立项;**票 78 改写**)。
  *
@@ -459,8 +633,9 @@ export function responseEfficiencyTitle(mode) {
  */
 export const WARNING_EFFICIENCY_GAP =
     '预警不挂工单,工单口径的处置率与时长只在报警效率出现;' +
-    '这里的条数、已解除率、平均处置时长与预警状态分布都是本窗口内预警自身的处置流程统计。' +
-    '已解除率=已解除的预警 ÷ 本窗口预警条数(分母即第一张卡的条数),平均处置时长从预警发布计到已解除'
+    '这里的预警数、未解除、未处置、处置率、平均处置时长与预警状态分布都是本窗口内预警自身的处置流程统计。' +
+    '未解除=待接收/已接收/处置中/已处置(不含草稿与已解除),未处置=其中还没人接手的三态;' +
+    '处置率=已解除的预警 ÷ 本窗口预警条数(分母即第一张卡的条数),平均处置时长从预警发布计到已解除'
 
 /**
  * 已解除率 → 界面读数(票 78,预警族)。
@@ -506,83 +681,236 @@ export function warningDurationHint(current) {
 }
 
 /**
- * 预警族 KPI:预警自身的处置流程读数(票 78;票 65 立的三卡已随票 74 契约升级)。
- * 三张卡=条数 / 已解除率 / 平均处置时长,读 `summary.warning*` 新字段;
- * 「已关联/未关联工单」退到窗口读数(真实计数仍在,只是不占 KPI 位 —— 见 `warningRangeNote`)。
- * 读数缺失走 `valueText` 的占位符(票 22/42 纪律:不把「没有」画成 0)。
+ * 3+2 栅格的**列跨度**(票 70)。
+ *
+ * 上排三卡等分、下排两卡填满整行 ⇒ 用**统一的 6 栏栅格**表达:
+ * 上排每张占 2 栏(2+2+2),下排每张占 3 栏(3+3)。
+ * 跨度值写在卡片上(`span`),渲染层据此排版 —— **领域层给的是意图,
+ * 渲染层只照着摆**,两边都不再各写一份「3+2 怎么排」。
+ *
+ * 共用一条栅格规则(两族一样),但**成员表分别定义**(见下两个函数):
+ * 预警与报警的指标不是同一批,混成一张表就又变成「两族一样」。
+ */
+export const KPI_GRID_SPAN = { third: 2, half: 3 }
+
+/**
+ * 预警族 3+2 指标(票 70 维护者给定)。
+ *
+ * **上排**:统计预警数 / 未解除预警 / 未处置预警
+ * **下排**:处置率 / 平均处置时长
+ *
+ * 口径(每一条都有出处,不是自拟):
+ *   · 统计预警数 = `summary.totalEvents`(本窗口预警条数;处置率的分母就是它)。
+ *   · 未解除预警 = 六态里 PENDING/RECEIVED/PROCESSING/HANDLED 之和 —— 与后端
+ *     `CockpitOverviewModulesService.UNRESOLVED_STATUSES` **同一条口径**
+ *     (该常量就是这四态;DRAFT/CLOSED 不算未解除)。
+ *   · 未处置预警 = 六态里 PENDING/RECEIVED/PROCESSING 之和(「还没人接手」的一批)。
+ *   · 处置率 = 已解除 ÷ 本窗口预警条数(`warningHandlingEfficiency`,后端同算法)。
+ *   · 平均处置时长 = 发布→已解除的平均时长(`warningDuration`,单位取后端 `unit`)。
+ *
+ * **缺值一律占位符,不补 0**(票 05/16 纪律):六态没给(旧载荷/报警族)时
+ * 未解除/未处置都是 `—`(我们**不知道**,不是 0)。
  */
 function warningEfficiencyKpis(current) {
+    const states = current.warningStates
+    const counts = states && states.length ? warningStateCounts(states) : null
     return kpiBlock([
-        { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' },
-        { key: 'warningResolvedRate', label: '已解除率', value: warningResolveRateView(current), hint: warningResolveRateHint(current) },
-        { key: 'warningDuration', label: '平均处置时长', value: warningDurationView(current), hint: warningDurationHint(current) },
-    ])
+        { key: 'warningTotal', label: '预警数', value: valueText(current.totalEvents), unit: '条', span: KPI_GRID_SPAN.third },
+        {
+            key: 'warningUnresolved',
+            label: '未解除预警',
+            value: counts === null ? valueText(null) : valueText(counts.unresolved),
+            unit: '条',
+            span: KPI_GRID_SPAN.third,
+            hint: counts === null ? '' : '待接收、已接收、处置中、已处置(不含草稿与已解除)',
+        },
+        {
+            key: 'warningUnhandled',
+            label: '未处置预警',
+            value: counts === null ? valueText(null) : valueText(counts.unhandled),
+            unit: '条',
+            span: KPI_GRID_SPAN.third,
+            hint: counts === null ? '' : '待接收、已接收、处置中(还没人接手的一批)',
+        },
+        {
+            key: 'warningResolvedRate',
+            label: '处置率',
+            value: warningResolveRateView(current),
+            hint: warningResolveRateHint(current),
+            span: KPI_GRID_SPAN.half,
+        },
+        {
+            key: 'warningDuration',
+            label: '平均处置时长',
+            value: warningDurationView(current),
+            // 票 70 要求 4:「单位与含义在卡上自明」—— 含义不能只藏在 hover 的 hint 里。
+            // 单位由后端 warningDuration.unit 决定(不写死 h),含义用卡内小字常显。
+            caption: '发布 → 已解除',
+            hint: warningDurationHint(current),
+            span: KPI_GRID_SPAN.half,
+        },
+    ], { layout: 'three-plus-two' })
 }
 
-/** 报警族 KPI:工单口径(工单是报警的处置手段 —— 票 41/42/52 既有裁定,本票不动)。 */
+/**
+ * 报警族 3+2 指标(票 70 维护者给定)。
+ *
+ * **上排**:统计报警数 / 未关联工单 / 已关闭报警
+ * **下排**:处置率 / 平均关闭时长
+ *
+ * 口径:
+ *   · 统计报警数 = `summary.totalEvents`(本窗口报警条数)。
+ *   · 未关联工单 = `summary.missingWorkOrderCount`(报警没回联到工单的条数)。
+ *   · 已关闭报警 = 本窗口内**已结案工单**(状态 5)的条数 —— 读
+ *     `durationTrend[].handledCount` 的和(逐桶的已结案计数,后端按事件回联口径给)。
+ *   · 处置率 = 已结案工单 ÷ 可计入工单(状态 0–6,排除已取消)。
+ *   · 平均关闭时长 = `summary.averageDuration`(工单结案时长,单位取后端 `unit`)。
+ *
+ * 与预警族一样:**缺值占位符、不补 0**。已关闭报警缺趋势口径(预警族/旧载荷)时是 `—`。
+ */
 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']) },
-    ])
+        { key: 'alarmTotal', label: '报警数', value: valueText(current.totalEvents), unit: '条', span: KPI_GRID_SPAN.third },
+        {
+            key: 'alarmUnlinked',
+            label: '未关联工单',
+            value: valueText(current.missingWorkOrderCount),
+            unit: '条',
+            span: KPI_GRID_SPAN.third,
+            hint: '本窗口报警里还没回联到工单的条数',
+        },
+        {
+            key: 'alarmClosed',
+            label: '已关闭报警',
+            value: valueText(current.closedAlarmCount),
+            unit: '条',
+            span: KPI_GRID_SPAN.third,
+            hint: '本窗口内回联工单已结案的报警条数(事件口径)',
+        },
+        {
+            key: 'handlingRate',
+            label: '处置率',
+            value: handlingRateView(current),
+            hint: handlingRateHint(current),
+            span: KPI_GRID_SPAN.half,
+        },
+        {
+            key: 'closeDuration',
+            label: '平均关闭时长',
+            value: durationView(current, ['closeDuration']),
+            // 票 70 要求 4:含义常显(不靠 hover)。单位取后端 averageDuration.unit。
+            caption: '工单结案耗时',
+            hint: closeDurationHint(current),
+            span: KPI_GRID_SPAN.half,
+        },
+    ], { layout: 'three-plus-two' })
+}
+
+/**
+ * 六态分布行 → 预警族两个计数(未解除 / 未处置)。
+ *
+ * **口径与后端同源**(不是自拟):后端 `CockpitOverviewModulesService` 的
+ * `UNRESOLVED_STATUSES = PENDING / RECEIVED / PROCESSING / HANDLED` —— 未解除就是这四态之和
+ * (DRAFT 还没发布、CLOSED 已了结,都不算未解除)。未处置取其中「还没人接手」的三态
+ * (PENDING / RECEIVED / PROCESSING)—— HANDLED 已处置过,不算未处置。
+ *
+ * 分布缺失(`warningStates` 为 null)时回 null:**不知道就是不知道,不画 0**。
+ */
+function warningStateCounts(states) {
+    const byCode = {}
+    for (const row of states) byCode[String(row.code)] = numberish(row.count) || 0
+    const sum = (...codes) => codes.reduce((total, code) => total + (byCode[code] || 0), 0)
+    return {
+        unresolved: sum('PENDING', 'RECEIVED', 'PROCESSING', 'HANDLED'),
+        unhandled: sum('PENDING', 'RECEIVED', 'PROCESSING'),
+    }
+}
+
+/**
+ * 已关闭报警数(报警族,票 70)—— 逐桶 `handledCount` 求和。
+ *
+ * 来源是 `durationTrend[].handledCount` —— 后端逐桶回「该桶内已结案的报警数」
+ * (`CockpitHandlingEfficiencyService.durationTrend`:报警族按事件回联到已结案工单计)。
+ * 报警族没有 `warning*` 那样的专属聚合字段,用这一份**逐桶计数**求和是对的:
+ * 它是**事件级**口径(多少条报警已了结),与工单张数口径(`workOrderStateCounts`)不同。
+ *
+ * **缺值不补 0**:桶缺该字段(预警族/旧载荷)或根本没有趋势行 ⇒ null
+ * (不是「0 条已关闭」)。
+ */
+function closedAlarmCount(trendRows) {
+    const rows = Array.isArray(trendRows) ? trendRows : []
+    if (!rows.length) return null
+    let sum = 0
+    for (const row of rows) {
+        const count = numberish(row && row.handledCount)
+        if (count === null) return null
+        sum += count
+    }
+    return sum
+}
+
+/**
+ * 平均关闭时长的提示(报警族,票 70)。
+ *
+ * `closeDuration` 为 null 有两种来由:后端 `averageDuration.state=EMPTY`(窗口内没有已结案工单)
+ * 或旧载荷根本没有这个聚合。两种都给占位符,但**说清是哪一种** ——
+ * 「没有样本」与「后端没给」不是同一句话。
+ */
+function closeDurationHint(current) {
+    const duration = current && current.closeDuration
+    if (duration) return ''
+    return '窗口内没有已结案工单,暂无关闭时长样本'
 }
 
 /**
- * 响应效率(「预警效率」)面板(票 19;**票 65 两族语义分流**)。
+ * 响应效率(「预警效率」)面板(票 19;票 65 两族分流;票 71 三轮返工)。
  *
- * 预警族与报警族的处置对象根本不同:**工单是报警的处置手段**(票 41/42/52),
- * 而预警不挂工单。所以同一个端点返回的工单口径字段只在报警模式下有意义;
- * 预警模式下照画就是「用一个不存在的对象统计预警」(2026-10-19 维护者反馈第 6 条)。
+ * 块序:KPI → 趋势 → 分布 → **列表垫底**(维护者第 16 轮第 8 条)。列表可达近百行,
+ * 排在中间会把走势与分布挤出首屏;读者先看结论与走势,逐条清单最后才展开。
  *
- * `extra.mode` 是当前切换族(外壳从 `eventStore.mode` 传入);`data.family` 是后端回显,
- * 两者都有时以**回显**为准(那份数据确实是哪一族就以哪一族画)。都没有时按报警口径
- * (工单口径是既有形态,不因本票对旧调用点改造)。
+ * 预警族已按维护者 2026-10-29 反馈删除「预警等级分布」与「预警发生趋势」两块。
  *
  * @param {object|null} data `toHandlingEfficiency()` 的领域对象
  * @param {object} section 分区
- * @param {{mode?: string}} [extra] 当前切换族
+ * @param {{mode?: string, workOrderList?: object}} [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),
+            // 票 71:逐日 新增/处置/未处置 三条线(排最前,与报警族同一块构造)。
+            efficiencyIncidentTrendBlock(current, EVENT_FAMILY.WARNING),
             // 票 78:预警状态分布(WarningWorkflow 六态 + 脏状态的「未知」桶)。
-            // 行的中文名由后端 `stateName` 下发,前端不另造状态词;字段缺失(旧载荷/
-            // 报警族)不画假块 —— 缺失不画假 0 的同一纪律。全 0 也是真实分布(缺桶计 0)。
-            // 维护者第 15 轮(预警模式):状态分布改**堆叠条**(六态一条读出流程推进),
-            // 等级分布改**饼图**(四级是固定语义阶梯,环图比排行更直观)。
             ...(Array.isArray(current.warningStates) && current.warningStates.length
-                ? [stackedBlock('预警状态分布', current.warningStates)]
+                ? [stackedBlock('预警状态分布', current.warningStates.map((row) => ({
+                       key: row.code,
+                       label: row.label,
+                       count: row.count,
+                   })))]
                 : []),
-            donutBlock('预警等级分布', current.levelDistribution || [], { layout: 'compact' }),
-            trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line', compact: true }),
+            efficiencyEventListBlock(current, EVENT_FAMILY.WARNING),
         ], {
             label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
             range: current.range,
-            // 口径说明(走票 56 的折叠摘要,`data-panel-note`):说清「预警不挂工单」
-            // 与这些数是预警自身的处置流程统计、已解除率的分母(票 78 改写)。
             note: WARNING_EFFICIENCY_GAP,
             rangeNote: warningRangeNote(current),
         })
     }
     return sectionView('responseEfficiency', section, [
         alarmEfficiencyKpis(current),
-        // 2026-10-13 图表选型:工单状态是多类别占比 → 百分比堆叠条(饼图超过 5 类不再适合)。
+        // 票 71:逐日 新增/处置/未处置 三条线。
+        efficiencyIncidentTrendBlock(current, EVENT_FAMILY.ALARM),
         stackedBlock('工单状态分布', current.workOrderStates || []),
-        // 时长趋势仍是时间序列 → 折线。
-        trendBlock('平均结案时长趋势', trendRows((current.trend || []).filter((row) => row.count !== null)), { chart: 'line', compact: true }),
-        // 票 65 追加:报警模式下直接列出当前工单(卡片,可点开详情)。
-        // 放在图之后:读者先看结论(处置率/分布),再看逐张明细。
+        // 维护者第 16 轮(第 8 条):**列表放最下面** —— 趋势与分布提上来,逐条清单垫底。
+        // 原来夹在趋势和分布之间,把走势挤到了下方。
+        efficiencyEventListBlock(current, EVENT_FAMILY.ALARM),
+        // 工单内容不属本票(维护者第 3 条交由另一会话),保持原样。
         workOrderListBlock(extra),
     ], {
         label: responseEfficiencyTitle(EVENT_FAMILY.ALARM),
         range: current.range,
-        // 报警族口径说明:工单是报警的处置手段(与预警族那句并列,切换时一起换)。
         note: ALARM_EFFICIENCY_BASIS,
         rangeNote: current.rangeNote === undefined
             ? (current.denominator === null || current.denominator === undefined
@@ -641,6 +969,20 @@ function workOrderListBlock(extra) {
     })
 }
 
+/**
+ * 工单 → 列表卡片行(票 65 精简形态)。
+ *
+ * 字段选择只取「一眼判断该不该点开」的信息:工单号、状态、设备、描述、创建时间。
+ * 行字段名**贴着共享渲染层已认的形状**(`code`/`name`/`tone`/`toneLabel`/`meta`):
+ * `PanelBlocks` 的 list 块见到 `row.code` 就把整张卡做成可点(点击 emit `open-card`),
+ * 见到 `tone`+`toneLabel` 就画状态标签 —— 所以这里不再发明第二套卡片协议。
+ *
+ * 文本一律复用 `equipmentDetailView.workOrderRows`(单一词表:状态字典 0–7、
+ * 关联文本、时间短读法都在那儿定过一次),本函数只做**卡片需要的再包装**。
+ *
+ * @param {Array} orders `toWorkOrder()` 的领域对象数组
+ */
+
 /**
  * 工单 → 列表卡片行(票 65 精简形态)。
  *
@@ -685,6 +1027,23 @@ export function workOrderCardRows(orders) {
     }))
 }
 
+/**
+ * 工单状态 → `StatusTag` 的色键(票 65 卡片用)。
+ *
+ * **不登记第二张状态词表**:词(待派单…)一律走 `workOrder.js` 的 `WORK_ORDER_STATES`,
+ * 这里只回答「这一态借哪一枚既有色键上色」。
+ *
+ * 为什么映射到既有键而不是新增键:`StatusTag` 的 tone 是**封闭词表**
+ * (`domain/statusTones.js` 的 `TONES`,未知 tone 会退回中性灰),
+ * 新增 `state-closed` 之类必须同时改那张表与样式,而那属于视觉批次;
+ * 本票只需「一眼看出这张工单是等着办、在办还是已了」,借用四档语义色即可:
+ *   · 待派单/待接单 → `lv1`(最需立刻响应,借一级告警色);
+ *   · 已接单/处理中/待验收 → `lv3`(在办,借三级信息色);
+ *   · 延期中 → `lv2`(旁路,需要盯);
+ *   · 已结案 → `lv4`(了结,借正常色);
+ *   · 已取消/认不出 → `neutral`(中性,不暗示任何态度)。
+ */
+
 /**
  * 工单状态 → `StatusTag` 的色键(票 65 卡片用)。
  *
@@ -715,7 +1074,8 @@ export function workOrderStateTone(stateCode) {
 
 /** 报警族的口径说明(与预警族那句成对:切换时口径说明一起换)。 */
 export const ALARM_EFFICIENCY_BASIS =
-    '报警以工单为处置手段:处置率=已结案工单 ÷ 可计入工单(状态 0–6,排除已取消),单位为工单张数'
+    '报警以工单为处置手段:处置率=已结案工单 ÷ 可计入工单(状态 0–6,排除已取消),单位为工单张数;' +
+    '已关闭报警按事件口径数(本窗口报警里回联工单已结案的条数),与工单张数口径不同'
 
 /**
  * 预警族的窗口读数(票 65 立法;票 78 扩到处置流程读数):
@@ -764,33 +1124,75 @@ export function eventAnalysisBasis(family) {
  *
  * @param {object|null} data `toEventAnalysis()` 的领域对象(label 已是中文)
  * @param {object} section 分区
- * @param {{mode?: string}} [extra] 当前切换族(与效率面板同一接缝)
+ * @param {{mode?: string, historyEvents?: Array}} [extra] 当前切换族与事件行(后者供报警族归类)
  */
+/**
+ * 报警族的「按事件类型」桶(维护者 2026-10-29)。
+ *
+ * ── 为什么不能用后端的 EVENT_TYPE ──
+ * 后端多维分析对报警族回的 `EVENT_TYPE` 是 **`UNKNOWN_EVENT_TYPE`**(实测:30 天窗口只有
+ * 一个「未知事件类型」桶)。根因是报警**没有预警类型码**:报警事件的 `eventType` 是
+ * **设备类型 id**(`"13"`/`"15"`),不是预警那套 `WARN-xxx` 码,后端归不出类型就落进未知桶。
+ * 所以屏上「按事件类型」对报警族恒为空/未知 —— 维护者反馈「不应该获取不到类型」正是这个。
+ *
+ * ── 用什么代替 ──
+ * 报警事件的**真实类目在 `eventName`**(实测:漏失监测 / COD监测 / 氨氮监测 / OFFLINE),
+ * 这才是值班人员嘴里说的「类型」。事件行本来就要取(见 `fetchIncidentTrend`),
+ * 这里按 `eventName` 聚合,不额外发请求、不猜。
+ *
+ * @param {object} current `toEventAnalysis()` 的领域对象
+ * @param {string} family 当前族
+ * @param {object} [extra] 面板额外输入(`historyEvents`:带 name 的事件行)
+ * @returns {Array<{code: string, label: string, count: number}>|null} 认不出族或没行时回 null(用后端桶)
+ */
+function alarmEventTypeRows(current, family, extra) {
+    if (String(family || '').toUpperCase() !== EVENT_FAMILY.ALARM) return null
+    const rows = (extra && extra.historyEvents) || null
+    if (!Array.isArray(rows) || !rows.length) return null
+    const counts = new Map()
+    for (const row of rows) {
+        // `name` 是 `toEventItem` 从后端 `eventName` 翻出来的领域字段。
+        const label = String((row && row.name) || '').trim()
+        // 没有名称的不丢掉,归入「未命名」并**如实显示**(丢行会让合计对不上条数)。
+        const key = label || '未命名'
+        counts.set(key, (counts.get(key) || 0) + 1)
+    }
+    return [...counts.entries()]
+        .map(([label, count]) => ({ code: label, label, count }))
+        .sort((left, right) => right.count - left.count)
+}
 export function eventAnalysisPanel(data, section, extra) {
     const current = data || {}
     const family = current.family || (extra && extra.mode) || EVENT_FAMILY.WARNING
-    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: [],
-    }))
     // 成因块(票 66):**后端回出这一维才画**。真实两族都恒定回(缺成因走 UNKNOWN_CAUSE 兜底桶),
     // 但旧载荷/mock 可能没有这一维 —— 没有就不画假块(票 05 的「缺值不画假 0」同一立场),
     // 顺便避免空环形图落进渲染层那句「当前范围内没有监测点与未关联设备」(那是监测概览的措辞)。
-    const causes = sortByCountDesc(current.causes || [], (row) => row.count)
+    // 维护者 2026-10-30 第 4 条:「按成因」的数据源是 multidimensional-analysis 的 CAUSE 维,
+    // 而该维实测**恒为 UNKNOWN_CAUSE**(「未知/缺失成因」)—— 等于没有这项数据。
+    // 换成后端确定有值的 DEVICE_TYPE(设备类型),两族都能出真实类目。
+    const deviceTypes = sortByCountDesc(current.deviceTypes || [], (row) => row.count)
+        // 后端把**全部**设备类型都列出来了(13 类,其中 11 类计数为 0)——
+        // 零计数桶留着就是一屏零。过滤掉(0 本来就不占类别,总数不变)。
+        .filter((row) => row.count > 0)
+    // 报警族的「按事件类型」改由事件行的 `eventName` 归类(后端对该族恒回未知桶);
+    // 预警族仍用后端 EVENT_TYPE 桶(那套 `WARN-xxx` 码对预警是准的)。
+    const alarmTypes = alarmEventTypeRows(current, family, extra)
+    const typeRows = alarmTypes || current.types || []
     return sectionView('eventAnalysis', section, [
         // 2026-10-13 维护者反馈:条形图四连排视觉疲劳——类别少的构成改环形图(行业=行业色、
         // 类型=图表色序),等级/片区保留条形(条形适合作排名比较)。行业行保留联动。
         // 票 37 走查扩展:类型/行业/片区是排名类别按计数降序(并列保序);
         // 事件等级是**固定序**(1–4 级语义阶梯),保持后端字典序不打乱。
         // 维护者第 15 轮:类型是**多类别**,排行(由大到小)比环图更读得清;成因改堆叠条。
-        rankingBarsBlock('按事件类型', sortByCountDesc(current.types || [], (row) => row.count), {
+        rankingBarsBlock('按事件类型', sortByCountDesc(typeRows, (row) => row.count), {
             limit: 0,
             variant: 'rank',
             unit: '起',
+            // 报警族的类别来自事件名称(后端对该族的 EVENT_TYPE 恒为未知桶),如实标注。
+            aside: alarmTypes ? '按事件名称归类(报警无预警类型码)' : '',
         }),
-        ...(causes.length ? [stackedBlock('按成因', causes)] : []),
+        // 后端没回设备类型这一维时不画假块(缺值不画假的同一纪律)。
+        ...(deviceTypes.length ? [stackedBlock('按设备类型', deviceTypes)] : []),
         donutBlock(
             '按行业分布',
             sortByCountDesc(
@@ -802,7 +1204,6 @@ export function eventAnalysisPanel(data, section, extra) {
         // 维护者第 15 轮:等级(固定四级阶梯)与片区(类别少)都改饼图。
         donutBlock('按事件等级', current.levels || [], { layout: 'compact' }),
         donutBlock('按片区分布', sortByCountDesc(current.regions || [], (row) => row.count), { layout: 'compact' }),
-        listBlock('重点识别', highlights, '当前没有识别到重点'),
     ], {
         label: '预警分析',
         range: current.range,

+ 141 - 4
src/modules/cockpit/services/warningResponseApi.js

@@ -1,6 +1,7 @@
 /** 预警处置模式三块聚合接口(票 19)。 */
 import { districtQuery, industryQuery } from '../domain/filterOptions.js'
 import { toEventAnalysis, toEventHistory, toHandlingEfficiency } from '../domain/warningResponsePanels.js'
+import { INCIDENT_TREND_GAP, dailyIncidentTrend } from '../domain/incidentTrend.js'
 import { fetchSection, isEmptyBy } from './domainRequest.js'
 import { eventTimeWindow, historyWindows } from '../domain/eventRange.js'
 import { SECTION_STATE, section } from '../domain/sectionState.js'
@@ -19,6 +20,9 @@ const familyParam = (mode) => (mode === 'ALARM' ? 'ALARM' : 'WARNING')
 
 /** 多窗合并时每窗的取数上限(后端 `pageSize` 上限 1000):把每个窗已加载的逐事件行
  * 全取下再合并,前端对合并集合分页(多窗不存在全局页,见规范三分法)。 */
+/** 趋势存量的固定基线天数(= 全仓最大窗口 90d)。 */
+const TREND_BASELINE_DAYS = 90
+
 const HISTORY_WINDOW_MAX_PAGE_SIZE = 1000
 
 /**
@@ -56,7 +60,13 @@ function rangeParams(options) {
  */
 export async function fetchEventHistory(options = {}) {
     const range = rangeParams(options)
-    const windows = historyWindows(range.startTime, range.endTime)
+    // 维护者 2026-10-30 第 4 次反馈:近 7 天的「未解除/未处置」与近 30/90 天对不上。
+    // 根因是存量(第三条线)原本只从**本窗口已取事件**里算 —— 7 天窗只含 7 天内创建的,
+    // 于是「截至某日还压着多少」被窗口截断(实测 10-30:7 天给 1、30/90 天给 4)。
+    // 修法:取数一律拉**固定 90 天基线**(全仓最大窗口);展示窗口只用来切「新增/处置」与日骨架,
+    // 于是同一天的存量在三档窗口里必然同值。
+    const baseline = trendBaselineRange(range)
+    const windows = historyWindows(baseline.startTime, baseline.endTime)
     const singleWindow = windows.length === 1
     const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
     const results = await Promise.all(
@@ -153,10 +163,19 @@ function fetchAggregated(path, options, hooks) {
     // 票 67:只有实测收片区的端点才带 district(见上方说明);多窗路径的每个窗都带上。
     if (hooks.acceptsDistrict === true) base.district = districtQuery(options.scope)
     const range = rangeParams(options)
-    const windows = historyWindows(range.startTime, range.endTime)
+    // 维护者 2026-10-30 第 4 次反馈:近 7 天的「未解除/未处置」与近 30/90 天对不上。
+    // 根因是存量(第三条线)原本只从**本窗口已取事件**里算 —— 7 天窗只含 7 天内创建的,
+    // 于是「截至某日还压着多少」被窗口截断(实测 10-30:7 天给 1、30/90 天给 4)。
+    // 修法:取数一律拉**固定 90 天基线**(全仓最大窗口);展示窗口只用来切「新增/处置」与日骨架,
+    // 于是同一天的存量在三档窗口里必然同值。
+    const baseline = trendBaselineRange(range)
+    const windows = historyWindows(baseline.startTime, baseline.endTime)
+    // 票 71:单窗路径的额外查询参数(页码/页大小)。多窗路径**不传** —— 那里每窗各取第 1 页,
+    // 合并后的集合没有全局页(与历史事件多窗合并同一条口径,见 `fetchEventHistory`)。
+    const pageParams = typeof hooks.pageParams === 'function' ? hooks.pageParams(options) : {}
     if (windows.length <= 1) {
         return fetchSection(path, {
-            params: { ...base, ...range },
+            params: { ...base, ...range, ...pageParams },
             toData: hooks.toDomain,
             isEmpty: isEmptyBy(hooks.isEmptyRaw),
             freshnessOf,
@@ -179,7 +198,13 @@ function fetchAggregated(path, options, hooks) {
         if (!populated.length) {
             return section(SECTION_STATE.EMPTY, { mode: 'multi-window', reason: REQUEST_REASON.UNAVAILABLE })
         }
-        const merged = hooks.merge(populated.map((result) => result.data), { startTime: range.startTime, endTime: range.endTime })
+        // 票 71:把**范围键**一起交给合并器 —— 多窗合并后的列表块要靠它做页码归属
+        // (范围一变页码回第 1 页;标量键,不接受对象身份,与票 61 同一条纪律)。
+        const merged = hooks.merge(populated.map((result) => result.data), {
+            startTime: range.startTime,
+            endTime: range.endTime,
+            rangeKey: RANGE_KEY_BY_TYPE[options.rangeType] || '',
+        })
         const retained = results.some((result) => result.state === SECTION_STATE.STALE)
         if (hooks.isEmptyRaw(merged)) {
             return section(SECTION_STATE.EMPTY, { mode: 'multi-window', retained })
@@ -327,6 +352,17 @@ export function mergeEfficiencyPayloads(payloads, fullRange) {
         range: { ...(first.range || {}), type: 'TREND_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime },
         durationTrend,
         events,
+        // 票 71:多窗合并**没有全局页**(每窗各取第 1 页,`events` 是已加载集合)。
+        // 所以明确撤掉单窗的 `pagination` 回显,改给 `paging` 元数据 —— 列表块据此
+        // 走前端集合分页并如实说明「已合并 N 个窗口、已加载 M 条」(与票 61 历史事件同一口径)。
+        // 保留第一窗的 `pagination` 会让列表画出「共 total 条」而其中只有第 1 页的行。
+        pagination: null,
+        paging: {
+            mode: 'merged',
+            windowCount: list.length,
+            loaded: events.length,
+            rangeKey: fullRange.rangeKey || '',
+        },
         workOrderStateCounts: stateCounts,
         // 票 78:预警族字段只在确有窗给出时进入合并产物(旧载荷/报警族保持缺失,前端不画假块)。
         ...(sawWarningStates ? { warningStateCounts: warningRows } : {}),
@@ -414,6 +450,10 @@ export function fetchHandlingEfficiency(options = {}) {
     return fetchAggregated(WARNING_RESPONSE_PATHS.efficiency, options, {
         toDomain: toHandlingEfficiency,
         merge: mergeEfficiencyPayloads,
+        // 票 71:本端点同时供出**事件列表**(报警效率/预警效率面板的列表块)。
+        // `page`/`pageSize` 只在单窗路径下发(多窗合并时逐窗各取第 1 页,前端没有全局页),
+        // 所以把页码参数挂在 hooks 上而不是写死在 `fetchAggregated` 里。
+        pageParams: (opts) => ({ page: opts.page, pageSize: opts.pageSize }),
         // 票 79:票 77 补齐并部署后实测该端点真收 district(非法值 400、工单口径随片区收敛),
         // 多窗(30/90 天)的每一窗都要带上,否则 90 天口径会退回全域。
         acceptsDistrict: true,
@@ -435,3 +475,100 @@ export function fetchEventAnalysis(options = {}) {
         },
     })
 }
+
+/**
+ * 逐日「总数 / 新增数 / 处置数」趋势(票 71 返工,维护者 2026-10-29 第 2 条)。
+ *
+ * ── 为什么必须另取一次数 ──
+ * 效率端点(`/events/handling-efficiency`)的逐事件行**不带 `status`**(实测字段清单:
+ * `eventNo/deviceCode/eventName/level/levelColor/eventTime/workOrderStateCode/...`,
+ * 没有 status),所以拿它既算不出「处置数」,也画不出列表的状态标签。
+ * `/events/history-analysis` 的逐事件行**带 status**(实测 WARNING=PENDING、ALARM=UNHANDLED),
+ * 且同一时间窗下总数与效率端点**一致**(30 天窗口实测两处都是 92 条)—— 同源,可换用。
+ *
+ * ── 取数纪律 ──
+ * 逐日聚合要求事件行**取全**:每个 30 天窗取 `pageSize=1000`(后端上限),
+ * 任一窗取不全 ⇒ 整条趋势返回 `PARTIAL` 原因码、**不画线**(偏低的假线比没有线更坏)。
+ *
+ * @param {object} options 与 `fetchEventHistory` 同一套(mode/range/industry/scope/config/fetchImpl)
+ * @returns {Promise<{days: Array|null, reason: string}>}
+ */
+
+/**
+ * 趋势取数的**存量基线窗口**:取「展示窗口起点」与「结束前 90 天」里更早的那个。
+ *
+ * 为什么需要它:第三条线(未解除/未处置)是**存量**——「截至某日还压着多少」,
+ * 必须把窗口之前创建、当日仍未了结的事件也算进去。若只取展示窗口,7 天窗会漏掉
+ * 更早创建仍未解除的事件,同一天的读数就与 30/90 天窗口对不上
+ * (维护者 2026-10-30 实测:10-30 这一天,7 天给 1、30 天与 90 天都给 4)。
+ *
+ * 90 天 = 全仓最大窗口(`EVENT_RANGES` 的 90d),三档窗口因此共用同一条基线,存量必然同值。
+ */
+function trendBaselineRange(range) {
+    const end = new Date(range.endTime).getTime()
+    if (!Number.isFinite(end)) return range
+    const floorAt = end - TREND_BASELINE_DAYS * 24 * 3600 * 1000
+    const start = new Date(range.startTime).getTime()
+    if (Number.isFinite(start) && start <= floorAt) return range
+    return { ...range, startTime: new Date(floorAt).toISOString() }
+}
+export async function fetchIncidentTrend(options = {}) {
+    const range = rangeParams(options)
+    // 维护者 2026-10-30 第 4 次反馈:近 7 天的「未解除/未处置」与近 30/90 天对不上。
+    // 根因是存量(第三条线)原本只从**本窗口已取事件**里算 —— 7 天窗只含 7 天内创建的,
+    // 于是「截至某日还压着多少」被窗口截断(实测 10-30:7 天给 1、30/90 天给 4)。
+    // 修法:取数一律拉**固定 90 天基线**(全仓最大窗口);展示窗口只用来切「新增/处置」与日骨架,
+    // 于是同一天的存量在三档窗口里必然同值。
+    const baseline = trendBaselineRange(range)
+    const windows = historyWindows(baseline.startTime, baseline.endTime)
+    // 每窗各取到底(后端单请求上限 1000),再合并去重 —— 与历史事件多窗同一段切窗逻辑。
+    const results = await Promise.all(
+        windows.map((window) =>
+            fetchHistoryWindow({
+                ...options,
+                ...window,
+                page: 1,
+                pageSize: HISTORY_WINDOW_MAX_PAGE_SIZE,
+            }),
+        ),
+    )
+    const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state))
+    // 任一窗失败 ⇒ 不画(不拿剩下的窗拼一条偏低的线)。
+    if (failed) return { days: null, reason: INCIDENT_TREND_GAP.PARTIAL, rows: [], windowCount: historyWindows(range.startTime, range.endTime).length }
+    const events = new Map()
+    let truncated = false
+    for (const result of results) {
+        const data = result.data
+        if (!data) continue
+        const rows = Array.isArray(data.events) ? data.events : []
+        for (const row of rows) events.set(row.code || `${row.eventTime}|${row.deviceCode}`, row)
+        const paging = data.pagination || {}
+        // 后端说还有下一页 ⇒ 这一窗没取全,整条趋势作废(宁可不画)。
+        if (paging.hasMore === true || (Number(paging.total) || 0) > rows.length) truncated = true
+    }
+    const allRows = [...events.values()]
+    // 列表仍只给**展示窗口内**的行(它要与 KPI「统计数」同源);存量另用 allRows 算。
+    // 必须用**数值**时间比较:窗口回显是 UTC(…Z)、事件时刻是 +08:00,
+    // 直接比字符串会把 10-30T09:30+08:00 判成晚于 10-30T01:33Z(其实是同一刻之前)。
+    const fromMs = new Date(range.startTime).getTime()
+    const toMs = new Date(range.endTime).getTime()
+    const rows = allRows.filter((row) => {
+        const at = new Date((row && row.eventTime) || 0).getTime()
+        return Number.isFinite(at) && at >= fromMs && at <= toMs
+    })
+    // 票 71 返工:这批行**同时**是列表的数据源 —— 因为只有它带 `status`(效率端点不带)。
+    // 一并返回,store 把它挂到效率分区上,列表与趋势因此看的是**同一批事件**(同源)。
+    // `windowCount` 必须回**真实**值(7 天 1 窗 / 30 天 2 窗 / 90 天 4 窗):
+    // 页脚要写「已合并 N 个时间窗」,写死 1 就是在谎报取数方式。
+    return {
+        ...dailyIncidentTrend({
+            events: allRows,
+            family: familyParam(options.mode),
+            startTime: range.startTime,
+            endTime: range.endTime,
+            complete: !truncated,
+        }),
+        rows,
+        windowCount: historyWindows(range.startTime, range.endTime).length,
+    }
+}

+ 161 - 25
src/modules/cockpit/stores/eventStore.js

@@ -34,7 +34,7 @@ import { getCameraState, getAssociatedDeviceState, highlightMapObject, setCamera
 import { useMapStore } from './mapStore.js'
 import { EVENT_RANGES } from '../domain/warningResponsePanels.js'
 import { eventTimeWindow, historyWindows } from '../domain/eventRange.js'
-import { fetchEventAnalysis, fetchEventHistory, fetchHandlingEfficiency } from '../services/warningResponseApi.js'
+import { fetchEventAnalysis, fetchEventHistory, fetchHandlingEfficiency, fetchIncidentTrend } from '../services/warningResponseApi.js'
 import { fetchWorkOrderList } from '../services/detailApi.js'
 import { PAGE_SIZE, serverPageView } from '../domain/pagination.js'
 
@@ -55,6 +55,26 @@ const CONDITION_KEYS = ['mode', 'range', 'industry', 'level', 'status', 'scope']
  */
 const WORK_ORDER_LIST_PAGE_SIZE = 5
 
+/**
+ * 效率面板「报警列表 / 预警列表」每页条数(票 71)。
+ *
+ * 与事件表 `PAGE_SIZE`(10)分开、也与工单列表的 5 分开:三者是**不同块**、
+ * 不同翻页语义,共用一个常量会让「调事件页大小」意外改掉另外两块(票 61 变异 M1 同款教训)。
+ * 取 5 是因为这块与趋势图同屏,行数是可读性约束而不是数据约束。
+ */
+const EFFICIENCY_EVENT_PAGE_SIZE = 5
+
+/**
+ * 「当前预警/报警」列表的**取数上限**(维护者 2026-10-29 第 4 条)。
+ *
+ * 为什么改成一次取全、而不是服务端逐页:这张表**本地过滤掉了已终态事件**
+ * (只留还在进行中的),而服务端分页的 `total`/`totalPages` 含已终态 ——
+ * 两边口径不一致时就会画出「只有 1 行却显示 10 页、且能翻到空页」。
+ * 一次取全(上限取后端 pageSize 上限 1000)后按**可见行**前端分页,页码与内容必然一致。
+ * 超过上限时 `currentEventsView` 会置 `truncated`,界面如实标注「已加载 N / 共 M」。
+ */
+const CURRENT_EVENTS_FETCH_SIZE = 1000
+
 /** 未取数时的分区(与其它 store 同形,`loaded:false` 表示"还没请求过")。 */
 function pending() {
     return { state: SECTION_STATE.EMPTY, reason: '', mode: '', data: null, missing: [], freshness: '', retained: false, loaded: false }
@@ -110,6 +130,14 @@ export const useEventStore = defineStore('eventStore', () => {
      */
     const workOrderPage = ref(1)
 
+    /**
+     * 效率面板「报警列表 / 预警列表」的服务端页码(票 71)。
+     *
+     * 与上面三个页码**互不牵连**:翻列表页只重取效率分区(那一块的行由该端点下发),
+     * 不动事件表/历史/分析/工单列表。
+     */
+    const efficiencyPage = ref(1)
+
     /** 最后一个「当前事件分区的领域对象」(视图层读它,避免每次都重解构 section)。 */
     const currentDomain = ref(null)
 
@@ -129,6 +157,24 @@ export const useEventStore = defineStore('eventStore', () => {
      */
     let workOrderToken = 0
 
+    /**
+     * 效率面板事件列表的令牌(票 71)。
+     *
+     * 理由与 `workOrderToken` 一字不差:效率块是**独立分区**,翻它一页不该把在途的
+     * 另外四块响应整批作废(`token` 是全局面板令牌)。
+     */
+    let efficiencyToken = 0
+
+    /**
+     * 效率面板列表翻页时**钉住**的请求条件(票 71)。
+     *
+     * 与 `workOrderWindow` 同一理由:翻页期间窗口必须一个字节都不变。若每次翻页都重算
+     * `eventTimeWindow()`,`endTime` 会毫秒级后移 —— 边界事件就会在页与页之间重复或消失。
+     * 存整份条件(mode/range/industry/scope/窗口)而不只是窗口:列表是**同一端点**的分页,
+     * 任何一项漂移都会让「第 2 页」不再属于「第 1 页」那批事件。
+     */
+    let efficiencyConditions = null
+
     /**
      * 工单列表当前钉住的时间窗(票 65,2026-10-22)。
      *
@@ -180,11 +226,7 @@ export const useEventStore = defineStore('eventStore', () => {
     }
 
     /** 当前事件页的后端回显 → 控件视图(失败/无回显回 null,不夹、不猜)。 */
-    function currentPageView(result) {
-        if (!result || !result.data) return null
-        const rows = result.data.sections && result.data.sections.events && result.data.sections.events.data
-        return serverPageView(result.data.pagination, Array.isArray(rows) ? rows.length : 0)
-    }
+
 
     /** 历史事件页的后端回显 → 控件视图(只有服务端分页形态才有全局页)。 */
     function historyPageView(result) {
@@ -232,7 +274,8 @@ export const useEventStore = defineStore('eventStore', () => {
     const view = computed(() => {
         // G11:类型筛选只在预警族生效(报警列表没有这套类型码)。
         const filter = mode.value === EVENT_FAMILY.WARNING ? warningTypeFilter.value : { code: '', label: '' }
-        const options = { warningType: filter.code, warningTypeLabel: filter.label }
+        // 页码是**前端**状态(取全后按可见行分页),所以这里要把它交给视图。
+        const options = { warningType: filter.code, warningTypeLabel: filter.label, page: currentPage.value }
         // 已取到数据:从领域对象读事件分区(行 + 内部状态)。
         if (currentDomain.value) return currentEventsView(currentDomain.value, undefined, options)
         // 还没取过 / 整体失败:用整个分区驱动占位措辞(pending → 正在取数;failed → 加载失败)。
@@ -270,19 +313,28 @@ export const useEventStore = defineStore('eventStore', () => {
             historyPage.value = 1
             // 票 65:条件变了,工单列表也回第 1 页(条文 5;定时刷新不带条件 ⇒ 页码保持)。
             workOrderPage.value = 1
+            // 票 71:效率面板的两族列表同理(换族/换范围/换筛选 = 换了一批事件)。
+            efficiencyPage.value = 1
         }
 
         const active = activeConditions()
         // 票 65:把窗口**钉**在这一次取数上。`eventTimeWindow()` 每次调用都按 `Date.now()`
         // 重算,首屏与翻页各算一次就会毫秒级漂移 —— 翻页时窗口在动,边界工单会重复或漏掉。
         workOrderWindow = { startTime: active.startTime, endTime: active.endTime }
+        // 票 71:效率列表翻页复用**整份**条件(含刚钉下的窗口),不只存窗口 ——
+        // 列表是该端点的分页,任何一项条件漂移都会让第 2 页不再属于第 1 页那批事件。
+        efficiencyConditions = { ...active }
         token += 1
         const mine = token
         loading.value = true
         const requests = await Promise.all([
-            fetchCurrentEvents({ ...active, page: currentPage.value, pageSize: PAGE_SIZE }),
+            // 维护者 2026-10-29 第 4 条:当前事件**一次取全**,页码交给视图按可见行算。
+            // 逐页取会让服务端 total(含已终态)与屏上可见行两套口径打架。
+            fetchCurrentEvents({ ...active, page: 1, pageSize: CURRENT_EVENTS_FETCH_SIZE }),
             fetchEventHistory({ ...active, page: historyPage.value, pageSize: PAGE_SIZE }),
-            fetchHandlingEfficiency(active),
+            // 票 71:效率端点同时供出两族的事件列表,页码取 `efficiencyPage`
+            // (定时刷新保持当前页;条件变化已在上方复位成第 1 页)。
+            fetchHandlingEfficiency({ ...active, page: efficiencyPage.value, pageSize: EFFICIENCY_EVENT_PAGE_SIZE }),
             fetchEventAnalysis(active),
             // 票 65:报警效率面板的「当前工单列表」走**全量工单**端点(另一个粒度:
             // handling-efficiency 只给状态计数、不返回工单实体)。预警族不画工单列表,
@@ -292,6 +344,12 @@ export const useEventStore = defineStore('eventStore', () => {
                 { page: workOrderPage.value, pageSize: WORK_ORDER_LIST_PAGE_SIZE, startTime: workOrderWindow.startTime, endTime: workOrderWindow.endTime },
                 requestOptions,
             ),
+            // 票 71 返工:逐日 新增/处置/累计 三条线要**带 status 的事件行**,而效率端点不返回
+            // status(实测),所以另取一次 history-analysis(同一时间窗、总数实测一致)。
+            // **必须放在数组末尾**:下面的解构按位置取值,插在中间会让后面每一项整体错位
+            // (返工第二版正是这样把 analysis/workOrderList/incidentTrend 全错拿了一格)。
+            // 失败只影响趋势那一块(分区独立纪律),不拖垮效率读数。
+            fetchIncidentTrend(active).catch(() => null),
         ].map(async (request) => {
             try {
                 return await request
@@ -301,8 +359,43 @@ export const useEventStore = defineStore('eventStore', () => {
         }))
         if (mine !== token) return sections.value
 
-        const [current, history, efficiency, analysis, workOrderList] = requests
-        const safeCurrent = await clampSettled(current, currentPage, currentPageView, fetchCurrentAt, mine)
+        const [current, history, efficiency, analysis, workOrderList, incidentTrend] = requests
+        // 票 71 返工:把逐日趋势挂进效率分区(domain 只读 `incidentTrend` 字段)。
+        // 取失败(null)时保持缺失 ⇒ 界面说「不画」的原因,不画假线。
+        // **同时**用这批带 status 的行替换列表数据源:效率端点不返回 status,
+        // 用它画列表会把每条都写成「状态未发布」(维护者 2026-10-29 第 1 条反馈)。
+        const trend = incidentTrend && incidentTrend.days ? incidentTrend : null
+        const trendRows = trend && Array.isArray(incidentTrend.rows) ? incidentTrend.rows : null
+        const efficiencyWithTrend = efficiency && efficiency.data
+            ? {
+                  ...efficiency,
+                  data: {
+                      ...efficiency.data,
+                      ...(trend ? { incidentTrend: { days: trend.days, reason: trend.reason } } : {}),
+                      // 事件行取自 history-analysis:条数与效率端点同源(实测同窗一致),
+                      // 且带 status / updatedTime,列表与趋势因此看同一批事件。
+                      ...(trendRows && trendRows.length ? { events: trendRows } : {}),
+                      // **分页形态必须跟着换**:行是 history 的**全量集合**(每窗取到 1000 条上限),
+                      // 不再是效率端点那一页。若保留效率端点的 `pagination`,列表会画出
+                      // 「共 N 条 · 第 1/1 页」却把全量行都铺出来 —— 页码与内容对不上。
+                      // 所以换成前端集合分页(`paging.merged`),并在页脚如实说明取数方式。
+                      ...(trendRows && trendRows.length
+                          ? {
+                                pagination: null,
+                                paging: {
+                                    mode: "merged",
+                                    // 真实窗口数(7 天 1 / 30 天 2 / 90 天 4)—— 页脚要照实说合并了几个窗。
+                                    windowCount: incidentTrend && incidentTrend.windowCount ? incidentTrend.windowCount : 1,
+                                    loaded: trendRows.length,
+                                    rangeKey: String(active.rangeType || ""),
+                                },
+                            }
+                          : {}),
+                  },
+              }
+            : efficiency
+        // 当前事件一次取全、页码由视图算 ⇒ 不再做「越界补取」(那套只对服务端分页有意义)。
+        const safeCurrent = current
         if (mine !== token) return sections.value
         const safeHistory = await clampSettled(history, historyPage, historyPageView, fetchHistoryAt, mine)
         if (mine !== token) return sections.value
@@ -310,7 +403,7 @@ export const useEventStore = defineStore('eventStore', () => {
         sections.value = {
             currentEvents: { ...safeCurrent, loaded: true },
             historyAnalysis: { ...safeHistory, loaded: true },
-            responseEfficiency: { ...efficiency, loaded: true },
+            responseEfficiency: { ...efficiencyWithTrend, loaded: true },
             eventAnalysis: { ...analysis, loaded: true },
             workOrderList: { ...workOrderList, loaded: true },
         }
@@ -351,19 +444,11 @@ export const useEventStore = defineStore('eventStore', () => {
      * @returns {Promise<object>} `sections.currentEvents`
      */
     async function setCurrentPage(next) {
+        // 维护者 2026-10-29 第 4 条:当前事件已在 `loadPanels` 一次取全,
+        // 翻页纯属**视图状态**(对可见行前端分页)—— 不发请求、不动分区。
+        // 这样页码/总页数恒等于用户真的能看到的内容,不会出现「翻到空页」。
         const page = Math.max(1, Math.trunc(Number(next) || 1))
-        if (page === currentPage.value) return sections.value.currentEvents
         currentPage.value = page
-        token += 1
-        const mine = token
-        // 翻页**不置 loading**:条文 8「翻页请求进行中保持旧内容,不闪空白」——
-        // 整面板 loading 只用于换条件/首取(那才需要占位)。
-        const result = await loadPage(fetchCurrentAt, currentPage, currentPageView, mine)
-        if (!result) return sections.value.currentEvents
-        sections.value = { ...sections.value, currentEvents: { ...result, loaded: true } }
-        currentDomain.value = result.data || null
-        // 翻页后保留已打开的详情与「当前列表未返回该事件」语义(票 13 既有行为)。
-        selectedEvent.value = refreshedEventSelection(selectedEvent.value, currentDomain.value)
         return sections.value.currentEvents
     }
 
@@ -428,6 +513,46 @@ export const useEventStore = defineStore('eventStore', () => {
         return sections.value.workOrderList
     }
 
+    /**
+     * 效率面板「报警列表 / 预警列表」翻页(服务端页码分页;票 71):只重取效率分区。
+     *
+     * 三条纪律与事件表/工单列表翻页一致,竞态用**独立令牌**:
+     *   · 翻页保持旧内容、不闪空白(条文 8);
+     *   · 页码越界由后端回显夹回(`serverPageView` 同一判据)并同步回 `efficiencyPage`;
+     *   · 迟到的页响应整体丢弃(`efficiencyToken` 变了就放弃)。
+     *
+     * 请求条件必须用 `loadPanels` 钉下的那一份(`efficiencyConditions`):重算窗口会让
+     * `endTime` 毫秒级后移,边界事件在页与页之间重复或消失。
+     *
+     * @param {number} next 目标页码(1 起)
+     * @returns {Promise<object>} `sections.responseEfficiency`
+     */
+    async function setEfficiencyPage(next) {
+        const page = Math.max(1, Math.trunc(Number(next) || 1))
+        if (page === efficiencyPage.value) return sections.value.responseEfficiency
+        efficiencyPage.value = page
+        efficiencyToken += 1
+        const mine = efficiencyToken
+        // 条件缺失(还没 loadPanels 过)时退回当前条件——那是首屏之前的情况,页码本就该是第 1 页。
+        const active = efficiencyConditions || activeConditions()
+        let result
+        try {
+            result = await fetchHandlingEfficiency({ ...active, page, pageSize: EFFICIENCY_EVENT_PAGE_SIZE })
+        } catch (error) {
+            result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+        }
+        // 迟到响应丢弃:期间又翻过页/切过条件,这一页不落分区。
+        if (mine !== efficiencyToken) return sections.value.responseEfficiency
+        const view = serverPageView(
+            result && result.data && result.data.pagination,
+            result && result.data && Array.isArray(result.data.events) ? result.data.events.length : 0,
+        )
+        // 后端夹回(数据变少后第 3 页不存在):以回显为准,不再补一次请求。
+        if (result && result.data && result.data.pagination) efficiencyPage.value = view.page
+        sections.value = { ...sections.value, responseEfficiency: { ...result, loaded: true } }
+        return sections.value.responseEfficiency
+    }
+
     /**
      * 取当前事件列表(跨设备的 `/events/current`;票 13 的预警处置面板数据源)。
      *
@@ -440,12 +565,15 @@ export const useEventStore = defineStore('eventStore', () => {
     async function loadCurrentEvents(options = {}) {
         if (options.mode !== undefined && isKnownEventMode(options.mode)) mode.value = options.mode
         if (Object.keys(options).some((key) => !['mode', 'page', 'pageSize'].includes(key))) configure(options)
-        const pageSize = Number.isInteger(options.pageSize) && options.pageSize > 0 ? options.pageSize : PAGE_SIZE
+        // 维护者 2026-10-29 第 4 条:一次取全,页码是前端视图状态(见 CURRENT_EVENTS_FETCH_SIZE)。
         currentPage.value = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
         token += 1
         const mine = token
         loading.value = true
-        const result = await loadPage((page) => fetchCurrentAt(page, pageSize), currentPage, currentPageView, mine)
+        const result = await fetchCurrentAt(1, CURRENT_EVENTS_FETCH_SIZE)
+        // 竞态令牌:这里不再经过 `loadPage`(那个函数内部做丢弃),所以必须**自己判**。
+        // 少了这两行,快速切族时迟到的旧族响应会覆盖新族数据(t13 的 G4 实测抓到过)。
+        if (mine !== token) return sections.value.currentEvents
         // 迟到的响应丢弃:mode/页码换过之后,旧请求的结果不得写进新分区。
         if (!result) return sections.value.currentEvents
 
@@ -523,6 +651,12 @@ export const useEventStore = defineStore('eventStore', () => {
         warningTypeFilter.value = { code: '', label: '' }
         currentPage.value = 1
         historyPage.value = 1
+        // 票 71:效率列表的页码与钉住的条件一并回零(reset 是「回到未加载状态」,
+        // 留下旧页码/旧条件会让下一轮首屏取到第 3 页或旧窗口)。
+        workOrderPage.value = 1
+        efficiencyPage.value = 1
+        workOrderWindow = null
+        efficiencyConditions = null
         selectedEvent.value = null
         locate.value = { result: '', code: '', deviceCode: '', pointCode: '', reasonText: '' }
         loading.value = false
@@ -552,6 +686,8 @@ export const useEventStore = defineStore('eventStore', () => {
         setCurrentPage,
         setHistoryPage,
         setWorkOrderPage,
+        efficiencyPage,
+        setEfficiencyPage,
         setMode,
         warningTypeFilter,
         setWarningTypeFilter,