Преглед изворни кода

feat(cockpit): 三宿主共用监控并按观看来源精确释放

ChenJZ пре 10 часа
родитељ
комит
51e14e069f

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

@@ -54,7 +54,7 @@
             而不是每块 section 上 —— 一个页签对应一个 tabpanel 才是正确的可访问语义)。
         -->
         <div class="equipment-tab__body" role="tabpanel" data-equipment-tab-body>
-            <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" />
+            <VideoMonitorEntry v-if="activeTab === 'metrics'" :device-code="deviceCode" :monitor-source="monitorSource" />
             <section
                 v-for="panel in activePanels"
                 :key="panel.name"
@@ -407,6 +407,7 @@ const props = defineProps({
     aggregate: { type: Object, default: null },
     /** 当前选中的设备编码(分区头与图片 alt 用)。 */
     deviceCode: { type: String, default: '' },
+    monitorSource: { type: String, default: 'detail' },
 })
 
 const panels = computed(() => equipmentPanels(props.aggregate))

+ 1 - 1
src/modules/cockpit/components/PipeEnhancement.vue

@@ -43,7 +43,7 @@
                 <p v-if="deviceState.selected.pointCode">所属监测点:{{ deviceState.selected.pointCode }}</p>
                 <p v-if="deviceState.loadingDetail">设备详情加载中</p>
                 <template v-else-if="!deviceState.aggregate?.data"><p>设备详情暂不可用</p><button type="button" @click="$emit('device-retry')">重新加载</button></template>
-                <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" />
+                <EquipmentDetailSections v-else :aggregate="deviceState.aggregate" :device-code="deviceState.selected.code" monitor-source="enhancement" />
             </section>
             <aside class="pipe-enhancement__devices" aria-label="附近设备与详情">
                 <section v-if="localPipeStates.length" class="pipe-enhancement__pipe-list" data-pipe-status-list aria-label="附近管道状态">

+ 1 - 0
src/modules/cockpit/components/PointDetailCard.vue

@@ -289,6 +289,7 @@
                 </button>
             </template>
             <EquipmentDetailSections
+                monitor-source="detail"
                 v-else
                 data-device-detail-body
                 :aggregate="aggregateSection"

+ 2 - 2
src/modules/cockpit/components/VideoMonitorEntry.vue

@@ -2,7 +2,7 @@
     <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>
+            <button type="button" data-video-open :disabled="device.bound !== true || device.capabilities?.live !== true || Boolean(video.state.cleanupError)" @click="video.open(device, monitorSource)">监控</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>
@@ -12,7 +12,7 @@
 <script setup>
 import { ref, watch, onBeforeUnmount } from 'vue'
 import { useVideoStore } from '../stores/videoStore.js'
-const props = defineProps({ deviceCode: { type: String, required: true } })
+const props = defineProps({ deviceCode: { type: String, required: true }, monitorSource: { type: String, default: 'detail' } })
 const video = useVideoStore()
 const device = ref(null), loading = ref(false), error = ref('')
 let generation = 0

+ 3 - 2
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -38,8 +38,9 @@ const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enh
 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()
+watch(() => [state.value.ownerSource, detail.selection.deviceCode, enhancement.selected?.code], ([ownerSource, detailCode, enhancementCode]) => {
+    const currentCode = ownerSource === 'enhancement' ? enhancementCode : detailCode
+    if (state.value.open && currentCode !== state.value.device?.deviceCode) void video.close()
 })
 let trigger
 watch(() => state.value.open, async (open) => {

+ 16 - 8
src/modules/cockpit/services/videoApi.js

@@ -17,6 +17,7 @@ const sessionPath = (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
+    const isLive = (path) => path === '/video/live' || /^\/video\/sessions\/[^/]+\/live$/.test(path)
     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', '请先配置真实视频接口')
@@ -34,33 +35,40 @@ export function createVideoApi(options = {}) {
             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))
+                const uncertain = isLive(path) && (!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'))
+            controller.abort(); reject(new VideoRequestError('REQUEST_TIMEOUT', '视频请求超时,结果未确认', isLive(path)))
         }, 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
+                if (isLive(path) && error.category === 'INVALID_RESPONSE') error.uncertain = true
                 throw error
             }
-            throw new VideoRequestError('NETWORK_ERROR', '视频网络连接失败,结果未确认', path === '/video/live')
+            throw new VideoRequestError('NETWORK_ERROR', '视频网络连接失败,结果未确认', isLive(path))
         } finally { timers.clearTimeout(timeout) }
     }
-    const sessionReply = (id, status) => (data) => {
-        if (data.sessionId !== id || data.status !== status) throw new VideoRequestError('INVALID_RESPONSE', '会话维护结果与当前观看不匹配')
+    const sessionReply = (id, statuses) => (data) => {
+        const allowed = Array.isArray(statuses) ? statuses : [statuses]
+        if (data.sessionId !== id || !allowed.includes(data.status)) throw new VideoRequestError('INVALID_RESPONSE', '会话维护结果与当前观看不匹配')
         return data
     }
     return {
         device: (deviceCode) => request(`/video/device?${new URLSearchParams({ deviceCode })}`),
-        start: (deviceCode) => request('/video/live', { deviceCode }, 100000),
+        prepare: (deviceCode) => request('/video/sessions/prepare', { deviceCode }),
+        start: (session) => typeof session === 'string'
+            ? request('/video/live', { deviceCode: session }, 100000)
+            : request(sessionPath(session.sessionId, 'live'), { sessionToken: session.sessionToken }, 100000)
+                .then(sessionReply(session.sessionId, ['STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
+        status: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'status'), { sessionToken })
+            .then(sessionReply(sessionId, ['PREPARED', 'STARTING', 'ACTIVE', 'CLEANUP_PENDING', 'STOPPED', 'FAILED'])),
         heartbeat: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'heartbeat'), { sessionToken }).then(sessionReply(sessionId, 'ACTIVE')),
-        stop: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'stop'), { sessionToken }).then(sessionReply(sessionId, 'STOPPED')),
+        stop: ({ sessionId, sessionToken }) => request(sessionPath(sessionId, 'stop'), { sessionToken }).then(sessionReply(sessionId, ['STOPPED', 'FAILED', 'CLEANUP_PENDING'])),
     }
 }
 

+ 96 - 44
src/modules/cockpit/services/videoLiveSession.js

@@ -18,74 +18,126 @@
  */
 import { selectVideoMedia, VideoRequestError } from './videoApi.js'
 
-/** Adapted from the existing management live-session helper: one owner, generation guard and late release. */
+/** One visible attempt, owned preparation before media, and a bounded cleanup observer. */
 export function createVideoLiveSession(api, update, timers = globalThis) {
-    let generation = 0, owned = null, pending = false, uncertain = false, heartbeat, readyTimeout, startTimeout, disposed = false
+    let generation = 0, current = null, cleanupTimer, cleanupChecks = 0, disposed = false
     const releases = new Set()
-    const clear = () => { timers.clearInterval(heartbeat); timers.clearTimeout(readyTimeout); timers.clearTimeout(startTimeout) }
+    const cleanupMessage = '会话释放未确认,等待服务端回收;可重试清理'
+    const publish = (patch) => { if (!disposed) update(patch) }
+    const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
+    const active = (attempt) => !disposed && current === attempt && attempt.version === generation
+    const clear = (attempt) => {
+        if (!attempt) return
+        timers.clearTimeout(attempt.startTimer); timers.clearTimeout(attempt.readyTimer); timers.clearInterval(attempt.heartbeat)
+    }
+    function scheduleCleanup() {
+        if (disposed || cleanupTimer || !releases.size || cleanupChecks >= 40) return
+        cleanupTimer = timers.setTimeout(async () => {
+            cleanupTimer = null; cleanupChecks += 1
+            for (const session of [...releases]) {
+                try {
+                    const result = await api.status(session)
+                    if (result.sessionId === session.sessionId && ['STOPPED', 'FAILED'].includes(result.status)) {
+                        session.released = true; releases.delete(session)
+                    }
+                } catch { /* Unknown status is not release evidence. */ }
+            }
+            reportCleanup(); scheduleCleanup()
+        }, 3000)
+    }
     async function release(session) {
-        if (!session) return true
+        if (!session || session.released) return true
+        if (session.releasing) return session.releasing
         releases.add(session)
-        try { await api.stop(session); releases.delete(session); return true }
-        catch { update({ cleanupError: '会话释放未确认,等待服务端回收;可重试清理' }); return false }
+        session.releasing = Promise.resolve().then(async () => {
+            try {
+                const result = await api.stop(session)
+                if (result.sessionId !== session.sessionId || !['STOPPED', 'FAILED'].includes(result.status)) throw new Error('Cleanup not confirmed')
+                session.released = true; releases.delete(session); return true
+            } catch {
+                if (session.released) return true
+                publish({ cleanupError: cleanupMessage }); scheduleCleanup(); return false
+            } finally { session.releasing = null }
+        })
+        return session.releasing
     }
     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: '' })
+        ++generation
+        const attempt = current; current = null; clear(attempt)
+        publish({ media: null, phase: 'idle' })
+        if (attempt?.identity) releases.add(attempt.identity)
+        cleanupChecks = 0
+        for (const session of [...releases]) await release(session)
+        reportCleanup()
+        return releases.size === 0
     }
-    async function fail(message) {
-        update({ error: message || '视频播放失败' })
+    async function fail(message, media) {
+        if (!current || (media && current.media !== media)) return
+        publish({ error: message || '视频播放失败' })
         const stopping = stop(), version = generation
         await stopping
-        if (version === generation) update({ phase: 'failed' })
+        if (version === generation) publish({ phase: 'failed' })
+    }
+    function ready(media) {
+        if (!current?.media || current.identity?.released || (media && current.media !== media)) return
+        timers.clearTimeout(current.readyTimer)
+        publish({ phase: 'playing' })
     }
-    function ready() { timers.clearTimeout(readyTimeout); if (owned && !disposed) update({ phase: 'playing' }) }
     async function start(deviceCode) {
         const stopping = stop(), version = generation
         await stopping
         if (disposed || version !== generation) return
-        if (pending || uncertain || releases.size) {
-            update({ phase: 'failed', error: '上次播放或清理结果尚未确认,暂不能重复建流' }); return
+        if (releases.size) {
+            publish({ phase: 'failed', error: '上次清理结果尚未确认,暂不能重复建流' }); return
         }
-        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)
+        const attempt = { version, deviceCode, identity: null, media: null }
+        current = attempt
+        publish({ phase: 'starting', media: null, error: '', cleanupError: '' })
+        attempt.startTimer = timers.setTimeout(() => { if (active(attempt)) void fail('视频建立超时') }, 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
+            const identity = await api.prepare(deviceCode)
+            if (typeof identity.sessionId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(identity.sessionId)
+                || typeof identity.sessionToken !== 'string' || !identity.sessionToken)
+                throw new VideoRequestError('INVALID_RESPONSE', '媒体启动前未取得完整观看身份')
+            attempt.identity = { sessionId: identity.sessionId, sessionToken: identity.sessionToken, deviceCode, mode: 'LIVE' }
+            if (!active(attempt)) { await release(attempt.identity); return }
+            if (identity.deviceCode !== deviceCode || identity.mode !== 'LIVE' || identity.status !== 'PREPARED')
+                throw new VideoRequestError('INVALID_RESPONSE', '观看身份与当前设备不匹配')
+            let data = await api.start(attempt.identity)
+            while (active(attempt) && data.status === 'STARTING') {
+                if (data.sessionId !== attempt.identity.sessionId) throw new VideoRequestError('INVALID_RESPONSE', '返回的观看会话不匹配')
+                await new Promise((resolve) => timers.setTimeout(resolve, 1000))
+                if (!active(attempt)) break
+                data = await api.status(attempt.identity)
+            }
+            if (!active(attempt)) { await release(attempt.identity); return }
+            if (data.sessionId !== attempt.identity.sessionId) throw new VideoRequestError('INVALID_RESPONSE', '返回的观看会话不匹配')
+            if (['STOPPED', 'FAILED'].includes(data.status)) {
+                attempt.identity.released = true; clear(attempt); current = null
+                publish({ media: null, phase: 'failed', error: '观看会话未建立或已停止' }); return
+            }
             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)
+            if (data.status !== 'ACTIVE') throw new VideoRequestError('SESSION_PENDING', '媒体尚未就绪,结束本次观看')
+            attempt.media = selectVideoMedia(data.media)
+            timers.clearTimeout(attempt.startTimer)
+            publish({ phase: 'connecting', media: attempt.media })
+            attempt.readyTimer = timers.setTimeout(() => { if (active(attempt)) void fail('播放器就绪超时') }, 20000)
             let beating = false
-            heartbeat = timers.setInterval(async () => {
-                if (beating || version !== generation || !owned) return
+            attempt.heartbeat = timers.setInterval(async () => {
+                if (beating || !active(attempt)) return
                 beating = true
-                try { await api.heartbeat(owned) }
-                catch { if (version === generation) await fail('播放会话已断开,请重新播放') }
+                try { await api.heartbeat(attempt.identity) }
+                catch { if (active(attempt)) await fail('播放会话已断开,请重新播放') }
                 finally { beating = false }
             }, 30000)
         } catch (error) {
-            if (error.uncertain) { uncertain = true; update({ cleanupError: '启动结果未确认,请核查服务端回收;暂不能重复建流' }) }
-            if (version === generation && !disposed) await fail(error.message || '视频连接失败')
+            if (active(attempt)) await fail(error.message || '视频连接失败')
+            else if (attempt.identity) await release(attempt.identity)
         } finally {
-            pending = false; timers.clearTimeout(startTimeout)
-            if (!releases.size && !uncertain) update({ cleanupError: '' })
+            timers.clearTimeout(attempt.startTimer)
+            reportCleanup()
         }
     }
-    async function dispose() { disposed = true; await stop() }
+    async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }
     return { start, stop, dispose, ready, fail }
 }

+ 5 - 4
src/modules/cockpit/stores/videoStore.js

@@ -4,7 +4,7 @@ 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 state = shallowRef({ open: false, device: null, ownerSource: 'detail', phase: 'idle', media: null, error: '', cleanupError: '' })
     const playerOptions = shallowRef({})
     let options = {}, controller
     const publish = (patch) => { state.value = { ...state.value, ...patch } }
@@ -21,9 +21,10 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         }
         return data
     }
-    async function open(device) {
-        if (state.value.open && state.value.device?.deviceCode === device?.deviceCode) return
-        publish({ open: true, device, error: '' })
+    async function open(device, ownerSource = 'detail') {
+        if (!['detail', 'enhancement'].includes(ownerSource)) throw new Error('监控入口来源不合法')
+        if (state.value.open && state.value.device?.deviceCode === device?.deviceCode) { publish({ ownerSource }); return }
+        publish({ open: true, device, ownerSource, error: '' })
         if (device?.typeCode !== '20' || device.bound !== true || device.capabilities?.live !== true) {
             publish({ phase: 'failed', error: '当前设备未确认实时视频能力' }); return
         }

+ 219 - 0
tests/cockpit-video/hosts.mjs

@@ -0,0 +1,219 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { createApp, h, nextTick } from 'vue'
+import { createPinia, setActivePinia } from 'pinia'
+import CockpitRoot from '../../src/pages/cockpit/index.vue'
+import PipeEnhancement from '../../src/modules/cockpit/components/PipeEnhancement.vue'
+import VideoMonitorPanel from '../../src/modules/cockpit/components/VideoMonitorPanel.vue'
+import { useEnhancementDevicesStore } from '../../src/modules/cockpit/stores/enhancementDevicesStore.js'
+import { loadCockpitConfig, resetCockpitConfigForTest } from '../../src/modules/cockpit/services/cockpitConfig.js'
+import { useVideoStore } from '../../src/modules/cockpit/stores/videoStore.js'
+
+const settle = async () => { for (let i = 0; i < 8; i++) { await new Promise(setImmediate); await nextTick() } }
+const envelope = (data, status = 200) => Response.json({ code: 200, data }, { status })
+const cameraCode = 'CAM/03 空格'
+const coordinate = { state: 'INVALID', renderable: false, reasons: ['MISSING_LONGITUDE'], longitude: null, latitude: null }
+const point = { pointCode: 'P-FIXED', pointName: '无位置摄像机监测点', pointStatus: 'NORMAL', industry: 'DRAINAGE',
+    deviceCodes: [cameraCode], longitude: null, latitude: null, coordinateValidation: coordinate }
+const camera = { deviceCode: cameraCode, deviceName: '无位置现场摄像机', deviceType: '20', deviceTypeName: '视频监控设备',
+    deviceStatus: 'NORMAL', industry: 'DRAINAGE', longitude: null, latitude: null, coordinateValidation: coordinate }
+const device = { deviceCode: cameraCode, deviceName: camera.deviceName, typeCode: '20', bound: true, capabilities: { live: true } }
+const aggregate = { deviceCode: cameraCode, aggregateState: 'READY',
+    archive: { state: 'SUCCESS', data: camera }, 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 } }
+const emptyEndpoints = new Set(['/overview/perception', '/overview/pipe-network', '/overview/warning-study', '/overview/alarm-disposal',
+    '/monitoring/overview', '/monitoring/situation', '/events/current'])
+
+function installGpu() {
+    const viewers = []
+    let visible = true, time = 0
+    const frame = () => { for (const viewer of viewers) for (const callback of [...viewer.frames]) callback() }
+    const projection = () => visible ? { x: 800, y: 450, visible: true, iconSize: 20 } : null
+    const gpu = {
+        createViewer(host) {
+            const canvas = document.createElement('canvas'); host.appendChild(canvas)
+            const frames = new Set()
+            const viewer = { canvas, frames, points: [], devices: [],
+                camera: { positionWC: { x: 0, y: 0, z: 6371000 }, directionWC: { x: 0, y: 0, z: -1 }, upWC: { x: 0, y: 1, z: 0 } },
+                scene: { canvas, globe: { ellipsoid: {} }, requestRender() {},
+                postRender: { addEventListener(callback) { frames.add(callback); return () => frames.delete(callback) } } } }
+            viewers.push(viewer); return viewer
+        },
+        destroyViewer(viewer) { viewer.frames.clear(); viewer.canvas.remove(); viewers.splice(viewers.indexOf(viewer), 1) },
+        resizeViewer() {}, applyOverlays: () => [], sampleTerrainHeight: async () => 0,
+        sampleTerrainHeights: async (_viewer, points) => points.map(() => 0),
+        groundHeightAt: () => 0, markerGroundHeight: () => 0,
+        createCameraEngine(viewer) { return {
+            applyView() {}, readView: () => ({ heading: 0, pitch: -45, range: 1000 }),
+            pickGroundAtCenter: () => ({ lon: 110.4, lat: 28.4, height: 0 }), projectPivot: projection,
+            inputTarget: () => viewer.canvas, viewport: () => ({ width: 1600, height: 900 }), fovY: () => Math.PI / 3,
+            now: () => time, requestFrame: (callback) => setTimeout(() => { time += 100; callback(time) }, 0),
+        } },
+        createOverlayReadiness: () => ({ state: { ready: true, pending: 0, failed: 0, lastError: '' }, refresh() {}, detach() {} }),
+        hasFogStage: () => false,
+        syncPointMarkers(viewer, rows) { viewer.points = rows; return { rendered: rows.length, unchanged: 0, skipped: 0, complete: true } },
+        syncAssociatedDevices(viewer, rows) { viewer.devices = rows; return { rendered: rows.length, skipped: 0 } },
+        clearAssociatedDevices(viewer) { viewer.devices = [] }, clearPointMarkers(viewer) { viewer.points = [] },
+        hasPointMarker: (viewer, kind, code) => viewer.points.some((row) => row.kind === kind && row.code === code),
+        hasDeviceMarker: (viewer, code) => viewer.devices.some((row) => row.code === code),
+        projectPointMarker: (viewer, kind, code) => gpu.hasPointMarker(viewer, kind, code) ? projection() : null,
+        projectDeviceMarker: (viewer, code) => gpu.hasDeviceMarker(viewer, code) ? projection() : null,
+        projectDeviceMarkers: (viewer) => viewer.devices.map((row) => ({ ...row, ...projection() })),
+        projectOverlapSymbols: () => [], readPointMarkers: (viewer) => viewer.points,
+        readAssociatedDevices: (viewer) => viewer.devices, readDeviceEffects: () => [], readOverlapTargets: () => [],
+        highlightEntities: () => 1, syncPipeLayer: async () => ({ pipes: 0, components: 0, skipped: 0 }), pickPipeObjectAt: () => null,
+    }
+    for (const name of ['applyViewModeLayers', 'applyMotionResolution', 'applyDeviceEffects', 'applyDeviceReactions',
+        'clearDeviceLead', 'syncDeviceLead', 'setOverlapPresentation', 'clearOverlapNudge', 'noteFrameInterval', 'tickAdaptiveResolution']) gpu[name] = () => {}
+    globalThis.__VIDEO_GPU__ = gpu
+    return { frame, viewers, setVisible(value) { visible = value; frame() } }
+}
+
+async function mountRoot({ positioned = false } = {}) {
+    const gpu = installGpu()
+    const location = positioned ? { state: 'VALID', renderable: true, reasons: [] } : coordinate
+    const selectedPoint = { ...point, pointCode: positioned ? 'P-FLOAT' : 'P-FIXED',
+        longitude: positioned ? 110.4 : null, latitude: positioned ? 28.4 : null, coordinateValidation: location }
+    const selectedCamera = { ...camera, longitude: selectedPoint.longitude, latitude: selectedPoint.latitude, coordinateValidation: location }
+    const requests = [], unexpected = [], errors = []
+    const fetchImpl = async (address, options = {}) => {
+        const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
+        const body = options.body ? JSON.parse(options.body) : null
+        requests.push({ url, path, body, options })
+        if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
+        assert.equal(new Headers(options.headers).get('authorization'), null)
+        if (path === '/map-snapshot') return envelope({ page: 1, pageSize: 100, snapshotAt: '2026-11-11T00:00:00Z',
+            batchUpdatedAt: '2026-11-11T00:00:00Z', total: 1, complete: true, monitoringPoints: [selectedPoint], unassociatedEquipment: [] })
+        if (path === '/monitoring/distribution') return envelope({ monitoringPoints: [selectedPoint], unassociatedEquipment: [],
+            snapshotAt: '2026-11-11T00:00:00Z', pagination: { page: 1, pageSize: 100, total: 1, totalPages: 1, hasMore: false } })
+        if (path === `/monitoring-points/${selectedPoint.pointCode}`) return envelope({ monitoringPointCode: selectedPoint.pointCode,
+            monitoringPointName: point.pointName, pointStatus: 'NORMAL', coordinateValidation: location,
+            deviceStatistics: { totalDeviceCount: 1, normalDeviceCount: 1 }, devices: [selectedCamera] })
+        if (path.startsWith('/equipments/') && path.endsWith('/detail-aggregate')) return envelope(aggregate)
+        if (path.startsWith('/equipments/') && path.endsWith('/collection-records')) return envelope({ deviceCode: cameraCode,
+            state: 'EMPTY', recordView: { columns: [], rows: [] } })
+        if (path.startsWith('/equipments/') && path.endsWith('/freshness')) return envelope({})
+        if (emptyEndpoints.has(path)) return envelope({})
+        if (path === '/video/device') { assert.equal(url.searchParams.get('deviceCode'), cameraCode); return envelope(device) }
+        if (path === '/video/sessions/prepare') {
+            assert.deepEqual(body, { deviceCode: cameraCode })
+            return envelope({ ...device, sessionId: 'session-host', sessionToken: 'owner-host', mode: 'LIVE', status: 'PREPARED' }, 201)
+        }
+        if (path === '/video/sessions/session-host/live') {
+            assert.deepEqual(body, { sessionToken: 'owner-host' })
+            return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'ACTIVE',
+                media: { native: true, url: 'https://media.invalid/live.mp4' } })
+        }
+        if (path === '/video/sessions/session-host/stop' || path === '/video/sessions/session-host/status') {
+            assert.deepEqual(body, { sessionToken: 'owner-host' })
+            return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'STOPPED' })
+        }
+        unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
+    }
+    globalThis.fetch = fetchImpl
+    globalThis.__COCKPIT_CONFIG__ = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' },
+        map: { cesium: { enabled: positioned }, intro: { enabled: false }, fog: { enabled: false }, grade: { enabled: false }, handover: { enabled: false }, effects: { enabled: false } }, runtime: { polling: false }, ws: { url: '' } }
+    resetCockpitConfigForTest()
+    const { config } = await loadCockpitConfig()
+    const pinia = createPinia(); setActivePinia(pinia)
+    const video = useVideoStore(); video.configure({ config, fetchImpl })
+    const host = document.createElement('main'); document.body.appendChild(host)
+    const app = createApp(CockpitRoot); app.use(pinia)
+    app.config.errorHandler = (error) => { errors.push(error) }
+    app.mount(host); await settle()
+    return { host, requests, unexpected, errors, video, gpu, selectedPoint, async dispose() {
+        app.unmount(); await video.close(); host.remove(); delete globalThis.__COCKPIT_CONFIG__; await settle()
+        globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
+    } }
+}
+
+// 真root→实际Shell固定态→既有点位设备卡→Latest TAB→监控;不直接调用视频store.open。
+for (const positioned of [false, true]) test(`${positioned ? '浮动' : '无地图位置固定'}:真实台账设备详情打开唯一根监控面板`, async () => {
+    const page = await mountRoot({ positioned })
+    try {
+        const mode = [...page.host.querySelectorAll('button')].find((button) => button.textContent.trim() === '运行监测')
+        assert.ok(mode); mode.click(); await settle()
+        const ledger = page.host.querySelector(`[data-ledger-card="${page.selectedPoint.pointCode}"]`)
+        assert.ok(ledger, '使用真实监测点台账卡片')
+        ledger.click(); await settle()
+        const card = page.host.querySelector('[data-device-card][data-device-code="CAM/03 空格"]')
+        assert.ok(card); card.click(); await settle(); page.gpu.frame(); await settle()
+        const detail = page.host.querySelector('.cockpit-shell__detail')
+        assert.equal(detail.getAttribute('data-anchor-state'), positioned ? 'visible' : 'fixed')
+        assert.equal(Boolean(detail.querySelector('[data-point-no-location]')), !positioned)
+        detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
+        const monitor = detail.querySelector('[data-video-open]')
+        assert.ok(monitor); assert.equal(monitor.disabled, false); monitor.click(); await settle()
+        const panels = document.querySelectorAll('[data-video-monitor]')
+        assert.equal(panels.length, 1)
+        const dialog = panels[0]
+        assert.ok(dialog.textContent.includes(cameraCode))
+        assert.ok(dialog.querySelector('video'), JSON.stringify({ text: dialog.textContent, unexpected: page.unexpected, errors: page.errors.map((error) => error.message) }))
+        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)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+// 地下管线宿主:真实 PipeEnhancement→其内设备详情→Latest TAB→监控;只替换物理渲染器。
+async function mountUnderground() {
+    const requests = [], unexpected = [], errors = []
+    const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1' } }
+    const fetchImpl = async (address, options = {}) => {
+        const url = new URL(address), path = url.pathname.replace('/api/cockpit/v1', '')
+        const body = options.body ? JSON.parse(options.body) : null
+        requests.push({ url, path, body, options })
+        if (url.origin !== 'http://fixture.invalid') { unexpected.push(url.href); throw new Error('Offline origin rejected') }
+        if (path === '/video/device') return envelope(device)
+        if (path === '/video/sessions/prepare') return envelope({ ...device, sessionId: 'session-under', sessionToken: 'owner-under', mode: 'LIVE', status: 'PREPARED' }, 201)
+        if (path === '/video/sessions/session-under/live') return envelope({ sessionId: 'session-under', deviceCode: cameraCode, mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: 'https://media.invalid/under.mp4' } })
+        if (path === '/video/sessions/session-under/stop' || path === '/video/sessions/session-under/status') return envelope({ sessionId: 'session-under', status: 'STOPPED' })
+        unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
+    }
+    globalThis.fetch = fetchImpl
+    const pinia = createPinia(); setActivePinia(pinia)
+    const video = useVideoStore(); video.configure({ config, fetchImpl })
+    const enhancement = useEnhancementDevicesStore()
+    await enhancement.open({ code: cameraCode, name: camera.deviceName, placementText: '地下清单' },
+        { fetchDetail: async () => ({ state: 'success', data: aggregate }) })
+    const plan = { visible: true, radiusM: 50, disclaimer: '显示示意', connectionNote: '仅供导引',
+        object: { title: '重点管段 P-1', code: 'PIPE-1', sourceKind: 'pipe', renderKind: 'pipe', colorKey: 'UNKNOWN', diameterM: 0.3, path: [[110.4, 28.4], [110.4008, 28.4006], [110.4016, 28.4012]] },
+        objects: [], devices: [], deviceList: [], coverage: null, deviceCoverage: null }
+    const fakeRenderer = () => ({ setPixelRatio() {}, setSize() {}, render() {}, dispose() {},
+        domElement: document.createElement('canvas') })
+    const host = document.createElement('main'); document.body.appendChild(host)
+    const app = createApp({ render: () => h('div', [
+        h(PipeEnhancement, { plan, deviceState: enhancement, rendererOptions: { rendererFactory: fakeRenderer } }),
+        h(VideoMonitorPanel)]) })
+    app.config.errorHandler = (error) => { errors.push(error) }
+    app.use(pinia).mount(host); await settle()
+    return { host, requests, unexpected, errors, video, enhancement, async dispose() {
+        app.unmount(); await video.close(); host.remove(); await settle()
+        globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
+    } }
+}
+
+test('地下管线详情:真实设备详情打开唯一根监控面板且来源为 enhancement', async () => {
+    const page = await mountUnderground()
+    try {
+        const detail = page.host.querySelector('[data-enhancement-device-detail]')
+        assert.ok(detail, JSON.stringify({ text: page.host.textContent, unexpected: page.unexpected, errors: page.errors.map((error) => error.message) }))
+        detail.querySelector('[data-equipment-tab="metrics"]').click(); await settle()
+        const monitor = detail.querySelector('[data-video-open]')
+        assert.ok(monitor); monitor.click(); await settle()
+        assert.equal(page.video.state.ownerSource, 'enhancement')
+        const panels = document.querySelectorAll('[data-video-monitor]')
+        assert.equal(panels.length, 1)
+        assert.ok(panels[0].querySelector('video'), JSON.stringify({ text: panels[0].textContent, unexpected: page.unexpected }))
+        panels[0].querySelector('[aria-label="关闭监控"]').click(); await settle()
+        assert.equal(document.querySelector('[data-video-monitor]'), null)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        assert.deepEqual(page.unexpected, [])
+        assert.deepEqual(page.errors, [])
+    } finally { await page.dispose() }
+})

+ 55 - 13
tests/cockpit-video/journey.mjs

@@ -3,9 +3,11 @@ 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 VideoMonitorEntry from '../../src/modules/cockpit/components/VideoMonitorEntry.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'
+import { useEnhancementDevicesStore } from '../../src/modules/cockpit/stores/enhancementDevicesStore.js'
 
 const code = 'CAM/01 空格'
 const device = { deviceCode: code, deviceName: '现场摄像机A', typeCode: '20', bound: true,
@@ -31,11 +33,17 @@ async function mountJourney(overrides = {}) {
             assert.equal(new URL(url).searchParams.get('deviceCode'), code)
             return envelope(overrides.device || device)
         }
-        if (path.endsWith('/video/live')) {
+        if (path.endsWith('/video/sessions/prepare')) {
             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' } })
+            return overrides.prepare?.() || new Response(JSON.stringify({ code: 200, data: {
+                deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', sessionToken: 'owner-a', mode: 'LIVE', status: 'PREPARED',
+            } }), { status: 201, headers: { 'Content-Type': 'application/json' } })
         }
+        if (path.endsWith('/video/sessions/session-a/live')) {
+            assert.deepEqual(body, { sessionToken: 'owner-a' })
+            return overrides.live?.() || envelope(livePayload({ native: true, url: 'https://media.invalid/live.mp4' }))
+        }
+        if (path.endsWith('/status')) return overrides.status?.() || envelope({ sessionId: 'session-a', deviceCode: code, mode: 'LIVE', status: 'STOPPED' })
         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)
@@ -45,18 +53,52 @@ async function mountJourney(overrides = {}) {
     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)]) })
+    const app = createApp({ render: () => h('div', [overrides.entryOnly
+        ? h(VideoMonitorEntry, { deviceCode: code, monitorSource: overrides.monitorSource || 'enhancement' })
+        : 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() } }
+    return { host, requests, video, detail, config, fetchImpl, 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 })
+const livePayload = (media) => ({ deviceCode: code, deviceName: device.deviceName, sessionId: 'session-a', mode: 'LIVE', status: 'ACTIVE', 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('closing the originating main detail releases video even if another host retains the same device code', async () => {
+    const page = await mountJourney()
+    try {
+        await useEnhancementDevicesStore().open({ code, name: device.deviceName }, {
+            fetchDetail: async () => ({ state: 'success', data: aggregate }),
+        })
+        await openMonitor(page)
+        page.host.querySelector('[data-detail-close]').click(); await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
+        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('the underground host owns its own viewing source and a main-detail close does not stop it', async () => {
+    const page = await mountJourney({ entryOnly: true })
+    try {
+        await useEnhancementDevicesStore().open({ code, name: device.deviceName }, {
+            fetchDetail: async () => ({ state: 'success', data: aggregate }),
+        })
+        page.host.querySelector('[data-video-open]').click(); await settle()
+        assert.equal(page.video.state.ownerSource, 'enhancement')
+        assert.ok(document.querySelector('[data-video-monitor]'))
+        await page.detail.closeDetail(); await settle()
+        assert.ok(document.querySelector('[data-video-monitor]'), 'main detail close must not stop the enhancement-owned video')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 0)
+        document.querySelector('[aria-label="关闭监控"]').click(); await settle()
+        assert.equal(Boolean(document.querySelector('[data-video-monitor]')), false)
+    } finally { await page.dispose() }
+})
+
 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' }) })
@@ -136,7 +178,7 @@ test('non-camera has no monitor button; unbound and unknown live capability stay
             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)
+            assert.equal(page.requests.some((request) => request.path.endsWith('/live')), false)
         } finally { await page.dispose() }
     }
 })
@@ -152,14 +194,14 @@ test('unsupported returned media releases its owned session and never presents p
     } finally { await page.dispose() }
 })
 
-test('HTTP 200 HTML is a visible failure with unconfirmed resources, not an active video', async () => {
+test('HTTP 200 HTML live response is a visible failure and releases the prepared viewing resource', 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)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/prepare')).length, 1)
     } finally { await page.dispose() }
 })
 
@@ -176,7 +218,7 @@ test('a live start exceeding 35 seconds exits visibly and releases a later owned
         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)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/live')).length, 1)
     } finally { await page.dispose() }
 })
 
@@ -202,7 +244,7 @@ test('hiding the page closes video and does not reopen it when visible again', a
         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('/live')).length, 1)
         assert.equal(page.requests.filter((request) => request.path.endsWith('/stop')).length, 1)
     } finally { delete document.hidden; await page.dispose() }
 })
@@ -287,7 +329,7 @@ test('a failed cleanup is visible at the entry and blocks another live request u
         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)
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/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)

+ 45 - 13
tests/cockpit-video/run.mjs

@@ -7,6 +7,8 @@ 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.
+// Every exercised network seam must be explicitly injected; never fall through to a live endpoint.
+globalThis.fetch = async () => { throw new Error('Uninjected network request in offline video test') }
 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] })
@@ -14,34 +16,64 @@ for (const key of ['window', 'document', 'HTMLElement', 'Element', 'Node', 'SVGE
 Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { userAgent: 'offline-video-test' } })
 globalThis.location = new URL('http://fixture.invalid/')
 window.location = globalThis.location
+document.location = globalThis.location
 window.requestAnimationFrame = globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0)
 window.cancelAnimationFrame = globalThis.cancelAnimationFrame = clearTimeout
+// Native DOM nodes are not plain objects; preserve that contract for Vue refs in LinkeDOM.
+Object.defineProperty(window.HTMLElement.prototype, Symbol.toStringTag, { configurable: true, value: 'HTMLElement' })
+globalThis.getComputedStyle = window.getComputedStyle = (element) => ({ getPropertyValue: (name) => element.style?.getPropertyValue(name) || '' })
+if (process.argv.includes('--hosts')) {
+    window.HTMLElement.prototype.getBoundingClientRect = function () {
+        const detail = this.classList?.contains('cockpit-shell__detail')
+        const width = detail ? 400 : 1600, height = detail ? 420 : 900
+        return { x: 0, y: 0, left: 0, top: 0, width, height, right: width, bottom: height }
+    }
+}
 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',
-])
+const suite = process.argv.includes('--hosts') ? 'hosts' : 'journey'
+const base = '668bdfc0e6313b9e75ae67524dc8ac7771326774'
+const sourceRef = process.argv.find((value) => value.startsWith('--source-ref='))?.slice('--source-ref='.length)
+    || (baselineOnly ? base : '')
+if (sourceRef && !/^[a-f0-9]{7,40}$/.test(sourceRef)) throw new Error('Expected a fixed Git source revision')
 async function sourceAt(path) {
     const name = relative(process.cwd(), path).replaceAll('\\', '/')
-    if (!baselineOnly || !name.startsWith('src/') || ownSources.has(name)) return readFile(path, 'utf8')
-    const revision = name === 'src/modules/cockpit/components/EquipmentDetailSections.vue' ? 'HEAD' : base
-    return execFileSync('git', ['show', `${revision}:${name}`], { encoding: 'utf8' })
+    if (!sourceRef || !name.startsWith('src/')) return readFile(path, 'utf8')
+    return execFileSync('git', ['show', `${sourceRef}:${name}`], { encoding: 'utf8' })
 }
-const output = resolve(`.scratch/cockpit-video-monitoring/test-output/journey${baselineOnly ? '-baseline' : ''}.mjs`)
+const output = resolve(`.scratch/cockpit-video-monitoring/test-output/${suite}${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,
+    entryPoints: [resolve(`tests/cockpit-video/${suite}.mjs`)], outfile: output,
+    alias: { '@': resolve('src') },
     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.onResolve({ filter: /\.css$/ }, ({ path }) => ({ path, namespace: 'offline-style' }))
+        builder.onLoad({ filter: /.*/, namespace: 'offline-style' }, () => ({ contents: '', loader: 'js' }))
+        if (suite === 'hosts') {
+            // Only physical rendering adapters vary. mapService/Shell/selection stay production code.
+            const engineMethods = ['createViewer', 'destroyViewer', 'resizeViewer', 'applyOverlays', 'sampleTerrainHeight', 'sampleTerrainHeights',
+                'createCameraEngine', 'groundHeightAt', 'markerGroundHeight', 'applyViewModeLayers', 'createOverlayReadiness',
+                'applyMotionResolution', 'hasFogStage', 'syncPointMarkers', 'syncAssociatedDevices', 'clearAssociatedDevices',
+                'clearPointMarkers', 'hasPointMarker', 'hasDeviceMarker', 'projectPointMarker', 'projectDeviceMarker',
+                'projectDeviceMarkers', 'projectOverlapSymbols', 'readPointMarkers', 'readAssociatedDevices', 'readDeviceEffects',
+                'applyDeviceEffects', 'applyDeviceReactions', 'clearDeviceLead', 'syncDeviceLead', 'setOverlapPresentation',
+                'readOverlapTargets', 'clearOverlapNudge', 'highlightEntities', 'syncPipeLayer', 'pickPipeObjectAt', 'noteFrameInterval', 'tickAdaptiveResolution']
+            builder.onResolve({ filter: /(?:^|\/)cesiumEngine\.js$/ }, ({ path }) => ({ path, namespace: 'offline-gpu' }))
+            builder.onLoad({ filter: /.*/, namespace: 'offline-gpu' }, () => ({ loader: 'js',
+                contents: engineMethods.map((name) => `export const ${name} = (...args) => globalThis.__VIDEO_GPU__.${name}(...args);`).join('\n') }))
+            builder.onResolve({ filter: /(?:^|\/)threePipeEnhancement\.js$/ }, ({ path, resolveDir, namespace }) => namespace === 'offline-three-real'
+                ? null : ({ path: resolve(resolveDir, path), namespace: 'offline-three-wrapper' }))
+            builder.onLoad({ filter: /.*/, namespace: 'offline-three-wrapper' }, ({ path }) => ({ loader: 'js', resolveDir: resolve(path, '..'),
+                contents: `import { mountPipeEnhancement as mount } from 'offline-three-real'; export function mountPipeEnhancement(host, plan, options) { return mount(host, plan, { ...globalThis.__VIDEO_THREE_OPTIONS__, ...options }); }` }))
+            builder.onResolve({ filter: /^offline-three-real$/ }, () => ({ path: resolve('src/modules/cockpit/services/threePipeEnhancement.js'), namespace: 'offline-three-real' }))
+            builder.onLoad({ filter: /.*/, namespace: 'offline-three-real' }, async ({ path }) => ({ contents: await sourceAt(path), loader: 'js', resolveDir: resolve(path, '..') }))
+        }
         builder.onLoad({ filter: /\.vue$/ }, async ({ path }) => {
             const source = await sourceAt(path)
             const { descriptor, errors } = parse(source, { filename: path })
@@ -50,7 +82,7 @@ await build({
             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
+            if (!sourceRef || !relative(process.cwd(), path).replaceAll('\\', '/').startsWith('src/')) return null
             return { contents: await sourceAt(path), loader: 'js', resolveDir: resolve(path, '..') }
         })
     } }],

+ 106 - 5
tests/cockpit-video/session.mjs

@@ -2,13 +2,16 @@ import test from 'node:test'
 import assert from 'node:assert/strict'
 import { createVideoLiveSession } from '../../src/modules/cockpit/services/videoLiveSession.js'
 
-const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', media: { native: true, url: `https://media.invalid/${code}.mp4` } })
+const data = (code) => ({ deviceCode: code, sessionId: `session-${code}`, mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: `https://media.invalid/${code}.mp4` } })
+const prepared = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`, mode: 'LIVE', status: 'PREPARED' })
+const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
 
 test('simultaneous open commands never create a video for a superseded device', async () => {
     const requests = [], updates = []
     const live = createVideoLiveSession({
-        start: async (code) => { requests.push(code); return data(code) },
-        heartbeat: async () => {}, stop: async () => {},
+        prepare: async (code) => prepared(code),
+        start: async (session) => { const code = typeof session === 'string' ? session : session.deviceCode; requests.push(code); return data(code) },
+        heartbeat: async () => {}, stop: async (session) => stopped(session),
     }, (patch) => updates.push(patch))
     try {
         await Promise.all([live.start('A'), live.start('B')])
@@ -20,8 +23,9 @@ test('simultaneous open commands never create a video for a superseded device',
 test('closing in the same turn cancels an open before it can start server media', async () => {
     const requests = []
     const live = createVideoLiveSession({
-        start: async (code) => { requests.push(code); return data(code) },
-        heartbeat: async () => {}, stop: async () => {},
+        prepare: async (code) => prepared(code),
+        start: async (session) => { const code = typeof session === 'string' ? session : session.deviceCode; requests.push(code); return data(code) },
+        heartbeat: async () => {}, stop: async (session) => stopped(session),
     }, () => {})
     try {
         const opening = live.start('A')
@@ -30,3 +34,100 @@ test('closing in the same turn cancels an open before it can start server media'
         assert.deepEqual(requests, [])
     } finally { await live.dispose() }
 })
+
+class Clock {
+    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 new Promise(setImmediate)
+        }
+        this.now = end; await new Promise(setImmediate)
+    }
+}
+
+test('STARTING is polled using the prepared identity and is never mistaken for active media', async () => {
+    const clock = new Clock(), updates = [], releases = [], statuses = []
+    const live = createVideoLiveSession({
+        prepare: async () => prepared('A'),
+        start: async () => ({ sessionId: 'session-A', status: 'STARTING' }),
+        status: async (session) => { statuses.push(session.sessionId); return data('A') },
+        stop: async (session) => { releases.push(session.sessionId); return stopped(session) },
+        heartbeat: async () => {},
+    }, (patch) => updates.push(patch), clock)
+    try {
+        const opening = live.start('A'); await new Promise(setImmediate)
+        assert.equal(updates.some((patch) => patch.media), false)
+        await clock.advance(5000); await opening
+        assert.deepEqual(statuses, ['session-A'])
+        assert.equal(updates.some((patch) => patch.media?.url.endsWith('/A.mp4')), true)
+        assert.deepEqual(releases, [])
+    } finally { await live.dispose() }
+})
+
+test('accepted cancellation stays unconfirmed until status attests terminal cleanup', async () => {
+    const clock = new Clock(), visible = {}, statusCalls = []
+    const live = createVideoLiveSession({
+        prepare: async () => prepared('A'), start: async () => data('A'), heartbeat: async () => {},
+        stop: async () => ({ sessionId: 'session-A', status: 'CLEANUP_PENDING' }),
+        status: async (session) => { statusCalls.push(session.sessionId); return { sessionId: 'session-A', status: 'STOPPED' } },
+    }, (patch) => Object.assign(visible, patch), clock)
+    try {
+        await live.start('A'); live.ready(); await live.stop()
+        assert.equal(visible.cleanupError.includes('未确认'), true)
+        await clock.advance(5000)
+        assert.deepEqual(statusCalls, ['session-A'])
+        assert.equal(visible.cleanupError, '')
+        assert.equal(visible.media, null)
+    } finally { await live.dispose() }
+})
+
+test('a confirmed cancel permits the new device while the old live response is still in flight', async () => {
+    const updates = [], starts = []
+    let arrived, finishOld
+    const entered = new Promise((resolve) => { arrived = resolve })
+    const late = new Promise((resolve) => { finishOld = resolve })
+    const live = createVideoLiveSession({
+        prepare: async (code) => prepared(code),
+        start: async (session) => { starts.push(session.deviceCode); if (session.deviceCode === 'A') { arrived(); return late }; return data('B') },
+        stop: async (session) => stopped(session), heartbeat: async () => {},
+    }, (patch) => updates.push(patch))
+    const first = live.start('A')
+    try {
+        await entered
+        await live.start('B')
+        assert.deepEqual(starts, ['A', 'B'])
+        finishOld(data('A')); await first
+        const displayed = updates.filter((patch) => patch.media).map((patch) => patch.media.url)
+        assert.deepEqual(displayed, ['https://media.invalid/B.mp4'])
+    } finally { finishOld(data('A')); await first; await live.dispose() }
+})
+
+test('cancel releases the prepared identity while live is still pending and a late response cannot publish media', async () => {
+    const releases = [], updates = []
+    let started, respond
+    const entered = new Promise((resolve) => { started = resolve })
+    const delayed = new Promise((resolve) => { respond = resolve })
+    const live = createVideoLiveSession({
+        prepare: async (code) => prepared(code),
+        start: async () => { started(); return delayed },
+        stop: async (session) => { releases.push({ sessionId: session.sessionId, sessionToken: session.sessionToken }); return stopped(session) },
+        heartbeat: async () => {},
+    }, (patch) => updates.push(patch))
+    const opening = live.start('A')
+    try {
+        await entered
+        await live.stop()
+        assert.deepEqual(releases, [{ sessionId: 'session-A', sessionToken: 'owner-A' }])
+        respond(data('A')); await opening
+        assert.equal(updates.some((patch) => patch.media), false)
+    } finally { respond(data('A')); await opening; await live.dispose() }
+})

+ 35 - 0
tests/cockpit-video/transport.mjs

@@ -0,0 +1,35 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createVideoApi } from '../../src/modules/cockpit/services/videoApi.js'
+
+const code = 'CAM/03 空格'
+const config = { dataSource: 'real', api: { baseUrl: 'http://fixture.invalid/api/cockpit/v1', token: 'never-send' } }
+const response = (data, status = 200) => Response.json({ code: 200, msg: '操作成功', data }, { status })
+
+test('prepare assigns an owned identity before any live request and later operations carry only its credential', async () => {
+    const calls = []
+    const api = createVideoApi({ config, fetchImpl: async (url, options) => {
+        calls.push({ url: new URL(url), options, body: JSON.parse(options.body) })
+        if (url.endsWith('/prepare')) return response({ deviceCode: code, deviceName: '摄像机03', sessionId: 'session-03', sessionToken: 'owner-03', mode: 'LIVE', status: 'PREPARED' }, 201)
+        if (url.endsWith('/live')) return response({ deviceCode: code, sessionId: 'session-03', mode: 'LIVE', status: 'ACTIVE', media: { native: true, url: 'https://media.invalid/live.mp4' } })
+        if (url.endsWith('/status')) return response({ sessionId: 'session-03', status: 'STOPPED' })
+        return response({ sessionId: 'session-03', status: 'CLEANUP_PENDING', category: 'CLEANUP_UNCONFIRMED' }, 202)
+    } })
+    const identity = await api.prepare(code)
+    assert.equal(calls.length, 1)
+    assert.equal(calls[0].url.pathname, '/api/cockpit/v1/video/sessions/prepare')
+    assert.deepEqual(calls[0].body, { deviceCode: code })
+    assert.equal(identity.status, 'PREPARED')
+    assert.equal(identity.media, undefined)
+    const started = await api.start(identity)
+    assert.equal(started.status, 'ACTIVE')
+    assert.equal(started.sessionToken, undefined)
+    assert.equal((await api.stop(identity)).status, 'CLEANUP_PENDING')
+    assert.equal((await api.status(identity)).status, 'STOPPED')
+    for (const call of calls) {
+        assert.equal(call.options.headers.Authorization, undefined)
+        assert.equal(call.options.credentials, 'omit')
+        assert.equal(call.url.search, '')
+    }
+    for (const call of calls.slice(1)) assert.deepEqual(call.body, { sessionToken: 'owner-03' })
+})