Forráskód Böngészése

feat(cockpit): split warning/alarm efficiency and land filtered work-order list (#65)

预警处置「预警效率」面板按事件族语义分流,并把报警族的「当前工单列表」接上
分页与时间窗(含票 61 的预警处置前端分页基建,此前一直未入库)。

预警/报警分流(票 65)
- 预警族只画该端点里真实属于预警的读数(条数/等级分布/发生趋势/工单关联实况),
  工单处置率、工单状态分布、结案时长在预警模式下不上屏;
- 分流依据 data.family(后端回显)优先于切换参数,都没有时落报警口径;
- 标题与口径说明随族换,词根取 eventListSwitchView 单一词表。

报警族「当前工单列表」(票 65)
- 卡片列表 + 点击看处置轨迹(复用票 12 的 WorkOrderTimeline,不另写轨迹渲染);
- 服务端翻页:独立令牌与独立页码,翻工单页不牵连另外四块分区;
  条件变化回第 1 页,越界按后端回显夹回,迟到响应丢弃;
- 时间窗按 create_time 过滤,与「工单状态分布」统计同一批工单;
  窗口在取数时钉住、翻页复用,避免 endTime 漂移让边界工单跨页重复/丢失。

90 天支持(票 75)
- 后端三个聚合端点单请求 ≤31 天,前端沿用票 61 的 historyWindows 分窗取数后合并;
- 效率合并:趋势时序拼接、summary 求和、处置率用合并后状态计数重算、
  平均时长按样本加权;分析合并:维度按码合并、百分比重算(不是各窗相加);
- 任一窗失败 → 整分区 FAILED,不拿 3/4 窗冒充 90 天;撤下「最多支持31天」提示。

分页基建(票 61,前端部分一并入库)
- EventListPane 接服务端页码翻页,PanelBlocks 支持 serverPage 块,
  eventStore 持页码态与 token 纪律。

闸门:t65 51/51、t65b 14/14、t75 18/18;变异 27/27、9/9、8/8。
ChenJZ 15 órája
szülő
commit
87cd486229

+ 16 - 4
src/modules/cockpit/components/EventListPane.vue

@@ -96,10 +96,21 @@
                 </tbody>
             </table>
 
-            <!-- 只展示最新 N 条,总条数更多时如实说明(验收第 2 条的可见口径) -->
-            <p v-if="view.limited" class="event-list-pane__note" data-event-limited>
-                仅展示最新 {{ view.rows.length }} 条 · 共 {{ view.total }} 条
-            </p>
+            <!--
+                票 61:服务端页码分页(共享 PagerNav,规范条文 2/3/4/9 + 跳页节)。
+                翻页带 page/pageSize 发请求(页码状态在 eventStore,组件只转发目标页);
+                一页时整块不渲染(控件自己按 pageCount>1 收口);
+                计数行「第 X/Y 页 · 共 N 条」由控件单源给,不暴露 page/pageSize 参数名(条文 10)。
+            -->
+            <div v-if="view.pagination && view.pagination.pageCount > 1" class="event-list-pane__pager" data-event-pager>
+                <PagerNav
+                    :page="view.pagination.page"
+                    :page-count="view.pagination.pageCount"
+                    :total="view.pagination.total"
+                    :list-name="view.switchView.title"
+                    @page="(target) => eventStore.setCurrentPage(target)"
+                />
+            </div>
             <p v-if="view.updatedAt" class="event-list-pane__note" data-event-updated>
                 数据时间 {{ view.updatedAt }}
             </p>
@@ -115,6 +126,7 @@ import { computed } from 'vue'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { sectionPending } from '../domain/panelBlocks.js'
 import { useEventStore } from '../stores/eventStore.js'
+import PagerNav from './PagerNav.vue'
 import StatusTag from './StatusTag.vue'
 
 const eventStore = useEventStore()

+ 51 - 21
src/modules/cockpit/components/PanelBlocks.vue

@@ -554,30 +554,33 @@
 
                     <!--
                         票 64:统一分页控件(共享 PagerNav,规范条文 2/3/4/9 + 跳页节)。
-                        只有 domain 声明 `paginate` 的块才画;一页不画(条文 4,由控件自己按
-                        pageCount>1 收口)。总数取**过滤前**集合长度,不由当前页反推(条文 7)。
+                        只有 domain 声明 `paginate`(前端集合分页)或 `serverPage`
+                        (服务端页码分页,行已是后端当前页)的块才画;一页不画(条文 4,
+                        由控件自己按 pageCount>1 收口)。总数取**过滤前**集合长度或后端
+                        `pagination.total`,不由当前页反推(条文 7)。
                     -->
                     <PagerNav
-                        v-if="block.paginate === true"
+                        v-if="block.paginate === true || block.serverPage"
                         :page="windowOf(block).page"
                         :page-count="windowOf(block).pageCount"
                         :total="windowOf(block).total"
                         :list-name="block.label"
                         @page="(target) => gotoListPage(block, target)"
                     />
+                    <!--
+                        票 61:翻页区的**如实口径说明**(多窗合并范围「已合并 N 个时间窗 ·
+                        已加载 M 条」)。它是口径不是控件——一页不到、控件不画时也要出现,
+                        所以不挂在 PagerNav 的 v-if 里。
+                    -->
+                    <p v-if="block.pageNote" class="panel-block__page-note" data-list-page-note>{{ block.pageNote }}</p>
                 </div>
             </section>
 
-            <!-- 台账的「加载下一页」(渐进加载;到到底了就不画按钮) -->
-            <button
-                v-if="view.hasMore"
-                type="button"
-                class="panel-more"
-                data-ledger-more
-                @click="emit('ledger-more')"
-            >
-                加载下一页监测点
-            </button>
+            <!--
+                票 62:「加载下一页监测点」按钮与 `ledger-more` 接缝已退役 ——
+                台账装载改**自动渐进链**(与一张图点位数据同款纪律),界面只对已装载集合
+                做页码分页;「还有没有下一批」由页脚 `pageNote` 如实说明,不再靠一个按钮。
+            -->
 
             <!-- 字段缺失:只说条数,路径进 tooltip(票 11 的立场) -->
             <p
@@ -626,7 +629,8 @@ const props = defineProps({
     view: { type: Object, required: true },
 })
 
-const emit = defineEmits(['locate', 'open-card', 'ledger-more', 'badge-needed', 'panel-action'])
+// 票 62:`ledger-more` 已退役(台账装载是自动链,界面不再有「加载下一页」入口)。
+const emit = defineEmits(['locate', 'open-card', 'badge-needed', 'panel-action', 'list-page'])
 
 /**
  * 票 40:整卡可点(交互参考「当前预警」的事件卡片)。
@@ -760,18 +764,35 @@ function pageOf(block) {
     return readListPage(listPages.value, blockKey(block))
 }
 
-/** 这一块要画几条:分页块交窗口行,其余块交全部行(引用不变,既有渲染零影响)。 */
+/**
+ * 这一块要画几条:分页块交窗口行,服务端分页块交**后端当前页**,其余块交全部行
+ * (引用不变,既有渲染零影响)。两种分页形态共用同一个 PagerNav,只是页码来源不同:
+ *   · `paginate`(前端集合):渲染层自己的 `listPages` 切窗口;
+ *   · `serverPage`(服务端页码,票 61):行已是后端当前页,页码/总数取后端回显,
+ *     翻页 emit `list-page` 交给 store 发请求。
+ */
 function windowOf(block) {
     const rows = block.rows || []
+    if (block.serverPage) {
+        const page = Number(block.serverPage.page) || 1
+        const pageCount = Number(block.serverPage.pageCount) || 1
+        const total = Number.isFinite(Number(block.serverPage.total)) ? Number(block.serverPage.total) : rows.length
+        return { rows, page, pageCount: Math.max(1, pageCount), total }
+    }
     if (block.paginate !== true) return { rows, page: 1, pageCount: 1, total: rows.length }
     return pagedRows(rows, pageOf(block))
 }
 
 /**
  * 翻页唯一出口(与票 59 同一条):前后页/页码直达/跳页都带**目标页码(1 起)**回来。
- * 这里只存状态,不另做夹取——越界统一由 domain `pagedRows` 在下一帧渲染时夹回。
+ * 前端集合分页只存状态(越界由 domain `pagedRows` 在下一帧夹回);服务端分页把目标页
+ * 交给父组件/store 发请求(页码状态在 store,块里不存第二份)。
  */
 function gotoListPage(block, target) {
+    if (block.serverPage) {
+        emit('list-page', { label: block.label, page: Number(target) || 1 })
+        return
+    }
     listPages.value = writeListPage(listPages.value, blockKey(block), target)
 }
 
@@ -947,12 +968,16 @@ function stackedColor(row) {
     return `color-mix(in srgb, ${base} ${Math.round(100 - (row.shade ?? 0.5) * 55)}%, var(--chart-c2, #00b8ff))`
 }
 
-// ---- 台账卡片的设备徽标:**卡片进视口才要**(票 18 的 Q14 裁定)----
+// ---- 台账卡片的设备徽标:**卡片进视口才要**(票 18 的 Q14 裁定;票 62 收窄到当前窗口)----
 //
 // 为什么必须是"进视口才拉"而不是"一页出来就全拉":台账的分页窗口横跨「监测点 + 未关联设备」,
 // 一页可能几十张卡,而用户常常只看最上面几张;更关键的是**逐点位一次请求**(本端点不给设备编码,
 // 见 `domain/monitoringPanels.js` 的说明),无脑全拉会在一屏之内打出一串并发请求。
 //
+// 票 62:台账接上统一页码分页后,**DOM 里只有当前页窗口的卡片**(≤10 张),
+// 于是"进视口才拉"自然只覆盖当前窗口 —— 翻到第 2 页才会为第 11–20 张要徽标。
+// 这比旧形态(一页 500 张卡都进观察集)负载低一个量级,是票面 Acceptance 第 3 条要的形态。
+//
 // 组件不自己取数(那一层在 store)——它只回答「这张卡露出来了」,
 // 由外壳转给 `monitoringStore.loadLedgerBadge()`。请求过的点位由 store 记账,不会重复要。
 //
@@ -979,10 +1004,14 @@ function observeCards() {
 onMounted(() => {
     if (typeof IntersectionObserver === 'undefined') {
         // 没有 IntersectionObserver 时**退化为全要**:徽标是可选增强,不该因此整块不显示。
-        for (const row of ((props.view.blocks || []).find((b) => b.kind === 'list') || {}).rows || []) {
-            if (row.code && !requested.has(row.code)) {
-                requested.add(row.code)
-                emit('badge-needed', row.code)
+        // 票 62:只覆盖**当前页窗口**的行(DOM 窗口与视图窗口同一份 `windowOf`)。
+        for (const block of props.view.blocks || []) {
+            if (block.kind !== 'list') continue
+            for (const row of windowOf(block).rows) {
+                if (row.code && !requested.has(row.code)) {
+                    requested.add(row.code)
+                    emit('badge-needed', row.code)
+                }
             }
         }
         return
@@ -1937,6 +1966,7 @@ onBeforeUnmount(() => {
 }
 
 .panel-block__empty,
+.panel-block__page-note,
 .panel-blocks__placeholder,
 .panel-blocks__missing,
 .is-muted {

+ 169 - 7
src/modules/cockpit/components/WarningResponsePane.vue

@@ -30,21 +30,41 @@
         <p class="warning-response-pane__range-status" role="status" aria-live="polite">
             {{ rangeLabel }} · {{ eventStore.loading ? '正在更新…' : '当前统计窗口' }}
         </p>
-        <p v-if="!eventStore.loading && eventStore.range === '90d' && panelId !== 'historyAnalysis'" class="warning-response-pane__range-status" role="status">
-            当前接口最多支持31天统计,请选择近7天或近30天;近90天历史趋势可正常查询。
-        </p>
         <p v-if="!eventStore.loading && section && section.reason && /历史接口回显/.test(section.reason)" class="warning-response-pane__range-status" role="alert">{{ section.reason }}</p>
-        <PanelBlocks v-if="view && !eventStore.loading" :key="`${panelId}-${eventStore.mode}-${eventStore.range}`" :view="view" />
+        <PanelBlocks v-if="view && !eventStore.loading" :key="`${panelId}-${eventStore.mode}-${eventStore.range}`" :view="view" @list-page="onListPage" @open-card="openWorkOrder" />
+        <!--
+            票 65:工单时间线浮层。报警效率面板的工单卡片点开后在这里看轨迹。
+            为什么不在面板里展开:卡片列表本身已经很长,内联展开会把下方内容推走;
+            浮层也让「点开、读完、关掉」的路径与设备详情的工单时间线一致(票 12 的形态)。
+            只读:这里没有任何写入口(工单状态由运营侧改,本面板只展示)。
+        -->
+        <div v-if="openedOrder" class="warning-response-pane__modal" role="dialog" aria-modal="true" :aria-label="`工单 ${openedOrder.code} 的处置轨迹`" data-work-order-modal>
+            <div class="warning-response-pane__modal-head">
+                <strong class="warning-response-pane__modal-title">{{ openedOrder.code }}</strong>
+                <StatusTag v-if="openedOrder.stateName" :tone="openedOrder.tone" :label="openedOrder.stateName" />
+                <button type="button" class="warning-response-pane__modal-close" data-work-order-modal-close @click="closeWorkOrder">关闭</button>
+            </div>
+            <p v-if="openedOrder.description" class="warning-response-pane__modal-desc">{{ openedOrder.description }}</p>
+            <p v-if="timelinePending" class="warning-response-pane__modal-hint">正在读取处置轨迹…</p>
+            <p v-else-if="timelineFailed" class="warning-response-pane__modal-hint" data-work-order-modal-failed>
+                处置轨迹未取到(工单本身的信息不受影响)
+            </p>
+            <WorkOrderTimeline v-else-if="track" :track="track" />
+        </div>
     </div>
 </template>
 
 <script setup>
-import { computed } from 'vue'
-import { EVENT_RANGES } from '../domain/warningResponsePanels.js'
+import { computed, ref } from 'vue'
+import { EVENT_RANGES, workOrderStateTone } from '../domain/warningResponsePanels.js'
 import { eventListSwitchOptions } from '../domain/eventList.js'
 import { panelView } from '../domain/panelViews.js'
+import { tracksByOrder } from '../domain/workOrderTimeline.js'
+import { fetchWorkOrderTimeline } from '../services/detailApi.js'
 import { useEventStore } from '../stores/eventStore.js'
 import PanelBlocks from './PanelBlocks.vue'
+import StatusTag from './StatusTag.vue'
+import WorkOrderTimeline from './WorkOrderTimeline.vue'
 
 const props = defineProps({ panelId: { type: String, required: true } })
 const eventStore = useEventStore()
@@ -54,8 +74,88 @@ const rangeLabel = computed(() => EVENT_RANGES.find((item) => item.key === event
 const section = computed(() => eventStore.sections[props.panelId])
 const view = computed(() => {
     const current = section.value || { state: 'empty', loaded: false, data: null }
-    return panelView(props.panelId, current.data, current)
+    // 票 65:把当前切换族交给 domain —— 预警效率面板要按族分流(预警不挂工单)。
+    // 族是标量、跨重算稳定;`data.family`(后端回显)仍优先于它,见
+    // `domain/warningResponsePanels.js` 的 `responseEfficiencyPanel`。
+    // `workOrderList` 是报警模式「当前工单列表」块的数据源(另一个端点,见 store 注释)。
+    return panelView(props.panelId, current.data, current, {
+        mode: eventStore.mode,
+        workOrderList: eventStore.sections.workOrderList,
+    })
 })
+
+/**
+ * 服务端页码翻页出口(PanelBlocks 的 `serverPage` 块)。
+ *
+ * 两块面板各有一个 `serverPage` 块,翻页去**各自的分区**——别按「面板名」一刀切:
+ *   · 历史分析:单窗范围的历史事件(多窗合并范围走块内前端集合分页,不经过这里);
+ *   · 报警效率:底部「当前工单列表」(票 65)。
+ * 早先这里只认 `historyAnalysis`,导致工单列表画出了翻页控件、点了却什么都没发生
+ * —— 事件被 `return` 掉,页码根本没进 store(t65 的 H 组 + 变异 M27 钉住)。
+ */
+function onListPage(payload) {
+    const target = payload && payload.page
+    if (props.panelId === 'historyAnalysis') {
+        eventStore.setHistoryPage(target)
+        return
+    }
+    if (props.panelId === 'responseEfficiency') {
+        eventStore.setWorkOrderPage(target)
+    }
+}
+
+// ---- 票 65:工单卡片 → 处置轨迹浮层 ----
+
+/** 当前打开的工单(含状态标签所需的 tone;卡片行已算好,这里只挑要用的字段)。 */
+const openedOrder = ref(null)
+/** 轨迹分区(点开时才取,取完缓存到本次打开期间)。 */
+const track = ref(null)
+const trackState = ref('idle') // idle | loading | done | failed
+const timelinePending = computed(() => trackState.value === 'loading')
+const timelineFailed = computed(() => trackState.value === 'failed')
+
+/**
+ * 点开工单卡片:先立即可见(状态/描述来自列表已有数据),再异步补轨迹。
+ *
+ * 为什么分两步:轨迹是**另一个端点**的第二次往返,等它回来才弹窗会让人觉得"点了没反应";
+ * 而卡片上的状态与描述本来就已经在手上,先画出来才是即时反馈。
+ * 轨迹取不到时如实说「未取到」,不把"没取到"画成"这张工单没有动作记录"(票 12/票 11 纪律)。
+ */
+async function openWorkOrder(code) {
+    const list = (view.value.blocks || []).find((block) => block.kind === 'list')
+    const row = ((list && list.rows) || []).find((item) => item.code === code)
+    if (!row) return
+    openedOrder.value = {
+        code: row.code,
+        stateName: row.stateName,
+        tone: workOrderStateTone(row.stateCode),
+        description: row.description,
+    }
+    track.value = null
+    trackState.value = 'loading'
+    try {
+        // 不传 config:`fetchSection` 会走当前生效配置(`getCockpitConfig()`),
+        // 与 store 里那四次取数同一条路径。`config` 选项只留给单测注入。
+        const result = await fetchWorkOrderTimeline(code)
+        if (!openedOrder.value || openedOrder.value.code !== code) return
+        if (result && (result.state === 'success' || result.state === 'stale') && result.data) {
+            // 复用票 12 的同一条轨迹转换(`tracksByOrder` → `trackOf`),不另写一份。
+            const { byCode } = tracksByOrder([result.data])
+            track.value = byCode[code] || null
+            trackState.value = track.value ? 'done' : 'failed'
+        } else {
+            trackState.value = 'failed'
+        }
+    } catch (error) {
+        trackState.value = 'failed'
+    }
+}
+
+function closeWorkOrder() {
+    openedOrder.value = null
+    track.value = null
+    trackState.value = 'idle'
+}
 </script>
 
 <style scoped>
@@ -107,4 +207,66 @@ const view = computed(() => {
     font-size: var(--fs-caption);
 }
 .is-updating { opacity: .45; transition: opacity .15s; }
+
+/* ---- 票 65:工单处置轨迹浮层 ---- */
+.warning-response-pane__modal {
+    position: fixed;
+    z-index: 30; /* 高于两侧面板栏,低于顶栏诊断浮层(20 的上级),保证能盖住卡片列表 */
+    right: calc(var(--column-w) + 12 * var(--u, 1px));
+    top: calc(120 * var(--u, 1px));
+    width: min(420px, 92vw);
+    max-height: 62vh;
+    overflow: auto;
+    padding: calc(10 * var(--u, 1px)) calc(12 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.5);
+    border-radius: calc(8 * var(--u, 1px));
+    background: rgba(2, 10, 20, 0.96);
+    box-shadow: 0 10px 32px rgba(0, 0, 0, 0.6);
+}
+
+.warning-response-pane__modal-head {
+    display: flex;
+    align-items: center;
+    gap: calc(8 * var(--u, 1px));
+}
+
+.warning-response-pane__modal-title {
+    flex: 1;
+    min-width: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    color: var(--text-strong);
+    font-size: var(--fs-body);
+}
+
+.warning-response-pane__modal-close {
+    padding: calc(2 * var(--u, 1px)) calc(8 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.4);
+    border-radius: calc(6 * var(--u, 1px));
+    background: rgba(0, 20, 72, 0.7);
+    color: var(--text-2);
+    font: inherit;
+    font-size: var(--fs-caption);
+    cursor: pointer;
+}
+
+.warning-response-pane__modal-close:hover,
+.warning-response-pane__modal-close:focus-visible {
+    border-color: rgba(0, 184, 255, 0.85);
+    color: var(--text-strong);
+}
+
+.warning-response-pane__modal-desc {
+    margin: calc(6 * var(--u, 1px)) 0 0;
+    color: var(--text-2);
+    font-size: var(--fs-caption);
+    line-height: 1.5;
+}
+
+.warning-response-pane__modal-hint {
+    margin: calc(8 * var(--u, 1px)) 0 0;
+    color: var(--text-2);
+    font-size: var(--fs-caption);
+}
 </style>

+ 17 - 15
src/modules/cockpit/domain/eventList.js

@@ -37,9 +37,7 @@ import { EVENT_FAMILY } from './events.js'
 import { LEVEL_TAGS, DEFAULT_TONE } from './statusTones.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { workOrderStateName } from './workOrder.js'
-
-/** 事件列表默认展示的最新条数(验收第 2 条「默认展示最新 10-15 条」的中值)。 */
-export const DEFAULT_EVENT_LIMIT = 12
+import { serverPageView } from './pagination.js'
 
 /** 事件 `status` 的直译表(已知取值,来源见文件头;认不出的回原码)。 */
 export const EVENT_STATUS_TEXT = {
@@ -298,7 +296,7 @@ export function refreshedEventSelection(selected, domain) {
 }
 
 /**
- * 事件分区 → 列表行(最新默认条数在前端收口:`latestEventRows` 取排序后的前 N 条)。
+ * 事件分区 → 列表行(票 61:**不再截最新 N 条**——分页交给服务端,行就是当前页)。
  * @param {object} eventsSection `toCurrentEvents().sections.events`
  */
 export function eventListRows(eventsSection) {
@@ -307,13 +305,16 @@ export function eventListRows(eventsSection) {
 }
 
 /**
- * 取最新 N 条(验收第 2 条)。排序口径与后端 `CockpitCurrentEventsService.eventOrdering()`
- * 一致:eventTime 倒序(缺时间的排最后),同时间按 eventNo 升序 —— 前端不发明第二套排序。
+ * 事件排序:eventTime 倒序(缺时间排最后),同时间按 eventNo 升序 —— 与后端
+ * `CockpitCurrentEventsService.eventOrdering()` 同一条口径(票 13 立的单源)。
+ *
+ * 票 61 起它只服务**历史事件多窗合并集合**(多个窗口各自回一页,合并后必须按同一
+ * 口径重排才能稳定分页);当前事件表**不再走这里**——服务端分页按后端返回序渲染,
+ * 前端不发明第二套排序(票 57 规范非目标)。
  * @param {Array} items 领域事件数组(未排序)
- * @param {number} [limit] 条数上限(默认 `DEFAULT_EVENT_LIMIT`)
  */
-export function latestEventRows(items, limit = DEFAULT_EVENT_LIMIT) {
-    const sorted = [...(items || [])].sort((left, right) => {
+export function sortEventRows(items) {
+    return [...(items || [])].sort((left, right) => {
         const l = (left && left.eventTime) || ''
         const r = (right && right.eventTime) || ''
         if (l && r && l !== r) return l > r ? -1 : 1
@@ -324,8 +325,6 @@ export function latestEventRows(items, limit = DEFAULT_EVENT_LIMIT) {
         if (lc && rc && lc !== rc) return lc < rc ? -1 : 1
         return 0
     })
-    const cap = Number.isInteger(limit) && limit > 0 ? limit : DEFAULT_EVENT_LIMIT
-    return sorted.slice(0, cap)
 }
 
 /**
@@ -352,7 +351,11 @@ export function currentEventsView(domain, eventsSection) {
     // 行必须转成**视图行**(`eventRowView`:等级 tag、状态文本、量值、设备名),
     // 组件只认视图行 —— 直接把领域行交给组件会让 level.tone/statusText/quantity 全是
     // undefined(逻辑闸门只数行数抓不到,真机走查才会红;这里收口)。
-    const rows = latestEventRows(items).map(eventRowView)
+    //
+    // 票 61:**行=后端返回的这一页**,前端不排、不再截 12 条(服务端分页按后端返回序,
+    // 总数读 `pagination.total`,条文 7)。空/失败时仍给安全的一页视图(pageCount=1)。
+    const rows = items.map(eventRowView)
+    const pagination = serverPageView(current && current.pagination, items.length)
     const placeholder = hasContent
         ? ''
         : pending
@@ -370,10 +373,9 @@ export function currentEventsView(domain, eventsSection) {
         section: sectionValue,
         hasContent,
         rows,
-        total: (sectionValue && sectionValue.data ? sectionValue.data.length : 0) || (current && current.pagination && current.pagination.total) || 0,
-        limited: !!sectionValue && hasContent && items.length > rows.length,
+        total: pagination.total,
         placeholder,
-        pagination: (current && current.pagination) || null,
+        pagination: hasContent ? pagination : null,
         updatedAt: (current && current.updatedAt) || '',
     }
 }

+ 26 - 0
src/modules/cockpit/domain/pagination.js

@@ -117,6 +117,32 @@ export function writeListPage(store, blockKey, target) {
     return { ...table, [listPageKey(blockKey)]: Number(target) || 1 }
 }
 
+/**
+ * 服务端页码分页的视图(票 61):把响应 `pagination` 收成控件要的三个数。
+ *
+ * 服务端分页与前端集合分页的差别在「页窗口是谁切的」:这一份只读**后端回显**的
+ * page/pageSize/total/totalPages,不自己数行(条文 7:总数不由当前页反推)。
+ * 仍与 `pagedRows` 共用同一套边界语义:空集合也是 1 页、越界页码夹回最后一页、
+ * 脏值有安全兜底(`totalPages` 缺失时按 total/pageSize 补算)。
+ *
+ * @param {object} summary 后端 `pagination`(`{page, pageSize, total, totalPages, hasMore}`)
+ * @param {number} [fallbackTotal] 响应缺分页元数据时的条数兜底(如行数组长度)
+ * @returns {{page:number, pageCount:number, pageSize:number, total:number}}
+ */
+export function serverPageView(summary, fallbackTotal = 0) {
+    const raw = summary && typeof summary === 'object' ? summary : {}
+    const totalValue = Number(raw.total)
+    const total = Number.isFinite(totalValue) && totalValue > 0 ? Math.trunc(totalValue) : Math.max(0, Math.trunc(Number(fallbackTotal) || 0))
+    const sizeValue = Number(raw.pageSize)
+    const pageSize = Number.isFinite(sizeValue) && sizeValue > 0 ? Math.trunc(sizeValue) : PAGE_SIZE
+    const rawPages = Number(raw.totalPages === undefined ? raw.pageCount : raw.totalPages)
+    const computedPages = total > 0 ? Math.ceil(total / pageSize) : 1
+    const pageCount = Number.isFinite(rawPages) && rawPages >= 1 ? Math.trunc(rawPages) : computedPages
+    const rawPage = Number(raw.page)
+    const page = Math.min(Math.max(1, Number.isFinite(rawPage) ? Math.trunc(rawPage) : 1), Math.max(1, pageCount))
+    return { page, pageCount: Math.max(1, pageCount), pageSize, total }
+}
+
 /**
  * 筛选条件变化时清掉这些块的页码槽(条文 5/6 的复位半边),返回新表。
  *

+ 21 - 2
src/modules/cockpit/domain/panelBlocks.js

@@ -200,6 +200,9 @@ export const KPI_ICONS = {
     warningTotal: 'warning',
     alarmTotal: 'alarm',
     warningCount: 'warning',
+    // 票 65:预警效率面板预警族的两个**真实**读数(预警不挂工单 ⇒ 关联/未关联是实况计数)。
+    warningLinked: 'warning',
+    warningMissing: 'unknownOrMissing',
     alarmCount: 'alarm',
     abnormalCount: 'abnormalCount',
     offlineCount: 'offline',
@@ -242,6 +245,8 @@ export const KPI_ICON_COLORS = {
     unknownOrMissing: 'var(--lv2, #ffb020)',
     warningTotal: 'var(--lv2, #ffb020)',
     warningCount: 'var(--lv2, #ffb020)',
+    warningLinked: 'var(--lv2, #ffb020)',
+    warningMissing: 'var(--muted, #5680b0)',
     '预警-total': 'var(--lv2, #ffb020)',
     '预警-pending': 'var(--lv2, #ffb020)',
     '预警-rate': 'var(--lv4, #27d17a)',
@@ -529,12 +534,19 @@ export function rowsBlock(label, rows) {
  *     (运行信息指标列表实测 6772 条 / 2.3 MB,票 57 首版误按「契约有界」排除,
  *     2026-10-19 维护者反馈纠正为接入)。
  *   · 其余字段原样透传(`emptyText` 等)。
+ *
+ * 票 61 新增两种「分页形态」声明(与 `paginate` 互斥,按块各自选择):
+ *   · `serverPage: {page, pageCount, total}` —— 行**已经是后端返回的当前页**,
+ *     渲染层只画控件、翻页发请求(服务端页码分页,如历史事件单窗);
+ *   · `pageNote: string` —— 翻页区的如实口径说明(多窗合并范围「已合并 N 个时间窗 ·
+ *     已加载 M 条」;即使一页不到、控件不画,这句说明也要出现——它是口径不是控件)。
  * 块**不携带页码状态**:分页是「这一屏怎么看」的视图私有状态(票 46 成例),
- * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口。
+ * 渲染层按 `blockKey` 记页码、用 `domain/pagination.js` 的 `pagedRows` 单源切窗口;
+ * 服务端形态的页码状态归 store(请求参数在那儿),块只带当前回显。
  * @param {string} label 块标题
  * @param {Array} rows 条目行
  * @param {string} [emptyText] 空态文案
- * @param {{paginate?: boolean}} [options]
+ * @param {{paginate?: boolean, pageScope?: string, serverPage?: object, pageNote?: string}} [options]
  */
 export function listBlock(label, rows, emptyText, options = {}) {
     const block = { kind: 'list', label, rows, emptyText }
@@ -546,6 +558,13 @@ export function listBlock(label, rows, emptyText, options = {}) {
         // 都生成全新对象,按对象记会把每次刷新都读成换筛选)。
         block.pageScope = typeof options.pageScope === 'string' ? options.pageScope : ''
     }
+    // 票 61:服务端页码分页的块(行已是后端当前页)与翻页区口径说明。
+    if (options.serverPage && typeof options.serverPage === 'object') {
+        block.serverPage = options.serverPage
+    }
+    if (typeof options.pageNote === 'string' && options.pageNote) {
+        block.pageNote = options.pageNote
+    }
     return block
 }
 

+ 301 - 25
src/modules/cockpit/domain/warningResponsePanels.js

@@ -5,7 +5,9 @@
  * 预警/报警沿用票 13 的 mode;历史、效率、分析三块不各自维护一套切换状态。
  */
 import { fieldReader } from './fields.js'
-import { eventLevelTag, toEventItem } from './eventList.js'
+import { eventLevelTag, eventListSwitchView, toEventItem } from './eventList.js'
+import { valueText, workOrderRows } from './equipmentDetailView.js'
+import { EVENT_FAMILY } from './events.js'
 import { WORK_ORDER_STATES } from './workOrder.js'
 import {
     barsBlock,
@@ -13,6 +15,7 @@ import {
     kpiBlock,
     listBlock,
     numberish,
+    rowsBlock,
     sectionHasContent,
     sectionPlaceholder,
     sortByCountDesc,
@@ -135,15 +138,20 @@ function sectionView(name, section, blocks, extra = {}) {
 export function toEventHistory(raw) {
     const reader = fieldReader(raw, 'eventHistory')
     const source = reader.raw.sections && reader.raw.sections.events ? reader.raw.sections.events.data : reader.raw.data
+    // 票 61:后端新增逐事件行 `events[]`(形状与 events/current 同构);
+    // 旧的 `sections.events.data` / `data` 形状保留兼容(mock 与历史载荷都吃得下)。
+    const rawEvents = Array.isArray(reader.raw.events) ? reader.raw.events : source
     return {
         mode: reader.optionalStr('mode') || reader.optionalStr('eventFamily'),
         range: rangeView(raw),
-        events: Array.isArray(source) ? source.map(toEventItem) : [],
+        events: Array.isArray(rawEvents) ? rawEvents.map(toEventItem) : [],
         trend: Array.isArray(reader.raw.rows)
             ? reader.raw.rows.map((row) => ({ bucket: row.timeBucket || '', count: numberish(row.eventCount) || 0 }))
             : list(raw, 'trend').map((item) => ({ bucket: String(item && (item.bucket || item.bucketStart || '')), count: numberish(item && item.count) || 0 })),
         bucketed: Array.isArray(reader.raw.rows),
         pagination: reader.raw.pagination || {},
+        // 票 61 分页口径(单窗服务端 / 多窗已加载合并集合),由 fetchEventHistory 组装。
+        paging: reader.raw.paging || null,
         missing: reader.missing,
     }
 }
@@ -151,6 +159,8 @@ export function toEventHistory(raw) {
 /** 效率载荷 → 领域对象。兼容秒数字段和 averageDuration 聚合对象。 */
 export function toHandlingEfficiency(raw) {
     const reader = fieldReader(raw, 'handlingEfficiency')
+    const familyRaw = reader.optionalStr('eventFamily') || reader.optionalStr('mode')
+    const familyCode = familyRaw === EVENT_FAMILY.ALARM || familyRaw === EVENT_FAMILY.WARNING ? familyRaw : ''
     const family = reader.raw.familyMetrics && typeof reader.raw.familyMetrics === 'object' ? reader.raw.familyMetrics : {}
     const summary = reader.raw.summary || {}
     const handling = summary.handlingEfficiency || family.handlingEfficiency || reader.raw.handlingEfficiency
@@ -164,7 +174,20 @@ export function toHandlingEfficiency(raw) {
         label: item.name,
         count: numberish(states[item.code] === undefined ? states[String(item.code)] : states[item.code]) || 0,
     }))
+    // 票 65:本端点按**事件族**分流。预警族没有工单口径可画(预警不挂工单),
+    // 但有真实的预警口径读数:条数、逐事件等级、逐桶发生量、工单关联实况;
+    // 处置率/工单状态/结案时长属**报警的工单处置口径**,预警模式下不得上屏。
+    const trendRowsRaw = list(raw, 'durationTrend')
+    const events = list(raw, 'events')
+    const totalEvents = numberish(summary.totalEvents === undefined ? reader.raw.totalEvents : summary.totalEvents)
+    // 等级分布只统计**已返回的**事件行(本端点分页,未取全时如实标记,不拿半份冒充全量)。
+    const levelDistribution = [1, 2, 3, 4].map((level) => ({
+        code: String(level),
+        label: eventLevelTag(level).label,
+        count: events.filter((item) => Number(item && item.level) === level).length,
+    }))
     return {
+        family: familyCode,
         handlingRate: numberish(handlingValue.value === undefined ? (handlingValue.rate === undefined ? reader.raw.handlingRate : handlingValue.rate) : handlingValue.value),
         // 票 20:把 `state` 一并带出 —— 「分母为 0」与「处置率真的是 0%」必须分得开
         // (后端在无分母时回 value="0.0000" + state="EMPTY")。
@@ -173,14 +196,25 @@ export function toHandlingEfficiency(raw) {
         basis: handlingValue.basis || reader.raw.basis || '',
         numerator: numberish(handlingValue.numerator === undefined ? reader.raw.numerator : handlingValue.numerator),
         denominator: numberish(handlingValue.denominator === undefined ? reader.raw.denominator : handlingValue.denominator),
-        totalEvents: numberish(reader.raw.totalEvents),
+        totalEvents,
         handledEvents: numberish(reader.raw.handledEvents),
+        // 票 65:事件回联工单的真实计数(预警族实测 linked=0 / missing=全部)。
+        linkedWorkOrderCount: numberish(summary.linkedWorkOrderCount === undefined ? reader.raw.linkedWorkOrderCount : summary.linkedWorkOrderCount),
+        missingWorkOrderCount: numberish(summary.missingWorkOrderCount === undefined ? reader.raw.missingWorkOrderCount : summary.missingWorkOrderCount),
+        levelDistribution,
+        // 等级分布的分母是否等于该窗口的事件总数(分页未取全时界面如实说明)。
+        levelDistributionComplete: totalEvents === null || events.length >= totalEvents,
         responseDuration: durationValue(raw, ['averageResponseDuration', 'averageResponseSeconds']) || durationValue(averageDuration, ['responseDuration', 'responseSeconds', 'response']),
         closeDuration: summary.averageDuration
             ? summary.averageDuration.state === 'EMPTY' ? null : summary.averageDuration
             : durationValue(raw, ['averageCloseDuration', 'averageCloseSeconds']) || durationValue(averageDuration, ['closeDuration', 'closeSeconds', 'close']),
         workOrderStates: distribution,
-        trend: list(raw, 'durationTrend').map((item) => ({
+        // 逐桶**事件发生量**(与下面的结案时长趋势是两个口径,预警族只用这一份)。
+        eventTrend: trendRowsRaw.map((item) => ({
+            bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
+            count: numberish(item && item.eventCount) || 0,
+        })),
+        trend: trendRowsRaw.map((item) => ({
             bucket: String(item && (item.timeBucket || item.bucket || item.bucketStart || '')),
             count: item && item.averageDuration
                 ? item.averageDuration.state === 'EMPTY' ? null : numberish(item.averageDuration.value)
@@ -238,24 +272,50 @@ function historyTrendRows(rows = []) {
     return trendRows([...days].map(([bucket, count]) => ({ bucket, count })))
 }
 
+/**
+ * 历史事件清单块(票 61)。
+ *
+ * 两种分页形态由 `data.paging` 决定,**不在这里猜**:
+ *   · `mode:'server'`(单窗,近 7 天):`events` 就是后端返回的当前页,
+ *     块只带 `serverPage` 元数据,翻页由 store 重新请求(PanelBlocks 只画控件);
+ *   · `mode:'merged'`(多窗,近 30/90 天):`events` 是已加载合并集合,
+ *     块声明 `paginate` 由渲染层切窗口;页脚如实说明合并了几个窗口/加载了多少条
+ *     (多窗不存在全局页,不伪造页码)。
+ * 没有 `paging` 元数据(旧 mock/旧载荷)时退化为普通清单块,不画翻页控件。
+ */
+function historyEventListBlock(current) {
+    const events = current.events || []
+    const rows = events.map((item) => ({
+        key: item.code,
+        title: item.name || item.code,
+        tone: eventLevelTag(item.level).tone,
+        toneLabel: eventLevelTag(item.level).label,
+        meta: [item.code, item.eventTime, item.deviceName, item.status].filter(Boolean),
+        points: [],
+    }))
+    const emptyText = '当前时间范围没有历史事件'
+    const paging = current.paging && typeof current.paging === 'object' ? current.paging : null
+    if (!paging) return listBlock('历史事件', rows, emptyText)
+    if (paging.mode === 'server') {
+        return listBlock('历史事件', rows, emptyText, {
+            serverPage: { page: paging.page, pageCount: paging.pageCount, total: paging.total },
+        })
+    }
+    return listBlock('历史事件', rows, emptyText, {
+        paginate: true,
+        // 页码归属键:范围/族一变就复位(条文 5/6;标量键,不接受对象身份)。
+        // 范围键由请求侧带出(后端回显的 range.type 是 TREND_WINDOW,不能当预设范围用)。
+        pageScope: `${current.mode || ''}|${paging.rangeKey || ''}`,
+        pageNote: `已合并 ${paging.windowCount || 0} 个时间窗 · 已加载 ${paging.loaded || 0} 条`,
+    })
+}
+
 export function historyAnalysisPanel(data, section) {
     const current = data || {}
-    const events = current.events || []
     return sectionView('historyAnalysis', section, [
         // 2026-10-13 图表选型:时间趋势用折线。
         trendBlock((current.trend || []).length > 48 ? '历史事件趋势 · 每日合计' : '历史事件趋势', historyTrendRows(current.trend || []), { chart: 'line' }),
-        listBlock(
-            '历史事件',
-            events.slice(0, 12).map((item) => ({
-                key: item.code,
-                title: item.name || item.code,
-                tone: eventLevelTag(item.level).tone,
-                toneLabel: eventLevelTag(item.level).label,
-                meta: [item.code, item.eventTime, item.deviceName, item.status].filter(Boolean),
-                points: [],
-            })),
-            current.bucketed ? '本端点提供历史时间桶,逐条事件请查看当前事件列表' : '当前时间范围没有历史事件',
-        ),
+        historyEventListBlock(current),
     ], {
         label: '历史分析',
         range: current.range,
@@ -263,25 +323,241 @@ export function historyAnalysisPanel(data, section) {
     })
 }
 
-export function responseEfficiencyPanel(data, section) {
+/**
+ * 预警效率面板的**标题**(票 65):随预警/报警切换一起换,词表复用
+ * `eventListSwitchView` 的单一词条(「预警」/「报警」),不新增第二张表。
+ * 外壳(面板外框标题)与本面板的内部标题都读它,两处由构造一致。
+ */
+export function responseEfficiencyTitle(mode) {
+    return `${eventListSwitchView(mode).label}效率`
+}
+
+/**
+ * 预警效率面板的**口径说明**(票 65,预警族)。
+ *
+ * 背景(2026-10-21 复核取证,维护者要求「确认预警数据是否正确」):
+ * 后端 `CockpitHandlingEfficiencyService` 的 `stateCounts` 来自 `scopeWorkOrders(...)`,
+ * 其源码注释原句是「范围内**全部工单**(不要求关联报警/事件)」——即处置率/状态分布/
+ * 结案时长都是**工单**口径,与事件族无关。实测两族同样结构:
+ * 预警族 `linkedWorkOrderCount=0`、八态合计 0。因此**预警族确实没有预警自身的处置流程统计**。
+ *
+ * 这段文字是要给值班人员看的,所以修掉上一版的两处毛病:
+ *   ① 「本端点」是接口词汇(项目纪律:接口词汇不上大屏)→ 改成说「后端/当前数据」;
+ *   ② 前半句先讲「我们没有字段」,读者要读两遍才知道面板上的数是什么
+ *      → 改成先讲**面板上这些数是什么、能信什么**,再讲为什么没有工单口径。
+ */
+export const WARNING_EFFICIENCY_GAP =
+    '预警不挂工单,所以这里不画工单口径的处置率、工单状态分布与结案时长;' +
+    '下方的条数、等级分布与发生趋势都是本窗口内预警自身的真实读数。' +
+    '预警的处置流程统计(如「预警→处置动作」流转)后端尚未提供,已在需求池登记,上线后此处会自动换成预警自身的口径'
+
+/**
+ * 预警族 KPI:预警自身可证实的读数(票 65)。
+ * 「已关联/未关联工单」是**真实计数**(预警族实测 0 / 全部),不是 0% 假读数。
+ * 读数缺失走 `valueText` 的占位符(票 22/42 纪律:不把「没有」画成 0)。
+ */
+function warningEfficiencyKpis(current) {
+    return kpiBlock([
+        { key: 'warningTotal', label: '预警条数', value: valueText(current.totalEvents), unit: '条' },
+        { key: 'warningLinked', label: '已关联工单', value: valueText(current.linkedWorkOrderCount), unit: '条', hint: '预警不挂工单:该读数是事件回联到工单的实况' },
+        { key: 'warningMissing', label: '未关联工单', value: valueText(current.missingWorkOrderCount), unit: '条', hint: '预警不挂工单:没有回联工单的预警条数' },
+    ])
+}
+
+/** 报警族 KPI:工单口径(工单是报警的处置手段 —— 票 41/42/52 既有裁定,本票不动)。 */
+function alarmEfficiencyKpis(current) {
+    return kpiBlock([
+        { key: 'handlingRate', label: '处置率', value: handlingRateView(current), hint: handlingRateHint(current) },
+        { key: 'responseDuration', label: '平均响应时长', value: durationView(current, ['responseDuration']) },
+        { key: 'closeDuration', label: '平均关闭时长', value: durationView(current, ['closeDuration']) },
+    ])
+}
+
+/**
+ * 响应效率(「预警效率」)面板(票 19;**票 65 两族语义分流**)。
+ *
+ * 预警族与报警族的处置对象根本不同:**工单是报警的处置手段**(票 41/42/52),
+ * 而预警不挂工单。所以同一个端点返回的工单口径字段只在报警模式下有意义;
+ * 预警模式下照画就是「用一个不存在的对象统计预警」(2026-10-19 维护者反馈第 6 条)。
+ *
+ * `extra.mode` 是当前切换族(外壳从 `eventStore.mode` 传入);`data.family` 是后端回显,
+ * 两者都有时以**回显**为准(那份数据确实是哪一族就以哪一族画)。都没有时按报警口径
+ * (工单口径是既有形态,不因本票对旧调用点改造)。
+ *
+ * @param {object|null} data `toHandlingEfficiency()` 的领域对象
+ * @param {object} section 分区
+ * @param {{mode?: string}} [extra] 当前切换族
+ */
+export function responseEfficiencyPanel(data, section, extra) {
     const current = data || {}
+    const family = current.family || (extra && extra.mode) || EVENT_FAMILY.ALARM
+    if (family === EVENT_FAMILY.WARNING) {
+        // 发生趋势与历史分析同一条「多桶→按日合并」口径(报复用,不另写一份采样)。
+        const trend = historyTrendRows(current.eventTrend || [])
+        return sectionView('responseEfficiency', section, [
+            warningEfficiencyKpis(current),
+            barsBlock('预警等级分布', current.levelDistribution || []),
+            trendBlock(trend.length > 48 ? '预警发生趋势 · 每日合计' : '预警发生趋势', trend, { chart: 'line' }),
+        ], {
+            label: responseEfficiencyTitle(EVENT_FAMILY.WARNING),
+            range: current.range,
+            // 口径说明(走票 56 的折叠摘要,`data-panel-note`):说清「预警不挂工单」与契约缺口。
+            note: WARNING_EFFICIENCY_GAP,
+            rangeNote: warningRangeNote(current),
+        })
+    }
     return sectionView('responseEfficiency', section, [
-        kpiBlock([
-            { key: 'handlingRate', label: '处置率', value: handlingRateView(current), hint: handlingRateHint(current) },
-            { key: 'responseDuration', label: '平均响应时长', value: durationView(current, ['responseDuration']) },
-            { key: 'closeDuration', label: '平均关闭时长', value: durationView(current, ['closeDuration']) },
-        ]),
+        alarmEfficiencyKpis(current),
         // 2026-10-13 图表选型:工单状态是多类别占比 → 百分比堆叠条(饼图超过 5 类不再适合)。
         stackedBlock('工单状态分布', current.workOrderStates || []),
         // 时长趋势仍是时间序列 → 折线。
         trendBlock('平均结案时长趋势', trendRows((current.trend || []).filter((row) => row.count !== null)), { chart: 'line' }),
+        // 票 65 追加:报警模式下直接列出当前工单(卡片,可点开详情)。
+        // 放在图之后:读者先看结论(处置率/分布),再看逐张明细。
+        workOrderListBlock(extra),
     ], {
-        label: '预警效率',
+        label: responseEfficiencyTitle(EVENT_FAMILY.ALARM),
         range: current.range,
-        rangeNote: current.denominator === null || current.denominator === undefined ? '' : `可计入工单 ${current.denominator} 张 · 已结案 ${current.numerator === null ? '—' : current.numerator} 张`,
+        // 报警族口径说明:工单是报警的处置手段(与预警族那句并列,切换时一起换)。
+        note: ALARM_EFFICIENCY_BASIS,
+        rangeNote: current.rangeNote === undefined
+            ? (current.denominator === null || current.denominator === undefined
+                ? ''
+                : `可计入工单 ${current.denominator} 张 · 已结案 ${current.numerator === null ? '—' : current.numerator} 张`)
+            : current.rangeNote,
     })
 }
 
+/**
+ * 报警族的「当前工单列表」块(票 65)。
+ *
+ * **数据来自另一个端点**(`GET /api/cockpit/v1/work-orders`),不是 `handling-efficiency`:
+ * 后者只给工单**状态计数**、不返回工单实体(实测报警族八态合计 0,但设备维度能查到 7 张工单)。
+ * 所以这个块的行来自 `extra.workOrderList`(store 单独取一次)。
+ *
+ * **两处口径必须是同一批工单**(2026-10-22 维护者反馈「列表没按时间筛」后对齐):
+ * 上方「工单状态分布」的分母来自 `CockpitHandlingEfficiencyService.scopeWorkOrders(...)`,
+ * 那里按 `create_time` 落在窗口内;因此本列表也按 `create_time` + 同一时间窗过滤
+ * (window 由 store 从 `activeConditions()` 传下去)。这样两处统计的是同一批工单,
+ * 不会再出现「分布说 0 张、列表列出 7 张」的自相矛盾。
+ *
+ * 三条如实处理:
+ *   · 还没取到 → 不给假空块,`emptyText` 说「正在取数」;
+ *   · 取到但是 0 张 → 说「当前没有工单」(这是真实业务结论),不是「加载失败」;
+ *   · 取失败 → 说「工单列表未取到」,与「确实没有」是两句不同的话(票 11/18 分区纪律)。
+ *
+ * @param {{workOrderList?: object}} [extra] store 传下来的工单列表分区(`sections.workOrderList`)
+ */
+function workOrderListBlock(extra) {
+    const section = (extra && extra.workOrderList) || null
+    const loaded = !!section && section.loaded === true
+    const data = (section && section.data) || null
+    const orders = (data && data.orders) || []
+    const paging = (data && data.pagination) || null
+
+    if (!loaded) {
+        return listBlock('当前工单列表', [], '正在取数…', { pageNote: '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单' })
+    }
+    if (section.state !== 'success' && section.state !== 'stale' && orders.length === 0) {
+        // 失败/不可用:明确说没取到,不画成「0 张工单」。
+        return listBlock('当前工单列表', [], '工单列表未取到(其余读数不受影响)', {
+            pageNote: '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单',
+        })
+    }
+    return listBlock('当前工单列表', workOrderCardRows(orders), '当前没有工单', {
+        serverPage: paging ? { page: paging.page, pageCount: paging.totalPages, total: paging.total } : undefined,
+        pageNote: paging
+            ? `本窗口内创建的工单共 ${paging.total} 张 · 与上方「工单状态分布」是同一批工单(均按创建时间落在所选范围)`
+            : '按创建时间落在此窗口,与上方「工单状态分布」是同一批工单',
+    })
+}
+
+/**
+ * 工单 → 列表卡片行(票 65 精简形态)。
+ *
+ * 字段选择只取「一眼判断该不该点开」的信息:工单号、状态、设备、描述、创建时间。
+ * 行字段名**贴着共享渲染层已认的形状**(`code`/`name`/`tone`/`toneLabel`/`meta`):
+ * `PanelBlocks` 的 list 块见到 `row.code` 就把整张卡做成可点(点击 emit `open-card`),
+ * 见到 `tone`+`toneLabel` 就画状态标签 —— 所以这里不再发明第二套卡片协议。
+ *
+ * 文本一律复用 `equipmentDetailView.workOrderRows`(单一词表:状态字典 0–7、
+ * 关联文本、时间短读法都在那儿定过一次),本函数只做**卡片需要的再包装**。
+ *
+ * @param {Array} orders `toWorkOrder()` 的领域对象数组
+ */
+export function workOrderCardRows(orders) {
+    const rows = workOrderRows(orders)
+    return rows.map((row) => ({
+        key: row.code,
+        // `code` 是渲染层的可点契约(点开走票 12 的工单时间线)。
+        code: row.code,
+        // 卡片抬头就是工单号:值班人员按编号对工单,不按故障描述认。
+        title: row.code,
+        name: row.code,
+        // 色键必带文字(票 06:标签不能只靠颜色)——文案取自 workOrder.js 的词表。
+        tone: workOrderStateTone(row.stateCodeNumber),
+        toneLabel: row.stateName,
+        stateCode: row.stateCodeNumber,
+        stateName: row.stateName,
+        // 副信息行:来源 / 创建时间 / 关联实况(都有才画,缺的自动不占位)。
+        meta: [
+            row.typeName,
+            row.createTime ? `创建 ${row.createTime}` : '',
+            row.associationText,
+        ].filter(Boolean),
+        // 描述可能很长(实测有「监测报警,当前值:14.60(阈值:-~5),请现场核实处理」):
+        // 列表里由 EllipsisText 省略,完整值在时间线弹窗里看 —— 卡片不为一段长文本撑高整块。
+        description: row.description,
+        reason: row.description,
+    }))
+}
+
+/**
+ * 工单状态 → `StatusTag` 的色键(票 65 卡片用)。
+ *
+ * **不登记第二张状态词表**:词(待派单…)一律走 `workOrder.js` 的 `WORK_ORDER_STATES`,
+ * 这里只回答「这一态借哪一枚既有色键上色」。
+ *
+ * 为什么映射到既有键而不是新增键:`StatusTag` 的 tone 是**封闭词表**
+ * (`domain/statusTones.js` 的 `TONES`,未知 tone 会退回中性灰),
+ * 新增 `state-closed` 之类必须同时改那张表与样式,而那属于视觉批次;
+ * 本票只需「一眼看出这张工单是等着办、在办还是已了」,借用四档语义色即可:
+ *   · 待派单/待接单 → `lv1`(最需立刻响应,借一级告警色);
+ *   · 已接单/处理中/待验收 → `lv3`(在办,借三级信息色);
+ *   · 延期中 → `lv2`(旁路,需要盯);
+ *   · 已结案 → `lv4`(了结,借正常色);
+ *   · 已取消/认不出 → `neutral`(中性,不暗示任何态度)。
+ */
+export function workOrderStateTone(stateCode) {
+    if (stateCode === null || stateCode === undefined) return 'neutral'
+    const state = WORK_ORDER_STATES.find((item) => item.code === stateCode)
+    if (!state) return 'neutral'
+    if (state.code === 0 || state.code === 1) return 'lv1'
+    if (state.code === 6) return 'lv2'
+    if (state.terminal && state.code === 5) return 'lv4'
+    if (state.terminal) return 'neutral'
+    return 'lv3'
+}
+
+
+/** 报警族的口径说明(与预警族那句成对:切换时口径说明一起换)。 */
+export const ALARM_EFFICIENCY_BASIS =
+    '报警以工单为处置手段:处置率=已结案工单 ÷ 可计入工单(状态 0–6,排除已取消),单位为工单张数'
+
+/**
+ * 预警族的窗口读数(票 65):条数与工单关联实况都取**本窗口**的计数。
+ * 后端未给任何一项时回空串(不编造「0 条」——那是在说一件没被告知的事)。
+ * 等级分布来自**已返回的事件行**,分页未取全时如实说明分母不全(不拿半份冒充全量)。
+ */
+function warningRangeNote(current) {
+    const parts = []
+    if (numberish(current.totalEvents) !== null) parts.push(`本窗口预警 ${current.totalEvents} 条`)
+    if (numberish(current.linkedWorkOrderCount) !== null) parts.push(`已关联工单 ${current.linkedWorkOrderCount} 条`)
+    if (numberish(current.missingWorkOrderCount) !== null) parts.push(`未关联工单 ${current.missingWorkOrderCount} 条`)
+    if (current.levelDistributionComplete === false) parts.push('等级分布仅统计已返回的事件页,非全量')
+    return parts.join(' · ')
+}
+
 export function eventAnalysisPanel(data, section) {
     const current = data || {}
     const highlights = (current.highlights || []).map((item, index) => ({

+ 176 - 13
src/modules/cockpit/mocks/contractMocks.js

@@ -20,6 +20,8 @@ import { REQUEST_REASON } from '../domain/requestReason.js'
 // 票 20:工单状态字典**只从唯一源取**(后端 `WorkOrderStatusEnum` 0–7 的前端映射)。
 // mock 不再自备一份副本 —— 那正是"后端改成 0–7 而 mock 还发 1–7"的成因。
 import { WORK_ORDER_STATES } from '../domain/workOrder.js'
+// 票 61:历史分析的逐事件行与当前事件表共用同一套排序口径(不另写一份比较器)。
+import { sortEventRows } from '../domain/eventList.js'
 
 const SNAPSHOT_AT = '2026-09-02T10:00:00+08:00'
 
@@ -1216,13 +1218,13 @@ function eventRead(scenario, deviceCode = '') {
  *   3. **`levelColor` 由后端按 level 解析**(`CockpitEventLevelColor`:1→RED …
  *      4→GREEN,认不出→UNKNOWN),mock 照抄同一个映射;
  *   4. **排序**:eventTime 倒序、同时间按 eventNo 升序(`eventOrdering()`),
- *      「默认展示最新 10-15 条」的前端断言靠它成立;
+ *      服务端分页的页序靠它成立(票 61:每页 10 条,翻页由 `page`/`pageSize` 真切片);
  *   5. **内部分区**:`sections.events` 是 `CockpitSectionResult`(空时
  *      `{state: EMPTY, reason: NO_EVENTS}`),工单分区独立(样例给空);
  *      顶层不算空 —— 与设备详情聚合同一模式。
  *
- * 样例规模刻意大于 `DEFAULT_EVENT_LIMIT`(12):预警和报警各 14 条,
- * 让「仅展示最新 12 条 · 共 N 条」在 mock 上真的可见(验收第 2 条)。
+ * 样例规模刻意大于一页:预警和报警各 14 条,让 `pageSize=10` 的服务端分页
+ * 在 mock 上真能演示第二页(票 61;此前是「仅展示最新 12 条」帽的演示数据)。
  */
 
 /** mock 里事件等级 → 颜色(照抄后端 `CockpitEventLevelColor.resolve`)。 */
@@ -1451,6 +1453,19 @@ function responseEventSource(query) {
     return { mode, items: source.filter((item) => !industry || item.industry === industry) }
 }
 
+/**
+ * 历史分析载荷(票 19;票 61 补逐事件行 + 服务端分页)。
+ *
+ * 与后端 `CockpitHistoryAnalysisResponse` 同形:`rows[]` 小时桶 + `summary` +
+ * **`events[]` 逐事件行**(形状与 `events/current` 的 `EventItem` 同构)+ `pagination`。
+ * `page`/`pageSize` 按后端语义真实切片(默认 100、上限 1000),所以 `pageSize=10`
+ * 在 mock 上就能演示两页(单窗直取;多窗由前端合并后集合分页)。
+ *
+ * 一处如实留档的取舍:样例事件时间固定在 `SNAPSHOT_AT` 附近,而面板请求的是
+ * 「现在」往前 N 天的窗口 —— 按窗口过滤事件会恒空,多页翻页在 mock 上就演不出来。
+ * 与既有 mock「固定快照日」同一取舍:**事件行不按请求窗口过滤**(只按族/行业过滤),
+ * 小时桶仍按请求窗口生成(既有行为不变)。真实契约的窗口过滤由闸门注入载荷证明。
+ */
 function historyAnalysisPayload(scenario, query) {
     const source = responseEventSource(query)
     const range = warningRange(query)
@@ -1460,32 +1475,91 @@ function historyAnalysisPayload(scenario, query) {
     for (let time = Math.floor(Date.parse(range.startTime) / hour) * hour; time <= Date.parse(range.endTime); time += hour) {
         buckets.push({ timeBucket: new Date(time).toISOString(), eventCount: scenario === 'empty' ? 0 : items.filter((item) => Math.floor(Date.parse(item.eventTime) / hour) === time / hour).length, handledCount: 0 })
     }
+    const events = scenario === 'empty' ? [] : sortEventRows(source.items)
+    const page = Number(query && query.page) > 0 ? Number(query.page) : 1
+    const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : 100
+    const from = Math.min((page - 1) * pageSize, events.length)
+    const to = Math.min(from + pageSize, events.length)
+    const total = events.length
     return {
         requestId: 'mock-history-analysis', apiVersion: 'v1', readOnly: true, mode: source.mode,
         range, updatedAt: SNAPSHOT_AT,
-        pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false },
+        pagination: { page, pageSize, total, totalPages: total ? Math.ceil(total / pageSize) : 0, hasMore: to < total },
+        // 票 61:事件行的总数与 `pagination.total` 同源(不由当前页反推)。
+        events: events.slice(from, to),
         rows: scenario === 'empty' ? [] : buckets,
-        summary: { eventCount: scenario === 'empty' ? 0 : items.length },
+        summary: { eventCount: total },
     }
 }
 
+/**
+ * `/events/handling-efficiency`(票 19;票 65 两族语义分流)。
+ *
+ * 形状照抄后端 `CockpitHandlingEfficiencyResponse`:`eventFamily` 回显 + `summary`
+ * (条数/工单关联实况/工单口径处置率与时长)+ `workOrderStateCounts`(八态全铺)+
+ * `durationTrend`(逐桶 `eventCount` 与结案时长)+ 逐事件行 `events[]`。
+ *
+ * **两族同形但语义不同**(票 65 的票面事实):后端对两族回同一套字段,可预警不挂工单。
+ * mock 因此照着真实数据给:预警族 `linkedWorkOrderCount=0`、`missingWorkOrderCount=条数`
+ * (逐事件行 `hasWorkOrder=false`),工单口径字段照旧存在但**只在报警模式下被消费**;
+ * 报警族按等级 1 的事件给承接工单(样例里 `linked=4`),让工单口径在报警模式有真读数。
+ * 不在这里「帮」前端把预警族的工单字段置空 —— 那会把契约缺口藏起来,与票面纪律相反。
+ */
 function handlingEfficiencyPayload(scenario, query) {
     const source = responseEventSource(query)
     const range = warningRange(query)
-    const denominator = scenario === 'empty' ? 0 : 3
-    const numerator = scenario === 'empty' ? 0 : 2
+    const blank = scenario === 'empty'
+    const denominator = blank ? 0 : 3
+    const numerator = blank ? 0 : 2
+    const items = blank ? [] : source.items
+    // 预警不挂工单(实测),报警按等级 1 的样本给承接工单 —— 两族的关联实况刻意不同。
+    const linkedOf = (item) => source.mode === 'ALARM' && Number(item.level) === 1
+    const linkedCount = items.filter(linkedOf).length
+    const events = items.map((item) => {
+        const linked = linkedOf(item)
+        const stateCode = linked ? 3 : null
+        return {
+            eventNo: item.eventNo,
+            deviceCode: item.deviceCode,
+            deviceName: item.deviceName,
+            level: item.level,
+            levelColor: eventLevelColorOf(item.level),
+            eventTime: item.eventTime,
+            workOrderStateCode: stateCode,
+            workOrderStateName: stateCode === null ? null : '处理中',
+            durationHours: null,
+            hasWorkOrder: linked,
+        }
+    })
     return {
-        requestId: 'mock-handling-efficiency', apiVersion: 'v1', readOnly: true, mode: source.mode,
+        requestId: 'mock-handling-efficiency', apiVersion: 'v1', readOnly: true,
+        displayName: source.mode === 'ALARM' ? '报警效率' : '预警效率',
+        eventFamily: source.mode,
         range, updatedAt: SNAPSHOT_AT,
+        pagination: { page: 1, pageSize: 100, total: items.length, totalPages: items.length ? 1 : 0, hasMore: false },
         summary: {
-            totalEvents: scenario === 'empty' ? 0 : source.items.length,
-            handlingEfficiency: { value: denominator ? numerator / denominator : 0, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },
-            averageDuration: { value: denominator ? 2.4 : 0, unit: 'HOURS', state: denominator ? 'AVAILABLE' : 'EMPTY' },
+            totalEvents: items.length,
+            linkedWorkOrderCount: linkedCount,
+            missingWorkOrderCount: items.length - linkedCount,
+            handledCount: blank ? 0 : 2,
+            inProgressCount: blank ? 0 : 1,
+            cancelledCount: blank ? 0 : 1,
+            handlingEfficiency: { value: blank ? 0 : numerator / denominator, numerator, denominator, state: denominator ? 'AVAILABLE' : 'EMPTY', reason: denominator ? 'READY' : 'NO_ELIGIBLE_WORK_ORDERS', basis: 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS' },
+            averageDuration: { value: denominator ? 2.4 : 0, unit: 'HOURS', state: denominator ? 'AVAILABLE' : 'EMPTY', sampleCount: denominator ? 2 : 0, reason: denominator ? null : 'NO_CLOSED_WORK_ORDERS' },
         },
         // 正式端点仅提供结案平均时长,不编造平均响应时长。
         // 票 20:键为后端 0–7 状态码(待派单…已取消)。旧的 1–7 键已随字典对齐改掉。
         workOrderStateCounts: WORK_ORDER_STATES.map((state) => ({ stateCode: state.code, stateName: state.name, count: denominator ? state.code === 5 ? 2 : state.code === 6 ? 1 : state.code === 7 ? 1 : 0 : 0 })),
-        durationTrend: scenario === 'empty' ? [] : [1.4, 2.1, 2.4, 1.8, 2.6, 2.2, 2.4].map((value, index) => ({ timeBucket: `2026-09-${String(index + 1).padStart(2, '0')}`, averageDuration: { value, unit: 'HOURS', state: 'AVAILABLE' } })),
+        // 票 65:逐桶带 `eventCount`(事件发生量;预警族只消费这一份,结案时长属工单口径)。
+        durationTrend: blank
+            ? []
+            : [1.4, 2.1, 2.4, 1.8, 2.6, 2.2, 2.4].map((value, index) => ({
+                  timeBucket: `2026-09-${String(index + 1).padStart(2, '0')}`,
+                  eventCount: 2,
+                  handledCount: 0,
+                  averageDuration: { value, unit: 'HOURS', state: 'AVAILABLE' },
+              })),
+        events,
     }
 }
 
@@ -1956,6 +2030,10 @@ function monitoringOverviewPayload(scenario, query) {
             ? { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_RESOLVED_DEVICES' }
             : { value: rate, numerator: onlineCount, denominator, state: 'AVAILABLE', reason: null },
         typeDistribution: types,
+        // 票 62 追加需求:逐设备台账 `devices[]`(与 `counts.equipmentCount` 同源同口径)。
+        // 用与类型分布同一份账本**逐台展开**——行数结构性等于 `equipmentCount`,
+        // 不是另写一组固定数字(那样两处读数早晚会漂)。
+        devices: blank || noDevices ? [] : overviewDevices(selectedTypes),
         mapFilter: { industry: industry || null, deviceType: deviceType || null, pointCodes: blank ? [] : ['MP-001', 'MP-002', 'MP-003'], deviceCodes: [] },
         // `degraded` 走字段缺失样例:剔除数缺失,适配层要记 missing 而不是当 0。
         excluded:
@@ -1965,6 +2043,45 @@ function monitoringOverviewPayload(scenario, query) {
     }
 }
 
+/**
+ * 逐设备台账样例(票 62 追加需求)。
+ *
+ * **从类型账本逐台展开**,不是另写一组固定行:后端 `build()` 里逐设备行与
+ * `counts.equipmentCount` 出自同一次遍历,mock 也照这个结构保证——行数恒等于
+ * `equipmentCount`(改账本就同时改两处,不会漂)。
+ *
+ * 每行的状态按该类型的 online/offline/stale 配比逐台给出(前 N 台在线、接着离线、
+ * 再接着滞后),所以「设备台账」在 mock 上能看到三种状态并存,而不是清一色在线。
+ */
+function overviewDevices(selectedTypes) {
+    const devices = []
+    for (const row of selectedTypes || []) {
+        const industry = row.industry
+        const typeCode = row.deviceType.code
+        const typeName = row.deviceType.name
+        for (let index = 0; index < row.deviceCount; index += 1) {
+            const seq = String(index + 1).padStart(2, '0')
+            const deviceCode = `EQ-${industry}-${typeCode}-${seq}`
+            const state = index < row.onlineCount ? 'ONLINE'
+                : index < row.onlineCount + row.offlineCount ? 'OFFLINE'
+                : 'STALE'
+            devices.push({
+                deviceCode,
+                deviceName: `${typeName}-${seq}`,
+                deviceType: { code: typeCode, name: typeName },
+                industry,
+                monitoringState: state,
+                // 前半数设备挂到监测点上,后半数留空 —— 台账里"未关联点位"是真实取值,
+                // 界面必须照实说(不拿空串冒充点位编码)。
+                monitoringPointCode: index % 2 === 0 ? `MP-${industry}-${seq}` : null,
+                sampledAt: '2026-09-02T09:59:00+08:00',
+                createdAt: '2026-08-31T08:00:00+08:00',
+            })
+        }
+    }
+    return devices
+}
+
 /** 一个分布 marker(监测点或未关联设备;两族字段名只差 `point*` / `device*`)。 */
 function distributionPoint(pointCode, pointName, district, industry, typeCode, typeName, pointStatus, pointCodes, coord) {
     return {
@@ -2090,11 +2207,30 @@ function monitoringDistributionPayload(scenario, query, overrides = {}) {
     // 分页:窗口横跨两类对象(一条总序列)。
     const page = Number(query && query.page) > 0 ? Number(query.page) : 1
     // 页大小的**默认值照后端**(500)。但那样 6 个点的样例一页就到底,
-    // 「台账渐进加载」这条验收在真机上永远演示不出来 —— 所以走查可用
+    // 「台账自动渐进装载」这条验收在真机上永远演示不出来 —— 所以走查可用
     // `window.__COCKPIT_MOCK_MONITORING__ = { pageSize: 3 }` 现场压小页大小
     // (与票 08 地图摘要的 `__COCKPIT_MOCK_MAP__` 同一套手法)。
     const fallbackPageSize = Number(overrides.pageSize) > 0 ? Number(overrides.pageSize) : 500
     const pageSize = Number(query && query.pageSize) > 0 ? Number(query.pageSize) : fallbackPageSize
+    // 票 62(执行票同期后端已补齐回显):快照 token 与 map-snapshot 同一套纪律——
+    //   · 第 1 页可不带,后端开新快照并**在响应里回显**;
+    //   · 第 2 页起**必须**带,且要与当前快照一致,否则按后端原样抛(400 / SNAPSHOT_EXPIRED)。
+    // 注意 `query` 是**已解析的参数对象**(`context.query`),不是原始路径串。
+    const snapshotAt = (query && query.snapshotAt) || ''
+    const snapshotToken = overrides.snapshotAt || SNAPSHOT_AT
+    if (page > 1 && !snapshotAt) {
+        // 后端对「第 2 页没带快照」回 400 + INVALID_REQUEST。mock 侧统一出口只认
+        // `REQUEST_REASON` 白名单,所以这里落成 `unavailable`(断言看的是「请求被拒、
+        // 台账停链且不回滚已装载内容」,不是具体原因码)。
+        const error = new Error('mock: 监测分布第 2 页起必须带 snapshotAt')
+        error.reason = REQUEST_REASON.UNAVAILABLE
+        throw error
+    }
+    if (snapshotAt && snapshotAt !== snapshotToken) {
+        const error = new Error('mock: 监测分布快照已失效')
+        error.reason = REQUEST_REASON.SNAPSHOT_EXPIRED
+        throw error
+    }
     const from = Math.max(0, (page - 1) * pageSize)
     const to = from + pageSize
     const slice = selected.slice(from, to)
@@ -2107,6 +2243,9 @@ function monitoringDistributionPayload(scenario, query, overrides = {}) {
     return {
         ...envelope,
         range: { type: 'CURRENT_BASELINE' },
+        // 票 62:快照标记回显(与真实后端执行票同期补齐的字段同名同义),
+        // 调用方第 2 页起原样回带。
+        snapshotAt: snapshotToken,
         districtDistribution: districtKeys.map((district) => distributionRow((item) => normalizedDistrict(item.district), { value: district, build: { district: district === 'UNASSIGNED' ? null : Number(district), districtName: district === 'UNASSIGNED' ? '未分区' : ['城南', '城北', '太常'][Number(district)] } })),
         industryDistribution: industryKeys.map((industry) => distributionRow((item) => item.industry, { value: industry, build: { industry } })),
         deviceTypeDistribution: typeKeys.map(([code, type]) =>
@@ -2566,6 +2705,30 @@ const PAYLOADS = {
     '/events/history-analysis': (s, _requested, context) => historyAnalysisPayload(s, context.query),
     '/events/handling-efficiency': (s, _requested, context) => handlingEfficiencyPayload(s, context.query),
     '/events/multidimensional-analysis': (s, _requested, context) => multidimensionalAnalysisPayload(s, context.query),
+    // 票 65:全量工单分页(报警效率面板的「当前工单列表」)。
+    // 与下面 `/equipments/*/work-orders` 的区别只在**没有设备/事件维度**:
+    // 真实后端 `GET /api/cockpit/v1/work-orders` 就是这个形状(含 pagination 四件套)。
+    // 分页在这里真的切窗口 —— mock 若总是回全量,前端的翻页与「共 N 张」口径就永远测不到。
+    '/work-orders': (s, _requested, context) => {
+        const query = (context && context.query) || {}
+        const all = s === 'empty' ? [] : TIMELINE_ORDERS
+        const page = Math.max(1, Number(query.page) || 1)
+        const pageSize = Math.max(1, Number(query.pageSize) || 10)
+        const total = all.length
+        const totalPages = Math.max(1, Math.ceil(total / pageSize))
+        const safePage = Math.min(page, totalPages)
+        return {
+            requestId: 'mock-work-order-list',
+            apiVersion: 'v1',
+            readOnly: true,
+            state: total === 0 ? 'EMPTY' : 'READY',
+            stateReason: total === 0 ? 'NO_WORK_ORDERS' : 'READY',
+            // 字典与设备维度同源(`domain/workOrder.js` 的 `WORK_ORDER_STATES`),不登记第二份。
+            stateDictionary: WORK_ORDER_STATES.map((state) => ({ code: state.code, name: state.name })),
+            workOrders: all.slice((safePage - 1) * pageSize, safePage * pageSize),
+            pagination: { page: safePage, pageSize, total, totalPages },
+        }
+    },
     '/equipments/*/work-orders': (s) => ({
         requestId: 'mock-work-orders',
         apiVersion: 'v1',

+ 48 - 2
src/modules/cockpit/services/detailApi.js

@@ -130,9 +130,55 @@ export function fetchWorkOrders({ deviceCode = '', eventSource = '', eventCode =
     })
 }
 
+/**
+ * 全量工单分页(票 65:报警效率面板的「当前工单列表」)。
+ *
+ * 与上面的 `fetchWorkOrders` 是**两个不同的问题**,刻意不合并:
+ *   · `fetchWorkOrders` 回答「某设备/某事件下有哪些工单」(详情卡用,无分页概念);
+ *   · 本函数回答「本窗口有哪些工单、共多少张」(效率面板用,要翻页 + 要时间筛)。
+ * 混成一个函数会让「没有编码」与「要全量」变成同一件事,调用方就得靠传空串区分。
+ *
+ * `startTime`/`endTime` 是 2026-10-22 补的:面板按近 7/30/90 天切换时列表必须跟着收窄
+ * (后端按 `create_time` 过滤,与上方「工单状态分布」同一批工单)。
+ * @param {{page?:number, pageSize?:number, stateCode?:number|null, startTime?:string, endTime?:string}} [query]
+ */
+export function fetchWorkOrderList(query = {}, options = {}) {
+    const params = new URLSearchParams()
+    if (Number.isInteger(query.page) && query.page > 0) params.set('page', String(query.page))
+    if (Number.isInteger(query.pageSize) && query.pageSize > 0) params.set('pageSize', String(query.pageSize))
+    if (Number.isInteger(query.stateCode)) params.set('stateCode', String(query.stateCode))
+    // 时间窗原样透传(ISO-8601 带偏移):掐头去尾的格式化会让「同一窗口」与其它面板不一致。
+    if (typeof query.startTime === 'string' && query.startTime) params.set('startTime', query.startTime)
+    if (typeof query.endTime === 'string' && query.endTime) params.set('endTime', query.endTime)
+    const queryString = params.toString()
+
+    return fetchSection(`/work-orders${queryString ? `?${queryString}` : ''}`, {
+        toData: (payload) => {
+            const paging = (payload && payload.pagination) || null
+            return {
+                state: (payload && payload.state) || '',
+                stateReason: (payload && payload.stateReason) || '',
+                dictionary: (payload && payload.stateDictionary) || [],
+                orders: Array.isArray(payload && payload.workOrders) ? payload.workOrders.map(toWorkOrder) : [],
+                // 分页回显:页码/页大小/总数/总页数都由后端给,前端不自行推算(票 61 纪律)。
+                pagination: paging
+                    ? {
+                          page: Number(paging.page) || 1,
+                          pageSize: Number(paging.pageSize) || 0,
+                          total: Number(paging.total) || 0,
+                          totalPages: Number(paging.totalPages) || 1,
+                      }
+                    : null,
+            }
+        },
+        isEmpty: isEmptyBy((payload) => Array.isArray(payload && payload.workOrders) && payload.workOrders.length === 0),
+        config: options.config,
+        fetchImpl: options.fetchImpl,
+    })
+}
+
 /** 单个工单的时间线(票 12)。 */
-export function fetchWorkOrderTimeline(workOrderCode, options = {}) {
-    if (!workOrderCode) return missingCode('工单', workOrderCode)
+export function fetchWorkOrderTimeline(workOrderCode, options = {}) {    if (!workOrderCode) return missingCode('工单', workOrderCode)
 
     return fetchSection(`/work-orders/${encodeURIComponent(workOrderCode)}/timeline`, {
         toData: toWorkOrderTimeline,

+ 270 - 28
src/modules/cockpit/services/warningResponseApi.js

@@ -4,6 +4,9 @@ import { toEventAnalysis, toEventHistory, toHandlingEfficiency } from '../domain
 import { fetchSection, isEmptyBy } from './domainRequest.js'
 import { eventTimeWindow, historyWindows } from '../domain/eventRange.js'
 import { SECTION_STATE, section } from '../domain/sectionState.js'
+import { sortEventRows } from '../domain/eventList.js'
+import { PAGE_SIZE, serverPageView } from '../domain/pagination.js'
+import { REQUEST_REASON } from '../domain/requestReason.js'
 
 export const WARNING_RESPONSE_PATHS = {
     history: '/events/history-analysis',
@@ -14,15 +17,49 @@ export const WARNING_RESPONSE_PATHS = {
 const freshnessOf = (payload) => (payload && payload.freshness && (payload.freshness.status || payload.freshness.state)) || ''
 const familyParam = (mode) => (mode === 'ALARM' ? 'ALARM' : 'WARNING')
 
+/** 多窗合并时每窗的取数上限(后端 `pageSize` 上限 1000):把每个窗已加载的逐事件行
+ * 全取下再合并,前端对合并集合分页(多窗不存在全局页,见规范三分法)。 */
+const HISTORY_WINDOW_MAX_PAGE_SIZE = 1000
+
+/**
+ * 请求范围类型 → 前端范围键(`7d`/`30d`/`90d`)。响应里的 `range.type` 是后端
+ * `TREND_WINDOW`(显式起止时间时不回显预设范围),所以**范围键由请求侧带出**,
+ * 供渲染层做「范围变化回第 1 页」的页码归属键(条文 5/6)。
+ */
+const RANGE_KEY_BY_TYPE = { LAST_7_DAYS: '7d', LAST_30_DAYS: '30d', LAST_90_DAYS: '90d' }
+
 function rangeParams(options) {
     const window = options.startTime && options.endTime ? options : eventTimeWindow(options.rangeType)
     return { range: options.rangeType, startTime: window.startTime, endTime: window.endTime }
 }
 
+/**
+ * 历史事件(票 19 聚合接口;票 61 改成「单窗服务端分页 / 多窗合并前端分页」):
+ *
+ *   · **单窗**(近 7 天):`page`/`pageSize=10` 进查询串,后端回当前页的逐事件行 +
+ *     `pagination`,`data.paging` 记 `mode:'server'`;
+ *   · **多窗**(近 30/90 天,单请求上限 31 天为后端契约):每窗取到上限 1000 条后
+ *     合并去重并按后端同一条排序口径重排,`data.paging` 记 `mode:'merged'` 与
+ *     `windowCount`/`loaded`——没有「全局页」,页脚必须如实说明,不伪造页码。
+ *
+ * 顶层失败/窗口回显不一致的收口与既有逻辑一致;竞态(迟到响应丢弃)由 eventStore 的
+ * token 纪律承担。
+ */
 export async function fetchEventHistory(options = {}) {
     const range = rangeParams(options)
     const windows = historyWindows(range.startTime, range.endTime)
-    const results = await Promise.all(windows.map((window) => fetchHistoryWindow({ ...options, ...window })))
+    const singleWindow = windows.length === 1
+    const requestedPage = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
+    const results = await Promise.all(
+        windows.map((window) =>
+            fetchHistoryWindow({
+                ...options,
+                ...window,
+                page: singleWindow ? requestedPage : 1,
+                pageSize: singleWindow ? PAGE_SIZE : 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 failed
     const hour = (value) => Math.floor(Date.parse(value) / 3600000)
@@ -39,15 +76,23 @@ export async function fetchEventHistory(options = {}) {
     }
     const first = populated[0]
     const retained = results.some((result) => result.state === SECTION_STATE.STALE)
+    const mergedEvents = sortEventRows([...events.values()])
+    const rangeKey = RANGE_KEY_BY_TYPE[options.rangeType] || ''
+    const paging = singleWindow
+        ? { mode: 'server', windowCount: 1, rangeKey, ...serverPageView(first.data && first.data.pagination, mergedEvents.length) }
+        : { mode: 'merged', windowCount: windows.length, loaded: mergedEvents.length, total: mergedEvents.length, rangeKey }
     return section(retained ? SECTION_STATE.STALE : SECTION_STATE.SUCCESS, {
         mode: first.mode,
         retained,
         data: {
             ...first.data,
             trend: [...rows.values()].sort((a, b) => String(a.bucket).localeCompare(String(b.bucket))),
-            events: [...events.values()],
+            events: mergedEvents,
             range: { key: '', type: options.rangeType || '', start: range.startTime, end: range.endTime },
             missing: [...new Set(populated.flatMap((result) => result.data.missing || []))],
+            // 单窗:服务端页码视图;多窗:只有「已加载集合」口径(无全局页)。
+            paging,
+            pagination: singleWindow ? (first.data && first.data.pagination) || null : null,
         },
     })
 }
@@ -73,42 +118,239 @@ function fetchHistoryWindow(options = {}) {
     })
 }
 
-function unsupportedWindow(options) {
+/**
+ * 聚合端点的**多窗取数 + 合并**(票 75)。
+ *
+ * 后端契约是「单请求 ≤31 天」(实测 90 天显式窗口 → `400 efficiency window must not exceed 31 days`),
+ * 但按 30 天切窗后逐窗请求全部 200 —— 所以 90 天的支持放在**前端消费侧**:用与票 61
+ * (历史事件多窗合并)同一段 `historyWindows` 切窗,逐窗取原始载荷,合并后再过一次域转换。
+ *
+ * 为什么在**原始 JSON 层**合并而不是域对象层:`toHandlingEfficiency`/`toEventAnalysis` 的
+ * 输入是后端形状,合并产物只要长得像"一次大窗口的响应",两条既有转换就一行不用改 ——
+ * 单窗路径与多窗路径最后走同一段域转换,不会分叉出两套口径。
+ *
+ * 失败语义(票 61 同一条纪律):任一窗失败 → 整分区 FAILED(不把 3/4 窗的数冒充 90 天);
+ * 有窗 STALE → 整体 STALE(retained:有值但过期);全部空窗 → EMPTY(真的没有,不是失败)。
+ *
+ * @param {string} path 端点路径
+ * @param {object} options 取数选项(mode/industry/config/fetchImpl)
+ * @param {{toDomain: Function, merge: Function, isEmptyRaw: Function}} hooks
+ */
+function fetchAggregated(path, options, hooks) {
+    const base = { eventFamily: familyParam(options.mode), industry: industryQuery(options.industry) }
     const range = rangeParams(options)
-    return Date.parse(range.endTime) - Date.parse(range.startTime) >= 32 * 86400000
-        ? section(SECTION_STATE.UNAVAILABLE, { reason: '当前效率与多维统计接口最多支持31天;90天历史趋势已通过分窗查询提供。' })
-        : null
-}
-
-export function fetchHandlingEfficiency(options = {}) {
-    const unavailable = unsupportedWindow(options)
-    if (unavailable) return Promise.resolve(unavailable)
-    return fetchSection(WARNING_RESPONSE_PATHS.efficiency, {
-        params: { eventFamily: familyParam(options.mode), industry: industryQuery(options.industry), ...rangeParams(options) },
-        toData: toHandlingEfficiency,
-        isEmpty: isEmptyBy((payload) => {
-            const family = payload && payload.familyMetrics
-            return !payload || (!(payload.summary && payload.summary.handlingEfficiency) && !payload.handlingEfficiency && !(family && family.handlingEfficiency) && payload.averageResponseSeconds === undefined && payload.averageResponseDuration === undefined && !(payload.averageDuration && typeof payload.averageDuration === 'object'))
-        }),
+    const windows = historyWindows(range.startTime, range.endTime)
+    if (windows.length <= 1) {
+        return fetchSection(path, {
+            params: { ...base, ...range },
+            toData: hooks.toDomain,
+            isEmpty: isEmptyBy(hooks.isEmptyRaw),
+            freshnessOf,
+            config: options.config,
+            fetchImpl: options.fetchImpl,
+        })
+    }
+    // 多窗:`toData` 走恒等(保留原始载荷给合并器),EMPTY 判定推迟到合并后统一做。
+    return Promise.all(windows.map((window) => fetchSection(path, {
+        params: { ...base, ...rangeParams({ ...options, ...window }) },
+        toData: (payload) => payload,
+        isEmpty: () => false,
         freshnessOf,
         config: options.config,
         fetchImpl: options.fetchImpl,
+    }))).then((results) => {
+        const failed = results.find((result) => ![SECTION_STATE.SUCCESS, SECTION_STATE.STALE, SECTION_STATE.EMPTY].includes(result.state))
+        if (failed) return failed
+        const populated = results.filter((result) => result.data)
+        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 })
+        const retained = results.some((result) => result.state === SECTION_STATE.STALE)
+        if (hooks.isEmptyRaw(merged)) {
+            return section(SECTION_STATE.EMPTY, { mode: 'multi-window', retained })
+        }
+        return section(retained ? SECTION_STATE.STALE : SECTION_STATE.SUCCESS, {
+            mode: 'multi-window',
+            retained,
+            data: hooks.toDomain(merged),
+            missing: populated.flatMap((result) => (result.data && result.data.missing) || []),
+        })
+    })
+}
+
+/** 防御性计数(合并求和用;非数字按 0,不让一个脏字段拖垮整窗合并)。 */
+function countOf(value) {
+    const n = Number(value)
+    return Number.isFinite(n) ? n : 0
+}
+
+/**
+ * 合并多个 handling-efficiency 载荷(票 75)。
+ *
+ * 逐字段口径(全部**可从各窗独立验证**,合并不发明新数):
+ *   · `durationTrend`:按时序拼接(窗口相接不重叠;仍按 `timeBucket` 去重防边界重复);
+ *   · `events`:各窗首页拼接(等级分布的"仅统计已返回页"标记由域层继续如实算);
+ *   · `summary` 各计数:求和;`handlingEfficiency` 用**合并后状态计数重算**
+ *     (分子=已结案(5),分母=可计入(0–6)——与后端 `stateCounts` 同一算法,不抄单窗百分比);
+ *   · `averageDuration`:按 `sampleCount` 加权平均(无样本如实 EMPTY,不画 0 冒充);
+ *   · `workOrderStateCounts`:按 `stateCode` 求和(字典名取首见窗)。
+ *
+ * 导出说明:闸门(t75)直接消费它断言合并口径 —— 合并规则只有这一份实现,
+ * 闸门不复刻第二份(复刻会漂移:服务改了、闸门还在验旧规则)。
+ */
+export function mergeEfficiencyPayloads(payloads, fullRange) {
+    const list = payloads.filter(Boolean)
+    const first = list[0] || {}
+    let durationTrend = []
+    const seenBuckets = new Set()
+    let events = []
+    let totalEvents = 0
+    let linked = 0
+    let missing = 0
+    let handled = 0
+    let inProgress = 0
+    let cancelled = 0
+    let weightedDuration = 0
+    let durationSamples = 0
+    const byState = new Map()
+    for (const payload of list) {
+        const summary = payload.summary || {}
+        totalEvents += countOf(summary.totalEvents)
+        linked += countOf(summary.linkedWorkOrderCount)
+        missing += countOf(summary.missingWorkOrderCount)
+        handled += countOf(summary.handledCount)
+        inProgress += countOf(summary.inProgressCount)
+        cancelled += countOf(summary.cancelledCount)
+        const duration = summary.averageDuration || {}
+        const samples = countOf(duration.sampleCount)
+        if (samples > 0 && Number.isFinite(Number(duration.value))) {
+            weightedDuration += Number(duration.value) * samples
+            durationSamples += samples
+        }
+        for (const row of Array.isArray(payload.workOrderStateCounts) ? payload.workOrderStateCounts : []) {
+            const previous = byState.get(row.stateCode)
+            byState.set(row.stateCode, {
+                stateCode: row.stateCode,
+                stateName: (previous && previous.stateName) || row.stateName,
+                count: ((previous && previous.count) || 0) + countOf(row.count),
+            })
+        }
+        for (const row of Array.isArray(payload.durationTrend) ? payload.durationTrend : []) {
+            if (seenBuckets.has(row.timeBucket)) continue
+            seenBuckets.add(row.timeBucket)
+            durationTrend.push(row)
+        }
+        if (Array.isArray(payload.events)) events = events.concat(payload.events)
+    }
+    durationTrend.sort((left, right) => String(left.timeBucket).localeCompare(String(right.timeBucket)))
+    const stateCounts = [...byState.values()].sort((left, right) => left.stateCode - right.stateCode)
+    const basis = (first.summary && first.summary.handlingEfficiency && first.summary.handlingEfficiency.basis)
+        || 'CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS'
+    const numerator = stateCounts.filter((row) => row.stateCode === 5).reduce((sum, row) => sum + row.count, 0)
+    const denominator = stateCounts.filter((row) => row.stateCode >= 0 && row.stateCode <= 6).reduce((sum, row) => sum + row.count, 0)
+    const handlingEfficiency = denominator > 0
+        ? { value: (numerator / denominator).toFixed(4), numerator, denominator, state: 'AVAILABLE', reason: 'READY', basis }
+        : { value: '0.0000', numerator: 0, denominator: 0, state: 'EMPTY', reason: 'NO_ELIGIBLE_WORK_ORDERS', basis }
+    const averageDuration = durationSamples > 0
+        ? { value: (weightedDuration / durationSamples).toFixed(2), unit: 'HOURS', sampleCount: durationSamples, state: 'AVAILABLE', reason: 'READY' }
+        : { value: '0.00', unit: 'HOURS', sampleCount: 0, state: 'EMPTY', reason: 'NO_CLOSED_WORK_ORDERS' }
+
+    return {
+        ...first,
+        range: { ...(first.range || {}), type: 'TREND_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime },
+        durationTrend,
+        events,
+        workOrderStateCounts: stateCounts,
+        summary: {
+            ...(first.summary || {}),
+            totalEvents,
+            linkedWorkOrderCount: linked,
+            missingWorkOrderCount: missing,
+            handledCount: handled,
+            inProgressCount: inProgress,
+            cancelledCount: cancelled,
+            handlingEfficiency,
+            averageDuration,
+        },
+    }
+}
+
+/**
+ * 合并多个 multidimensional-analysis 载荷(票 75)。
+ *
+ * `dimensions` 按 `dimensionCode` 合并(次序取首见窗——各窗维度集相同,只是数不同);
+ * buckets 按 `bucketCode` 求和,`percentage` **重算**而不是把各窗百分比相加
+ * (50% + 25% = 75% 是错的,分母变了)。
+ *
+ * 导出说明:同 `mergeEfficiencyPayloads` —— 合并规则单一实现,闸门直接消费。
+ */
+export function mergeAnalysisPayloads(payloads, fullRange) {
+    const list = payloads.filter(Boolean)
+    const first = list[0] || {}
+    const dimensions = new Map()
+    let totalCount = 0
+    for (const payload of list) {
+        totalCount += countOf(payload.totalCount)
+        for (const dimension of Array.isArray(payload.dimensions) ? payload.dimensions : []) {
+            const entry = dimensions.get(dimension.dimensionCode) || {
+                dimensionCode: dimension.dimensionCode,
+                dimensionLabel: dimension.dimensionLabel,
+                totalCount: 0,
+                buckets: new Map(),
+                bucketOrder: [],
+            }
+            entry.totalCount += countOf(dimension.totalCount)
+            for (const bucket of Array.isArray(dimension.buckets) ? dimension.buckets : []) {
+                const previous = entry.buckets.get(bucket.bucketCode)
+                if (previous) {
+                    previous.count += countOf(bucket.count)
+                } else {
+                    const copy = { ...bucket, count: countOf(bucket.count) }
+                    entry.buckets.set(bucket.bucketCode, copy)
+                    entry.bucketOrder.push(copy)
+                }
+            }
+            dimensions.set(dimension.dimensionCode, entry)
+        }
+    }
+    return {
+        ...first,
+        range: { ...(first.range || {}), type: 'ANALYSIS_WINDOW', startTime: fullRange.startTime, endTime: fullRange.endTime },
+        totalCount,
+        dimensions: [...dimensions.values()].map((entry) => ({
+            dimensionCode: entry.dimensionCode,
+            dimensionLabel: entry.dimensionLabel,
+            totalCount: entry.totalCount,
+            buckets: entry.bucketOrder.map((bucket) => ({
+                ...bucket,
+                percentage: entry.totalCount > 0 ? ((bucket.count / entry.totalCount) * 100).toFixed(2) : '0.00',
+            })),
+        })),
+    }
+}
+
+const EFFICIENCY_EMPTY = (payload) => {
+    const family = payload && payload.familyMetrics
+    return !payload || (!(payload.summary && payload.summary.handlingEfficiency) && !payload.handlingEfficiency && !(family && family.handlingEfficiency) && payload.averageResponseSeconds === undefined && payload.averageResponseDuration === undefined && !(payload.averageDuration && typeof payload.averageDuration === 'object'))
+}
+
+export function fetchHandlingEfficiency(options = {}) {
+    return fetchAggregated(WARNING_RESPONSE_PATHS.efficiency, options, {
+        toDomain: toHandlingEfficiency,
+        merge: mergeEfficiencyPayloads,
+        isEmptyRaw: EFFICIENCY_EMPTY,
     })
 }
 
 export function fetchEventAnalysis(options = {}) {
-    const unavailable = unsupportedWindow(options)
-    if (unavailable) return Promise.resolve(unavailable)
-    return fetchSection(WARNING_RESPONSE_PATHS.analysis, {
-        params: { eventFamily: familyParam(options.mode), industry: industryQuery(options.industry), ...rangeParams(options) },
-        toData: toEventAnalysis,
-        isEmpty: isEmptyBy((payload) => {
+    return fetchAggregated(WARNING_RESPONSE_PATHS.analysis, options, {
+        toDomain: toEventAnalysis,
+        merge: mergeAnalysisPayloads,
+        isEmptyRaw: (payload) => {
             const dimensions = (payload && payload.dimensions) || payload || {}
             if (Array.isArray(dimensions)) return !dimensions.some((row) => Array.isArray(row.buckets) && row.buckets.length)
             return !['types', 'byType', 'levels', 'byLevel', 'industries', 'byIndustry', 'regions', 'byRegion'].some((key) => Array.isArray(dimensions[key]) && dimensions[key].length)
-        }),
-        freshnessOf,
-        config: options.config,
-        fetchImpl: options.fetchImpl,
+        },
     })
 }

+ 288 - 26
src/modules/cockpit/stores/eventStore.js

@@ -15,6 +15,12 @@
  *
  * 竞态收口沿用票 10/11 的 token 纪律:快速连续切 预警→报警→预警 时,
  * 只让最后一次请求落进分区(迟到的响应丢弃),不会出现"显示报警却把预警数据挂上去"。
+ *
+ * 票 61(统一分页批次三)新增**服务端页码状态**:当前事件表与历史事件单窗范围都按
+ * `page`+`pageSize=10` 发请求,页码是本 store 的视图状态(`currentPage`/`historyPage`)。
+ * 纪律有三条:① 条件(mode/range/industry/level/status)变化 → 页码回第 1 页;
+ * ② 定时刷新/同条件重取**不复位**(条文 6 的反面),越界由回显夹回最后一页;
+ * ③ 翻一页只重取该分区(不牵连另外三块面板),并用同一 token 作废迟到的页响应。
  */
 import { computed, ref } from 'vue'
 import { defineStore } from 'pinia'
@@ -27,8 +33,21 @@ import { EVENT_DETAIL_RESULT, eventDetailView, planEventLocate } from '../domain
 import { getCameraState, getAssociatedDeviceState, highlightMapObject, setCameraFocus } from '../services/mapService.js'
 import { useMapStore } from './mapStore.js'
 import { EVENT_RANGES } from '../domain/warningResponsePanels.js'
-import { eventTimeWindow } from '../domain/eventRange.js'
+import { eventTimeWindow, historyWindows } from '../domain/eventRange.js'
 import { fetchEventAnalysis, fetchEventHistory, fetchHandlingEfficiency } from '../services/warningResponseApi.js'
+import { fetchWorkOrderList } from '../services/detailApi.js'
+import { PAGE_SIZE, serverPageView } from '../domain/pagination.js'
+
+/** 会触发「回第 1 页」的条件键(条文 5/6;定时刷新不经过这里)。 */
+const CONDITION_KEYS = ['mode', 'range', 'industry', 'level', 'status']
+
+/**
+ * 报警效率面板「当前工单列表」的页大小(票 65)。
+ *
+ * 与事件表的 `PAGE_SIZE`(10)**刻意分开**:两者是不同端点、不同翻页语义,
+ * 共用一个常量会让「改事件页大小」意外改掉工单列表(票 61 变异 M1 记过同类教训)。
+ */
+const WORK_ORDER_LIST_PAGE_SIZE = 5
 
 /** 未取数时的分区(与其它 store 同形,`loaded:false` 表示"还没请求过")。 */
 function pending() {
@@ -41,6 +60,10 @@ function initialSections() {
         historyAnalysis: pending(),
         responseEfficiency: pending(),
         eventAnalysis: pending(),
+        // 票 65:报警效率面板的「当前工单列表」(全量工单端点,与上面四块各自成败)。
+        // 分区名刻意叫 `workOrderList`:store 只说领域词汇,不照抄后端字段名
+        // (照抄会被 t13 的 I1 守卫判红,票 05 纪律)。
+        workOrderList: pending(),
     }
 }
 
@@ -57,6 +80,20 @@ export const useEventStore = defineStore('eventStore', () => {
     /** 正在取数(界面只做轻提示;分区成败仍由分区自己说)。 */
     const loading = ref(false)
 
+    /** 当前事件表的服务端页码(1 起,`pageSize=PAGE_SIZE`)。 */
+    const currentPage = ref(1)
+
+    /** 历史事件**单窗范围**的服务端页码(多窗合并范围无全局页,渲染层自持页码)。 */
+    const historyPage = ref(1)
+
+    /**
+     * 「当前工单列表」的服务端页码(票 65)。
+     *
+     * 与上面两个页码**互不牵连**:翻工单页只重取工单分区,不动事件表/历史/效率/分析
+     * (与「翻一页只重取该分区」同一条纪律)。
+     */
+    const workOrderPage = ref(1)
+
     /** 最后一个「当前事件分区的领域对象」(视图层读它,避免每次都重解构 section)。 */
     const currentDomain = ref(null)
 
@@ -64,9 +101,27 @@ export const useEventStore = defineStore('eventStore', () => {
     const selectedEvent = ref(null)
     const locate = ref({ result: '', deviceCode: '', pointCode: '', reasonText: '' })
 
-    /** 请求序号:快速切换两次族时旧的在途响应作废(token 纪律)。 */
+    /** 请求序号:翻页/切族/切范围共用一个令牌,迟到的页响应整批丢弃(token 纪律)。 */
     let token = 0
 
+    /**
+     * 工单列表专用的令牌(票 65)。
+     *
+     * 为什么不共用 `token`:那份是全局面板的令牌,翻一个工单页就把它 +1,会把在途的
+     * 四块面板响应整批作废(用户只是翻了个工单页,另外三块却停在旧数据上)。
+     * 工单列表是**独立分区**,竞态也该独立收口。
+     */
+    let workOrderToken = 0
+
+    /**
+     * 工单列表当前钉住的时间窗(票 65,2026-10-22)。
+     *
+     * 由 `loadPanels` 在每次取数时写入,`setWorkOrderPage` 直接复用 —— 翻页期间窗口必须
+     * 一个字节都不变。若每次翻页都重算 `eventTimeWindow()`,`endTime` 会毫秒级后移,
+     * 边界工单就会在页与页之间重复或消失(P10 断言钉住这件事)。
+     */
+    let workOrderWindow = null
+
     /** 请求注入(config / fetchImpl / industry…):取一次让后续所有取数共用。 */
     let requestOptions = {}
 
@@ -79,6 +134,83 @@ export const useEventStore = defineStore('eventStore', () => {
         sections.value = { ...sections.value, [name]: { ...value, loaded: true } }
     }
 
+    /** 条件的可比较快照(只取值,不按对象身份——视图计划每次重算都是新对象)。 */
+    function conditionSnapshot() {
+        return {
+            mode: mode.value,
+            range: range.value,
+            industry: requestOptions.industry,
+            level: requestOptions.level,
+            status: requestOptions.status,
+        }
+    }
+
+    function selectedRange() {
+        return EVENT_RANGES.find((item) => item.key === range.value) || EVENT_RANGES[0]
+    }
+
+    /** 四块面板共享的请求条件(`/events/current` 与三块聚合接口同一份)。 */
+    function activeConditions() {
+        const active = selectedRange()
+        return { ...requestOptions, ...eventTimeWindow(active.type), mode: mode.value, rangeType: active.type }
+    }
+
+    /** 该范围是否存在「全局页」(单窗才有;多窗合并范围只能对已加载集合分页)。 */
+    function singleHistoryWindow() {
+        const active = selectedRange()
+        const window = eventTimeWindow(active.type)
+        return historyWindows(window.startTime, window.endTime).length <= 1
+    }
+
+    /** 当前事件页的后端回显 → 控件视图(失败/无回显回 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) {
+        const paging = result && result.data && result.data.paging
+        if (!paging || paging.mode !== 'server') return null
+        return serverPageView(paging, Array.isArray(result.data.events) ? result.data.events.length : 0)
+    }
+
+    async function fetchCurrentAt(page, pageSize = PAGE_SIZE) {
+        try {
+            return await fetchCurrentEvents({ ...activeConditions(), page, pageSize })
+        } catch (error) {
+            return section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+        }
+    }
+
+    async function fetchHistoryAt(page) {
+        try {
+            return await fetchEventHistory({ ...activeConditions(), page, pageSize: PAGE_SIZE })
+        } catch (error) {
+            return section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+        }
+    }
+
+    /**
+     * 取一页并做越界夹回(条文 6:数据变少时页码越界 ⇒ 夹回最后一页再取一次)。
+     * 只在「后端回显的分页视图说当前页超过了最后一页」时补一次请求,不递归。
+     * @returns {Promise<object|null>} `null` = 期间又发生了更新的请求(token 已变),调用方应放弃
+     */
+    async function loadPage(request, pageRef, pageViewOf, mine) {
+        let result = await request(pageRef.value)
+        if (mine !== token) return null
+        const view = pageViewOf(result)
+        if (view && view.page !== pageRef.value) {
+            pageRef.value = view.page
+            result = await request(pageRef.value)
+            if (mine !== token) return null
+            const clamped = pageViewOf(result)
+            if (clamped) pageRef.value = clamped.page
+        }
+        return result
+    }
+
     /** 当前「事件 + 切换」视图(领域层现算;组件与走查都读它)。 */
     const view = computed(() => {
         // 已取到数据:从领域对象读事件分区(行 + 内部状态)。
@@ -88,23 +220,42 @@ export const useEventStore = defineStore('eventStore', () => {
     })
 
     /**
-     * 取预警处置四块面板。切换 mode 或 range 时四块共享一次条件,迟到响应整批丢弃。
+     * 取预警处置四块面板。切换 mode/range/筛选时四块共享一次条件,迟到响应整批丢弃;
+     * 条件变化同时把两个服务端页码回第 1 页(**定时刷新不带条件 ⇒ 页码保持**)。
      */
     async function loadPanels(options = {}) {
+        const before = conditionSnapshot()
         if (options.mode !== undefined && isKnownEventMode(options.mode)) mode.value = options.mode
         if (options.range !== undefined && EVENT_RANGES.some((item) => item.key === options.range)) range.value = options.range
         if (Object.keys(options).some((key) => !['mode', 'range'].includes(key))) configure(options)
+        const after = conditionSnapshot()
+        if (CONDITION_KEYS.some((key) => before[key] !== after[key])) {
+            currentPage.value = 1
+            historyPage.value = 1
+            // 票 65:条件变了,工单列表也回第 1 页(条文 5;定时刷新不带条件 ⇒ 页码保持)。
+            workOrderPage.value = 1
+        }
 
-        const selectedRange = EVENT_RANGES.find((item) => item.key === range.value) || EVENT_RANGES[0]
-        const active = { ...requestOptions, ...eventTimeWindow(selectedRange.type), mode: mode.value, rangeType: selectedRange.type }
+        const active = activeConditions()
+        // 票 65:把窗口**钉**在这一次取数上。`eventTimeWindow()` 每次调用都按 `Date.now()`
+        // 重算,首屏与翻页各算一次就会毫秒级漂移 —— 翻页时窗口在动,边界工单会重复或漏掉。
+        workOrderWindow = { startTime: active.startTime, endTime: active.endTime }
         token += 1
         const mine = token
         loading.value = true
         const requests = await Promise.all([
-            fetchCurrentEvents(active),
-            fetchEventHistory(active),
+            fetchCurrentEvents({ ...active, page: currentPage.value, pageSize: PAGE_SIZE }),
+            fetchEventHistory({ ...active, page: historyPage.value, pageSize: PAGE_SIZE }),
             fetchHandlingEfficiency(active),
             fetchEventAnalysis(active),
+            // 票 65:报警效率面板的「当前工单列表」走**全量工单**端点(另一个粒度:
+            // handling-efficiency 只给状态计数、不返回工单实体)。预警族不画工单列表,
+            // 但这里照取不误——切族时不必等一次新往返,且失败只影响那一块分区。
+            // 页码取 `workOrderPage`(定时刷新保持当前页),时间窗用刚钉下的那一份。
+            fetchWorkOrderList(
+                { page: workOrderPage.value, pageSize: WORK_ORDER_LIST_PAGE_SIZE, startTime: workOrderWindow.startTime, endTime: workOrderWindow.endTime },
+                requestOptions,
+            ),
         ].map(async (request) => {
             try {
                 return await request
@@ -114,55 +265,158 @@ export const useEventStore = defineStore('eventStore', () => {
         }))
         if (mine !== token) return sections.value
 
-        const [current, history, efficiency, analysis] = requests
+        const [current, history, efficiency, analysis, workOrderList] = requests
+        const safeCurrent = await clampSettled(current, currentPage, currentPageView, fetchCurrentAt, mine)
+        if (mine !== token) return sections.value
+        const safeHistory = await clampSettled(history, historyPage, historyPageView, fetchHistoryAt, mine)
+        if (mine !== token) return sections.value
+
         sections.value = {
-            currentEvents: { ...current, loaded: true },
-            historyAnalysis: { ...history, loaded: true },
+            currentEvents: { ...safeCurrent, loaded: true },
+            historyAnalysis: { ...safeHistory, loaded: true },
             responseEfficiency: { ...efficiency, loaded: true },
             eventAnalysis: { ...analysis, loaded: true },
+            workOrderList: { ...workOrderList, loaded: true },
         }
-        currentDomain.value = current.data || null
+        currentDomain.value = safeCurrent.data || null
         selectedEvent.value = refreshedEventSelection(selectedEvent.value, currentDomain.value)
         loading.value = false
         return sections.value
     }
 
+    /**
+     * 已落定的一页若越界则夹回并补取一次(与 `loadPage` 同一判据)。
+     * 并发批次取数时不能在 `Promise.all` 里改页码,所以夹回发生在整批回包之后。
+     */
+    async function clampSettled(result, pageRef, pageViewOf, request, mine) {
+        if (mine !== token) return result
+        const view = pageViewOf(result)
+        if (!view || view.page === pageRef.value) {
+            if (view) pageRef.value = view.page
+            return result
+        }
+        pageRef.value = view.page
+        const clamped = await request(pageRef.value)
+        if (mine !== token) return result
+        const clampedView = pageViewOf(clamped)
+        if (clampedView) pageRef.value = clampedView.page
+        return clamped
+    }
+
     function setRange(next) {
         if (!EVENT_RANGES.some((item) => item.key === next) || next === range.value) return sections.value
         return loadPanels({ range: next })
     }
 
+    /**
+     * 当前事件表翻页(服务端页码分页):只重取当前事件分区,其余三块不动。
+     * 迟到的页响应由 token 作废;越界页码按回显夹回最后一页。
+     * @param {number} next 目标页码(1 起)
+     * @returns {Promise<object>} `sections.currentEvents`
+     */
+    async function setCurrentPage(next) {
+        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
+    }
+
+    /**
+     * 历史事件单窗范围翻页(服务端页码分页):只重取历史分析分区。
+     * 多窗合并范围没有全局页,本入口不适用(渲染层对已加载集合分页)。
+     * @param {number} next 目标页码(1 起)
+     * @returns {Promise<object>} `sections.historyAnalysis`
+     */
+    async function setHistoryPage(next) {
+        const page = Math.max(1, Math.trunc(Number(next) || 1))
+        if (!singleHistoryWindow() || page === historyPage.value) return sections.value.historyAnalysis
+        historyPage.value = page
+        token += 1
+        const mine = token
+        // 与当前事件表同一条纪律:翻页保持旧内容,不闪空白(条文 8)。
+        const result = await loadPage(fetchHistoryAt, historyPage, historyPageView, mine)
+        if (!result) return sections.value.historyAnalysis
+        sections.value = { ...sections.value, historyAnalysis: { ...result, loaded: true } }
+        return sections.value.historyAnalysis
+    }
+
+    /**
+     * 「当前工单列表」翻页(服务端页码分页;票 65):只重取工单分区。
+     *
+     * 三条纪律与事件表翻页一致,只是竞态用**独立令牌**:
+     *   · 翻页保持旧内容、不闪空白(条文 8);
+     *   · 页码越界由后端回显夹回(`serverPageView` 同一判据),夹回后同步到 `workOrderPage`;
+     *   · 迟到的页响应整体丢弃(`workOrderToken` 变了就放弃)。
+     *
+     * @param {number} next 目标页码(1 起)
+     * @returns {Promise<object>} `sections.workOrderList`
+     */
+    async function setWorkOrderPage(next) {
+        const page = Math.max(1, Math.trunc(Number(next) || 1))
+        if (page === workOrderPage.value) return sections.value.workOrderList
+        workOrderPage.value = page
+        workOrderToken += 1
+        const mine = workOrderToken
+        // 翻页必须用**首屏钉下的同一份时间窗**(不是现在重算一份):
+        // 少了窗口,第 2 页会悄悄变回全库口径;每次重算窗口,`endTime` 会毫秒级后移,
+        // 边界工单就会在页与页之间重复或消失。两种情况都是「看不出来」的错。
+        const window = workOrderWindow || activeConditions()
+        let result
+        try {
+            result = await fetchWorkOrderList(
+                { page, pageSize: WORK_ORDER_LIST_PAGE_SIZE, startTime: window.startTime, endTime: window.endTime },
+                requestOptions,
+            )
+        } catch (error) {
+            result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+        }
+        // 迟到响应丢弃:期间又翻过页/切过条件,这一页不落分区。
+        if (mine !== workOrderToken) return sections.value.workOrderList
+        const view = serverPageView(
+            result && result.data && result.data.pagination,
+            result && result.data && Array.isArray(result.data.orders) ? result.data.orders.length : 0,
+        )
+        // 后端夹回(如数据变少后第 3 页不存在):以回显为准,不再补一次请求。
+        if (view) workOrderPage.value = view.page
+        sections.value = { ...sections.value, workOrderList: { ...result, loaded: true } }
+        return sections.value.workOrderList
+    }
+
     /**
      * 取当前事件列表(跨设备的 `/events/current`;票 13 的预警处置面板数据源)。
      *
      * `options.mode` 显式给(菜单面板里点别的族)时更新切换状态;没给就沿用当前族。
      * token 纪律:换族后旧的在途响应作废(迟到的预警不落进报警表)。
+     * 票 61:`page`/`pageSize` 进请求;`page` 缺省回第 1 页(这是一次完整重取)。
      * @param {object} [options] `{ mode, industry, level, status, page, pageSize, config, fetchImpl }`
      * @returns {Promise<object>} `sections.currentEvents`
      */
     async function loadCurrentEvents(options = {}) {
         if (options.mode !== undefined && isKnownEventMode(options.mode)) mode.value = options.mode
-        if (Object.keys(options).some((key) => !['mode'].includes(key))) configure(options)
-
-        const active = { ...requestOptions, mode: mode.value }
+        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
+        currentPage.value = Number.isInteger(options.page) && options.page > 0 ? options.page : 1
         token += 1
         const mine = token
         loading.value = true
-
-        let result = null
-        try {
-            result = await fetchCurrentEvents(active)
-        } catch (error) {
-            // 与其它 store 同款兜底:适配层不抛,但转换层万一抛了要收成失败分区。
-            result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
-        }
-
-        // 迟到的响应丢弃:mode 换过之后,旧请求的结果不得写进新分区。
-        if (mine !== token) return sections.value.currentEvents
+        const result = await loadPage((page) => fetchCurrentAt(page, pageSize), currentPage, currentPageView, mine)
+        // 迟到的响应丢弃:mode/页码换过之后,旧请求的结果不得写进新分区。
+        if (!result) return sections.value.currentEvents
 
         const next = { ...result, loaded: true }
         sections.value = { ...sections.value, currentEvents: next }
         currentDomain.value = result.data || null
+        selectedEvent.value = refreshedEventSelection(selectedEvent.value, currentDomain.value)
         loading.value = false
         return next
     }
@@ -228,6 +482,8 @@ export const useEventStore = defineStore('eventStore', () => {
         sections.value = initialSections()
         currentDomain.value = null
         range.value = '7d'
+        currentPage.value = 1
+        historyPage.value = 1
         selectedEvent.value = null
         locate.value = { result: '', code: '', deviceCode: '', pointCode: '', reasonText: '' }
         loading.value = false
@@ -239,6 +495,9 @@ export const useEventStore = defineStore('eventStore', () => {
         range,
         sections,
         loading,
+        currentPage,
+        historyPage,
+        workOrderPage,
         currentDomain,
         selectedEvent,
         locate,
@@ -251,7 +510,10 @@ export const useEventStore = defineStore('eventStore', () => {
         locateEvent,
         setSection,
         loadCurrentEvents,
+        setCurrentPage,
+        setHistoryPage,
+        setWorkOrderPage,
         setMode,
         reset,
     }
-})
+})