Explorar el Código

Merge remote-tracking branch 'origin/master'

zengy hace 1 semana
padre
commit
5162da91f9

+ 637 - 0
public/jessibuca/jessibuca.d.ts

@@ -0,0 +1,637 @@
+declare namespace Jessibuca {
+
+    /** 超时信息 */
+    enum TIMEOUT {
+        /** 当play()的时候,如果没有数据返回 */
+        loadingTimeout = 'loadingTimeout',
+        /** 当播放过程中,如果超过timeout之后没有数据渲染 */
+        delayTimeout = 'delayTimeout',
+    }
+
+    /** 错误信息 */
+    enum ERROR {
+        /** 播放错误,url 为空的时候,调用 play 方法 */
+        playError = 'playError',
+        /** http 请求失败 */
+        fetchError = 'fetchError',
+        /** websocket 请求失败 */
+        websocketError = 'websocketError',
+        /** webcodecs 解码 h265 失败 */
+        webcodecsH265NotSupport = 'webcodecsH265NotSupport',
+        /** mediaSource 解码 h265 失败 */
+        mediaSourceH265NotSupport = 'mediaSourceH265NotSupport',
+        /** wasm 解码失败 */
+        wasmDecodeError = 'wasmDecodeError',
+    }
+
+    interface Config {
+        /**
+         * 播放器容器
+         * *  若为 string ,则底层调用的是 document.getElementById('id')
+         * */
+        container: HTMLElement | string;
+        /**
+         * 设置最大缓冲时长,单位秒,播放器会自动消除延迟
+         */
+        videoBuffer?: number;
+        /**
+         * worker地址
+         * *  默认引用的是根目录下面的decoder.js文件 ,decoder.js 与 decoder.wasm文件必须是放在同一个目录下面。 */
+        decoder?: string;
+        /**
+         * 是否不使用离屏模式(提升渲染能力)
+         */
+        forceNoOffscreen?: boolean;
+        /**
+         * 是否开启当页面的'visibilityState'变为'hidden'的时候,自动暂停播放。
+         */
+        hiddenAutoPause?: boolean;
+        /**
+         * 是否有音频,如果设置`false`,则不对音频数据解码,提升性能。
+         */
+        hasAudio?: boolean;
+        /**
+         * 设置旋转角度,只支持,0(默认),180,270 三个值
+         */
+        rotate?: boolean;
+        /**
+         * 1. 当为`true`的时候:视频画面做等比缩放后,高或宽对齐canvas区域,画面不被拉伸,但有黑边。 等同于 `setScaleMode(1)`
+         * 2. 当为`false`的时候:视频画面完全填充canvas区域,画面会被拉伸。等同于 `setScaleMode(0)`
+         */
+        isResize?: boolean;
+        /**
+         * 1. 当为`true`的时候:视频画面做等比缩放后,完全填充canvas区域,画面不被拉伸,没有黑边,但画面显示不全。等同于 `setScaleMode(2)`
+         */
+        isFullResize?: boolean;
+        /**
+         * 1. 当为`true`的时候:ws协议不检验是否以.flv为依据,进行协议解析。
+         */
+        isFlv?: boolean;
+        /**
+         * 是否开启控制台调试打
+         */
+        debug?: boolean;
+        /**
+         * 1. 设置超时时长, 单位秒
+         * 2. 在连接成功之前(loading)和播放中途(heart),如果超过设定时长无数据返回,则回调timeout事件
+         */
+        timeout?: number;
+        /**
+         * 1. 设置超时时长, 单位秒
+         * 2. 在连接成功之前,如果超过设定时长无数据返回,则回调timeout事件
+         */
+        heartTimeout?: number;
+        /**
+         * 1. 设置超时时长, 单位秒
+         * 2. 在连接成功之前,如果超过设定时长无数据返回,则回调timeout事件
+         */
+        loadingTimeout?: number;
+        /**
+         * 是否支持屏幕的双击事件,触发全屏,取消全屏事件
+         */
+        supportDblclickFullscreen?: boolean;
+        /**
+         * 是否显示网
+         */
+        showBandwidth?: boolean;
+        /**
+         * 配置操作按钮
+         */
+        operateBtns?: {
+            /** 是否显示全屏按钮 */
+            fullscreen?: boolean;
+            /** 是否显示截图按钮 */
+            screenshot?: boolean;
+            /** 是否显示播放暂停按钮 */
+            play?: boolean;
+            /** 是否显示声音按钮 */
+            audio?: boolean;
+            /** 是否显示录制按 */
+            record?: boolean;
+        };
+        /**
+         * 开启屏幕常亮,在手机浏览器上, canvas标签渲染视频并不会像video标签那样保持屏幕常亮
+         */
+        keepScreenOn?: boolean;
+        /**
+         * 是否开启声音,默认是关闭声音播放的
+         */
+        isNotMute?: boolean;
+        /**
+         * 加载过程中文案
+         */
+        loadingText?: string;
+        /**
+         * 背景图片
+         */
+        background?: string;
+        /**
+         * 是否开启MediaSource硬解码
+         * * 视频编码只支持H.264视频(Safari on iOS不支持)
+         * * 不支持 forceNoOffscreen 为 false (开启离屏渲染)
+         */
+        useMSE?: boolean;
+        /**
+         * 是否开启Webcodecs硬解码
+         * *  视频编码只支持H.264视频 (需在chrome 94版本以上,需要https或者localhost环境)
+         * *  支持 forceNoOffscreen 为 false (开启离屏渲染)
+         * */
+        useWCS?: boolean;
+        /**
+         * 是否开启键盘快捷键
+         * 目前支持的键盘快捷键有:esc -> 退出全屏;arrowUp -> 声音增加;arrowDown -> 声音减少;
+         */
+        hotKey?: boolean;
+        /**
+         *  在使用MSE或者Webcodecs 播放H265的时候,是否自动降级到wasm模式。
+         *  设置为false 则直接关闭播放,抛出Error 异常,设置为true 则会自动切换成wasm模式播放。
+         */
+        autoWasm?: boolean;
+        /**
+         * heartTimeout 心跳超时之后自动再播放,不再抛出异常,而直接重新播放视频地址。
+         */
+        heartTimeoutReplay?: boolean,
+        /**
+         * heartTimeoutReplay 从试次数,超过之后,不再自动播放
+         */
+        heartTimeoutReplayTimes?: number,
+        /**
+         * loadingTimeout loading之后自动再播放,不再抛出异常,而直接重新播放视频地址。
+         */
+        loadingTimeoutReplay?: boolean,
+        /**
+         * heartTimeoutReplay 从试次数,超过之后,不再自动播放
+         */
+        loadingTimeoutReplayTimes?: number
+        /**
+         * wasm解码报错之后,不再抛出异常,而是直接重新播放视频地址。
+         */
+        wasmDecodeErrorReplay?: boolean,
+        /**
+         * https://github.com/langhuihui/jessibuca/issues/152 解决方案
+         * 例如:WebGL图像预处理默认每次取4字节的数据,但是540x960分辨率下的U、V分量宽度是540/2=270不能被4整除,导致绿屏。
+         */
+        openWebglAlignment?: boolean
+    }
+}
+
+
+declare class Jessibuca {
+
+    constructor(config?: Jessibuca.Config);
+
+    /**
+     * 是否开启控制台调试打印
+     @example
+     // 开启
+     jessibuca.setDebug(true)
+     // 关闭
+     jessibuca.setDebug(false)
+     */
+    setDebug(flag: boolean): void;
+
+    /**
+     * 静音
+     @example
+     jessibuca.mute()
+     */
+    mute(): void;
+
+    /**
+     * 取消静音
+     @example
+     jessibuca.cancelMute()
+     */
+    cancelMute(): void;
+
+    /**
+     * 留给上层用户操作来触发音频恢复的方法。
+     *
+     * iPhone,chrome等要求自动播放时,音频必须静音,需要由一个真实的用户交互操作来恢复,不能使用代码。
+     *
+     * https://developers.google.com/web/updates/2017/09/autoplay-policy-changes
+     */
+    audioResume(): void;
+
+    /**
+     *
+     * 设置超时时长, 单位秒
+     * 在连接成功之前和播放中途,如果超过设定时长无数据返回,则回调timeout事件
+
+     @example
+     jessibuca.setTimeout(10)
+
+     jessibuca.on('timeout',function(){
+        //
+    });
+     */
+    setTimeout(): void;
+
+    /**
+     * @param mode
+     *      0 视频画面完全填充canvas区域,画面会被拉伸  等同于参数 `isResize` 为false
+     *
+     *      1 视频画面做等比缩放后,高或宽对齐canvas区域,画面不被拉伸,但有黑边 等同于参数 `isResize` 为true
+     *
+     *      2 视频画面做等比缩放后,完全填充canvas区域,画面不被拉伸,没有黑边,但画面显示不全 等同于参数 `isFullResize` 为true
+     @example
+     jessibuca.setScaleMode(0)
+
+     jessibuca.setScaleMode(1)
+
+     jessibuca.setScaleMode(2)
+     */
+    setScaleMode(mode: number): void;
+
+    /**
+     * 暂停播放
+     *
+     * 可以在pause 之后,再调用 `play()`方法就继续播放之前的流。
+     @example
+     jessibuca.pause().then(()=>{
+        console.log('pause success')
+
+        jessibuca.play().then(()=>{
+
+        }).catch((e)=>{
+
+        })
+
+    }).catch((e)=>{
+        console.log('pause error',e);
+    })
+     */
+    pause(): Promise<void>;
+
+    /**
+     * 关闭视频,不释放底层资源
+     @example
+     jessibuca.close();
+     */
+    close(): void;
+
+    /**
+     * 关闭视频,释放底层资源
+     @example
+     jessibuca.destroy()
+     */
+    destroy(): void;
+
+    /**
+     * 清理画布为黑色背景
+     @example
+     jessibuca.clearView()
+     */
+    clearView(): void;
+
+    /**
+     * 播放视频
+     @example
+
+     jessibuca.play('url').then(()=>{
+        console.log('play success')
+    }).catch((e)=>{
+        console.log('play error',e)
+    })
+     //
+     jessibuca.play()
+     */
+    play(url?: string): Promise<void>;
+
+    /**
+     * 重新调整视图大小
+     */
+    resize(): void;
+
+    /**
+     * 设置最大缓冲时长,单位秒,播放器会自动消除延迟。
+     *
+     * 等同于 `videoBuffer` 参数。
+     *
+     @example
+     // 设置 200ms 缓冲
+     jessibuca.setBufferTime(0.2)
+     */
+    setBufferTime(time: number): void;
+
+    /**
+     * 设置旋转角度,只支持,0(默认) ,180,270 三个值。
+     *
+     * > 可用于实现监控画面小窗和全屏效果,由于iOS没有全屏API,此方法可以模拟页面内全屏效果而且多端效果一致。   *
+     @example
+     jessibuca.setRotate(0)
+
+     jessibuca.setRotate(90)
+
+     jessibuca.setRotate(270)
+     */
+    setRotate(deg: number): void;
+
+    /**
+     *
+     * 设置音量大小,取值0 — 1
+     *
+     * > 区别于 mute 和 cancelMute 方法,虽然设置setVolume(0) 也能达到 mute方法,但是mute 方法是不调用底层播放音频的,能提高性能。而setVolume(0)只是把声音设置为0 ,以达到效果。
+     * @param volume 当为0时,完全无声;当为1时,最大音量,默认值
+     @example
+     jessibuca.setVolume(0.2)
+
+     jessibuca.setVolume(0)
+
+     jessibuca.setVolume(1)
+     */
+    setVolume(volume: number): void;
+
+    /**
+     * 返回是否加载完毕
+     @example
+     var result = jessibuca.hasLoaded()
+     console.log(result) // true
+     */
+    hasLoaded(): boolean;
+
+    /**
+     * 开启屏幕常亮,在手机浏览器上, canvas标签渲染视频并不会像video标签那样保持屏幕常亮。
+     * H5目前在chrome\edge 84, android chrome 84及以上有原生亮屏API, 需要是https页面
+     * 其余平台为模拟实现,此时为兼容实现,并不保证所有浏览器都支持
+     @example
+     jessibuca.setKeepScreenOn()
+     */
+    setKeepScreenOn(): boolean;
+
+    /**
+     * 全屏(取消全屏)播放视频
+     @example
+     jessibuca.setFullscreen(true)
+     //
+     jessibuca.setFullscreen(false)
+     */
+    setFullscreen(flag: boolean): void;
+
+    /**
+     *
+     * 截图,调用后弹出下载框保存截图
+     * @param filename 可选参数, 保存的文件名, 默认 `时间戳`
+     * @param format   可选参数, 截图的格式,可选png或jpeg或者webp ,默认 `png`
+     * @param quality  可选参数, 当格式是jpeg或者webp时,压缩质量,取值0 ~ 1 ,默认 `0.92`
+     * @param type 可选参数, 可选download或者base64或者blob,默认`download`
+
+     @example
+
+     jessibuca.screenshot("test","png",0.5)
+
+     const base64 = jessibuca.screenshot("test","png",0.5,'base64')
+
+     const fileBlob = jessibuca.screenshot("test",'blob')
+     */
+    screenshot(filename?: string, format?: string, quality?: number, type?: string): void;
+
+    /**
+     * 开始录制。
+     * @param fileName 可选,默认时间戳
+     * @param fileType 可选,默认webm,支持webm 和mp4 格式
+
+     @example
+     jessibuca.startRecord('xxx','webm')
+     */
+    startRecord(fileName: string, fileType: string): void;
+
+    /**
+     * 暂停录制并下载。
+     @example
+     jessibuca.stopRecordAndSave()
+     */
+    stopRecordAndSave(): void;
+
+    /**
+     * 返回是否正在播放中状态。
+     @example
+     var result = jessibuca.isPlaying()
+     console.log(result) // true
+     */
+    isPlaying(): boolean;
+
+    /**
+     *   返回是否静音。
+     @example
+     var result = jessibuca.isMute()
+     console.log(result) // true
+     */
+    isMute(): boolean;
+
+    /**
+     * 返回是否正在录制。
+     @example
+     var result = jessibuca.isRecording()
+     console.log(result) // true
+     */
+    isRecording(): boolean;
+
+
+    /**
+     * 监听 jessibuca 初始化事件
+     * @example
+     * jessibuca.on("load",function(){console.log('load')})
+     */
+    on(event: 'load', callback: () => void): void;
+
+    /**
+     * 视频播放持续时间,单位ms
+     * @example
+     * jessibuca.on('timeUpdate',function (ts) {console.log('timeUpdate',ts);})
+     */
+    on(event: 'timeUpdate', callback: () => void): void;
+
+    /**
+     * 当解析出视频信息时回调,2个回调参数
+     * @example
+     * jessibuca.on("videoInfo",function(data){console.log('width:',data.width,'height:',data.width)})
+     */
+    on(event: 'videoInfo', callback: (data: {
+        /** 视频宽 */
+        width: number;
+        /** 视频高 */
+        height: number;
+    }) => void): void;
+
+    /**
+     * 当解析出音频信息时回调,2个回调参数
+     * @example
+     * jessibuca.on("audioInfo",function(data){console.log('numOfChannels:',data.numOfChannels,'sampleRate',data.sampleRate)})
+     */
+    on(event: 'audioInfo', callback: (data: {
+        /** 声频通道 */
+        numOfChannels: number;
+        /** 采样率 */
+        sampleRate: number;
+    }) => void): void;
+
+    /**
+     * 信息,包含错误信息
+     * @example
+     * jessibuca.on("log",function(data){console.log('data:',data)})
+     */
+    on(event: 'log', callback: () => void): void;
+
+    /**
+     * 错误信息
+     * @example
+     * jessibuca.on("error",function(error){
+        if(error === Jessibuca.ERROR.fetchError){
+            //
+        }
+        else if(error === Jessibuca.ERROR.webcodecsH265NotSupport){
+            //
+        }
+        console.log('error:',error)
+    })
+     */
+    on(event: 'error', callback: (err: Jessibuca.ERROR) => void): void;
+
+    /**
+     * 当前网速, 单位KB 每秒1次,
+     * @example
+     * jessibuca.on("kBps",function(data){console.log('kBps:',data)})
+     */
+    on(event: 'kBps', callback: (value: number) => void): void;
+
+    /**
+     * 渲染开始
+     * @example
+     * jessibuca.on("start",function(){console.log('start render')})
+     */
+    on(event: 'start', callback: () => void): void;
+
+    /**
+     * 当设定的超时时间内无数据返回,则回调
+     * @example
+     * jessibuca.on("timeout",function(error){console.log('timeout:',error)})
+     */
+    on(event: 'timeout', callback: (error: Jessibuca.TIMEOUT) => void): void;
+
+    /**
+     * 当play()的时候,如果没有数据返回,则回调
+     * @example
+     * jessibuca.on("loadingTimeout",function(){console.log('timeout')})
+     */
+    on(event: 'loadingTimeout', callback: () => void): void;
+
+    /**
+     * 当播放过程中,如果超过timeout之后没有数据渲染,则抛出异常。
+     * @example
+     * jessibuca.on("delayTimeout",function(){console.log('timeout')})
+     */
+    on(event: 'delayTimeout', callback: () => void): void;
+
+    /**
+     * 当前是否全屏
+     * @example
+     * jessibuca.on("fullscreen",function(flag){console.log('is fullscreen',flag)})
+     */
+    on(event: 'fullscreen', callback: () => void): void;
+
+    /**
+     * 触发播放事件
+     * @example
+     * jessibuca.on("play",function(flag){console.log('play')})
+     */
+    on(event: 'play', callback: () => void): void;
+
+    /**
+     * 触发暂停事件
+     * @example
+     * jessibuca.on("pause",function(flag){console.log('pause')})
+     */
+    on(event: 'pause', callback: () => void): void;
+
+    /**
+     * 触发声音事件,返回boolean值
+     * @example
+     * jessibuca.on("mute",function(flag){console.log('is mute',flag)})
+     */
+    on(event: 'mute', callback: () => void): void;
+
+    /**
+     * 流状态统计,流开始播放后回调,每秒1次。
+     * @example
+     * jessibuca.on("stats",function(s){console.log("stats is",s)})
+     */
+    on(event: 'stats', callback: (stats: {
+        /** 当前缓冲区时长,单位毫秒 */
+        buf: number;
+        /** 当前视频帧率 */
+        fps: number;
+        /** 当前音频码率,单位byte */
+        abps: number;
+        /** 当前视频码率,单位byte */
+        vbps: number;
+        /** 当前视频帧pts,单位毫秒 */
+        ts: number;
+    }) => void): void;
+
+    /**
+     * 渲染性能统计,流开始播放后回调,每秒1次。
+     * @param performance 0: 表示卡顿,1: 表示流畅,2: 表示非常流程
+     * @example
+     * jessibuca.on("performance",function(performance){console.log("performance is",performance)})
+     */
+    on(event: 'performance', callback: (performance: 0 | 1 | 2) => void): void;
+
+    /**
+     * 录制开始的事件
+
+     * @example
+     * jessibuca.on("recordStart",function(){console.log("record start")})
+     */
+    on(event: 'recordStart', callback: () => void): void;
+
+    /**
+     * 录制结束的事件
+
+     * @example
+     * jessibuca.on("recordEnd",function(){console.log("record end")})
+     */
+    on(event: 'recordEnd', callback: () => void): void;
+
+    /**
+     * 录制的时候,返回的录制时长,1s一次
+
+     * @example
+     * jessibuca.on("recordingTimestamp",function(timestamp){console.log("recordingTimestamp is",timestamp)})
+     */
+    on(event: 'recordingTimestamp', callback: (timestamp: number) => void): void;
+
+    /**
+     * 监听调用play方法 经过 初始化-> 网络请求-> 解封装 -> 解码 -> 渲染 一系列过程的时间消耗
+     * @param event
+     * @param callback
+     */
+    on(event: 'playToRenderTimes', callback: (times: {
+        playInitStart: number, // 1 初始化
+        playStart: number, // 2 初始化
+        streamStart: number, // 3 网络请求
+        streamResponse: number, // 4 网络请求
+        demuxStart: number, // 5 解封装
+        decodeStart: number, // 6 解码
+        videoStart: number, // 7 渲染
+        playTimestamp: number,// playStart- playInitStart
+        streamTimestamp: number,// streamStart - playStart
+        streamResponseTimestamp: number,// streamResponse - streamStart
+        demuxTimestamp: number, // demuxStart - streamResponse
+        decodeTimestamp: number, // decodeStart - demuxStart
+        videoTimestamp: number,// videoStart - decodeStart
+        allTimestamp: number // videoStart - playInitStart
+    }) => void): void
+
+    /**
+     * 监听方法
+     *
+     @example
+
+     jessibuca.on("load",function(){console.log('load')})
+     */
+    on(event: string, callback: Function): void;
+
+}
+
+export default Jessibuca;

+ 16 - 0
src/api/video/index.js

@@ -0,0 +1,16 @@
+import request from '@/utils/request'
+
+export const listVideoDevices = params => request({ url: '/api/video/devices', params })
+export const getVideoDevice = id => request({ url: `/api/video/devices/${id}` })
+export const saveVideoDevice = data => request({ url: `/api/video/devices${data.videoId ? `/${data.videoId}` : ''}`, method: data.videoId ? 'put' : 'post', data })
+export const removeVideoDevice = id => request({ url: `/api/video/devices/${id}`, method: 'delete' })
+export const discoverVideoDevices = params => request({ url: '/api/video/discovered', params })
+export const syncVideoDevice = id => request({ url: `/api/video/discovered/${id}/sync`, method: 'post' })
+export const syncAllVideoDevices = () => request({ url: '/api/video/discovered/sync', method: 'post', timeout: 35000 })
+export const startLive = id => request({ url: `/api/video/devices/${id}/live`, method: 'post', timeout: 35000 })
+export const queryRecords = (id, params) => request({ url: `/api/video/devices/${id}/records`, params, timeout: 95000 })
+export const startPlayback = (id, data) => request({ url: `/api/video/devices/${id}/playback`, method: 'post', data, timeout: 35000 })
+export const ptzVideo = (id, data) => request({ url: `/api/video/devices/${id}/ptz`, method: 'post', data, headers: { repeatSubmit: false } })
+export const stopVideo = id => request({ url: `/api/video/sessions/${id}/stop`, method: 'post', headers: { repeatSubmit: false } })
+export const heartbeatVideo = id => request({ url: `/api/video/sessions/${id}/heartbeat`, method: 'post', headers: { repeatSubmit: false, 'X-Silent-Error': true } })
+export const controlPlayback = (id, data) => request({ url: `/api/video/sessions/${id}/control`, method: 'post', data, headers: { repeatSubmit: false } })

+ 282 - 0
src/components/EquipmentEditorFields/index.vue

@@ -0,0 +1,282 @@
+<template>
+  <div>
+      <el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
+        <div class="form-section-title">基础信息</div>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="设备编码" prop="equipmentCode">
+              <el-input v-model="form.equipmentCode" placeholder="请输入设备编码" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="设备名称" prop="equipmentName">
+              <el-input v-model="form.equipmentName" placeholder="请输入设备名称" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="设备类型" prop="equipmentTypeId">
+              <el-tree-select
+                v-model="form.equipmentTypeId"
+                :disabled="typeLocked"
+                :data="types"
+                node-key="id"
+                :props="{ label: 'typeName', children: 'children', disabled: 'disabled' }"
+                default-expand-all
+                :render-after-expand="false"
+                placeholder="请选择设备类型"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="所属片区" prop="district">
+              <el-select v-model="form.district" placeholder="请选择所属片区" style="width: 100%">
+                <el-option v-for="item in districtOptions" :key="item.value" :label="item.label" :value="item.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="设备型号" prop="equipmentModel">
+              <el-input v-model="form.equipmentModel" placeholder="请输入设备型号" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="制造商" prop="manufacturer">
+              <el-input v-model="form.manufacturer" placeholder="请输入制造商" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="生产日期" prop="productionDate">
+              <el-date-picker v-model="form.productionDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择生产日期" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="规格参数" prop="equipmentSpec">
+              <el-input v-model="form.equipmentSpec" placeholder="请输入设备规格参数" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <div class="form-section-title">资产信息</div>
+        <el-row :gutter="16">
+          <el-col :span="12">
+            <el-form-item label="资产状态" prop="currentStatus">
+              <el-select v-model="form.currentStatus" placeholder="请选择资产状态" style="width: 100%">
+                <el-option v-for="item in equipmentStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="资产价值(元)" prop="assetValue">
+              <el-input-number v-model="form.assetValue" :precision="2" :step="100" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="使用年限(年)" prop="useLife">
+              <el-input-number v-model="form.useLife" :precision="0" :step="1" style="width: 100%" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+
+        <div class="form-section-title">部署运维</div>
+        <el-row :gutter="16">
+          <el-col :span="24">
+            <el-form-item label="安装位置" prop="equipmentLocation">
+              <el-input v-model="form.equipmentLocation" placeholder="请输入或通过地图选择安装位置" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="地图坐标">
+              <div class="coordinate-row">
+                <el-input-number v-model="form.longitude" :precision="6" :step="0.000001" controls-position="right" placeholder="经度" />
+                <el-input-number v-model="form.latitude" :precision="6" :step="0.000001" controls-position="right" placeholder="纬度" />
+                <el-button type="primary" plain @click="openMapPicker">地图选点</el-button>
+              </div>
+              <div class="coordinate-tip">{{ locationTip || "点击地图获取经纬度,地址将自动回填到安装位置,也可以直接手工修改。" }}</div>
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="安装时间" prop="installTime">
+              <el-date-picker
+                v-model="form.installTime"
+                type="datetime"
+                value-format="YYYY-MM-DD HH:mm:ss"
+                placeholder="请选择安装时间"
+                style="width: 100%"
+              />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="维护人员" prop="maintainer">
+              <el-input v-model="form.maintainer" placeholder="请输入维护人员" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="12">
+            <el-form-item label="联系电话" prop="maintainerPhone">
+              <el-input v-model="form.maintainerPhone" placeholder="请输入联系电话" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="备注" prop="remark">
+              <el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item label="设备图片" prop="equipmentImage">
+              <image-upload v-model="form.equipmentImage" :limit="1" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+        <slot />
+      </el-form>
+    <!-- 地图选点弹窗 -->
+    <el-dialog v-model="mapOpen" title="地图选点" width="760px" append-to-body>
+      <div class="map-picker">
+        <GisMapCanvas
+          :center="mapCenter"
+          :points="mapPoints"
+          :enable-clustering="false"
+          :show-marker-name="false"
+          @map-click="handleMapClick"
+        />
+      </div>
+      <div class="map-picker-coordinates">
+        当前坐标:经度 {{ form.longitude ?? "-" }},纬度 {{ form.latitude ?? "-" }}
+      </div>
+      <div class="map-picker-coordinates">
+        解析地址:{{ form.equipmentLocation || "点击地图后自动解析" }}
+      </div>
+      <template #footer>
+        <el-button @click="mapOpen = false">关 闭</el-button>
+        <el-button type="primary" @click="mapOpen = false">使用当前坐标</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import GisMapCanvas from '@/components/GisMapCanvas.vue'
+import { bd09ToWgs84 } from '@/utils/coordTransform'
+const props = defineProps({ modelValue: { type: Object, required: true }, types: { type: Array, default: () => [] }, typeLocked: Boolean })
+const emit = defineEmits(['update:modelValue'])
+const form = computed({ get: () => props.modelValue, set: value => emit('update:modelValue', value) })
+const formRef = ref(), mapOpen = ref(false), mapCenter = ref({ lng: 110.3965, lat: 28.4638 }), mapPoints = ref([]), locationTip = ref('')
+const districtOptions = [{ label: '城南', value: 0 }, { label: '城北', value: 1 }, { label: '太常', value: 2 }]
+const equipmentStatusOptions = [{ label: '在用', value: 0 }, { label: '维修', value: 1 }, { label: '报废', value: 2 }]
+const rules = {
+  equipmentCode: [{ required: true, message: '设备编码不能为空', trigger: 'blur' }],
+  equipmentName: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
+  equipmentTypeId: [{ required: true, message: '设备类型不能为空', trigger: 'change' }],
+  district: [{ required: true, message: '所属片区不能为空', trigger: 'change' }]
+}
+// 逆地理编码:传入百度 BD-09 坐标,返回结构化地址文本,失败时返回空串
+function reverseGeocode(lng, lat) {
+  return new Promise((resolve) => {
+    const BMapGL = window.BMapGL;
+    if (!BMapGL || typeof BMapGL.Geocoder !== "function") {
+      resolve("");
+      return;
+    }
+    try {
+      const geocoder = new BMapGL.Geocoder();
+      geocoder.getLocation(new BMapGL.Point(lng, lat), (result) => {
+        resolve(extractAddress(result));
+      });
+    } catch (error) {
+      resolve("");
+    }
+  });
+}
+
+function extractAddress(result) {
+  const content = result?.content || result || {};
+  const address = result?.address || content.address;
+  if (address) {
+    return String(address);
+  }
+  const components = result?.addressComponents || content.address_detail || {};
+  return [components.province, components.city, components.district, components.street, components.streetNumber]
+    .filter(Boolean)
+    .join("");
+}
+
+// 地图选点后按坐标回填安装位置,解析失败时保留原值
+async function fillLocationByPoint(lng, lat) {
+  locationTip.value = "正在解析地址...";
+  const address = await reverseGeocode(lng, lat);
+  if (address) {
+    form.value.equipmentLocation = address;
+    locationTip.value = "已按选点回填安装位置,可手工修改。";
+  } else {
+    locationTip.value = "地址解析失败,请手动填写安装位置。";
+  }
+}
+
+function openMapPicker() {
+  const longitude = Number(form.value.longitude);
+  const latitude = Number(form.value.latitude);
+  mapCenter.value = Number.isFinite(longitude) && Number.isFinite(latitude)
+    ? { lng: longitude, lat: latitude }
+    : { lng: 110.3965, lat: 28.4638 };
+  mapPoints.value = Number.isFinite(longitude) && Number.isFinite(latitude)
+    ? [{ lng: longitude, lat: latitude, name: form.value.equipmentName || "设备位置" }]
+    : [];
+  locationTip.value = "";
+  mapOpen.value = true;
+}
+
+function handleMapClick(point) {
+  if (!point) return;
+  const [longitude, latitude] = bd09ToWgs84(point.lng, point.lat);
+  form.value.longitude = Number(longitude.toFixed(6));
+  form.value.latitude = Number(latitude.toFixed(6));
+  mapPoints.value = [{ lng: form.value.longitude, lat: form.value.latitude, name: form.value.equipmentName || "设备位置" }];
+  fillLocationByPoint(point.lng, point.lat);
+}
+
+
+defineExpose({ validate: (...args) => formRef.value.validate(...args), resetFields: () => formRef.value?.resetFields() })
+</script>
+
+<style scoped>
+.form-section-title {
+  margin: 8px 0 12px;
+  padding-left: 10px;
+  border-left: 3px solid #014db2;
+  color: #1f2937;
+  font-size: 14px;
+  font-weight: 600;
+}
+
+.coordinate-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  width: 100%;
+}
+
+.coordinate-row :deep(.el-input-number) {
+  flex: 1;
+}
+
+.coordinate-tip {
+  margin-top: 6px;
+  color: #9ca3af;
+  font-size: 12px;
+}
+
+.map-picker {
+  height: 420px;
+  overflow: hidden;
+  border: 1px solid #e5e7eb;
+  border-radius: 6px;
+}
+
+.map-picker-coordinates {
+  margin-top: 10px;
+  color: #6b7280;
+  font-size: 13px;
+}
+
+</style>

+ 53 - 0
src/components/VideoPlayer/index.vue

@@ -0,0 +1,53 @@
+<template>
+  <div class="video-player">
+    <video v-if="native" ref="video" :src="url" controls autoplay playsinline @error="emit('error', '视频地址无法播放,请确认格式和网络')" />
+    <div v-else ref="container" class="decoder-player" />
+    <div v-if="error" class="player-error">{{ error }}</div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
+const props = defineProps({ url: String, native: Boolean })
+const emit = defineEmits(['error', 'ready'])
+const container = ref(), video = ref(), error = ref('')
+let player, generation = 0
+function loadSdk() {
+  if (window.Jessibuca) return Promise.resolve()
+  if (!window.__cityVideoSdk) window.__cityVideoSdk = new Promise((resolve, reject) => {
+    const script = document.createElement('script')
+    script.src = `${import.meta.env.BASE_URL}jessibuca/jessibuca.js`
+    script.onload = resolve
+    script.onerror = () => { window.__cityVideoSdk = null; script.remove(); reject(new Error('视频播放器加载失败')) }
+    document.head.appendChild(script)
+  })
+  return window.__cityVideoSdk
+}
+function dispose() {
+  generation++
+  if (player) { player.destroy(); player = null }
+  if (video.value) { video.value.pause(); video.value.removeAttribute('src'); video.value.load() }
+}
+watch(() => [props.url, props.native], async () => {
+  dispose(); error.value = ''
+  const version = generation
+  if (!props.url || props.native) return
+  try {
+    await nextTick(); await loadSdk()
+    if (version !== generation || !container.value) return
+    player = new window.Jessibuca({ container: container.value, decoder: `${import.meta.env.BASE_URL}jessibuca/decoder.js`,
+      isResize: true, useMSE: false, useWCS: false, hasAudio: true, videoBuffer: 0.2,
+      operateBtns: { fullscreen: true, screenshot: true, play: false, audio: true }, loadingText: '视频加载中' })
+    player.on('error', () => { error.value = '视频解码或网络异常,请重试'; emit('error', error.value) })
+    player.on('play', () => emit('ready'))
+    await player.play(props.url)
+  } catch (err) { if (version === generation) { error.value = err.message || '视频加载失败'; emit('error', error.value) } }
+}, { immediate: true })
+onBeforeUnmount(dispose)
+</script>
+
+<style scoped>
+.video-player { position: relative; height: 430px; background: #101820; }
+.decoder-player, video { width: 100%; height: 100%; object-fit: contain; }
+.player-error { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: #101820dd; }
+</style>

+ 219 - 0
src/views/subSystem/video/index.vue

@@ -0,0 +1,219 @@
+<template>
+  <div class="app-container video-management">
+    <el-form inline @submit.prevent="search">
+      <el-form-item label="设备名称/编码"><el-input v-model="query.query" clearable @keyup.enter="search" /></el-form-item>
+      <el-form-item label="接入方式"><el-select v-model="query.sourceType" clearable style="width: 150px"><el-option label="手动添加" value="MANUAL" /><el-option label="国标设备" value="GB28181" /></el-select></el-form-item>
+      <el-form-item><el-button type="primary" @click="search">查询</el-button><el-button @click="reset">重置</el-button></el-form-item>
+    </el-form>
+    <el-row class="toolbar" :gutter="12">
+      <el-col :span="1.5"><el-button v-hasPermi="['video:device:add']" type="success" plain @click="openDiscovery">发现注册设备</el-button></el-col>
+    </el-row>
+    <el-table v-loading="loading" :data="rows" row-key="videoId">
+      <el-table-column label="设备名称" prop="deviceName" min-width="150" show-overflow-tooltip />
+      <el-table-column label="业务设备编码" prop="deviceCode" min-width="160" show-overflow-tooltip />
+      <el-table-column label="接入方式" width="110"><template #default="{row}"><el-tag :type="row.sourceType === 'GB28181' ? 'success' : 'info'">{{ row.sourceType === 'GB28181' ? '国标' : '手动' }}</el-tag></template></el-table-column>
+      <el-table-column label="国标设备 / 通道" min-width="210"><template #default="{row}"><div>设备:{{ row.gbDeviceId || '—' }}</div><div v-if="row.gbChannelId">通道:{{ row.gbChannelId }}</div></template></el-table-column>
+      <el-table-column label="安装位置" prop="location" min-width="150" show-overflow-tooltip />
+      <el-table-column label="厂商" prop="manufacturer" width="110" show-overflow-tooltip />
+      <el-table-column label="操作" fixed="right" width="315"><template #default="{row}">
+        <el-button v-hasPermi="['video:device:play']" link type="primary" @click="openLive(row)">实时播放</el-button>
+        <el-button v-if="row.sourceType === 'GB28181'" v-hasPermi="['video:device:record']" link type="primary" @click="openRecords(row)">历史录像</el-button>
+        <el-button v-hasPermi="['video:device:edit']" link type="primary" @click="edit(row)">修改</el-button>
+        <el-button v-hasPermi="['video:device:remove']" link type="danger" @click="remove(row)">移除</el-button>
+      </template></el-table-column>
+      <template #empty><el-empty description="暂无视频设备,请从发现注册设备中纳管" /></template>
+    </el-table>
+    <pagination v-show="total > 0" :total="total" v-model:page="query.pageNum" v-model:limit="query.pageSize" @pagination="load" />
+
+    <el-dialog v-model="editVisible" :title="form.videoId ? '修改视频设备' : '新增设备 · 纳管国标通道'" width="860px" append-to-body :close-on-click-modal="false">
+      <EquipmentEditorFields ref="formRef" v-model="equipmentForm" :types="videoTypeOptions" type-locked>
+        <div class="video-connection-title">视频接入信息</div>
+        <el-form-item v-if="form.sourceType === 'MANUAL'" label="视频地址"><el-input v-model="form.videoUrl" maxlength="512" placeholder="历史手动设备的视频地址" /></el-form-item>
+        <template v-else>
+          <el-form-item label="国标设备编号"><el-input :model-value="form.gbDeviceId" disabled /></el-form-item>
+          <el-form-item label="国标通道编号"><el-input :model-value="form.gbChannelId" disabled /></el-form-item>
+        </template>
+        <el-form-item label="关联场站ID"><el-input v-model="form.stationId" maxlength="64" /></el-form-item>
+      </EquipmentEditorFields>
+      <template #footer><el-button @click="editVisible = false" :disabled="saving">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
+    </el-dialog>
+
+    <el-dialog v-model="discoveryVisible" title="发现自动注册的国标设备" width="1000px">
+      <el-alert title="设备编号标识摄像机或 NVR,通道编号标识具体视频通道。同步目录获取通道清单;点击纳管并保存后加入主列表。" type="info" :closable="false" />
+      <el-form inline class="discovery-search">
+        <el-form-item label="设备/通道"><el-input v-model="discoveryQuery.query" clearable @keyup.enter="discover(true)" /></el-form-item>
+        <el-form-item label="注册状态"><el-select v-model="discoveryQuery.online" clearable style="width: 130px"><el-option label="在线" :value="true" /><el-option label="离线" :value="false" /></el-select></el-form-item>
+        <el-button type="primary" @click="discover(true)">搜索</el-button><el-button @click="discover()">刷新</el-button><el-button v-hasPermi="['video:device:add']" type="success" :loading="syncingAll" @click="syncAll">同步全部在线设备目录</el-button>
+      </el-form>
+      <el-table v-loading="discovering" :data="discovered">
+        <el-table-column label="设备名称" prop="deviceName" min-width="130" show-overflow-tooltip />
+        <el-table-column label="国标设备编号" prop="gbDeviceId" width="205" />
+        <el-table-column label="通道名称 / 编号" min-width="205"><template #default="{row}"><div>{{ row.channelName || '尚无通道' }}</div><div>{{ row.gbChannelId }}</div></template></el-table-column>
+        <el-table-column label="地址" prop="ip" width="130" />
+        <el-table-column label="注册状态" width="95"><template #default="{row}"><el-tag :type="row.online ? 'success' : 'info'">{{ row.online ? '在线' : '离线' }}</el-tag></template></el-table-column>
+        <el-table-column label="操作" width="80"><template #default="{row}"><el-button link type="primary" :disabled="!row.gbChannelId" @click="adopt(row)">纳管</el-button></template></el-table-column>
+      </el-table>
+      <pagination v-show="discoveredTotal > 0" :total="discoveredTotal" v-model:page="discoveryQuery.pageNum" v-model:limit="discoveryQuery.pageSize" @pagination="discover()" />
+    </el-dialog>
+
+    <el-dialog v-model="viewerVisible" :title="`${selected?.deviceName || ''} · ${playbackMode ? '历史录像' : '实时播放'}`" width="1000px" destroy-on-close :close-on-click-modal="false" @close="closeViewer">
+      <div v-if="playbackMode" class="record-query">
+        <el-date-picker v-model="range" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss" start-placeholder="开始时间" end-placeholder="结束时间" :clearable="false" />
+        <el-button v-hasPermi="['video:device:record']" type="primary" :loading="queryingRecords" @click="loadRecords">查询录像</el-button>
+      </div>
+      <div v-loading="starting" element-loading-text="正在建立视频连接">
+        <VideoPlayer v-if="streamUrl" :url="streamUrl" :native="nativePlayer" @error="playerError = $event" />
+        <el-empty v-else :description="starting ? '正在建立视频连接' : playbackMode ? '选择录像后开始回放' : playerError || '尚未开始播放'" :image-size="80" />
+      </div>
+      <el-alert v-if="playerError" :title="playerError" type="error" :closable="false" class="play-error" />
+      <div class="player-actions">
+        <el-button v-if="!playbackMode" v-hasPermi="['video:device:play']" :loading="starting" @click="playLive">重新播放</el-button>
+        <el-button :disabled="!sessionId" @click="stopCurrent">停止播放</el-button>
+        <template v-if="playbackMode && sessionId">
+          <el-button @click="control(paused ? 'resume' : 'pause')">{{ paused ? '继续' : '暂停' }}</el-button>
+          <el-select v-model="playSpeed" style="width: 100px" @change="control('speed', $event)"><el-option v-for="speed in [0.25,0.5,1,2,4]" :key="speed" :value="speed" :label="`${speed} 倍速`" /></el-select>
+          <el-slider v-model="seekSeconds" :max="recordDuration" :show-tooltip="true" class="seek-bar" @change="control('seek', $event)" /><span>{{ seekSeconds }} 秒</span>
+        </template>
+      </div>
+      <div v-if="!playbackMode && selected?.sourceType === 'GB28181'" v-hasPermi="['video:device:ptz']" class="ptz-panel">
+        <span>云台控制</span>
+        <div class="ptz-grid"><el-button v-for="item in directions" :key="item[0]" :disabled="!sessionId || starting" @pointerdown.prevent="beginPtz($event, item[0])" @keydown.enter.prevent="pulsePtz(item[0])">{{ item[1] }}</el-button></div>
+        <div><el-button :disabled="!sessionId" @pointerdown.prevent="beginPtz($event, 'zoomin')">放大</el-button><el-button :disabled="!sessionId" @pointerdown.prevent="beginPtz($event, 'zoomout')">缩小</el-button></div>
+        <div class="ptz-speed">速度<el-slider v-model="ptzSpeed" :min="1" :max="255" /></div>
+        <span class="muted">按住移动,松开停止;固定摄像机可能不支持云台。</span>
+      </div>
+      <el-table v-if="playbackMode" v-loading="queryingRecords" :data="records" max-height="260" class="record-table">
+        <el-table-column label="录像名称" prop="name" min-width="160" show-overflow-tooltip /><el-table-column label="开始时间" prop="startTime" width="180" /><el-table-column label="结束时间" prop="endTime" width="180" /><el-table-column label="录像类型" prop="type" width="100" />
+        <el-table-column label="操作" width="100"><template #default="{row}"><el-button link type="primary" :disabled="starting" @click="playRecord(row)">回放</el-button></template></el-table-column>
+        <template #empty><el-empty :description="recordsQueried ? '所选时间段没有设备录像' : '请先查询设备历史录像'" :image-size="60" /></template>
+      </el-table>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup name="VideoDevices">
+import { ref, reactive, onBeforeUnmount } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import VideoPlayer from '@/components/VideoPlayer/index.vue'
+import EquipmentEditorFields from '@/components/EquipmentEditorFields/index.vue'
+import { listVideoDevices, getVideoDevice, saveVideoDevice, removeVideoDevice, discoverVideoDevices, syncAllVideoDevices, startLive, queryRecords, startPlayback, ptzVideo, stopVideo, heartbeatVideo, controlPlayback } from '@/api/video'
+const query = reactive({ pageNum: 1, pageSize: 10, query: '', sourceType: '' }), rows = ref([]), total = ref(0), loading = ref(false)
+const editVisible = ref(false), form = ref({}), formRef = ref(), saving = ref(false)
+const equipmentForm = ref({})
+const videoTypeOptions = [{ id: 2, typeName: '排水', disabled: true, children: [{ id: 20, typeName: '视频监控(排口+内涝点)' }] }]
+const discoveryVisible = ref(false), discovering = ref(false), syncingAll = ref(false), discovered = ref([]), discoveredTotal = ref(0)
+const discoveryQuery = reactive({ pageNum: 1, pageSize: 10, query: '', online: undefined })
+const viewerVisible = ref(false), selected = ref(), playbackMode = ref(false), starting = ref(false), streamUrl = ref(''), nativePlayer = ref(false), playerError = ref(''), sessionId = ref('')
+const range = ref([]), records = ref([]), queryingRecords = ref(false), recordsQueried = ref(false), paused = ref(false), playSpeed = ref(1), seekSeconds = ref(0), recordDuration = ref(1), ptzSpeed = ref(100)
+const directions = [['upleft','↖'],['up','↑'],['upright','↗'],['left','←'],['stop','停止'],['right','→'],['downleft','↙'],['down','↓'],['downright','↘']]
+let viewerGeneration = 0, heartbeat, activePtzDevice = null, ptzTimer
+async function load() { loading.value = true; try { const result = await listVideoDevices(query); rows.value = result.rows; total.value = result.total } finally { loading.value = false } }
+function search() { query.pageNum = 1; load() }
+function reset() { Object.assign(query, { pageNum: 1, query: '', sourceType: '' }); load() }
+async function edit(row) {
+  if (!row) return
+  form.value = { ...(await getVideoDevice(row.videoId)).data }
+  equipmentForm.value = { ...form.value, equipmentId: form.value.videoId, equipmentTypeId: 20, currentStatus: form.value.currentStatus ?? 0 }
+  editVisible.value = true
+}
+async function save() {
+  if (saving.value) return
+  await formRef.value.validate()
+  if (form.value.sourceType === 'MANUAL' && !form.value.videoUrl?.trim()) { ElMessage.warning('请输入视频地址'); return }
+  saving.value = true
+  try {
+    await saveVideoDevice({ ...form.value, equipment: equipmentForm.value, currentStatus: equipmentForm.value.currentStatus,
+      deviceName: equipmentForm.value.equipmentName, deviceCode: equipmentForm.value.equipmentCode,
+      location: equipmentForm.value.equipmentLocation, manufacturer: equipmentForm.value.manufacturer, remark: equipmentForm.value.remark })
+    editVisible.value = false; ElMessage.success('视频设备已保存'); await load()
+  } finally { saving.value = false }
+}
+async function remove(row) { await ElMessageBox.confirm(`确认移除“${row.deviceName}”?`, '移除视频设备', { type: 'warning' }); await removeVideoDevice(row.videoId); await load() }
+function openDiscovery() { discoveryVisible.value = true; discoveryQuery.pageNum = 1; discover() }
+async function discover(resetPage = false) { if (resetPage) discoveryQuery.pageNum = 1; discovering.value = true; try { const result = await discoverVideoDevices(discoveryQuery); discovered.value = result.data.rows; discoveredTotal.value = result.data.total } finally { discovering.value = false } }
+async function syncAll() {
+  if (syncingAll.value) return
+  syncingAll.value = true
+  try {
+    const result = await syncAllVideoDevices()
+    const data = result.data || {}
+    const started = Number(data.started || 0)
+    const total = Number(data.total || 0)
+    if (!total) ElMessage.warning('当前没有在线注册设备')
+    else ElMessage.success(`已向 ${started} 台在线设备发起目录同步,请稍后点击刷新`)
+    await discover()
+  } finally { syncingAll.value = false }
+}
+function adopt(row) {
+  form.value = { sourceType: 'GB28181', gbDeviceId: row.gbDeviceId, gbChannelId: row.gbChannelId }
+  equipmentForm.value = { equipmentName: row.channelName || row.deviceName || row.gbChannelId,
+    equipmentCode: row.gbDeviceId + '/' + row.gbChannelId, equipmentTypeId: 20, currentStatus: 0, manufacturer: row.manufacturer }
+  editVisible.value = true
+}
+async function openLive(row) { selected.value = row; playbackMode.value = false; viewerVisible.value = true; playerError.value = ''; await playLive() }
+function dateString(date) { return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}:${String(date.getSeconds()).padStart(2,'0')}` }
+function openRecords(row) { selected.value = row; playbackMode.value = true; viewerVisible.value = true; records.value = []; recordsQueried.value = false; playerError.value = ''; const now = new Date(); range.value = [dateString(new Date(now.getFullYear(),now.getMonth(),now.getDate())), dateString(now)]; loadRecords() }
+async function stopCurrent() { clearInterval(heartbeat); heartbeat = null; streamUrl.value = ''; await endPtz(); const id = sessionId.value; if (id) { await stopVideo(id); sessionId.value = '' } }
+function acceptStream(data) {
+  sessionId.value = data.sessionId
+  const secure = location.protocol === 'https:'
+  streamUrl.value = data.url || (secure ? data.wss_flv || data.https_flv : data.ws_flv || data.flv)
+  nativePlayer.value = Boolean(data.native) && !/\.flv(?:[?#]|$)|^wss?:/.test(streamUrl.value || '')
+  if (!streamUrl.value) throw new Error(secure ? '媒体节点没有可用的 HTTPS 播放地址' : '媒体节点未返回播放地址')
+  heartbeat = setInterval(() => { if (sessionId.value) heartbeatVideo(sessionId.value).catch(() => { playerError.value = '播放会话已断开,请重新播放' }) }, 30000)
+}
+async function playLive() {
+  if (starting.value) return
+  const version = ++viewerGeneration; starting.value = true; playerError.value = ''
+  try { await stopCurrent(); const result = await startLive(selected.value.videoId); if (version !== viewerGeneration || !viewerVisible.value) { await stopVideo(result.data.sessionId); return } acceptStream(result.data) }
+  catch (error) { if (version === viewerGeneration) playerError.value = error.message || '实时播放失败,请检查设备注册及媒体服务' }
+  finally { if (version === viewerGeneration) starting.value = false }
+}
+async function loadRecords() {
+  if (range.value?.length !== 2 || queryingRecords.value) return
+  const version = viewerGeneration; queryingRecords.value = true
+  try { const result = await queryRecords(selected.value.videoId, { startTime: range.value[0], endTime: range.value[1] }); if (version !== viewerGeneration || !viewerVisible.value) return; records.value = result.data?.recordList || []; recordsQueried.value = true }
+  catch (error) { records.value = []; recordsQueried.value = false }
+  finally { queryingRecords.value = false }
+}
+async function playRecord(row) {
+  if (starting.value) return
+  const version = ++viewerGeneration; starting.value = true; playerError.value = ''
+  try { await stopCurrent(); const result = await startPlayback(selected.value.videoId, { startTime: row.startTime.replace('T',' '), endTime: row.endTime.replace('T',' ') }); if (version !== viewerGeneration || !viewerVisible.value) { await stopVideo(result.data.sessionId); return } acceptStream(result.data); paused.value = false; playSpeed.value = 1; seekSeconds.value = 0; recordDuration.value = Math.max(1, (new Date(row.endTime.replace('T',' ')) - new Date(row.startTime.replace('T',' '))) / 1000) }
+  catch (error) { if (version === viewerGeneration) playerError.value = error.message || '录像回放失败' }
+  finally { if (version === viewerGeneration) starting.value = false }
+}
+async function control(action, value) { await controlPlayback(sessionId.value, { action, value }); if (action === 'pause') paused.value = true; if (action === 'resume') paused.value = false }
+function beginPtz(event, command) { event.currentTarget.setPointerCapture?.(event.pointerId); pulsePtz(command, false) }
+async function pulsePtz(command, keyboard = true) {
+  if (activePtzDevice) return
+  if (command === 'stop') { await ptzVideo(selected.value.videoId, { command: 'stop', speed: ptzSpeed.value, zoomSpeed: 8 }); return }
+  activePtzDevice = selected.value.videoId
+  // Register release listeners before sending; a release during the request still queues STOP after movement.
+  activePtzPromise = ptzVideo(activePtzDevice, { command, speed: ptzSpeed.value, zoomSpeed: 8 })
+  window.addEventListener('pointerup', endPtz, { once: true }); window.addEventListener('pointercancel', endPtz, { once: true }); window.addEventListener('blur', endPtz, { once: true })
+  ptzTimer = setTimeout(endPtz, keyboard ? 250 : 5000)
+  try { await activePtzPromise } catch { await endPtz() }
+}
+let activePtzPromise
+async function endPtz() { clearTimeout(ptzTimer); window.removeEventListener('pointerup', endPtz); window.removeEventListener('pointercancel', endPtz); window.removeEventListener('blur', endPtz); const id = activePtzDevice; if (!id) return; activePtzDevice = null; await activePtzPromise.catch(() => {}); await ptzVideo(id, { command: 'stop', speed: 1, zoomSpeed: 1 }).catch(() => {}) }
+function closeViewer() { viewerGeneration++; starting.value = false; stopCurrent().catch(() => {}); playerError.value = '' }
+onBeforeUnmount(() => { viewerGeneration++; clearInterval(heartbeat); endPtz(); if (sessionId.value) stopVideo(sessionId.value).catch(() => {}) })
+load()
+</script>
+
+<style scoped>
+.video-connection-title { margin: 8px 0 12px; padding-left: 10px; border-left: 3px solid #014db2; font-weight: 600; }
+.toolbar { margin-bottom: 14px; }
+.discovery-search { margin-top: 20px; }
+.record-query, .player-actions { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; flex-wrap: wrap; }
+.player-actions { margin-top: 14px; }
+.seek-bar { width: 220px; margin: 0 8px; }
+.play-error { margin-top: 12px; }
+.ptz-panel { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
+.ptz-grid { display: grid; grid-template-columns: repeat(3,55px); gap: 5px; }
+.ptz-grid .el-button { margin: 0; }
+.ptz-speed { width: 150px; }
+.muted { color: #909399; font-size: 12px; }
+.record-table { margin-top: 12px; }
+</style>