ソースを参照

feat(cockpit): 从最新指标进入匿名实时视频

ChenJZ 1 日 前
親
コミット
cafb1652

+ 65 - 0
docs/cockpit-video-monitoring-acceptance.md

@@ -0,0 +1,65 @@
+# 02:最新指标入口与匿名实时视频——实施及验收记录
+
+## 交付范围
+
+本票交付首条链:既有设备选择/详情 → **最新指标 TAB** → 监控 → 中心面板 → 驾驶舱视频专用接口 → 可见播放状态与自身资源释放。不是管理端页面复制,不新增台账、信令或媒体系统。
+
+- [共用详情分区](<../src/modules/cockpit/components/EquipmentDetailSections.vue>)只新增独立[监控入口](<../src/modules/cockpit/components/VideoMonitorEntry.vue>),位于 metrics TAB、独立于采集记录成败。未以名称或行业猜摄像机,身份与能力由专用接口提供。
+- [路由根组件](<../src/pages/cockpit/index.vue>)挂载一份[全局监控面板](<../src/modules/cockpit/components/VideoMonitorPanel.vue>)。现有固定详情、地下管线详情也消费同一详情组件,不人为禁用其他宿主;三入口全面验证与复杂生命周期强化仍属于03。
+- [视频适配](<../src/modules/cockpit/services/videoApi.js>)使用现有部署配置的 API baseUrl,免业务账号/Bearer;归属凭据仅发给对应会话的心跳/停止,不显示在DOM、不存浏览器持久化、不附到媒体URL。
+- [会话编排](<../src/modules/cockpit/services/videoLiveSession.js>)沿用既有实时helper的单会话/代次/迟到释放模式,适配匿名凭据。建流可见等待上限35秒、播放器就绪20秒;30秒观看心跳不等于播放就绪。只由 playing 确认画面,不自动重连(04职责)。
+- 关闭、隐藏/离页、切一级模式或设备选择改变执行基础释放;失败与未知结果单独呈现,清理未确认保留重试并阻止新建。未返回会话标识的未知启动不能推断无资源;需要服务端核查回收,不以重新加载页面保证清理。03须进一步完成跨宿主、迟到/未知启动的强化补偿。
+- 历史/云台仅灰色占位,无申请、独占、租约、物理控制、截图、下载或录制。默认静音;原生控件限制下载/PiP/远端播放入口并非防止第三方录屏的承诺。
+
+## 播放器来源与协议
+
+用户明确选择**浏览器原生 WebRTC / video**,不复制参考仓中来源/版本未明确且缺 Wasm 的 Jessibuca。详情见[来源核查](<cockpit-video-player-provenance.md#L96-L131>)。
+
+- [播放器](<../src/modules/cockpit/services/videoPlayer.js>)通过原生RTCPeerConnection接收轨道,按实际ZLM URL发送text/plain SDP offer、验证JSON code=0/type=answer及video SDP;ICE等待5秒,不配置公网STUN。Peer/轨道挂载本身不冒充正在播放。
+- 手动原生流直接使用支持的媒体URL,FLV不冒充原生video,WebRTC地址不塞进src。HTTP/HTTPS混合内容、userinfo、非法协议和HTML/信令错误进入明确失败。
+- 不需要发布第三方播放器/解码器文件;只新增应用源码。不改既有静态资源白名单。浏览器原生解码、设备编码、ZLM网络/HTTPS/信令/CORS/SIP/RTP仍待07真实验收。
+- [会话helper](<../src/modules/cockpit/services/videoLiveSession.js>)为参考应用MIT代码的兼容改写,源码保留MIT版权与许可通知。该MIT不用于推断任何Jessibuca授权。
+- 用户批准测试依赖 `linkedom@0.18.15`(ISC),通过 `yarn add --dev --exact --ignore-scripts` 安装,整次安装明确跳过所有脚本;没有新增canvas或播放SDK。新增锁文件均为测试DOM依赖闭包,原生产依赖版本未升级。LinkeDOM不是完整浏览器,不能验证真实媒体/布局/焦点像素行为。
+
+## 后端合同与安全边界
+
+代码在独立后端仓库,复用01的VideoDeviceService和会话表:
+
+| 方法与路径(`/api/cockpit/v1`前缀) | 输入 | 公开结果 |
+| --- | --- | --- |
+| GET `/video/device` | query `deviceCode`,原样业务编码 | 设备编码/名称、typeCode、bound、逐项能力与不可用原因 |
+| POST `/video/live` | JSON `{deviceCode}` | 独立sessionId/sessionToken、LIVE、媒体白名单 |
+| POST `/video/sessions/{id}/heartbeat` | JSON `{sessionToken}` | 匹配sessionId、ACTIVE;不证明画面健康 |
+| POST `/video/sessions/{id}/stop` | JSON `{sessionToken}` | 匹配sessionId、STOPPED,自身观看资源幂等释放;不是云台STOP |
+
+[后端适配](<E:/pipe-ner-server/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/video/CockpitVideoService.java>)按equipment_code精确查询并二次核对,斜杠/空格/百分号不重复解码;只有type20与真实合法绑定支持本票实时能力。历史/云台能力不因GB28181而默认支持。
+
+[安全配置](<E:/pipe-ner-server/pipe-network-service/zksy-framework/src/main/java/com/zksy/framework/config/SecurityConfig.java>)仅增加device GET和三条POST精确例外,并在既有全驾驶舱GET规则之前限制其余video路径;旧 `/api/video` 与其他业务写权限不变。[Controller](<E:/pipe-ner-server/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitVideoController.java>)成功/失败均no-store,固定中文分类与请求/服务时间,不透明透传底层错误或秘密。
+
+媒体输出不含内部ID/国标绑定/stream/proxyKey/内部token。URL白名单拒绝userinfo、fragment与未知query;实际上游rtc/rtcs映射到webrtc。不剥掉密码后假报可用。不能发布媒体时释放新会话,失败明确CLEANUP_UNCONFIRMED。API免业务登录不证明部署内网隔离;媒体URL的网络访问安全不由CORS/隐藏按钮提供。
+
+01的[迁移脚本](<E:/pipe-ner-server/docs/video-session-anonymous-owner-20261110.sql>)**尚未执行**;部署本票代码前必须先核对并执行迁移。现有01对未知启动/底层迟到取消的边界仍有效,后续03与07负责强化和真实验证。
+
+## 离线验证(2026-11-11,审查前)
+
+- 前端:[入口旅程](<../tests/cockpit-video/journey.mjs>) **13/13**、[播放器边界](<../tests/cockpit-video/player.mjs>) **10/10**,共23项全通过。实际Vue SFC编译/Pinia store/LinkeDOM按钮事件走公开详情→最新指标→监控,不用私有函数或源码关键字代替入口。媒体播放与Peer是外部替身。
+- [测试运行器](<../tests/cockpit-video/run.mjs>)的 `--baseline-only` 用开始时前端基点加本票插入运行同一13项:全通过。既有其它在制源文件不混入纯本票验证;工作树验证亦通过。既有mapService/panelBlocks重复键警告未在本票扩大修复。
+- 后端:[新HTTP真实安全链](<E:/pipe-ner-server/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitVideoControllerTest.java>)60项、01旧HTTP36项与会话31项,**127项全通过**;真实SecurityConfig/JWT/方法权限/新旧业务模块,DB/Redis/媒体仅边界替身。
+- 原静态匿名规则测试因“只能有一条GET放行”与新视频例外冲突而RED;以7项实际HTTP鉴权行为覆盖旧GET/POST/default/WS路径后替换,不简单修改源码计数或禁用过滤器。只证明WS鉴权路径,不声称真实socket握手。
+- 完整后端管网reactor已运行:zksy-system 256项有2失败/1错误,zksy-admin 811项有17失败,总1067项含**20项非视频失败/错误**,与01记录的类别一致。本票不改这些模块,未另跑原始基点严格归因;`maven.test.failure.ignore=true`仅用于收齐结果,退出0不代表全绿。
+- 项目没有前端test/typecheck脚本或本票TS源:执行Node语法检查和SFC编译,不冒称tsc验证;前端全套指新增视频套件,未批量运行可能包含浏览器/网络请求的历史verify脚本。
+
+复现命令(不启动服务):
+
+```bash
+node tests/cockpit-video/run.mjs
+node tests/cockpit-video/run.mjs --baseline-only
+node tests/cockpit-video/player.mjs
+# 在 E:/pipe-ner-server 根目录
+JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 mvn -o -pl pipe-network-service/zksy-admin -am -Dtest=CockpitVideoControllerTest,VideoDeviceCompatibilityTest,VideoSessionCompatibilityTest -Dsurefire.failIfNoSpecifiedTests=false test
+JAVA_TOOL_OPTIONS=-Dfile.encoding=UTF-8 mvn -o -pl pipe-network-service/zksy-admin -am -Dmaven.test.failure.ignore=true test
+```
+
+## 当前收尾状态
+
+前后端实现及离线回归已完成,双轴审查和独立提交进行中;本记录不宣称真实视频交付。由于项目规则已跳过应用build/debug、浏览器调试/截图、服务启停、真实数据库迁移和设备操作。所有实际验证均限定在用户已授权的离线编译/测试范围。

+ 131 - 0
docs/cockpit-video-player-provenance.md

@@ -0,0 +1,131 @@
+# 驾驶舱视频播放器来源与离线测试依赖核查
+
+核查日期:2026-11-10(UTC+8)。范围:共享任务 task-1;只写本文,不改应用代码、包清单、票据或参考仓,不复制播放器资产。本文是工程来源核查,不替代法务意见,也不代表已获授权或真实播放验收通过。
+
+## 结论先行
+
+> **后续决定优先**:第1至5节保留首轮核查时的事实与待定建议。Lead 随后转达用户已选择原生 WebRTC/原生 video,并允许仅测试用 DOM 依赖、禁用安装脚本;当前执行决定与 LinkeDOM 官方核实见第6节。首轮“尚未决定/不可安装”不再作为新的方案或授权结论。
+
+- **现有参考 Jessibuca 资产不能直接作为“已确认合法且完整”的交付载体。** 当前只找到两个压缩脚本和一个类型声明;无法确证本地播放器/解码器的发行版本与原始来源包,缺少其引用的同目录 Wasm。没有找到随这三份资产提供的 SDK 许可或商业授权证明。
+- **官方仓的开源许可可确认,但不能据此填补本地来源链。** 官方 `v3` 分支与 `master` 分支的 LICENSE 都展示 GNU GPL version 3;不能用参考应用的 MIT 或当前应用的 Apache-2.0 代替 SDK 许可。用户需确认实际授权、交付形态和相应发行义务后,才能决定复用。
+- **无需新增第三方播放器 SDK 的原生 WebRTC 可保留为传输适配备选,不是已决定的替换方案。** 是否可用取决于既有媒体服务返回的真实协议/信令合同与浏览器能力;不因此重建视频台账、业务会话或媒体服务,也不把 WebRTC URL 当作普通 `<video src>`。
+- **离线已有 Vue 编译/SSR 工具,不等于已有 DOM 测试环境。** 根目录不能解析 jsdom、happy-dom、linkedom、Vue Test Utils、Vitest、jest-environment-jsdom;不可安装新包来填补。本次仅核查依赖,没有运行编译、DOM 测试或媒体代码。
+
+## 1. 约束与已读依据
+
+已阅读[项目规则](<E:/City-Lifeline-Cockpit-TvT/AGENTS.md>)、[领域词汇](<E:/City-Lifeline-Cockpit-TvT/CONTEXT.md>)、[领域文档规则](<E:/City-Lifeline-Cockpit-TvT/docs/agents/domain.md>)、[票据规则](<E:/City-Lifeline-Cockpit-TvT/docs/agents/issue-tracker.md>)、[实施票02](<E:/City-Lifeline-Cockpit-TvT/.scratch/cockpit-video-monitoring/issues/02-最新指标入口打开匿名实时视频.md>)、[视频规格](<E:/City-Lifeline-Cockpit-TvT/.scratch/cockpit-video-monitoring/spec.md>)以及[视频边界例外](<E:/City-Lifeline-Cockpit-TvT/docs/adr/0001-parallel-web-cockpit-baseline.md#L15-L17>)、[视频访问例外](<E:/City-Lifeline-Cockpit-TvT/docs/adr/0005-web-integration-and-performance.md#L17-L26>)。
+
+参考仓的[协作规则](<E:/city-life-line/AGENTS.md>)、[记忆索引](<E:/city-life-line/memory/index.md>)与[最近索引会话](<E:/city-life-line/memory/sessions/2026-10-22.md>)亦已读取;索引指定的长期记忆文件读取为不存在。参考仓的记忆写入要求不扩大本任务唯一文档写权限;未在参考仓补写记录。
+
+本任务不运行服务、浏览器、设备操作、真实迁移、包安装或下载 SDK;不使用 deck,不提交。
+
+## 2. 本地资产身份与完整性
+
+对 `E:/city-life-line/public/jessibuca` 做了完整普通文件列表检查(3个文件),并以只读 SHA-256 运算固定当前内容。参考仓 `git ls-files public/jessibuca` 也仅列出这三项;该路径 `git status --short` 无变更。最近路径历史为 `5117acf`(2026-10-13,视频重构)和 `df508d1`(2026-10-12,新增视频模块),它们是应用入库历史,**不是上游发行版本证明**。
+
+| 本地文件 | 字节数 | SHA-256 |
+| --- | ---: | --- |
+| [jessibuca.js](<E:/city-life-line/public/jessibuca/jessibuca.js>) | 207042 | `7112378e6c3e6e477eb8155dd71857a707867455cdb2cbe13a7407bfcc3ee783` |
+| [decoder.js](<E:/city-life-line/public/jessibuca/decoder.js>) | 110216 | `30ea729d30ec1f93740c265549e74cf07d8ea923a1bacff8beb5ef57623314ff` |
+| [jessibuca.d.ts](<E:/city-life-line/public/jessibuca/jessibuca.d.ts>) | 20126 | `cad4bacfbdb65cf639d96b7c4ba426625fe61da8ccab33953d2116980c481df3` |
+
+### 可以确证的内容
+
+1. [类型声明:37–40](<E:/city-life-line/public/jessibuca/jessibuca.d.ts#L37-L40>)明确写道:`decoder.js 与 decoder.wasm文件必须是放在同一个目录下面`。当前该目录没有 `decoder.wasm`。解码脚本本身也有 `we(ve="decoder.wasm")`,后续通过 `locateFile` 或脚本路径拼接定位;在当前参考组件的设置下不能把三个文件视作完整软解码交付包。
+2. [类型声明:169–173](<E:/city-life-line/public/jessibuca/jessibuca.d.ts#L169-L173>)引用了[上游 issue 152](<https://github.com/langhuihui/jessibuca/issues/152>),压缩脚本导出 `window.Jessibuca`。这只能提供项目归属线索,不能证明来自某个 tag、未修改的发行包或授权合同。
+3. 对两个脚本做有界字符串分析,未发现明确的 Jessibuca/decoder 发行版本或独立 license/copyright 标记。本地播放器出现的 `5.6.2` 处于 `RecordRTC` 代码上下文;**不能把它报告成 Jessibuca 版本**。脚本中的 Emscripten 文档地址也不是具体编译器/解码库版本或许可证明。
+4. 目录内没有 LICENSE、NOTICE、来源清单、原始发行包元数据或源码映射。当前无法核实解码器对应源码、构建选项、内嵌依赖许可清单和获取渠道;这些均保留为未知,不根据文件时间或应用提交日期猜版本。
+
+### 参考组件并非可原样复制的交付件
+
+[参考 VideoPlayer:15–43](<E:/city-life-line/src/components/VideoPlayer/index.vue#L15-L43>)按 `BASE_URL` 加载本地播放器和解码脚本,设置 `useMSE: false`、`useWCS: false`,并打开 `screenshot: true`;它只把 script `onload` 当作加载完成、监听播放器 `play` 作为 ready。其缺失 Wasm、加载/就绪确认、受控路径及禁止截图/录制等边界必须独立复核,不能复制后就声称满足[视频规格的静态资源和真实验收要求](<E:/City-Lifeline-Cockpit-TvT/.scratch/cockpit-video-monitoring/spec.md#L124-L132>)。
+
+本地压缩播放器还包含录制相关的外部默认资源 URL(例如 unpkg 上的 webm-wasm)。本次未执行脚本,**不能据此断言实际发生过外连**;后续受控资源检查不应只核对顶层 script 地址,也不应启用超出规格的录制路径。
+
+## 3. 官方许可核实:已知与未知分开
+
+先加载了 research、firecrawl、firecrawl-developer-index、firecrawl-scrape 技能。现有 `firecrawl --status` 返回 command not found,未安装工具;直连 raw GitHub 失败。随后使用现有 `read_page` 读取官方 GitHub 页面,工具明确返回 **Firecrawl 云端 Markdown 提取**,不是原始文件字节。未启动本地浏览器,未登录,未读取用户凭据,未下载 SDK。
+
+| 官方证据 | 可得出的结论 | 不可得出的结论 |
+| --- | --- | --- |
+| [v3/LICENSE](<https://github.com/langhuihui/jessibuca/blob/v3/LICENSE>);页面文件提交 `379c930c88ab403d7f7d8b917e077b85bb3a8929` | 正文标题为 `GNU GENERAL PUBLIC LICENSE` / `Version 3, 29 June 2007`,不是 MIT | 不能证明本地压缩资产与该分支/提交逐字一致,不能证明已有商业例外授权 |
+| [master/LICENSE](<https://github.com/langhuihui/jessibuca/blob/master/LICENSE>) | 此分支同样显示 GPL v3,未将旧 master 错当当前默认分支 | 不能从分支许可替本地资产完成版本归属 |
+| [v3/package.json](<https://github.com/langhuihui/jessibuca/blob/v3/package.json>);页面文件提交 `663cbb1d84fa3459c0a25379433f2d74c2346062` | 当次页面显示 `jessibuca`、`3.3.28`,依赖 `recordrtc: 5.6.2` | `3.3.28`不是已核实的本地版本;`5.6.2`不是播放器版本 |
+| [官方仓 README](<https://github.com/langhuihui/jessibuca>) | 区分开源版与 PRO 版;说明 Emscripten/Wasm 解码及多种播放能力 | “开源”“已有文件”或“可商用”不等于免除许可证条件,也不证明购买/授权范围 |
+
+GPL 正文已提取到第4、5、6节:包括保留许可/版权通知、修改说明、适用的整体许可要求及非源码形式的 Corresponding Source 条件。本文不替用户判断本项目交付是否属于何种组合/传播形式;**需维护者/授权方确认合规路径**。许可文本中的 `Copyright (C) 2007 Free Software Foundation, Inc.` 是许可证文本版权,不能当作 Jessibuca 实现的作者/权利人证明。
+
+[参考应用 LICENSE](<E:/city-life-line/LICENSE#L1-L13>)写的是 `Copyright (c) 2018 RuoYi` 和 MIT;[参考应用包清单](<E:/city-life-line/package.json#L1-L18>)也写 MIT。它们没有给这套第三方压缩 SDK 建立可验证的授权链。[当前驾驶舱包清单](<E:/City-Lifeline-Cockpit-TvT/package.json#L25-L26>)的 Apache-2.0 同样不覆盖这个证据缺口。
+
+### 复用前最少需要用户确认/提供
+
+- 已合法持有的播放器原始包或供应方交付件,以及明确版本、获取来源、对应源码/构建来源和可核对指纹。
+- 与播放器配套的完整解码器文件及其许可/NOTICE/内嵌依赖信息;不能任意补一份同名 Wasm 混版本。
+- 若按 GPL 开源版交付:明确本项目的交付对象、组合方式及履行适用许可义务的方案;若依靠商业许可:提供覆盖该版本、解码器、目标应用及再分发方式的许可确认。不在本文记录合同密钥或其他凭据。
+- 确认之后才决定受控静态资源发布、版本锁定、错误 HTML 拒收与失败退出等实施内容。**本次未复制任何 SDK 资产。**
+
+## 4. 当前前端离线测试依赖
+
+以当前仓根目录的 `createRequire(.../package.json).resolve(...)` 做只读入口解析,并读取已有包清单/许可证;没有调用这些模块的编译或测试函数。Node 为 `v24.20.0`。
+
+| 包 | 本地状态 | 可以支持/不能证明的事项 |
+| --- | --- | --- |
+| `vue` | `3.5.21`,入口可解析;版本与[应用声明](<E:/City-Lifeline-Cockpit-TvT/package.json#L39-L42>)一致 | 已有 Vue 运行时;不提供浏览器 DOM |
+| `@vue/compiler-sfc` | [清单](<E:/City-Lifeline-Cockpit-TvT/node_modules/@vue/compiler-sfc/package.json>) `3.5.21`;[本地 MIT](<E:/City-Lifeline-Cockpit-TvT/node_modules/@vue/compiler-sfc/LICENSE>) | 可作为后续获准离线 SFC 编译工具;本次未执行 |
+| `@vue/compiler-dom` | [清单](<E:/City-Lifeline-Cockpit-TvT/node_modules/@vue/compiler-dom/package.json>) `3.5.21`;[本地 MIT](<E:/City-Lifeline-Cockpit-TvT/node_modules/@vue/compiler-dom/LICENSE>) | 模板编译器,不是 DOM 模拟器 |
+| `@vue/server-renderer` | [清单](<E:/City-Lifeline-Cockpit-TvT/node_modules/@vue/server-renderer/package.json>) `3.5.21`;[本地 MIT](<E:/City-Lifeline-Cockpit-TvT/node_modules/@vue/server-renderer/LICENSE>) | 可供后续 SSR 输出检查;SSR 不能证明 TAB 点击、焦点、事件传播、媒体播放或卸载行为 |
+| `jsdom` / `happy-dom` / `linkedom` | 根目录逐项解析均 `MODULE_NOT_FOUND` | 不能声称已有现成离线浏览器 DOM 环境 |
+| `@vue/test-utils` / `vitest` / `jest-environment-jsdom` | 根目录逐项解析均 `MODULE_NOT_FOUND` | 不能默认使用这些测试 API,也未安装它们 |
+
+直接针对[现有 yarn.lock](<E:/City-Lifeline-Cockpit-TvT/yarn.lock>)的完整检索也未匹配上述缺失包。对整个依赖目录的通用递归搜索曾达到10,000文件扫描上限,**因此不宣称整个磁盘或任意深层私有依赖绝对不存在这些包**;本结论限定为当前项目根入口不可解析、锁文件无记录。缩小到 `node_modules/@vue` 的列表已完成,现有 compiler/runtime/server-renderer 可见,test-utils 未列出。
+
+最小测试建议(供 lead 决定,不是本任务实施):
+
+1. 先复用现有 Node 与 Vue 工具,在已获准的离线边界上验证外部 API/时钟/播放器替身及资源归属,不引入新框架。
+2. 若自行提供很小的 DOM 宿主替身或 Vue 自定义 renderer,需明确其只是组件/状态行为测试,不应冒称浏览器 DOM 集成测试;不能在夹具里重写设备能力或会话业务算法。
+3. 编译/SSR/替身通过仍不能代替从“设备详情→最新指标TAB→监控按钮”开始的真实入口验收,也不能证明真实画面、浏览器媒体能力或物理设备停止。
+
+## 5. 给主任务的方案建议与未完成门槛
+
+- **优先路径**:继续复用已有业务会话/接口边界,等待能够确证版本、来源、完整性和授权的现有播放器载体;不要先复制不明许可资源再补手续。
+- **备选路径**:仅在 lead/用户确认且既有媒体合同匹配时,考虑浏览器原生 WebRTC 的小型传输适配。它不捆绑第三方播放器/解码 SDK,但仍须核实实际 SDP/信令响应、媒体协议与编解码兼容、网络可达性、静音播放、有界就绪/失败及本会话释放。不能新建平行信令/媒体业务,不能把媒体服务成功响应当首帧已播放,也不能对未知媒体地址盲目尝试。
+- **本任务没有改变播放器方案**,没有把等待许可或等待真实验收写成完成交付。当前允许复用的确定载体只有项目已安装且本地许可可见的 Vue 开发工具;本地 Jessibuca 交付资格仍未确认。
+
+由于项目规则要求,**已跳过 build/debug 验证**;未运行服务、浏览器、测试套件、真实视频、设备操作或数据库迁移。本文只提供只读证据与决策输入,不把 SDK 来源缺口或 DOM 测试缺口静默掩盖。
+
+## 6. 后续用户决定与 LinkeDOM 官方核实
+
+更新日期:2026-11-10(UTC+8)。决定依据:本会话 Lead 转达“用户已选择原生WebRTC/原生video,明确允许添加仅测试用DOM依赖并禁安装脚本”。这不是本研究代理自行变更方案;本代理仍只更新本文、只读核实,不安装任何包、不修改[应用包清单](<E:/City-Lifeline-Cockpit-TvT/package.json>)或[锁文件](<E:/City-Lifeline-Cockpit-TvT/yarn.lock>),由 Lead 负责依赖变更。
+
+### 已决定的播放载体
+
+- 本次实施采用浏览器原生 WebRTC / 原生 `<video>` 的协议适配,**不复制现有 Jessibuca、decoder 脚本或 Wasm,不新增第三方播放 SDK**。
+- 浏览器内置的媒体解码能力不需要应用另行发布 vendor 解码器静态资源;这不表示所有媒体协议/编解码都受浏览器支持,也不改变浏览器、媒体服务或编码内容各自的许可责任。
+- 仍复用已有设备业务编码、匿名会话、媒体服务与资源归属接口,不新建信令/媒体业务。WebRTC 用实际信令协商与媒体轨道,不能把 `webrtc:` 地址直接塞进 `<video src>`;原生 video 也只消费实际支持的媒体信息。
+- **编解码兼容、媒体网络可达性、信令与跨域/安全上下文、静音播放和真实首帧仍待真实验收。** DOM 替身的 `playing`/轨道事件不能替代真实播放;本会话没有因此获得服务启动、浏览器或设备操作授权。
+
+### LinkeDOM:当前可用版本与许可
+
+本次只读查询[官方 npm latest 元数据](<https://registry.npmjs.org/linkedom/latest>)和[固定版本元数据](<https://registry.npmjs.org/linkedom/0.18.15>),均返回 HTTP 200;并与[官方 GitHub 包清单](<https://github.com/WebReflection/linkedom/blob/main/package.json>)、[官方 LICENSE](<https://github.com/WebReflection/linkedom/blob/main/LICENSE>)交叉核对。npm 返回的是 JSON 元数据;GitHub 通过已有 Firecrawl 云读取通道提取。未下载 tarball、未安装、未运行包脚本。
+
+| 项目 | 当次可核实的结果 |
+| --- | --- |
+| 名称 / 当前 latest | `linkedom` / **`0.18.15`**;建议 Lead 若采用则精确锁定这个版本,不把浮动 latest 写入测试依赖 |
+| 上游仓库 | `https://github.com/WebReflection/linkedom.git` |
+| npm gitHead / 官方包清单提交 | `6e2ae0ca56adc00f9e6e7d35971f22d676a454ff`(官方页面显示 0.18.15) |
+| 包许可 | **ISC**;官方 LICENSE 为 `Copyright (c) 2021, Andrea Giammarchi, @WebReflection`,允许有偿/无偿使用、复制、修改、分发,但要求保留版权与许可通知;无担保 |
+| Node 要求 | `engines.node: >=16`;此前本机 `v24.20.0` 满足直接包要求,尚未安装验证传递依赖 |
+| 已发布入口 | ESM `./esm/index.js`;CommonJS `./cjs/index.js`;包元数据已列相应 exports,无需从 Git 源码运行 build 来获得入口 |
+| 直接运行依赖 | `css-select ^7.0.0`、`cssomnom ^0.1.11`、`html-escaper ^3.0.3`、`htmlparser2 ^10.1.0`、`uhyphen ^0.2.0` |
+| 可选 peer | `canvas >=2`,`peerDependenciesMeta.canvas.optional: true`;本测试范围不需要主动加入原生 canvas 包或其构建链 |
+| 自身安装生命周期 | 当次官方 scripts **未声明** `preinstall`、`install`、`postinstall`、`prepare`;存在 build/test 等开发脚本,但本次不执行。这不能证明传递依赖或当前应用没有安装脚本 |
+| tarball 完整性元数据 | `sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==`;仅记录 registry 值,未下载验证 |
+
+ISC 原文关键条件:“Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.” 此许可结论来自 LinkeDOM 自身,不由应用许可推断;传递依赖应保留其各自许可。
+
+### 只用于测试,禁安装脚本仍是全局边界
+
+- 用户已允许添加**仅测试用 DOM 依赖**,Lead 可将 LinkeDOM 放入开发依赖而不是生产播放链。本研究代理不执行安装。
+- 后续安装需在所用包管理器的整次安装操作上禁用脚本,例如使用其 `--ignore-scripts` 选项,而非仅检查 LinkeDOM 自身。尤其[应用现有 postinstall](<E:/City-Lifeline-Cockpit-TvT/package.json#L9-L12>)也属于需禁止的安装脚本;不能为“修补环境”补跑。依赖图与锁文件最终结果仍由 Lead 核对。
+- [官方 README](<https://github.com/WebReflection/linkedom#faq>)明确其为 DOM-like、偏向 SSR 的实现,不追求100%标准兼容或完整浏览器模拟,且不支持 live collections。因此可作为 Vue 挂载、元素查询和合成事件的离线测试宿主候选,但需实际验证适配;媒体播放、WebRTC、布局/焦点等缺失或不完整能力仍须诚实标注并在对应外部边界使用替身。
+- 本追加没有将“npm 可获取”写成“已安装可用”,也没有将“有 DOM 依赖”写成“真实媒体验收已通过”。本代理仍未运行构建、debug、测试、服务、浏览器、设备或迁移;**已跳过 build/debug 验证**。

+ 2 - 1
package.json

@@ -28,7 +28,8 @@
     "access": "public"
   },
   "devDependencies": {
-    "cross-env": "^10.1.0"
+    "cross-env": "^10.1.0",
+    "linkedom": "0.18.15"
   },
   "dependencies": {
     "@fesjs/builder-vite": "4.0.5",

+ 2 - 0
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -54,6 +54,7 @@
             而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
         -->
         <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
+            <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" />
             <section
                 v-for="panel in activePanels"
                 :key="panel.name"
@@ -396,6 +397,7 @@ import {
     workOrderRows,
 } from '../domain/equipmentDetailView.js'
 import { pathOnlyTrack } from '../domain/workOrderTimeline.js'
+import VideoMonitorEntry from './VideoMonitorEntry.vue'
 import SectionStateTag from './SectionStateTag.vue'
 import WorkOrderTimeline from './WorkOrderTimeline.vue'
 import PagerNav from './PagerNav.vue'

+ 37 - 0
src/modules/cockpit/components/VideoMonitorEntry.vue

@@ -0,0 +1,37 @@
+<template>
+    <div v-if="loading || error || device?.typeCode === '20' || video.state.cleanupError" class="video-entry" data-video-entry>
+        <p v-if="loading" class="video-entry__hint">正在确认视频能力…</p>
+        <template v-else-if="device?.typeCode === '20'">
+            <button type="button" data-video-open :disabled="device.bound !== true || device.capabilities?.live !== true || Boolean(video.state.cleanupError)" @click="video.open(device)">监控</button>
+            <span v-if="!device.capabilities?.live" class="video-entry__hint">{{ device.unavailableReason || '实时视频能力尚未确认' }}</span>
+        </template>
+        <p v-if="error" class="video-entry__hint" role="status">{{ error }} <button type="button" @click="load">重查视频能力</button></p>
+        <p v-if="video.state.cleanupError" class="video-entry__hint" role="alert">{{ video.state.cleanupError }} <button type="button" @click="video.retryCleanup">重试清理</button></p>
+    </div>
+</template>
+<script setup>
+import { ref, watch, onBeforeUnmount } from 'vue'
+import { useVideoStore } from '../stores/videoStore.js'
+const props = defineProps({ deviceCode: { type: String, required: true } })
+const video = useVideoStore()
+const device = ref(null), loading = ref(false), error = ref('')
+let generation = 0
+async function load() {
+    const version = ++generation
+    device.value = null; error.value = ''; loading.value = Boolean(props.deviceCode)
+    if (!props.deviceCode) return
+    try {
+        const result = await video.loadDevice(props.deviceCode)
+        if (version === generation) device.value = result
+    } catch (failure) { if (version === generation) error.value = failure.message || '视频能力暂不可用' }
+    finally { if (version === generation) loading.value = false }
+}
+watch(() => props.deviceCode, load, { immediate: true })
+onBeforeUnmount(() => { generation += 1 })
+</script>
+<style scoped>
+.video-entry { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; padding: 10px 0 14px; }
+.video-entry button { color: #c6f7ff; background: #0b5264; border: 1px solid #39879a; border-radius: 4px; padding: 6px 16px; cursor: pointer; }
+.video-entry button:disabled { color: #82949c; background: #243841; border-color: #45616a; cursor: not-allowed; }
+.video-entry__hint { margin: 0; color: #a7bdc7; font-size: 0.85em; }
+</style>

+ 86 - 0
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -0,0 +1,86 @@
+<template>
+    <Teleport to="body">
+        <div v-if="state.open" class="video-monitor-backdrop">
+            <section class="video-monitor" role="dialog" aria-modal="true" aria-labelledby="video-monitor-title" data-video-monitor>
+                <header>
+                    <div><h2 id="video-monitor-title">{{ state.device?.deviceName || '视频监控设备' }}</h2><p>{{ state.device?.deviceCode }}</p></div>
+                    <button type="button" aria-label="关闭监控" @click="video.close">关闭</button>
+                </header>
+                <div class="video-monitor__tabs"><strong>实时视频</strong><button type="button" disabled title="历史录像能力尚未接入">历史录像</button></div>
+                <p class="video-monitor__status" role="status">{{ labels[state.phase] || '未播放' }}</p>
+                <div class="video-monitor__body">
+                    <aside><h3>云台控制</h3><button type="button" disabled title="停止保障尚未验证,不提供物理控制">方向/变倍</button><p>能力尚未开放</p></aside>
+                    <div class="video-monitor__screen">
+                        <VideoPlayer v-if="state.media" :media="state.media" :options="playerOptions" @ready="video.ready" @error="video.fail" />
+                        <p v-else>{{ state.error || '等待视频连接' }}</p>
+                    </div>
+                </div>
+                <p v-if="state.error && state.media" role="alert">{{ state.error }}</p>
+                <p v-if="state.cleanupError" class="video-monitor__warning" role="alert">{{ state.cleanupError }}</p>
+                <footer>
+                    <button v-if="!state.media && !['starting','connecting'].includes(state.phase)" type="button" :disabled="Boolean(state.cleanupError)" @click="video.retry">重新播放</button>
+                    <button v-if="state.cleanupError" type="button" @click="video.retryCleanup">重试清理</button>
+                    <span>默认静音 · 不提供截图、下载或录制</span>
+                </footer>
+            </section>
+        </div>
+    </Teleport>
+</template>
+<script setup>
+import { watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
+import { storeToRefs } from 'pinia'
+import { useVideoStore } from '../stores/videoStore.js'
+import { useUiStore } from '../stores/uiStore.js'
+import { useDetailStore } from '../stores/detailStore.js'
+import { useEnhancementDevicesStore } from '../stores/enhancementDevicesStore.js'
+import VideoPlayer from './VideoPlayer.vue'
+const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enhancement = useEnhancementDevicesStore()
+const { state, playerOptions } = storeToRefs(video)
+const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', failed: '播放失败' }
+watch(() => ui.mode, () => { void video.close() })
+watch(() => [detail.selection.deviceCode, enhancement.selected?.code], (codes) => {
+    if (state.value.open && !codes.includes(state.value.device?.deviceCode)) void video.close()
+})
+let trigger
+watch(() => state.value.open, async (open) => {
+    if (!open) { if (trigger?.isConnected) trigger.focus?.(); return }
+    trigger = document.activeElement
+    await nextTick()
+    if (state.value.open) document.querySelector('[data-video-monitor] [aria-label="关闭监控"]')?.focus()
+})
+const keyboard = (event) => {
+    if (!state.value.open) return
+    if (event.key === 'Escape') { event.preventDefault(); void video.close(); return }
+    if (event.key !== 'Tab') return
+    const items = Array.from(document.querySelectorAll('[data-video-monitor] button:not(:disabled), [data-video-monitor] video'))
+    if (!items.length) return
+    const index = items.indexOf(document.activeElement)
+    if (index < 0 || (event.shiftKey && index === 0) || (!event.shiftKey && index === items.length - 1)) {
+        event.preventDefault(); items[event.shiftKey ? items.length - 1 : 0].focus?.()
+    }
+}
+const hidden = () => { if (document.hidden) void video.close() }
+const leave = () => { void video.close() }
+onMounted(() => { document.addEventListener('visibilitychange', hidden); document.addEventListener('keydown', keyboard); window.addEventListener('pagehide', leave) })
+onBeforeUnmount(() => { document.removeEventListener('visibilitychange', hidden); document.removeEventListener('keydown', keyboard); window.removeEventListener('pagehide', leave); void video.close() })
+</script>
+<style scoped>
+.video-monitor-backdrop { position: fixed; inset: 0; z-index: 15000; display: grid; place-items: center; padding: 24px; background: #0009; }
+.video-monitor { width: min(76vw, 1400px); max-height: 92vh; overflow: auto; color: #e0f6ff; background: #092635f5; border: 1px solid #3a91a5; border-radius: 8px; padding: 20px; box-shadow: 0 20px 90px #0009; font-family: sans-serif; }
+.video-monitor header { display: flex; justify-content: space-between; gap: 20px; align-items: start; }
+.video-monitor h2, .video-monitor h3 { margin: 0 0 8px; }
+.video-monitor header p { margin: 0; color: #9dbbc8; overflow-wrap: anywhere; }
+.video-monitor button { padding: 7px 18px; color: #d8f8ff; background: #14556b; border: 1px solid #448ba0; border-radius: 4px; cursor: pointer; }
+.video-monitor button:disabled { color: #8399a3; background: #263c46; border-color: #49616b; cursor: not-allowed; }
+.video-monitor__tabs { display: flex; gap: 20px; align-items: center; margin-top: 18px; }
+.video-monitor__status { color: #a3d5e3; }
+.video-monitor__body { display: grid; grid-template-columns: 150px minmax(0, 1fr); gap: 18px; }
+.video-monitor aside { color: #a7c3d0; background: #09212c; padding: 15px; }
+.video-monitor aside p, .video-monitor footer { font-size: 13px; color: #9cb9c5; }
+.video-monitor__screen { min-height: 300px; aspect-ratio: 16 / 9; display: grid; place-items: center; background: #02090e; }
+.video-monitor__screen > :deep(*) { width: 100%; height: 100%; }
+.video-monitor__screen > p { display: grid; place-items: center; margin: 0; }
+.video-monitor__warning { color: #ffc478; }
+.video-monitor footer { display: flex; gap: 16px; align-items: center; margin-top: 15px; }
+@media (max-width: 720px) { .video-monitor { width: 100%; } .video-monitor__body { grid-template-columns: 1fr; } .video-monitor aside { display: none; } }
+</style>

+ 44 - 0
src/modules/cockpit/components/VideoPlayer.vue

@@ -0,0 +1,44 @@
+<template>
+    <video
+        ref="video"
+        class="cockpit-video-player"
+        aria-label="实时视频画面"
+        controls autoplay muted playsinline
+        controlslist="nodownload noremoteplayback noplaybackrate"
+        disablepictureinpicture disableremoteplayback
+        @contextmenu.prevent
+    />
+</template>
+
+<script setup>
+import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue'
+import { createVideoPlayer } from '../services/videoPlayer.js'
+
+const props = defineProps({
+    media: { type: Object, default: null },
+    options: { type: Object, default: () => ({}) },
+})
+const emit = defineEmits(['ready', 'error'])
+const video = shallowRef(null)
+let player
+const dispose = () => {
+    player?.destroy()
+    player = null
+}
+const start = () => {
+    dispose()
+    if (!video.value || !props.media) return
+    player = createVideoPlayer(video.value, props.media, {
+        ...props.options,
+        onReady: () => emit('ready'),
+        onError: (error) => emit('error', error),
+    })
+}
+onMounted(start)
+watch(() => props.media, start, { flush: 'post' })
+onBeforeUnmount(dispose)
+</script>
+
+<style scoped>
+.cockpit-video-player { display: block; width: 100%; height: 100%; min-height: 260px; object-fit: contain; background: #060d16; }
+</style>

+ 77 - 0
src/modules/cockpit/services/videoApi.js

@@ -0,0 +1,77 @@
+import { getCockpitConfig } from './cockpitConfig.js'
+import { planCockpitRequest } from './cockpitApi.js'
+
+export class VideoRequestError extends Error {
+    constructor(category, message, uncertain = false) { super(message); this.category = category; this.uncertain = uncertain }
+}
+const messages = {
+    INVALID_REQUEST: '视频请求参数不正确', DEVICE_NOT_FOUND: '视频设备不存在', VIDEO_UNAVAILABLE: '此设备暂不支持实时监控',
+    SESSION_FORBIDDEN: '播放会话不存在或无权操作', SESSION_EXPIRED: '播放会话已失效', VIDEO_START_FAILED: '视频连接失败,请稍后重试',
+    VIDEO_READ_FAILED: '视频能力暂不可用', SESSION_HEARTBEAT_FAILED: '播放会话维护失败', CLEANUP_UNCONFIRMED: '会话释放未确认,等待服务端回收',
+}
+const sessionPath = (id, action) => {
+    if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new VideoRequestError('INVALID_RESPONSE', '播放会话标识无效')
+    return `/video/sessions/${encodeURIComponent(id)}/${action}`
+}
+
+/** Dedicated video transport: no business login, no mock fallback, credentials only in the owned-session body. */
+export function createVideoApi(options = {}) {
+    const timers = options.timers || globalThis
+    async function request(path, body, timeoutMs = 10000) {
+        const plan = planCockpitRequest(options.config || getCockpitConfig(), path)
+        if (plan.mode !== 'real' || plan.error) throw new VideoRequestError('NOT_CONFIGURED', '请先配置真实视频接口')
+        const controller = new AbortController()
+        let timeout
+        const operation = (async () => {
+            const response = await (options.fetchImpl || globalThis.fetch)(plan.url, {
+                method: body === undefined ? 'GET' : 'POST', credentials: 'omit', cache: 'no-store', signal: controller.signal,
+                ...(path.endsWith('/stop') ? { keepalive: true } : {}),
+                headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
+                ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+            })
+            let payload
+            try { payload = await response.json() } catch { throw new VideoRequestError('INVALID_RESPONSE', '视频接口返回了无效数据') }
+            if (!response.ok || payload?.code !== 200) {
+                const category = payload?.data?.category
+                const known = typeof category === 'string' && Object.hasOwn(messages, category)
+                const uncertain = path === '/video/live' && (!known || ['VIDEO_START_FAILED', 'CLEANUP_UNCONFIRMED'].includes(category))
+                throw new VideoRequestError(known ? category : 'REQUEST_FAILED', known ? messages[category] : '视频请求失败', uncertain)
+            }
+            if (!payload.data || typeof payload.data !== 'object' || Array.isArray(payload.data)) throw new VideoRequestError('INVALID_RESPONSE', '视频接口载荷不完整')
+            return payload.data
+        })()
+        const deadline = new Promise((_, reject) => { timeout = timers.setTimeout(() => {
+            controller.abort(); reject(new VideoRequestError('REQUEST_TIMEOUT', '视频请求超时,结果未确认', path === '/video/live'))
+        }, timeoutMs) })
+        try { return await Promise.race([operation, deadline]) }
+        catch (error) {
+            if (error instanceof VideoRequestError) {
+                if (path === '/video/live' && error.category === 'INVALID_RESPONSE') error.uncertain = true
+                throw error
+            }
+            throw new VideoRequestError('NETWORK_ERROR', '视频网络连接失败,结果未确认', path === '/video/live')
+        } finally { timers.clearTimeout(timeout) }
+    }
+    const sessionReply = (id, status) => (data) => {
+        if (data.sessionId !== id || data.status !== status) throw new VideoRequestError('INVALID_RESPONSE', '会话维护结果与当前观看不匹配')
+        return data
+    }
+    return {
+        device: (deviceCode) => request(`/video/device?${new URLSearchParams({ deviceCode })}`),
+        start: (deviceCode) => request('/video/live', { deviceCode }, 100000),
+        heartbeat: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'heartbeat'), { sessionToken }).then(sessionReply(sessionId, 'ACTIVE')),
+        stop: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'stop'), { sessionToken }).then(sessionReply(sessionId, 'STOPPED')),
+    }
+}
+
+/** Only the explicitly selected browser-native transports; FLV is not a native video URL. */
+export function selectVideoMedia(media) {
+    const kind = media?.native === true ? 'native' : 'webrtc'
+    const value = kind === 'native' ? media?.url : media?.webrtc
+    let url
+    try { url = new URL(value) } catch { throw new VideoRequestError('UNSUPPORTED_MEDIA', '当前播放方案不支持返回的媒体协议') }
+    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || /\.(?:flv|html?)(?:$)/i.test(url.pathname)) {
+        throw new VideoRequestError('UNSUPPORTED_MEDIA', '媒体地址不是可用的原生播放地址')
+    }
+    return { kind, url: url.href }
+}

+ 91 - 0
src/modules/cockpit/services/videoLiveSession.js

@@ -0,0 +1,91 @@
+/*!
+ * Live-session helper adapted from city-life-line/src/utils/videoLiveSession.js.
+ * Copyright (c) 2018 RuoYi — MIT License.
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ */
+import { selectVideoMedia, VideoRequestError } from './videoApi.js'
+
+/** Adapted from the existing management live-session helper: one owner, generation guard and late release. */
+export function createVideoLiveSession(api, update, timers = globalThis) {
+    let generation = 0, owned = null, pending = false, uncertain = false, heartbeat, readyTimeout, startTimeout, disposed = false
+    const releases = new Set()
+    const clear = () => { timers.clearInterval(heartbeat); timers.clearTimeout(readyTimeout); timers.clearTimeout(startTimeout) }
+    async function release(session) {
+        if (!session) return true
+        releases.add(session)
+        try { await api.stop(session); releases.delete(session); return true }
+        catch { update({ cleanupError: '会话释放未确认,等待服务端回收;可重试清理' }); return false }
+    }
+    async function stop() {
+        ++generation; clear()
+        const session = owned; owned = null
+        update({ media: null, phase: 'idle' })
+        if (pending && !uncertain) update({ cleanupError: '正在等待启动结果并清理,暂不能重复建流' })
+        if (session) releases.add(session)
+        for (const item of [...releases]) await release(item)
+        if (!releases.size && !uncertain && !pending) update({ cleanupError: '' })
+    }
+    async function fail(message) {
+        update({ error: message || '视频播放失败' })
+        const stopping = stop(), version = generation
+        await stopping
+        if (version === generation) update({ phase: 'failed' })
+    }
+    function ready() { timers.clearTimeout(readyTimeout); if (owned && !disposed) update({ phase: 'playing' }) }
+    async function start(deviceCode) {
+        await stop()
+        if (disposed) return
+        if (pending || uncertain || releases.size) {
+            update({ phase: 'failed', error: '上次播放或清理结果尚未确认,暂不能重复建流' }); return
+        }
+        const version = generation
+        update({ phase: 'starting', media: null, error: '', cleanupError: '' })
+        pending = true
+        let startExpired = false
+        startTimeout = timers.setTimeout(() => {
+            if (version !== generation) return
+            startExpired = true; ++generation
+            update({ phase: 'failed', error: '视频建立超时', cleanupError: '启动结果未确认,正在等待结果后清理' })
+        }, 35000)
+        try {
+            const data = await api.start(deviceCode)
+            const session = data.sessionId && data.sessionToken ? { sessionId: data.sessionId, sessionToken: data.sessionToken } : null
+            if (!session) { uncertain = true; throw new VideoRequestError('INVALID_RESPONSE', '媒体服务没有返回完整会话,资源状态未确认', true) }
+            if (disposed || version !== generation || startExpired) { await release(session); return }
+            owned = session
+            if (data.deviceCode !== deviceCode || data.mode !== 'LIVE') throw new VideoRequestError('INVALID_RESPONSE', '返回的播放会话与当前设备不匹配')
+            const media = selectVideoMedia(data.media)
+            update({ phase: 'connecting', media })
+            readyTimeout = timers.setTimeout(() => { if (version === generation) void fail('播放器就绪超时') }, 20000)
+            let beating = false
+            heartbeat = timers.setInterval(async () => {
+                if (beating || version !== generation || !owned) return
+                beating = true
+                try { await api.heartbeat(owned) }
+                catch { if (version === generation) await fail('播放会话已断开,请重新播放') }
+                finally { beating = false }
+            }, 30000)
+        } catch (error) {
+            if (error.uncertain) { uncertain = true; update({ cleanupError: '启动结果未确认,请核查服务端回收;暂不能重复建流' }) }
+            if (version === generation && !disposed) await fail(error.message || '视频连接失败')
+        } finally {
+            pending = false; timers.clearTimeout(startTimeout)
+            if (!releases.size && !uncertain) update({ cleanupError: '' })
+        }
+    }
+    async function dispose() { disposed = true; await stop() }
+    return { start, stop, dispose, ready, fail }
+}

+ 148 - 0
src/modules/cockpit/services/videoPlayer.js

@@ -0,0 +1,148 @@
+function playbackUrl(video, media) {
+    try {
+        if (!['native', 'webrtc'].includes(media?.kind) || typeof media.url !== 'string'
+            || /[\u0000-\u0020\u007f]/.test(media.url)) throw new Error()
+        const url = new URL(media.url)
+        const pageProtocol = video.ownerDocument?.location?.protocol || globalThis.location?.protocol
+        if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password
+            || (pageProtocol === 'https:' && url.protocol !== 'https:')) throw new Error()
+        const path = decodeURIComponent(url.pathname)
+        if (media.kind === 'native' && (/\.flv$/i.test(path) || /\/(?:index\/api\/webrtc|rtc\/v1\/play)\/?$/i.test(path))) throw new Error()
+        return url.href
+    } catch {
+        throw Object.assign(new Error('视频地址或播放协议不可用'), { code: 'VIDEO_URL_INVALID' })
+    }
+}
+
+/** 仅管理浏览器本地播放资源;匿名观看会话由上层维护和释放。 */
+export function createVideoPlayer(video, media, options = {}) {
+    let destroyed = false
+    let ready = false
+    let attached = false
+    let peer
+    let cancelIce
+    const listeners = []
+    const abort = new AbortController()
+    const timers = options.timers || globalThis
+    const listen = (target, type, callback) => {
+        target.addEventListener(type, callback)
+        listeners.push(() => target.removeEventListener(type, callback))
+    }
+    const destroy = () => {
+        if (destroyed) return
+        destroyed = true
+        abort.abort()
+        cancelIce?.()
+        listeners.splice(0).forEach((remove) => remove())
+        const tracks = new Set([
+            ...(video.srcObject?.getTracks?.() || []),
+            ...(peer?.getReceivers?.().map((receiver) => receiver.track).filter(Boolean) || []),
+        ])
+        tracks.forEach((track) => track.stop())
+        peer?.close()
+        video.pause()
+        video.srcObject = null
+        video.removeAttribute('src')
+        video.load()
+    }
+    const fail = (message, code = 'VIDEO_PLAYBACK_FAILED') => {
+        if (destroyed) return
+        destroy()
+        options.onError?.(Object.assign(new Error(message), { code }))
+    }
+    const play = () => Promise.resolve(video.play()).catch(() => fail('视频播放失败,请检查浏览器播放能力'))
+    listen(video, 'playing', () => {
+        if (destroyed || ready || !attached) return
+        ready = true
+        options.onReady?.()
+    })
+    listen(video, 'error', () => fail('视频解码或网络异常,请重试'))
+    listen(video, 'ended', () => fail('视频流已结束,请重新播放'))
+    const waitForIce = () => new Promise((resolve) => {
+        const finish = (complete) => {
+            timers.clearTimeout(timeout)
+            peer.removeEventListener('icegatheringstatechange', onState)
+            cancelIce = null
+            resolve(complete)
+        }
+        const onState = () => {
+            if (peer.iceGatheringState === 'complete') finish(true)
+        }
+        const timeout = timers.setTimeout(() => {
+            finish(false)
+            fail('视频连接候选收集超时', 'VIDEO_ICE_TIMEOUT')
+        }, 5000)
+        cancelIce = () => finish(false)
+        peer.addEventListener('icegatheringstatechange', onState)
+        onState()
+    })
+    const start = async () => {
+        if (destroyed) return
+        const url = playbackUrl(video, media)
+        video.muted = true
+        video.playsInline = true
+        if (media.kind === 'native') {
+            attached = true
+            video.src = url
+            await play()
+            return
+        }
+        const PeerConnection = options.PeerConnection || globalThis.RTCPeerConnection
+        const fetchImpl = options.fetchImpl || globalThis.fetch
+        if (!PeerConnection || !fetchImpl) {
+            fail('当前浏览器不支持实时视频连接', 'VIDEO_UNSUPPORTED')
+            return
+        }
+        peer = new PeerConnection({ iceServers: [] })
+        const onConnectionState = () => {
+            if ([peer.connectionState, peer.iceConnectionState].some((state) => ['failed', 'disconnected', 'closed'].includes(state))) {
+                fail('实时视频连接已断开', 'VIDEO_CONNECTION_FAILED')
+            }
+        }
+        listen(peer, 'connectionstatechange', onConnectionState)
+        listen(peer, 'iceconnectionstatechange', onConnectionState)
+        peer.addTransceiver('video', { direction: 'recvonly' })
+        peer.addTransceiver('audio', { direction: 'recvonly' })
+        listen(peer, 'track', (event) => {
+            if (destroyed) return
+            try {
+                if (event.track.kind === 'video') listen(event.track, 'ended', () => fail('实时视频轨道已结束'))
+                const Stream = video.ownerDocument?.defaultView?.MediaStream || globalThis.MediaStream
+                const stream = video.srcObject || event.streams?.[0] || new Stream()
+                if (!stream.getTracks().includes(event.track)) stream.addTrack(event.track)
+                video.srcObject = stream
+                attached = stream.getTracks().some((track) => track.kind === 'video' && track.readyState !== 'ended')
+                play()
+            } catch {
+                fail('视频轨道无法播放')
+            }
+        })
+        const offer = await peer.createOffer()
+        if (destroyed) return
+        await peer.setLocalDescription(offer)
+        if (destroyed || !await waitForIce()) return
+        const response = await fetchImpl(url, {
+            method: 'POST', headers: { 'Content-Type': 'text/plain' },
+            body: peer.localDescription.sdp, signal: abort.signal,
+            credentials: 'omit', redirect: 'error',
+        })
+        if (destroyed) return
+        if (!response.ok || !/^application\/(?:json|[\w.-]+\+json)(?:;|$)/i.test(response.headers.get('content-type') || '')) {
+            fail('视频信令响应无效', 'VIDEO_SIGNALING_FAILED')
+            return
+        }
+        const answer = await response.json()
+        if (destroyed) return
+        if (answer.code !== 0 || answer.type !== 'answer' || typeof answer.sdp !== 'string'
+            || !/^v=0\r?\n/.test(answer.sdp) || !/^m=video /m.test(answer.sdp)) {
+            fail('视频信令应答无效', 'VIDEO_SIGNALING_FAILED')
+            return
+        }
+        await peer.setRemoteDescription({ type: 'answer', sdp: answer.sdp })
+    }
+    Promise.resolve().then(start).catch((error) => {
+        if (error?.code === 'VIDEO_URL_INVALID') fail(error.message, error.code)
+        else fail('视频连接失败,请检查媒体服务与浏览器能力', 'VIDEO_SIGNALING_FAILED')
+    })
+    return { destroy }
+}

+ 38 - 0
src/modules/cockpit/stores/videoStore.js

@@ -0,0 +1,38 @@
+import { shallowRef } from 'vue'
+import { defineStore } from 'pinia'
+import { createVideoApi, VideoRequestError } from '../services/videoApi.js'
+import { createVideoLiveSession } from '../services/videoLiveSession.js'
+
+export const useVideoStore = defineStore('cockpitVideo', () => {
+    const state = shallowRef({ open: false, device: null, phase: 'idle', media: null, error: '', cleanupError: '' })
+    const playerOptions = shallowRef({})
+    let options = {}, controller
+    const publish = (patch) => { state.value = { ...state.value, ...patch } }
+    function configure(next = {}) {
+        if (controller) throw new Error('请在建立视频会话前配置传输')
+        options = next
+        playerOptions.value = next.playerOptions || {}
+    }
+    const session = () => controller || (controller = createVideoLiveSession(createVideoApi(options), publish, options.timers))
+    async function loadDevice(deviceCode) {
+        const data = await createVideoApi(options).device(deviceCode)
+        if (data.deviceCode !== deviceCode || typeof data.typeCode !== 'string' || typeof data.capabilities?.live !== 'boolean') {
+            throw new VideoRequestError('INVALID_RESPONSE', '视频能力载荷与当前设备不匹配')
+        }
+        return data
+    }
+    async function open(device) {
+        if (state.value.open && state.value.device?.deviceCode === device?.deviceCode) return
+        publish({ open: true, device, error: '' })
+        if (device?.typeCode !== '20' || device.bound !== true || device.capabilities?.live !== true) {
+            publish({ phase: 'failed', error: '当前设备未确认实时视频能力' }); return
+        }
+        await session().start(device.deviceCode)
+    }
+    async function close() { publish({ open: false }); if (controller) await controller.stop() }
+    async function retry() { if (state.value.device) await session().start(state.value.device.deviceCode) }
+    async function retryCleanup() { if (controller) await controller.stop() }
+    const ready = () => controller?.ready()
+    const fail = (error) => controller?.fail(typeof error === 'string' ? error : error?.message || '视频播放失败')
+    return { state, playerOptions, configure, loadDevice, open, close, retry, retryCleanup, ready, fail }
+})

+ 3 - 1
src/pages/cockpit/index.vue

@@ -1,10 +1,12 @@
 <!--
-  /cockpit 入口。保持薄:只挂载外壳,业务都在 src/modules/cockpit(ADR 0006/0007)。
+  /cockpit 入口。保持薄:挂载外壳与唯一共享监控面板,业务都在 src/modules/cockpit(ADR 0006/0007)。
 -->
 <template>
     <CockpitShell />
+    <VideoMonitorPanel />
 </template>
 
 <script setup>
 import CockpitShell from '@/modules/cockpit/CockpitShell.vue'
+import VideoMonitorPanel from '@/modules/cockpit/components/VideoMonitorPanel.vue'
 </script>

+ 295 - 0
tests/cockpit-video/journey.mjs

@@ -0,0 +1,295 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { createApp, h, nextTick } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+import PointDetailCard from '../../src/modules/cockpit/components/PointDetailCard.vue'
+import VideoMonitorPanel from '../../src/modules/cockpit/components/VideoMonitorPanel.vue'
+import { useDetailStore } from '../../src/modules/cockpit/stores/detailStore.js'
+import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js'
+
+const code = 'CAM/01 空格'
+const device = { deviceCode: code, deviceName: '现场摄像机A', typeCode: '20', bound: true,
+    capabilities: { live: true, records: false, playback: false, playbackControl: false, ptz: false, ptzStopGuaranteed: false }, unavailableReason: null }
+const envelope = (data, status = 200) => Response.json({ code: status, msg: status === 200 ? '操作成功' : '失败', data }, { status })
+const aggregate = { deviceCode: code, aggregateState: 'READY',
+    archive: { state: 'SUCCESS', data: { deviceCode: code, deviceName: '现场摄像机A', deviceType: '20' } },
+    metrics: { state: 'EMPTY', data: null }, warnings: { state: 'EMPTY', data: [] }, alarms: { state: 'EMPTY', data: [] },
+    workOrders: { state: 'EMPTY', data: null }, timelines: { state: 'EMPTY', data: null }, latestImage: { state: 'EMPTY', data: null } }
+async function settle() { for (let i = 0; i < 4; i++) { await new Promise(setImmediate); await nextTick() } }
+
+async function mountJourney(overrides = {}) {
+    const requests = []
+    const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1', token: 'must-not-send' } }
+    const fetchImpl = async (url, options = {}) => {
+        const path = new URL(url).pathname
+        const body = options.body ? JSON.parse(options.body) : null
+        requests.push({ url: new URL(url), path, options, body })
+        assert.equal(options.headers?.Authorization, undefined)
+        if (path.endsWith('/detail-aggregate')) return envelope(aggregate)
+        if (path.endsWith('/collection-records')) return overrides.records?.() || envelope({ deviceCode: code, state: 'EMPTY', recordView: { columns: [], rows: [] } })
+        if (path.endsWith('/video/device')) {
+            assert.equal(new URL(url).searchParams.get('deviceCode'), code)
+            return envelope(overrides.device || device)
+        }
+        if (path.endsWith('/video/live')) {
+            assert.deepEqual(body, { deviceCode: code })
+            return overrides.live?.() || envelope({ deviceCode: code, deviceName: device.deviceName,
+                sessionId: 'session-a', sessionToken: 'owner-a', mode: 'LIVE', media: { native: true, url: 'https://media.invalid/live.mp4' } })
+        }
+        if (path.endsWith('/heartbeat')) return overrides.heartbeat?.() || envelope({ sessionId: 'session-a', status: 'ACTIVE' })
+        if (path.endsWith('/stop')) return overrides.stop?.() || envelope({ sessionId: 'session-a', status: 'STOPPED' })
+        throw new Error('Unexpected fixture request: ' + path)
+    }
+    const pinia = createPinia(); setActivePinia(pinia)
+    const detail = useDetailStore(); detail.configure({ config, fetchImpl })
+    const video = useVideoStore(); video.configure({ config, fetchImpl, ...(overrides.sessionOptions || {}) })
+    await detail.openDevice(code)
+    const host = document.createElement('main'); document.body.appendChild(host)
+    const app = createApp({ render: () => h('div', [h(PointDetailCard), h(VideoMonitorPanel)]) })
+    app.use(pinia).mount(host)
+    await settle()
+    return { host, requests, video, detail, async dispose() { app.unmount(); await video.close(); host.remove() } }
+}
+
+const livePayload = (media) => ({ deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', sessionToken: 'owner-a', mode: 'LIVE', media })
+async function openMonitor(page) {
+    page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
+    page.host.querySelector('[data-video-open]').click(); await settle()
+    return document.querySelector('[data-video-monitor]')
+}
+test('a successful HTTP envelope for another session cannot confirm cleanup of the current viewer', async () => {
+    let wrong = true
+    const page = await mountJourney({ stop: () => envelope({ sessionId: wrong ? 'someone-else' : 'session-a', status: 'STOPPED' }) })
+    try {
+        await openMonitor(page)
+        document.querySelector('[aria-label="关闭监控"]').click(); await settle()
+        assert.ok(page.host.querySelector('[data-video-entry]').textContent.includes('会话释放未确认'))
+        assert.equal(page.host.querySelector('[data-video-open]').disabled, true)
+    } finally { wrong = false; await page.dispose() }
+})
+
+class TestClock {
+    now = 0; next = 1; jobs = new Map()
+    setTimeout(fn, delay) { const id = this.next++; this.jobs.set(id, { fn, at: this.now + delay }); return id }
+    clearTimeout(id) { this.jobs.delete(id) }
+    setInterval(fn, delay) { const id = this.setTimeout(fn, delay); this.jobs.get(id).interval = delay; return id }
+    clearInterval(id) { this.clearTimeout(id) }
+    async advance(duration) {
+        const end = this.now + duration
+        while (true) {
+            const due = Array.from(this.jobs).filter(([, job]) => job.at <= end).sort((a, b) => a[1].at - b[1].at)[0]
+            if (!due) break
+            const [id, job] = due; this.now = job.at
+            if (job.interval) job.at += job.interval; else this.jobs.delete(id)
+            job.fn(); await settle()
+        }
+        this.now = end; await settle()
+    }
+}
+
+test('watching heartbeat preserves ownership but never establishes media readiness; failure releases the viewer', async () => {
+    const clock = new TestClock(); let wrong = false
+    const page = await mountJourney({ sessionOptions: { timers: clock }, heartbeat: () => envelope({ sessionId: wrong ? 'other' : 'session-a', status: 'ACTIVE' }) })
+    try {
+        const dialog = await openMonitor(page)
+        dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
+        await clock.advance(30000)
+        const beat = page.requests.find((request) => request.path.endsWith('/heartbeat'))
+        assert.deepEqual(beat.body, { sessionToken: 'owner-a' })
+        wrong = true
+        await clock.advance(30000)
+        assert.equal(dialog.querySelector('video'), null)
+        assert.ok(dialog.textContent.includes('播放会话已断开'))
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { await page.dispose() }
+})
+
+test('Escape closes the global modal and releases its viewing resource', async () => {
+    const page = await mountJourney()
+    try {
+        await openMonitor(page)
+        const key = new Event('keydown', { bubbles: true, cancelable: true }); key.key = 'Escape'
+        document.dispatchEvent(key); await settle()
+        assert.equal(document.querySelector('[data-video-monitor]'), null)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { await page.dispose() }
+})
+
+test('collection-record failure does not remove the latest-tab monitoring entry', async () => {
+    const page = await mountJourney({ records: () => envelope({}, 503) })
+    try {
+        const dialog = await openMonitor(page)
+        assert.ok(dialog.querySelector('video'))
+        assert.ok(page.host.textContent.includes('其它分区不受影响'))
+    } finally { await page.dispose() }
+})
+
+test('non-camera has no monitor button; unbound and unknown live capability stay disabled without starting', async () => {
+    for (const camera of [
+        { ...device, typeCode: '15', bound: false, capabilities: { live: false } },
+        { ...device, bound: false, capabilities: { live: false }, unavailableReason: '尚未绑定视频' },
+        { ...device, bound: true, capabilities: { live: false }, unavailableReason: '实时能力未确认' },
+    ]) {
+        const page = await mountJourney({ device: camera })
+        try {
+            page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
+            const button = page.host.querySelector('[data-video-open]')
+            if (camera.typeCode !== '20') assert.equal(button, null)
+            else { assert.equal(button.disabled, true); assert.ok(page.host.textContent.includes(camera.unavailableReason)) }
+            assert.equal(page.requests.some((request) => request.path.endsWith('/video/live')), false)
+        } finally { await page.dispose() }
+    }
+})
+
+test('unsupported returned media releases its owned session and never presents playing', async () => {
+    const page = await mountJourney({ live: () => envelope(livePayload({ native: false, flv: 'https://media.invalid/live.flv' })) })
+    try {
+        const dialog = await openMonitor(page)
+        assert.equal(dialog.querySelector('video'), null)
+        assert.ok(dialog.textContent.includes('不支持'))
+        assert.equal(dialog.textContent.includes('正在播放'), false)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { await page.dispose() }
+})
+
+test('HTTP 200 HTML is a visible failure with unconfirmed resources, not an active video', async () => {
+    const page = await mountJourney({ live: () => new Response('<html>wrong route</html>', { headers: { 'Content-Type': 'text/html' } }) })
+    try {
+        const dialog = await openMonitor(page)
+        assert.equal(dialog.querySelector('video'), null)
+        assert.ok(dialog.textContent.includes('无效数据'))
+        assert.ok(dialog.textContent.includes('未确认'))
+        assert.equal(Array.from(dialog.querySelectorAll('button')).find((button) => button.textContent === '重新播放').disabled, true)
+    } finally { await page.dispose() }
+})
+
+test('a live start exceeding 35 seconds exits visibly and releases a later owned response without opening video', async () => {
+    const clock = new TestClock(); let respond
+    const delayed = new Promise((resolve) => { respond = resolve })
+    const page = await mountJourney({ live: () => delayed, sessionOptions: { timers: clock } })
+    try {
+        const dialog = await openMonitor(page)
+        await clock.advance(35000)
+        assert.ok(dialog.textContent.includes('视频建立超时'))
+        assert.equal(dialog.querySelector('video'), null)
+        respond(envelope(livePayload({ native: true, url: 'https://media.invalid/live.mp4' })))
+        await settle()
+        assert.equal(dialog.querySelector('video'), null)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/video/live')).length, 1)
+    } finally { await page.dispose() }
+})
+
+test('player readiness has a bound independent of HTTP success and cleans its owned session', async () => {
+    const clock = new TestClock()
+    const page = await mountJourney({ sessionOptions: { timers: clock } })
+    try {
+        const dialog = await openMonitor(page)
+        assert.ok(dialog.textContent.includes('等待视频画面'))
+        await clock.advance(20000)
+        assert.ok(dialog.textContent.includes('播放器就绪超时'))
+        assert.equal(dialog.querySelector('video'), null)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { await page.dispose() }
+})
+
+test('hiding the page closes video and does not reopen it when visible again', async () => {
+    const page = await mountJourney()
+    try {
+        await openMonitor(page)
+        Object.defineProperty(document, 'hidden', { configurable: true, value: true })
+        document.dispatchEvent(new Event('visibilitychange')); await settle()
+        assert.equal(document.querySelector('[data-video-monitor]'), null)
+        Object.defineProperty(document, 'hidden', { configurable: true, value: false })
+        document.dispatchEvent(new Event('visibilitychange')); await settle()
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/video/live')).length, 1)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { delete document.hidden; await page.dispose() }
+})
+
+test('latest-tab entry drives WebRTC signaling and only a video playing event establishes visible playback', async () => {
+    const peers = [], signaling = []
+    class Emitter {
+        handlers = new Map()
+        addEventListener(type, fn) { const group = this.handlers.get(type) || new Set(); group.add(fn); this.handlers.set(type, group) }
+        removeEventListener(type, fn) { this.handlers.get(type)?.delete(fn) }
+        emit(type, event = {}) { for (const fn of this.handlers.get(type) || []) fn(event) }
+    }
+    const track = new Emitter(); track.kind = 'video'; track.readyState = 'live'; track.stop = () => { track.readyState = 'ended' }
+    class Peer extends Emitter {
+        iceGatheringState = 'complete'; connectionState = 'new'; closed = false
+        constructor(options) { super(); assert.deepEqual(options, { iceServers: [] }); peers.push(this) }
+        addTransceiver() {}
+        async createOffer() { return { type: 'offer', sdp: 'v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n' } }
+        async setLocalDescription(offer) { this.localDescription = offer }
+        async setRemoteDescription(answer) { this.remoteDescription = answer }
+        getReceivers() { return [{ track }] }
+        close() { this.closed = true }
+    }
+    const page = await mountJourney({
+        live: () => envelope(livePayload({ native: false, webrtc: 'http://media.invalid/index/api/webrtc?app=rtp&stream=cam&type=play' })),
+        sessionOptions: { playerOptions: { PeerConnection: Peer, fetchImpl: async (url, options) => {
+            signaling.push({ url, options }); assert.equal(options.credentials, 'omit')
+            return Response.json({ code: 0, type: 'answer', sdp: 'v=0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\n' })
+        } } },
+    })
+    try {
+        const dialog = await openMonitor(page)
+        assert.equal(signaling.length, 1)
+        assert.ok(signaling[0].options.body.startsWith('v=0'))
+        assert.equal(dialog.textContent.includes('正在播放'), false)
+        const stream = { getTracks: () => [track], addTrack() {} }
+        peers[0].emit('track', { track, streams: [stream] })
+        dialog.querySelector('video').dispatchEvent(new Event('playing')); await settle()
+        assert.ok(dialog.textContent.includes('正在播放'))
+        dialog.querySelector('[aria-label="关闭监控"]').click(); await settle()
+        assert.equal(peers[0].closed, true)
+        assert.equal(track.readyState, 'ended')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+    } finally { await page.dispose() }
+})
+
+test('device detail latest tab opens its own anonymous live video even with no collection records, then closes it', async () => {
+    const page = await mountJourney()
+    try {
+        const latest = page.host.querySelector('[data-equipment-tab="metrics"]')
+        assert.ok(latest, 'existing device-detail latest tab must be visible')
+        latest.click(); await settle()
+        const open = page.host.querySelector('[data-video-open]')
+        assert.ok(open, 'the monitor entry must be inside the latest tab')
+        assert.equal(open.disabled, false)
+        open.click(); await settle()
+        const dialog = document.querySelector('[role="dialog"][data-video-monitor]')
+        assert.ok(dialog)
+        assert.ok(dialog.textContent.includes('现场摄像机A'))
+        assert.ok(dialog.textContent.includes(code))
+        assert.equal(dialog.textContent.includes('owner-a'), false)
+        dialog.querySelector('video').dispatchEvent(new Event('playing'))
+        await settle()
+        assert.ok(dialog.textContent.includes('正在播放'))
+        dialog.querySelector('[aria-label="关闭监控"]').click(); await settle()
+        assert.equal(document.querySelector('[data-video-monitor]'), null)
+        const stops = page.requests.filter((request) => request.path.endsWith('/stop'))
+        assert.equal(stops.length, 1)
+        assert.deepEqual(stops[0].body, { sessionToken: 'owner-a' })
+    } finally { await page.dispose() }
+})
+
+test('a failed cleanup is visible at the entry and blocks another live request until cleanup succeeds', async () => {
+    let cleanupFails = true
+    const page = await mountJourney({ stop: () => cleanupFails
+        ? envelope({ category: 'CLEANUP_UNCONFIRMED' }, 503)
+        : envelope({ sessionId: 'session-a', status: 'STOPPED' }) })
+    try {
+        page.host.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
+        page.host.querySelector('[data-video-open]').click(); await settle()
+        document.querySelector('[aria-label="关闭监控"]').click(); await settle()
+        const entry = page.host.querySelector('[data-video-entry]')
+        assert.ok(entry.textContent.includes('会话释放未确认'))
+        assert.equal(entry.querySelector('[data-video-open]').disabled, true)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/video/live')).length, 1)
+        cleanupFails = false
+        Array.from(entry.querySelectorAll('button')).find((button) => button.textContent === '重试清理').click(); await settle()
+        assert.equal(page.host.querySelector('[data-video-open]').disabled, false)
+    } finally { cleanupFails = false; await page.dispose() }
+})

+ 420 - 0
tests/cockpit-video/player.mjs

@@ -0,0 +1,420 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { readFile } from 'node:fs/promises'
+import { createVideoPlayer } from '../../src/modules/cockpit/services/videoPlayer.js'
+
+const settle = () => new Promise((resolve) => setImmediate(resolve))
+const offerSdp = 'v=0\r\no=- 1 1 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=recvonly\r\n'
+const answerSdp = 'v=0\r\no=- 2 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\nm=video 9 UDP/TLS/RTP/SAVPF 96\r\na=sendonly\r\n'
+const signalingUrl = 'https://media.test/index/api/webrtc?app=rtp&stream=camera%2F01&type=play'
+const answer = () => new Response(JSON.stringify({ code: 0, type: 'answer', sdp: answerSdp }), {
+    headers: { 'Content-Type': 'application/json' },
+})
+
+class Peer extends EventTarget {
+    static instances = []
+    constructor(configuration) {
+        super()
+        this.configuration = configuration
+        this.iceGatheringState = 'new'
+        this.connectionState = 'new'
+        this.receivers = []
+        this.transceivers = []
+        Peer.instances.push(this)
+    }
+    addTransceiver(kind, options) { this.transceivers.push({ kind, ...options }) }
+    async createOffer() { return { type: 'offer', sdp: offerSdp } }
+    async setLocalDescription(description) { this.localDescription = description; this.iceGatheringState = 'gathering' }
+    async setRemoteDescription(description) { this.remoteDescription = description }
+    gather() {
+        this.localDescription = { type: 'offer', sdp: offerSdp + 'a=candidate:1 1 udp 1 192.0.2.1 5000 typ host\r\n' }
+        this.iceGatheringState = 'complete'
+        this.dispatchEvent(new Event('icegatheringstatechange'))
+    }
+    receive(track, streamless = false) {
+        this.receivers.push({ track })
+        const stream = { getTracks: () => [track] }
+        this.dispatchEvent(Object.assign(new Event('track'), { track, streams: streamless ? [] : [stream] }))
+        return stream
+    }
+    getReceivers() { return this.receivers }
+    close() { this.connectionState = 'closed' }
+}
+
+class Clock {
+    now = 0
+    next = 1
+    tasks = new Map()
+    setTimeout(callback, delay) { const id = this.next++; this.tasks.set(id, { at: this.now + delay, callback }); return id }
+    clearTimeout(id) { this.tasks.delete(id) }
+    advance(ms) {
+        const end = this.now + ms
+        while (true) {
+            const entry = [...this.tasks].sort((a, b) => a[1].at - b[1].at).find(([, item]) => item.at <= end)
+            if (!entry) break
+            const [id, item] = entry
+            this.tasks.delete(id); this.now = item.at; item.callback()
+        }
+        this.now = end
+    }
+}
+
+class Track extends EventTarget {
+    kind = 'video'
+    readyState = 'live'
+    stop() { this.readyState = 'ended' }
+}
+
+
+class Video extends EventTarget {
+    constructor(protocol = 'https:') {
+        super()
+        this.ownerDocument = { location: { protocol } }
+        this.attributes = new Map()
+        this.srcObject = null
+        this.paused = true
+        this.loads = 0
+    }
+    set src(value) { this.setAttribute('src', value) }
+    get src() { return this.getAttribute('src') || '' }
+    setAttribute(name, value) { this.attributes.set(name, String(value)) }
+    getAttribute(name) { return this.attributes.get(name) ?? null }
+    removeAttribute(name) { this.attributes.delete(name) }
+    play() { this.paused = false; return Promise.resolve() }
+    pause() { this.paused = true }
+    load() { this.loads++ }
+    fire(type) { this.dispatchEvent(new Event(type)) }
+}
+
+// 已确认接缝:真实driver + HTMLMediaElement/Peer/HTTP/时钟的外部边界替身。
+// 只观察媒体元素、回调、信令合同与本地资源释放,不读取driver私有状态。
+test('原生视频仅在playing后就绪,销毁时清空媒体且不再报告迟到事件', async () => {
+    const video = new Video()
+    const ready = [], errors = []
+    const player = createVideoPlayer(video, { kind: 'native', url: 'https://media.test/live.mp4' }, {
+        onReady: () => ready.push('画面就绪'), onError: (error) => errors.push(error),
+    })
+    await settle()
+    assert.equal(video.src, 'https://media.test/live.mp4')
+    assert.equal(video.muted, true)
+    video.fire('loadedmetadata')
+    assert.deepEqual(ready, [])
+    video.fire('playing')
+    assert.deepEqual(ready, ['画面就绪'])
+    player.destroy()
+    player.destroy()
+    assert.equal(video.src, '')
+    assert.equal(video.srcObject, null)
+    assert.equal(video.paused, true)
+    video.fire('playing')
+    video.fire('error')
+    assert.deepEqual(ready, ['画面就绪'])
+    assert.deepEqual(errors, [])
+})
+
+test('协议、凭据、混合内容和非原生媒体地址在触达媒体前被拒绝', async () => {
+    const invalid = [
+        { kind: 'native', url: 'javascript:alert(1)' },
+        { kind: 'native', url: 'webrtc://media.test/live' },
+        { kind: 'native', url: '/live.mp4' },
+        { kind: 'native', url: 'https://user:secret@media.test/live.mp4' },
+        { kind: 'native', url: 'http://media.test/live.mp4' },
+        { kind: 'native', url: 'https://media.test/live.flv?token=secret' },
+        { kind: 'native', url: 'https://media.test/index/api/webrtc?app=live&type=play' },
+        { kind: 'unknown', url: 'https://media.test/live.mp4' },
+    ]
+    for (const media of invalid) {
+        const video = new Video(), errors = []
+        const player = createVideoPlayer(video, media, { onError: (error) => errors.push(error) })
+        await settle()
+        assert.equal(video.src, '')
+        assert.equal(errors.length, 1)
+        assert.equal(errors[0].code, 'VIDEO_URL_INVALID')
+        assert.doesNotMatch(errors[0].message, /secret|media\.test/)
+        player.destroy()
+    }
+})
+
+test('WebRTC收集ICE后发送实际ZLM SDP,接收轨道且playing才就绪', async () => {
+    const video = new Video(), requests = [], ready = [], errors = []
+    const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
+        PeerConnection: Peer,
+        fetchImpl: async (url, init) => { requests.push({ url, ...init }); return answer() },
+        onReady: () => ready.push('已播放'), onError: (error) => errors.push(error),
+    })
+    await settle()
+    const peer = Peer.instances.at(-1)
+    assert.ok(peer, '通过浏览器Peer边界建立连接')
+    assert.deepEqual(peer.configuration?.iceServers || [], [])
+    assert.deepEqual(peer.transceivers, [{ kind: 'video', direction: 'recvonly' }, { kind: 'audio', direction: 'recvonly' }])
+    assert.equal(requests.length, 0, '不提交尚未收集完的offer')
+    assert.equal(video.src, '', '信令地址不得进入video src')
+    peer.gather()
+    await settle()
+    assert.equal(requests.length, 1)
+    assert.equal(requests[0].url, signalingUrl)
+    assert.equal(requests[0].method, 'POST')
+    assert.equal(new Headers(requests[0].headers).get('content-type'), 'text/plain')
+    assert.equal(new Headers(requests[0].headers).get('authorization'), null)
+    assert.equal(requests[0].credentials, 'omit')
+    assert.equal(requests[0].redirect, 'error')
+    assert.match(requests[0].body, /a=candidate:1/)
+    assert.deepEqual(peer.remoteDescription, { type: 'answer', sdp: answerSdp })
+    const track = new Track(), stream = peer.receive(track)
+    await settle()
+    assert.equal(video.srcObject, stream)
+    assert.deepEqual(ready, [])
+    video.fire('playing')
+    assert.deepEqual(ready, ['已播放'])
+    player.destroy()
+    assert.equal(peer.connectionState, 'closed')
+    assert.equal(track.readyState, 'ended')
+    assert.equal(video.srcObject, null)
+    assert.deepEqual(errors, [])
+    assert.equal(requests.length, 1, '本地销毁不调用后端stop或重连')
+})
+
+test('ICE收集最多等待5秒,超时关闭Peer且不发出不完整信令', async () => {
+    const video = new Video(), clock = new Clock(), errors = [], requests = []
+    const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
+        PeerConnection: Peer, timers: clock,
+        fetchImpl: async (...args) => { requests.push(args); return answer() },
+        onError: (error) => errors.push(error),
+    })
+    await settle()
+    const peer = Peer.instances.at(-1)
+    clock.advance(4999)
+    assert.equal(errors.length, 0)
+    clock.advance(1)
+    await settle()
+    assert.equal(errors.length, 1)
+    assert.equal(errors[0].code, 'VIDEO_ICE_TIMEOUT')
+    assert.equal(peer.connectionState, 'closed')
+    assert.deepEqual(requests, [])
+    player.destroy()
+})
+
+test('媒体错误、轨道结束与Peer断流会释放本地资源并只报一次失败', async () => {
+    for (const failure of ['video-error', 'video-ended', 'track-ended', 'peer-failed', 'peer-disconnected', 'ice-failed']) {
+        const video = new Video(), errors = [], ready = [], requests = []
+        const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
+            PeerConnection: Peer,
+            fetchImpl: async (...args) => { requests.push(args); return answer() },
+            onError: (error) => errors.push(error), onReady: () => ready.push('已播放'),
+        })
+        await settle()
+        const peer = Peer.instances.at(-1)
+        peer.gather(); await settle()
+        const track = new Track()
+        peer.receive(track); await settle()
+        video.fire('playing')
+        if (failure.startsWith('video-')) video.fire(failure.slice(6))
+        if (failure === 'track-ended') track.dispatchEvent(new Event('ended'))
+        if (failure.startsWith('peer-')) {
+            peer.connectionState = failure.slice(5)
+            peer.dispatchEvent(new Event('connectionstatechange'))
+        }
+        if (failure === 'ice-failed') {
+            peer.iceConnectionState = 'failed'
+            peer.dispatchEvent(new Event('iceconnectionstatechange'))
+        }
+        await settle()
+        assert.equal(errors.length, 1, failure)
+        assert.equal(peer.connectionState, 'closed', failure)
+        assert.equal(track.readyState, 'ended', failure)
+        assert.equal(video.srcObject, null, failure)
+        video.fire('error'); video.fire('playing')
+        assert.equal(errors.length, 1)
+        assert.deepEqual(ready, ['已播放'])
+        assert.equal(requests.length, 1)
+        player.destroy()
+    }
+})
+
+test('销毁中的ICE和迟到信令不会再挂轨道、报就绪或请求后端停止', async () => {
+    const clock = new Clock(), errors = [], requests = []
+    const first = createVideoPlayer(new Video(), { kind: 'webrtc', url: signalingUrl }, {
+        PeerConnection: Peer, timers: clock, onError: (error) => errors.push(error),
+        fetchImpl: async (...args) => { requests.push(args); return answer() },
+    })
+    await settle()
+    const waiting = Peer.instances.at(-1)
+    first.destroy(); clock.advance(10000); waiting.gather(); await settle()
+    assert.equal(waiting.connectionState, 'closed')
+    assert.equal(requests.length, 0)
+    assert.equal(errors.length, 0)
+
+    const video = new Video(), ready = []
+    let resolveResponse
+    const response = new Promise((resolve) => { resolveResponse = resolve })
+    const second = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
+        PeerConnection: Peer, onReady: () => ready.push(true), onError: (error) => errors.push(error),
+        fetchImpl: (url, init) => { requests.push({ url, ...init }); return response },
+    })
+    await settle()
+    const peer = Peer.instances.at(-1)
+    peer.gather(); await settle()
+    assert.equal(requests.length, 1)
+    second.destroy()
+    assert.equal(requests[0].signal.aborted, true)
+    resolveResponse(answer()); await settle()
+    assert.equal(peer.remoteDescription, undefined)
+    assert.equal(video.srcObject, null)
+    video.fire('playing')
+    assert.deepEqual(ready, [])
+    assert.deepEqual(errors, [])
+    assert.equal(requests.length, 1)
+})
+
+test('HTML、非成功信令与非法answer不被视作可播放媒体', async () => {
+    const responses = [
+        () => new Response('<html>secret</html>', { headers: { 'Content-Type': 'text/html' } }),
+        () => new Response('upstream secret', { status: 502 }),
+        () => new Response(JSON.stringify({ code: 1, msg: 'secret' }), { headers: { 'Content-Type': 'application/json' } }),
+        () => new Response(JSON.stringify({ code: 0, type: 'offer', sdp: answerSdp }), { headers: { 'Content-Type': 'application/json' } }),
+        () => new Response(JSON.stringify({ code: 0, type: 'answer', sdp: '<html>secret</html>' }), { headers: { 'Content-Type': 'application/json' } }),
+        () => new Response('null', { headers: { 'Content-Type': 'application/json' } }),
+        () => { throw new Error('upstream secret at https://media.test/token') },
+    ]
+    for (const response of responses) {
+        const video = new Video(), errors = [], ready = []
+        const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
+            PeerConnection: Peer, fetchImpl: async () => response(),
+            onError: (error) => errors.push(error), onReady: () => ready.push(true),
+        })
+        await settle()
+        const peer = Peer.instances.at(-1)
+        peer.gather(); await settle()
+        assert.equal(errors.length, 1)
+        assert.equal(errors[0].code, 'VIDEO_SIGNALING_FAILED')
+        assert.doesNotMatch(errors[0].message, /secret|media\.test|token/)
+        assert.equal(peer.remoteDescription, undefined)
+        assert.equal(peer.connectionState, 'closed')
+        assert.equal(video.src, '')
+        assert.deepEqual(ready, [])
+        player.destroy()
+    }
+})
+
+test('原生播放拒绝和缺失WebRTC能力均返回可清理的失败而非伪就绪', async () => {
+    const video = new Video(), errors = [], ready = []
+    video.play = () => Promise.reject(new Error('autoplay secret'))
+    const player = createVideoPlayer(video, { kind: 'native', url: 'https://media.test/live.mp4' }, {
+        onError: (error) => errors.push(error), onReady: () => ready.push(true),
+    })
+    await settle()
+    assert.equal(errors[0].code, 'VIDEO_PLAYBACK_FAILED')
+    assert.doesNotMatch(errors[0].message, /secret/)
+    assert.equal(video.src, '')
+    video.fire('playing')
+    assert.deepEqual(ready, [])
+    player.destroy()
+    const unsupported = createVideoPlayer(new Video(), { kind: 'webrtc', url: signalingUrl }, {
+        onError: (error) => errors.push(error),
+    })
+    await settle()
+    assert.equal(errors[1].code, 'VIDEO_UNSUPPORTED')
+    unsupported.destroy()
+})
+
+test('无关联stream的音视频轨道保留在同一媒体流,只有视频轨道可报画面就绪', async () => {
+    class MediaStream {
+        tracks = []
+        addTrack(track) { this.tracks.push(track) }
+        getTracks() { return this.tracks }
+    }
+    const video = new Video(), errors = [], ready = []
+    video.ownerDocument.defaultView = { MediaStream }
+    const player = createVideoPlayer(video, { kind: 'webrtc', url: signalingUrl }, {
+        PeerConnection: Peer, fetchImpl: async () => answer(),
+        onError: (error) => errors.push(error), onReady: () => ready.push('画面就绪'),
+    })
+    await settle()
+    const peer = Peer.instances.at(-1)
+    peer.gather(); await settle()
+    const audio = new Track(), picture = new Track()
+    audio.kind = 'audio'
+    peer.receive(audio, true); await settle()
+    assert.deepEqual(errors, [])
+    video.fire('playing')
+    assert.deepEqual(ready, [], '不能把只有音频当作视频画面就绪')
+    peer.receive(picture, true); await settle()
+    assert.deepEqual(video.srcObject.getTracks(), [audio, picture])
+    video.fire('playing')
+    assert.deepEqual(ready, ['画面就绪'])
+    player.destroy()
+    assert.equal(audio.readyState, 'ended')
+    assert.equal(picture.readyState, 'ended')
+})
+
+test('Vue播放器公开media与ready接缝可挂载,换媒体和卸载清理旧播放', async () => {
+    const { parseHTML } = await import('linkedom')
+    const { window } = parseHTML('<html><body><div id="host"></div></body></html>')
+    const previous = new Map()
+    for (const name of ['window', 'document', 'Element', 'HTMLElement', 'SVGElement']) {
+        previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name))
+        Object.defineProperty(globalThis, name, { configurable: true, writable: true, value: window[name] })
+    }
+    // LinkeDOM不实现媒体播放;只在HTMLVideoElement外部边界提供播放/清理替身。
+    const createElement = window.document.createElement.bind(window.document)
+    window.document.createElement = (name, ...args) => {
+        const element = createElement(name, ...args)
+        if (name === 'video') {
+            Object.defineProperty(element, 'src', {
+                get: () => element.getAttribute('src') || '',
+                set: (value) => element.setAttribute('src', value),
+            })
+            element.play = () => Promise.resolve()
+            element.pause = () => { element.dataset.paused = 'true' }
+            element.load = () => {}
+        }
+        return element
+    }
+    let app
+    try {
+        const { parse, compileScript } = await import('@vue/compiler-sfc')
+        const file = new URL('../../src/modules/cockpit/components/VideoPlayer.vue', import.meta.url)
+        const { descriptor, errors: parseErrors } = parse(await readFile(file, 'utf8'), { filename: file.pathname })
+        assert.deepEqual(parseErrors, [])
+        const compiled = compileScript(descriptor, { id: 'player-test', inlineTemplate: true })
+        const code = compiled.content
+            .replace(/from ["']vue["']/g, `from ${JSON.stringify(import.meta.resolve('vue'))}`)
+            .replace(/from ["']\.\.\/services\/videoPlayer\.js["']/g,
+                `from ${JSON.stringify(new URL('../../src/modules/cockpit/services/videoPlayer.js', import.meta.url).href)}`)
+        const { default: Component } = await import(`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`)
+        const { createApp, h, shallowRef, nextTick } = await import('vue')
+        const media = shallowRef({ kind: 'native', url: 'https://media.test/first.mp4' })
+        const ready = [], errors = []
+        app = createApp({ render: () => h(Component, {
+            media: media.value, onReady: () => ready.push('ready'), onError: (error) => errors.push(error),
+        }) })
+        app.mount(window.document.querySelector('#host'))
+        await nextTick(); await settle()
+        const video = window.document.querySelector('video')
+        assert.ok(video)
+        assert.equal(video.src, 'https://media.test/first.mp4')
+        assert.equal(video.hasAttribute('controls'), true)
+        assert.match(video.getAttribute('controlslist'), /nodownload/)
+        assert.equal(video.hasAttribute('disablepictureinpicture'), true)
+        assert.equal(video.hasAttribute('disableremoteplayback'), true)
+        video.dispatchEvent(new window.Event('playing'))
+        assert.deepEqual(ready, ['ready'])
+        media.value = { kind: 'native', url: 'https://media.test/second.mp4' }
+        await nextTick(); await settle()
+        assert.equal(video.src, 'https://media.test/second.mp4')
+        assert.equal(video.dataset.paused, 'true')
+        video.dispatchEvent(new window.Event('playing'))
+        assert.deepEqual(ready, ['ready', 'ready'])
+        app.unmount(); app = null
+        assert.equal(video.getAttribute('src'), null)
+        video.dispatchEvent(new window.Event('playing'))
+        assert.deepEqual(ready, ['ready', 'ready'])
+        assert.deepEqual(errors, [])
+    } finally {
+        app?.unmount()
+        for (const [name, descriptor] of previous) {
+            if (descriptor) Object.defineProperty(globalThis, name, descriptor)
+            else delete globalThis[name]
+        }
+    }
+})

+ 64 - 0
tests/cockpit-video/run.mjs

@@ -0,0 +1,64 @@
+import { parseHTML } from 'linkedom'
+import { build } from 'esbuild'
+import { parse, compileScript } from '@vue/compiler-sfc'
+import { readFile, mkdir } from 'node:fs/promises'
+import { resolve, relative } from 'node:path'
+import { execFileSync } from 'node:child_process'
+import { pathToFileURL } from 'node:url'
+
+// A DOM-like test host, not a browser or a media decoder. Install it before importing Vue.
+const { window } = parseHTML('<html><body></body></html>')
+for (const key of ['window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGElement', 'Event', 'CustomEvent', 'MutationObserver']) {
+    Object.defineProperty(globalThis, key, { configurable: true, value: key === 'window' ? window : window[key] })
+}
+Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'offline-video-test' } })
+globalThis.location = new URL('http://fixture.invalid/')
+window.location = globalThis.location
+window.requestAnimationFrame = globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0)
+window.cancelAnimationFrame = globalThis.cancelAnimationFrame = clearTimeout
+const videoPrototype = Object.getPrototypeOf(document.createElement('video'))
+videoPrototype.play = () => Promise.resolve()
+videoPrototype.pause = () => {}
+videoPrototype.load = () => {}
+
+const baselineOnly = process.argv.includes('--baseline-only')
+const base = 'f437979141947a68c67cbc6797ae31f7aae1188f'
+const ownSources = new Set([
+    'src/modules/cockpit/components/VideoMonitorEntry.vue', 'src/modules/cockpit/components/VideoMonitorPanel.vue',
+    'src/modules/cockpit/components/VideoPlayer.vue', 'src/modules/cockpit/services/videoApi.js',
+    'src/modules/cockpit/services/videoLiveSession.js', 'src/modules/cockpit/services/videoPlayer.js',
+    'src/modules/cockpit/stores/videoStore.js', 'src/pages/cockpit/index.vue',
+])
+async function sourceAt(path) {
+    const name = relative(process.cwd(), path).replaceAll('\\', '/')
+    if (!baselineOnly || !name.startsWith('src/') || ownSources.has(name)) return readFile(path, 'utf8')
+    let source = execFileSync('git', ['show', `${base}:${name}`], { encoding: 'utf8' })
+    if (name === 'src/modules/cockpit/components/EquipmentDetailSections.vue') {
+        const anchor = '        <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>'
+        if (source.split(anchor).length !== 2) throw new Error('Video entry insertion anchor must be unique')
+        source = source.replace(anchor, `${anchor}\n            <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" />`)
+            .replace("import SectionStateTag from './SectionStateTag.vue'", "import VideoMonitorEntry from './VideoMonitorEntry.vue'\nimport SectionStateTag from './SectionStateTag.vue'")
+    }
+    return source
+}
+const output = resolve(`.scratch/cockpit-video-monitoring/test-output/journey${baselineOnly ? '-baseline' : ''}.mjs`)
+await mkdir(resolve('.scratch/cockpit-video-monitoring/test-output'), { recursive: true })
+await build({
+    entryPoints: [resolve('tests/cockpit-video/journey.mjs')], outfile: output,
+    bundle: true, platform: 'node', format: 'esm', packages: 'external',
+    define: { 'import.meta.env': '{}', '__VUE_OPTIONS_API__': 'true', '__VUE_PROD_DEVTOOLS__': 'false' },
+    plugins: [{ name: 'offline-vue-sfc', setup(builder) {
+        builder.onLoad({ filter: /\.vue$/ }, async ({ path }) => {
+            const source = await sourceAt(path)
+            const { descriptor, errors } = parse(source, { filename: path })
+            if (errors.length) throw errors[0]
+            const script = compileScript(descriptor, { id: path, inlineTemplate: true })
+            return { contents: script.content, loader: 'js', resolveDir: resolve(path, '..') }
+        })
+        builder.onLoad({ filter: /\.js$/ }, async ({ path }) => {
+            if (!baselineOnly || !relative(process.cwd(), path).replaceAll('\\', '/').startsWith('src/')) return null
+            return { contents: await sourceAt(path), loader: 'js', resolveDir: resolve(path, '..') }
+        })
+    } }],
+})
+await import(pathToFileURL(output).href)

+ 131 - 2
yarn.lock

@@ -1950,6 +1950,11 @@ boolbase@^1.0.0:
   resolved "https://registry.npmmirror.com/boolbase/-/boolbase-1.0.0.tgz"
   integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==
 
+boolbase@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-2.0.0.tgz#832968d211d5c008d833b86e049bb3943036e5b1"
+  integrity sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==
+
 brace-expansion@^1.1.7:
   version "1.1.11"
   resolved "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.11.tgz"
@@ -2251,11 +2256,32 @@ css-select@^4.2.1:
     domutils "^2.8.0"
     nth-check "^2.0.1"
 
+css-select@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/css-select/-/css-select-7.0.0.tgz#7532bab5977b1f0e83bc16036661dbe0968b4dec"
+  integrity sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==
+  dependencies:
+    boolbase "^2.0.0"
+    css-what "^8.0.0"
+    domhandler "^6.0.1"
+    domutils "^4.0.2"
+    nth-check "^3.0.1"
+
 css-what@^6.0.1:
   version "6.1.0"
   resolved "https://registry.npmmirror.com/css-what/-/css-what-6.1.0.tgz"
   integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
 
+css-what@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/css-what/-/css-what-8.0.0.tgz#237889d60c4afef4736892f39e133ab4c8c21f0c"
+  integrity sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==
+
+cssomnom@^0.1.11:
+  version "0.1.11"
+  resolved "https://registry.yarnpkg.com/cssomnom/-/cssomnom-0.1.11.tgz#92aeb767e84c151b31d104ba058f54892f69f956"
+  integrity sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==
+
 csstype@^3.1.3:
   version "3.1.3"
   resolved "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz"
@@ -2320,16 +2346,39 @@ dom-serializer@^1.0.1:
     domhandler "^4.2.0"
     entities "^2.0.0"
 
+dom-serializer@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53"
+  integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==
+  dependencies:
+    domelementtype "^2.3.0"
+    domhandler "^5.0.2"
+    entities "^4.2.0"
+
+dom-serializer@^3.0.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-3.1.1.tgz#54be70ee4fcc2da010f488165e62294798dc57d3"
+  integrity sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==
+  dependencies:
+    domelementtype "^3.0.0"
+    domhandler "^6.0.0"
+    entities "^8.0.0"
+
 dom-walk@^0.1.0:
   version "0.1.2"
   resolved "https://registry.npmmirror.com/dom-walk/-/dom-walk-0.1.2.tgz"
   integrity sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==
 
-domelementtype@^2.0.1, domelementtype@^2.2.0:
+domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0:
   version "2.3.0"
   resolved "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz"
   integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==
 
+domelementtype@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-3.0.0.tgz#e6c0a24bd39ca5eb7ea67a98d2f699497d7bcc55"
+  integrity sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==
+
 domhandler@^4.2.0, domhandler@^4.3.1:
   version "4.3.1"
   resolved "https://registry.npmmirror.com/domhandler/-/domhandler-4.3.1.tgz"
@@ -2337,6 +2386,20 @@ domhandler@^4.2.0, domhandler@^4.3.1:
   dependencies:
     domelementtype "^2.2.0"
 
+domhandler@^5.0.2, domhandler@^5.0.3:
+  version "5.0.3"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31"
+  integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==
+  dependencies:
+    domelementtype "^2.3.0"
+
+domhandler@^6.0.0, domhandler@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-6.0.1.tgz#75f351a03a6e10c35e08418f9cf0d287e00797cf"
+  integrity sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==
+  dependencies:
+    domelementtype "^3.0.0"
+
 dompurify@^3.4.14:
   version "3.4.15"
   resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.15.tgz#30351b34513894f428c13bddb28bff0a027ae804"
@@ -2353,6 +2416,24 @@ domutils@^2.8.0:
     domelementtype "^2.2.0"
     domhandler "^4.2.0"
 
+domutils@^3.2.2:
+  version "3.2.2"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78"
+  integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==
+  dependencies:
+    dom-serializer "^2.0.0"
+    domelementtype "^2.3.0"
+    domhandler "^5.0.3"
+
+domutils@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/domutils/-/domutils-4.0.2.tgz#0c1ac1fdbe8f554a60a6f5eb143ee85630327c94"
+  integrity sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==
+  dependencies:
+    dom-serializer "^3.0.0"
+    domelementtype "^3.0.0"
+    domhandler "^6.0.0"
+
 dot-case@^3.0.4:
   version "3.0.4"
   resolved "https://registry.npmmirror.com/dot-case/-/dot-case-3.0.4.tgz"
@@ -2427,11 +2508,21 @@ entities@^2.0.0:
   resolved "https://registry.npmmirror.com/entities/-/entities-2.2.0.tgz"
   integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==
 
-entities@^4.5.0:
+entities@^4.2.0, entities@^4.5.0:
   version "4.5.0"
   resolved "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz"
   integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==
 
+entities@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b"
+  integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==
+
+entities@^8.0.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/entities/-/entities-8.1.0.tgz#9632d69619ebdbe0cf0ef22f53088fe5a2163d86"
+  integrity sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==
+
 envinfo@^7.7.3:
   version "7.21.0"
   resolved "https://registry.npmmirror.com/envinfo/-/envinfo-7.21.0.tgz"
@@ -2826,6 +2917,11 @@ he@1.2.0, he@^1.2.0:
   resolved "https://registry.npmmirror.com/he/-/he-1.2.0.tgz"
   integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
 
+html-escaper@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-3.0.3.tgz#4d336674652beb1dcbc29ef6b6ba7f6be6fdfed6"
+  integrity sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==
+
 html-minifier-terser@^6.1.0:
   version "6.1.0"
   resolved "https://registry.npmmirror.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz"
@@ -2839,6 +2935,16 @@ html-minifier-terser@^6.1.0:
     relateurl "^0.2.7"
     terser "^5.10.0"
 
+htmlparser2@^10.1.0:
+  version "10.1.0"
+  resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4"
+  integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==
+  dependencies:
+    domelementtype "^2.3.0"
+    domhandler "^5.0.3"
+    domutils "^3.2.2"
+    entities "^7.0.1"
+
 http-errors@~2.0.0, http-errors@~2.0.1:
   version "2.0.1"
   resolved "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz"
@@ -3088,6 +3194,17 @@ less@^4.2.0:
     needle "^3.1.0"
     source-map "~0.6.0"
 
+linkedom@0.18.15:
+  version "0.18.15"
+  resolved "https://registry.yarnpkg.com/linkedom/-/linkedom-0.18.15.tgz#f2aaae4fd2c5beab3f72e1ed53dbf47f6297ac0a"
+  integrity sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==
+  dependencies:
+    css-select "^7.0.0"
+    cssomnom "^0.1.11"
+    html-escaper "^3.0.3"
+    htmlparser2 "^10.1.0"
+    uhyphen "^0.2.0"
+
 load-bmfont@^1.3.1, load-bmfont@^1.4.0:
   version "1.4.2"
   resolved "https://registry.npmmirror.com/load-bmfont/-/load-bmfont-1.4.2.tgz"
@@ -3355,6 +3472,13 @@ nth-check@^2.0.1:
   dependencies:
     boolbase "^1.0.0"
 
+nth-check@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-3.0.1.tgz#a5ede96060f7f0b74d7d3d8425f2a8f0610c5776"
+  integrity sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==
+  dependencies:
+    boolbase "^2.0.0"
+
 object-inspect@^1.13.3:
   version "1.13.4"
   resolved "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz"
@@ -4167,6 +4291,11 @@ type-is@~1.6.18:
     media-typer "0.3.0"
     mime-types "~2.1.24"
 
+uhyphen@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/uhyphen/-/uhyphen-0.2.0.tgz#8fdf0623314486e020a3c00ee5cc7a12fe722b81"
+  integrity sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==
+
 unicode-canonical-property-names-ecmascript@^2.0.0:
   version "2.0.1"
   resolved "https://registry.npmmirror.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz"