Quellcode durchsuchen

feat(cockpit): 历史录像查询与受控回放页

ChenJZ vor 4 Stunden
Ursprung
Commit
062ee0e6e8

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

@@ -6,13 +6,36 @@
                     <div><h2 id="video-monitor-title">{{ state.device?.deviceName || '视频监控设备' }}</h2><p>{{ state.device?.deviceCode }}</p></div>
                     <button type="button" aria-label="关闭监控" @click="video.close">关闭</button>
                 </header>
-                <div class="video-monitor__tabs"><strong>实时视频</strong><button type="button" disabled title="历史录像能力尚未接入">历史录像</button></div>
+                <div class="video-monitor__tabs">
+                    <button type="button" :aria-pressed="mode === 'live'" @click="switchMode('live')">实时视频</button>
+                    <button type="button" :aria-pressed="mode === 'history'" :disabled="!state.device?.capabilities?.records"
+                            :title="state.device?.capabilities?.records ? '' : '此设备未确认录像能力'" @click="switchMode('history')">历史录像</button>
+                </div>
+                <template v-if="mode === 'history'">
+                    <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>
+                    </div>
+                    <p v-if="state.phase === '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">
+                        <li v-for="(record, index) in state.records" :key="index">
+                            <span>{{ record.name || '录像' }} {{ record.startTime }} ~ {{ record.endTime }}</span>
+                            <button type="button" @click="playRecord(record)">播放</button>
+                        </li>
+                    </ul>
+                </template>
                 <p class="video-monitor__status" role="status">{{ labels[state.phase] || '未播放' }}</p>
                 <p v-if="retryNote" class="video-monitor__status" role="status">{{ retryNote }}</p>
                 <div class="video-monitor__body">
                     <aside><h3>云台控制</h3><button type="button" disabled title="停止保障尚未验证,不提供物理控制">方向/变倍</button><p>能力尚未开放</p></aside>
                     <div class="video-monitor__screen">
                         <VideoPlayer v-if="state.media" :media="state.media" :options="playerOptions" @ready="video.ready" @error="video.fail" />
+                        <video v-else-if="state.playbackMedia" :src="state.playbackMedia.url" controls autoplay muted playsinline
+                               controlslist="nodownload noremoteplayback noplaybackrate" disablepictureinpicture disableremoteplayback
+                               aria-label="历史录像画面" @contextmenu.prevent />
                         <p v-else-if="state.phase === 'retrying'">等待自动重连…</p>
                         <p v-else>{{ state.error || '等待视频连接' }}</p>
                     </div>
@@ -29,7 +52,7 @@
     </Teleport>
 </template>
 <script setup>
-import { watch, onMounted, onBeforeUnmount, nextTick, computed } from 'vue'
+import { watch, onMounted, onBeforeUnmount, nextTick, computed, ref, reactive } from 'vue'
 import { storeToRefs } from 'pinia'
 import { useVideoStore } from '../stores/videoStore.js'
 import { useUiStore } from '../stores/uiStore.js'
@@ -38,7 +61,16 @@ import { useEnhancementDevicesStore } from '../stores/enhancementDevicesStore.js
 import VideoPlayer from './VideoPlayer.vue'
 const video = useVideoStore(), ui = useUiStore(), detail = useDetailStore(), enhancement = useEnhancementDevicesStore()
 const { state, playerOptions } = storeToRefs(video)
-const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', retrying: '连接中断,正在自动重连', failed: '播放失败' }
+const labels = { idle: '未播放', starting: '正在建立视频会话', connecting: '等待视频画面', playing: '正在播放', retrying: '连接中断,正在自动重连', failed: '播放失败', querying: '正在查询录像', listed: '已返回录像列表' }
+// The panel owns the visible tab; switching always ends the previous stream first.
+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) }
+}
+const playRecord = (record) => video.play(record.startTime, record.endTime)
 const retryNote = computed(() => state.value.phase !== 'retrying' || !state.value.retryAttempt
     ? ''
     : `第 ${state.value.retryAttempt} 次自动重试,等待 ${Math.round((state.value.retryDelayMs || 0) / 1000)} 秒(剩余 ${state.value.retriesLeft ?? 0} 次)`)

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

@@ -60,6 +60,15 @@ export function createVideoApi(options = {}) {
     }
     return {
         device: (deviceCode) => request(`/video/device?${new URLSearchParams({ deviceCode })}`),
+        records: (deviceCode, startTime, endTime) => request(`/video/records?${new URLSearchParams({ deviceCode, startTime, endTime })}`),
+        playback: (deviceCode, startTime, endTime) => request('/video/playback', { deviceCode, startTime, endTime }, 100000)
+            .then((data) => {
+                if (data.mode !== 'PLAYBACK') throw new VideoRequestError('INVALID_RESPONSE', '返回的回放会话与当前设备不匹配')
+                if (typeof data.sessionId !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(data.sessionId)
+                    || typeof data.sessionToken !== 'string' || !data.sessionToken)
+                    throw new VideoRequestError('INVALID_RESPONSE', '回放未取得完整观看身份')
+                return data
+            }),
         prepare: (deviceCode) => request('/video/sessions/prepare', { deviceCode }),
         start: (session) => typeof session === 'string'
             ? request('/video/live', { deviceCode: session }, 100000)

+ 123 - 0
src/modules/cockpit/services/videoPlaybackSession.js

@@ -0,0 +1,123 @@
+import { selectVideoMedia, VideoRequestError } from './videoApi.js'
+
+const WINDOW = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/
+const MAX_SPAN_MS = 7 * 24 * 60 * 60 * 1000
+const UTC8_OFFSET_MS = 8 * 60 * 60 * 1000
+
+/** Today 00:00 to now, expressed as UTC+8 wall-clock text regardless of the machine zone. */
+export function defaultHistoryWindow(now = () => Date.now()) {
+    const shifted = new Date(now() + UTC8_OFFSET_MS)
+    const pad = (value) => String(value).padStart(2, '0')
+    const day = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad(shifted.getUTCDate())}`
+    const clock = `${pad(shifted.getUTCHours())}:${pad(shifted.getUTCMinutes())}:${pad(shifted.getUTCSeconds())}`
+    return { startTime: `${day} 00:00:00`, endTime: `${day} ${clock}` }
+}
+
+/** Parses a UTC+8 wall-clock string into a comparable instant, or null when malformed. */
+function instant(text) {
+    const match = WINDOW.exec(typeof text === 'string' ? text : '')
+    if (!match) return null
+    const [, year, month, day, hour, minute, second] = match.map(Number)
+    const value = Date.UTC(year, month - 1, day, hour, minute, second)
+    const back = new Date(value)
+    // Reject impossible calendar values instead of letting Date silently roll them over.
+    if (back.getUTCFullYear() !== year || back.getUTCMonth() !== month - 1 || back.getUTCDate() !== day
+        || back.getUTCHours() !== hour || back.getUTCMinutes() !== minute || back.getUTCSeconds() !== second) return null
+    return value
+}
+
+/** The query window contract: real UTC+8 instants, ordered, and at most seven days apart. */
+function validate(from, to) {
+    const start = instant(from), end = instant(to)
+    if (start === null || end === null || end <= start || end - start > MAX_SPAN_MS)
+        throw new VideoRequestError('INVALID_REQUEST', '录像时间格式为 yyyy-MM-dd HH:mm:ss,结束时间须晚于开始时间且范围不超过 7 天')
+    return { start, end }
+}
+
+/**
+ * History browsing and controlled playback for one anonymous viewer.
+ * Records are only ever shown from what the device/NVR returned; playback starts only on an explicit choice.
+ */
+export function createVideoPlaybackSession(api, update, timers = globalThis) {
+    const cleanupMessage = '回放释放未确认,等待服务端回收'
+    let disposed = false, current = null, identity = null, records = [], generation = 0
+    const publish = (patch) => { if (!disposed) update(patch) }
+    const reset = () => { current = null; identity = null; records = []; publish({ media: null, records: [], empty: false, error: '', cleanupError: '' }) }
+
+    async function query(deviceCode, startTime, endTime) {
+        validate(startTime, endTime)
+        records = []
+        publish({ phase: 'querying', records: [], empty: false, error: '', cleanupError: '', window: { startTime, endTime } })
+        let reply
+        try {
+            reply = await api.records(deviceCode, startTime, endTime)
+        } catch (error) {
+            publish({ phase: 'failed', records: [], empty: false, error: error?.message || '录像查询失败' })
+            return []
+        }
+        if (disposed) return []
+        const list = Array.isArray(reply?.records) ? reply.records : []
+        records = list
+        // An empty list is an answer; a failure is not. Never retry either automatically.
+        publish({ phase: 'listed', records: list, empty: list.length === 0, error: '' })
+        return list
+    }
+
+    async function play(deviceCode, startTime, endTime) {
+        validate(startTime, endTime)
+        // The previous stream of whichever mode must end before this one replaces it.
+        await stop()
+        if (disposed) return false
+        const version = ++generation
+        publish({ phase: 'starting', media: null, records, error: '', cleanupError: '' })
+        let started
+        try {
+            started = await api.playback(deviceCode, startTime, endTime)
+        } catch (error) {
+            if (disposed || version !== generation) return false
+            // An occupied channel is reported as such and never releases the existing viewer.
+            const occupied = error?.category === 'PLAYBACK_OCCUPIED'
+            publish({ phase: 'failed', media: null, error: occupied ? '此通道已有录像回放,请结束后重试' : (error?.message || '录像回放启动失败') })
+            return false
+        }
+        if (disposed || version !== generation) {
+            await release(started)
+            return false
+        }
+        let media
+        try { media = selectVideoMedia(started.media) } catch (error) {
+            await release(started)
+            if (version === generation) publish({ phase: 'failed', media: null, error: error?.message || '回放媒体不可用' })
+            return false
+        }
+        identity = { sessionId: started.sessionId, sessionToken: started.sessionToken, deviceCode, mode: 'PLAYBACK' }
+        current = identity
+        publish({ phase: 'playing', media, window: { startTime, endTime }, controls: { pause: false, resume: false, speed: false, seek: false } })
+        return true
+    }
+
+    async function release(session) {
+        if (!session) return true
+        try {
+            const result = await api.stop(session)
+            if (result?.sessionId !== session.sessionId || !['STOPPED', 'FAILED'].includes(result?.status)) throw new Error('unconfirmed')
+            return true
+        } catch {
+            publish({ cleanupError: cleanupMessage })
+            return false
+        }
+    }
+
+    async function stop() {
+        ++generation
+        const owned = identity
+        identity = null; current = null
+        publish({ media: null, phase: 'idle' })
+        const clean = await release(owned)
+        if (!clean) publish({ phase: 'failed', error: cleanupMessage })
+        return clean
+    }
+
+    async function dispose() { await stop(); disposed = true }
+    return { query, play, stop, dispose, records: () => records, current: () => current }
+}

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

@@ -2,14 +2,26 @@ import { shallowRef } from 'vue'
 import { defineStore } from 'pinia'
 import { createVideoApi, VideoRequestError } from '../services/videoApi.js'
 import { createVideoLiveSession } from '../services/videoLiveSession.js'
+import { createVideoPlaybackSession, defaultHistoryWindow } from '../services/videoPlaybackSession.js'
 import { useDetailStore } from './detailStore.js'
 import { useEnhancementDevicesStore } from './enhancementDevicesStore.js'
 
 export const useVideoStore = defineStore('cockpitVideo', () => {
-    const state = shallowRef({ open: false, device: null, ownerSource: 'detail', phase: 'idle', media: null, error: '', cleanupError: '' })
+    const state = shallowRef({ open: false, device: null, ownerSource: 'detail', phase: 'idle', media: null, error: '', cleanupError: '',
+        playbackMedia: null, historyPhase: '', historyError: '', records: [], empty: false })
     const playerOptions = shallowRef({})
-    let options = {}, controller
-    const publish = (patch) => { state.value = { ...state.value, ...patch } }
+    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 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)
+    }
     function configure(next = {}) {
         if (controller) throw new Error('请在建立视频会话前配置传输')
         options = next
@@ -37,7 +49,22 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         }
         await session().start(device.deviceCode)
     }
-    async function close() { publish({ open: false }); if (controller) await controller.stop() }
+    async function close() { publish({ open: false, playbackMedia: null }); if (history) await history.dispose(); history = undefined; if (controller) await controller.stop() }
+    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) {
+        const device = state.value.device
+        if (!device?.deviceCode) return []
+        const records = await playback().query(device.deviceCode, startTime, endTime)
+        return records
+    }
+    async function play(startTime, endTime) {
+        const device = state.value.device
+        if (!device?.deviceCode) return false
+        return playback().play(device.deviceCode, startTime, endTime)
+    }
+    async function stopPlayback() { if (history) await history.stop(); publish({ playbackMedia: null }) }
+    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() }
     const ready = () => controller?.ready()
@@ -46,5 +73,6 @@ export const useVideoStore = defineStore('cockpitVideo', () => {
         if (code) controller?.fail(Object.assign(new Error(typeof error === 'string' ? error : error?.message || '视频播放失败'), { code }))
         else controller?.fail(error instanceof Error ? error : error?.message || '视频播放失败')
     }
-    return { state, playerOptions, configure, loadDevice, open, close, retry, retryCleanup, ready, fail }
+    return { state, playerOptions, configure, loadDevice, open, close, retry, retryCleanup, ready, fail,
+        queryHistory, play, stopPlayback, historyWindow }
 })

+ 162 - 0
tests/cockpit-video/playback.mjs

@@ -0,0 +1,162 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import { createVideoPlaybackSession, defaultHistoryWindow } from '../../src/modules/cockpit/services/videoPlaybackSession.js'
+
+const media = (code) => ({ native: true, url: `https://media.invalid/${code}.mp4` })
+const started = (code) => ({ deviceCode: code, sessionId: `session-${code}`, sessionToken: `owner-${code}`,
+    mode: 'PLAYBACK', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00', media: media(code) })
+const stopped = (session) => ({ sessionId: session.sessionId, status: 'STOPPED' })
+
+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)
+    }
+}
+
+/** UTC+8 wall clock, independent of the machine zone. */
+test('the default history window is today 00:00 to now in UTC+8', async () => {
+    const at = Date.parse('2026-11-10T03:30:00Z') // 11:30 in UTC+8
+    const window = defaultHistoryWindow(() => at)
+    assert.deepEqual(window, { startTime: '2026-11-10 00:00:00', endTime: '2026-11-10 11:30:00' })
+})
+
+/** 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 = []
+    const playback = createVideoPlaybackSession({
+        records: async (...args) => { asked.push(args); return { records: [] } },
+        playback: async () => started('A'), stop: async (session) => stopped(session),
+    }, () => {})
+    try {
+        await assert.rejects(
+            () => playback.query('A', '2026-11-01 00:00:00', '2026-11-10 00:00:00'),
+            (error) => error.category === 'INVALID_REQUEST')
+        assert.deepEqual(asked, [], 'an illegal window must never reach the server')
+    } finally { await playback.dispose() }
+})
+
+test('an inverted or malformed window is refused locally', async () => {
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }), playback: async () => started('A'),
+        stop: async (session) => stopped(session),
+    }, () => {})
+    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')
+        }
+    } finally { await playback.dispose() }
+})
+
+test('querying publishes the returned list and distinguishes empty from failure', async () => {
+    const updates = []
+    let visible = {}
+    let reply = { records: [{ name: 'CAM/01', startTime: '2026-11-10 01:00:00', endTime: '2026-11-10 02:00:00' }] }
+    const playback = createVideoPlaybackSession({
+        records: async () => reply, playback: async () => started('A'), stop: async (session) => stopped(session),
+    }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } })
+    try {
+        await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
+        assert.equal(visible.phase, 'listed')
+        assert.equal(visible.records.length, 1)
+        assert.equal(visible.empty, false)
+        reply = { records: [] }
+        await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
+        assert.equal(visible.empty, true, 'an empty list is a result, not an error')
+        assert.equal(visible.phase, 'listed')
+        // A genuine failure is shown as such and never retried automatically.
+        const failing = createVideoPlaybackSession({
+            records: async () => { throw Object.assign(new Error('查询失败'), { category: 'VIDEO_READ_FAILED' }) },
+            playback: async () => started('A'), stop: async (session) => stopped(session),
+        }, (patch) => { visible = { ...visible, ...patch } })
+        try {
+            await failing.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
+            assert.equal(visible.phase, 'failed')
+            assert.equal(visible.empty, false, 'a failure is not an empty result')
+        } finally { await failing.dispose() }
+    } finally { await playback.dispose() }
+})
+
+test('playback starts only for a chosen recording and never stops another viewer', async () => {
+    const calls = []
+    const updates = []
+    let visible = {}
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }),
+        playback: async (code, from, to) => { calls.push([code, from, to]); return started(code) },
+        stop: async (session) => stopped(session),
+    }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } })
+    try {
+        // Choosing a recording is what starts playback; listing alone never does.
+        await playback.query('A', '2026-11-10 00:00:00', '2026-11-10 12:00:00')
+        assert.deepEqual(calls, [], 'a query must not start playback')
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        assert.deepEqual(calls, [['A', '2026-11-10 01:00:00', '2026-11-10 02:00:00']])
+        assert.equal(visible.phase, 'playing')
+        assert.equal(visible.media.url.includes('A.mp4'), true)
+    } finally { await playback.dispose() }
+})
+
+test('an occupied channel is reported without stopping the existing playback', async () => {
+    const stops = [], updates = []
+    let visible = {}
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }),
+        playback: async () => { throw Object.assign(new Error('此通道已有录像回放'), { category: 'PLAYBACK_OCCUPIED' }) },
+        stop: async (session) => { stops.push(session); return stopped(session) },
+    }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } })
+    try {
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        assert.equal(visible.phase, 'failed')
+        assert.ok(visible.error.includes('已有录像回放'), visible.error)
+        assert.deepEqual(stops, [], 'a losing request must never release somebody else playback')
+    } finally { await playback.dispose() }
+})
+
+test('stopping releases the owned playback session and reports unconfirmed cleanup truthfully', async () => {
+    const clock = new Clock(), updates = []
+    let visible = {}
+    let stopReply = stopped({ sessionId: 'session-A' })
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }), playback: async () => started('A'),
+        stop: async () => stopReply,
+        status: async () => ({ sessionId: 'session-A', status: 'STOPPED' }),
+    }, (patch) => { updates.push(patch); visible = { ...visible, ...patch } }, clock)
+    try {
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        assert.equal(await playback.stop(), true)
+        assert.equal(visible.media, null)
+        assert.equal(visible.cleanupError, '')
+        // An unconfirmed release must not be reported as success.
+        stopReply = { sessionId: 'session-A', status: 'CLEANUP_PENDING' }
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        assert.equal(await playback.stop(), false, 'an unconfirmed cleanup is not a clean stop')
+        assert.notEqual(visible.cleanupError, '')
+    } finally { await playback.dispose() }
+})
+
+test('switching back to live ends the old playback before live starts', async () => {
+    const events = []
+    const playback = createVideoPlaybackSession({
+        records: async () => ({ records: [] }),
+        playback: async () => { events.push('playback-start'); return started('A') },
+        stop: async (session) => { events.push('playback-stop'); return stopped(session) },
+    }, () => {})
+    try {
+        await playback.play('A', '2026-11-10 01:00:00', '2026-11-10 02:00:00')
+        await playback.stop()
+        assert.deepEqual(events, ['playback-start', 'playback-stop'], 'the old stream ends before anything replaces it')
+    } finally { await playback.dispose() }
+})