Ver código fonte

feat(cockpit): 历史回放控制灰色门控与三类旅程结果

ChenJZ 3 horas atrás
pai
commit
42c1ab58fb

+ 19 - 1
src/modules/cockpit/components/VideoMonitorPanel.vue

@@ -22,10 +22,16 @@
                     <p v-else-if="state.historyPhase === 'listed' && state.empty">该时间段没有录像(查询范围不代表留存期限)</p>
                     <ul v-else-if="state.records?.length" class="video-monitor__records">
                         <li v-for="(record, index) in state.records" :key="index">
-                            <span>{{ record.name || '录像' }} {{ record.startTime }} ~ {{ record.endTime }}</span>
+                            <span>{{ record.name || '录像' }}{{ record.type ? ` · ${record.type}` : '' }} {{ record.startTime }} ~ {{ record.endTime }}</span>
                             <button type="button" @click="playRecord(record)">播放</button>
                         </li>
                     </ul>
+                    <!-- Unconfirmed playback controls stay visible but grey, each stating why. -->
+                    <div v-if="state.playbackMedia" class="video-monitor__playback-controls">
+                        <button v-for="control in playbackControls" :key="control.key" type="button" data-playback-control
+                                :data-playback-control="control.key" :disabled="!control.enabled"
+                                :title="control.enabled ? '' : control.reason" @click="runControl(control)">{{ control.label }}</button>
+                    </div>
                 </template>
                 <p class="video-monitor__status" role="status">{{ labels[state.phase] || '未播放' }}</p>
                 <p v-if="retryNote" class="video-monitor__status" role="status">{{ retryNote }}</p>
@@ -78,6 +84,18 @@ const switchMode = async (next) => {
     }
 }
 const playRecord = (record) => video.play(record.startTime, record.endTime)
+// Every playback control is advertised with its real availability. Until a device is confirmed,
+// each stays grey with a stated reason instead of silently disappearing.
+const CONTROL_REASON = '真实设备未确认此控制能力,暂不开放'
+const playbackControls = computed(() => {
+    const granted = state.value.playbackControls || {}
+    const control = (key, label) => ({ key, label, enabled: granted[key] === true, reason: CONTROL_REASON })
+    return [control('pause', '暂停'), control('resume', '继续'), control('speed-0.25', '0.25×'),
+        control('speed-0.5', '0.5×'), control('speed-1', '1×'), control('speed-2', '2×'),
+        control('speed-4', '4×'), control('seek', '跳转')]
+})
+// A disabled control must not reach the server even when activated programmatically.
+const runControl = (control) => { if (control.enabled) void video.controlPlayback(control.key) }
 const retryNote = computed(() => state.value.phase !== 'retrying' || !state.value.retryAttempt
     ? ''
     : `第 ${state.value.retryAttempt} 次自动重试,等待 ${Math.round((state.value.retryDelayMs || 0) / 1000)} 秒(剩余 ${state.value.retriesLeft ?? 0} 次)`)

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

@@ -7,7 +7,8 @@ export class VideoRequestError extends Error {
 const messages = {
     INVALID_REQUEST: '视频请求参数不正确', DEVICE_NOT_FOUND: '视频设备不存在', VIDEO_UNAVAILABLE: '此设备暂不支持实时监控',
     SESSION_FORBIDDEN: '播放会话不存在或无权操作', SESSION_EXPIRED: '播放会话已失效', VIDEO_START_FAILED: '视频连接失败,请稍后重试',
-    VIDEO_READ_FAILED: '视频能力暂不可用', SESSION_HEARTBEAT_FAILED: '播放会话维护失败', CLEANUP_UNCONFIRMED: '会话释放未确认,等待服务端回收',
+    VIDEO_READ_FAILED: '视频能力暂不可用', VIDEO_RECORDS_FAILED: '录像查询失败,请稍后重试',
+    SESSION_HEARTBEAT_FAILED: '播放会话维护失败', CLEANUP_UNCONFIRMED: '会话释放未确认,等待服务端回收',
     PLAYBACK_OCCUPIED: '此通道已有录像回放,请结束后重试', UNSUPPORTED_MEDIA: '当前播放方案不支持返回的媒体协议',
 }
 const sessionPath = (id, action) => {
@@ -79,6 +80,7 @@ export function createVideoApi(options = {}) {
             .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', 'FAILED', 'CLEANUP_PENDING'])),
+        control: ({ sessionId, sessionToken }, action) => request(sessionPath(sessionId, 'control'), { sessionToken, action }),
     }
 }
 

+ 32 - 5
src/modules/cockpit/services/videoPlaybackSession.js

@@ -45,7 +45,12 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
     const reset = () => { current = null; identity = null; records = []; publish({ media: null, records: [], empty: false, error: '', cleanupError: '' }) }
 
     async function query(deviceCode, startTime, endTime) {
-        validate(startTime, endTime)
+        // An illegal window is refused locally and surfaced, never sent to the server.
+        try { validate(startTime, endTime) } catch (error) {
+            records = []
+            publish({ phase: 'failed', records: [], empty: false, error: error?.message || '录像时间范围不合法' })
+            return []
+        }
         records = []
         publish({ phase: 'querying', records: [], empty: false, error: '', cleanupError: '', window: { startTime, endTime } })
         let reply
@@ -64,7 +69,10 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
     }
 
     async function play(deviceCode, startTime, endTime) {
-        validate(startTime, endTime)
+        try { validate(startTime, endTime) } catch (error) {
+            publish({ phase: 'failed', media: null, error: error?.message || '录像时间范围不合法' })
+            return false
+        }
         // The previous stream of whichever mode must end before this one replaces it.
         await stop()
         if (disposed) return false
@@ -90,12 +98,31 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
             if (version === generation) publish({ phase: 'failed', media: null, error: error?.message || '回放媒体不可用' })
             return false
         }
-        identity = { sessionId: started.sessionId, sessionToken: started.sessionToken, deviceCode, mode: 'PLAYBACK' }
+        identity = { sessionId: started.sessionId, sessionToken: started.sessionToken, deviceCode, mode: 'PLAYBACK',
+            controls: { pause: false, resume: false, speed: false, seek: false } }
         current = identity
-        publish({ phase: 'playing', media, window: { startTime, endTime }, controls: { pause: false, resume: false, speed: false, seek: false } })
+        // Controls stay unconfirmed until a real device proves support; the panel renders them grey.
+        publish({ phase: 'playing', media, window: { startTime, endTime }, controls: identity.controls })
         return true
     }
 
+    /** Only a control the device confirmed may be sent; everything else is refused locally. */
+    async function control(key) {
+        const owned = current
+        if (!owned || !/^(pause|resume|seek|speed-(0\.25|0\.5|1|2|4))$/.test(String(key))) return false
+        const granted = owned.controls || {}
+        const allowed = key === 'pause' ? granted.pause : key === 'resume' ? granted.resume
+            : key === 'seek' ? granted.seek : granted.speed === true
+        if (allowed !== true) return false
+        try {
+            await api.control(owned, key)
+            return true
+        } catch (error) {
+            publish({ error: error?.message || '回放控制失败' })
+            return false
+        }
+    }
+
     async function release(session) {
         if (!session) return true
         try {
@@ -119,5 +146,5 @@ export function createVideoPlaybackSession(api, update, timers = globalThis) {
     }
 
     async function dispose() { await stop(); disposed = true }
-    return { query, play, stop, dispose, records: () => records, current: () => current }
+    return { query, play, control, stop, dispose, records: () => records, current: () => current }
 }

+ 6 - 3
src/modules/cockpit/stores/videoStore.js

@@ -14,11 +14,12 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
     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 publishHistory = ({ media, error, phase, controls, ...rest }) => {
         const next = { ...rest }
         if (media !== undefined) next.playbackMedia = media
         if (error !== undefined) next.historyError = error
         if (phase !== undefined) next.historyPhase = phase
+        if (controls !== undefined) next.playbackControls = controls
         publish(next)
     }
     function configure(next = {}) {
@@ -72,7 +73,9 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         if (!device?.deviceCode) return false
         return playback().play(device.deviceCode, startTime, endTime)
     }
-    async function stopPlayback() { if (history) await history.stop(); publish({ playbackMedia: null }) }
+    async function stopPlayback() { if (history) await history.stop(); publish({ playbackMedia: null, playbackControls: {} }) }
+    /** Only a confirmed control may be sent; the panel greys the rest out. */
+    async function controlPlayback(key) { return history ? history.control(key) : false }
     const historyWindow = (now) => defaultHistoryWindow(now)
     async function retry() { if (state.value.device) await session().start(state.value.device.deviceCode) }
     async function retryCleanup() { if (controller) await controller.stop() }
@@ -83,5 +86,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,
-        stopLive, startLive, queryHistory, play, stopPlayback, historyWindow }
+        stopLive, startLive, queryHistory, play, stopPlayback, controlPlayback, historyWindow }
 })

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

@@ -94,7 +94,8 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
         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 server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true, liveFailures: 0 }
+    const server = { snapshotGate: null, pointGate: null, pointFails: false, cameraPresent: true, liveFailures: 0,
+        recordings: [], recordsFail: false, playbackBusy: false }
     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
@@ -131,6 +132,7 @@ async function mountRoot({ positioned = false, underground = false } = {}) {
         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)
+            if (server.recordsFail) return { ok: false, status: 502, json: async () => ({ code: 502, data: { category: 'VIDEO_RECORDS_FAILED', message: '录像查询失败,请稍后重试' } }) }
             const rows = server.recordings ?? []
             return envelope({ deviceCode: cameraCode, deviceName: device.deviceName,
                 startTime: url.searchParams.get('startTime'), endTime: url.searchParams.get('endTime'),
@@ -323,6 +325,85 @@ test('历史回放占用时提示占用且不释放他人回放', async () => {
     } 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' }]
+        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()
+        // Unconfirmed controls must be present but grey, each with a reason, not silently absent.
+        const controls = [...dialog().querySelectorAll('[data-playback-control]')]
+        assert.ok(controls.length >= 6, 'pause/resume and 0.25/0.5/1/2/4x plus seek must all be rendered')
+        for (const control of controls) {
+            assert.equal(control.disabled, true, `${control.dataset.playbackControl} 未确认时必须灰色`)
+            assert.ok((control.getAttribute('title') || '').trim().length > 0, `${control.dataset.playbackControl} 必须说明为何不可用`)
+        }
+        // A grey control must never send a request even if activated programmatically.
+        for (const control of controls) control.click()
+        await settle()
+        assert.equal(page.requests.filter((request) => request.path.includes('/control')).length, 0,
+            '灰色控件不得发出任何控制请求')
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('历史页区分无录像、查询失败与非法时间范围三种结果', async () => {
+    const page = await mountRoot()
+    try {
+        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()))
+        page.server.recordings = []
+        tabs[1].click(); await settle()
+        // No recordings is a legitimate answer that must not look like a failure.
+        assert.ok(dialog().textContent.includes('没有录像'), dialog().textContent)
+        // A failed query is a different visible state and is never auto-retried.
+        page.server.recordsFail = true
+        const queryButton = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '查询录像')
+        queryButton.click(); await settle()
+        assert.ok(dialog().textContent.includes('查询失败'), dialog().textContent)
+        const after = page.requests.filter((request) => request.path.endsWith('/records')).length
+        await new Promise((resolve) => setTimeout(resolve, 2500))
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, after,
+            '查询失败不得自动重试')
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
+test('历史页非法时间范围在本地拒绝且不发查询请求', async () => {
+    const page = await mountRoot()
+    try {
+        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()))
+        page.server.recordings = []
+        tabs[1].click(); await settle()
+        const before = page.requests.filter((request) => request.path.endsWith('/records')).length
+        const inputs = [...dialog().querySelectorAll('.video-monitor__range input')]
+        assert.equal(inputs.length, 2)
+        // A 7-day+ span is refused before any request is made.
+        inputs[0].value = '2026-11-01 00:00:00'; inputs[0].dispatchEvent(new Event('input'))
+        inputs[1].value = '2026-11-10 00:00:00'; inputs[1].dispatchEvent(new Event('input'))
+        await settle()
+        const queryButton = [...dialog().querySelectorAll('button')].find((button) => button.textContent.trim() === '查询录像')
+        queryButton.click(); await settle()
+        assert.equal(page.requests.filter((request) => request.path.endsWith('/records')).length, before,
+            '超 7 天的范围不得发到服务端')
+        assert.ok(dialog().textContent.includes('7 天'), dialog().textContent)
+        assert.deepEqual(page.errors, [])
+        assert.deepEqual(page.unexpected, [])
+    } finally { await page.dispose() }
+})
+
 test('已关闭的设备详情入口同轮迟到点击不得创建观看', async () => {
     const page = await mountRoot()
     try {

+ 34 - 8
tests/cockpit-video/playback.mjs

@@ -36,27 +36,33 @@ test('the default history window is today 00:00 to now in UTC+8', async () => {
 /** The 7-day ceiling is a query bound, not a retention promise. */
 test('a window longer than seven days is refused before any request', async () => {
     const asked = []
+    let visible = {}
     const playback = createVideoPlaybackSession({
         records: async (...args) => { asked.push(args); return { records: [] } },
         playback: async () => started('A'), stop: async (session) => stopped(session),
-    }, () => {})
+    }, (patch) => { visible = { ...visible, ...patch } })
     try {
-        await assert.rejects(
-            () => playback.query('A', '2026-11-01 00:00:00', '2026-11-10 00:00:00'),
-            (error) => error.category === 'INVALID_REQUEST')
+        // Refused locally and surfaced as an error state, never sent to the server.
+        assert.deepEqual(await playback.query('A', '2026-11-01 00:00:00', '2026-11-10 00:00:00'), [])
         assert.deepEqual(asked, [], 'an illegal window must never reach the server')
+        assert.equal(visible.phase, 'failed')
+        assert.ok(visible.error.includes('7 天'), visible.error)
     } finally { await playback.dispose() }
 })
 
 test('an inverted or malformed window is refused locally', async () => {
+    const asked = []
+    let visible = {}
     const playback = createVideoPlaybackSession({
-        records: async () => ({ records: [] }), playback: async () => started('A'),
-        stop: async (session) => stopped(session),
-    }, () => {})
+        records: async (...args) => { asked.push(args); return { records: [] } },
+        playback: async () => started('A'), stop: async (session) => stopped(session),
+    }, (patch) => { visible = { ...visible, ...patch } })
     try {
         for (const [from, to] of [['2026-11-10 02:00:00', '2026-11-10 01:00:00'], ['2026-13-10 00:00:00', '2026-11-10 01:00:00']]) {
-            await assert.rejects(() => playback.query('A', from, to), (error) => error.category === 'INVALID_REQUEST')
+            assert.deepEqual(await playback.query('A', from, to), [])
+            assert.equal(visible.phase, 'failed')
         }
+        assert.deepEqual(asked, [], 'an illegal window must never reach the server')
     } finally { await playback.dispose() }
 })
 
@@ -125,6 +131,26 @@ test('an occupied channel is reported without stopping the existing playback', a
     } finally { await playback.dispose() }
 })
 
+test('an unconfirmed playback control is refused locally without a request', async () => {
+    const sent = []
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }), playback: async () => started('A'),
+        control: async (session, action) => { sent.push(action); return {} },
+        stop: async (session) => stopped(session),
+    }, () => {})
+    try {
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        // The default capability set confirms nothing, so every control must be refused locally.
+        for (const key of ['pause', 'resume', 'seek', 'speed-1', 'speed-4']) {
+            assert.equal(await playback.control(key), false, `${key} 未确认时不得发送`)
+        }
+        assert.deepEqual(sent, [], 'no unconfirmed control may reach the server')
+        // An unrecognised action is rejected too, never forwarded blindly.
+        assert.equal(await playback.control('reboot'), false)
+        assert.deepEqual(sent, [])
+    } finally { await playback.dispose() }
+})
+
 test('stopping releases the owned playback session and reports unconfirmed cleanup truthfully', async () => {
     const clock = new Clock(), updates = []
     let visible = {}