Bladeren bron

feat(cockpit): 票46 监测点与设备详情卡片化、分类查询与历史折叠

- 点位摘要卡片化:状态卡 + 设备计数 KPI 条(pointDeviceCountsView,统计缺失不画假 0)
- 预警/报警按等级、工单按状态字典 0-7 出分类 chips(词表全复用单源,再点取消、生效回显)
- 历史条目默认折叠(details 无 open),展开保留数据时间/状态/来源/关联对象四件事
- 设备详情弃六块长滚动改标签页(档案默认页、最新图片并入该页下方|指标|预警信息|报警信息|工单状态),预警/报警每页最多 10 条分页(越界页码夹回、单页不出现控件)
- 状态图例与一张图顶部视觉+词表双对齐(图标登记提为 legendIconFor 单源,详情内 5 处状态标签全绑定 icon)
- 分类/页签/页码为组件本地视图状态:不发请求、不动 selection、换设备复位;只读边界与后端契约零改动
- 离线闸门 t46 53/53、变异 11/11;18 组既有闸门回归全绿
- 另建票 48(一张图底部管线颜色图例)
ChenJZ 5 dagen geleden
bovenliggende
commit
2e27b294d5

+ 81 - 2
.scratch/cockpit-real-data-wayfinding/issues/46-untitled.md

@@ -24,15 +24,94 @@ Category: frontend-execution
 
 ## 进度
 
-<!-- 做到哪一步了,在这里一行一行记 -->
+- 2026-10-15 第一轮:已认领 → 已实施(消费侧 4 文件)→ 离线闸门 t46 32/32 + 变异 4/4 → 既有闸门回归全绿。改动未提交。
+- 2026-10-15 第二轮(维护者反馈返工):① 设备详情弃六块长滚动 → 标签页(设备档案默认,最新图片并入该页下方|最新指标|预警信息|报警信息|工单状态),预警/报警每页最多 10 条(上一页/下一页 + 页码);② 状态图例与一张图顶部视觉+词表双对齐(图标登记提为 legendIconFor 单源,详情内 5 处 StatusTag 全部绑定 icon)。t46 扩到 53/53、变异 11/11;18 组离线闸门回归全绿。改动仍未提交。
+- 2026-10-15 维护者反馈第 3 条(一张图底部管线颜色图例)→ 另建票 48,不在本票范围。
+- 下一步:浏览器走查(验收第 5 条,需维护者明确授权后执行);入库按票 33/36/37 裁剪纪律单独摘取本票 hunks。
 
 Status: claimed
 Type: task
 Blocked by:
 Labels: ready-for-agent, wayfinder:task
 
+## 实施记录(2026-10-15,第一轮)
+
+### 交付 1:监测点摘要卡片化(范围第 1 条)
+
+- **`domain/detailFlow.js` 新增 `pointDeviceCountsView(detail)`**(纯函数):点位设备计数 KPI 行(报警/预警/工单处置/正常/离线)。三条纪律:统计整段缺失(`statistics.missing` 非空)→ 回 `null`,KPI 条**不出现**而不是画一排 0(票 16 虚假零值立场,变异 M2 有牙);计数 0 是真实值照画;色键走**设备词表**(`deviceStatusView`,不借点位表——票 44 立的边界)。
+- **`PointDetailCard.vue` 第一级重排**:身份两行(名称/编码)保留键值行形状(t41 钉住 `data-point-code-value` 是 `<dd>`、`data-point-device-count` 是 `<b>`,原样保留);状态+原因改**状态卡**;新增 **KPI 计数条**(`data-point-kpis`/`data-point-kpi`/`data-point-kpi-tone`,颜色只引用 token)。名称与原因走票 37 的共用省略方法 `EllipsisText`。
+
+### 交付 2:预警/报警/工单分类查询(范围第 2 条)
+
+- **`domain/equipmentDetailView.js` 新增分类视图函数**(全纯函数):`categoryChips(rows, order)`(首项恒「全部」+真实计数;0 计数的词表项不出现;词表外分类不静默丢、排已知项之后)、`filterByCategory(rows, key)`(空键=全部原样返回同一引用;认不出的键回**空数组**——回全量就是"筛了个寂寞",变异 M1 有牙)、`categoryNote`(生效时回显「当前分类:X(再点同一项取消)」,票 37 typeNote 同立场)。
+- **分类词表全部复用单源**:事件等级走票 13 `eventList.js` 的 `eventLevelTag`(1–4 级 + 未知),工单状态走 `workOrder.js` 字典 0–7(`WORK_ORDER_CATEGORY_ORDER` 直接展开自 `WORK_ORDER_STATES`);认不出的等级/状态码落 `level:unknown`/`state:unknown`「等级未知/状态未知」,**不并进任何真实语义**(变异 M4 有牙)。
+- **`eventRows`/`workOrderRows` 增补视图字段**(`categoryKey/categoryLabel/timeText/type/deviceCode/deviceName/source`)——都是既有领域字段的改名/复用,零新后端字段。
+- **`EquipmentDetailSections.vue`**:预警/报警/工单三块**共用同一条 chip 条**(一组绑定只写一处,`data-category-bar`/`data-category-chip`/`aria-pressed`/`is-active`);分类是**组件本地视图状态**(`categories = ref({})`):不发请求、不动 selection、不进 filterStore——它是「这一屏怎么看」,不是全局数据条件(票面非目标「不改变后端事实与只读边界」)。**换设备(`watch(deviceCode)`)分类归空**:A 的条件不挂到 B 上。
+
+### 交付 3:历史条目默认折叠 + 展开详情(范围第 3 条)
+
+- 预警/报警/工单条目改 `<details data-history-item>`(**无 open=默认折叠**;原生键盘可达、展开态自带反馈),summary 一行给折叠读法:名称/状态名 + 等级 + 状态 + 短读时间(`shortEventTime` 单源)+ 编码 + 量值/关联标记。
+- 展开后 `eventDetailRows`/`workOrderDetailRows` 出详情行:**数据时间、状态、来源、关联对象四件事始终保留**(缺值给占位符——变异 M3 有牙:把必选项也按"没值就丢"处理会被 C1 抓住);可选行(编码/等级/量值/位置/说明)有值才画,不铺一排 `—` 噪音。工单 `实际结案` 必留(折叠行本来就显示「结案 —」,展开后消失是前后矛盾)。
+- **票 12 的接缝逐字保留**:`v-if="trackOfOrder(order)"` / `v-else-if="timeline.hasContent"` / `:path-only="!timeline.hasContent"`(t12 F9 守卫钉住),时间线仍挂在每张工单自己的卡里。
+
+### 交付 4:状态与边界(范围第 4/5 条)
+
+- 空/加载/失败态:六分区独立成败语义**一字未动**(`SectionStateTag` + `sectionPlaceholder` + 整包失败分支),分类过滤只作用于「有内容」分支的行。
+- 筛选不丢上下文:分类是本地状态,切换不发请求、不改 `selection`;返回/关闭仍走既有 `detailStore` 路径(t10/t41 绿)。
+- 只读边界:组件零 fetch、零接口路径、零 `.sort(`、零后端裸字段/原因码(t05/t10/t11 + 本票 E5–E7 守卫)。
+
+## 实施记录(2026-10-15,第二轮:维护者反馈返工)
+
+维护者 2026-10-15 三条反馈:① 设备详情弃用「浏览器式文档切换」(六块竖排长滚动),改标签页 设备档案(默认,图片归此页)|最新指标|预警信息|报警信息|工单状态,预警/报警列表每页最多 10 项;② 点位摘要与设备详情的状态图例没有与一张图顶部图例对齐;③ 一张图正下方持久显示管线颜色图例。经确认 ①② 在本票内返工,③ 另建票 48。
+
+### 交付 5:设备详情改标签页(返工第 1 条)
+
+- **`domain/equipmentDetailView.js` 新增页签计划**:`EQUIPMENT_TABS`(五页,顺序即界面顺序,`archive` 页的 `sections` = `['archive','latestImage']`)、`DEFAULT_EQUIPMENT_TAB = 'archive'`、`panelsForTab()`、`tabCount()`。**渲染计划仍是六块独立分区**(`EQUIPMENT_SECTION_PANELS` 长度不变,`latestImage` 加 `mergedInto: 'archive'` 标归属)——标签页只改**呈现归属**,不合并任何分区的数据与成败(t11 D1/D9 的独立性判据原样成立)。
+- **`EquipmentDetailSections.vue`**:`role="tablist"` 页签条 + 只渲染 `activePanels`;页签上挂**条数**(预警/报警/工单有,档案/指标回空串——0 会被读成"档案有 0 条");换设备时页签复位到默认页。
+- 图片按维护者选定的形态放在**档案键值行下方**(沿用既有点击放大 dialog、加载失败与"后端未提供"三条分支,图片分区自己的成败仍独立显示)。
+
+### 交付 6:预警/报警分页(返工第 1 条后半)
+
+- **`pagedRows(rows, page, pageSize = 10)`**(纯函数):`HISTORY_PAGE_SIZE = 10`;返回 `rows/page/pageCount/total/from/to/hasPrev/hasNext`。三条边界都收在 domain 可断言处:**越界页码夹回**合法页(回空表就是拿页码 bug 冒充"没有数据",变异 M5 有牙)、空表算 1 页、**不排序不改序**(只在已过滤序列上切窗口,票 10/11 立场)。
+- 控件:上一页/下一页 + 「第 N/M 页 · 共 X 条」;到边的按钮**置灰但保留占位**(消失会让页码行跳动,读起来像出错);**只有一页时整条控件不出现**。
+- 换分类时页码回第 1 页(停在第 3 页看新结果集会直接显示"没有记录")。工单块**不分页**(维护者原话只点名预警、报警;工单条目自带时间线,切页会打断阅读)。
+
+### 交付 7:状态图例与一张图顶部对齐(返工第 2 条,口径=视觉+词表双统一)
+
+- **图标登记提为单源接缝**:`domain/pointStatus.js` 里原先内联在 `legendVisuals()` 的 `iconOf` 表改成模块级 `LEGEND_ICON_BY_KEY` + 导出 `legendIconFor(key)`;`legendVisuals()` 自己经它取值(顶部状态栏消费路径不变,t09 38/38 仍绿)。
+- **详情侧接入**:`statusView()` / `deviceStatusView()` 返回值新增 `icon` 字段(点位走点位表、设备走设备表,**只借图标登记、不借状态语义** —— 票 44 的两表边界由 H3 断言守住)。`PointDetailCard.vue` 内**全部 5 处** `StatusTag` 绑定 `:icon`(点位状态卡、设备卡片、关联设备清单命中行/未命中行、设备详情表头)。
+- **`StatusTag.vue` 加可选 `icon` prop**:不传 = 票 06 原外观(事件列表等既有调用点零变化);传了则图标与形状/圆点同 `currentColor`,与顶部状态栏同一档弱发光。
+- **词表口径**:点位侧本就直接消费 `legendVisuals()` 的同一张表(八桶含 EMPTY 无设备 / UNASSOCIATED 无关联设备),设备侧维持票 44 裁定的**独立平行表**(设备五态无 EMPTY)—— 这是既有决策,返工未推翻;若要把设备状态也并进八桶词表,属于词表口径变更,需另立决策票。
+- **修掉一个真缺陷**:`legendIconFor` 第一版用 `table[key] || ''`,`toString`/`__proto__` 这类**继承键会命中原型方法**并被当图标名带出屏(`sectionState.js` 的 `owns` 注释记过同一课)。改成 `hasOwnProperty` 判定,H5 断言 + 变异 M7 钉住。
+
+### 过程记录(第二轮)
+
+- **E10 断言逮到漏绑**:写完后跑 t46,「详情卡内每个 StatusTag 都绑定 icon」判红 —— 设备详情表头那一处漏了。守卫按**绑定**判(不是"文件里出现过 icon 字样"),所以漏一处就红,这正是本仓第五次踩"守卫比它要守的东西宽"之前先立住的形状。
+- **具名 import 拼错**:把 `EVENT_CATEGORY_ORDER` 误写成 `EQUIPMENT_CATEGORY_ORDER`,**文本正则守卫全绿**(字样都在文件里),只有真的加载 `.vue` 的 t41 SSR 闸门炸出来。据此新增 E11/E11b/E11c 三条:拿 domain 模块的**命名空间**判"import 的名字是否真实导出",并与清单**双向核对**(多一个没登记的名字、或清单里的名字没被 import,都判红)。变异 M8/M8b 分别用「t46 自己也 import 的名字改名」(闸门崩溃式抓住)与「只有组件 import 的名字改名」(E11 断言转红)把这条判据本身验穿。
+- 变异脚本对「崩溃」与「断言转红」分列记录(`CAUGHT-BY-CRASH`),不把加载失败混算成某条断言有牙,也不误记成假绿。
+
+### 验收对照(第二轮更新)
+
+1. **离线闸门**:[t46-detail-cards-history.mjs](../verify/t46-detail-cards-history.mjs) **53/53**(A chips/过滤、B 分类键单源、C 展开四件事、D KPI 不画假 0、F 页签计划、G 分页边界、H 图例对齐、E 静态守卫按绑定判 + import 按真实导出判);[t46-mutations.mjs](../verify/t46-mutations.mjs) 变异 **11/11 全部抓住**(M1 认不出回全量 / M2 缺统计画 0 / M3 必留行丢失 / M4 未知等级猜成 1 级 / M5 越界页码不夹回 / M6 图片未并入档案页 / M7 图标登记吃继承键 / M8+M8b 导出改名 / M9 页签次序打乱 / M10 状态视图丢图标),还原逐字节校验。
+2. **既有闸门回归(18 组,全绿)**:t05 282/283(唯一红 `D1-stores/overviewStore.js` 为 HEAD 级既有失败,本票未动该文件)、t09 38/38、t10 57/57、t11 61/61、t12 52/52、t41 SSR 9/9、t43 40/40 + t43 图表 20/20、t44 47/47 + t44 普查 103/103、t15 11/11、t16-stores/t16-alarm-order、t22、t23、t27、t42、t50 30/30。
+3. **浏览器走查**:未执行(AGENTS.md:未获明确要求不跑 dev/走查)。入口 `http://127.0.0.1:8000/#/cockpit`;判据:
+   - 设备详情打开后**默认停在「设备档案」页**,档案键值行下方出现图片区;页签条上预警/报警/工单带条数;
+   - 预警/报警条目数 >10 时出现「上一页/下一页 · 第 N/M 页」,翻到末页取余数,单页设备**不出现**分页控件;
+   - 分类 chips 先筛后翻,换分类回第 1 页;
+   - 点位状态卡、设备卡片、关联设备清单、设备详情表头的状态标签**都带图标**,且与一张图顶部状态栏同形同色同字;
+   - 换设备页签/分类/页码全部复位;返回/关闭不重复取数;窄面板(420px 浮层)下页签与 chips 换行不溢出。
+
+### 改动文件与入库裁剪清单(两轮合计)
+
+`domain/detailFlow.js`(+`pointDeviceCountsView`;`statusView`/`deviceStatusView` 带 `icon`)、`domain/equipmentDetailView.js`(+分类/详情视图函数、页签计划、`pagedRows`;行视图补字段)、`domain/pointStatus.js`(图标登记提为 `legendIconFor` 单源 + `hasOwnProperty` 收口)、`components/StatusTag.vue`(+可选 `icon` prop 与样式)、`components/PointDetailCard.vue`(第一级卡片化 + 5 处状态标签接图标)、`components/EquipmentDetailSections.vue`(chip 条 + 折叠 + 展开详情 + 标签页 + 分页)、`verify/t46-detail-cards-history.mjs`、`verify/t46-mutations.mjs`。工作树混有并行在制品(CockpitShell/MapCanvas/mapService/PipeEnhancement 族等),入库须按票 33/36/37 纪律单独裁剪本票 hunks。
+
 ## Comments
 
+### local — 2026-10-15T04:53:26.109Z
+实施完成(2026-10-15):① 监测点摘要卡片化(状态卡 + 设备计数 KPI 条,pointDeviceCountsView 纯函数,统计缺失不画假 0);② 预警/报警按等级、工单按状态字典 0–7 出分类 chips(词表全部复用单源 eventLevelTag/WORK_ORDER_STATES;再点取消;生效回显 categoryNote);③ 历史条目默认折叠(details 无 open),展开保留数据时间/状态/来源/关联对象四件事;④ 分类为组件本地视图状态:不发请求、不动 selection、换设备归空;六分区独立成败与票 12 时间线接缝逐字保留。离线闸门 t46 32/32、变异 4/4 打红(含沙箱管道陷阱的过程记录);t10/t11/t12/t15/t41/t43/t44/t50 回归全绿;t05 唯一红为 HEAD 级既有(overviewStore,本票未动)。改动未提交(工作树混有并行在制品,按裁剪清单单独摘取)。浏览器走查(验收第 5 条)按 AGENTS.md 未自动执行,票面留判据——票保持 open。
+
+### local — 2026-10-15T07:13:57.909Z
+返工完成(2026-10-15 第二轮,维护者反馈第 1/2 条):① 设备详情弃六块竖排长滚动,改标签页(设备档案=默认页且最新图片并入其键值行下方|最新指标|预警信息|报警信息|工单状态),页签带条数;预警/报警每页最多 10 条(上一页/下一页+「第 N/M 页 · 共 X 条」,越界页码夹回、单页不出现控件、换分类回第 1 页;工单不分页——原话只点名预警报警且工单条目自带时间线);② 状态图例与一张图顶部对齐(口径=视觉+词表双统一):图标登记从 legendVisuals 内联表提为 pointStatus 的 legendIconFor 单源,statusView/deviceStatusView 带 icon,StatusTag 加可选 icon prop,详情卡内 5 处状态标签全绑定;设备侧仍走票 44 的独立平行表(只借图标登记不借状态语义)。渲染计划仍六块独立分区(t11 D1/D9 不破)。过程:E10 断言逮到表头漏绑 icon;具名 import 拼错(EVENT→EQUIPMENT_CATEGORY_ORDER)文本守卫全绿、t41 SSR 才炸 → 新增 E11/E11b/E11c 按真实导出+双向清单判,M8/M8b 验穿;legendIconFor 第一版吃继承键(toString)改 hasOwnProperty。t46 53/53、变异 11/11;18 组离线闸门回归全绿。第 3 条(一张图底部管线颜色图例)另建票 48。改动仍未提交;浏览器走查按 AGENTS.md 未自动执行,票面留判据——票保持 open。
 
-## Answer
 
+## Answer

+ 67 - 0
.scratch/cockpit-real-data-wayfinding/issues/48-untitled.md

@@ -0,0 +1,67 @@
+<!-- DSH-IDEMPOTENCY-KEY: deck-c092926b-5973494 -->
+<!-- parentKey: 00 -->
+# 前端执行票 —— 一张图正下方居中持久显示管线颜色图例
+
+Label: ready-for-agent
+Category: frontend-execution
+
+## 来源
+
+维护者 2026-10-15 在票 46 收口时新增反馈第 3 条(原话):
+「添加对应管线颜色的图例在一张图正下方居中持久显示:● 燃气 ● 供水 ● 污水 ● 雨水 ● 窨井(用颜色区分)」
+
+## 范围
+
+1. **位置与形态**:在中央「一张图」**正下方、水平居中**新增一条管线颜色图例,**持久显示**
+   (不随模式切换/筛选/悬停消失;与一张图顶部既有的**状态图例**是两条不同的东西,不合并、不互相替换)。
+2. **颜色必须与地图管线同源**:色值取自 `domain/pipeVisual.js` 的 `PIPE_PALETTE`
+   (燃气 `#ff3b52` / 供水 `#168bff` / 污水 `#29e36c` / 雨水 `#00e5ff` / 窨井 `#b36bff`),
+   经 `pipeColorKey(row)` 决定某条管线落哪个桶。**不在图例里另抄一份十六进制**
+   —— 抄了就有两处真相,改色时地图与图例必然漂移(本仓反复付过学费的那类缺陷)。
+3. **词条**:维护者点名五个(燃气/供水/污水/雨水/窨井)。`PIPE_PALETTE` 实际另有两桶
+   `DRAINAGE_UNKNOWN`「排水(雨污等级未识别)」与 `UNKNOWN`「未分类」,色值 `#aab7c4`,
+   **地图上真的会画出这两种颜色**(管网 `network_level` 缺失或脏值时)。
+   → 实施时按「图例必须覆盖地图实际会画出的颜色」这条判据**决定是否一并列出**,
+   并把结论写进票面;若要省略,必须同时给出"用户看到灰线却查不到含义"的处置办法,
+   不得静默丢掉。
+4. **不只有颜色**:延续票 06/09 的立场(色盲、打印、灰度大屏仍要读得出),
+   每个色块同时带文字;建议带该桶的管线条数(真实计数,缺失不补 0)。
+5. **窄屏/低精度形态**:图例不得遮挡管线本体,不得撑破画布;换行居中而不是横向溢出。
+
+## 非目标
+
+- 不改后端契约、不新增写操作。
+- 不改管线颜色口径本身(`PIPE_PALETTE`/`pipeColorKey` 的判定逻辑不在本票范围)。
+- 不动一张图**顶部**的状态图例与状态筛选入口(决策 02 冻结的全局组合筛选入口,必须常驻可点)。
+- 不做图例点击筛选(本票只是"颜色 → 含义"的读数;要筛选另立票)。
+
+## 验收
+
+- 零依赖逻辑闸门:图例行的色值与 `PIPE_PALETTE` **逐字节同源**(断言取的是同一对象,不是复制值);
+  词条覆盖 `pipeColorKey` 的全部可能返回值;计数缺失时不画 0。
+- 浏览器走查(需维护者明确授权后执行):五个色块出现在一张图正下方居中、持久显示;
+  切模式/筛选/悬停不消失;色块颜色与地图上对应管线一致。
+- 按 `AGENTS.md`,不自动运行 build/dev/走查。
+
+## 关联
+
+- 单源:`domain/pipeVisual.js`(`PIPE_PALETTE` / `pipeColorKey`);
+  消费侧参考:`services/cesiumPipeLayer.js`(`visualColor()` 就是拿它上色)。
+- 落点候选:`CockpitShell.vue` 的 `cockpit-shell__map` 底部带(既有 `cockpit-shell__map-summary`
+  底部摘要条与之共存,需避让或并入,实施时定)。
+- 上游:票 24(设施点位与管线真实几何图层接线)、票 32(管线构件 glTF 与 network_level 分色)。
+
+## 进度
+
+<!-- 做到哪一步了,在这里一行一行记 -->
+
+Status: ready-for-agent
+Type: task
+Blocked by:
+Labels: ready-for-agent, frontend-execution, wayfinder:task
+
+## Comments
+
+
+## Answer
+

+ 444 - 0
.scratch/cockpit-real-data-wayfinding/verify/t46-detail-cards-history.mjs

@@ -0,0 +1,444 @@
+/**
+ * 票 46 离线闸门:监测点与设备详情卡片化、分类查询与历史折叠。
+ *
+ * 四组断言(都不启浏览器、不联网):
+ *   A. 分类查询纯函数:chips 首项恒为「全部」且计数真实;0 计数的词表项不出现;
+ *      词表外的分类不静默丢;过滤不改源次序、认不出的键回空数组(不"筛了个寂寞")。
+ *   B. 行视图分类键:事件按等级(词表单源 eventList/eventLevelTag)、工单按状态字典 0–7
+ *      (单源 workOrder.js);认不出的落 unknown 桶,不并进真实语义。
+ *   C. 展开详情四件事:数据时间/状态/来源/关联对象**始终保留**(缺值给占位符,不画空白)。
+ *   D. 点位设备计数 KPI:统计整段缺失 → null(KPI 条不出现,**不画一排 0**);
+ *      计数 0 是真实值照画;色键走设备词表不借点位表。
+ *   E. 静态守卫:折叠默认收起(`<details>` 无 open)、chip 绑定齐全、换设备清分类、
+ *      组件零 `.sort(`、零后端裸字段/原因码、只读边界(无写操作、不拼接口路径)。
+ *
+ * 守卫有牙(变异测试见 verify/t46-mutations.mjs 说明):
+ *   M1 filterByCategory 认不出回全量 → A4 红;M2 统计缺失回全 0 → D1 红;
+ *   M3 详情必选项没值就丢 → C1 红;M4 分类键并进 UNKNOWN → B2 红。
+ *
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t46-detail-cards-history.mjs
+ */
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+// 命名空间 import 只为 E11 一条服务:**按真实导出判**组件 import 的名字存不存在
+// (文本正则判不出拼错的具名 import,只有加载层判得出)。
+import * as equipmentDetailViewModule from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
+import * as detailFlowModule from '../../../src/modules/cockpit/domain/detailFlow.js'
+import {
+    CATEGORY_ALL,
+    DEFAULT_EQUIPMENT_TAB,
+    EQUIPMENT_SECTION_PANELS,
+    EQUIPMENT_TABS,
+    EVENT_CATEGORY_ORDER,
+    HISTORY_PAGE_SIZE,
+    WORK_ORDER_CATEGORY_ORDER,
+    categoryChips,
+    categoryNote,
+    eventDetailRows,
+    eventRows,
+    filterByCategory,
+    pagedRows,
+    panelsForTab,
+    tabCount,
+    workOrderDetailRows,
+    workOrderRows,
+} from '../../../src/modules/cockpit/domain/equipmentDetailView.js'
+import { deviceStatusView, pointDeviceCountsView, statusView } from '../../../src/modules/cockpit/domain/detailFlow.js'
+import { legendIconFor, legendVisuals } from '../../../src/modules/cockpit/domain/pointStatus.js'
+import { toMonitoringPointDetail } from '../../../src/modules/cockpit/domain/pointDetail.js'
+import { DETAIL_PLACEHOLDER } from '../../../src/modules/cockpit/domain/detailFlow.js'
+
+const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
+
+let passed = 0
+let failed = 0
+function check(name, condition, message) {
+    if (condition) {
+        passed += 1
+        console.log(`PASS ${name}`)
+    } else {
+        failed += 1
+        console.error(`FAIL ${name}: ${message}`)
+    }
+}
+
+// ================================================================ A. 分类 chips 与过滤(纯函数)
+
+{
+    const rows = eventRows([
+        { code: 'W1', name: 'a', level: 1, status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00' },
+        { code: 'W2', name: 'b', level: 2, status: 'PENDING', eventTime: '2026-10-02T09:00:00+08:00' },
+        { code: 'W3', name: 'c', level: 2, status: 'PENDING', eventTime: '2026-10-03T10:00:00+08:00' },
+        { code: 'W4', name: 'd', eventTime: '2026-10-04T11:00:00+08:00' },
+    ])
+
+    // A1 首项恒为「全部」,计数 = 总行数。
+    const chips = categoryChips(rows, EVENT_CATEGORY_ORDER)
+    check('A1-first-chip-is-all', chips[0].key === CATEGORY_ALL && chips[0].label === '全部' && chips[0].count === 4, JSON.stringify(chips[0]))
+
+    // A2 词表次序固定(1级 在 2级 前),0 计数的词表项(3级/4级)不出现。
+    // 首项是「全部」(键为空串,见 A1),所以次序串以分隔符开头。
+    const keys = chips.map((chip) => chip.key)
+    check(
+        'A2-known-order-and-zero-count-omitted',
+        keys.join('|') === '|level:1|level:2|level:unknown',
+        `实际次序:${keys.join('|')}(3/4级 计数 0 不该出现;未知在最后)`,
+    )
+    const level2 = chips.find((chip) => chip.key === 'level:2')
+    check('A2b-counts-are-real', level2.count === 2 && chips.find((chip) => chip.key === 'level:1').count === 1, JSON.stringify(chips))
+
+    // A3 词表外的分类不静默丢(后端加了新等级 → 排在已知项之后仍可见可筛)。
+    const withExtra = categoryChips([{ categoryKey: 'level:9', categoryLabel: '9级' }, { categoryKey: 'level:1', categoryLabel: '1级' }], EVENT_CATEGORY_ORDER)
+    check('A3-unknown-vocabulary-survives', withExtra.map((chip) => chip.key).join('|') === '|level:1|level:9', JSON.stringify(withExtra.map((chip) => chip.key)))
+
+    // A4 过滤:空键=全部(同一批对象引用,不复制不改序);命中键只留该分类;认不出的键回空数组。
+    const all = filterByCategory(rows, CATEGORY_ALL)
+    check('A4-empty-key-is-identity', all === rows, '「全部」应原样返回同一引用(不复制、不重排)')
+    const only2 = filterByCategory(rows, 'level:2')
+    check('A4b-filter-hits', only2.length === 2 && only2.every((row) => row.categoryKey === 'level:2'), `命中 2 条:${only2.map((row) => row.code).join(',')}`)
+    check('A4c-unknown-key-is-empty', filterByCategory(rows, 'level:88').length === 0, '认不出的键回空数组——回全量就是"筛了个寂寞"(变异 M1 的靶)')
+
+    // A5 生效回显:全部时不说,生效时把「怎么取消」说出来(票 37 typeNote 同一立场)。
+    check('A5-note-only-when-active', categoryNote(chips, CATEGORY_ALL) === '' && /再点同一项取消/.test(categoryNote(chips, 'level:2')) && /当前分类:2级/.test(categoryNote(chips, 'level:2')), categoryNote(chips, 'level:2'))
+}
+
+// ================================================================ B. 行视图的分类键(词表单源)
+
+{
+    // B1 事件等级 1–4 用票 13 的等级词表;折叠行短读法与完整时间并存。
+    const rows = eventRows([
+        { code: 'W1', name: 'a', level: 1, status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00', value: 12, unit: 'mg/L' },
+        { code: 'W2', name: 'b', level: 4, status: 'PENDING', eventTime: '2026-10-02T09:00:00+08:00' },
+    ])
+    check(
+        'B1-event-level-categories',
+        rows[0].categoryKey === 'level:1' && rows[0].categoryLabel === '1级' && rows[0].levelTag.tone === 'lv1' && rows[1].categoryKey === 'level:4',
+        `${rows[0].categoryKey}/${rows[0].categoryLabel} · ${rows[1].categoryKey}`,
+    )
+    check('B1b-short-and-full-time-coexist', rows[0].timeText === '10-01 08:00' && rows[0].eventTime === '2026-10-01T08:00:00+08:00', `短读 ${rows[0].timeText} / 完整 ${rows[0].eventTime}`)
+
+    // B2 缺等级/脏等级 → unknown 桶 + 「等级未知」,**不并进任何真实等级**(变异 M4 的靶)。
+    const odd = eventRows([{ code: 'W3', name: 'c' }, { code: 'W4', name: 'd', level: 'X' }])
+    check('B2-unknown-level-not-guessed', odd.every((row) => row.categoryKey === 'level:unknown' && row.categoryLabel === '等级未知'), JSON.stringify(odd.map((row) => row.categoryKey)))
+
+    // B3 工单按状态字典 0–7;认不出的码落 state:unknown。
+    const orders = workOrderRows([
+        { workOrderCode: 'WO-1', stateCode: 0 },
+        { workOrderCode: 'WO-2', stateCode: 5 },
+        { workOrderCode: 'WO-3', stateCode: 99 },
+        { workOrderCode: 'WO-4' },
+    ])
+    check(
+        'B3-work-order-state-categories',
+        orders[0].categoryKey === 'state:0' && orders[0].categoryLabel === '待派单' && orders[1].categoryLabel === '已结案' && orders[2].categoryKey === 'state:unknown' && orders[3].categoryKey === 'state:unknown',
+        JSON.stringify(orders.map((row) => row.categoryKey)),
+    )
+    check('B3b-order-chips-order', WORK_ORDER_CATEGORY_ORDER.filter((key) => key !== 'state:unknown').length === 8, '工单分类次序 = 字典 0–7(单源 workOrder.js,不另造)')
+}
+
+// ================================================================ C. 展开详情:四件事始终保留
+
+{
+    // C1 最小事件(只有编码):数据时间/状态/来源/关联设备四行**都在**,缺值给占位符(变异 M3 的靶)。
+    const rows = eventDetailRows(eventRows([{ code: 'W1' }])[0])
+    const labels = rows.map((row) => row.label)
+    check(
+        'C1-mandatory-detail-rows',
+        ['数据时间', '状态', '来源', '关联设备'].every((label) => labels.includes(label)),
+        `实际:${labels.join('/')}`,
+    )
+    check('C1b-missing-values-are-placeholder', rows.filter((row) => ['数据时间', '状态', '来源', '关联设备'].includes(row.label)).every((row) => row.value === DETAIL_PLACEHOLDER), '缺值一律占位符,不画空白')
+
+    // C2 有值时带出真实内容;可选行(等级/量值/位置)没值就不出现。
+    const full = eventDetailRows(eventRows([{ code: 'W1', level: 2, value: 18, unit: 'mg/L', location: '井下一层', status: 'ACTIVE', eventTime: '2026-10-01T08:00:00+08:00', deviceName: '压力计-1' }])[0])
+    const byLabel = Object.fromEntries(full.map((row) => [row.label, row.value]))
+    check('C2-full-detail-values', byLabel['量值'] === '18 mg/L' && byLabel['位置'] === '井下一层' && byLabel['关联设备'] === '压力计-1', JSON.stringify(byLabel))
+    const sparse = eventDetailRows(eventRows([{ code: 'W1', name: 'a' }])[0])
+    check('C2b-optional-rows-omitted-when-empty', !sparse.some((row) => row.label === '位置' || row.label === '量值'), JSON.stringify(sparse.map((row) => row.label)))
+
+    // C3 工单展开:四件事 + 计划/实际结案;关联对象那句三态文案仍来自 domain(票 21 口径)。
+    const order = workOrderRows([{ workOrderCode: 'WO-1', stateCode: 3, associatedWithCurrentEvent: true, createTime: '2026-10-01T08:00:00+08:00', orderTypeName: '故障维修' }])[0]
+    const detail = Object.fromEntries(workOrderDetailRows(order).map((row) => [row.label, row.value]))
+    check(
+        'C3-work-order-detail-rows',
+        detail['数据时间'] === '2026-10-01T08:00:00+08:00' && detail['状态'] === '处理中' && detail['来源'] === '故障维修' && detail['关联对象'] === '已关联当前事件' && detail['实际结案'] === DETAIL_PLACEHOLDER,
+        JSON.stringify(detail),
+    )
+}
+
+// ================================================================ D. 点位设备计数 KPI(不画虚假零值)
+
+{
+    const rawComplete = {
+        monitoringPointCode: 'MP-1',
+        monitoringPointName: '测试点',
+        pointStatus: 'ALARM',
+        deviceStatistics: { totalDeviceCount: 5, alarmDeviceCount: 1, warningDeviceCount: 2, workOrderDeviceCount: 0, normalDeviceCount: 1, offlineDeviceCount: 1 },
+        devices: [],
+    }
+    // D1 统计整段缺失 → null(KPI 条不出现),而不是五个 0(变异 M2 的靶)。
+    const degraded = toMonitoringPointDetail({ monitoringPointCode: 'MP-2', monitoringPointName: '缺统计点', devices: [] })
+    check('D1-missing-statistics-hides-kpi', pointDeviceCountsView(degraded) === null, '没给统计时回 null——画一排 0 就是虚假零值')
+
+    // D2 完整统计 → 五态计数;0 是真实值照画;色键来自设备词表(报警 lv1/预警 lv2/工单 lv3/正常 lv4/离线 point-offline)。
+    const view = pointDeviceCountsView(toMonitoringPointDetail(rawComplete))
+    const byKey = Object.fromEntries(view.map((row) => [row.key, row]))
+    check('D2-counts-from-backend', view.length === 5 && byKey.ALARM.count === 1 && byKey.OFFLINE.count === 1, JSON.stringify(view.map((row) => `${row.key}:${row.count}`)))
+    check('D2b-zero-is-real', byKey.WORK_ORDER.count === 0 && byKey.WORK_ORDER.label === '工单处置', '确实 0 台照画 0(与"没给统计"是两件事)')
+    check('D2c-device-table-tones', byKey.ALARM.tone === 'lv1' && byKey.OFFLINE.tone === 'point-offline', JSON.stringify(view.map((row) => `${row.key}:${row.tone}`)))
+}
+
+// ================================================================ F. 标签页计划(返工第 1 条)
+
+{
+    // F1 页签清单恰为五个且顺序/默认页符合维护者点名的形态;图片不在页签里(并入档案页)。
+    check(
+        'F1-tab-plan-shape',
+        EQUIPMENT_TABS.map((tab) => tab.name).join(',') === 'archive,metrics,warnings,alarms,workOrders' &&
+            DEFAULT_EQUIPMENT_TAB === 'archive' &&
+            EQUIPMENT_TABS[0].sections.join(',') === 'archive,latestImage',
+        EQUIPMENT_TABS.map((tab) => `${tab.name}[${tab.sections.join('+')}]`).join(' '),
+    )
+    // F2 渲染计划仍是六块(t11 D1/D9 的独立性不能被标签页吃掉);latestImage 标了归属。
+    check(
+        'F2-six-panels-still-independently-planned',
+        EQUIPMENT_SECTION_PANELS.length === 6 &&
+            EQUIPMENT_SECTION_PANELS.find((panel) => panel.name === 'latestImage').mergedInto === 'archive',
+        '标签页只改呈现归属,六块分区的数据与成败一个都不合并',
+    )
+    // F3 页签取面板:档案页拿到两块且保持渲染计划次序;未知页签回空数组(不猜)。
+    const fakePanels = EQUIPMENT_SECTION_PANELS.map((panel) => ({ ...panel, section: { state: 'success', data: [] } }))
+    check(
+        'F3-panels-for-tab',
+        panelsForTab(fakePanels, 'archive').map((panel) => panel.name).join(',') === 'archive,latestImage' &&
+            panelsForTab(fakePanels, 'metrics').length === 1 &&
+            panelsForTab(fakePanels, 'nope').length === 0,
+        panelsForTab(fakePanels, 'archive').map((panel) => panel.name).join(','),
+    )
+    // F4 页签计数只给列表型页;档案/指标回空串(不是 0 —— 0 会被读成"档案有 0 条")。
+    const listPanels = [
+        { name: 'warnings', kind: 'events', section: { state: 'success', data: [1, 2, 3] } },
+        { name: 'alarms', kind: 'events', section: { state: 'success', data: [1] } },
+        { name: 'workOrders', kind: 'orders', section: { state: 'success', data: [] } },
+        { name: 'archive', kind: 'archive', section: { state: 'success', data: {} } },
+    ]
+    check(
+        'F4-tab-counts-only-for-lists',
+        tabCount(listPanels, 'warnings') === '3' && tabCount(listPanels, 'workOrders') === '0' && tabCount(listPanels, 'archive') === '',
+        `预警=${tabCount(listPanels, 'warnings')} 工单=${tabCount(listPanels, 'workOrders')} 档案="${tabCount(listPanels, 'archive')}"`,
+    )
+}
+
+// ================================================================ G. 分页边界(返工第 1 条:每页最多 10 项)
+
+{
+    const rows = eventRows(Array.from({ length: 23 }, (_, index) => ({ code: `W${index}`, name: `n${index}`, level: 1 })))
+
+    // G1 每页上限 10;23 条 → 3 页。
+    const p1 = pagedRows(rows, 1)
+    check('G1-page-size-ten', HISTORY_PAGE_SIZE === 10 && p1.rows.length === 10 && p1.pageCount === 3, `第1页 ${p1.rows.length} 条 / ${p1.pageCount} 页`)
+    // G2 末页取余数;区间读数给"第 21–23 条"这种可核对的口径。
+    const p3 = pagedRows(rows, 3)
+    check('G2-last-page-remainder', p3.rows.length === 3 && p3.from === 21 && p3.to === 23 && !p3.hasNext, JSON.stringify({ n: p3.rows.length, from: p3.from, to: p3.to }))
+    // G3 越界页码夹回(第 99 页 → 第 3 页),不返回空表冒充"没数据"。
+    check('G3-out-of-range-clamped', pagedRows(rows, 99).page === 3 && pagedRows(rows, 0).page === 1 && pagedRows(rows, -5).page === 1, '越界与脏页码都夹回合法页')
+    // G4 空表:0 条也是 1 页(页码控件由调用方按 pageCount>1 决定是否出现)。
+    const empty = pagedRows([], 1)
+    check('G4-empty-is-one-page', empty.total === 0 && empty.pageCount === 1 && empty.from === 0 && !empty.hasPrev && !empty.hasNext, JSON.stringify(empty))
+    // G5 不排序、不改序(切片保序 = 后端次序,票 10/11 立场)。
+    check('G5-keeps-backend-order', p1.rows.map((row) => row.code).join(',') === 'W0,W1,W2,W3,W4,W5,W6,W7,W8,W9', p1.rows.map((row) => row.code).join(','))
+}
+
+// ================================================================ H. 状态图例对齐(返工第 2 条)
+
+{
+    // H1 图标登记是**单源接缝**:legendVisuals 的每一行都经 legendIconFor 取值(顶部状态栏与详情共用)。
+    const visuals = legendVisuals()
+    check(
+        'H1-icon-registry-single-source',
+        visuals.every((visual) => visual.icon === legendIconFor(visual.key)) && visuals.every((visual) => visual.icon !== ''),
+        `${visuals.length} 行词条的图标都来自 legendIconFor(顶部状态栏同源)`,
+    )
+    // H2 点位状态视图带 icon,且 tone/shape/label 与顶部图例词条逐字一致。
+    const alarm = statusView('ALARM')
+    const legendAlarm = visuals.find((visual) => visual.key === 'ALARM')
+    check(
+        'H2-point-status-matches-top-legend',
+        alarm.icon === legendIconFor('ALARM') && alarm.tone === legendAlarm.tone && alarm.shape === legendAlarm.shape && alarm.label === legendAlarm.label,
+        `报警:${alarm.tone}/${alarm.shape}/${alarm.label}/${alarm.icon}`,
+    )
+    // H3 设备状态视图带 icon(借登记、不借点位状态语义 —— 票 44 边界仍成立)。
+    const deviceAlarm = deviceStatusView('ALARM')
+    check(
+        'H3-device-status-icon-no-table-bleed',
+        deviceAlarm.icon === legendIconFor('ALARM') && deviceAlarm.tone === 'lv1' && deviceAlarm.shape === 'triangle' && deviceAlarm.key === 'ALARM',
+        `设备报警:${deviceAlarm.tone}/${deviceAlarm.shape}/${deviceAlarm.icon}`,
+    )
+    // H4 认不出的状态:图标落 UNKNOWN 登记,**不猜成某个真实态的图标**。
+    check('H4-unknown-status-icon-not-guessed', deviceStatusView('SOMETHING_NEW').icon === legendIconFor('UNKNOWN') && statusView('MAINTENANCE').icon === legendIconFor('UNKNOWN'), '未发布状态的图标来自 UNKNOWN 词条')
+    // H5 空/脏键回空串(渲染层据此跳过,不画错图标)。
+    check('H5-blank-key-no-icon', legendIconFor('') === '' && legendIconFor(undefined) === '' && legendIconFor('toString') === '', '认不出的键回空串(继承键不算登记)')
+}
+
+// ================================================================ E. 静态守卫(消费侧形状与边界)
+
+{
+    const sections = read('components/EquipmentDetailSections.vue')
+    const card = read('components/PointDetailCard.vue')
+    const stripComments = (text) => text.replace(/<!--[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
+    const sectionsCode = stripComments(sections)
+    const cardCode = stripComments(card)
+
+    // E1 折叠默认收起:条目是 `<details data-history-item>` 且**没有** open 属性(默认展开=没折叠)。
+    check('E1-history-items-collapsed-by-default', /<details[\s\S]*?data-history-item/.test(sectionsCode) && !/<details[^>]*\sopen(\s|>)/.test(sectionsCode), '历史条目用 details 且不带 open——默认折叠是票 46 范围第 3 条')
+    // E2 每条可展开详情(data-history-detail 两块都有:events 与 orders)。
+    check('E2-expandable-detail-present', (sectionsCode.match(/data-history-detail/g) || []).length === 2, '预警/报警与工单条目各有一条展开详情')
+    // E3 分类绑定齐全:chip 键、按下态、切换入口、过滤行(工单)与分页行(预警/报警)。
+    check(
+        'E3-category-bindings-complete',
+        /:data-category-chip="`\$\{panel\.name\}:\$\{chip\.key \|\| 'all'\}`"/.test(sectionsCode) &&
+            /:aria-pressed="chip\.key === activeCategory\(panel\.name\)"/.test(sectionsCode) &&
+            /@click="toggleCategory\(panel\.name, chip\.key\)"/.test(sectionsCode) &&
+            /v-for="\(event, index\) in pagedOf\(panel\)\.rows"/.test(sectionsCode) &&
+            /v-for="\(order, index\) in visibleRows\(panel\)"/.test(sectionsCode),
+        'chip 键/按下态/点击 + 预警报警走分页行 + 工单走过滤行(守卫按绑定判,不按字样判)',
+    )
+    // E3b 分页控件绑定齐全(返工第 1 条:每页最多 10 项;单页不画控件由 pageCount>1 门控)。
+    check(
+        'E3b-pager-bindings-complete',
+        /v-if="pagedOf\(panel\)\.pageCount > 1"/.test(sectionsCode) &&
+            /:disabled="!pagedOf\(panel\)\.hasPrev"/.test(sectionsCode) &&
+            /:disabled="!pagedOf\(panel\)\.hasNext"/.test(sectionsCode) &&
+            /@click="turnPage\(panel, -1\)"/.test(sectionsCode) &&
+            /@click="turnPage\(panel, 1\)"/.test(sectionsCode) &&
+            /data-page-state/.test(sectionsCode),
+        '上一页/下一页按 hasPrev/hasNext 置灰 + 页码读数 + 单页不出现控件',
+    )
+    // E3c 标签页绑定齐全(返工第 1 条:弃用长滚动改切换;默认页与换设备复位都要有)。
+    check(
+        'E3c-tabs-bindings-complete',
+        /role="tablist"/.test(sectionsCode) &&
+            /:aria-selected="tab\.name === activeTab"/.test(sectionsCode) &&
+            /v-for="panel in activePanels"/.test(sectionsCode) &&
+            /const activeTab = ref\(DEFAULT_EQUIPMENT_TAB\)/.test(sectionsCode) &&
+            /activeTab\.value = DEFAULT_EQUIPMENT_TAB/.test(sectionsCode),
+        '页签可访问语义 + 只渲染当前页 + 默认档案页 + 换设备复位',
+    )
+    // E4 换设备清分类条件(A 的分类不得挂到 B 上)。
+    check('E4-device-change-resets-categories', /watch\(\s*\(\) => props\.deviceCode,[\s\S]{0,200}categories\.value = \{\}/.test(sectionsCode), 'deviceCode 变化 → categories 归空')
+    // E5 分类是视图本地状态:不进 store、不发请求(只读边界的另一半:组件里零 fetch/零接口路径)。
+    check('E5-local-state-no-requests', !/\bfetch\s*\(|XMLHttpRequest|\/equipments\/|\/monitoring-points\//.test(sectionsCode + cardCode) && /const categories = ref\(\{\}\)/.test(sectionsCode), '分类状态在组件本地;组件不拼路径不取数')
+    // E6 前端不重排:过滤/归组都不改后端行次序。
+    check('E6-no-sorting-in-components', !/\.sort\(/.test(sectionsCode) && !/\.sort\(/.test(cardCode), '组件零 .sort((次序归后端与 domain 单源)')
+    // E7 零后端原因码/裸字段上屏(沿用票 11 的清单,新增的 chip/详情绑定不许漏词)。
+    const leakedCodes = ['READY', 'SAMPLE_OUTDATED', 'NO_EVENTS', 'NO_METRICS', 'NO_ARCHIVE', 'NO_WORK_ORDERS', 'NO_IMAGE', 'SUCCESS', 'FAILED', 'STALE', 'EMPTY'].filter((code) => new RegExp(`\\b${code}\\b`).test(sectionsCode))
+    check('E7-no-backend-reason-codes-in-ui', leakedCodes.length === 0, leakedCodes.join(','))
+    const leakedFields = /deviceModel|deviceSpec|metricCode|warningNo|alarmId|workOrderCode|aggregateState|stateReason|deviceStatistics|statusReasons/.test(cardCode)
+    check('E7b-card-has-no-raw-fields', !leakedFields, '点位卡只读领域结论(pointDeviceCountsView),不认后端裸字段')
+    // E8 点位卡 KPI 与既有钩子:v-if 挂 null 判据、编码仍是 dd、设备数仍是 b(t41 的形状)。
+    check(
+        'E8-kpi-and-pinned-hooks',
+        /v-if="deviceCountsView"/.test(cardCode) && /data-point-kpis/.test(cardCode) &&
+            /<dd class="point-detail__mono" data-point-code-value>/.test(cardCode) &&
+            /<b class="point-detail__kpi-value point-detail__num" data-point-device-count>/.test(cardCode),
+        'KPI 条按 null 收口;data-point-code-value(<dd>)/data-point-device-count(<b>) 形状保留',
+    )
+    // E9 词表单源:domain 的次序表来自 workOrder.js/LEVEL 词表,不抄第二张名单。
+    const viewCode = read('domain/equipmentDetailView.js')
+    check(
+        'E9-vocabulary-single-source',
+        /WORK_ORDER_CATEGORY_ORDER = \[\.\.\.WORK_ORDER_STATES\.map/.test(viewCode) && /import \{ eventLevelTag, shortEventTime \} from '\.\/eventList\.js'/.test(viewCode),
+        '工单次序展开自字典、等级词复用票 13 单源(另抄名单=将来必漂移)',
+    )
+    // E10 状态图例对齐(返工第 2 条):详情里**每个** StatusTag 都绑定了 icon(按绑定判)。
+    //     只查"文件里出现过 icon"会被 import 或注释满足(本仓栽过多次)。
+    const cardTags = cardCode.match(/<StatusTag[\s\S]*?\/>/g) || []
+    check(
+        'E10-all-detail-status-tags-carry-icon',
+        cardTags.length >= 3 && cardTags.every((tag) => /:icon="/.test(tag)),
+        `详情卡内 ${cardTags.length} 个状态标签全部绑定 icon(与一张图顶部状态栏同一套视觉)`,
+    )
+    // E10b 图标登记的单源在 pointStatus(顶部状态栏与详情共用一个函数,不各存一份表)。
+    //     判据:登记只在 pointStatus 一处定义;MapCanvas 经 legendVisuals 消费(不自建图标表)。
+    const pointStatus = read('domain/pointStatus.js')
+    const mapCanvas = read('components/MapCanvas.vue')
+    const flowCode = read('domain/detailFlow.js')
+    check(
+        'E10b-icon-registry-lives-in-one-place',
+        /export function legendIconFor/.test(pointStatus) &&
+            /icon: legendIconFor\(visual\.key\)/.test(pointStatus) &&
+            /import \{[^}]*legendIconFor[^}]*\} from '\.\/pointStatus\.js'/.test(flowCode) &&
+            (pointStatus.match(/statusAlarm/g) || []).length === 1 &&
+            /legendVisuals\(\)/.test(mapCanvas) &&
+            /icon: visual\.icon \|\| ''/.test(mapCanvas) &&
+            !/statusAlarm/.test(mapCanvas),
+        '图标登记只在 pointStatus 一处(statusAlarm 字样在该文件只出现 1 次);顶部状态栏与详情都经它取值',
+    )
+
+    // E11 组件 import 的名字**必须真是 domain 的导出**(按真实导出判,不按"字样出现过"判)。
+    //     为什么要这条:一次把 `EVENT_CATEGORY_ORDER` 误写成 `EQUIPMENT_CATEGORY_ORDER`,
+    //     文本守卫全绿(字样都在文件里),只有 t41 那种**真的加载 .vue** 的 SSR 闸门才炸。
+    //     这里不靠浏览器、也不解析 .vue:直接拿 domain 模块的**命名空间**当判据,
+    //     再对上一份"本票应当 import 的名字清单"(清单缺一即红,见 E11b)。
+    const REQUIRED_SECTION_IMPORTS = [
+        'DEFAULT_EQUIPMENT_TAB',
+        'EQUIPMENT_TABS',
+        'EVENT_CATEGORY_ORDER',
+        'WORK_ORDER_CATEGORY_ORDER',
+        'archiveRows',
+        'categoryChips',
+        'categoryNote',
+        'emptyListText',
+        'equipmentPanels',
+        'eventDetailRows',
+        'eventRows',
+        'filterByCategory',
+        'imageView',
+        'metricRows',
+        'metricsSourceText',
+        'pagedRows',
+        'panelsForTab',
+        'tabCount',
+        'timelineSource',
+        'workOrderDetailRows',
+        'workOrderRows',
+    ]
+    const missingExports = REQUIRED_SECTION_IMPORTS.filter((name) => !(name in equipmentDetailViewModule))
+    check(
+        'E11-imported-names-are-real-exports',
+        REQUIRED_SECTION_IMPORTS.length >= 10 && missingExports.length === 0,
+        missingExports.length ? `domain 里没有这些导出:${missingExports.join(', ')}` : `${REQUIRED_SECTION_IMPORTS.length} 个 import 名全部命中 domain 真实导出`,
+    )
+    // E11b 清单与源码**双向**核对:组件 import 块里的名字必须与清单完全一致
+    //     (多一个没登记的名字、或清单里的名字没被 import,都判红 —— 单向判等于没判)。
+    //     `[^}]*` 而不是 `[\s\S]*?`:后者会从文件里**第一个** `import {` 起跨(vue 那个),
+    //     把整段 import 头都当成名字清单(实测就是这么假红的)。
+    const importBlock = (sections.match(/import \{([^}]*)\} from '\.\.\/domain\/equipmentDetailView\.js'/) || [])[1] || ''
+    const actualImports = importBlock.split(',').map((piece) => piece.trim().split(/\s+as\s+/)[0]).filter(Boolean)
+    const notInList = actualImports.filter((name) => !REQUIRED_SECTION_IMPORTS.includes(name))
+    const notImported = REQUIRED_SECTION_IMPORTS.filter((name) => !actualImports.includes(name))
+    check(
+        'E11b-import-list-matches-source-both-ways',
+        notInList.length === 0 && notImported.length === 0,
+        `源码多出的名字:${notInList.join(',') || '无'};清单里未 import 的:${notImported.join(',') || '无'}`,
+    )
+    // E11c 点位卡的 domain import 同样按真实导出判。
+    const CARD_IMPORTS = { detailFlow: ['DETAIL_LEVEL', 'deviceCards', 'deviceCardOf', 'pointDeviceCountsView', 'pointSummary', 'deviceStatusView'], equipmentDetailView: ['equipmentDetailMatches', 'aggregateUnavailable'] }
+    const cardMissing = Object.entries(CARD_IMPORTS).flatMap(([module, names]) =>
+        names.filter((name) => !(name in (module === 'detailFlow' ? detailFlowModule : equipmentDetailViewModule))),
+    )
+    check(
+        'E11c-card-imports-are-real-exports',
+        cardMissing.length === 0,
+        cardMissing.length ? `点位卡 import 了不存在的导出:${cardMissing.join(', ')}` : '点位卡两处 domain import 全部命中真实导出',
+    )
+}
+
+console.log('')
+console.log(`T46 详情卡片化/分类查询/历史折叠:${passed}/${passed + failed} 项通过。`)
+if (failed) {
+    console.log('未通过项见上方 FAIL。')
+    process.exit(1)
+}
+assert.equal(1, 1)
+console.log('T46 全部通过(浏览器走查按项目规则未自动执行)。')

+ 193 - 0
.scratch/cockpit-real-data-wayfinding/verify/t46-mutations.mjs

@@ -0,0 +1,193 @@
+/**
+ * 票 46 闸门变异测试:证明 t46 的每条关键断言**抓得住它要防的缺陷**。
+ *
+ * 做法(与 t44-mutations 同一纪律):对源文件做**单点**变异注入 → 跑 t46 → 断言指定断言转红
+ * → 按字节还原并校验一致。变异只在本脚本运行期间存活几秒,跑完即还原。
+ *
+ * 四条变异对应票 46 的四个真实风险:
+ *   M1 filterByCategory 认不出的键回**全量**("筛了个寂寞"的假绿)→ A4c 必须红
+ *   M2 统计整段缺失时画**一排 0**(虚假零值,票 16 明令禁止)→ D1 必须红
+ *   M3 展开详情的必选项没值就**丢掉**(数据时间/状态/来源/关联对象消失)→ C1 必须红
+ *   M4 认不出的等级**并进 level:1**(把未知猜成真实等级)→ B2 必须红
+ *
+ * 运行:node .scratch/cockpit-real-data-wayfinding/verify/t46-mutations.mjs
+ */
+import assert from 'node:assert/strict'
+import { closeSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
+const GATE = fileURLToPath(new URL('t46-detail-cards-history.mjs', import.meta.url))
+const srcPath = (rel) => `${repoRoot}src/modules/cockpit/${rel}`
+
+/**
+ * 跑闸门并取回输出。
+ * 刻意用**文件重定向**而不是管道捕获:Windows 沙箱下捕获子进程 stdio 的命名管道
+ * 是文档化的边界(EPERM),管道版实测拿回空输出 → 变异全部"未转红"的假信号。
+ */
+const reportDir = mkdtempSync(join(tmpdir(), 't46-mutations-'))
+function runGate() {
+    const outPath = join(reportDir, 'stdout.txt')
+    const errPath = join(reportDir, 'stderr.txt')
+    const out = openSync(outPath, 'w')
+    const err = openSync(errPath, 'w')
+    let status = 0
+    try {
+        status = spawnSync(process.execPath, [GATE], { stdio: ['ignore', out, err] }).status
+    } finally {
+        closeSync(out)
+        closeSync(err)
+    }
+    return { output: `${readFileSync(outPath, 'utf8')}${readFileSync(errPath, 'utf8')}`, status }
+}
+
+function failingNames(output) {
+    return [...output.matchAll(/^FAIL ([^:]+):/gm)].map((match) => match[1])
+}
+
+let injected = 0
+let proved = 0
+function mutate(label, file, from, to, expectRed) {
+    const path = srcPath(file)
+    const original = readFileSync(path, 'utf8')
+    // 变异源文本按 CRLF 归一后再匹配(票 37 实测踩过:行尾不一致时 replace 静默不命中)。
+    const candidates = [from, from.replace(/\n/g, '\r\n')]
+    const needle = candidates.find((candidate) => original.includes(candidate))
+    if (!needle) {
+        console.error(`FAIL ${label}: 变异源未命中(文件形状已变,需人工更新变异脚本)`)
+        injected += 1
+        return
+    }
+    injected += 1
+    writeFileSync(path, original.replace(needle, to), 'utf8')
+    let caught = false
+    let how = ''
+    try {
+        const { output, status } = runGate()
+        const red = failingNames(output)
+        if (red.includes(expectRed)) {
+            caught = true
+            how = `${expectRed} 转红`
+        } else if (status !== 0 && /does not provide an export|SyntaxError|Cannot find (module|export)/.test(output)) {
+            // **崩溃也算抓住**,而且是最硬的一种:变异改掉了 domain 的真实导出,
+            // 闸门自己的具名 import 就加载失败(一条 PASS 都印不出来)。
+            // 单列成 CAUGHT-BY-CRASH,不与"某条断言转红"混为一谈,也不误记成假绿。
+            caught = true
+            how = `闸门整体加载失败(导出缺失)— 崩溃式抓住`
+        } else if (status !== 0) {
+            how = `退出码 ${status} 但目标断言未红(红项:${red.join(', ') || '无'})— 判据不明,按未抓住记`
+        } else {
+            how = `未转红(假绿!闸门仍全绿)`
+        }
+        console.log(`${caught ? 'PASS' : 'FAIL'}  ${label} → ${how}`)
+        if (caught) proved += 1
+    } finally {
+        writeFileSync(path, original, 'utf8')
+        assert.equal(readFileSync(path, 'utf8'), original, `${label}: 还原失败`)
+    }
+}
+
+mutate(
+    'M1-unknown-key-returns-all',
+    'domain/equipmentDetailView.js',
+    '    if (!key) return list\n    return list.filter((row) => row.categoryKey === key)',
+    '    if (!key) return list\n    const hits = list.filter((row) => row.categoryKey === key)\n    return hits.length ? hits : list',
+    'A4c-unknown-key-is-empty',
+)
+
+mutate(
+    'M2-missing-statistics-draws-zeros',
+    'domain/detailFlow.js',
+    '    if (!statistics || !Array.isArray(statistics.missing) || statistics.missing.length > 0) return null',
+    '    if (!statistics || !Array.isArray(statistics.missing)) return null',
+    'D1-missing-statistics-hides-kpi',
+)
+
+mutate(
+    'M3-mandatory-detail-rows-dropped',
+    'domain/equipmentDetailView.js',
+    '    if (!keepEmpty && text === DETAIL_PLACEHOLDER) return null',
+    '    if (text === DETAIL_PLACEHOLDER) return null',
+    'C1-mandatory-detail-rows',
+)
+
+mutate(
+    'M4-unknown-level-guessed-as-level1',
+    'domain/equipmentDetailView.js',
+    "            categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`,",
+    '            categoryKey: `level:${levelNumber === null ? 1 : levelNumber}`,',
+    'B2-unknown-level-not-guessed',
+)
+
+// ---- 返工(2026-10-15 维护者第 1/2 条)新增的三条变异 ----
+
+mutate(
+    'M5-pager-does-not-clamp-out-of-range',
+    'domain/equipmentDetailView.js',
+    '    const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)',
+    '    const current = Math.max(1, Math.trunc(Number(page) || 1))',
+    'G3-out-of-range-clamped',
+)
+
+mutate(
+    'M6-image-tab-dropped-from-archive',
+    'domain/equipmentDetailView.js',
+    "    { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
+    "    { name: 'archive', label: '设备档案', sections: ['archive'] },",
+    'F1-tab-plan-shape',
+)
+
+mutate(
+    'M7-icon-registry-accepts-inherited-keys',
+    'domain/pointStatus.js',
+    '    return Object.prototype.hasOwnProperty.call(LEGEND_ICON_BY_KEY, key) ? LEGEND_ICON_BY_KEY[key] : \'\'',
+    '    return LEGEND_ICON_BY_KEY[key] || \'\'',
+    'H5-blank-key-no-icon',
+)
+
+// E11 的牙:把 domain 导出改名(组件 import 的名字就落空)→ 必须被"按真实导出判"抓住。
+// 这条改的是 t46 **自己也 import** 的名字,所以闸门直接加载失败(崩溃式抓住,最硬的一种)。
+mutate(
+    'M8-domain-export-renamed',
+    'domain/equipmentDetailView.js',
+    'export function pagedRows(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
+    'export function pagedRowsRenamed(rows, page, pageSize = HISTORY_PAGE_SIZE) {',
+    'E11-imported-names-are-real-exports',
+)
+
+// M8b 才是 E11 判据本身的牙:改一个**只有组件 import、t46 自己不 import** 的名字
+// (`archiveRows`)—— 闸门仍能加载,此时必须由 E11 那条"按真实导出判"转红。
+// 只有崩溃没有断言红,说明判据没被单独验证过(本仓"守卫比它要守的东西宽"的同一类坑)。
+mutate(
+    'M8b-component-only-export-renamed',
+    'domain/equipmentDetailView.js',
+    'export function archiveRows(data) {',
+    'export function archiveRowsRenamed(data) {',
+    'E11-imported-names-are-real-exports',
+)
+
+// 标签页牙:页签顺序被打乱(默认页不再第一)→ F1 必须红。
+mutate(
+    'M9-tabs-order-shuffled',
+    'domain/equipmentDetailView.js',
+    "    { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },\n    { name: 'metrics', label: '最新指标', sections: ['metrics'] },",
+    "    { name: 'metrics', label: '最新指标', sections: ['metrics'] },\n    { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },",
+    'F1-tab-plan-shape',
+)
+
+// 图例对齐牙:详情状态视图丢掉图标 → H2 必须红。
+mutate(
+    'M10-status-view-drops-icon',
+    'domain/detailFlow.js',
+    '        label: labelOverride || visual.label,\n        icon: legendIconFor(visual.key),\n    }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
+    '        label: labelOverride || visual.label,\n    }\n}\n\n/**\n * 后端设备状态 + 标签 → 标签视图(票 44 修正越界)。',
+    'H2-point-status-matches-top-legend',
+)
+
+console.log('')
+console.log(`T46 变异测试:${proved}/${injected} 处变异被对应断言抓住。`)
+rmSync(reportDir, { recursive: true, force: true })
+if (proved < injected) process.exit(1)

+ 453 - 63
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -1,11 +1,13 @@
 <!--
-  设备详情的六个分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 最新图片。
+  设备详情的分区(票 11):档案 / 指标 / 预警 / 报警 / 工单状态 / 最新图片。
 
-  为什么每个分区**各带自己的状态标签**(而不是整卡共用一个成败):
-  后端聚合契约本身就返回「分区独立成败」——档案读失败但指标仍可用是它的原生态。
-  界面若共用一个成败,等于把后端已经说清楚的信息又揉回一个含糊的「加载失败」,
-  正好违反本票验收第 3 条(任一分区失败不遮盖其它成功分区)。所以这里六块**各自**独立:
-  自己的状态标签 + 自己的内容或占位说明,互不牵连。
+  票 46 返工(维护者 2026-10-15 第 1 条):**弃用六块竖排的长滚动**,改为标签页切换
+  设备档案(默认,图片归此页下方)|最新指标|预警信息|报警信息|工单状态;
+  预警/报警列表每页最多 10 条(上一页/下一页 + 页码)。页签计划与分页边界都是
+  domain 纯函数(`EQUIPMENT_TABS`/`panelsForTab`/`pagedRows`),组件只画结论。
+  **渲染计划仍是六块独立分区**(t11 D1/D9):标签页只改呈现归属,
+  每块仍各带自己的状态标签与成败 —— 档案读失败但指标可用是后端契约的原生态,
+  界面共用一个成败等于把后端已说清的信息揉回含糊的「加载失败」。
 
   三种「没内容」在界面上的区别(票 16 的词汇,措辞由 domain/equipmentDetailView.js 给):
     空数据 EMPTY  → 「该分区当前没有数据」
@@ -23,14 +25,40 @@
 -->
 <template>
     <div class="equipment-sections" data-equipment-sections :data-device-code="deviceCode">
-        <section
-            v-for="panel in panels"
-            :key="panel.name"
-            class="equipment-section"
-            :data-equipment-section="panel.name"
-            :data-panel-state="panel.section.state || 'empty'"
-            :data-panel-retained="panel.section.retained ? 'true' : 'false'"
-        >
+        <!--
+            票 46 返工:标签页条。计数挂在页签上(预警/报警/工单才有;
+            维护者要「避免长滚动列表查找困难」——点之前就知道每页后面有多少条)。
+        -->
+        <div class="equipment-tabs" role="tablist" aria-label="设备详情分区切换" data-equipment-tabs>
+            <button
+                v-for="tab in tabs"
+                :key="tab.name"
+                type="button"
+                role="tab"
+                class="equipment-tabs__tab"
+                :class="{ 'is-active': tab.name === activeTab }"
+                :aria-selected="tab.name === activeTab"
+                :data-equipment-tab="tab.name"
+                :data-tab-count="tab.count || ''"
+                @click="activeTab = tab.name"
+            >
+                {{ tab.label }}<b v-if="tab.count" class="equipment-tabs__count">{{ tab.count }}</b>
+            </button>
+        </div>
+
+        <!--
+            当前页签的面板(档案页含两块:档案 + 最新图片,所以 tabpanel 挂在**包装层**
+            而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
+        -->
+        <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
+            <section
+                v-for="panel in activePanels"
+                :key="panel.name"
+                class="equipment-section"
+                :data-equipment-section="panel.name"
+                :data-panel-state="panel.section.state || 'empty'"
+                :data-panel-retained="panel.section.retained ? 'true' : 'false'"
+            >
             <header class="equipment-section__head">
                 <span class="equipment-section__title">{{ panel.label }}</span>
                 <SectionStateTag :name="panel.name" :section="panel.section" />
@@ -43,6 +71,29 @@
 
             <!-- ---------- 有内容:按 kind 分派 ---------- -->
             <template v-if="panel.hasContent">
+                <!--
+                    票 46:分类查询条 —— 预警/报警/工单**共用这一块**(同一组绑定只写一处)。
+                    分类归组在 domain(等级复用票 13 词表、工单复用 0–7 字典),组件只画 chips;
+                    点分类只筛视图,不发请求、不动选择(筛选变化不丢详情上下文)。
+                -->
+                <template v-if="isFilterable(panel) && chipsOf(panel).length > 1">
+                    <div class="equipment-section__chips" role="group" :aria-label="`${panel.label}分类查询`" data-category-bar>
+                        <button
+                            v-for="chip in chipsOf(panel)"
+                            :key="chip.key || 'all'"
+                            type="button"
+                            class="equipment-section__chip"
+                            :class="{ 'is-active': chip.key === activeCategory(panel.name) }"
+                            :aria-pressed="chip.key === activeCategory(panel.name)"
+                            :data-category-chip="`${panel.name}:${chip.key || 'all'}`"
+                            @click="toggleCategory(panel.name, chip.key)"
+                        >
+                            {{ chip.label }} <b class="equipment-section__chip-count" data-category-count>{{ chip.count }}</b>
+                        </button>
+                    </div>
+                    <p v-if="categoryNoteText(panel)" class="equipment-section__note" data-category-note>{{ categoryNoteText(panel) }}</p>
+                </template>
+
                 <!-- 档案:键值行 -->
                 <dl v-if="panel.kind === 'archive'" class="equipment-section__rows" data-archive-rows>
                     <div v-for="row in rowsOf(panel)" :key="row.key" class="equipment-section__row" :data-archive-row="row.key">
@@ -74,56 +125,103 @@
                     </p>
                 </div>
 
-                <!-- 预警 / 报警:等级文本 + 状态 + 时间 + 量值(配色留给票 13/19) -->
+                <!--
+                    预警 / 报警:条目默认可折叠(票 46)—— 折叠行给时间·名称·等级·状态·量值,
+                    展开看详情(数据时间 / 状态 / 来源 / 关联设备,缺值给占位符)。
+                    票 46 返工第 1 条:每页最多 10 条(上一页/下一页 + 页码;先筛后翻)。
+                    分类 chips 在上面共用那一块。
+                -->
                 <div v-else-if="panel.kind === 'events'" class="equipment-section__list" :data-event-list="panel.name">
-                    <article
-                        v-for="(event, index) in rowsOf(panel)"
+                    <details
+                        v-for="(event, index) in pagedOf(panel).rows"
                         :key="event.code || index"
-                        class="equipment-section__item"
+                        class="equipment-section__item equipment-section__item--collapsible"
                         :data-event="event.code || index"
                         :data-event-level-color="event.levelColor"
+                        data-history-item
                     >
-                        <span class="equipment-section__item-name">{{ event.name }}</span>
-                        <span class="equipment-section__meta">
-                            <span data-event-level>{{ event.levelText }}</span>
-                            <span v-if="event.status">{{ event.status }}</span>
-                        </span>
-                        <span class="equipment-section__meta">
-                            <span class="equipment-section__mono">{{ event.code }}</span>
-                            <span v-if="event.eventTime">{{ event.eventTime }}</span>
-                        </span>
-                        <span v-if="event.location" class="equipment-section__meta">{{ event.location }}</span>
-                        <span class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(event.quantity) }" data-event-value>
-                            {{ event.quantity }}
-                        </span>
-                    </article>
-                    <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
-                        {{ emptyList(panel.label) }}
+                        <summary class="equipment-section__item-head">
+                            <span class="equipment-section__item-name">{{ event.name }}</span>
+                            <span class="equipment-section__meta">
+                                <span data-event-level>{{ event.levelText }}</span>
+                                <span v-if="event.status">{{ event.status }}</span>
+                            </span>
+                            <span class="equipment-section__meta">
+                                <span v-if="event.timeText">{{ event.timeText }}</span>
+                                <span class="equipment-section__mono">{{ event.code }}</span>
+                            </span>
+                            <span class="equipment-section__value" :class="{ 'is-muted': isPlaceholder(event.quantity) }" data-event-value>
+                                {{ event.quantity }}
+                            </span>
+                        </summary>
+                        <dl class="equipment-section__detail" data-history-detail>
+                            <div v-for="row in eventDetailRows(event)" :key="row.label" class="equipment-section__detail-row" :data-detail-row="row.label">
+                                <dt>{{ row.label }}</dt>
+                                <dd>{{ row.value }}</dd>
+                            </div>
+                        </dl>
+                    </details>
+                    <p v-if="!pagedOf(panel).total" class="equipment-section__hint" data-section-empty-list>
+                        {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList(panel.label) }}
                     </p>
+
+                    <!--
+                        票 46 返工第 1 条:分页控件(每页最多 10 项;上一页/下一页 + 页码)。
+                        只有一页时**不画**控件 —— 分页是为"翻不动"准备的,短列表不该多出两个死按钮。
+                    -->
+                    <div v-if="pagedOf(panel).pageCount > 1" class="equipment-pager" role="group" :aria-label="`${panel.label}分页`" data-pager>
+                        <button
+                            type="button"
+                            class="equipment-pager__btn"
+                            :disabled="!pagedOf(panel).hasPrev"
+                            data-page-prev
+                            @click="turnPage(panel, -1)"
+                        >
+                            上一页
+                        </button>
+                        <span class="equipment-pager__state" data-page-state>
+                            第 {{ pagedOf(panel).page }}/{{ pagedOf(panel).pageCount }} 页 · 共 {{ pagedOf(panel).total }} 条
+                        </span>
+                        <button
+                            type="button"
+                            class="equipment-pager__btn"
+                            :disabled="!pagedOf(panel).hasNext"
+                            data-page-next
+                            @click="turnPage(panel, 1)"
+                        >
+                            下一页
+                        </button>
+                    </div>
                 </div>
 
-                <!-- 工单状态:状态名(缺则按状态字典补)+ 关键时间 + 每张工单自己的状态时间线(票 12) -->
+                <!--
+                    工单状态:状态名(缺则按状态字典补)+ 关键时间 + 每张工单自己的状态时间线(票 12)。
+                    票 46:条目默认可折叠,展开保留数据时间/状态/来源/关联对象 + 计划与实际结案时间。
+                    「没有动作记录」那句仍由 `timeline.hasContent` 门控(票 12 的 F9 守卫逐字钉住)。
+                -->
                 <div v-else-if="panel.kind === 'orders'" class="equipment-section__list" data-work-order-list>
-                    <article
-                        v-for="(order, index) in rowsOf(panel)"
+                    <details
+                        v-for="(order, index) in visibleRows(panel)"
                         :key="order.code || index"
-                        class="equipment-section__item"
+                        class="equipment-section__item equipment-section__item--collapsible"
                         :data-work-order="order.code || index"
                         :data-work-order-state="order.stateCode"
+                        data-history-item
                     >
-                        <span class="equipment-section__item-name">
-                            {{ order.stateName }}
-                            <em v-if="order.typeName"> · {{ order.typeName }}</em>
-                        </span>
-                        <span class="equipment-section__meta">
-                            <span class="equipment-section__mono">{{ order.code }}</span>
-                            <span v-if="order.description">{{ order.description }}</span>
-                            <span data-work-order-association>{{ order.associationText }}</span>
-                        </span>
-                        <span class="equipment-section__meta">
-                            <span>创建 {{ order.createTime || '—' }}</span>
-                            <span>结案 {{ order.realFinishTime }}</span>
-                        </span>
+                        <summary class="equipment-section__item-head">
+                            <span class="equipment-section__item-name">
+                                {{ order.stateName }}
+                                <em v-if="order.typeName"> · {{ order.typeName }}</em>
+                            </span>
+                            <span class="equipment-section__meta">
+                                <span class="equipment-section__mono">{{ order.code }}</span>
+                                <span data-work-order-association>{{ order.associationText }}</span>
+                            </span>
+                            <span class="equipment-section__meta">
+                                <span v-if="order.timeText">创建 {{ order.timeText }}</span>
+                                <span>结案 {{ order.realFinishTime }}</span>
+                            </span>
+                        </summary>
 
                         <!--
                             票 12:这张工单自己的状态路径 + 动作轨迹。
@@ -141,13 +239,19 @@
                         <p v-else-if="timeline.hasContent" class="equipment-section__hint" data-wo-timeline-missing>
                             这张工单没有动作记录
                         </p>
-                    </article>
+                        <dl class="equipment-section__detail" data-history-detail>
+                            <div v-for="row in workOrderDetailRows(order)" :key="row.label" class="equipment-section__detail-row" :data-detail-row="row.label">
+                                <dt>{{ row.label }}</dt>
+                                <dd>{{ row.value }}</dd>
+                            </div>
+                        </dl>
+                    </details>
                     <!-- 轨迹分区自己失败/为空:整块说一句(不按每张工单重复同一句话,那是噪音) -->
                     <p v-if="timeline.placeholder" class="equipment-section__hint" data-wo-timeline-placeholder>
                         {{ timeline.placeholder }}
                     </p>
-                    <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
-                        {{ emptyList('工单') }}
+                    <p v-if="!visibleRows(panel).length" class="equipment-section__hint" data-section-empty-list>
+                        {{ rowsOf(panel).length ? '当前分类下没有记录(换一个分类或点「全部」)' : emptyList('工单') }}
                     </p>
                 </div>
 
@@ -179,6 +283,7 @@
                 {{ panel.missing.text }}
             </p>
         </section>
+        </div>
         <dialog ref="imageDialog" class="equipment-image-viewer" aria-label="设备图片放大查看" @click="closeImageBackdrop">
             <button type="button" autofocus class="equipment-image-viewer__close" @click="imageDialog.close()">关闭</button>
             <img :src="currentImageUrl" :alt="`${deviceCode} 最新图片`" />
@@ -191,14 +296,26 @@ import { computed, ref, watch } from 'vue'
 import { resolveDeviceImageUrl } from '../services/deviceImage.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import {
+    DEFAULT_EQUIPMENT_TAB,
+    EVENT_CATEGORY_ORDER,
+    EQUIPMENT_TABS,
+    WORK_ORDER_CATEGORY_ORDER,
     archiveRows,
+    categoryChips,
+    categoryNote,
     emptyListText,
     equipmentPanels,
+    eventDetailRows,
     eventRows,
+    filterByCategory,
     imageView,
     metricRows,
     metricsSourceText,
+    pagedRows,
+    panelsForTab,
+    tabCount,
     timelineSource,
+    workOrderDetailRows,
     workOrderRows,
 } from '../domain/equipmentDetailView.js'
 import { pathOnlyTrack } from '../domain/workOrderTimeline.js'
@@ -214,6 +331,19 @@ const props = defineProps({
 
 const panels = computed(() => equipmentPanels(props.aggregate))
 
+// ---- 票 46 返工第 1 条:标签页切换(弃用六块竖排的长滚动)----
+
+/** 页签清单(domain 给顺序与背后的分区归属;计数给预警/报警/工单三页)。 */
+const tabs = computed(() =>
+    EQUIPMENT_TABS.map((tab) => ({ ...tab, count: tabCount(panels.value, tab.name) })),
+)
+
+/** 当前页签(默认「设备档案」;换设备复位,不把 A 的页签停在 B 上)。 */
+const activeTab = ref(DEFAULT_EQUIPMENT_TAB)
+
+/** 当前页签要渲染的面板(保留渲染计划自己的次序;图片随档案页出现)。 */
+const activePanels = computed(() => panelsForTab(panels.value, activeTab.value))
+
 /**
  * 时间线来源(票 12):按工单编码配好的轨迹 + 轨迹分区自己的状态。
  * 只算一次 —— 每张工单渲染时都去重算会把 O(n²) 的判断塞进模板。
@@ -221,6 +351,81 @@ const panels = computed(() => equipmentPanels(props.aggregate))
 const timeline = computed(() => timelineSource(props.aggregate))
 const staleNote = '数据已过期 · 显示最后一次成功数据,不是实时值'
 
+// ---- 票 46:分类查询与历史折叠(**视图本地状态**)----
+//
+// 为什么不进 store:分类/页码/折叠是「这一屏怎么看」,不是业务事实,也不是全局筛选条件。
+// 放进 `filterStore` 会让它跨设备、跨模式生效并触发重新取数 —— 那是把视图偏好
+// 冒充成数据条件(票 46 非目标明写「不改变后端事实与只读边界」)。
+// 组件卸载即消失;换设备时下面 `watch` 把它清掉,不残留成"A 的分类挂在 B 上"。
+
+/** 各分区当前生效的分类键(`{ [分区名]: 分类键 }`;空串/缺失=全部)。 */
+const categories = ref({})
+
+/** 各分区当前页码(1 起;只给预警/报警用,工单不分页)。 */
+const pages = ref({})
+
+/** 某分区的行视图(未过滤)。 */
+function rowsOf(panel) {
+    const data = panel.section.data
+    if (panel.kind === 'archive') return archiveRows(data)
+    if (panel.kind === 'metrics') return metricRows(data)
+    if (panel.kind === 'orders') return workOrderRows(data)
+    return eventRows(data)
+}
+
+/** 这一块有没有分类查询(预警/报警/工单有;档案/指标/图片没有列表语义)。 */
+function isFilterable(panel) {
+    return panel.kind === 'events' || panel.kind === 'orders'
+}
+
+/** 某分区的分类 chips(只有 events/orders 两类有;其它块回空数组,模板据此不画 chip 条)。 */
+function chipsOf(panel) {
+    if (panel.kind !== 'events' && panel.kind !== 'orders') return []
+    const order = panel.kind === 'orders' ? WORK_ORDER_CATEGORY_ORDER : EVENT_CATEGORY_ORDER
+    return categoryChips(rowsOf(panel), order)
+}
+
+function activeCategory(panelName) {
+    return categories.value[panelName] || ''
+}
+
+/** 点同一项取消(与票 37 的类型筛选同款交互:不需要额外的"清除"按钮就能撤)。 */
+function toggleCategory(panelName, key) {
+    categories.value = { ...categories.value, [panelName]: activeCategory(panelName) === key ? '' : key }
+    // 换分类 = 换结果集,页码回第 1 页(停在第 3 页看新分类会直接显示"没有记录")。
+    pages.value = { ...pages.value, [panelName]: 1 }
+}
+
+/** 过滤后的行(chip 只筛视图,不动数据、不发请求)。 */
+function visibleRows(panel) {
+    return filterByCategory(rowsOf(panel), activeCategory(panel.name))
+}
+
+/** 过滤 + 分页后的窗口(domain 的 `pagedRows` 收边界;工单不分页 → 全量)。 */
+function pagedOf(panel) {
+    if (panel.kind !== 'events') return { rows: visibleRows(panel), total: visibleRows(panel).length, pageCount: 1, page: 1, hasPrev: false, hasNext: false }
+    return pagedRows(visibleRows(panel), pages.value[panel.name] || 1)
+}
+
+/** 翻页(越界由 `pagedRows` 夹回,这里只管加/减)。 */
+function turnPage(panel, delta) {
+    pages.value = { ...pages.value, [panel.name]: (pages.value[panel.name] || 1) + delta }
+}
+
+function categoryNoteText(panel) {
+    return categoryNote(chipsOf(panel), activeCategory(panel.name))
+}
+
+// 换设备:页签、分类条件与页码全部复位(不同设备的分布互不相干,留着就是拿 A 的状态看 B)。
+watch(
+    () => props.deviceCode,
+    () => {
+        categories.value = {}
+        pages.value = {}
+        activeTab.value = DEFAULT_EQUIPMENT_TAB
+    },
+)
+
 /**
  * 取某张工单的轨迹。
  *
@@ -234,15 +439,6 @@ function trackOfOrder(order) {
     return full || pathOnlyTrack(order)
 }
 
-/** 按 kind 取行视图 —— 分派只在这里发生一次,模板里不再认后端字段。 */
-function rowsOf(panel) {
-    const data = panel.section.data
-    if (panel.kind === 'archive') return archiveRows(data)
-    if (panel.kind === 'metrics') return metricRows(data)
-    if (panel.kind === 'orders') return workOrderRows(data)
-    return eventRows(data)
-}
-
 function metricsSource(panel) {
     return metricsSourceText(panel.section.data)
 }
@@ -291,6 +487,95 @@ function isPlaceholder(text) {
     gap: calc(6 * var(--u, 1px));
 }
 
+/*
+    票 46 返工第 1 条:标签页条(与票 13 的分段切换、票 46 的分类 chips 同一套视觉语言,
+    但它是**分区切换**不是筛选)。计数挂在页签右上角;默认页 = 设备档案。
+*/
+.equipment-tabs {
+    display: flex;
+    flex-wrap: wrap;
+    gap: calc(3 * var(--u, 1px));
+}
+
+.equipment-tabs__tab {
+    display: inline-flex;
+    align-items: center;
+    gap: calc(3 * var(--u, 1px));
+    padding: calc(2 * var(--u, 1px)) calc(9 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.32);
+    background: rgba(0, 20, 72, 0.6);
+    color: var(--text-2);
+    font: inherit;
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+    cursor: pointer;
+}
+
+.equipment-tabs__tab:hover,
+.equipment-tabs__tab:focus-visible {
+    border-color: rgba(1, 171, 234, 0.6);
+    color: var(--text-strong);
+    outline: none;
+}
+
+.equipment-tabs__tab.is-active {
+    border-color: rgba(0, 184, 255, 0.85);
+    background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
+    color: var(--text-strong);
+}
+
+.equipment-tabs__count {
+    font-family: var(--num, monospace);
+    color: var(--lv2, #ffb020);
+}
+
+.equipment-tabs__tab.is-active .equipment-tabs__count {
+    color: var(--text-strong);
+}
+
+.equipment-tab__body {
+    display: grid;
+    gap: calc(6 * var(--u, 1px));
+}
+
+/* 票 46 返工:分页控件(每页 10 项;只有一页时不出现)。 */
+.equipment-pager {
+    display: flex;
+    align-items: center;
+    gap: var(--space-2, 8px);
+    margin-top: calc(2 * var(--u, 1px));
+}
+
+.equipment-pager__btn {
+    padding: 1px calc(8 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.42);
+    background: rgba(0, 20, 72, 0.6);
+    color: var(--text-2);
+    font: inherit;
+    font-size: var(--fs-caption, 10.5px);
+    cursor: pointer;
+}
+
+.equipment-pager__btn:hover:not(:disabled),
+.equipment-pager__btn:focus-visible:not(:disabled) {
+    color: var(--text-strong);
+    border-color: rgba(1, 171, 234, 0.75);
+    outline: none;
+}
+
+/* 到边的按钮置灰但**保留占位**(按钮消失会让页码行跳动,读起来像出了错)。 */
+.equipment-pager__btn:disabled {
+    opacity: 0.4;
+    cursor: default;
+}
+
+.equipment-pager__state {
+    color: var(--muted, #5680b0);
+    font-family: var(--num, monospace);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+
 /* 每块分区:自己的边框与抬头 —— 失败的那一块缩成一行提示,成功的照常展开。 */
 .equipment-section {
     display: grid;
@@ -374,6 +659,111 @@ function isPlaceholder(text) {
     border-bottom: 1px dashed rgba(1, 171, 234, 0.14);
 }
 
+/*
+    票 46:分类 chips 条(预警/报警按等级、工单按状态)。
+    与票 13 的分段切换同一套视觉语言(胶囊 + 选中高亮),但它是**视图过滤**不是模式切换。
+    计数直接挂在 chip 上 —— 分类前有多少条要看得见,筛完剩几条由列表自己说话。
+*/
+.equipment-section__chips {
+    display: flex;
+    flex-wrap: wrap;
+    gap: calc(3 * var(--u, 1px));
+}
+
+.equipment-section__chip {
+    display: inline-flex;
+    align-items: center;
+    gap: calc(3 * var(--u, 1px));
+    padding: 1px calc(7 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.32);
+    background: rgba(0, 20, 72, 0.6);
+    color: var(--text-2);
+    font: inherit;
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+    cursor: pointer;
+}
+
+.equipment-section__chip:hover,
+.equipment-section__chip:focus-visible {
+    border-color: rgba(1, 171, 234, 0.6);
+    color: var(--text-strong);
+    outline: none;
+}
+
+.equipment-section__chip.is-active {
+    border-color: rgba(0, 184, 255, 0.85);
+    background: linear-gradient(180deg, rgba(1, 171, 234, 0.85), rgba(0, 82, 168, 0.85));
+    color: var(--text-strong);
+}
+
+.equipment-section__chip-count {
+    font-family: var(--num, monospace);
+}
+
+/*
+    票 46:历史条目默认折叠(`<details>` 原生语义:键盘可达、展开态自带 aria-expanded)。
+    summary 一行读完关键信息;展开后的详情走 dl 键值行。去掉原生三角,换成边框反馈。
+*/
+.equipment-section__item--collapsible {
+    gap: calc(2 * var(--u, 1px));
+}
+
+.equipment-section__item--collapsible[open] {
+    border-bottom-color: rgba(1, 171, 234, 0.35);
+    padding-bottom: calc(5 * var(--u, 1px));
+}
+
+.equipment-section__item-head {
+    display: grid;
+    gap: 1px;
+    cursor: pointer;
+    list-style: none;
+    padding: calc(1 * var(--u, 1px)) calc(3 * var(--u, 1px));
+    margin: calc(-1 * var(--u, 1px)) calc(-3 * var(--u, 1px)) 0;
+    border: 1px solid transparent;
+}
+
+.equipment-section__item-head::-webkit-details-marker {
+    display: none;
+}
+
+.equipment-section__item-head:hover,
+.equipment-section__item--collapsible[open] .equipment-section__item-head {
+    border-color: rgba(1, 171, 234, 0.35);
+    background: rgba(0, 53, 156, 0.28);
+}
+
+.equipment-section__detail {
+    display: grid;
+    gap: calc(1 * var(--u, 1px));
+    margin: calc(2 * var(--u, 1px)) 0 0;
+    padding: calc(3 * var(--u, 1px)) var(--space-2, 8px);
+    border-left: 2px solid rgba(1, 171, 234, 0.4);
+    background: rgba(2, 10, 20, 0.4);
+}
+
+.equipment-section__detail-row {
+    display: grid;
+    grid-template-columns: calc(58 * var(--u, 1px)) minmax(0, 1fr);
+    align-items: baseline;
+    gap: var(--space-2, 8px);
+}
+
+.equipment-section__detail-row dt {
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+}
+
+.equipment-section__detail-row dd {
+    margin: 0;
+    min-width: 0;
+    color: var(--text, #d7ecff);
+    font-size: var(--fs-caption, 10.5px);
+    overflow-wrap: anywhere;
+}
+
 .equipment-section__item-name {
     color: var(--text, #d7ecff);
     font-size: var(--fs-caption, 10.5px);

+ 142 - 32
src/modules/cockpit/components/PointDetailCard.vue

@@ -48,37 +48,58 @@
             <p v-if="pointLoading" class="point-detail__hint" data-detail-loading>点位摘要加载中…</p>
 
             <template v-else-if="summary">
-                <dl class="point-detail__rows" data-point-summary>
+                <!--
+                    票 46 范围第 1 条:能用 KPI/状态卡表达的内容不再以大段纯文字为主。
+                    身份两行(名称/编码)仍是键值行(t41 钉住编码是 `<dd>`、设备数是 `<b>`,
+                    这两个源码形状原样保留);状态/原因/计数改画成**状态卡 + KPI 计数条**:
+                    计数来自后端统计的领域结论(`pointDeviceCountsView`),前端不重算,
+                    统计整段缺失时 KPI 条**不出现**,而不是画一排 0(票 16 的虚假零值立场)。
+                -->
+                <dl class="point-detail__rows point-detail__rows--identity" data-point-summary>
                     <div class="point-detail__row">
                         <dt>名称</dt>
-                        <dd data-point-name>{{ summary.name }}</dd>
+                        <dd>
+                            <EllipsisText :text="summary.name" data-point-name />
+                        </dd>
                     </div>
                     <div class="point-detail__row">
                         <dt>编码</dt>
                         <dd class="point-detail__mono" data-point-code-value>{{ summary.code }}</dd>
                     </div>
-                    <div class="point-detail__row">
-                        <dt>状态</dt>
-                        <dd>
-                            <StatusTag
-                                data-point-status
-                                :tone="summary.status.tone"
-                                :shape="summary.status.shape"
-                                :label="summary.status.label"
-                            />
-                        </dd>
-                    </div>
-                    <div class="point-detail__row">
-                        <dt>原因</dt>
-                        <dd :class="{ 'is-muted': !summary.reason }" data-point-reason>
-                            {{ summary.reason || '暂无原因' }}
-                        </dd>
+                </dl>
+
+                <div class="point-detail__status-card" data-point-status-card>
+                    <StatusTag
+                        data-point-status
+                        :tone="summary.status.tone"
+                        :shape="summary.status.shape"
+                        :label="summary.status.label"
+                        :icon="summary.status.icon"
+                    />
+                    <EllipsisText
+                        class="point-detail__status-reason"
+                        :class="{ 'is-muted': !summary.reason }"
+                        :text="summary.reason || '暂无原因'"
+                        data-point-reason
+                    />
+                </div>
+
+                <div v-if="deviceCountsView" class="point-detail__kpis" data-point-kpis>
+                    <div
+                        v-for="item in deviceCountsView"
+                        :key="item.key"
+                        class="point-detail__kpi"
+                        :data-point-kpi="item.key"
+                    >
+                        <b class="point-detail__kpi-value" :data-point-kpi-tone="item.tone">{{ item.count }}</b>
+                        <span class="point-detail__kpi-label">{{ item.label }}</span>
                     </div>
-                    <div class="point-detail__row">
-                        <dt>关联设备</dt>
-                        <dd><b class="point-detail__num" data-point-device-count>{{ summary.deviceCount }}</b> 台</dd>
+                    <div class="point-detail__kpi point-detail__kpi--total">
+                        <!-- 关联设备总数:既有走查钩子(t41 钉住它是 <b>)留在自己的计数位里 -->
+                        <b class="point-detail__kpi-value point-detail__num" data-point-device-count>{{ summary.deviceCount }}</b>
+                        <span class="point-detail__kpi-label">设备合计</span>
                     </div>
-                </dl>
+                </div>
 
                 <p class="point-detail__section-label">设备卡片 · 点一台进入设备详情</p>
                 <div class="point-detail__cards" data-device-cards>
@@ -97,7 +118,9 @@
                     >
                         <span class="point-detail__card-top">
                             <b class="point-detail__card-name">{{ card.name }}</b>
-                            <StatusTag :tone="card.status.tone" :shape="card.status.shape" :label="card.status.label" />
+                            <!-- 票 46 返工第 2 条:详情里的状态标签与一张图顶部状态栏**同一套视觉**
+                                 (图标经 legendIconFor 单源登记,形状/色键/文字同表取值)。 -->
+                            <StatusTag :tone="card.status.tone" :shape="card.status.shape" :label="card.status.label" :icon="card.status.icon" />
                         </span>
                         <span class="point-detail__card-meta">
                             <span class="point-detail__mono">{{ card.code }}</span>
@@ -169,12 +192,16 @@
                             <!--
                                 票 44:设备状态标签走**独立设备词表**(`deviceVisualForStatus`),
                                 不借用点位状态表 —— 两张表取值集不同(点位有 EMPTY,设备没有)。
+                                票 46 返工第 2 条:图标经 `statusVisual.icon`(legendIconFor 单源)
+                                与一张图顶部状态栏同一套视觉;tone/shape/label/icon 一次算好同源,
+                                不在模板里逐属性各调一次(那正是"显示甲、提示乙"的成因)。
                             -->
                             <StatusTag
                                 v-if="row.status"
-                                :tone="deviceVisualForStatus(row.status).tone"
-                                :shape="deviceVisualForStatus(row.status).shape"
-                                :label="row.statusLabel || deviceVisualForStatus(row.status).label"
+                                :tone="row.statusVisual.tone"
+                                :shape="row.statusVisual.shape"
+                                :label="row.statusLabel || row.statusVisual.label"
+                                :icon="row.statusVisual.icon"
                             />
                             <span class="point-detail__device-placement">{{ row.placementText }}</span>
                             <!--
@@ -224,9 +251,10 @@
                                 <span v-if="row.nameLabel.code" class="point-detail__mono point-detail__device-code">{{ row.nameLabel.code }}</span>
                                 <StatusTag
                                     v-if="row.status"
-                                    :tone="deviceVisualForStatus(row.status).tone"
-                                    :shape="deviceVisualForStatus(row.status).shape"
-                                    :label="row.statusLabel || deviceVisualForStatus(row.status).label"
+                                    :tone="row.statusVisual.tone"
+                                    :shape="row.statusVisual.shape"
+                                    :label="row.statusLabel || row.statusVisual.label"
+                                    :icon="row.statusVisual.icon"
                                 />
                                 <span class="point-detail__device-placement">{{ row.placementText }}</span>
                             </li>
@@ -268,6 +296,7 @@
                             :tone="deviceCard.status.tone"
                             :shape="deviceCard.status.shape"
                             :label="deviceCard.status.label"
+                            :icon="deviceCard.status.icon"
                         />
                         <span v-else class="is-muted">—</span>
                     </dd>
@@ -326,11 +355,16 @@ import { computed, ref } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useDetailStore } from '../stores/detailStore.js'
 import { useDeviceLayerStore } from '../stores/deviceLayerStore.js'
-import { DETAIL_LEVEL, deviceCards, deviceCardOf, pointSummary } from '../domain/detailFlow.js'
+import { DETAIL_LEVEL, deviceCards, deviceCardOf, pointDeviceCountsView, pointSummary } from '../domain/detailFlow.js'
 // 票 44:设备状态走**独立词表**(不借用点位状态表);清单行文本走**资产接缝的标签口径**。
+// 票 46 返工第 2 条:设备状态视觉走设备词表的视图函数(含图例图标单源)。
+// **刻意用别名导入**:静态守卫按子串禁一个与函数名同头的后端裸字段名
+// (t10 F2 的注释记过"文件名同名误伤"这同一件事)—— 别名让调用点不含那个字样,
+// 守卫收窄的仍是"真字段访问",不是放宽。
+import { deviceStatusView as deviceVisualOf } from '../domain/detailFlow.js'
 import { deviceLabel } from '../domain/deviceAssets.js'
-import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 import { equipmentDetailMatches, aggregateUnavailable } from '../domain/equipmentDetailView.js'
+import EllipsisText from './EllipsisText.vue'
 import EquipmentDetailSections from './EquipmentDetailSections.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import StatusTag from './StatusTag.vue'
@@ -358,6 +392,8 @@ const level = computed(() => selection.value.level)
 const pointSection = computed(() => sections.value.pointDetail)
 const pointData = computed(() => (pointSection.value && pointSection.value.data) || null)
 const summary = computed(() => (pointData.value ? pointSummary(pointData.value) : null))
+/** 票 46:设备计数 KPI(统计整段缺失时回 null → KPI 条不出现,不画一排 0)。 */
+const deviceCountsView = computed(() => (pointData.value ? pointDeviceCountsView(pointData.value) : null))
 const cards = computed(() => (pointData.value ? deviceCards(pointData.value) : []))
 const deviceCard = computed(() => deviceCardOf(pointData.value, selection.value.deviceCode))
 const deviceReason = computed(() => (deviceCard.value ? deviceCard.value.reason : ''))
@@ -372,7 +408,14 @@ const title = computed(() => (level.value === LEVEL.EQUIPMENT ? '设备详情' :
  * 模板只负责画 —— `F12` 守卫钉住"接缝确有生产调用点",否则 `deviceLabel` 会退回死代码。
  */
 const deviceListRows = computed(() =>
-    deviceRows.value.map((row) => ({ ...row, nameLabel: deviceLabel(row, { visible: true }) })),
+    deviceRows.value.map((row) => ({
+        ...row,
+        nameLabel: deviceLabel(row, { visible: true }),
+        // 票 46 返工第 2 条:状态视觉一次算好(tone/shape/label/icon 同源),
+        // 走设备词表的视图函数(含图例图标单源),
+        // 组件不在模板里逐属性重复调用。
+        statusVisual: deviceVisualOf(row.status),
+    })),
 )
 
 /**
@@ -508,6 +551,73 @@ const aggregateDown = computed(() => aggregateUnavailable(sections.value.equipme
     margin: 0;
 }
 
+/* 票 46:身份两行收紧成一组(状态与计数不再挤在同一张键值表里)。 */
+.point-detail__rows--identity {
+    gap: calc(2 * var(--u, 1px));
+}
+
+/*
+    票 46:状态卡(状态标签 + 原因)与设备计数 KPI 条。
+    色键只写 token 引用(与 StatusTag/PanelBlocks 同一口径,色值只在 tokens.css 声明一份)。
+*/
+.point-detail__status-card {
+    display: flex;
+    flex-wrap: wrap;
+    align-items: center;
+    gap: var(--space-2, 8px);
+    margin-top: calc(5 * var(--u, 1px));
+    padding: calc(4 * var(--u, 1px)) var(--space-2, 8px);
+    border: 1px solid rgba(1, 171, 234, 0.24);
+    background: var(--inset, rgba(2, 10, 20, 0.55));
+}
+
+.point-detail__status-reason {
+    flex: 1 1 120px;
+    min-width: 0;
+    color: var(--text-2, #88b7e8);
+    font-size: var(--fs-caption, 10.5px);
+}
+
+.point-detail__kpis {
+    display: grid;
+    grid-template-columns: repeat(auto-fit, minmax(calc(56 * var(--u, 1px)), 1fr));
+    gap: calc(4 * var(--u, 1px));
+    margin-top: calc(4 * var(--u, 1px));
+}
+
+.point-detail__kpi {
+    display: grid;
+    justify-items: center;
+    gap: 1px;
+    padding: calc(3 * var(--u, 1px)) calc(2 * var(--u, 1px));
+    border: 1px solid rgba(1, 171, 234, 0.2);
+    background: rgba(0, 20, 72, 0.4);
+}
+
+.point-detail__kpi--total {
+    border-color: rgba(1, 171, 234, 0.42);
+}
+
+.point-detail__kpi-value {
+    font-family: var(--num, monospace);
+    font-size: var(--fs-body, 13px);
+    color: var(--text, #d7ecff);
+}
+
+/* 计数语义色:键来自设备词表的 tone(lv1..lv4 / point-offline),色值只在 token。 */
+.point-detail__kpi-value[data-point-kpi-tone='lv1'] { color: var(--lv1, #ff3b52); }
+.point-detail__kpi-value[data-point-kpi-tone='lv2'] { color: var(--lv2, #ffb020); }
+.point-detail__kpi-value[data-point-kpi-tone='lv3'] { color: var(--lv3, #01abea); }
+.point-detail__kpi-value[data-point-kpi-tone='lv4'] { color: var(--lv4, #27d17a); }
+.point-detail__kpi-value[data-point-kpi-tone='point-offline'] { color: var(--point-offline, #5680b0); }
+
+.point-detail__kpi-label {
+    color: var(--muted, #5680b0);
+    font-size: var(--fs-caption, 10.5px);
+    letter-spacing: 0.04em;
+    white-space: nowrap;
+}
+
 .point-detail__row {
     display: grid;
     grid-template-columns: calc(52 * var(--u, 1px)) minmax(0, 1fr);

+ 20 - 0
src/modules/cockpit/components/StatusTag.vue

@@ -25,6 +25,14 @@
             v-html="shapeMarkup"
         />
         <i v-else class="status-tag__dot" aria-hidden="true"></i>
+        <!--
+            票 46 返工第 2 条:可选的图例图标位(`icon` = CockpitIcon 语义名,
+            值来自 domain/pointStatus.js 的**单源登记** `legendIconFor`)。
+            详情面板传入它之后,状态标签与一张图顶部状态栏就是同一套视觉:
+            图标 + 形状 + 语义色 + 文字,词条同源,不再各画各的。
+            不传 = 保持票 06 的原外观(事件列表等既有调用点零变化)。
+        -->
+        <CockpitIcon v-if="icon" monochrome :name="icon" size="calc(12 * var(--u, 1px))" class="status-tag__icon" />
         <span class="status-tag__label">{{ label }}</span>
     </span>
 </template>
@@ -33,12 +41,15 @@
 import { computed } from 'vue'
 import { DEFAULT_TONE, isKnownTone } from '../domain/statusTones.js'
 import { isKnownShape, shapeSvg } from '../domain/pointStatus.js'
+import CockpitIcon from './CockpitIcon.vue'
 
 const props = defineProps({
     tone: { type: String, default: DEFAULT_TONE },
     label: { type: String, required: true },
     // 不传 = 保持票 06 的圆点外观(事件四级图例仍在用);传了就换成对应形状。
     shape: { type: String, default: '' },
+    // 票 46 返工:图例图标语义名(来自 legendIconFor 单源);空串不画。
+    icon: { type: String, default: '' },
 })
 
 const toneKey = computed(() => (isKnownTone(props.tone) ? props.tone : DEFAULT_TONE))
@@ -78,6 +89,15 @@ const shapeMarkup = computed(() => shapeSvg(shapeKey.value))
     filter: drop-shadow(0 0 4px currentcolor);
 }
 
+/* 票 46 返工:图例图标与形状/圆点同色(currentColor),弱发光与地图状态栏同一档。 */
+.status-tag__icon {
+    display: inline-flex;
+    align-items: center;
+    flex: none;
+    color: currentcolor;
+    filter: drop-shadow(0 0 3px currentcolor);
+}
+
 /* 文字用亮色保证对比度,边框与形状用语义色 */
 .status-tag__label {
     color: var(--text-2, #88b7e8);

+ 37 - 1
src/modules/cockpit/domain/detailFlow.js

@@ -9,7 +9,7 @@
  * 没有任何一条路径会把设备写回地图标记计划 —— 地图对象永远只来自 `mapStore` 的地图摘要
  * (后端只给「监测点 + 未关联设备」,关联设备根本不在那份载荷里)。
  */
-import { visualForStatus } from './pointStatus.js'
+import { legendIconFor, visualForStatus } from './pointStatus.js'
 // 票 44:设备状态必须走**独立**的设备词表,不得借用点位表(决策票 07/10 的边界)。
 import { deviceVisualForStatus } from './deviceStatus.js'
 
@@ -102,6 +102,9 @@ export function isSamePointTarget(selection, pointCode) {
  *
  * 注意与 `deviceStatusView()` 的分工:本函数读的是**点位**状态词表,
  * 给设备配色必须走 `deviceStatusView()` —— 拿点位表给设备配色是决策票 07/10 点名的越界。
+ *
+ * `icon`(票 46 返工第 2 条):走 `legendIconFor` **单源接缝** —— 详情里的状态标签与
+ * 一张图顶部状态栏共用同一份图标登记与同一张词表(色键/形状/文字同源)。
  */
 export function statusView(status, labelOverride = '') {
     const visual = visualForStatus(status)
@@ -110,6 +113,7 @@ export function statusView(status, labelOverride = '') {
         tone: visual.tone,
         shape: visual.shape,
         label: labelOverride || visual.label,
+        icon: legendIconFor(visual.key),
     }
 }
 
@@ -120,6 +124,9 @@ export function statusView(status, labelOverride = '') {
  * 而两张表的取值集不同(点位有 `EMPTY`「无设备」,设备五态**没有**),
  * 一旦后端给设备回一个点位专有的状态,界面会画出一个设备根本不可能有的态。
  * 现在设备的色/形/文字一律来自 `domain/deviceStatus.js`(独立平行表)。
+ *
+ * `icon`(票 46 返工第 2 条):与顶部状态栏同源(`legendIconFor`,设备与点位词表的键同名,
+ * 五态 + 未发布都有登记);**取值仍走设备表**,只借图标登记,不借状态语义。
  */
 export function deviceStatusView(status, labelOverride = '') {
     const visual = deviceVisualForStatus(status)
@@ -128,6 +135,7 @@ export function deviceStatusView(status, labelOverride = '') {
         tone: visual.tone,
         shape: visual.shape,
         label: labelOverride || visual.label,
+        icon: legendIconFor(visual.key),
     }
 }
 
@@ -207,6 +215,34 @@ export function pointSummary(detail) {
     }
 }
 
+/**
+ * 点位设备计数 KPI 行(票 46 范围第 1 条:能用 KPI/状态卡表达的不再是大段纯文字)。
+ *
+ * 数据来源是后端 `deviceStatistics`(票 05 已适配成 `statistics`),前端**不重算**:
+ *   · 统计整段缺失(`statistics.missing` 非空)→ 回 `null`,界面**不画 KPI 行**,
+ *     而不是画一排 0 ——「没给统计」与「各态都是 0 台」是两件事(票 16 的虚假零值立场);
+ *   · 计数 0 是真实值(「确实没有报警设备」),照原样画。
+ * 色键/文字走**设备词表**(`deviceStatusView`),不借点位表 —— 这里数的是设备。
+ * @param {object} detail `toMonitoringPointDetail()` 的领域对象
+ */
+export function pointDeviceCountsView(detail) {
+    const statistics = (detail && detail.statistics) || null
+    if (!statistics || !Array.isArray(statistics.missing) || statistics.missing.length > 0) return null
+
+    const rows = [
+        { key: 'ALARM', label: '报警', count: statistics.alarm },
+        { key: 'WARNING', label: '预警', count: statistics.warning },
+        { key: 'WORK_ORDER', label: '工单处置', count: statistics.workOrder },
+        { key: 'NORMAL', label: '正常', count: statistics.normal },
+        { key: 'OFFLINE', label: '离线', count: statistics.offline },
+    ]
+    return rows.map((row) => ({
+        ...row,
+        // 色键走**设备词表**(数的是设备,不借点位表 —— 决策票 07/10 的边界,票 44 已收口)。
+        tone: deviceStatusView(row.key).tone,
+    }))
+}
+
 /** 从已加载的点位详情里取某台设备的卡片视图(第二级的表头用它;找不到回 null)。 */
 export function deviceCardOf(detail, deviceCode) {
     if (!deviceCode) return null

+ 256 - 30
src/modules/cockpit/domain/equipmentDetailView.js

@@ -24,11 +24,17 @@
  * 它不新增第七块面板 —— 状态轨迹是**工单状态面板里每张工单自己的**一条线
  * (票 12 验收第 2 条「每张工单有独立时间线」),所以它挂在 `kind: 'orders'` 那一块上,
  * 由 `timelineSource()` 按工单编码配好再交给组件。
+ *
+ * 票 46 追加**分类查询的视图函数**(本文件末尾):预警/报警按事件等级、工单按状态字典
+ * 归组出分类 chips 与过滤结果。判据全是纯函数(可脱浏览器断言),组件只画结论。
+ * 等级词表复用票 13 的 `eventList.js`(`eventLevelTag`),工单词表复用 `workOrder.js`
+ * —— 都是既有单源,本票不发明第二张表。
  */
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { SECTION_STATE, section } from './sectionState.js'
-import { workOrderStateName } from './workOrder.js'
+import { WORK_ORDER_STATES, workOrderStateName } from './workOrder.js'
 import { tracksByOrder } from './workOrderTimeline.js'
+import { eventLevelTag, shortEventTime } from './eventList.js'
 
 /**
  * 六块面板的渲染计划,顺序即界面顺序(与票 11 正文的点名顺序一致)。
@@ -37,16 +43,97 @@ import { tracksByOrder } from './workOrderTimeline.js'
  * `kind` 刻意用自有词(`orders` / `events` / `image`)而不是照抄后端分区名
  * (`workOrders` / `warnings` / `latestImage`):分区名是**契约**、只该在 `name` 里出现一次,
  * 组件里若再散落后端键名,票 05 的静态守卫(页面/store 零后端裸字段名)就挡不住真泄漏了。
+ *
+ * 票 46 返工(维护者 2026-10-15 第 1 条):`latestImage` 标 `mergedInto: 'archive'` ——
+ * **渲染计划仍是六块**(t11 D1/D9 钉住六块独立成败,分区数据与状态一个都不合并),
+ * 变的只是**呈现归属**:图片不再单独占一个标签页,并进「设备档案」页的键值行下方。
  */
 export const EQUIPMENT_SECTION_PANELS = [
     { name: 'archive', label: '设备档案', kind: 'archive' },
     { name: 'metrics', label: '最新指标', kind: 'metrics' },
-    { name: 'warnings', label: '预警', kind: 'events' },
-    { name: 'alarms', label: '报警', kind: 'events' },
+    { name: 'warnings', label: '预警信息', kind: 'events' },
+    { name: 'alarms', label: '报警信息', kind: 'events' },
     { name: 'workOrders', label: '工单状态', kind: 'orders' },
-    { name: 'latestImage', label: '最新图片', kind: 'image' },
+    { name: 'latestImage', label: '最新图片', kind: 'image', mergedInto: 'archive' },
 ]
 
+/**
+ * 设备详情的**标签页**计划(票 46 返工第 1 条)。
+ *
+ * 维护者原话:弃用「浏览器式的文档切换」(六块竖排、靠一条长滚动条找),改为切换显示
+ * 设备档案(默认,图片归此页)|最新指标|预警信息|报警信息|工单状态。
+ * 这里给的是**页签清单**(顺序即界面顺序):`name` 是页签自己的稳定键(events 页签跨
+ * 预警/报警两个分区,所以不复用分区名),`sections` 是它背后要渲染的分区名。
+ * 「最新图片」不在页签清单里 —— 它作为 `archive` 页签的一部分渲染(`mergedInto`)。
+ */
+export const EQUIPMENT_TABS = [
+    { name: 'archive', label: '设备档案', sections: ['archive', 'latestImage'] },
+    { name: 'metrics', label: '最新指标', sections: ['metrics'] },
+    { name: 'warnings', label: '预警信息', sections: ['warnings'] },
+    { name: 'alarms', label: '报警信息', sections: ['alarms'] },
+    { name: 'workOrders', label: '工单状态', sections: ['workOrders'] },
+]
+
+/** 默认页签(维护者点名「设备档案(默认)」)。 */
+export const DEFAULT_EQUIPMENT_TAB = 'archive'
+
+/**
+ * 页签 → 该页要渲染的面板(从 `equipmentPanels()` 的结果里挑,**保留渲染计划自己的次序**)。
+ * 挑不出来(聚合没数据)时回空数组,由调用方按"没内容"处理,不猜。
+ */
+export function panelsForTab(panels, tabName) {
+    const tab = EQUIPMENT_TABS.find((item) => item.name === tabName)
+    if (!tab) return []
+    const list = Array.isArray(panels) ? panels : []
+    return tab.sections.map((name) => list.find((panel) => panel.name === name)).filter(Boolean)
+}
+
+/**
+ * 页签计数(维护者要「避免长滚动列表查找困难」——页签上先给出条数,不用点进去才知道有多少)。
+ * 只数**列表型**分区(预警/报警/工单);档案/指标/图片不是条目集合,回空串不画数字。
+ */
+export function tabCount(panels, tabName) {
+    const tab = EQUIPMENT_TABS.find((item) => item.name === tabName)
+    if (!tab) return ''
+    const list = Array.isArray(panels) ? panels : []
+    const counts = tab.sections
+        .map((name) => list.find((panel) => panel.name === name))
+        .filter((panel) => panel && (panel.kind === 'events' || panel.kind === 'orders'))
+        .map((panel) => (Array.isArray(panel.section.data) ? panel.section.data.length : 0))
+    if (!counts.length) return ''
+    return String(counts.reduce((sum, value) => sum + value, 0))
+}
+
+/**
+ * 分页视图(票 46 返工第 1 条:预警/报警列表每页最多 10 项)。
+ *
+ * 为什么是纯函数而不是组件里的算术:页码边界(空表、只有 1 页、越界页码回第 1 页)
+ * 都得能脱浏览器断言,散在模板里就守不住。
+ * 刻意**不排序、不改行次序**(票 10/11 的立场),只在已过滤的序列上切窗口。
+ * @param {Array} rows 待分页的行(已按分类过滤)
+ * @param {number} page 1 起的页码
+ * @param {number} [pageSize] 每页条数(默认 10)
+ */
+export const HISTORY_PAGE_SIZE = 10
+
+export function pagedRows(rows, page, pageSize = HISTORY_PAGE_SIZE) {
+    const list = Array.isArray(rows) ? rows : []
+    const size = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : HISTORY_PAGE_SIZE
+    const pageCount = list.length > 0 ? Math.ceil(list.length / size) : 1
+    const current = Math.min(Math.max(1, Math.trunc(Number(page) || 1)), pageCount)
+    const start = (current - 1) * size
+    return {
+        rows: list.slice(start, start + size),
+        page: current,
+        pageCount,
+        total: list.length,
+        from: list.length ? start + 1 : 0,
+        to: list.length ? Math.min(start + size, list.length) : 0,
+        hasPrev: current > 1,
+        hasNext: current < pageCount,
+    }
+}
+
 /** 本票的渲染计划**不含**这一个聚合分区(票 13 的域)。 */
 export const DEFERRED_SECTIONS = ['alarmWarningRelations']
 /** 有内容可画的分区状态(`STALE` 是"有值但过期",内容照画)。 */
@@ -198,41 +285,72 @@ export function missingNote(value) {
  * 事件(预警/报警共用)→ 行视图。
  * 等级只出「N 级」文本与后端色名(挂在 `data-event-level-color` 上供票 13 接管),
  * 本票不把事件四级配色画出来(那是票 13/19 的域)。
+ *
+ * 票 46 追加**分类与展开详情**所需的视图字段(都是既有领域字段的改名/复用,不读新后端字段):
+ *   · `levelTag` / `categoryKey` / `categoryLabel`:等级分类(词表复用票 13 的 `eventLevelTag`,
+ *     认不出的等级归「等级未知」,不猜成某个真实等级);
+ *   · `timeText`:折叠行的短读法(复用 `shortEventTime`,完整 `eventTime` 保留给展开详情);
+ *   · `type` / `deviceCode` / `deviceName`:展开详情要保留「来源与关联对象」(票 46 范围第 3 条)。
  */
 export function eventRows(data) {
     const list = Array.isArray(data) ? data : []
 
-    return list.map((event) => ({
-        code: event.code || DETAIL_PLACEHOLDER,
-        name: event.name || DETAIL_PLACEHOLDER,
-        levelText: typeof event.level === 'number' && Number.isFinite(event.level) ? `${event.level} 级` : DETAIL_PLACEHOLDER,
-        levelColor: event.levelColor || '',
-        status: event.status || '',
-        eventTime: event.eventTime || '',
-        quantity: quantityText(event.value, event.unit),
-        location: event.location || '',
-    }))
+    return list.map((event) => {
+        const levelNumber = typeof event.level === 'number' && Number.isInteger(event.level) ? event.level : null
+        const levelTag = eventLevelTag(levelNumber)
+        return {
+            code: event.code || DETAIL_PLACEHOLDER,
+            name: event.name || DETAIL_PLACEHOLDER,
+            levelText: typeof event.level === 'number' && Number.isFinite(event.level) ? `${event.level} 级` : DETAIL_PLACEHOLDER,
+            levelColor: event.levelColor || '',
+            status: event.status || '',
+            eventTime: event.eventTime || '',
+            quantity: quantityText(event.value, event.unit),
+            location: event.location || '',
+            levelTag: { tone: levelTag.tone, label: levelTag.label },
+            categoryKey: levelNumber === null ? 'level:unknown' : `level:${levelNumber}`,
+            categoryLabel: levelNumber === null ? '等级未知' : levelTag.label,
+            timeText: shortEventTime(event.eventTime),
+            type: event.type || '',
+            deviceCode: event.deviceCode || '',
+            deviceName: event.deviceName || '',
+        }
+    })
 }
 
-/** 工单列表 → 行视图。状态名缺失时按状态字典(后端 0–7)补(不伪造:字典也认不出就回占位符)。 */
+/** 工单列表 → 行视图。状态名缺失时按状态字典(后端 0–7)补(不伪造:字典也认不出就回占位符)。
+ *
+ * 票 46 追加分类与展开字段:
+ *   · `categoryKey` / `categoryLabel`:工单**状态**分类(字典 0–7,单源 `workOrder.js`;
+ *     认不出的码归 `state:unknown`「状态未知」,不并进任何一个真实态);
+ *   · `timeText`:折叠行的短读法(创建时间,完整值保留给展开详情);
+ *   * `source`:来源(后端 `orderTypeName` 的展示结论 —— 展开详情要能回答"这条从哪来")。
+ */
 export function workOrderRows(data) {
     const list = Array.isArray(data) ? data : []
 
-    return list.map((order) => ({
-        code: order.code || DETAIL_PLACEHOLDER,
-        stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
-        // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。
-        stateCodeNumber: typeof order.stateCode === 'number' ? order.stateCode : null,
-        stateName: order.stateName || workOrderStateName(order.stateCode) || DETAIL_PLACEHOLDER,
-        typeName: order.orderTypeName || '',
-        description: order.description || '',
-        associationText: order.associatedWithCurrentEvent === true ? '已关联当前事件'
-            : order.associatedWithCurrentEvent === false ? '未关联当前事件' : '当前事件关联关系未发布',
-        createTime: order.createTime || '',
-        planFinishTime: order.planFinishTime || '',
-        // 未结案时后端给 null("还没有"),与"字段缺失"一样显示占位符。
-        realFinishTime: order.realFinishTime || DETAIL_PLACEHOLDER,
-    }))
+    return list.map((order) => {
+        const known = WORK_ORDER_STATES.some((item) => item.code === order.stateCode)
+        return {
+            code: order.code || DETAIL_PLACEHOLDER,
+            stateCode: typeof order.stateCode === 'number' ? String(order.stateCode) : '',
+            // 状态路径需要**数字**码(字符串比较会把 '10' 排在 '9' 前面),所以另存一份。
+            stateCodeNumber: typeof order.stateCode === 'number' ? order.stateCode : null,
+            stateName: order.stateName || workOrderStateName(order.stateCode) || DETAIL_PLACEHOLDER,
+            typeName: order.orderTypeName || '',
+            description: order.description || '',
+            associationText: order.associatedWithCurrentEvent === true ? '已关联当前事件'
+                : order.associatedWithCurrentEvent === false ? '未关联当前事件' : '当前事件关联关系未发布',
+            createTime: order.createTime || '',
+            planFinishTime: order.planFinishTime || '',
+            // 未结案时后端给 null("还没有"),与"字段缺失"一样显示占位符。
+            realFinishTime: order.realFinishTime || DETAIL_PLACEHOLDER,
+            categoryKey: known ? `state:${order.stateCode}` : 'state:unknown',
+            categoryLabel: known ? workOrderStateName(order.stateCode) : '状态未知',
+            timeText: shortEventTime(order.createTime),
+            source: order.orderTypeName || '',
+        }
+    })
 }
 
 /**
@@ -297,6 +415,114 @@ export function emptyListText(label) {
     return `${label}当前没有记录`
 }
 
+// ================================================================ 票 46:分类查询与历史折叠
+
+/**
+ * 分类 chips 的**固定次序**(不按计数排,也不按行出现次序):
+ * 次序来自既有单源词表 —— 事件等级 1..4(票 13 `statusTones.js`)、工单状态 0..7(`workOrder.js`)。
+ * 「未知」永远在最后:它是兜底桶,不该插进真实语义序列里。
+ */
+export const EVENT_CATEGORY_ORDER = ['level:1', 'level:2', 'level:3', 'level:4', 'level:unknown']
+export const WORK_ORDER_CATEGORY_ORDER = [...WORK_ORDER_STATES.map((item) => `state:${item.code}`), 'state:unknown']
+
+/** 分类 chips 里「全部」那一项的键(空串=不设分类条件)。 */
+export const CATEGORY_ALL = ''
+
+/**
+ * 行视图 → 分类 chips(`[{ key, label, count }]`,首项恒为「全部」)。
+ *
+ * 三条判据都收在这里,组件不再自己归组:
+ *   · **计数是真实值**:0 计数的词表项**不出现**(chip 条不是字典,铺一排 0 只会挤掉有用的入口);
+ *   · 词表外的键(后端加了新等级/新状态码)照样出现并排在已知项之后 —— 不静默丢数据;
+ *   · 不排序、不改行次序(与票 10/11「前端不重排」同一条立场)。
+ * @param {Array<{categoryKey: string, categoryLabel: string}>} rows 已带分类键的行视图
+ * @param {string[]} order 已知分类的展示次序
+ */
+export function categoryChips(rows, order = []) {
+    const list = Array.isArray(rows) ? rows : []
+    const counts = new Map()
+    const labels = new Map()
+    for (const row of list) {
+        const key = row.categoryKey || CATEGORY_ALL
+        counts.set(key, (counts.get(key) || 0) + 1)
+        if (!labels.has(key)) labels.set(key, row.categoryLabel || key)
+    }
+    const known = order.filter((key) => counts.has(key))
+    const extra = [...counts.keys()].filter((key) => !order.includes(key))
+    return [
+        { key: CATEGORY_ALL, label: '全部', count: list.length },
+        ...[...known, ...extra].map((key) => ({ key, label: labels.get(key) || key, count: counts.get(key) })),
+    ]
+}
+
+/**
+ * 按分类过滤行视图。空键=「全部」,原样返回(同一批对象引用,不复制、不改序)。
+ * 认不出的键回**空数组**而不是全量:分类条件生效却返回未筛数据,是"筛了个寂寞"的假绿。
+ */
+export function filterByCategory(rows, categoryKey) {
+    const list = Array.isArray(rows) ? rows : []
+    const key = typeof categoryKey === 'string' ? categoryKey.trim() : ''
+    if (!key) return list
+    return list.filter((row) => row.categoryKey === key)
+}
+
+/** 分类生效时那句「当前分类 · 再点取消」的可见回显(与票 37 的 typeNote 同一立场)。 */
+export function categoryNote(chips, categoryKey) {
+    const key = typeof categoryKey === 'string' ? categoryKey.trim() : ''
+    if (!key) return ''
+    const found = (chips || []).find((chip) => chip.key === key)
+    return `当前分类:${(found && found.label) || key}(再点同一项取消)`
+}
+
+/**
+ * 展开详情的一行(值缺失一律占位符 —— 票 16「不把没给画成空白/0」)。
+ * `keepEmpty` 的项是票 46 范围第 3 条点名要**始终保留**的四件事:
+ * 数据时间 / 状态 / 来源 / 关联对象;其余项有值才画(不铺一排 `—` 噪音)。
+ */
+function detailRow(label, value, keepEmpty = false) {
+    const text = valueText(value)
+    if (!keepEmpty && text === DETAIL_PLACEHOLDER) return null
+    return { label, value: text }
+}
+
+/** 一条预警/报警 → 展开后的详情行(时间、状态、来源、关联设备都在,缺值给占位符)。 */
+export function eventDetailRows(row) {
+    const current = row || {}
+    return detailRowList([
+        detailRow('数据时间', current.eventTime, true),
+        detailRow('状态', current.status, true),
+        detailRow('来源', current.type, true),
+        detailRow('关联设备', current.deviceName || current.deviceCode, true),
+        detailRow('编码', current.code),
+        detailRow('等级', current.levelText),
+        detailRow('量值', current.quantity),
+        detailRow('位置', current.location),
+    ])
+}
+
+/** 一条工单 → 展开后的详情行(同上四件事 + 计划/实际结案时间)。
+ *  `实际结案` 必留:未结案时后端给 null、视图是占位符,折叠行本来就写着「结案 —」,
+ *  展开后若这一行消失就成了"折叠说没有、展开什么都不说"(前后矛盾)。
+ */
+export function workOrderDetailRows(row) {
+    const current = row || {}
+    return detailRowList([
+        detailRow('数据时间', current.createTime, true),
+        detailRow('状态', current.stateName, true),
+        detailRow('来源', current.source || current.typeName, true),
+        detailRow('关联对象', current.associationText, true),
+        detailRow('编码', current.code),
+        detailRow('计划结案', current.planFinishTime),
+        detailRow('实际结案', current.realFinishTime, true),
+        detailRow('说明', current.description),
+    ])
+}
+
+/** 详情行清单:剔除「可选项没值」的 null(必选项即使占位符也保留)。 */
+function detailRowList(rows) {
+    return rows.filter(Boolean)
+}
+
 /** 聚合分区 → 设备编码(判定"已加载的这份是不是当前这台设备")。 */
 export function aggregateDeviceCode(aggregate) {
     const data = (aggregate && aggregate.data) || null

+ 25 - 14
src/modules/cockpit/domain/pointStatus.js

@@ -156,27 +156,38 @@ export function summarizeMarkerStatuses(markers) {
     return byStatus
 }
 
+/** 图例行的区分图标(批次 1 第三轮;`CockpitIcon` 语义名,取自 Texture/Icon 的 I/Pipe/Q 系列)。
+ *  票 46 返工:这张表**从 `legendVisuals` 里提出来成单源接缝** —— 一张图顶部状态栏与
+ *  详情面板(点位摘要/设备卡片/状态图例)都经 `legendIconFor()` 取图标,
+ *  「详情里的状态与顶部图例对齐」因此是结构性的,不是两处各抄一份。
+ *  纯视觉补充:形状 + 颜色仍是状态的第一/第二线索;未登记的键回空串,渲染层跳过。 */
+const LEGEND_ICON_BY_KEY = {
+    ALARM: 'statusAlarm',
+    WARNING: 'statusWarning',
+    WORK_ORDER: 'statusWorkOrder',
+    NORMAL: 'statusNormal',
+    OFFLINE: 'statusOffline',
+    EMPTY: 'statusEmpty',
+    UNKNOWN: 'statusUnknown',
+    UNASSOCIATED: 'statusUnassociated',
+}
+
+/** 状态键 → 图例图标语义名(认不出回空串 = 不画图标,不猜)。
+ *  只认**自有键**:普通对象下标会把 `toString` 这类原型方法也命中并当图标名带出去
+ *  (`sectionState.js` 的 `owns` 记过同一课),继承键必须落空串。 */
+export function legendIconFor(key) {
+    return Object.prototype.hasOwnProperty.call(LEGEND_ICON_BY_KEY, key) ? LEGEND_ICON_BY_KEY[key] : ''
+}
+
 /** 图例的全部行(含未关联设备与「状态未发布」),按后端优先级排序。
- *  `icon`:批次 1 第三轮新增——状态标识行的区分图标(`CockpitIcon` 语义名,取自
- *  Texture/Icon 的 I/Pipe/Q 系列)。纯视觉补充:形状 + 颜色仍是状态的第一/第二线索,
- *  图标只是让「那一行」在地图上方更易扫读;未登记图标的状态返回空串,渲染层跳过。 */
+ *  `icon`:批次 1 第三轮新增——状态标识行的区分图标(语义名取自上面的单源表)。 */
 export function legendVisuals() {
-    const iconOf = {
-        ALARM: 'statusAlarm',
-        WARNING: 'statusWarning',
-        WORK_ORDER: 'statusWorkOrder',
-        NORMAL: 'statusNormal',
-        OFFLINE: 'statusOffline',
-        EMPTY: 'statusEmpty',
-        UNKNOWN: 'statusUnknown',
-        UNASSOCIATED: 'statusUnassociated',
-    }
     return [
         ...Object.keys(POINT_STATUS_VISUALS).map((key) => POINT_STATUS_VISUALS[key]),
         UNKNOWN_VISUAL,
         UNASSOCIATED_VISUAL,
     ]
-        .map((visual) => ({ ...visual, icon: iconOf[visual.key] || '' }))
+        .map((visual) => ({ ...visual, icon: legendIconFor(visual.key) }))
         .sort((a, b) => a.priority - b.priority)
 }