Ver Fonte

fix(cockpit): 修复历史TAB关面板与查询态字段错配

ChenJZ há 4 horas atrás
pai
commit
c31cbf09f9

+ 12 - 5
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -15,9 +15,9 @@
                     <div class="video-monitor__range">
                         <label>开始 <input v-model="range.startTime" type="text" placeholder="yyyy-MM-dd HH:mm:ss" /></label>
                         <label>结束 <input v-model="range.endTime" type="text" placeholder="yyyy-MM-dd HH:mm:ss" /></label>
-                        <button type="button" :disabled="state.phase === 'querying'" @click="video.queryHistory(range.startTime, range.endTime)">查询录像</button>
+                        <button type="button" :disabled="state.historyPhase === 'querying'" @click="video.queryHistory(range.startTime, range.endTime)">查询录像</button>
                     </div>
-                    <p v-if="state.phase === 'querying'">正在查询录像…</p>
+                    <p v-if="state.historyPhase === 'querying'">正在查询录像…</p>
                     <p v-else-if="state.historyError" role="alert">{{ state.historyError }}</p>
                     <p v-else-if="state.historyPhase === 'listed' && state.empty">该时间段没有录像(查询范围不代表留存期限)</p>
                     <ul v-else-if="state.records?.length" class="video-monitor__records">
@@ -62,13 +62,20 @@ 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: '正在播放', retrying: '连接中断,正在自动重连', failed: '播放失败', querying: '正在查询录像', listed: '已返回录像列表' }
-// The panel owns the visible tab; switching always ends the previous stream first.
+// Switching tabs ends the current stream but must keep the monitor open.
 const mode = ref('live')
 const range = reactive(video.historyWindow())
 const switchMode = async (next) => {
     if (next === mode.value) return
-    if (next === 'history') { await video.close(); mode.value = 'history'; await video.queryHistory(range.startTime, range.endTime) }
-    else { await video.stopPlayback(); mode.value = 'live'; await video.open(state.value.device, state.value.ownerSource) }
+    if (next === 'history') {
+        await video.stopLive()
+        mode.value = 'history'
+        await video.queryHistory(range.startTime, range.endTime)
+    } else {
+        await video.stopPlayback()
+        mode.value = 'live'
+        await video.startLive()
+    }
 }
 const playRecord = (record) => video.play(record.startTime, record.endTime)
 const retryNote = computed(() => state.value.phase !== 'retrying' || !state.value.retryAttempt

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

@@ -8,6 +8,7 @@ const messages = {
     INVALID_REQUEST: '视频请求参数不正确', DEVICE_NOT_FOUND: '视频设备不存在', VIDEO_UNAVAILABLE: '此设备暂不支持实时监控',
     SESSION_FORBIDDEN: '播放会话不存在或无权操作', SESSION_EXPIRED: '播放会话已失效', VIDEO_START_FAILED: '视频连接失败,请稍后重试',
     VIDEO_READ_FAILED: '视频能力暂不可用', SESSION_HEARTBEAT_FAILED: '播放会话维护失败', CLEANUP_UNCONFIRMED: '会话释放未确认,等待服务端回收',
+    PLAYBACK_OCCUPIED: '此通道已有录像回放,请结束后重试', UNSUPPORTED_MEDIA: '当前播放方案不支持返回的媒体协议',
 }
 const sessionPath = (id, action) => {
     if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new VideoRequestError('INVALID_RESPONSE', '播放会话标识无效')

+ 19 - 10
src/modules/cockpit/stores/videoStore.js

@@ -11,16 +11,15 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         playbackMedia: null, historyPhase: '', historyError: '', records: [], empty: false })
     const playerOptions = shallowRef({})
     let options = {}, controller, history
-    const publishRaw = (patch) => { state.value = { ...state.value, ...patch } }
-    // The live session publishes its own media; history is a separate viewer with its own field.
-    const publish = (patch) => publishRaw(patch)
-    const publishHistory = (patch) => {
-        const { media, error, phase, ...rest } = patch
+    const publish = (patch) => { state.value = { ...state.value, ...patch } }
+    // History is a separate viewer: its media/error/phase live under their own fields so the
+    // live player never consumes a playback stream or vice versa.
+    const publishHistory = ({ media, error, phase, ...rest }) => {
         const next = { ...rest }
-        if (Object.hasOwn(patch, 'media')) next.playbackMedia = media
-        if (Object.hasOwn(patch, 'error')) next.historyError = error
-        if (Object.hasOwn(patch, 'phase')) next.historyPhase = phase
-        publishRaw(next)
+        if (media !== undefined) next.playbackMedia = media
+        if (error !== undefined) next.historyError = error
+        if (phase !== undefined) next.historyPhase = phase
+        publish(next)
     }
     function configure(next = {}) {
         if (controller) throw new Error('请在建立视频会话前配置传输')
@@ -50,6 +49,16 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         await session().start(device.deviceCode)
     }
     async function close() { publish({ open: false, playbackMedia: null }); if (history) await history.dispose(); history = undefined; if (controller) await controller.stop() }
+    /** Ends the live stream but keeps the monitor open; tab switches use this instead of close(). */
+    async function stopLive() { if (controller) await controller.stop() }
+    async function startLive() {
+        const device = state.value.device
+        if (!device?.deviceCode) return
+        if (device.typeCode !== '20' || device.bound !== true || device.capabilities?.live !== true) {
+            publish({ phase: 'failed', error: '当前设备未确认实时视频能力' }); return
+        }
+        await session().start(device.deviceCode)
+    }
     const playback = () => history || (history = createVideoPlaybackSession(createVideoApi(options), publishHistory, options.timers))
     /** History only lists what the device/NVR returns; it never starts a stream by itself. */
     async function queryHistory(startTime, endTime) {
@@ -74,5 +83,5 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         else controller?.fail(error instanceof Error ? error : error?.message || '视频播放失败')
     }
     return { state, playerOptions, configure, loadDevice, open, close, retry, retryCleanup, ready, fail,
-        queryHistory, play, stopPlayback, historyWindow }
+        stopLive, startLive, queryHistory, play, stopPlayback, historyWindow }
 })

+ 73 - 1
tests/cockpit-video/hosts.mjs

@@ -18,7 +18,8 @@ const point = { pointCode: 'P-FIXED', pointName: '无位置摄像机监测点',
     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 device = { deviceCode: cameraCode, deviceName: camera.deviceName, typeCode: '20', bound: true,
+    capabilities: { live: true, records: true, playback: true, playbackControl: false } }
 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 },
@@ -128,6 +129,21 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
         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/records') {
+            assert.equal(url.searchParams.get('deviceCode'), cameraCode)
+            const rows = server.recordings ?? []
+            return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
+                startTime: url.searchParams.get('startTime'), endTime: url.searchParams.get('endTime'),
+                records: rows, empty: rows.length === 0 })
+        }
+        if (path === '/video/playback') {
+            assert.equal(body.deviceCode, cameraCode)
+            if (server.playbackBusy) return { ok: false, status: 409, json: async () => ({ code: 409, data: { category: 'PLAYBACK_OCCUPIED', message: '此通道已有录像回放,请结束后重试' } }) }
+            return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
+                sessionId: 'session-pb', sessionToken: 'owner-pb', mode: 'PLAYBACK',
+                startTime: body.startTime, endTime: body.endTime,
+                media: { native: true, url: 'https://media.invalid/playback.mp4' } })
+        }
         if (path === '/video/sessions/prepare') {
             assert.deepEqual(body, { deviceCode: cameraCode })
             return envelope({ ...device, sessionId: 'session-host', sessionToken: 'owner-host', mode: 'LIVE', status: 'PREPARED' }, 201)
@@ -146,6 +162,10 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
             assert.deepEqual(body, { sessionToken: 'owner-host' })
             return envelope({ sessionId: 'session-host', deviceCode: cameraCode, mode: 'LIVE', status: 'STOPPED' })
         }
+        if (path === '/video/sessions/session-pb/stop' || path === '/video/sessions/session-pb/status') {
+            assert.deepEqual(body, { sessionToken: 'owner-pb' })
+            return envelope({ sessionId: 'session-pb', deviceCode: cameraCode, mode: 'PLAYBACK', status: 'STOPPED' })
+        }
         unexpected.push(path); throw new Error('Unconfigured offline request: ' + path)
     }
     globalThis.fetch = fetchImpl
@@ -251,6 +271,58 @@ test('真实Root旅程中临时失败展示重试状态,关闭后不再自动
     } finally { await page.dispose() }
 })
 
+test('历史录像TAB不关闭面板,列片后才起流,占用不抢停别人', async () => {
+    const page = await mountRoot()
+    try {
+        page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
+        const detail = await openMainDetail(page)
+        detail.querySelector('[data-video-open]').click(); await settle()
+        const dialog = () => document.querySelector('[data-video-monitor]')
+        assert.ok(dialog(), '监控面板应已打开')
+        // Switching to the history tab must end the live stream but keep the monitor mounted.
+        const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
+        assert.equal(tabs.length, 2, '两个TAB都应存在')
+        tabs[1].click(); await settle()
+        assert.ok(dialog(), '切到历史TAB后面板不得消失')
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, 1, '应查询录像')
+        // Listing alone must never start playback.
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/playback')).length, 0)
+        assert.ok(dialog().textContent.includes('CAM/03'), dialog().textContent)
+        // Choosing a recording starts the owned playback session.
+        const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
+        assert.ok(play); play.click(); await settle()
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/playback')).length, 1)
+        assert.ok(dialog().querySelector('video[aria-label="历史录像画面"]'), '应展示回放画面')
+        // Closing releases the playback session and never leaves the panel half-open.
+        dialog().querySelector('[aria-label="关闭监控"]').click(); await settle()
+        assert.equal(document.querySelector('[data-video-monitor]'), null)
+        assert.ok(page.requests.some((request) => request.path.includes('session-pb') && request.path.endsWith('/stop')), '回放必须被释放')
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('历史回放占用时提示占用且不释放他人回放', async () => {
+    const page = await mountRoot()
+    try {
+        page.server.recordings = [{ name: 'CAM/03', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', type: 'time' }]
+        page.server.playbackBusy = true
+        const detail = await openMainDetail(page)
+        detail.querySelector('[data-video-open]').click(); await settle()
+        const dialog = () => document.querySelector('[data-video-monitor]')
+        const tabs = [...dialog().querySelectorAll('button')].filter((button) => ['实时视频', '历史录像'].includes(button.textContent.trim()))
+        tabs[1].click(); await settle()
+        const play = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '播放')
+        play.click(); await settle()
+        // The occupancy must surface as its own message, not a generic failure.
+        assert.ok(dialog().textContent.includes('已有录像回放'), dialog().textContent)
+        assert.equal(page.requests.filter((request) => request.path.includes('session-pb')).length, 0,
+            '占用时不得停止或触碰他人的回放会话')
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
 test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
     const page = await mountRoot()
     try {