yanghao 1 неделя назад
Родитель
Сommit
236258ddc0
30 измененных файлов с 7315 добавлено и 51 удалено
  1. 1 1
      package.json
  2. 6 6
      pnpm-lock.yaml
  3. 0 0
      public/h5player/h5player.min.js
  4. 705 0
      public/h5player/playctrl1/DecodeWorker.js
  5. BIN
      public/h5player/playctrl1/Decoder.js
  6. 705 0
      public/h5player/playctrl1simd/DecodeWorker.js
  7. BIN
      public/h5player/playctrl1simd/Decoder.js
  8. 0 0
      public/h5player/playctrl2/Decoder.js
  9. BIN
      public/h5player/playctrl2/Decoder.wasm
  10. 0 0
      public/h5player/playctrl2/Decoder.worker.js
  11. 0 0
      public/h5player/playctrl3/Decoder.js
  12. BIN
      public/h5player/playctrl3/Decoder.wasm
  13. 0 0
      public/h5player/playctrl3/Decoder.worker.js
  14. 7 0
      public/h5player/talk/AudioInterCom.js
  15. BIN
      public/h5player/talk/AudioInterCom.wasm
  16. 8 0
      public/h5player/talkW/AudioInterCom.js
  17. BIN
      public/h5player/talkW/AudioInterCom.wasm
  18. 0 0
      public/h5player/talkW/AudioInterCom.worker.js
  19. 2668 0
      public/h5player/transform/libSystemTransform.js
  20. BIN
      public/h5player/transform/libSystemTransform.wasm
  21. 126 0
      public/h5player/transform/systemTransform-worker.js
  22. 104 0
      src/api/pms/hikvision/index.ts
  23. 1 0
      src/assets/icons/grid-16.svg
  24. 1 0
      src/assets/icons/grid-four.svg
  25. 1 0
      src/assets/icons/grid-nine.svg
  26. 1 0
      src/assets/icons/grid-one.svg
  27. 167 0
      src/utils/hikvisionH5Player.ts
  28. 30 23
      src/views/pms/device/DeviceBasicInfo.vue
  29. 152 21
      src/views/pms/device/DeviceInfo.vue
  30. 2632 0
      src/views/pms/video_center/haikang/index.vue

+ 1 - 1
package.json

@@ -119,7 +119,7 @@
   "devDependencies": {
     "@commitlint/cli": "^19.0.1",
     "@commitlint/config-conventional": "^19.0.0",
-    "@iconify/json": "^2.2.187",
+    "@iconify/json": "^2.2.524",
     "@intlify/unplugin-vue-i18n": "^2.0.0",
     "@purge-icons/generated": "^0.9.0",
     "@types/lodash-es": "^4.17.12",

+ 6 - 6
pnpm-lock.yaml

@@ -286,8 +286,8 @@ importers:
         specifier: ^19.0.0
         version: 19.6.0
       '@iconify/json':
-        specifier: ^2.2.187
-        version: 2.2.277
+        specifier: ^2.2.524
+        version: 2.2.524
       '@intlify/unplugin-vue-i18n':
         specifier: ^2.0.0
         version: 2.0.0(rollup@4.27.4)(vue-i18n@9.10.2(vue@3.5.12(typescript@5.3.3)))
@@ -1421,8 +1421,8 @@ packages:
     resolution: {integrity: sha512-1nemfyD/OJzh9ALepH7YfuuP8BdEB24Skhd8DXWh0hzcOxImbb1ZizSZkpCzAwSZSGcJFmscIBaBQu+yLyWaxQ==}
     deprecated: no longer maintained, switch to modern iconify-icon web component
 
-  '@iconify/json@2.2.277':
-    resolution: {integrity: sha512-hNBnGD2djNgsdB4Yq5dBhP2CI0PLt+4EamozKSAD+hsbFAzVUN6sMj5FUiBFu8BKUOBIYcrX8ri7C7Qe3K10ew==}
+  '@iconify/json@2.2.524':
+    resolution: {integrity: sha512-xw2TRuiRcOzx8mTXRNIfg9FYr4sd7Z7s/DGKitJZt3njeiFmjHte8qKWEGK+IePYtQ1QyVnhfOi+5sEjVTmvDQ==}
 
   '@iconify/types@2.0.0':
     resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
@@ -7857,10 +7857,10 @@ snapshots:
     dependencies:
       '@iconify/types': 2.0.0
 
-  '@iconify/json@2.2.277':
+  '@iconify/json@2.2.524':
     dependencies:
       '@iconify/types': 2.0.0
-      pathe: 1.1.2
+      pathe: 2.0.3
 
   '@iconify/types@2.0.0': {}
 

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/h5player/h5player.min.js


+ 705 - 0
public/h5player/playctrl1/DecodeWorker.js

@@ -0,0 +1,705 @@
+/**
+ * Created by wangweijie5 on 2016/12/5.
+ */
+(function (event) {
+    const AUDIO_TYPE = 0;	// 音频
+    const VIDEO_TYPE = 1;   // 视频
+    const PRIVT_TYPE = 2;  // 私有帧
+
+    const PLAYM4_AUDIO_FRAME = 100; // 音频帧
+    const PLAYM4_VIDEO_FRAME = 101; // 视频帧
+
+    const PLAYM4_OK = 1;
+    const PLAYM4_ORDER_ERROR = 2;
+    const PLAYM4_DECODE_ERROR = 44 	// 解码失败
+    const PLAYM4_NOT_KEYFRAME = 48; 	// 非关键帧
+    const PLAYM4_NEED_MORE_DATA = 31;   // 需要更多数据才能解析
+    const PLAYM4_NEED_NEET_LOOP = 35; //丢帧需要下个循环
+    const PLAYM4_SYS_NOT_SUPPORT = 16; 	// 不支持
+
+    // importScripts('Decoder.js');
+    // Module.addOnPostRun(function () {
+    //     postMessage({ 'function': "loaded" });
+    // });
+
+    var iStreamMode = 0;  // 流模式
+
+    var bOpenMode = false;
+    var bOpenStream = false;
+
+    var funGetFrameData = null;
+    var funGetAudFrameData = null;
+
+    var bWorkerPrintLog = false;//worker层log开关
+
+    var g_nPort = -1;
+    var pInputData = null;
+    var inputBufferSize = 40960;
+
+    self.JSPlayM4_RunTimeInfoCallBack = function (nPort, pstRunTimeInfo, pUser) {
+        let port = nPort;
+        let user = pUser;
+        let nRunTimeModule = Module.HEAP32[pstRunTimeInfo >> 2];
+        let nStrVersion = Module.HEAP32[pstRunTimeInfo + 4 >> 2];
+        let nFrameTimeStamp = Module.HEAP32[pstRunTimeInfo + 8 >> 2];
+        let nFrameNum = Module.HEAP32[pstRunTimeInfo + 12 >> 2];
+        let nErrorCode = Module.HEAP32[pstRunTimeInfo + 16 >> 2];
+        // console.log("nRunTimeModule:"+nRunTimeModule+",nFrameNum:"+nFrameNum+",nErrorCode:"+nErrorCode);
+        postMessage({ 'function': "RunTimeInfoCallBack", 'nRunTimeModule': nRunTimeModule, 'nStrVersion': nStrVersion, 'nFrameTimeStamp': nFrameTimeStamp, 'nFrameNum': nFrameNum, 'nErrorCode': nErrorCode });
+    }
+	
+	self.JSPlayM4_StreamInfoCallBack = function (nPort, pstStreamInfo, pUser)
+	{
+		let port = nPort;
+        let user = pUser;
+        let nSystemformat = Module.HEAP16[pstStreamInfo >> 1]; //封装类型
+        let nVideoformat = Module.HEAP16[pstStreamInfo + 2 >> 1];//视频编码类型
+        let nAudioformat = Module.HEAP16[pstStreamInfo + 4 >> 1];//音频编码类型
+        let nAudiochannels = Module.HEAP16[pstStreamInfo + 6 >> 1]; //音频通道数
+        let nAudiobitspersample = Module.HEAP32[pstStreamInfo + 8 >> 2];//音频样位率
+        let nAudiosamplesrate = Module.HEAP32[pstStreamInfo + 12 >> 2];//音频采样率
+        let nAudiobitrate = Module.HEAP32[pstStreamInfo + 16 >> 2];//音频比特率,单位:bit
+		//console.log("nSystemformat:" + nSystemformat + ",nVideoformat:" + nVideoformat + ",nAudioformat:" + nAudioformat + ",nAudiochannels:" + nAudiochannels + ",nAudiobitspersample:" + nAudiobitspersample + ",nAudiosamplesrate:" + nAudiosamplesrate + ",nAudiobitrate:" + nAudiobitrate);
+        postMessage({ 'function': "StreamInfoCallBack", 'nSystemformat': nSystemformat, 'nVideoformat': nVideoformat, 'nAudioformat': nAudioformat, 'nAudiochannels': nAudiochannels, 'nAudiobitspersample': nAudiobitspersample, 'nAudiosamplesrate': nAudiosamplesrate, 'nAudiobitrate': nAudiobitrate});
+	}
+
+    onmessage = function (event) {
+        var eventData = event.data;
+        var res = 0;
+        switch (eventData.command) {
+            case "importScripts":
+                const decodebase = eventData.data + "Decoder.js"
+                importScripts(decodebase);
+                Module.addOnPostRun(function () {
+                    postMessage({ 'function': "loaded" });
+                });
+                break;
+            case "printLog":
+                let downloadFlag = eventData.data;
+                if (downloadFlag === true) {
+                    bWorkerPrintLog = true;
+                    res = Module._SetPrintLogFlag(g_nPort, downloadFlag);
+                }
+                else {
+                    bWorkerPrintLog = false;
+                    res = Module._SetPrintLogFlag(g_nPort, downloadFlag);
+                }
+
+                if (res !== PLAYM4_OK) {
+                    console.log("DecodeWorker.js: PlayerSDK print log failed,res" + res);
+                    postMessage({ 'function': "printLog", 'errorCode': res });
+                }
+                break;
+            case "SetPlayPosition":
+                let nFrameNumOrTime = eventData.data;
+                let enPosType = eventData.type;
+                // res = Module._SetPlayPosition(nFrameNumOrTime,enPosType);
+                // if (res !== PLAYM4_OK)
+                // {
+                //     postMessage({'function': "SetPlayPosition", 'errorCode': res});
+                //     return;
+                // }
+                // //有没有buffer需要清除
+
+                break;
+            case "SetStreamOpenMode":
+                //获取端口号
+                g_nPort = Module._GetPort();
+                //设置流打开模式
+                iStreamMode = eventData.data;
+                res = Module._SetStreamOpenMode(g_nPort, iStreamMode);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "SetStreamOpenMode", 'errorCode': res });
+                    return;
+                }
+                bOpenMode = true;
+                break;
+
+            case "OpenStream":
+                // 接收到的数据
+                var iHeadLen = eventData.dataSize;
+                var pHead = Module._malloc(iHeadLen + 4);
+                if (pHead === null) {
+                    return;
+                }
+                var aHead = Module.HEAPU8.subarray(pHead, pHead + iHeadLen);
+                aHead.set(new Uint8Array(eventData.data));
+                res = Module._OpenStream(g_nPort, pHead, iHeadLen, eventData.bufPoolSize);
+                postMessage({ 'function': "OpenStream", 'errorCode': res });
+                if (res !== PLAYM4_OK) {
+                    //释放内存
+                    Module._free(pHead);
+                    pHead = null;
+                    return;
+                }
+                bOpenStream = true;
+                break;
+            case "Play":
+                let resP = Module._Play(g_nPort);
+                if (resP !== PLAYM4_OK) {
+                    return;
+                }
+                break;
+            case "InputData":
+                // 接收到的数据
+                var iLen = eventData.dataSize;
+                if (iLen > 0) {
+                    if (pInputData == null || iLen > inputBufferSize) {
+                        if (pInputData != null) {
+                            Module._free(pInputData);
+                            pInputData = null;
+                        }
+                        if (iLen > inputBufferSize) {
+                            inputBufferSize = iLen;
+                        }
+
+                        pInputData = Module._malloc(inputBufferSize);
+                        if (pInputData === null) {
+                            return;
+                        }
+                    }
+
+                    var inputData = new Uint8Array(eventData.data);
+                    // var aInputData = Module.HEAPU8.subarray(pInputData, pInputData + iLen);
+                    // aInputData.set(inputData);
+                    Module.writeArrayToMemory(inputData, pInputData);
+                    inputData = null;
+                    res = Module._InputData(g_nPort, pInputData, iLen);
+                    if (res !== PLAYM4_OK) {
+                        let errorCode = Module._GetLastError(g_nPort);
+                        let sourceRemain = Module._GetSourceBufferRemain(g_nPort);
+                        postMessage({ 'function': "InputData", 'errorCode': errorCode, "sourceRemain": sourceRemain });
+                    }
+                    //Module._free(pInputData);
+                    //pInputData = null;
+                } else {
+                    let sourceRemain = Module._GetSourceBufferRemain(g_nPort);
+                    if (sourceRemain == 0) {
+                        postMessage({ 'function': "InputData", 'errorCode': PLAYM4_NEED_MORE_DATA });
+                        return;
+                    }
+                }
+
+                /////////////////////
+                // if (funGetFrameData === null) {
+                //     funGetFrameData = Module.cwrap('GetFrameData', 'number');
+                // }
+
+                while (bOpenMode && bOpenStream) {
+
+                    var ret = getFrameData();
+                    // 直到获取视频帧或数据不足为止
+                    if (PLAYM4_VIDEO_FRAME === ret || PLAYM4_NEED_MORE_DATA === ret || PLAYM4_ORDER_ERROR === ret)//PLAYM4_VIDEO_FRAME === ret ||  || PLAYM4_NEED_NEET_LOOP === ret
+                    {
+                        break;
+                    }
+                }
+                break;
+
+            case "SetSecretKey":
+                var keyLen = eventData.nKeyLen;
+                var pKeyData = Module._malloc(keyLen);
+                if (pKeyData === null) {
+                    return;
+                }
+                var nKeySize = eventData.data.length
+                var bufData = stringToBytes(eventData.data);
+                var aKeyData = Module.HEAPU8.subarray(pKeyData, pKeyData + keyLen);
+                let u8array = new Uint8Array(keyLen);
+                aKeyData.set(u8array, 0);
+                aKeyData.set(new Uint8Array(bufData));
+                aKeyData = null;
+                u8array = null;
+
+                res = Module._SetSecretKey(g_nPort, eventData.nKeyType, pKeyData, keyLen);//, nKeySize
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "SetSecretKey", 'errorCode': res });
+                    Module._free(pKeyData);
+                    pKeyData = null;
+                    return;
+                }
+
+                Module._free(pKeyData);
+                pKeyData = null;
+                break;
+
+            case "GetBMP":
+                var nBMPWidth = eventData.width;
+                var nBMPHeight = eventData.height;
+                var pYUVData = eventData.data;
+                var nYUVSize = nBMPWidth * nBMPHeight * 3 / 2;
+                var oBMPCropRect = {
+                    left: eventData.left,
+                    top: eventData.top,
+                    right: eventData.right,
+                    bottom: eventData.bottom
+                };
+
+                var pDataYUV = Module._malloc(nYUVSize);
+                if (pDataYUV === null) {
+                    return;
+                }
+
+                Module.writeArrayToMemory(new Uint8Array(pYUVData, 0, nYUVSize), pDataYUV);
+
+                // 分配BMP空间
+                var nBmpSize = nBMPWidth * nBMPHeight * 4 + 60;
+                var pBmpData = Module._malloc(nBmpSize);
+                var pBmpSize = Module._malloc(4);
+                if (pBmpData === null || pBmpSize === null) {
+                    Module._free(pDataYUV);
+                    pDataYUV = null;
+
+                    if (pBmpData != null) {
+                        Module._free(pBmpData);
+                        pBmpData = null;
+                    }
+
+                    if (pBmpSize != null) {
+                        Module._free(pBmpSize);
+                        pBmpSize = null;
+                    }
+                    return;
+                }
+
+                //Module._memset(pBmpSize, nBmpSize, 4); // 防止bmp截图出现输入数据过大的错误码
+                Module.setValue(pBmpSize, nBmpSize, "i32");
+                res = Module._GetBMP(g_nPort, pDataYUV, nYUVSize, pBmpData, pBmpSize,
+                    oBMPCropRect.left, oBMPCropRect.top, oBMPCropRect.right, oBMPCropRect.bottom);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "GetBMP", 'errorCode': res });
+                    Module._free(pDataYUV);
+                    pDataYUV = null;
+                    Module._free(pBmpData);
+                    pBmpData = null;
+                    Module._free(pBmpSize);
+                    pBmpSize = null;
+                    return;
+                }
+
+                // 获取BMP图片大小
+                var nBmpDataSize = Module.getValue(pBmpSize, "i32");
+
+                // 获取BMP图片数据
+                var aBmpData = new Uint8Array(nBmpDataSize);
+                aBmpData.set(Module.HEAPU8.subarray(pBmpData, pBmpData + nBmpDataSize));
+
+                postMessage({ 'function': "GetBMP", 'data': aBmpData, 'errorCode': res }, [aBmpData.buffer]);
+                aBmpData = null;
+                if (pDataYUV != null) {
+                    Module._free(pDataYUV);
+                    pDataYUV = null;
+                }
+                if (pBmpData != null) {
+                    Module._free(pBmpData);
+                    pBmpData = null;
+                }
+                if (pBmpSize != null) {
+                    Module._free(pBmpSize);
+                    pBmpSize = null;
+                }
+                break;
+
+            case "GetJPEG":
+                var nJpegWidth = eventData.width;
+                var nJpegHeight = eventData.height;
+                var pYUVData1 = eventData.data;
+                var nYUVSize1 = nJpegWidth * nJpegHeight * 3 / 2;
+                var oJpegCropRect = {
+                    left: eventData.left,
+                    top: eventData.top,
+                    right: eventData.right,
+                    bottom: eventData.bottom
+                };
+
+                var pDataYUV1 = Module._malloc(nYUVSize1);
+                if (pDataYUV1 === null) {
+                    return;
+                }
+
+                Module.writeArrayToMemory(new Uint8Array(pYUVData1, 0, nYUVSize1), pDataYUV1);
+
+                // 分配JPEG空间
+                var pJpegData = Module._malloc(nYUVSize1);
+                var pJpegSize = Module._malloc(4);
+                if (pJpegData === null || pJpegSize === null) {
+                    if (pJpegData != null) {
+                        Module._free(pJpegData);
+                        pJpegData = null;
+                    }
+
+                    if (pJpegSize != null) {
+                        Module._free(pJpegSize);
+                        pJpegSize = null;
+                    }
+
+                    if (pDataYUV1 != null) {
+                        Module._free(pDataYUV1);
+                        pDataYUV1 = null;
+                    }
+                    return;
+                }
+
+                Module.setValue(pJpegSize, nJpegWidth * nJpegHeight * 2, "i32");    // JPEG抓图,输入缓冲长度不小于当前帧YUV大小
+
+                res = Module._GetJPEG(g_nPort, pDataYUV1, nYUVSize1, pJpegData, pJpegSize,
+                    oJpegCropRect.left, oJpegCropRect.top, oJpegCropRect.right, oJpegCropRect.bottom);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "GetJPEG", 'errorCode': res });
+                    if (pJpegData != null) {
+                        Module._free(pJpegData);
+                        pJpegData = null;
+                    }
+
+                    if (pJpegSize != null) {
+                        Module._free(pJpegSize);
+                        pJpegSize = null;
+                    }
+
+                    if (pDataYUV1 != null) {
+                        Module._free(pDataYUV1);
+                        pDataYUV1 = null;
+                    }
+                    return;
+                }
+
+                // 获取JPEG图片大小
+                var nJpegSize = Module.getValue(pJpegSize, "i32");
+
+                // 获取JPEG图片数据
+                var aJpegData = new Uint8Array(nJpegSize);
+                aJpegData.set(Module.HEAPU8.subarray(pJpegData, pJpegData + nJpegSize));
+
+                postMessage({ 'function': "GetJPEG", 'data': aJpegData, 'errorCode': res }, [aJpegData.buffer]);
+
+                nJpegSize = null;
+                aJpegData = null;
+
+                if (pDataYUV1 != null) {
+                    Module._free(pDataYUV1);
+                    pDataYUV1 = null;
+                }
+                if (pJpegData != null) {
+                    Module._free(pJpegData);
+                    pJpegData = null;
+                }
+                if (pJpegSize != null) {
+                    Module._free(pJpegSize);
+                    pJpegSize = null;
+                }
+                break;
+
+            case "SetDecodeFrameType":
+                var nFrameType = eventData.data;
+                res = Module._SetDecodeFrameType(g_nPort, nFrameType);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "SetDecodeFrameType", 'errorCode': res });
+                    return;
+                }
+                break;
+            case "CloseStream":
+                //stop
+                let resS = Module._Stop(g_nPort);
+                if (resS !== PLAYM4_OK) {
+                    postMessage({ 'function': "Stop", 'errorCode': res });
+                    return;
+                }
+                //closeStream
+                res = Module._CloseStream(g_nPort);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "CloseStream", 'errorCode': res });
+                    return;
+                }
+                //freePort
+                let resF = Module._FreePort(g_nPort);
+                if (resF !== PLAYM4_OK) {
+                    postMessage({ 'function': "FreePort", 'errorCode': res });
+                    return;
+                }
+                if (pInputData != null) {
+                    Module._free(pInputData);
+                    pInputData = null;
+                }
+                break;
+            case "PlaySound":
+                let resPS = Module._PlaySound(g_nPort);
+                if (resPS !== PLAYM4_OK) {
+                    console.log("PlaySound failed");
+                    return;
+                }
+                break;
+            case "StopSound":
+                let resSS = Module._StopSound();
+                if (resSS !== PLAYM4_OK) {
+                    console.log("StopSound failed");
+                    return;
+                }
+                break;
+            case "SetVolume":
+                let resSV = Module._SetVolume(g_nPort, eventData.volume);
+                if (resSV !== PLAYM4_OK) {
+                    console.log("Audio SetVolume failed");
+                    return;
+                }
+                break;
+            case "GetVolume":
+                let volume = Module._GetVolume();
+                if (volume > 0) {
+                    postMessage({ 'function': "GetVolume", 'volume': volume });
+                }
+                else {
+                    console.log("Audio GetVolume failed");
+                    return;
+                }
+                break;
+            case "OnlyPlaySound":
+                let resOPS = Module._OnlyPlaySound(g_nPort);
+                if (resOPS !== PLAYM4_OK) {
+                    console.log("OnlyPlaySound failed");
+                    return;
+                }
+                break;
+            case "Pause":
+                let resPa = Module._Pause(g_nPort, eventData.bPlay);
+                if (resPa !== PLAYM4_OK) {
+                    console.log("Pause failed");
+                    return;
+                }
+            case "PlayRate":
+                Module._SetPlayRate(g_nPort, eventData.playRate);
+                break;
+            case "SetIFrameDecInterval":
+                Module._SetIFrameDecInterval(g_nPort, eventData.data);
+                break;
+            case "SetLostFrameMode":
+                Module._SetLostFrameMode(g_nPort, eventData.data, 0);
+                break;
+            case "SetDemuxModel":
+                let resSDM = Module._SetDemuxModel(g_nPort, eventData.nIdemuxType, eventData.bTrue);
+                break;
+            case "SkipErrorData":
+                Module._SkipErrorData(g_nPort, eventData.bSkip);
+                break;
+            case "SetDecodeERC":
+                Module._SetDecodeERC(g_nPort, eventData.nLevel);
+                break;
+            case "SetANRParam":
+                Module._SetANRParam(g_nPort, eventData.nEnable, eventData.nANRLevel);
+                break;
+            case "SetResampleValue":
+                Module._SetResampleValue(g_nPort, eventData.nEnable, eventData.resampleValue);
+                break;
+            case "GetLastError":
+                let errorCode = Module._GetLastError(g_nPort);
+                postMessage({ 'function': "GetLastError", 'errorCode': errorCode });
+                break;
+            case "SetGlobalBaseTime":
+                Module._SetGlobalBaseTime(g_nPort, eventData.year, eventData.month, eventData.day, eventData.hour, eventData.min, eventData.sec, eventData.ms);
+                break;
+            case "SetRunTimeInfoCB":
+                Module._SetRunTimeInfoCallBackEx(g_nPort, eventData.nModuleType, 0);
+                break;
+			case "SetStreamInfoCB":
+                Module._SetStreamInfoCallBack(g_nPort, eventData.nType, 0);
+                break;
+			case "ResetBuffer":
+                Module._JSPlayM4_ResetBuffer(g_nPort, eventData.type);
+                break;
+            default:
+                break;
+        }
+    };
+
+    function getOSDTime(oFrameInfo) {
+        var iYear = oFrameInfo.year;
+        var iMonth = oFrameInfo.month;
+        var iDay = oFrameInfo.day;
+        var iHour = oFrameInfo.hour;
+        var iMinute = oFrameInfo.minute;
+        var iSecond = oFrameInfo.second;
+        var iMiSecond = oFrameInfo.misecond
+
+        if (iMonth < 10) {
+            iMonth = "0" + iMonth;
+        }
+        if (iDay < 10) {
+            iDay = "0" + iDay;
+        }
+        if (iHour < 10) {
+            iHour = "0" + iHour;
+        }
+        if (iMinute < 10) {
+            iMinute = "0" + iMinute;
+        }
+        if (iSecond < 10) {
+            iSecond = "0" + iSecond;
+        }
+        let osdTime = {};
+
+        osdTime.year = iYear;
+        osdTime.month = iMonth;
+        osdTime.week = 0;
+        osdTime.day = iDay;
+        osdTime.hour = iHour;
+        osdTime.minute = iMinute;
+        osdTime.second = iSecond;
+        osdTime.milliseconds = iMiSecond;
+        return osdTime;
+        //return iYear + "-" + iMonth + "-" + iDay + " " + iHour + ":" + iMinute + ":" + iSecond;
+    }
+    // 获取帧数据
+    function getFrameData() {
+        // function getFrameData() {
+        // 获取帧数据
+        var res = Module._GetFrameData();
+        //var res = fun();
+        if (res === PLAYM4_OK) {
+            var iFrameInfo = Module._GetFrameInfo();
+            let oFrameInfo = {};
+            oFrameInfo.frameType = Module.HEAP32[iFrameInfo >> 2];
+            oFrameInfo.frameSize = Module.HEAP32[iFrameInfo + 4 >> 2];
+            oFrameInfo.width = Module.HEAP32[iFrameInfo + 8 >> 2];
+            oFrameInfo.height = Module.HEAP32[iFrameInfo + 12 >> 2];
+            oFrameInfo.timeStamp = Module.HEAP32[iFrameInfo + 16 >> 2];
+            oFrameInfo.frameRate = Module.HEAP32[iFrameInfo + 20 >> 2];
+            oFrameInfo.bitsPerSample = Module.HEAP32[iFrameInfo + 24 >> 2];
+            oFrameInfo.samplesPerSec = Module.HEAP32[iFrameInfo + 28 >> 2];
+            oFrameInfo.channels = Module.HEAP32[iFrameInfo + 32 >> 2];
+            oFrameInfo.frameNum = Module.HEAP32[iFrameInfo + 36 >> 2];
+
+            oFrameInfo.cropLeft = Module.HEAP32[iFrameInfo + 40 >> 2];
+            oFrameInfo.cropRight = Module.HEAP32[iFrameInfo + 44 >> 2];
+            oFrameInfo.cropTop = Module.HEAP32[iFrameInfo + 48 >> 2];
+            oFrameInfo.cropBottom = Module.HEAP32[iFrameInfo + 52 >> 2];
+
+            oFrameInfo.year = Module.HEAP16[iFrameInfo + 64 >> 1];
+            oFrameInfo.month = Module.HEAP16[iFrameInfo + 66 >> 1];
+            oFrameInfo.day = Module.HEAP16[iFrameInfo + 68 >> 1];
+            oFrameInfo.hour = Module.HEAP16[iFrameInfo + 70 >> 1];
+            oFrameInfo.minute = Module.HEAP16[iFrameInfo + 72 >> 1];
+            oFrameInfo.second = Module.HEAP16[iFrameInfo + 74 >> 1];
+            oFrameInfo.misecond = Module.HEAP16[iFrameInfo + 76 >> 1];
+            switch (oFrameInfo.frameType) {
+                case AUDIO_TYPE:
+                    var iSize = oFrameInfo.frameSize;
+                    if (0 === iSize) {
+                        return -1;
+                    }
+                    var pPCM = Module._GetFrameBuffer();
+                    // var audioBuf = new ArrayBuffer(iSize);
+                    var aPCMData = new Uint8Array(iSize);
+                    aPCMData.set(Module.HEAPU8.subarray(pPCM, pPCM + iSize));
+                    if (bWorkerPrintLog) {
+                        console.log("<<<Worker: audio media Info: nSise:" + oFrameInfo.frameSize + ",nSampleRate:" + oFrameInfo.samplesPerSec + ',channel:' + oFrameInfo.channels + ',bitsPerSample:' + oFrameInfo.bitsPerSample);
+                    }
+                    postMessage({
+                        'function': "GetFrameData", 'type': "audioType", 'data': aPCMData.buffer,
+                        'frameInfo': oFrameInfo, 'errorCode': res
+                    }, [aPCMData.buffer]);
+
+                    oFrameInfo = null;
+                    pPCM = null;
+                    aPCMData = null;
+                    return PLAYM4_AUDIO_FRAME;
+
+                case VIDEO_TYPE:
+                    var szOSDTime = getOSDTime(oFrameInfo);
+
+                    var iWidth = oFrameInfo.width;
+                    var iHeight = oFrameInfo.height;
+
+                    var iYUVSize = iWidth * iHeight * 3 / 2;
+                    if (0 === iYUVSize) {
+                        return -1;
+                    }
+
+                    var pYUV = Module._GetFrameBuffer();
+
+                    // 图像数据渲染后压回,若从主码流切到子码流,存在数组大小与图像大小不匹配现象
+                    var aYUVData = new Uint8Array(iYUVSize);
+                    aYUVData.set(Module.HEAPU8.subarray(pYUV, pYUV + iYUVSize));
+                    if (bWorkerPrintLog) {
+                        console.log("<<<Worker: video media Info: Width:" + oFrameInfo.width + ",Height:" + oFrameInfo.height + ",timeStamp:" + oFrameInfo.timeStamp);
+                    }
+
+                    postMessage({
+                        'function': "GetFrameData", 'type': "videoType", 'data': aYUVData.buffer,
+                        'dataLen': aYUVData.length, 'osd': szOSDTime, 'frameInfo': oFrameInfo, 'errorCode': res
+                    }, [aYUVData.buffer]);
+
+                    oFrameInfo = null;
+                    pYUV = null;
+                    aYUVData = null;
+                    return PLAYM4_VIDEO_FRAME;
+
+                case PRIVT_TYPE:
+                    postMessage({
+                        'function': "GetFrameData", 'type': "", 'data': null,
+                        'dataLen': -1, 'osd': 0, 'frameInfo': null, 'errorCode': PLAYM4_SYS_NOT_SUPPORT
+                    });
+                    return PLAYM4_SYS_NOT_SUPPORT;
+
+                default:
+                    postMessage({
+                        'function': "GetFrameData", 'type': "", 'data': null,
+                        'dataLen': -1, 'osd': 0, 'frameInfo': null, 'errorCode': PLAYM4_SYS_NOT_SUPPORT
+                    });
+                    return PLAYM4_SYS_NOT_SUPPORT;
+            }
+        }
+        else {
+            let errorCode = Module._GetLastError(g_nPort);
+            //解码失败返回裸数据
+            if (PLAYM4_DECODE_ERROR === errorCode) {
+                var rawInfo = Module._GetRawDataInfo();
+                var pRawData = Module._GetRawDataBuffer();
+                var aRawData = new Uint8Array(rawInfo.isize);
+                aRawData.set(Module.HEAPU8.subarray(pRawData, pRawData + rawInfo.isize));
+                postMessage({
+                    'function': "GetRawData", 'type': "", 'data': aRawData.buffer,
+                    'rawDataLen': rawInfo.isize, 'osd': 0, 'frameInfo': null, 'errorCode': errorCode
+                });
+                rawInfo = null;
+                pRawData = null;
+                aRawData = null;
+            }
+            //需要更多数据
+            if (PLAYM4_NEED_MORE_DATA === errorCode || PLAYM4_SYS_NOT_SUPPORT === errorCode || PLAYM4_NEED_NEET_LOOP === errorCode) {
+                postMessage({
+                    'function': "GetFrameData", 'type': "", 'data': null,
+                    'dataLen': -1, 'osd': 0, 'frameInfo': null, 'errorCode': errorCode
+                });
+            }
+            return errorCode;
+        }
+    }
+
+    // 开始计算时间
+    function startTime() {
+        return new Date().getTime();
+    }
+
+    // 结束计算时间
+    function endTime() {
+        return new Date().getTime();
+    }
+
+    // 字母字符串转byte数组
+    function stringToBytes(str) {
+        var ch, st, re = [];
+        for (var i = 0; i < str.length; i++) {
+            ch = str.charCodeAt(i);  // get char
+            st = [];                 // set up "stack"
+            do {
+                st.push(ch & 0xFF);  // push byte to stack
+                ch = ch >> 8;          // shift value down by 1 byte
+            }
+            while (ch);
+            // add stack contents to result
+            // done because chars have "wrong" endianness
+            re = re.concat(st.reverse());
+        }
+        // return an array of bytes
+        return re;
+    }
+})();

BIN
public/h5player/playctrl1/Decoder.js


+ 705 - 0
public/h5player/playctrl1simd/DecodeWorker.js

@@ -0,0 +1,705 @@
+/**
+ * Created by wangweijie5 on 2016/12/5.
+ */
+(function (event) {
+    const AUDIO_TYPE = 0;	// 音频
+    const VIDEO_TYPE = 1;   // 视频
+    const PRIVT_TYPE = 2;  // 私有帧
+
+    const PLAYM4_AUDIO_FRAME = 100; // 音频帧
+    const PLAYM4_VIDEO_FRAME = 101; // 视频帧
+
+    const PLAYM4_OK = 1;
+    const PLAYM4_ORDER_ERROR = 2;
+    const PLAYM4_DECODE_ERROR = 44 	// 解码失败
+    const PLAYM4_NOT_KEYFRAME = 48; 	// 非关键帧
+    const PLAYM4_NEED_MORE_DATA = 31;   // 需要更多数据才能解析
+    const PLAYM4_NEED_NEET_LOOP = 35; //丢帧需要下个循环
+    const PLAYM4_SYS_NOT_SUPPORT = 16; 	// 不支持
+
+    // importScripts('Decoder.js');
+    // Module.addOnPostRun(function () {
+    //     postMessage({ 'function': "loaded" });
+    // });
+
+    var iStreamMode = 0;  // 流模式
+
+    var bOpenMode = false;
+    var bOpenStream = false;
+
+    var funGetFrameData = null;
+    var funGetAudFrameData = null;
+
+    var bWorkerPrintLog = false;//worker层log开关
+
+    var g_nPort = -1;
+    var pInputData = null;
+    var inputBufferSize = 40960;
+
+    self.JSPlayM4_RunTimeInfoCallBack = function (nPort, pstRunTimeInfo, pUser) {
+        let port = nPort;
+        let user = pUser;
+        let nRunTimeModule = Module.HEAP32[pstRunTimeInfo >> 2];
+        let nStrVersion = Module.HEAP32[pstRunTimeInfo + 4 >> 2];
+        let nFrameTimeStamp = Module.HEAP32[pstRunTimeInfo + 8 >> 2];
+        let nFrameNum = Module.HEAP32[pstRunTimeInfo + 12 >> 2];
+        let nErrorCode = Module.HEAP32[pstRunTimeInfo + 16 >> 2];
+        // console.log("nRunTimeModule:"+nRunTimeModule+",nFrameNum:"+nFrameNum+",nErrorCode:"+nErrorCode);
+        postMessage({ 'function': "RunTimeInfoCallBack", 'nRunTimeModule': nRunTimeModule, 'nStrVersion': nStrVersion, 'nFrameTimeStamp': nFrameTimeStamp, 'nFrameNum': nFrameNum, 'nErrorCode': nErrorCode });
+    }
+	
+	self.JSPlayM4_StreamInfoCallBack = function (nPort, pstStreamInfo, pUser)
+	{
+		let port = nPort;
+        let user = pUser;
+        let nSystemformat = Module.HEAP16[pstStreamInfo >> 1]; //封装类型
+        let nVideoformat = Module.HEAP16[pstStreamInfo + 2 >> 1];//视频编码类型
+        let nAudioformat = Module.HEAP16[pstStreamInfo + 4 >> 1];//音频编码类型
+        let nAudiochannels = Module.HEAP16[pstStreamInfo + 6 >> 1]; //音频通道数
+        let nAudiobitspersample = Module.HEAP32[pstStreamInfo + 8 >> 2];//音频样位率
+        let nAudiosamplesrate = Module.HEAP32[pstStreamInfo + 12 >> 2];//音频采样率
+        let nAudiobitrate = Module.HEAP32[pstStreamInfo + 16 >> 2];//音频比特率,单位:bit
+		//console.log("nSystemformat:" + nSystemformat + ",nVideoformat:" + nVideoformat + ",nAudioformat:" + nAudioformat + ",nAudiochannels:" + nAudiochannels + ",nAudiobitspersample:" + nAudiobitspersample + ",nAudiosamplesrate:" + nAudiosamplesrate + ",nAudiobitrate:" + nAudiobitrate);
+        postMessage({ 'function': "StreamInfoCallBack", 'nSystemformat': nSystemformat, 'nVideoformat': nVideoformat, 'nAudioformat': nAudioformat, 'nAudiochannels': nAudiochannels, 'nAudiobitspersample': nAudiobitspersample, 'nAudiosamplesrate': nAudiosamplesrate, 'nAudiobitrate': nAudiobitrate});
+	}
+
+    onmessage = function (event) {
+        var eventData = event.data;
+        var res = 0;
+        switch (eventData.command) {
+            case "importScripts":
+                const decodebase = eventData.data + "Decoder.js"
+                importScripts(decodebase);
+                Module.addOnPostRun(function () {
+                    postMessage({ 'function': "loaded" });
+                });
+                break;
+            case "printLog":
+                let downloadFlag = eventData.data;
+                if (downloadFlag === true) {
+                    bWorkerPrintLog = true;
+                    res = Module._SetPrintLogFlag(g_nPort, downloadFlag);
+                }
+                else {
+                    bWorkerPrintLog = false;
+                    res = Module._SetPrintLogFlag(g_nPort, downloadFlag);
+                }
+
+                if (res !== PLAYM4_OK) {
+                    console.log("DecodeWorker.js: PlayerSDK print log failed,res" + res);
+                    postMessage({ 'function': "printLog", 'errorCode': res });
+                }
+                break;
+            case "SetPlayPosition":
+                let nFrameNumOrTime = eventData.data;
+                let enPosType = eventData.type;
+                // res = Module._SetPlayPosition(nFrameNumOrTime,enPosType);
+                // if (res !== PLAYM4_OK)
+                // {
+                //     postMessage({'function': "SetPlayPosition", 'errorCode': res});
+                //     return;
+                // }
+                // //有没有buffer需要清除
+
+                break;
+            case "SetStreamOpenMode":
+                //获取端口号
+                g_nPort = Module._GetPort();
+                //设置流打开模式
+                iStreamMode = eventData.data;
+                res = Module._SetStreamOpenMode(g_nPort, iStreamMode);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "SetStreamOpenMode", 'errorCode': res });
+                    return;
+                }
+                bOpenMode = true;
+                break;
+
+            case "OpenStream":
+                // 接收到的数据
+                var iHeadLen = eventData.dataSize;
+                var pHead = Module._malloc(iHeadLen + 4);
+                if (pHead === null) {
+                    return;
+                }
+                var aHead = Module.HEAPU8.subarray(pHead, pHead + iHeadLen);
+                aHead.set(new Uint8Array(eventData.data));
+                res = Module._OpenStream(g_nPort, pHead, iHeadLen, eventData.bufPoolSize);
+                postMessage({ 'function': "OpenStream", 'errorCode': res });
+                if (res !== PLAYM4_OK) {
+                    //释放内存
+                    Module._free(pHead);
+                    pHead = null;
+                    return;
+                }
+                bOpenStream = true;
+                break;
+            case "Play":
+                let resP = Module._Play(g_nPort);
+                if (resP !== PLAYM4_OK) {
+                    return;
+                }
+                break;
+            case "InputData":
+                // 接收到的数据
+                var iLen = eventData.dataSize;
+                if (iLen > 0) {
+                    if (pInputData == null || iLen > inputBufferSize) {
+                        if (pInputData != null) {
+                            Module._free(pInputData);
+                            pInputData = null;
+                        }
+                        if (iLen > inputBufferSize) {
+                            inputBufferSize = iLen;
+                        }
+
+                        pInputData = Module._malloc(inputBufferSize);
+                        if (pInputData === null) {
+                            return;
+                        }
+                    }
+
+                    var inputData = new Uint8Array(eventData.data);
+                    // var aInputData = Module.HEAPU8.subarray(pInputData, pInputData + iLen);
+                    // aInputData.set(inputData);
+                    Module.writeArrayToMemory(inputData, pInputData);
+                    inputData = null;
+                    res = Module._InputData(g_nPort, pInputData, iLen);
+                    if (res !== PLAYM4_OK) {
+                        let errorCode = Module._GetLastError(g_nPort);
+                        let sourceRemain = Module._GetSourceBufferRemain(g_nPort);
+                        postMessage({ 'function': "InputData", 'errorCode': errorCode, "sourceRemain": sourceRemain });
+                    }
+                    //Module._free(pInputData);
+                    //pInputData = null;
+                } else {
+                    let sourceRemain = Module._GetSourceBufferRemain(g_nPort);
+                    if (sourceRemain == 0) {
+                        postMessage({ 'function': "InputData", 'errorCode': PLAYM4_NEED_MORE_DATA });
+                        return;
+                    }
+                }
+
+                /////////////////////
+                // if (funGetFrameData === null) {
+                //     funGetFrameData = Module.cwrap('GetFrameData', 'number');
+                // }
+
+                while (bOpenMode && bOpenStream) {
+
+                    var ret = getFrameData();
+                    // 直到获取视频帧或数据不足为止
+                    if (PLAYM4_VIDEO_FRAME === ret || PLAYM4_NEED_MORE_DATA === ret || PLAYM4_ORDER_ERROR === ret)//PLAYM4_VIDEO_FRAME === ret ||  || PLAYM4_NEED_NEET_LOOP === ret
+                    {
+                        break;
+                    }
+                }
+                break;
+
+            case "SetSecretKey":
+                var keyLen = eventData.nKeyLen;
+                var pKeyData = Module._malloc(keyLen);
+                if (pKeyData === null) {
+                    return;
+                }
+                var nKeySize = eventData.data.length
+                var bufData = stringToBytes(eventData.data);
+                var aKeyData = Module.HEAPU8.subarray(pKeyData, pKeyData + keyLen);
+                let u8array = new Uint8Array(keyLen);
+                aKeyData.set(u8array, 0);
+                aKeyData.set(new Uint8Array(bufData));
+                aKeyData = null;
+                u8array = null;
+
+                res = Module._SetSecretKey(g_nPort, eventData.nKeyType, pKeyData, keyLen);//, nKeySize
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "SetSecretKey", 'errorCode': res });
+                    Module._free(pKeyData);
+                    pKeyData = null;
+                    return;
+                }
+
+                Module._free(pKeyData);
+                pKeyData = null;
+                break;
+
+            case "GetBMP":
+                var nBMPWidth = eventData.width;
+                var nBMPHeight = eventData.height;
+                var pYUVData = eventData.data;
+                var nYUVSize = nBMPWidth * nBMPHeight * 3 / 2;
+                var oBMPCropRect = {
+                    left: eventData.left,
+                    top: eventData.top,
+                    right: eventData.right,
+                    bottom: eventData.bottom
+                };
+
+                var pDataYUV = Module._malloc(nYUVSize);
+                if (pDataYUV === null) {
+                    return;
+                }
+
+                Module.writeArrayToMemory(new Uint8Array(pYUVData, 0, nYUVSize), pDataYUV);
+
+                // 分配BMP空间
+                var nBmpSize = nBMPWidth * nBMPHeight * 4 + 60;
+                var pBmpData = Module._malloc(nBmpSize);
+                var pBmpSize = Module._malloc(4);
+                if (pBmpData === null || pBmpSize === null) {
+                    Module._free(pDataYUV);
+                    pDataYUV = null;
+
+                    if (pBmpData != null) {
+                        Module._free(pBmpData);
+                        pBmpData = null;
+                    }
+
+                    if (pBmpSize != null) {
+                        Module._free(pBmpSize);
+                        pBmpSize = null;
+                    }
+                    return;
+                }
+
+                //Module._memset(pBmpSize, nBmpSize, 4); // 防止bmp截图出现输入数据过大的错误码
+                Module.setValue(pBmpSize, nBmpSize, "i32");
+                res = Module._GetBMP(g_nPort, pDataYUV, nYUVSize, pBmpData, pBmpSize,
+                    oBMPCropRect.left, oBMPCropRect.top, oBMPCropRect.right, oBMPCropRect.bottom);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "GetBMP", 'errorCode': res });
+                    Module._free(pDataYUV);
+                    pDataYUV = null;
+                    Module._free(pBmpData);
+                    pBmpData = null;
+                    Module._free(pBmpSize);
+                    pBmpSize = null;
+                    return;
+                }
+
+                // 获取BMP图片大小
+                var nBmpDataSize = Module.getValue(pBmpSize, "i32");
+
+                // 获取BMP图片数据
+                var aBmpData = new Uint8Array(nBmpDataSize);
+                aBmpData.set(Module.HEAPU8.subarray(pBmpData, pBmpData + nBmpDataSize));
+
+                postMessage({ 'function': "GetBMP", 'data': aBmpData, 'errorCode': res }, [aBmpData.buffer]);
+                aBmpData = null;
+                if (pDataYUV != null) {
+                    Module._free(pDataYUV);
+                    pDataYUV = null;
+                }
+                if (pBmpData != null) {
+                    Module._free(pBmpData);
+                    pBmpData = null;
+                }
+                if (pBmpSize != null) {
+                    Module._free(pBmpSize);
+                    pBmpSize = null;
+                }
+                break;
+
+            case "GetJPEG":
+                var nJpegWidth = eventData.width;
+                var nJpegHeight = eventData.height;
+                var pYUVData1 = eventData.data;
+                var nYUVSize1 = nJpegWidth * nJpegHeight * 3 / 2;
+                var oJpegCropRect = {
+                    left: eventData.left,
+                    top: eventData.top,
+                    right: eventData.right,
+                    bottom: eventData.bottom
+                };
+
+                var pDataYUV1 = Module._malloc(nYUVSize1);
+                if (pDataYUV1 === null) {
+                    return;
+                }
+
+                Module.writeArrayToMemory(new Uint8Array(pYUVData1, 0, nYUVSize1), pDataYUV1);
+
+                // 分配JPEG空间
+                var pJpegData = Module._malloc(nYUVSize1);
+                var pJpegSize = Module._malloc(4);
+                if (pJpegData === null || pJpegSize === null) {
+                    if (pJpegData != null) {
+                        Module._free(pJpegData);
+                        pJpegData = null;
+                    }
+
+                    if (pJpegSize != null) {
+                        Module._free(pJpegSize);
+                        pJpegSize = null;
+                    }
+
+                    if (pDataYUV1 != null) {
+                        Module._free(pDataYUV1);
+                        pDataYUV1 = null;
+                    }
+                    return;
+                }
+
+                Module.setValue(pJpegSize, nJpegWidth * nJpegHeight * 2, "i32");    // JPEG抓图,输入缓冲长度不小于当前帧YUV大小
+
+                res = Module._GetJPEG(g_nPort, pDataYUV1, nYUVSize1, pJpegData, pJpegSize,
+                    oJpegCropRect.left, oJpegCropRect.top, oJpegCropRect.right, oJpegCropRect.bottom);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "GetJPEG", 'errorCode': res });
+                    if (pJpegData != null) {
+                        Module._free(pJpegData);
+                        pJpegData = null;
+                    }
+
+                    if (pJpegSize != null) {
+                        Module._free(pJpegSize);
+                        pJpegSize = null;
+                    }
+
+                    if (pDataYUV1 != null) {
+                        Module._free(pDataYUV1);
+                        pDataYUV1 = null;
+                    }
+                    return;
+                }
+
+                // 获取JPEG图片大小
+                var nJpegSize = Module.getValue(pJpegSize, "i32");
+
+                // 获取JPEG图片数据
+                var aJpegData = new Uint8Array(nJpegSize);
+                aJpegData.set(Module.HEAPU8.subarray(pJpegData, pJpegData + nJpegSize));
+
+                postMessage({ 'function': "GetJPEG", 'data': aJpegData, 'errorCode': res }, [aJpegData.buffer]);
+
+                nJpegSize = null;
+                aJpegData = null;
+
+                if (pDataYUV1 != null) {
+                    Module._free(pDataYUV1);
+                    pDataYUV1 = null;
+                }
+                if (pJpegData != null) {
+                    Module._free(pJpegData);
+                    pJpegData = null;
+                }
+                if (pJpegSize != null) {
+                    Module._free(pJpegSize);
+                    pJpegSize = null;
+                }
+                break;
+
+            case "SetDecodeFrameType":
+                var nFrameType = eventData.data;
+                res = Module._SetDecodeFrameType(g_nPort, nFrameType);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "SetDecodeFrameType", 'errorCode': res });
+                    return;
+                }
+                break;
+            case "CloseStream":
+                //stop
+                let resS = Module._Stop(g_nPort);
+                if (resS !== PLAYM4_OK) {
+                    postMessage({ 'function': "Stop", 'errorCode': res });
+                    return;
+                }
+                //closeStream
+                res = Module._CloseStream(g_nPort);
+                if (res !== PLAYM4_OK) {
+                    postMessage({ 'function': "CloseStream", 'errorCode': res });
+                    return;
+                }
+                //freePort
+                let resF = Module._FreePort(g_nPort);
+                if (resF !== PLAYM4_OK) {
+                    postMessage({ 'function': "FreePort", 'errorCode': res });
+                    return;
+                }
+                if (pInputData != null) {
+                    Module._free(pInputData);
+                    pInputData = null;
+                }
+                break;
+            case "PlaySound":
+                let resPS = Module._PlaySound(g_nPort);
+                if (resPS !== PLAYM4_OK) {
+                    console.log("PlaySound failed");
+                    return;
+                }
+                break;
+            case "StopSound":
+                let resSS = Module._StopSound();
+                if (resSS !== PLAYM4_OK) {
+                    console.log("StopSound failed");
+                    return;
+                }
+                break;
+            case "SetVolume":
+                let resSV = Module._SetVolume(g_nPort, eventData.volume);
+                if (resSV !== PLAYM4_OK) {
+                    console.log("Audio SetVolume failed");
+                    return;
+                }
+                break;
+            case "GetVolume":
+                let volume = Module._GetVolume();
+                if (volume > 0) {
+                    postMessage({ 'function': "GetVolume", 'volume': volume });
+                }
+                else {
+                    console.log("Audio GetVolume failed");
+                    return;
+                }
+                break;
+            case "OnlyPlaySound":
+                let resOPS = Module._OnlyPlaySound(g_nPort);
+                if (resOPS !== PLAYM4_OK) {
+                    console.log("OnlyPlaySound failed");
+                    return;
+                }
+                break;
+            case "Pause":
+                let resPa = Module._Pause(g_nPort, eventData.bPlay);
+                if (resPa !== PLAYM4_OK) {
+                    console.log("Pause failed");
+                    return;
+                }
+            case "PlayRate":
+                Module._SetPlayRate(g_nPort, eventData.playRate);
+                break;
+            case "SetIFrameDecInterval":
+                Module._SetIFrameDecInterval(g_nPort, eventData.data);
+                break;
+            case "SetLostFrameMode":
+                Module._SetLostFrameMode(g_nPort, eventData.data, 0);
+                break;
+            case "SetDemuxModel":
+                let resSDM = Module._SetDemuxModel(g_nPort, eventData.nIdemuxType, eventData.bTrue);
+                break;
+            case "SkipErrorData":
+                Module._SkipErrorData(g_nPort, eventData.bSkip);
+                break;
+            case "SetDecodeERC":
+                Module._SetDecodeERC(g_nPort, eventData.nLevel);
+                break;
+            case "SetANRParam":
+                Module._SetANRParam(g_nPort, eventData.nEnable, eventData.nANRLevel);
+                break;
+            case "SetResampleValue":
+                Module._SetResampleValue(g_nPort, eventData.nEnable, eventData.resampleValue);
+                break;
+            case "GetLastError":
+                let errorCode = Module._GetLastError(g_nPort);
+                postMessage({ 'function': "GetLastError", 'errorCode': errorCode });
+                break;
+            case "SetGlobalBaseTime":
+                Module._SetGlobalBaseTime(g_nPort, eventData.year, eventData.month, eventData.day, eventData.hour, eventData.min, eventData.sec, eventData.ms);
+                break;
+            case "SetRunTimeInfoCB":
+                Module._SetRunTimeInfoCallBackEx(g_nPort, eventData.nModuleType, 0);
+                break;
+			case "SetStreamInfoCB":
+                Module._SetStreamInfoCallBack(g_nPort, eventData.nType, 0);
+                break;
+			case "ResetBuffer":
+                Module._JSPlayM4_ResetBuffer(g_nPort, eventData.type);
+                break;
+            default:
+                break;
+        }
+    };
+
+    function getOSDTime(oFrameInfo) {
+        var iYear = oFrameInfo.year;
+        var iMonth = oFrameInfo.month;
+        var iDay = oFrameInfo.day;
+        var iHour = oFrameInfo.hour;
+        var iMinute = oFrameInfo.minute;
+        var iSecond = oFrameInfo.second;
+        var iMiSecond = oFrameInfo.misecond
+
+        if (iMonth < 10) {
+            iMonth = "0" + iMonth;
+        }
+        if (iDay < 10) {
+            iDay = "0" + iDay;
+        }
+        if (iHour < 10) {
+            iHour = "0" + iHour;
+        }
+        if (iMinute < 10) {
+            iMinute = "0" + iMinute;
+        }
+        if (iSecond < 10) {
+            iSecond = "0" + iSecond;
+        }
+        let osdTime = {};
+
+        osdTime.year = iYear;
+        osdTime.month = iMonth;
+        osdTime.week = 0;
+        osdTime.day = iDay;
+        osdTime.hour = iHour;
+        osdTime.minute = iMinute;
+        osdTime.second = iSecond;
+        osdTime.milliseconds = iMiSecond;
+        return osdTime;
+        //return iYear + "-" + iMonth + "-" + iDay + " " + iHour + ":" + iMinute + ":" + iSecond;
+    }
+    // 获取帧数据
+    function getFrameData() {
+        // function getFrameData() {
+        // 获取帧数据
+        var res = Module._GetFrameData();
+        //var res = fun();
+        if (res === PLAYM4_OK) {
+            var iFrameInfo = Module._GetFrameInfo();
+            let oFrameInfo = {};
+            oFrameInfo.frameType = Module.HEAP32[iFrameInfo >> 2];
+            oFrameInfo.frameSize = Module.HEAP32[iFrameInfo + 4 >> 2];
+            oFrameInfo.width = Module.HEAP32[iFrameInfo + 8 >> 2];
+            oFrameInfo.height = Module.HEAP32[iFrameInfo + 12 >> 2];
+            oFrameInfo.timeStamp = Module.HEAP32[iFrameInfo + 16 >> 2];
+            oFrameInfo.frameRate = Module.HEAP32[iFrameInfo + 20 >> 2];
+            oFrameInfo.bitsPerSample = Module.HEAP32[iFrameInfo + 24 >> 2];
+            oFrameInfo.samplesPerSec = Module.HEAP32[iFrameInfo + 28 >> 2];
+            oFrameInfo.channels = Module.HEAP32[iFrameInfo + 32 >> 2];
+            oFrameInfo.frameNum = Module.HEAP32[iFrameInfo + 36 >> 2];
+
+            oFrameInfo.cropLeft = Module.HEAP32[iFrameInfo + 40 >> 2];
+            oFrameInfo.cropRight = Module.HEAP32[iFrameInfo + 44 >> 2];
+            oFrameInfo.cropTop = Module.HEAP32[iFrameInfo + 48 >> 2];
+            oFrameInfo.cropBottom = Module.HEAP32[iFrameInfo + 52 >> 2];
+
+            oFrameInfo.year = Module.HEAP16[iFrameInfo + 64 >> 1];
+            oFrameInfo.month = Module.HEAP16[iFrameInfo + 66 >> 1];
+            oFrameInfo.day = Module.HEAP16[iFrameInfo + 68 >> 1];
+            oFrameInfo.hour = Module.HEAP16[iFrameInfo + 70 >> 1];
+            oFrameInfo.minute = Module.HEAP16[iFrameInfo + 72 >> 1];
+            oFrameInfo.second = Module.HEAP16[iFrameInfo + 74 >> 1];
+            oFrameInfo.misecond = Module.HEAP16[iFrameInfo + 76 >> 1];
+            switch (oFrameInfo.frameType) {
+                case AUDIO_TYPE:
+                    var iSize = oFrameInfo.frameSize;
+                    if (0 === iSize) {
+                        return -1;
+                    }
+                    var pPCM = Module._GetFrameBuffer();
+                    // var audioBuf = new ArrayBuffer(iSize);
+                    var aPCMData = new Uint8Array(iSize);
+                    aPCMData.set(Module.HEAPU8.subarray(pPCM, pPCM + iSize));
+                    if (bWorkerPrintLog) {
+                        console.log("<<<Worker: audio media Info: nSise:" + oFrameInfo.frameSize + ",nSampleRate:" + oFrameInfo.samplesPerSec + ',channel:' + oFrameInfo.channels + ',bitsPerSample:' + oFrameInfo.bitsPerSample);
+                    }
+                    postMessage({
+                        'function': "GetFrameData", 'type': "audioType", 'data': aPCMData.buffer,
+                        'frameInfo': oFrameInfo, 'errorCode': res
+                    }, [aPCMData.buffer]);
+
+                    oFrameInfo = null;
+                    pPCM = null;
+                    aPCMData = null;
+                    return PLAYM4_AUDIO_FRAME;
+
+                case VIDEO_TYPE:
+                    var szOSDTime = getOSDTime(oFrameInfo);
+
+                    var iWidth = oFrameInfo.width;
+                    var iHeight = oFrameInfo.height;
+
+                    var iYUVSize = iWidth * iHeight * 3 / 2;
+                    if (0 === iYUVSize) {
+                        return -1;
+                    }
+
+                    var pYUV = Module._GetFrameBuffer();
+
+                    // 图像数据渲染后压回,若从主码流切到子码流,存在数组大小与图像大小不匹配现象
+                    var aYUVData = new Uint8Array(iYUVSize);
+                    aYUVData.set(Module.HEAPU8.subarray(pYUV, pYUV + iYUVSize));
+                    if (bWorkerPrintLog) {
+                        console.log("<<<Worker: video media Info: Width:" + oFrameInfo.width + ",Height:" + oFrameInfo.height + ",timeStamp:" + oFrameInfo.timeStamp);
+                    }
+
+                    postMessage({
+                        'function': "GetFrameData", 'type': "videoType", 'data': aYUVData.buffer,
+                        'dataLen': aYUVData.length, 'osd': szOSDTime, 'frameInfo': oFrameInfo, 'errorCode': res
+                    }, [aYUVData.buffer]);
+
+                    oFrameInfo = null;
+                    pYUV = null;
+                    aYUVData = null;
+                    return PLAYM4_VIDEO_FRAME;
+
+                case PRIVT_TYPE:
+                    postMessage({
+                        'function': "GetFrameData", 'type': "", 'data': null,
+                        'dataLen': -1, 'osd': 0, 'frameInfo': null, 'errorCode': PLAYM4_SYS_NOT_SUPPORT
+                    });
+                    return PLAYM4_SYS_NOT_SUPPORT;
+
+                default:
+                    postMessage({
+                        'function': "GetFrameData", 'type': "", 'data': null,
+                        'dataLen': -1, 'osd': 0, 'frameInfo': null, 'errorCode': PLAYM4_SYS_NOT_SUPPORT
+                    });
+                    return PLAYM4_SYS_NOT_SUPPORT;
+            }
+        }
+        else {
+            let errorCode = Module._GetLastError(g_nPort);
+            //解码失败返回裸数据
+            if (PLAYM4_DECODE_ERROR === errorCode) {
+                var rawInfo = Module._GetRawDataInfo();
+                var pRawData = Module._GetRawDataBuffer();
+                var aRawData = new Uint8Array(rawInfo.isize);
+                aRawData.set(Module.HEAPU8.subarray(pRawData, pRawData + rawInfo.isize));
+                postMessage({
+                    'function': "GetRawData", 'type': "", 'data': aRawData.buffer,
+                    'rawDataLen': rawInfo.isize, 'osd': 0, 'frameInfo': null, 'errorCode': errorCode
+                });
+                rawInfo = null;
+                pRawData = null;
+                aRawData = null;
+            }
+            //需要更多数据
+            if (PLAYM4_NEED_MORE_DATA === errorCode || PLAYM4_SYS_NOT_SUPPORT === errorCode || PLAYM4_NEED_NEET_LOOP === errorCode) {
+                postMessage({
+                    'function': "GetFrameData", 'type': "", 'data': null,
+                    'dataLen': -1, 'osd': 0, 'frameInfo': null, 'errorCode': errorCode
+                });
+            }
+            return errorCode;
+        }
+    }
+
+    // 开始计算时间
+    function startTime() {
+        return new Date().getTime();
+    }
+
+    // 结束计算时间
+    function endTime() {
+        return new Date().getTime();
+    }
+
+    // 字母字符串转byte数组
+    function stringToBytes(str) {
+        var ch, st, re = [];
+        for (var i = 0; i < str.length; i++) {
+            ch = str.charCodeAt(i);  // get char
+            st = [];                 // set up "stack"
+            do {
+                st.push(ch & 0xFF);  // push byte to stack
+                ch = ch >> 8;          // shift value down by 1 byte
+            }
+            while (ch);
+            // add stack contents to result
+            // done because chars have "wrong" endianness
+            re = re.concat(st.reverse());
+        }
+        // return an array of bytes
+        return re;
+    }
+})();

BIN
public/h5player/playctrl1simd/Decoder.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/h5player/playctrl2/Decoder.js


BIN
public/h5player/playctrl2/Decoder.wasm


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/h5player/playctrl2/Decoder.worker.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/h5player/playctrl3/Decoder.js


BIN
public/h5player/playctrl3/Decoder.wasm


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/h5player/playctrl3/Decoder.worker.js


Разница между файлами не показана из-за своего большого размера
+ 7 - 0
public/h5player/talk/AudioInterCom.js


BIN
public/h5player/talk/AudioInterCom.wasm


Разница между файлами не показана из-за своего большого размера
+ 8 - 0
public/h5player/talkW/AudioInterCom.js


BIN
public/h5player/talkW/AudioInterCom.wasm


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
public/h5player/talkW/AudioInterCom.worker.js


Разница между файлами не показана из-за своего большого размера
+ 2668 - 0
public/h5player/transform/libSystemTransform.js


BIN
public/h5player/transform/libSystemTransform.wasm


+ 126 - 0
public/h5player/transform/systemTransform-worker.js

@@ -0,0 +1,126 @@
+importScripts('libSystemTransform.js');
+    const RECORDRTP = 0;  //录制一份未经过转封装的码流原始数据,用于定位问题
+    let dataType = 1;
+    
+    // 转封装库回调函数
+    self.STCallBack = function (fileIndex,indexLen, data, dataLen)
+    {
+        //stFrameInfo的类型见DETAIL_FRAME_INFO
+		let stFrameInfo = Module._GetDetialFrameInfo();
+        let nIsMp4Index = stFrameInfo.nIsMp4Index;
+		//console.log("FrameType is " , stFrameInfo);	
+		//console.log("nIsMp4Index is " + nIsMp4Index);
+        //debugger
+        var pData = null;
+        pData = new Uint8Array(dataLen);
+        pData.set(Module.HEAPU8.subarray(data, data + dataLen));
+        if (dataType === 1) {
+            if (pData[0] == 0x49 && pData[1] == 0x4d && pData[2] == 0x4b && pData[3] == 0x48) {//码流头丢掉
+                return;
+            }
+            postMessage({type: "outputData", buf: pData, dType: 1});
+            dataType = 2;
+        } else {
+            if (nIsMp4Index) {
+                postMessage({type: "outputData", buf: pData, dType: 6}); //6:索引类型
+            } else {
+                postMessage({type: "outputData", buf: pData, dType: 2}); //2:码流
+            }
+        }
+
+        //stFrameInfo的类型见DETAIL_FRAME_INFO
+		//let stFrameInfo = Module._GetDetialFrameInfo();
+		//let stFrameType = stFrameInfo.nFrameType;
+		//let nFrameNum = stFrameInfo.nFrameNum;
+		//let nTimeStamp = stFrameInfo.nTimeStamp;
+        //let nIsMp4Index = stFrameInfo.nIsMp4Index;
+		
+		//console.log("FrameType is " + stFrameType);	
+		//console.log("nIsMp4Index is " + nIsMp4Index);	
+        
+    }
+
+    // self.Module = { memoryInitializerRequest: loadMemInitFile(), TOTAL_MEMORY: 128*1024*1024 };
+    // importScripts('SystemTransform.js');
+
+    self.Module['onRuntimeInitialized'] = function (){
+        postMessage({type: "loaded"});
+    }
+    onmessage = function (e) {
+        var data = e.data;
+        if ("create" === data.type) {
+            if (RECORDRTP) {
+                postMessage({type: "created"});
+                postMessage({type: "outputData", buf: data.buf, dType: 1});
+            } else {
+                var iHeadLen = data.len;
+                var pHead = Module._malloc(iHeadLen);
+    
+                self.writeArrayToMemory(new Uint8Array(data.buf), pHead);
+                var iTransType = data.packType;//目标格式
+                var iRet = Module._CreatHandle(pHead, iTransType, 4096);
+                if (iRet != 0) {
+                    console.log("_CreatHandle failed!" + iRet);
+                } else {
+                    iRet = Module._SysTransRegisterDataCallBack();			
+                    if(iRet != 0)
+                    {
+                        console.log("_SysTransRegisterDataCallBack Failed:" + iRet);
+                    }
+                    if (iTransType === 5) {//转mp4支持私有帧打入
+                        iRet = Module._SysTransEnableCapacity(7)
+                        if(iRet != 0)
+                        {
+                            console.log("_SysTransSetGlobalTime Failed:" + iRet);
+                        }
+                    }
+                    iRet = Module._SysTransStart(null, null);
+                    if(iRet != 0)
+                    {
+                        console.log("_SysTransStart Failed:" + iRet);
+                    }
+                    postMessage({type: "created"});
+                }
+            }
+
+        } else if ("inputData" === data.type) {
+            if (RECORDRTP) {
+                var aFileData = new Uint8Array(data.buf);  // 拷贝一份
+                var iBufferLen = aFileData.length;
+                var szBufferLen = iBufferLen.toString(16);
+                if (szBufferLen.length === 1) {
+                    szBufferLen = "000" + szBufferLen;
+                } else if (szBufferLen.length === 2) {
+                    szBufferLen = "00" + szBufferLen;
+                } else if (szBufferLen.length === 3) {
+                    szBufferLen = "0" + szBufferLen;
+                }
+                var aData = [0, 0, parseInt(szBufferLen.substring(0, 2), 16), parseInt(szBufferLen.substring(2, 4), 16)];
+                for(var iIndex = 0, iDataLength = aFileData.length; iIndex < iDataLength; iIndex++) {
+                    aData[iIndex + 4] = aFileData[iIndex]
+                }
+                var dataUint8 = new Uint8Array(aData);
+                postMessage({type: "outputData", buf: dataUint8.buffer, dType: 2});
+            } else {
+                let pInputDataBuf = Module._malloc(data.len);
+                var idataLen = data.len;
+                self.writeArrayToMemory(new Uint8Array(data.buf), pInputDataBuf);
+                    // 输入数据,每次最多2m
+                let pp = Module._SysTransInputData(0, pInputDataBuf, idataLen);
+                if(pp != 0) {
+                    //console.log("InputData Failed:" + pp);
+                }
+                Module._free(pInputDataBuf);
+            }
+        } else if ("release" === data.type) {
+            var iRet = Module._SysTransStop();
+            if (iRet != 0) {
+                console.log("_SysTransStop Failed:", iRet);
+            }
+            Module._SysTransRelease();
+            if (iRet != 0) {
+                console.log("_SysTransRelease Failed:", iRet);
+            }
+            close();
+        }
+    };

+ 104 - 0
src/api/pms/hikvision/index.ts

@@ -0,0 +1,104 @@
+import request from '@/config/axios'
+
+export interface HikvisionRegionNode {
+  indexCode: string
+  name: string
+  nodeType: 'region'
+  parentIndexCode?: string
+  available?: boolean
+  leaf: boolean
+  children: HikvisionTreeNode[]
+}
+
+export interface HikvisionCameraNode {
+  indexCode: string
+  name: string
+  nodeType: 'camera'
+  parentIndexCode?: string
+  available?: boolean
+  onlineStatus?: boolean
+}
+
+export type HikvisionTreeNode = HikvisionRegionNode | HikvisionCameraNode
+export type HikvisionStreamType = 0 | 1
+/**
+ * 海康云台方向命令。
+ *
+ * 接口要求 command 使用大写字符串,并通过下划线连接组合方向;这里使用联合类型
+ * 约束页面只能提交后端支持的八个方向,避免大小写或拼写错误直到运行时才被发现。
+ */
+export type HikvisionPtzCommand =
+  | 'LEFT' // 向左
+  | 'RIGHT' // 向右
+  | 'UP' // 向上
+  | 'DOWN' // 向下
+  | 'LEFT_UP' // 左上
+  | 'LEFT_DOWN' // 左下
+  | 'RIGHT_UP' // 右上
+  | 'RIGHT_DOWN' // 右下
+
+/** `/vms/ptz-control` 云台控制接口的请求体。 */
+export interface HikvisionPtzControlRequest {
+  /** 监控点唯一标识,对应当前播放窗口绑定摄像机的 indexCode。 */
+  cameraIndexCode: string
+  /** 操作类型:0 表示开始持续转动,1 表示停止当前方向转动。 */
+  action: 0 | 1
+  /** 本次需要执行或停止的云台命令。 */
+  command: HikvisionPtzCommand
+  /** 云台速度,允许范围为 1~100;不传时由海康平台使用默认值。 */
+  speed?: number
+}
+
+export const HikvisionApi = {
+  /** 按监控点名称搜索,返回匹配监控点及完整祖先目录,children 无需再懒加载。 */
+  searchTree: (keyword: string): Promise<HikvisionTreeNode[]> => {
+    return request.get({
+      url: '/vms/tree/search',
+      params: { keyword }
+    })
+  },
+
+  getTreeNodes: (regionIndexCode: string): Promise<HikvisionTreeNode[]> => {
+    return request.get({
+      url: '/vms/tree',
+      params: { regionIndexCode }
+    })
+  },
+
+  /**
+   * 获取监控点的 H5player 实时预览地址。
+   *
+   * streamType:0 为主码流,1 为子码流;CommonResult.data 返回可供 JS_Play 使用的 URL。
+   */
+  getPreviewUrl: (
+    cameraIndexCode: string,
+    streamType: HikvisionStreamType = 0
+  ): Promise<string> => {
+    return request.get({
+      url: '/vms/preview-url',
+      params: { cameraIndexCode, streamType }
+    })
+  },
+
+  /** 获取监控点的 H5player WSS 对讲地址。 */
+  getTalkUrl: (cameraIndexCode: string): Promise<string> => {
+    return request.get({
+      url: '/vms/talk-url',
+      params: { cameraIndexCode }
+    })
+  },
+
+  /**
+   * 开始或停止监控点的云台方向控制。
+   *
+   * Axios 实例的 baseURL 已包含 `/admin-api`,因此这里保留业务路径即可,浏览器最终
+   * 请求地址为 `/admin-api/vms/ptz-control`。方向控制为持续动作,调用方必须保证
+   * `action: 0` 与 `action: 1` 成对发送。
+   */
+  controlPtz: (data: HikvisionPtzControlRequest): Promise<void> => {
+    return request.post({
+      url: '/vms/ptz-control',
+      data
+    })
+  }
+}

+ 1 - 0
src/assets/icons/grid-16.svg

@@ -0,0 +1 @@
+<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20937"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20937)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM2 5L6 5L6 8L2 8L2 5ZM7 5L11 5L11 8L7 8L7 5ZM12 5L16 5L16 8L12 8L12 5ZM17 5L21 5L21 8L17 8L17 5ZM2 9L6 9L6 11L2 11L2 9ZM7 9L11 9L11 11L7 11L7 9ZM12 9L16 9L16 11L12 11L12 9ZM17 9L21 9L21 11L17 11L17 9ZM2 12L6 12L6 15L2 15L2 12ZM7 12L11 12L11 15L7 15L7 12ZM12 12L16 12L16 15L12 15L12 12ZM17 12L21 12L21 15L17 15L17 12ZM2 16L6 16L6 19L2 19L2 16ZM7 16L11 16L11 19L7 19L7 16ZM12 16L16 16L16 19L12 19L12 16ZM17 16L21 16L21 19L17 19L17 16Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>

+ 1 - 0
src/assets/icons/grid-four.svg

@@ -0,0 +1 @@
+<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20933"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20933)"><path d="M22 3L1 3C0.447723 3 0 3.44771 0 4L0 20C0 20.5523 0.447723 21 1 21L22 21C22.5523 21 23 20.5523 23 20L23 4C23 3.44771 22.5523 3 22 3ZM1 4L22 4L22 20L1 20L1 4ZM2 5L11 5L11 11L2 11L2 5ZM12 5L21 5L21 11L12 11L12 5ZM2 12L11 12L11 19L2 19L2 12ZM21 12L12 12L12 19L21 19L21 12Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>

+ 1 - 0
src/assets/icons/grid-nine.svg

@@ -0,0 +1 @@
+<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20935"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20935)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM2 5L8 5L8 9L2 9L2 5ZM9 5L14 5L14 9L9 9L9 5ZM15 5L21 5L21 9L15 9L15 5ZM2 10L8 10L8 14L2 14L2 10ZM9 10L14 10L14 14L9 14L9 10ZM15 10L21 10L21 14L15 14L15 10ZM2 15L8 15L8 19L2 19L2 15ZM9 15L14 15L14 19L9 19L9 15ZM15 15L21 15L21 19L15 19L15 15Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>

+ 1 - 0
src/assets/icons/grid-one.svg

@@ -0,0 +1 @@
+<svg width="24.000000" height="24.000000" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><clipPath id="clip1_20931"><rect width="24.000000" height="24.000000" fill="white" fill-opacity="0"></rect></clipPath></defs><rect width="24.000000" height="24.000000" fill="currentColor" fill-opacity="0"></rect><rect width="24.000000" height="24.000000" fill-opacity="1.000000"></rect><g clip-path="url(#clip1_20931)"><path d="M1 3L22 3C22.5523 3 23 3.44771 23 4L23 20C23 20.5523 22.5523 21 22 21L1 21C0.447723 21 0 20.5523 0 20L0 4C0 3.44771 0.447723 3 1 3ZM22 4L1 4L1 20L22 20L22 4ZM21 5L2 5L2 19L21 19L21 5Z" clip-rule="evenodd" fill="currentColor" fill-opacity="0.701961" fill-rule="evenodd"></path></g></svg>

+ 167 - 0
src/utils/hikvisionH5Player.ts

@@ -0,0 +1,167 @@
+/**
+ * H5player 的分屏参数表示每行/每列的窗口数量,而不是窗口总数。
+ * 例如传入 2 表示 2×2(4 窗口),传入 4 表示 4×4(16 窗口)。
+ * 开发指南限定该参数范围为 1~4,使用联合类型可以在编译阶段阻止非法值。
+ */
+export type HikvisionSplitColumns = 1 | 2 | 3 | 4
+
+/** 创建 H5player 实例时使用的配置。 */
+export interface HikvisionPlayerOptions {
+  /** 播放器挂载容器的 id,必须以英文字母开头。 */
+  szId: string
+  /** SDK 资源目录,必须与 h5player.min.js 所在目录一致。 */
+  szBasePath: string
+  /** 实例允许使用的最大分屏规格,4 表示最大支持 4×4。 */
+  iMaxSplit?: HikvisionSplitColumns
+  /** 实例创建时显示的分屏规格,2 表示默认显示 2×2。 */
+  iCurrentSplit?: HikvisionSplitColumns
+  /** 是否输出 SDK 调试日志。 */
+  openDebug?: boolean
+  /** 是否启用 MSE Worker;开发指南建议分辨率高于 1080P 时开启。 */
+  mseWorkerEnable?: boolean
+  /** 是否允许双击播放器进入或退出全屏。 */
+  bSupporDoubleClickFull?: boolean
+  oStyle?: {
+    /** SDK 内部当前选中窗口的边框颜色。 */
+    borderSelect?: string
+  }
+}
+
+/**
+ * JS_SetWindowControlCallback 支持的事件。
+ * 这里只声明当前预览页面实际需要的事件,后续接入声音、抓图或回放时再按指南扩展。
+ */
+export interface HikvisionPlayerEvents {
+  /** 取流、解码或播放器内部异常。 */
+  pluginErrorHandler?: (windowIndex: number, errorCode: number, error: unknown) => void
+  /** 对讲采集、编码或连接异常。该回调不包含窗口下标,因为单实例仅支持一路对讲。 */
+  talkPluginErrorHandler?: (errorCode: number, error: unknown) => void
+  /** 指定窗口首帧真正渲染完成,适合用于结束窗口 loading。 */
+  firstFrameDisplay?: (windowIndex: number, width: number, height: number) => void
+  /** 当前设备解码性能不足。 */
+  performanceLack?: (windowIndex: number) => void
+  /** 流播放结束。实时预览正常情况下不会主动结束。 */
+  StreamEnd?: (windowIndex: number) => void
+  /** 指定窗口在 interruptTime 秒内没有收到码流。 */
+  InterruptStream?: (windowIndex: number, interruptTime: number) => void
+  /** 用户选中了某个播放窗口。 */
+  windowEventSelect?: (windowIndex: number) => void
+  /** 鼠标进入某个播放窗口。 */
+  windowEventOver?: (windowIndex: number) => void
+  /** 鼠标离开某个播放窗口。 */
+  windowEventOut?: (windowIndex: number) => void
+  /** 鼠标在某个播放窗口中松开。 */
+  windowEventUp?: (windowIndex: number) => void
+  /** 整体全屏状态发生变化,包括按钮和双击触发的变化。 */
+  windowFullCcreenChange?: (fullscreen: boolean) => void
+}
+
+/** 当前页面使用到的 H5player 2.5.1 实例方法。 */
+export interface HikvisionPlayerInstance {
+  /** 注册窗口及播放状态回调。不同构建版本可能返回 Promise 或 void。 */
+  JS_SetWindowControlCallback: (events: HikvisionPlayerEvents) => Promise<void> | void
+  /** 在指定窗口播放实时流;预览场景不传回放开始、结束时间。 */
+  JS_Play: (
+    url: string,
+    options: {
+      playURL: string
+      mode: 0 | 1
+      keepDecoder: 0 | 1
+    },
+    windowIndex: number
+  ) => Promise<void>
+  /** 停止指定窗口;省略下标时 SDK 会使用当前选中窗口。 */
+  JS_Stop: (windowIndex?: number) => Promise<void>
+  /** 停止当前实例中的全部实时预览。 */
+  JS_StopRealPlayAll: () => Promise<void>
+  /** 抓取指定窗口的当前画面;不传 callback 时由 SDK 直接下载 JPEG 文件。 */
+  JS_CapturePicture: (
+    windowIndex: number,
+    fileName: string,
+    fileType: 'JPEG',
+    callback?: (imageData: string) => void
+  ) => Promise<void>
+  /** 开启指定窗口的声音;同一播放器实例同时只支持一路声音。 */
+  JS_OpenSound: (windowIndex?: number) => Promise<void>
+  /** 关闭指定窗口的声音。 */
+  JS_CloseSound: (windowIndex?: number) => Promise<void>
+  /** 使用监控点的 WSS 地址开启对讲;同一播放器实例同时只支持一路对讲。 */
+  JS_StartTalk: (talkUrl: string, options?: { token?: string }) => Promise<void>
+  /** 停止当前播放器实例正在进行的对讲。 */
+  JS_StopTalk: () => Promise<void>
+  /** 开启指定窗口的电子放大;开启后由 SDK 处理窗口内的框选放大交互。 */
+  JS_EnableZoom: (windowIndex?: number) => Promise<void>
+  /** 关闭指定窗口的电子放大并恢复原始画面。 */
+  JS_DisableZoom: (windowIndex?: number) => Promise<void>
+  /** 调整分屏规格,参数 1~4 分别对应 1×1~4×4。 */
+  JS_ArrangeWindow: (split: HikvisionSplitColumns) => Promise<void> | void
+  /** 主动切换 SDK 内部选中的窗口。 */
+  JS_SelectWnd: (windowIndex: number) => Promise<void>
+  /** 切换整个播放器区域的全屏状态。 */
+  JS_FullScreenDisplay: (fullscreen: boolean) => Promise<void>
+  /** 将单个播放窗口切换为全屏。 */
+  JS_FullScreenSingle: (windowIndex: number) => Promise<void>
+  /** 通知 SDK 重新计算播放器及全部子窗口尺寸。 */
+  JS_Resize: (width?: number, height?: number) => Promise<void>
+  /** 释放播放器创建的 DOM、Worker、WASM 和媒体资源。 */
+  JS_Destroy: () => Promise<void> | void
+}
+
+type HikvisionPlayerConstructor = new (options: HikvisionPlayerOptions) => HikvisionPlayerInstance
+
+declare global {
+  interface Window {
+    JSPlugin?: HikvisionPlayerConstructor
+  }
+}
+
+/**
+ * SDK 脚本的共享加载任务。
+ * 多个初始化请求同时到达时复用同一个 Promise,避免重复插入 script 标签。
+ */
+let loadPromise: Promise<HikvisionPlayerConstructor> | undefined
+
+/** 计算部署环境中的 SDK 目录,同时兼容应用部署在非根路径的情况。 */
+function getBasePath() {
+  const appBasePath = import.meta.env.BASE_URL.endsWith('/')
+    ? import.meta.env.BASE_URL
+    : `${import.meta.env.BASE_URL}/`
+  return `${appBasePath}h5player`
+}
+
+export function getHikvisionPlayerBasePath() {
+  return getBasePath()
+}
+
+/**
+ * 按需加载 h5player.min.js 并返回其暴露在 window 上的 JSPlugin 构造器。
+ * 加载失败时会清理 script 和缓存 Promise,使用户后续操作能够重新尝试初始化。
+ */
+export function loadHikvisionPlayer() {
+  if (window.JSPlugin) return Promise.resolve(window.JSPlugin)
+  if (loadPromise) return loadPromise
+
+  loadPromise = new Promise<HikvisionPlayerConstructor>((resolve, reject) => {
+    const script = document.createElement('script')
+    script.src = `${getBasePath()}/h5player.min.js`
+    script.async = true
+    script.dataset.hikvisionH5player = 'true'
+    script.onload = () => {
+      if (window.JSPlugin) {
+        resolve(window.JSPlugin)
+        return
+      }
+      loadPromise = undefined
+      script.remove()
+      reject(new Error('H5player 加载完成,但未找到 JSPlugin'))
+    }
+    script.onerror = () => {
+      loadPromise = undefined
+      script.remove()
+      reject(new Error('H5player 脚本加载失败'))
+    }
+    document.head.appendChild(script)
+  })
+
+  return loadPromise
+}

+ 30 - 23
src/views/pms/device/DeviceBasicInfo.vue

@@ -7,7 +7,7 @@
         </span>
         <div>
           <h3>制造信息</h3>
-          <p>设备生产、供应及质保信息</p>
+          <p>设备生产、供应、质保及启用信息</p>
         </div>
       </div>
       <div class="basic-grid basic-grid--manufacture">
@@ -24,8 +24,8 @@
           <Icon icon="ep:coin" />
         </span>
         <div>
-          <h3>资产折旧</h3>
-          <p>采购成本及折旧执行信息</p>
+          <h3>财务信息</h3>
+          <p>{{ financeDescription }}</p>
         </div>
       </div>
       <div class="basic-grid">
@@ -42,7 +42,7 @@
           <Icon icon="ep:grid" />
         </span>
         <div>
-          <h3>扩展属性</h3>
+          <h3>其他信息</h3>
           <p>设备分类模板补充信息</p>
         </div>
       </div>
@@ -66,7 +66,9 @@ interface DeviceBasicInfoData {
   manDate?: number | string | Date
   supplierName?: string
   expires?: number | string | Date
+  enableDate?: number | string | Date
   nameplate?: string
+  assetProperty?: string
   plPrice?: number | string
   plDate?: number | string | Date
   plYear?: number | string
@@ -82,7 +84,9 @@ interface DeviceBasicInfoData {
 interface TemplateField {
   sort?: number
   name?: string
-  value?: string | number | null
+  code?: string
+  type?: string
+  value?: string | number | Date | null
   identifier?: string
 }
 
@@ -124,32 +128,42 @@ const manufactureFields = computed<DisplayField[]>(() => [
   {
     label: '质保到期',
     value: displayDate(props.device.expires)
+  },
+  {
+    label: '启用日期',
+    value: displayDate(props.device.enableDate)
   }
 ])
 
+const isOwnedAsset = computed(() => props.device.assetProperty !== 'zl')
+
+const financeDescription = computed(() =>
+  isOwnedAsset.value ? '采购成本及折旧执行信息' : '租赁成本及租赁执行信息'
+)
+
 const financeFields = computed<DisplayField[]>(() => [
   {
-    label: '采购价格',
+    label: isOwnedAsset.value ? '采购价格' : '租赁价格',
     value: displayValue(props.device.plPrice)
   },
   {
-    label: '采购日期',
+    label: isOwnedAsset.value ? '采购日期' : '租赁日期',
     value: displayDate(props.device.plDate)
   },
   {
-    label: '折旧年限',
+    label: isOwnedAsset.value ? '折旧年限' : '租赁年限',
     value: displayValue(props.device.plYear)
   },
   {
-    label: '折旧开始日期',
+    label: isOwnedAsset.value ? '折旧开始日期' : '租赁开始日期',
     value: displayDate(props.device.plStartDate)
   },
   {
-    label: '已提折旧月数',
+    label: isOwnedAsset.value ? '已提折旧月数' : '已租赁月数',
     value: displayValue(props.device.plMonthed)
   },
   {
-    label: '已提折旧金额',
+    label: isOwnedAsset.value ? '已提折旧金额' : '已租赁金额',
     value: displayValue(props.device.plAmounted)
   },
   {
@@ -172,9 +186,12 @@ const financeFields = computed<DisplayField[]>(() => [
 
 const normalizedTemplateFields = computed(() => {
   return (props.templateFields || []).map((field, index) => ({
-    key: field.identifier || field.name || String(field.sort ?? index),
+    key: field.code || field.identifier || field.name || String(field.sort ?? index),
     label: field.name || '-',
-    value: displayValue(field.value)
+    value:
+      field.type === 'date'
+        ? displayDate(field.value as number | string | Date | undefined)
+        : displayValue(field.value as string | number | null | undefined)
   }))
 })
 </script>
@@ -290,10 +307,6 @@ const normalizedTemplateFields = computed(() => {
     &:nth-child(3n) {
       border-right: 0;
     }
-
-    &:nth-child(4) {
-      grid-column: span 2;
-    }
   }
 }
 
@@ -329,12 +342,6 @@ const normalizedTemplateFields = computed(() => {
       border-right: 0;
     }
   }
-
-  .basic-grid--manufacture {
-    .basic-item:nth-child(4) {
-      grid-column: span 1;
-    }
-  }
 }
 
 @media (width <= 575px) {

+ 152 - 21
src/views/pms/device/DeviceInfo.vue

@@ -3,7 +3,7 @@
     <div class="device-main">
       <div class="device-media">
         <button class="device-image-frame" type="button" @click="imagePreview(defaultPicUrl)">
-          <el-image :src="defaultPicUrl" class="device-image" fit="cover">
+          <el-image :src="defaultPicUrl" class="device-image" fit="contain">
             <template #error>
               <div class="device-image__empty">
                 <Icon icon="ep:picture" />
@@ -39,7 +39,7 @@
               <Icon :icon="field.icon" />
               {{ field.label }}
             </span>
-            <strong>{{ field.value }}</strong>
+            <strong :title="field.value">{{ field.value }}</strong>
           </div>
         </div>
       </div>
@@ -103,6 +103,8 @@
 <script setup lang="ts">
 import { computed, onMounted, ref } from 'vue'
 import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
+import { DeviceAttrModelApi, type DeviceAttrModelData } from '@/api/pms/deviceattrmodel'
+import { IotYfClassifyApi } from '@/api/pms/yfclass'
 import { createImageViewer } from '@/components/ImageViewer'
 import { getAccessToken } from '@/utils/auth'
 import { DICT_TYPE, getDictLabel } from '@/utils/dict'
@@ -139,11 +141,18 @@ type DeviceDetail = Partial<
     | 'totalMonth'
   >
 > & {
+  yfClass?: string
   yfDeviceCode?: string
   brandName?: string
   assetClass?: number
   assetClassName?: string
   model?: string | number
+  carNo?: string
+  deviceNo?: string
+  useProject?: string
+  assetOwnership?: string
+  address?: string
+  remark?: string
   responsibleNames?: string
   devicePersons?: string
   zzName?: string
@@ -163,13 +172,18 @@ type DeviceDetail = Partial<
   monthAmount?: number | string
   totalMonth?: number | string
   currency?: string
-  templateJson?: string
+  templateJson?: string | TemplateField[]
+  assetProperty?: string
+  [key: string]: unknown
 }
 
 interface TemplateField {
   sort?: number
   name?: string
-  value?: string | number | null
+  code?: string
+  type?: string
+  defaultValue?: string
+  value?: string | number | Date | null
   identifier?: string
 }
 
@@ -180,6 +194,12 @@ interface MainInfoField {
   icon: string
 }
 
+interface EncodingTypeOption {
+  label: string
+  value: string
+  children?: EncodingTypeOption[]
+}
+
 const defaultDevicePic =
   import.meta.env.VITE_BASE_URL + '/admin-api/infra/file/29/get/IntegratedSolution.png'
 
@@ -190,6 +210,8 @@ const formLoading = ref(false)
 const activeName = ref('info')
 const defaultPicUrl = ref(defaultDevicePic)
 const formData = ref<DeviceDetail>({})
+const encodingTypeOptions = ref<EncodingTypeOption[]>([])
+const templateFields = ref<TemplateField[]>([])
 
 const deviceId = computed(() => {
   const id = params.id ?? query.id
@@ -217,18 +239,87 @@ const responsibleNames = computed(() => {
   return formData.value.responsibleNames || formData.value.devicePersons || '-'
 })
 
-const templateFields = computed<TemplateField[]>(() => {
-  if (!formData.value.templateJson) return []
+const yfClassLabel = computed(() => {
+  if (!formData.value.yfClass) return '-'
+
+  const codes = formData.value.yfClass.split(',').filter(Boolean)
+  const labels: string[] = []
+  let options = encodingTypeOptions.value
+
+  for (const code of codes) {
+    const option = options.find((item) => String(item.value) === code)
+    if (!option) return '-'
+    labels.push(option.label)
+    options = option.children || []
+  }
+
+  return labels.join(' / ') || '-'
+})
+
+const getEncodingTypeOptions = async () => {
+  const options = await IotYfClassifyApi.getChildrenList()
+  encodingTypeOptions.value = options || []
+}
+
+const parseTemplateFields = (source: unknown): TemplateField[] => {
+  let parsed = source
+
+  for (let index = 0; index < 2 && typeof parsed === 'string'; index += 1) {
+    if (!parsed.trim()) return []
+    try {
+      parsed = JSON.parse(parsed)
+    } catch {
+      return []
+    }
+  }
+
+  if (Array.isArray(parsed)) return parsed
+  if (!parsed || typeof parsed !== 'object') return []
+
+  const container = parsed as Record<string, unknown>
+  const fields = container.fields || container.list || container.data
+  return Array.isArray(fields) ? fields : []
+}
+
+const loadTemplateFields = async (detail: DeviceDetail) => {
+  const savedFields = parseTemplateFields(detail.templateJson)
+  if (!detail.assetClass) {
+    templateFields.value = savedFields
+    return
+  }
 
   try {
-    const fields = JSON.parse(formData.value.templateJson)
-    return Array.isArray(fields) ? fields : []
+    const definitions = (await DeviceAttrModelApi.getDeviceAttrModelListByDeviceCategoryId(
+      detail.assetClass
+    )) as DeviceAttrModelData[]
+    const savedFieldMap = new Map(
+      savedFields.filter((field) => field.code).map((field) => [field.code, field])
+    )
+
+    const fieldsFromDefinitions: TemplateField[] = (definitions || []).map((definition) => {
+      const code = definition.code || ''
+      const savedField = savedFieldMap.get(code)
+      savedFieldMap.delete(code)
+      return {
+        ...definition,
+        ...savedField,
+        value: savedField?.value ?? detail[code] ?? definition.defaultValue ?? null
+      } as TemplateField
+    })
+
+    templateFields.value = [...fieldsFromDefinitions, ...savedFieldMap.values()]
   } catch {
-    return []
+    templateFields.value = savedFields
   }
-})
+}
 
 const mainInfoFields = computed<MainInfoField[]>(() => [
+  {
+    prop: 'yfClass',
+    label: t('iotDevice.yfClass'),
+    value: yfClassLabel.value,
+    icon: 'ep:connection'
+  },
   {
     prop: 'yfDeviceCode',
     label: t('iotDevice.yfCode'),
@@ -267,7 +358,7 @@ const mainInfoFields = computed<MainInfoField[]>(() => [
   },
   {
     prop: 'deviceStatus',
-    label: '施工状态',
+    label: t('iotDevice.status'),
     value: statusLabel.value,
     icon: 'ep:operation'
   },
@@ -283,11 +374,48 @@ const mainInfoFields = computed<MainInfoField[]>(() => [
     value: formatValue(formData.value.model),
     icon: 'ep:set-up'
   },
+  {
+    prop: 'carNo',
+    label: '车牌号',
+    value: formatValue(formData.value.carNo),
+    icon: 'ep:van'
+  },
+  {
+    prop: 'deviceNo',
+    label: '设备号',
+    value: formatValue(formData.value.deviceNo),
+    icon: 'ep:document'
+  },
+  {
+    prop: 'useProject',
+    label: t('deviceForm.useProject'),
+    value: formatValue(formData.value.useProject),
+    icon: 'ep:briefcase'
+  },
+  {
+    prop: 'assetOwnership',
+    label: t('deviceForm.assetOwner'),
+    value: formatValue(formData.value.assetOwnership),
+    icon: 'ep:stamp'
+  },
+  {
+    prop: 'address',
+    label: '所在地点',
+    value: formatValue(formData.value.address),
+    icon: 'ep:location'
+  },
+
   {
     prop: 'responsibleNames',
     label: t('devicePerson.rp'),
     value: responsibleNames.value,
     icon: 'ep:user'
+  },
+  {
+    prop: 'remark',
+    label: t('deviceForm.remark'),
+    value: formatValue(formData.value.remark),
+    icon: 'ep:chat-line-square'
   }
 ])
 
@@ -299,6 +427,7 @@ const getDetail = async () => {
     const detail = (await IotDeviceApi.getIotDevice(deviceId.value)) as DeviceDetail
     formData.value = detail || {}
     defaultPicUrl.value = detail?.picUrl || defaultDevicePic
+    await loadTemplateFields(formData.value)
   } finally {
     formLoading.value = false
   }
@@ -318,6 +447,7 @@ const imagePreview = (imgUrl: string) => {
 
 onMounted(() => {
   getDetail()
+  getEncodingTypeOptions()
 })
 </script>
 
@@ -339,7 +469,7 @@ onMounted(() => {
 
 .device-main {
   display: grid;
-  grid-template-columns: 292px minmax(0, 1fr);
+  grid-template-columns: 264px minmax(0, 1fr);
   min-height: 286px;
   padding: 24px;
   background: radial-gradient(circle at 18px 22px, rgb(44 123 229 / 12%), transparent 30%),
@@ -348,19 +478,19 @@ onMounted(() => {
 
 .device-media {
   display: flex;
-  align-items: stretch;
+  align-items: center;
   justify-content: center;
 }
 
 .device-image-frame {
   position: relative;
-  width: 260px;
-  height: 226px;
-  padding: 8px;
+  width: 236px;
+  height: 236px;
+  padding: 12px;
   overflow: hidden;
   font: inherit;
   cursor: zoom-in;
-  background: var(--el-bg-color);
+  background: linear-gradient(145deg, #fff 0%, #f7faff 100%);
   border: 1px solid rgb(44 123 229 / 14%);
   border-radius: 12px;
   box-shadow: 0 14px 32px rgb(39 83 135 / 15%);
@@ -381,6 +511,7 @@ onMounted(() => {
   width: 100%;
   height: 100%;
   overflow: hidden;
+  background: transparent;
   border-radius: 8px;
   transition: transform 0.3s ease;
 }
@@ -472,8 +603,8 @@ onMounted(() => {
 
 .device-info-grid {
   display: grid;
-  grid-template-columns: repeat(5, minmax(132px, 1fr));
-  gap: 0 22px;
+  grid-template-columns: repeat(6, minmax(120px, 1fr));
+  gap: 0 18px;
   padding-top: 14px;
 }
 
@@ -540,11 +671,11 @@ onMounted(() => {
 
 @media (width <= 1399px) {
   .device-main {
-    grid-template-columns: 260px minmax(0, 1fr);
+    grid-template-columns: 232px minmax(0, 1fr);
   }
 
   .device-image-frame {
-    width: 232px;
+    width: 208px;
     height: 208px;
   }
 

+ 2632 - 0
src/views/pms/video_center/haikang/index.vue

@@ -0,0 +1,2632 @@
+<script lang="ts" setup>
+import {
+  HikvisionApi,
+  type HikvisionCameraNode,
+  type HikvisionPtzCommand,
+  type HikvisionStreamType,
+  type HikvisionTreeNode
+} from '@/api/pms/hikvision'
+import grid16Icon from '@/assets/icons/grid-16.svg'
+import gridFourIcon from '@/assets/icons/grid-four.svg'
+import gridNineIcon from '@/assets/icons/grid-nine.svg'
+import gridOneIcon from '@/assets/icons/grid-one.svg'
+import {
+  getHikvisionPlayerBasePath,
+  loadHikvisionPlayer,
+  type HikvisionPlayerInstance,
+  type HikvisionSplitColumns
+} from '@/utils/hikvisionH5Player'
+import { Loading, Location, Search, VideoCamera } from '@element-plus/icons-vue'
+import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
+import { IotDeviceApi } from '@/api/pms/device'
+import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
+
+defineOptions({ name: 'HikvisionVideoCenter' })
+
+const layoutTriggerRef = ref<HTMLElement>()
+const playerContainerRef = ref<HTMLElement>()
+// 原生全屏必须挂在同时包含播放器、悬浮层和工具栏的共同容器上。
+// 如果继续让 H5Player 仅全屏自己的挂载节点,兄弟节点形式的 Vue 悬浮层会被浏览器排除在全屏画面外。
+const videoStageRef = ref<HTMLElement>()
+const keyword = ref('')
+const treeData = ref<HikvisionTreeNode[]>([])
+const expandedKeys = ref<string[]>([])
+const treeLoading = ref(false)
+// 搜索结果是完整树;普通目录仍按需加载。每次请求后重建树,避免复用旧的懒加载缓存。
+const treeSearchMode = ref(false)
+const treeVersion = ref(0)
+let treeRequestId = 0
+const playerContainerId = 'hikvision-preview-player'
+const previewLoadingWindowIndexes = ref<number[]>([])
+const previewWindowIndexes = ref<number[]>([])
+const previewWindowCameras = ref<Record<number, HikvisionCameraNode>>({})
+const previewWindowStreamTypes = ref<Record<number, HikvisionStreamType>>({})
+const interruptedWindowIndexes = ref<number[]>([])
+const previewError = ref('')
+const captureAllLoading = ref(false)
+const stopAllLoading = ref(false)
+const soundWindowIndex = ref<number>()
+const soundControlLoading = ref(false)
+const talkWindowIndex = ref<number>()
+const talkTargetWindowIndex = ref<number>()
+const talkControlLoading = ref(false)
+const zoomWindowIndexes = ref<number[]>([])
+const zoomLoadingWindowIndexes = ref<number[]>([])
+const currentSplitCount = ref<SplitCount>(4)
+const activeWindowIndex = ref(0)
+const hoveredWindowIndex = ref<number>()
+// 鼠标进入页面叠加按钮后 SDK 会触发 windowEventOut,单独记录按钮悬浮以保持栏可见。
+const hoveredControlWindowIndex = ref<number>()
+const splitPopoverVisible = ref(false)
+const splitControlLoading = ref(false)
+// 云台 Popover 的全局展示状态和所属窗口,用于确保弹层打开时对应视口工具栏保持可见。
+const ptzPopoverVisible = ref(false)
+const ptzPopoverWindowIndex = ref<number>()
+// 当前被按住的方向及窗口,仅用于按钮按压态展示;松开时会立即清空。
+const activePtzCommand = ref<HikvisionPtzCommand>()
+const activePtzWindowIndex = ref<number>()
+const sidebarCollapsed = ref(false)
+const stageFullscreen = ref(false)
+// undefined 表示整体分屏全屏;有值时表示双击后只放大对应的播放器窗口。
+const singleFullscreenWindowIndex = ref<number>()
+const fullscreenControlLoading = ref(false)
+
+type SplitCount = 1 | 4 | 9 | 16
+
+// SDK 的分屏参数是边长:4 表示最大 4×4,2 表示默认 2×2。
+const MAX_SPLIT_COLUMNS = 4
+const DEFAULT_SPLIT_COLUMNS = 2
+// 云台速度允许 1~100;当前统一使用较平缓的 30,便于按压控制时精确调整画面方向。
+const PTZ_CONTROL_SPEED = 30
+
+const splitOptions: Array<{ count: SplitCount; columns: HikvisionSplitColumns; icon: string }> = [
+  { count: 1, columns: 1, icon: gridOneIcon },
+  { count: 4, columns: 2, icon: gridFourIcon },
+  { count: 9, columns: 3, icon: gridNineIcon },
+  { count: 16, columns: 4, icon: grid16Icon }
+]
+
+/**
+ * 八方向控制盘的展示配置与接口命令映射。
+ * gridArea 将八个按钮固定在 3×3 网格四周,中间单元格留给不可点击的云台标识。
+ */
+const ptzDirections: Array<{
+  /** 页面展示和无障碍朗读使用的中文方向名。 */
+  label: string
+  /** UnoCSS Lucide 图标类名。 */
+  icon: string
+  /** CSS Grid 行列位置。 */
+  gridArea: string
+  /** `/vms/ptz-control` 接口要求的大写命令。 */
+  command: HikvisionPtzCommand
+}> = [
+  { label: '左上', icon: 'i-lucide:arrow-up-left', gridArea: '1 / 1', command: 'LEFT_UP' },
+  { label: '向上', icon: 'i-lucide:arrow-up', gridArea: '1 / 2', command: 'UP' },
+  { label: '右上', icon: 'i-lucide:arrow-up-right', gridArea: '1 / 3', command: 'RIGHT_UP' },
+  { label: '向左', icon: 'i-lucide:arrow-left', gridArea: '2 / 1', command: 'LEFT' },
+  { label: '向右', icon: 'i-lucide:arrow-right', gridArea: '2 / 3', command: 'RIGHT' },
+  {
+    label: '左下',
+    icon: 'i-lucide:arrow-down-left',
+    gridArea: '3 / 1',
+    command: 'LEFT_DOWN'
+  },
+  { label: '向下', icon: 'i-lucide:arrow-down', gridArea: '3 / 2', command: 'DOWN' },
+  {
+    label: '右下',
+    icon: 'i-lucide:arrow-down-right',
+    gridArea: '3 / 3',
+    command: 'RIGHT_DOWN'
+  }
+]
+
+/**
+ * 一次尚未完整结束的云台按压操作。
+ *
+ * 开始和停止请求必须使用相同的摄像机与命令,因此按下时保存快照,不能在松开时重新
+ * 读取可能已经切换过的视口状态。startRequest 用于保证停止请求排在开始请求之后发送。
+ */
+interface ActivePtzPress {
+  /** 按下瞬间对应的监控点标识。 */
+  cameraIndexCode: string
+  /** 按下的方向命令。 */
+  command: HikvisionPtzCommand
+  /** Pointer Events 分配的指针编号,用于过滤其他鼠标或触摸点的释放事件。 */
+  pointerId: number
+  /** 触发按钮,用于主动释放指针捕获。 */
+  target: HTMLElement
+  /** 云台操作所属的播放器窗口。 */
+  windowIndex: number
+  /** 开始请求;停止流程会等待它结束,避免两个请求在服务端乱序。 */
+  startRequest: Promise<boolean>
+}
+
+let player: HikvisionPlayerInstance | undefined
+// 同一时刻只允许一个播放器初始化任务,避免快速操作时创建多个 JSPlugin 实例。
+let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
+let previewErrorTimer: ReturnType<typeof window.setTimeout> | undefined
+
+/**
+ * 记录每个窗口当前显示 loading 的请求编号。
+ * 关闭 loading 时会核对编号,防止旧请求把新请求的 loading 提前关闭。
+ */
+const previewLoadingWindowRequests = new Map<number, number>()
+
+/** 每个窗口最近一次预览请求的编号,用于让不同窗口并发、同一窗口只保留最新请求。 */
+const previewWindowRequests = new Map<number, number>()
+
+/**
+ * 已经进入 JS_Play 阶段的请求。
+ * 该状态用来区分“旧视频在请求新地址期间触发的回调”和“新视频自身触发的回调”。
+ */
+const previewPlayingWindowRequests = new Map<number, number>()
+/** 正在主动关闭的窗口,用于合并重复点击,并忽略 JS_Stop 触发的播放结束回调。 */
+const stoppingWindowIndexes = new Set<number>()
+/** 正在抓图的窗口,避免连续点击触发重复下载。 */
+const capturingWindowIndexes = new Set<number>()
+let nextPreviewRequestId = 0
+// 获取地址、停止旧对讲和开启新对讲均为异步操作,用编号阻止关闭视口后的旧任务重新开启麦克风。
+let talkOperationId = 0
+let nextZoomOperationId = 0
+// 页面同一时刻只允许一个方向处于按压状态,符合云台一次执行一个持续动作的语义。
+let activePtzPress: ActivePtzPress | undefined
+// 从按下开始直至停止请求完成前保持 true,防止用户快速连续按键产生交叉请求。
+let ptzControlBusy = false
+/** 每个窗口独立记录电子放大操作,避免异步开启完成后覆盖关闭或切流操作。 */
+const zoomWindowOperations = new Map<number, number>()
+
+// SDK 异步加载完成前页面可能已经卸载,使用该标记阻止卸载后继续创建播放器。
+let componentUnmounted = false
+
+// ResizeObserver 负责容器尺寸变化,requestAnimationFrame 用于合并同一帧内的重复 resize。
+let playerResizeObserver: ResizeObserver | undefined
+let playerResizeFrame: number | undefined
+let playerResizeInProgress = false
+let playerResizePending = false
+
+const currentSplitColumns = computed(() => Math.sqrt(currentSplitCount.value))
+const currentSplitRows = computed(() => currentSplitColumns.value)
+const currentSplitWindowIndexes = computed(() =>
+  Array.from({ length: currentSplitCount.value }, (_, index) => index)
+)
+
+const treeProps = {
+  children: 'children',
+  label: 'name',
+  isLeaf: (data: HikvisionTreeNode) => data.nodeType === 'camera'
+}
+
+function clearPreviewErrorTimer() {
+  if (previewErrorTimer === undefined) return
+  window.clearTimeout(previewErrorTimer)
+  previewErrorTimer = undefined
+}
+
+function clearPreviewError() {
+  clearPreviewErrorTimer()
+  previewError.value = ''
+}
+
+function showPreviewError(message: string) {
+  if (componentUnmounted) return
+  clearPreviewErrorTimer()
+  previewError.value = message
+  previewErrorTimer = setTimeout(() => {
+    previewError.value = ''
+    previewErrorTimer = undefined
+  }, 2000)
+}
+
+/** 更新指定窗口的 loading,并确保旧请求不能覆盖新请求的状态。 */
+function setWindowPreviewLoading(windowIndex: number, loading: boolean, requestId?: number) {
+  if (loading) {
+    if (requestId !== undefined) previewLoadingWindowRequests.set(windowIndex, requestId)
+    if (!previewLoadingWindowIndexes.value.includes(windowIndex)) {
+      previewLoadingWindowIndexes.value = [...previewLoadingWindowIndexes.value, windowIndex]
+    }
+    return
+  }
+
+  if (requestId !== undefined && previewLoadingWindowRequests.get(windowIndex) !== requestId) return
+
+  previewLoadingWindowRequests.delete(windowIndex)
+  previewLoadingWindowIndexes.value = previewLoadingWindowIndexes.value.filter(
+    (loadingWindowIndex) => loadingWindowIndex !== windowIndex
+  )
+}
+
+function clearWindowPreviewLoading() {
+  previewLoadingWindowRequests.clear()
+  previewLoadingWindowIndexes.value = []
+}
+
+/** 为指定窗口创建新请求编号;只会替换该窗口,不影响其他窗口正在进行的预览。 */
+function beginWindowPreviewRequest(windowIndex: number) {
+  const requestId = ++nextPreviewRequestId
+  previewWindowRequests.set(windowIndex, requestId)
+  previewPlayingWindowRequests.delete(windowIndex)
+  setWindowPreviewLoading(windowIndex, true, requestId)
+  return requestId
+}
+
+/** 判断异步结果是否仍属于该窗口的最新一次操作。 */
+function isCurrentWindowPreviewRequest(windowIndex: number, requestId: number) {
+  return previewWindowRequests.get(windowIndex) === requestId
+}
+
+/** 取消指定窗口的待处理请求,同时移除该窗口的 loading。 */
+function cancelWindowPreviewRequest(windowIndex: number) {
+  previewWindowRequests.delete(windowIndex)
+  previewPlayingWindowRequests.delete(windowIndex)
+  setWindowPreviewLoading(windowIndex, false)
+}
+
+/** 停止全部操作时统一使所有尚未返回的异步结果失效。 */
+function cancelAllWindowPreviewRequests() {
+  previewWindowRequests.clear()
+  previewPlayingWindowRequests.clear()
+  clearWindowPreviewLoading()
+}
+
+function setWindowPreviewSelected(windowIndex: number, selected: boolean) {
+  if (selected) {
+    if (!previewWindowIndexes.value.includes(windowIndex)) {
+      previewWindowIndexes.value = [...previewWindowIndexes.value, windowIndex]
+    }
+    return
+  }
+
+  previewWindowIndexes.value = previewWindowIndexes.value.filter(
+    (previewWindowIndex) => previewWindowIndex !== windowIndex
+  )
+}
+
+function setWindowPreviewCamera(windowIndex: number, camera?: HikvisionCameraNode) {
+  const nextWindowCameras = { ...previewWindowCameras.value }
+  if (camera) {
+    nextWindowCameras[windowIndex] = camera
+  } else {
+    delete nextWindowCameras[windowIndex]
+  }
+  previewWindowCameras.value = nextWindowCameras
+}
+
+function setWindowPreviewStreamType(windowIndex: number, streamType?: HikvisionStreamType) {
+  const nextWindowStreamTypes = { ...previewWindowStreamTypes.value }
+  if (streamType !== undefined) {
+    nextWindowStreamTypes[windowIndex] = streamType
+  } else {
+    delete nextWindowStreamTypes[windowIndex]
+  }
+  previewWindowStreamTypes.value = nextWindowStreamTypes
+}
+
+function setWindowInterrupted(windowIndex: number, interrupted: boolean) {
+  if (interrupted) {
+    if (!interruptedWindowIndexes.value.includes(windowIndex)) {
+      interruptedWindowIndexes.value = [...interruptedWindowIndexes.value, windowIndex]
+    }
+    return
+  }
+
+  interruptedWindowIndexes.value = interruptedWindowIndexes.value.filter(
+    (interruptedWindowIndex) => interruptedWindowIndex !== windowIndex
+  )
+}
+
+function clearWindowSoundState(windowIndex: number) {
+  if (soundWindowIndex.value === windowIndex) soundWindowIndex.value = undefined
+}
+
+function clearWindowTalkState(windowIndex: number) {
+  if (talkWindowIndex.value === windowIndex) talkWindowIndex.value = undefined
+  if (talkTargetWindowIndex.value !== windowIndex) return
+  talkOperationId += 1
+  talkTargetWindowIndex.value = undefined
+  talkControlLoading.value = false
+}
+
+function setWindowZoomEnabled(windowIndex: number, enabled: boolean) {
+  if (enabled) {
+    if (!zoomWindowIndexes.value.includes(windowIndex)) {
+      zoomWindowIndexes.value = [...zoomWindowIndexes.value, windowIndex]
+    }
+    return
+  }
+  zoomWindowIndexes.value = zoomWindowIndexes.value.filter(
+    (zoomWindowIndex) => zoomWindowIndex !== windowIndex
+  )
+}
+
+function setWindowZoomLoading(windowIndex: number, loading: boolean) {
+  if (loading) {
+    if (!zoomLoadingWindowIndexes.value.includes(windowIndex)) {
+      zoomLoadingWindowIndexes.value = [...zoomLoadingWindowIndexes.value, windowIndex]
+    }
+    return
+  }
+  zoomLoadingWindowIndexes.value = zoomLoadingWindowIndexes.value.filter(
+    (zoomWindowIndex) => zoomWindowIndex !== windowIndex
+  )
+}
+
+function clearWindowZoomState(windowIndex: number) {
+  zoomWindowOperations.delete(windowIndex)
+  setWindowZoomEnabled(windowIndex, false)
+  setWindowZoomLoading(windowIndex, false)
+}
+
+/**
+ * 关闭指定窗口的云台弹层状态。
+ * 若方向按钮仍处于按压状态,会同时触发停止兜底,防止弹层消失后云台继续转动。
+ */
+function closeWindowPtzPopover(windowIndex: number) {
+  // 无论当前展示的是哪个弹层,都先尝试停止该窗口正在进行的云台动作。
+  void stopActivePtzControl(windowIndex)
+  // 旧 Popover 的延迟 hide 事件不能清掉另一个窗口刚打开的 Popover 状态。
+  if (ptzPopoverWindowIndex.value !== windowIndex) return
+  ptzPopoverVisible.value = false
+  ptzPopoverWindowIndex.value = undefined
+}
+
+/** 记录当前打开云台弹层的窗口,让该窗口的悬浮操作栏在鼠标移入弹层后仍然可见。 */
+function handleWindowPtzPopoverShow(windowIndex: number) {
+  ptzPopoverWindowIndex.value = windowIndex
+  ptzPopoverVisible.value = true
+}
+
+/** 响应 Element Plus Popover 的关闭事件,并执行云台停止兜底。 */
+function handleWindowPtzPopoverHide(windowIndex: number) {
+  closeWindowPtzPopover(windowIndex)
+}
+
+/** 按下方向按钮后发送开始命令,并捕获指针以确保在按钮外松开时仍能收到停止事件。 */
+function startWindowPtzControl(
+  event: PointerEvent,
+  windowIndex: number,
+  command: HikvisionPtzCommand
+) {
+  // 只响应主指针的鼠标左键/单指触控,并禁止尚未停止时开启第二个方向。
+  if (!event.isPrimary || event.button !== 0 || activePtzPress || ptzControlBusy) return
+
+  // 云台接口必须使用摄像机 indexCode;视口尚未绑定监控点时不发送请求。
+  const camera = previewWindowCameras.value[windowIndex]
+  if (!camera) return
+
+  // 阻止触屏上的滚动、选中文本等默认行为干扰持续按压。
+  event.preventDefault()
+  const target = event.currentTarget as HTMLElement
+  try {
+    // 捕获后,即使指针移出按钮,pointerup 仍会派发给该按钮,从而可靠发送停止命令。
+    target.setPointerCapture(event.pointerId)
+  } catch {
+    // 个别旧浏览器不支持指针捕获,仍可通过按钮上的 pointerup 完成停止。
+  }
+
+  // 保存按下瞬间的数据快照,避免播放窗口在异步请求期间切换到另一台摄像机。
+  const press: ActivePtzPress = {
+    cameraIndexCode: camera.indexCode,
+    command,
+    pointerId: event.pointerId,
+    target,
+    windowIndex,
+    startRequest: Promise.resolve(true)
+  }
+  activePtzPress = press
+  ptzControlBusy = true
+  // 先同步更新视觉状态,使按钮按下反馈不必等待网络请求返回。
+  activePtzCommand.value = command
+  activePtzWindowIndex.value = windowIndex
+  clearPreviewError()
+
+  // action=0 表示开始;开始动作携带固定速度,停止动作不需要重复传递速度。
+  press.startRequest = HikvisionApi.controlPtz({
+    cameraIndexCode: camera.indexCode,
+    action: 0,
+    command,
+    speed: PTZ_CONTROL_SPEED
+  })
+    .then(() => true)
+    .catch((error) => {
+      // 将异常转换为 false,确保松开流程等待该 Promise 时不会产生未处理的拒绝。
+      console.error('开启云台方向控制失败', error)
+      showPreviewError(error instanceof Error ? error.message : '开启云台方向控制失败')
+      return false
+    })
+}
+
+/** 松开、取消、关闭弹层或销毁页面时统一发送停止命令。 */
+async function stopActivePtzControl(windowIndex?: number, pointerId?: number) {
+  const press = activePtzPress
+  if (!press) return
+  // windowIndex 用于隔离不同视口,pointerId 用于忽略其他触摸点或鼠标产生的事件。
+  if (windowIndex !== undefined && press.windowIndex !== windowIndex) return
+  if (pointerId !== undefined && press.pointerId !== pointerId) return
+
+  // 在第一个 await 之前清空按压状态,防止 pointerup 与 lostpointercapture 重复发送停止请求。
+  activePtzPress = undefined
+  activePtzCommand.value = undefined
+  activePtzWindowIndex.value = undefined
+  try {
+    // 主动释放捕获;若浏览器已自动释放或元素已销毁,catch 会安全忽略。
+    if (press.target.hasPointerCapture(press.pointerId)) {
+      press.target.releasePointerCapture(press.pointerId)
+    }
+  } catch {
+    // 引用元素可能已随视口或弹层销毁,无需额外处理。
+  }
+
+  // 等待开始请求完成,保证同一摄像机的 action=1 不会先于 action=0 到达后端。
+  await press.startRequest
+  try {
+    // 即使开始请求的响应失败也发送停止命令:服务端可能已开始转动,只是响应在途中失败。
+    await HikvisionApi.controlPtz({
+      cameraIndexCode: press.cameraIndexCode,
+      action: 1,
+      command: press.command
+    })
+  } catch (error) {
+    console.error('停止云台方向控制失败', error)
+    showPreviewError(error instanceof Error ? error.message : '停止云台方向控制失败')
+  } finally {
+    // 停止请求结束后才允许下一次按压,避免不同方向请求交叉执行。
+    ptzControlBusy = false
+  }
+}
+
+/** 将按钮产生的释放类 PointerEvent 转交给统一停止流程。 */
+function stopWindowPtzControl(event: PointerEvent, windowIndex: number) {
+  void stopActivePtzControl(windowIndex, event.pointerId)
+}
+
+/** 清空一个窗口在前端维护的请求、监控点、播放和断流状态。 */
+function clearWindowPreview(windowIndex: number) {
+  cancelWindowPreviewRequest(windowIndex)
+  setWindowPreviewSelected(windowIndex, false)
+  setWindowPreviewCamera(windowIndex)
+  setWindowPreviewStreamType(windowIndex)
+  setWindowInterrupted(windowIndex, false)
+  clearWindowSoundState(windowIndex)
+  clearWindowTalkState(windowIndex)
+  clearWindowZoomState(windowIndex)
+  // 视口状态清空会销毁对应 Popover,销毁前先通过统一入口停止可能存在的云台动作。
+  closeWindowPtzPopover(windowIndex)
+}
+
+function clearWindowPreviewSelected() {
+  // 全量清理不逐个经过 clearWindowPreview,因此在这里单独停止全局唯一的云台动作。
+  void stopActivePtzControl()
+  // 同步清除弹层归属,避免停止全部预览后工具栏仍保留云台高亮状态。
+  ptzPopoverVisible.value = false
+  ptzPopoverWindowIndex.value = undefined
+  previewWindowIndexes.value = []
+  previewWindowCameras.value = {}
+  previewWindowStreamTypes.value = {}
+  interruptedWindowIndexes.value = []
+  soundWindowIndex.value = undefined
+  talkWindowIndex.value = undefined
+  talkTargetWindowIndex.value = undefined
+  talkControlLoading.value = false
+  zoomWindowOperations.clear()
+  zoomWindowIndexes.value = []
+  zoomLoadingWindowIndexes.value = []
+}
+
+function getWindowPreviewStatus(windowIndex: number) {
+  if (previewLoadingWindowIndexes.value.includes(windowIndex)) return '正在加载'
+  if (interruptedWindowIndexes.value.includes(windowIndex)) return '画面中断'
+  if (previewWindowIndexes.value.includes(windowIndex)) return '正在预览'
+  return '未预览'
+}
+
+function getWindowPreviewTitle(windowIndex: number) {
+  return previewWindowCameras.value[windowIndex]?.name || '未选择监控'
+}
+
+function getWindowStreamType(windowIndex: number): HikvisionStreamType {
+  return previewWindowStreamTypes.value[windowIndex] ?? 0
+}
+
+function getWindowStreamSwitchTitle(windowIndex: number) {
+  return getWindowStreamType(windowIndex) === 0 ? '切换为子码流' : '切换为主码流'
+}
+
+function isWindowSoundEnabled(windowIndex: number) {
+  return soundWindowIndex.value === windowIndex
+}
+
+function getWindowSoundTitle(windowIndex: number) {
+  return isWindowSoundEnabled(windowIndex) ? '关闭声音' : '开启声音'
+}
+
+function isWindowTalking(windowIndex: number) {
+  return talkWindowIndex.value === windowIndex
+}
+
+function getWindowTalkTitle(windowIndex: number) {
+  if (talkControlLoading.value && talkTargetWindowIndex.value === windowIndex) return '正在开启对讲'
+  if (talkControlLoading.value && talkWindowIndex.value === windowIndex) return '正在停止对讲'
+  return isWindowTalking(windowIndex) ? '停止对讲' : '开启对讲'
+}
+
+function isWindowZoomEnabled(windowIndex: number) {
+  return zoomWindowIndexes.value.includes(windowIndex)
+}
+
+function getWindowZoomTitle(windowIndex: number) {
+  if (zoomLoadingWindowIndexes.value.includes(windowIndex)) {
+    return isWindowZoomEnabled(windowIndex) ? '正在关闭电子放大' : '正在开启电子放大'
+  }
+  return isWindowZoomEnabled(windowIndex) ? '关闭电子放大' : '开启电子放大'
+}
+
+function getTalkErrorMessage(errorCode: number) {
+  if (errorCode === 0x12f950000) return '麦克风采集失败,请检查 HTTPS 环境和麦克风权限'
+  if (errorCode === 0x12f950001) return '监控设备的对讲音频编码不受支持'
+  return `对讲异常(${errorCode})`
+}
+
+const globalSoundTitle = computed(() =>
+  soundWindowIndex.value === undefined ? '开启当前监控声音' : '关闭全部监控声音'
+)
+
+async function loadRootTree(searchKeyword = '', requestId = ++treeRequestId) {
+  treeLoading.value = true
+  try {
+    const data = searchKeyword
+      ? await HikvisionApi.searchTree(searchKeyword)
+      : await HikvisionApi.getTreeNodes('-1')
+    // 新搜索或清空会使旧请求失效,防止慢搜索覆盖最新目录。
+    if (componentUnmounted || requestId !== treeRequestId) return
+    treeSearchMode.value = Boolean(searchKeyword)
+    treeData.value = data
+    const root = treeData.value[0]
+    expandedKeys.value = !searchKeyword && root?.nodeType === 'region' ? [root.indexCode] : []
+    treeVersion.value += 1
+  } catch {
+    if (componentUnmounted || requestId !== treeRequestId) return
+    treeData.value = []
+  } finally {
+    if (!componentUnmounted && requestId === treeRequestId) treeLoading.value = false
+  }
+}
+
+const loadTreeNode: LoadFunction = async (node, resolve, reject) => {
+  if (node.level === 0) {
+    resolve(treeData.value)
+    return
+  }
+
+  const data = node.data as HikvisionTreeNode
+  if (data.nodeType !== 'region') {
+    resolve([])
+    return
+  }
+
+  if (data.children.length) {
+    resolve(data.children)
+    return
+  }
+
+  try {
+    resolve(await HikvisionApi.getTreeNodes(data.indexCode))
+  } catch {
+    reject()
+  }
+}
+
+function initPlayer(): Promise<HikvisionPlayerInstance> {
+  if (player) return Promise.resolve(player)
+  if (playerInitPromise) return playerInitPromise
+
+  clearPreviewError()
+  playerInitPromise = (async () => {
+    // 初始化未完成前不写入全局 player,便于页面卸载或注册回调失败时完整回收半成品实例。
+    let initializingPlayer: HikvisionPlayerInstance | undefined
+    try {
+      await nextTick()
+      const JSPlugin = await loadHikvisionPlayer()
+      if (componentUnmounted) throw new Error('页面已卸载,取消初始化播放器')
+
+      initializingPlayer = new JSPlugin({
+        szId: playerContainerId,
+        szBasePath: getHikvisionPlayerBasePath(),
+        iMaxSplit: MAX_SPLIT_COLUMNS,
+        iCurrentSplit: DEFAULT_SPLIT_COLUMNS,
+        // 指南建议高分辨率画面开启多线程解码;仅在页面具备跨源隔离时启用,
+        // 避免未配置 COOP/COEP 的部署环境错误使用 SharedArrayBuffer。
+        mseWorkerEnable: window.crossOriginIsolated,
+        // SDK 的双击全屏只包含其自身 DOM,无法带上页面叠加的标题和操作栏。
+        // 页面改为捕获播放器双击并全屏整个 video-stage,因此关闭 SDK 内置双击行为。
+        bSupporDoubleClickFull: false,
+        oStyle: { borderSelect: '#facc15' }
+      })
+
+      // 开发指南要求创建实例后先注册事件,再调用 JS_Play 等播放接口。
+      await initializingPlayer.JS_SetWindowControlCallback({
+        windowEventSelect: (windowIndex) => {
+          activeWindowIndex.value = windowIndex
+        },
+        windowEventOver: (windowIndex) => {
+          // 使用 SDK 提供的窗口进入事件控制悬浮工具栏,无需自行计算鼠标坐标。
+          hoveredWindowIndex.value = windowIndex
+        },
+        windowEventOut: (windowIndex) => {
+          if (hoveredWindowIndex.value === windowIndex) hoveredWindowIndex.value = undefined
+        },
+        firstFrameDisplay: (windowIndex) => {
+          // JS_Play 成功只代表播放命令已接受;首帧回调才代表用户真正看到了画面。
+          const playingRequestId = previewPlayingWindowRequests.get(windowIndex)
+          if (
+            playingRequestId === undefined ||
+            !isCurrentWindowPreviewRequest(windowIndex, playingRequestId)
+          ) {
+            return
+          }
+          setWindowInterrupted(windowIndex, false)
+          setWindowPreviewSelected(windowIndex, true)
+          cancelWindowPreviewRequest(windowIndex)
+        },
+        InterruptStream: (windowIndex, interruptTime) => {
+          // 获取新地址期间收到的回调可能属于旧流;新流尚未进入 JS_Play 时忽略该旧回调。
+          if (
+            previewWindowRequests.has(windowIndex) &&
+            !previewPlayingWindowRequests.has(windowIndex)
+          ) {
+            return
+          }
+          if (!previewWindowCameras.value[windowIndex]) return
+          cancelWindowPreviewRequest(windowIndex)
+          setWindowInterrupted(windowIndex, true)
+          clearWindowSoundState(windowIndex)
+          void stopWindowTalk(windowIndex)
+          void stopWindowZoom(windowIndex)
+          showPreviewError(`监控画面已中断(${interruptTime} 秒未收到码流)`)
+        },
+        StreamEnd: (windowIndex) => {
+          // 用户主动关闭时由 stopWindowPreview 统一清理,不显示“画面已结束”的异常提示。
+          if (stoppingWindowIndexes.has(windowIndex)) return
+          // 与断流回调相同,避免旧流结束事件清掉正在请求的新监控点。
+          if (
+            previewWindowRequests.has(windowIndex) &&
+            !previewPlayingWindowRequests.has(windowIndex)
+          ) {
+            return
+          }
+          if (!previewWindowCameras.value[windowIndex]) return
+          void stopWindowTalk(windowIndex)
+          void stopWindowZoom(windowIndex)
+          clearWindowPreview(windowIndex)
+          showPreviewError('监控画面已结束')
+        },
+        performanceLack: () => {
+          // SDK 已经完成性能判断,页面只负责给出短暂提示,不自行推断设备性能。
+          showPreviewError('当前设备性能不足,监控画面可能卡顿')
+        },
+        pluginErrorHandler: (windowIndex, errorCode, error) => {
+          console.error('H5player 播放器错误', errorCode, error)
+          // 新地址仍在请求时,窗口里的 SDK 错误属于即将替换的旧流,不应取消新请求。
+          if (
+            windowIndex >= 0 &&
+            previewWindowRequests.has(windowIndex) &&
+            !previewPlayingWindowRequests.has(windowIndex)
+          ) {
+            return
+          }
+          if (windowIndex >= 0) {
+            void stopWindowTalk(windowIndex)
+            void stopWindowZoom(windowIndex)
+            clearWindowPreview(windowIndex)
+          }
+          showPreviewError(`播放器异常(${errorCode})`)
+        },
+        talkPluginErrorHandler: (errorCode, error) => {
+          console.error('H5player 对讲错误', errorCode, error)
+          talkOperationId += 1
+          talkWindowIndex.value = undefined
+          talkTargetWindowIndex.value = undefined
+          talkControlLoading.value = false
+          showPreviewError(getTalkErrorMessage(errorCode))
+        }
+      })
+      if (componentUnmounted) throw new Error('页面已卸载,取消初始化播放器')
+
+      // 回调注册成功且页面仍存在后,播放器实例才正式对其他操作可见。
+      player = initializingPlayer
+      initializingPlayer = undefined
+      schedulePlayerResize()
+      return player
+    } catch (error) {
+      // 初始化失败也要销毁半成品实例,否则 Worker、WASM 或媒体资源可能残留。
+      await initializingPlayer?.JS_StopRealPlayAll().catch(() => undefined)
+      await Promise.resolve(initializingPlayer?.JS_Destroy()).catch(() => undefined)
+      if (!componentUnmounted) {
+        console.error('H5player 初始化失败', error)
+        showPreviewError(error instanceof Error ? error.message : '播放器初始化失败')
+      }
+      throw error
+    } finally {
+      playerInitPromise = undefined
+    }
+  })()
+
+  return playerInitPromise
+}
+
+async function playCameraPreview(camera: HikvisionCameraNode) {
+  if (stopAllLoading.value || splitControlLoading.value) {
+    showPreviewError('播放器正在切换状态,请稍后再选择监控点')
+    return
+  }
+
+  // 点击监控点时固定目标窗口,之后用户切换选中窗口不会改变本次请求的播放位置。
+  const windowIndex = activeWindowIndex.value
+  const requestId = beginWindowPreviewRequest(windowIndex)
+  clearPreviewError()
+
+  try {
+    // SDK 初始化和后端预览地址互不依赖,可以并行等待以缩短首次点播耗时。
+    const [currentPlayer, previewUrl] = await Promise.all([
+      initPlayer(),
+      HikvisionApi.getPreviewUrl(camera.indexCode)
+    ])
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    if (!previewUrl) throw new Error('预览地址为空')
+
+    // keepDecoder 为 0 时切换监控点前先停止旧流,确保旧解码资源得到回收。
+    // 替换摄像机前停止旧监控点的云台动作,停止请求必须使用旧 indexCode。
+    await stopActivePtzControl(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    await stopWindowTalk(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    await stopWindowZoom(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    await currentPlayer.JS_Stop(windowIndex).catch(() => undefined)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    clearWindowSoundState(windowIndex)
+    setWindowPreviewSelected(windowIndex, false)
+    setWindowInterrupted(windowIndex, false)
+    setWindowPreviewCamera(windowIndex, camera)
+    setWindowPreviewStreamType(windowIndex, 0)
+
+    // 在调用 JS_Play 前记录阶段,使首帧、断流和错误回调能够识别当前新流。
+    previewPlayingWindowRequests.set(windowIndex, requestId)
+    await currentPlayer.JS_Play(
+      previewUrl,
+      {
+        playURL: previewUrl,
+        mode: 0,
+        keepDecoder: 0
+      },
+      windowIndex
+    )
+    // 这里不关闭 loading;只有 firstFrameDisplay、错误或断流回调才能结束窗口 loading。
+  } catch (error) {
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    clearWindowPreview(windowIndex)
+    console.error('监控点预览失败', error)
+    showPreviewError(error instanceof Error ? error.message : '获取预览地址或播放失败')
+  }
+}
+
+/** 获取另一种码流地址,并在原窗口停止旧流后重新播放。 */
+async function switchWindowStream(windowIndex: number) {
+  const camera = previewWindowCameras.value[windowIndex]
+  if (!camera) return
+
+  const nextStreamType: HikvisionStreamType = getWindowStreamType(windowIndex) === 0 ? 1 : 0
+  const requestId = beginWindowPreviewRequest(windowIndex)
+  let stopped = false
+  clearPreviewError()
+
+  try {
+    const [currentPlayer, previewUrl] = await Promise.all([
+      initPlayer(),
+      HikvisionApi.getPreviewUrl(camera.indexCode, nextStreamType)
+    ])
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    if (!previewUrl) throw new Error('切换码流的预览地址为空')
+
+    // 主、子码流编码格式可能不同,先停止并回收旧解码器,再使用新地址播放最稳妥。
+    // 切换码流期间关闭持续云台动作,避免画面重载时失去方向按钮的释放事件。
+    await stopActivePtzControl(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    await stopWindowTalk(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    await stopWindowZoom(windowIndex)
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    await currentPlayer.JS_Stop(windowIndex)
+    stopped = true
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    clearWindowSoundState(windowIndex)
+
+    setWindowPreviewSelected(windowIndex, false)
+    setWindowInterrupted(windowIndex, false)
+    setWindowPreviewStreamType(windowIndex, nextStreamType)
+    previewPlayingWindowRequests.set(windowIndex, requestId)
+    await currentPlayer.JS_Play(
+      previewUrl,
+      {
+        playURL: previewUrl,
+        mode: 0,
+        keepDecoder: 0
+      },
+      windowIndex
+    )
+    // 与首次预览相同,等待 firstFrameDisplay 后再结束 loading。
+  } catch (error) {
+    if (!isCurrentWindowPreviewRequest(windowIndex, requestId)) return
+    if (stopped) {
+      clearWindowPreview(windowIndex)
+    } else {
+      cancelWindowPreviewRequest(windowIndex)
+    }
+    console.error('切换监控码流失败', error)
+    showPreviewError(error instanceof Error ? error.message : '切换监控码流失败')
+  }
+}
+
+function createCaptureFileName(camera: HikvisionCameraNode) {
+  const safeCameraName = camera.name.replace(/[\\/:*?"<>|]/g, '_').trim() || camera.indexCode
+  const now = new Date()
+  const pad = (value: number) => String(value).padStart(2, '0')
+  const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
+    now.getHours()
+  )}${pad(now.getMinutes())}${pad(now.getSeconds())}`
+  return `${safeCameraName}_${timestamp}`
+}
+
+/** 使用 SDK 抓取指定窗口当前帧,并直接下载为 JPEG 文件。 */
+async function captureWindowPicture(windowIndex: number, notify = true): Promise<boolean> {
+  const camera = previewWindowCameras.value[windowIndex]
+  const currentPlayer = player
+  if (!camera || !currentPlayer) return false
+  if (
+    previewLoadingWindowIndexes.value.includes(windowIndex) ||
+    !previewWindowIndexes.value.includes(windowIndex)
+  ) {
+    if (notify) showPreviewError('请等待监控画面首帧显示后再抓图')
+    return false
+  }
+  if (interruptedWindowIndexes.value.includes(windowIndex)) {
+    if (notify) showPreviewError('监控画面已中断,暂时无法抓图')
+    return false
+  }
+  if (capturingWindowIndexes.has(windowIndex)) return false
+
+  capturingWindowIndexes.add(windowIndex)
+  clearPreviewError()
+  try {
+    await currentPlayer.JS_CapturePicture(windowIndex, createCaptureFileName(camera), 'JPEG')
+    if (notify) ElMessage.success('抓图已保存到本地')
+    return true
+  } catch (error) {
+    console.error('监控画面抓图失败', error)
+    if (notify) showPreviewError(error instanceof Error ? error.message : '监控画面抓图失败')
+    return false
+  } finally {
+    capturingWindowIndexes.delete(windowIndex)
+  }
+}
+
+/** 对当前分屏内所有可抓图窗口逐一调用 SDK,并汇总本次下载结果。 */
+async function captureAllWindowPictures() {
+  if (captureAllLoading.value) return
+
+  const previewingWindowIndexes = currentSplitWindowIndexes.value.filter(
+    (windowIndex) =>
+      previewWindowCameras.value[windowIndex] && previewWindowIndexes.value.includes(windowIndex)
+  )
+  if (!previewingWindowIndexes.length) {
+    ElMessage.warning('当前没有可抓图的监控画面')
+    return
+  }
+
+  captureAllLoading.value = true
+  clearPreviewError()
+  try {
+    const results = await Promise.all(
+      previewingWindowIndexes.map((windowIndex) => captureWindowPicture(windowIndex, false))
+    )
+    const successCount = results.filter(Boolean).length
+    const failedCount = results.length - successCount
+    if (!successCount) {
+      ElMessage.error('全部抓图失败,请确认监控画面已正常显示')
+    } else if (failedCount) {
+      ElMessage.warning(`已保存 ${successCount} 张,${failedCount} 个画面抓图失败`)
+    } else {
+      ElMessage.success(`已完成全部抓图,共保存 ${successCount} 张`)
+    }
+  } finally {
+    captureAllLoading.value = false
+  }
+}
+
+/**
+ * 切换指定窗口的声音。SDK 同一实例只支持一路声音,开启新窗口时会自动关闭旧窗口。
+ */
+async function toggleWindowSound(windowIndex: number, notify = true): Promise<boolean> {
+  if (soundControlLoading.value) return false
+
+  const currentPlayer = player
+  const soundEnabled = isWindowSoundEnabled(windowIndex)
+  if (!currentPlayer) return false
+  if (
+    !soundEnabled &&
+    (previewLoadingWindowIndexes.value.includes(windowIndex) ||
+      !previewWindowIndexes.value.includes(windowIndex) ||
+      interruptedWindowIndexes.value.includes(windowIndex))
+  ) {
+    if (notify) showPreviewError('请等待监控画面正常显示后再开启声音')
+    return false
+  }
+
+  soundControlLoading.value = true
+  clearPreviewError()
+  try {
+    if (soundEnabled) {
+      await currentPlayer.JS_CloseSound(windowIndex)
+      clearWindowSoundState(windowIndex)
+      if (notify) ElMessage.success('声音已关闭')
+    } else {
+      await currentPlayer.JS_OpenSound(windowIndex)
+      soundWindowIndex.value = windowIndex
+      if (notify) ElMessage.success('声音已开启')
+    }
+    return true
+  } catch (error) {
+    console.error('切换监控声音失败', error)
+    if (notify) showPreviewError(error instanceof Error ? error.message : '切换监控声音失败')
+    return false
+  } finally {
+    soundControlLoading.value = false
+  }
+}
+
+/** 全局声音按钮:有声时全部静音,无声时开启当前选中或首个可播放窗口。 */
+async function toggleGlobalSound() {
+  if (soundControlLoading.value) return
+  if (soundWindowIndex.value !== undefined) {
+    await toggleWindowSound(soundWindowIndex.value)
+    return
+  }
+
+  const targetWindowIndex = currentSplitWindowIndexes.value.find(
+    (windowIndex) =>
+      windowIndex === activeWindowIndex.value &&
+      previewWindowIndexes.value.includes(windowIndex) &&
+      !previewLoadingWindowIndexes.value.includes(windowIndex) &&
+      !interruptedWindowIndexes.value.includes(windowIndex)
+  )
+  const fallbackWindowIndex = currentSplitWindowIndexes.value.find(
+    (windowIndex) =>
+      previewWindowIndexes.value.includes(windowIndex) &&
+      !previewLoadingWindowIndexes.value.includes(windowIndex) &&
+      !interruptedWindowIndexes.value.includes(windowIndex)
+  )
+  const windowIndex = targetWindowIndex ?? fallbackWindowIndex
+  if (windowIndex === undefined) {
+    ElMessage.warning('当前没有可开启声音的监控画面')
+    return
+  }
+  await toggleWindowSound(windowIndex)
+}
+
+/**
+ * 停止指定窗口关联的对讲。对讲没有窗口参数,因此页面必须记录当前归属窗口并统一清理。
+ */
+async function stopWindowTalk(windowIndex: number, notify = false): Promise<boolean> {
+  const isPending = talkTargetWindowIndex.value === windowIndex
+  const isTalking = talkWindowIndex.value === windowIndex
+  if (!isPending && !isTalking) return true
+
+  const operationId = ++talkOperationId
+  talkTargetWindowIndex.value = undefined
+  talkControlLoading.value = isTalking
+  if (!isTalking) return true
+
+  try {
+    await player?.JS_StopTalk()
+    if (operationId !== talkOperationId) return false
+    talkWindowIndex.value = undefined
+    if (notify) ElMessage.success('对讲已停止')
+    return true
+  } catch (error) {
+    console.error('停止监控对讲失败', error)
+    if (notify) showPreviewError(error instanceof Error ? error.message : '停止对讲失败')
+    return false
+  } finally {
+    if (operationId === talkOperationId) talkControlLoading.value = false
+  }
+}
+
+/** 获取对讲地址并开启麦克风;切换窗口前先停止实例中已有的一路对讲。 */
+async function toggleWindowTalk(windowIndex: number) {
+  if (talkControlLoading.value) return
+  if (isWindowTalking(windowIndex)) {
+    await stopWindowTalk(windowIndex, true)
+    return
+  }
+
+  const camera = previewWindowCameras.value[windowIndex]
+  const currentPlayer = player
+  if (!camera || !currentPlayer) return
+  if (!window.isSecureContext) {
+    showPreviewError('对讲功能仅支持 HTTPS 安全环境')
+    return
+  }
+  if (
+    previewLoadingWindowIndexes.value.includes(windowIndex) ||
+    !previewWindowIndexes.value.includes(windowIndex) ||
+    interruptedWindowIndexes.value.includes(windowIndex)
+  ) {
+    showPreviewError('请等待监控画面正常显示后再开启对讲')
+    return
+  }
+
+  const operationId = ++talkOperationId
+  talkTargetWindowIndex.value = windowIndex
+  talkControlLoading.value = true
+  clearPreviewError()
+  try {
+    const talkUrl = await HikvisionApi.getTalkUrl(camera.indexCode)
+    if (operationId !== talkOperationId) return
+    if (!talkUrl) throw new Error('对讲地址为空')
+
+    if (talkWindowIndex.value !== undefined) {
+      await currentPlayer.JS_StopTalk()
+      if (operationId !== talkOperationId) return
+      talkWindowIndex.value = undefined
+    }
+
+    await currentPlayer.JS_StartTalk(talkUrl)
+    // 视口可能在 SDK 请求麦克风权限期间被关闭;若任务已失效,立即回收刚开启的对讲。
+    if (operationId !== talkOperationId) {
+      await currentPlayer.JS_StopTalk().catch(() => undefined)
+      return
+    }
+    talkWindowIndex.value = windowIndex
+    ElMessage.success('对讲已开启')
+  } catch (error) {
+    if (operationId !== talkOperationId) return
+    console.error('开启监控对讲失败', error)
+    showPreviewError(error instanceof Error ? error.message : '开启对讲失败,请检查麦克风权限')
+  } finally {
+    if (operationId === talkOperationId) {
+      talkTargetWindowIndex.value = undefined
+      talkControlLoading.value = false
+    }
+  }
+}
+
+/** 关闭指定窗口的电子放大,并使尚未完成的旧开启任务失效。 */
+async function stopWindowZoom(windowIndex: number): Promise<boolean> {
+  const hasPendingOperation = zoomWindowOperations.has(windowIndex)
+  if (!hasPendingOperation && !isWindowZoomEnabled(windowIndex)) return true
+
+  const operationId = ++nextZoomOperationId
+  zoomWindowOperations.set(windowIndex, operationId)
+  setWindowZoomLoading(windowIndex, true)
+  try {
+    await player?.JS_DisableZoom(windowIndex)
+    if (zoomWindowOperations.get(windowIndex) !== operationId) return false
+    setWindowZoomEnabled(windowIndex, false)
+    return true
+  } catch (error) {
+    console.error('关闭电子放大失败', error)
+    return false
+  } finally {
+    if (zoomWindowOperations.get(windowIndex) === operationId) {
+      zoomWindowOperations.delete(windowIndex)
+      setWindowZoomLoading(windowIndex, false)
+    }
+  }
+}
+
+/** 切换指定窗口的电子放大,窗口之间互不影响。 */
+async function toggleWindowZoom(windowIndex: number) {
+  if (zoomLoadingWindowIndexes.value.includes(windowIndex)) return
+
+  const currentPlayer = player
+  if (!currentPlayer) return
+  if (!window.isSecureContext) {
+    showPreviewError('电子放大仅支持 HTTPS 安全环境')
+    return
+  }
+  if (
+    previewLoadingWindowIndexes.value.includes(windowIndex) ||
+    !previewWindowIndexes.value.includes(windowIndex) ||
+    interruptedWindowIndexes.value.includes(windowIndex)
+  ) {
+    showPreviewError('请等待监控画面正常显示后再开启电子放大')
+    return
+  }
+
+  const enableZoom = !isWindowZoomEnabled(windowIndex)
+  const operationId = ++nextZoomOperationId
+  zoomWindowOperations.set(windowIndex, operationId)
+  setWindowZoomLoading(windowIndex, true)
+  clearPreviewError()
+  try {
+    if (enableZoom) {
+      await currentPlayer.JS_EnableZoom(windowIndex)
+    } else {
+      await currentPlayer.JS_DisableZoom(windowIndex)
+    }
+
+    if (zoomWindowOperations.get(windowIndex) !== operationId) {
+      if (enableZoom) await currentPlayer.JS_DisableZoom(windowIndex).catch(() => undefined)
+      return
+    }
+    setWindowZoomEnabled(windowIndex, enableZoom)
+    ElMessage.success(enableZoom ? '电子放大已开启,请在画面中框选区域' : '电子放大已关闭')
+  } catch (error) {
+    if (zoomWindowOperations.get(windowIndex) !== operationId) return
+    console.error('切换电子放大失败', error)
+    showPreviewError(error instanceof Error ? error.message : '切换电子放大失败')
+  } finally {
+    if (zoomWindowOperations.get(windowIndex) === operationId) {
+      zoomWindowOperations.delete(windowIndex)
+      setWindowZoomLoading(windowIndex, false)
+    }
+  }
+}
+
+/** 按开发指南停止指定窗口的播放,并仅清理该窗口对应的前端状态。 */
+async function stopWindowPreview(windowIndex: number) {
+  if (stoppingWindowIndexes.has(windowIndex)) return
+
+  stoppingWindowIndexes.add(windowIndex)
+  // 先使该窗口尚未完成的异步预览失效,避免关闭后旧请求继续调用 JS_Play。
+  cancelWindowPreviewRequest(windowIndex)
+  clearPreviewError()
+
+  try {
+    // 优先停止云台,防止后续播放器停止耗时期间摄像机仍持续转动。
+    await stopActivePtzControl(windowIndex)
+    await stopWindowTalk(windowIndex)
+    await stopWindowZoom(windowIndex)
+    await player?.JS_Stop(windowIndex)
+    clearWindowPreview(windowIndex)
+  } catch (error) {
+    console.error('关闭监控画面失败', error)
+    showPreviewError(error instanceof Error ? error.message : '关闭监控画面失败')
+  } finally {
+    stoppingWindowIndexes.delete(windowIndex)
+  }
+}
+
+async function handleSplitChange(count: SplitCount, columns: HikvisionSplitColumns) {
+  splitPopoverVisible.value = false
+  if (splitControlLoading.value || currentSplitCount.value === count) return
+  if (stopAllLoading.value) {
+    showPreviewError('正在关闭全部监控画面,请稍后切换分屏')
+    return
+  }
+
+  splitControlLoading.value = true
+  try {
+    const currentPlayer = await initPlayer()
+
+    // 从大分屏切换到小分屏时,先计算将被隐藏的窗口,例如 9 分屏切到 4 分屏会清理 4~8。
+    const hiddenWindowIndexes = Array.from(
+      { length: Math.max(currentSplitCount.value - count, 0) },
+      (_, index) => count + index
+    )
+    const hiddenTalkWindowIndex = hiddenWindowIndexes.find(
+      (windowIndex) =>
+        talkWindowIndex.value === windowIndex || talkTargetWindowIndex.value === windowIndex
+    )
+    const hiddenPtzWindowIndex = hiddenWindowIndexes.find(
+      (windowIndex) => activePtzWindowIndex.value === windowIndex
+    )
+    // 缩小分屏会卸载隐藏视口的按钮,因此必须在清理视口前主动停止云台。
+    if (hiddenPtzWindowIndex !== undefined) await stopActivePtzControl(hiddenPtzWindowIndex)
+    if (hiddenTalkWindowIndex !== undefined) await stopWindowTalk(hiddenTalkWindowIndex)
+    await Promise.all(hiddenWindowIndexes.map((windowIndex) => stopWindowZoom(windowIndex)))
+    await Promise.all(
+      hiddenWindowIndexes.map((windowIndex) =>
+        currentPlayer.JS_Stop(windowIndex).catch(() => undefined)
+      )
+    )
+    // SDK 流和页面状态必须同时清理,避免隐藏窗口继续占用连接及解码资源。
+    hiddenWindowIndexes.forEach(clearWindowPreview)
+    await currentPlayer.JS_ArrangeWindow(columns)
+    currentSplitCount.value = count
+    activeWindowIndex.value = 0
+
+    // 分屏后显式同步 SDK 的选中窗口,保证黄色边框和页面 activeWindowIndex 一致。
+    await currentPlayer.JS_SelectWnd(0).catch(() => undefined)
+    await nextTick()
+    schedulePlayerResize()
+  } catch (error) {
+    console.error('切换监控分屏失败', error)
+    showPreviewError(error instanceof Error ? error.message : '切换分屏失败')
+  } finally {
+    splitControlLoading.value = false
+  }
+}
+
+function handleNodeClick(data: HikvisionTreeNode) {
+  if (data.nodeType !== 'camera') return
+
+  // 已知离线或不可用的监控点直接提示,不请求后端预览地址,也不影响当前正在播放的画面。
+  if (data.available === false || data.onlineStatus === false) {
+    showPreviewError(`${data.name} 不在线,无法预览`)
+    return
+  }
+  void playCameraPreview(data)
+}
+
+async function resizePlayer() {
+  playerResizeFrame = undefined
+  const currentPlayer = player
+  if (!currentPlayer) return
+
+  // ResizeObserver 在侧栏动画期间可能连续触发。SDK resize 尚未结束时只保留一次尾调用,
+  // 避免多个 WASM/Canvas 尺寸计算并发堆积。
+  if (playerResizeInProgress) {
+    playerResizePending = true
+    return
+  }
+
+  const playerContainer = playerContainerRef.value
+  if (!playerContainer) {
+    return
+  }
+
+  const { clientWidth, clientHeight } = playerContainer
+  if (!clientWidth || !clientHeight) return
+  playerResizeInProgress = true
+  try {
+    await currentPlayer.JS_Resize(clientWidth, clientHeight)
+    syncSingleWindowPlayerLayout()
+  } catch {
+    // 容器变化和播放器销毁可能发生在同一帧,尺寸同步失败无需打断预览。
+  } finally {
+    playerResizeInProgress = false
+    if (playerResizePending && !componentUnmounted) {
+      playerResizePending = false
+      schedulePlayerResize()
+    }
+  }
+}
+
+/** 合并同一帧内的尺寸变化,避免侧栏动画或布局变化时连续调用昂贵的 SDK resize。 */
+function schedulePlayerResize() {
+  if (playerResizeFrame !== undefined) cancelAnimationFrame(playerResizeFrame)
+  playerResizeFrame = requestAnimationFrame(resizePlayer)
+}
+
+async function stopAllPreviews() {
+  if (stopAllLoading.value) return
+  if (splitControlLoading.value) {
+    showPreviewError('正在切换分屏,请稍后关闭全部画面')
+    return
+  }
+
+  // 先让所有未完成的接口请求失效,防止停止完成后某个旧请求又调用 JS_Play。
+  cancelAllWindowPreviewRequests()
+  clearPreviewError()
+
+  stopAllLoading.value = true
+  try {
+    // 云台属于设备侧持续动作,应先于播放器媒体资源停止。
+    await stopActivePtzControl()
+    const currentTalkWindowIndex = talkWindowIndex.value ?? talkTargetWindowIndex.value
+    if (currentTalkWindowIndex !== undefined) await stopWindowTalk(currentTalkWindowIndex)
+    const zoomWindowIndexesToStop = [
+      ...new Set([...zoomWindowIndexes.value, ...zoomWindowOperations.keys()])
+    ]
+    await Promise.all(zoomWindowIndexesToStop.map((windowIndex) => stopWindowZoom(windowIndex)))
+    await player?.JS_StopRealPlayAll()
+    clearWindowPreviewSelected()
+    activeWindowIndex.value = 0
+  } catch (error) {
+    console.error('关闭全部监控画面失败', error)
+    showPreviewError(error instanceof Error ? error.message : '关闭全部监控画面失败')
+  } finally {
+    stopAllLoading.value = false
+  }
+}
+
+/**
+ * H5Player 的 JS_FullScreenSingle 会只全屏 SDK 内部节点,使 Vue 悬浮操作层再次被浏览器排除。
+ * 因此仍由 video-stage 承载原生全屏,只把被双击的 SDK 子窗口铺满播放器容器。
+ */
+function syncSingleWindowPlayerLayout() {
+  const targetWindowIndex = stageFullscreen.value ? singleFullscreenWindowIndex.value : undefined
+  const playerWindows = playerContainerRef.value?.querySelectorAll<HTMLElement>('.sub-wnd') ?? []
+
+  playerWindows.forEach((playerWindow, windowIndex) => {
+    playerWindow.classList.toggle('is-single-fullscreen-target', targetWindowIndex === windowIndex)
+  })
+}
+
+async function handlePlayerDoubleClick() {
+  if (fullscreenControlLoading.value) return
+
+  // H5Player 会先通过 windowEventSelect 回调同步当前选中窗口,双击只消费 SDK 给出的下标,
+  // 不根据坐标重复推算分屏位置,避免边框、缩放或 SDK 布局调整造成误判。
+  const windowIndex = activeWindowIndex.value
+  // 空视口没有可放大的监控画面,也不应改变当前全屏状态。
+  if (!previewWindowCameras.value[windowIndex]) return
+
+  const videoStage = videoStageRef.value
+  if (!videoStage) return
+
+  fullscreenControlLoading.value = true
+  try {
+    // 已经处于单视口全屏时,再次双击等同于退出全屏。
+    if (
+      document.fullscreenElement === videoStage &&
+      singleFullscreenWindowIndex.value !== undefined
+    ) {
+      await document.exitFullscreen()
+      return
+    }
+
+    activeWindowIndex.value = windowIndex
+    singleFullscreenWindowIndex.value = windowIndex
+
+    if (document.fullscreenElement === videoStage) {
+      // 从整体分屏全屏切换到单视口时无需再次请求 Fullscreen API,只更新内部布局。
+      await nextTick()
+      syncSingleWindowPlayerLayout()
+      schedulePlayerResize()
+    } else {
+      await videoStage.requestFullscreen()
+    }
+  } catch (error) {
+    singleFullscreenWindowIndex.value = undefined
+    syncSingleWindowPlayerLayout()
+    console.error('切换单视口全屏失败', error)
+    showPreviewError(error instanceof Error ? error.message : '切换单视口全屏失败')
+  } finally {
+    fullscreenControlLoading.value = false
+  }
+}
+
+async function toggleFullscreen() {
+  if (fullscreenControlLoading.value) return
+
+  const videoStage = videoStageRef.value
+  if (!videoStage) {
+    showPreviewError('视频区域尚未加载完成')
+    return
+  }
+
+  fullscreenControlLoading.value = true
+  try {
+    // 浏览器全屏只展示目标元素及其后代。video-stage 同时包含 SDK 播放器、Vue 悬浮层和底部工具栏,
+    // 因而全屏后 windowEventOver/Out 仍能驱动原有悬浮状态,按钮也仍可接收指针事件。
+    if (document.fullscreenElement === videoStage) {
+      await document.exitFullscreen()
+    } else {
+      // 工具栏的全屏按钮保持“整体分屏全屏”语义,只有双击视口才设置单窗口下标。
+      singleFullscreenWindowIndex.value = undefined
+      await videoStage.requestFullscreen()
+    }
+  } catch (error) {
+    console.error('切换监控全屏失败', error)
+    showPreviewError(error instanceof Error ? error.message : '切换全屏失败')
+  } finally {
+    fullscreenControlLoading.value = false
+  }
+}
+
+function handleFullscreenChange() {
+  // Esc、浏览器菜单等都可能退出全屏,必须以 Fullscreen API 的真实状态同步按钮图标。
+  stageFullscreen.value = document.fullscreenElement === videoStageRef.value
+  if (!stageFullscreen.value) singleFullscreenWindowIndex.value = undefined
+  void nextTick(() => {
+    syncSingleWindowPlayerLayout()
+    schedulePlayerResize()
+  })
+}
+
+async function toggleSidebar() {
+  sidebarCollapsed.value = !sidebarCollapsed.value
+  await nextTick()
+  schedulePlayerResize()
+}
+
+async function destroyPlayer() {
+  // 卸载后不再接收异步请求结果、错误定时器或任何尺寸变化通知。
+  cancelAllWindowPreviewRequests()
+  clearPreviewErrorTimer()
+  window.removeEventListener('resize', schedulePlayerResize)
+  playerResizeObserver?.disconnect()
+  playerResizeObserver = undefined
+  if (playerResizeFrame !== undefined) cancelAnimationFrame(playerResizeFrame)
+  playerResizeFrame = undefined
+  playerResizePending = false
+  if (!player) return
+
+  const currentPlayer = player
+  const zoomWindowIndexesToStop = [
+    ...new Set([...zoomWindowIndexes.value, ...zoomWindowOperations.keys()])
+  ]
+  await Promise.all(zoomWindowIndexesToStop.map((windowIndex) => stopWindowZoom(windowIndex)))
+  player = undefined
+
+  // 多分屏场景必须停止全部实时流;只停止窗口 0 会遗留其他窗口连接。
+  await currentPlayer.JS_StopTalk().catch(() => undefined)
+  await currentPlayer.JS_StopRealPlayAll().catch(() => undefined)
+  await Promise.resolve(currentPlayer.JS_Destroy()).catch(() => undefined)
+}
+
+function handleTreeSearch(event: KeyboardEvent) {
+  // 中文输入法确认候选词时的回车不提交搜索。
+  if (event.isComposing || event.keyCode === 229) return
+  void loadRootTree(keyword.value.trim())
+}
+
+watch(keyword, (value, previousValue) => {
+  // 输入文字不请求;点击清空或手动删空时立即恢复初始树。
+  if (!value.trim() && previousValue.trim()) void loadRootTree()
+})
+
+onMounted(() => {
+  componentUnmounted = false
+  window.addEventListener('resize', schedulePlayerResize)
+  document.addEventListener('fullscreenchange', handleFullscreenChange)
+  if (playerContainerRef.value) {
+    // window.resize 无法覆盖侧栏折叠等局部布局变化,因此额外观察播放器容器本身。
+    playerResizeObserver = new ResizeObserver(schedulePlayerResize)
+    playerResizeObserver.observe(playerContainerRef.value)
+  }
+  void loadRootTree()
+})
+
+onBeforeUnmount(() => {
+  componentUnmounted = true
+  treeRequestId += 1
+  document.removeEventListener('fullscreenchange', handleFullscreenChange)
+  // 页面离开时补发停止;即使开始请求仍在途中,停止流程也会等待并保持正确顺序。
+  void stopActivePtzControl()
+  void destroyPlayer()
+})
+</script>
+<template>
+  <div
+    class="hikvision-shell h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <aside class="monitor-panel" :class="{ 'is-collapsed': sidebarCollapsed }">
+      <div class="panel-title">
+        <div>
+          <div class="eyebrow">MONITOR DIRECTORY</div>
+          <h2>监控点目录</h2>
+        </div>
+      </div>
+
+      <el-input
+        v-model="keyword"
+        :prefix-icon="Search"
+        clearable
+        placeholder="输入监控点名称,回车搜索"
+        @keydown.enter="handleTreeSearch" />
+
+      <div v-loading="treeLoading" class="monitor-tree-wrap">
+        <el-tree
+          v-if="!treeLoading"
+          :key="treeVersion"
+          class="monitor-tree"
+          :data="treeData"
+          :props="treeProps"
+          :load="loadTreeNode"
+          :default-expanded-keys="expandedKeys"
+          node-key="indexCode"
+          :lazy="!treeSearchMode"
+          :default-expand-all="treeSearchMode"
+          highlight-current
+          :expand-on-click-node="false"
+          empty-text="暂无监控点"
+          @node-click="handleNodeClick">
+          <template #default="{ data }">
+            <div
+              class="tree-node"
+              :class="{
+                'is-camera': data.nodeType === 'camera',
+                'is-unavailable': data.available === false
+              }">
+              <el-icon class="node-icon">
+                <Location v-if="data.nodeType === 'region'" />
+                <VideoCamera v-else />
+              </el-icon>
+              <span class="node-name" :title="data.name">{{ data.name }}</span>
+              <span
+                v-if="data.nodeType === 'camera'"
+                class="status-dot"
+                :class="{
+                  online: data.onlineStatus === true,
+                  offline: data.onlineStatus === false
+                }"
+                :title="
+                  data.onlineStatus === true
+                    ? '在线'
+                    : data.onlineStatus === false
+                      ? '离线'
+                      : '状态未知'
+                "></span>
+            </div>
+          </template>
+        </el-tree>
+      </div>
+
+      <div class="panel-tip">搜索仅筛选当前已加载的节点</div>
+    </aside>
+
+    <main class="video-workspace">
+      <section
+        ref="videoStageRef"
+        class="video-stage"
+        :class="{
+          'is-single-window-fullscreen':
+            stageFullscreen && singleFullscreenWindowIndex !== undefined
+        }">
+        <div
+          :id="playerContainerId"
+          ref="playerContainerRef"
+          class="player-container"
+          @dblclick.capture="handlePlayerDoubleClick"></div>
+        <div
+          class="window-loading-layer"
+          :style="{
+            gridTemplateColumns: `repeat(${currentSplitColumns}, minmax(0, 1fr))`,
+            gridTemplateRows: `repeat(${currentSplitRows}, minmax(0, 1fr))`
+          }">
+          <div
+            v-for="windowIndex in currentSplitWindowIndexes"
+            :key="windowIndex"
+            class="window-loading-cell"
+            :class="{
+              'is-active': activeWindowIndex === windowIndex,
+              'is-single-fullscreen-target': singleFullscreenWindowIndex === windowIndex,
+              'is-hovered':
+                hoveredWindowIndex === windowIndex ||
+                hoveredControlWindowIndex === windowIndex ||
+                (ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex)
+            }">
+            <div v-if="previewWindowCameras[windowIndex]" class="window-hover-title">
+              <span class="window-title-main">
+                <i class="i-lucide:video size-4"></i>
+                <span>{{ getWindowPreviewTitle(windowIndex) }}</span>
+              </span>
+              <span class="window-title-status">
+                {{ getWindowPreviewStatus(windowIndex) }}
+              </span>
+              <button
+                class="window-close-button"
+                type="button"
+                title="关闭预览"
+                aria-label="关闭预览"
+                @mouseenter="hoveredControlWindowIndex = windowIndex"
+                @mouseleave="hoveredControlWindowIndex = undefined"
+                @focus="hoveredControlWindowIndex = windowIndex"
+                @blur="hoveredControlWindowIndex = undefined"
+                @click.stop="stopWindowPreview(windowIndex)">
+                <i class="i-lucide:circle-x size-4"></i>
+              </button>
+            </div>
+            <div
+              v-if="previewLoadingWindowIndexes.includes(windowIndex)"
+              class="window-loading-indicator">
+              <el-icon class="is-loading"><Loading /></el-icon>
+            </div>
+            <div
+              v-else-if="!previewWindowIndexes.includes(windowIndex)"
+              class="window-empty-indicator">
+              <el-icon><VideoCamera /></el-icon>
+              <span>暂无监控</span>
+            </div>
+            <div
+              v-if="previewWindowCameras[windowIndex]"
+              class="window-hover-actions"
+              @mouseenter="hoveredControlWindowIndex = windowIndex"
+              @mouseleave="hoveredControlWindowIndex = undefined">
+              <el-tooltip
+                :content="getWindowStreamSwitchTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
+                <button
+                  class="window-action-button"
+                  type="button"
+                  :aria-label="getWindowStreamSwitchTitle(windowIndex)"
+                  @focus="hoveredControlWindowIndex = windowIndex"
+                  @blur="hoveredControlWindowIndex = undefined"
+                  @click.stop="switchWindowStream(windowIndex)">
+                  <i class="i-lucide:refresh-cw"></i>
+                </button>
+              </el-tooltip>
+              <el-tooltip content="抓取当前画面" placement="top" :teleported="false">
+                <button
+                  class="window-action-button"
+                  type="button"
+                  aria-label="抓取当前画面"
+                  @focus="hoveredControlWindowIndex = windowIndex"
+                  @blur="hoveredControlWindowIndex = undefined"
+                  @click.stop="captureWindowPicture(windowIndex)">
+                  <i class="i-lucide:camera"></i>
+                </button>
+              </el-tooltip>
+              <el-tooltip
+                :content="getWindowZoomTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
+                <button
+                  class="window-action-button"
+                  :class="{ 'is-zooming': isWindowZoomEnabled(windowIndex) }"
+                  type="button"
+                  :aria-label="getWindowZoomTitle(windowIndex)"
+                  :aria-pressed="isWindowZoomEnabled(windowIndex)"
+                  :disabled="zoomLoadingWindowIndexes.includes(windowIndex)"
+                  @focus="hoveredControlWindowIndex = windowIndex"
+                  @blur="hoveredControlWindowIndex = undefined"
+                  @click.stop="toggleWindowZoom(windowIndex)">
+                  <i
+                    v-if="zoomLoadingWindowIndexes.includes(windowIndex)"
+                    class="i-lucide:loader-circle animate-spin"></i>
+                  <i
+                    v-else
+                    :class="
+                      isWindowZoomEnabled(windowIndex) ? 'i-lucide:zoom-out' : 'i-lucide:zoom-in'
+                    "></i>
+                </button>
+              </el-tooltip>
+              <el-tooltip
+                :content="getWindowSoundTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
+                <button
+                  class="window-action-button"
+                  :class="{ active: isWindowSoundEnabled(windowIndex) }"
+                  type="button"
+                  :aria-label="getWindowSoundTitle(windowIndex)"
+                  :aria-pressed="isWindowSoundEnabled(windowIndex)"
+                  :disabled="soundControlLoading"
+                  @focus="hoveredControlWindowIndex = windowIndex"
+                  @blur="hoveredControlWindowIndex = undefined"
+                  @click.stop="toggleWindowSound(windowIndex)">
+                  <i
+                    :class="
+                      isWindowSoundEnabled(windowIndex) ? 'i-lucide:volume-2' : 'i-lucide:volume-x'
+                    "></i>
+                </button>
+              </el-tooltip>
+              <el-tooltip
+                :content="getWindowTalkTitle(windowIndex)"
+                placement="top"
+                :teleported="false">
+                <button
+                  class="window-action-button"
+                  :class="{ 'is-talking': isWindowTalking(windowIndex) }"
+                  type="button"
+                  :aria-label="getWindowTalkTitle(windowIndex)"
+                  :aria-pressed="isWindowTalking(windowIndex)"
+                  :disabled="talkControlLoading"
+                  @focus="hoveredControlWindowIndex = windowIndex"
+                  @blur="hoveredControlWindowIndex = undefined"
+                  @click.stop="toggleWindowTalk(windowIndex)">
+                  <i
+                    v-if="
+                      talkControlLoading &&
+                      (talkTargetWindowIndex === windowIndex || talkWindowIndex === windowIndex)
+                    "
+                    class="i-lucide:loader-circle animate-spin"></i>
+                  <i
+                    v-else
+                    :class="isWindowTalking(windowIndex) ? 'i-lucide:mic' : 'i-lucide:mic-off'"></i>
+                </button>
+              </el-tooltip>
+              <!--
+                云台按钮与弹层是一一对应关系,直接使用 reference 插槽触发,无需 virtual-ref。
+                show/hide 事件只负责同步所属窗口和执行停止兜底,方向控制由内部按钮的指针事件完成。
+              -->
+              <el-popover
+                placement="top-end"
+                trigger="click"
+                :width="154"
+                :show-arrow="false"
+                :teleported="false"
+                popper-class="hikvision-ptz-popover"
+                @show="handleWindowPtzPopoverShow(windowIndex)"
+                @hide="handleWindowPtzPopoverHide(windowIndex)">
+                <!-- 操作栏最右侧的云台入口;弹层打开时 is-ptz-open 提供高亮反馈。 -->
+                <template #reference>
+                  <button
+                    class="window-action-button"
+                    :class="{
+                      'is-ptz-open': ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex
+                    }"
+                    type="button"
+                    aria-label="打开云台控制"
+                    :aria-expanded="ptzPopoverVisible && ptzPopoverWindowIndex === windowIndex"
+                    @focus="hoveredControlWindowIndex = windowIndex"
+                    @blur="hoveredControlWindowIndex = undefined">
+                    <i class="i-lucide:move"></i>
+                  </button>
+                </template>
+                <!-- 弹层标题与八方向控制盘。中间图标只做视觉定位,不会发送接口请求。 -->
+                <div class="ptz-popover-content">
+                  <div class="ptz-control-title">
+                    <i class="i-lucide:move"></i>
+                    <span>云台控制</span>
+                  </div>
+                  <div
+                    class="ptz-control-panel"
+                    role="group"
+                    :aria-label="`视口 ${windowIndex + 1} 云台方向控制`">
+                    <!--
+                      pointerdown 开始转动;pointerup、pointercancel 和丢失指针捕获均停止。
+                      多个释放事件可能连续到达,脚本中的 activePtzPress 会保证只发送一次停止。
+                    -->
+                    <button
+                      v-for="direction in ptzDirections"
+                      :key="direction.label"
+                      type="button"
+                      class="ptz-direction-button"
+                      :class="{
+                        'is-active':
+                          activePtzWindowIndex === windowIndex &&
+                          activePtzCommand === direction.command
+                      }"
+                      :style="{ gridArea: direction.gridArea }"
+                      :title="direction.label"
+                      :aria-label="direction.label"
+                      :aria-pressed="
+                        activePtzWindowIndex === windowIndex &&
+                        activePtzCommand === direction.command
+                      "
+                      @pointerdown="startWindowPtzControl($event, windowIndex, direction.command)"
+                      @pointerup="stopWindowPtzControl($event, windowIndex)"
+                      @pointercancel="stopWindowPtzControl($event, windowIndex)"
+                      @lostpointercapture="stopWindowPtzControl($event, windowIndex)"
+                      @contextmenu.prevent
+                      @dragstart.prevent>
+                      <i :class="direction.icon"></i>
+                    </button>
+                    <span class="ptz-control-center" aria-hidden="true">
+                      <i class="i-lucide:scan"></i>
+                    </span>
+                  </div>
+                </div>
+              </el-popover>
+            </div>
+          </div>
+        </div>
+
+        <div v-if="previewError" class="stage-placeholder is-error">
+          <el-icon><VideoCamera /></el-icon>
+          <p>{{ previewError }}</p>
+        </div>
+        <div class="video-toolbar">
+          <el-tooltip
+            :content="sidebarCollapsed ? '展开监控点目录' : '收起监控点目录'"
+            placement="top"
+            :teleported="false">
+            <button
+              type="button"
+              class="sidebar-toggle"
+              :aria-label="sidebarCollapsed ? '展开监控点目录' : '收起监控点目录'"
+              :aria-expanded="!sidebarCollapsed"
+              @click="toggleSidebar">
+              <i v-if="sidebarCollapsed" class="i-lucide:panel-left-open"></i>
+              <i v-else class="i-lucide:panel-left-close"></i>
+            </button>
+          </el-tooltip>
+
+          <div class="toolbar-actions">
+            <el-tooltip content="关闭全部监控画面" placement="top" :teleported="false">
+              <button
+                type="button"
+                class="stop-all-trigger"
+                aria-label="关闭全部监控画面"
+                :aria-busy="stopAllLoading"
+                :disabled="stopAllLoading"
+                @click="stopAllPreviews">
+                <i v-if="stopAllLoading" class="i-lucide:loader-circle animate-spin"></i>
+                <i v-else class="i-lucide:video-off"></i>
+              </button>
+            </el-tooltip>
+
+            <el-tooltip content="全部抓图" placement="top" :teleported="false">
+              <button
+                type="button"
+                class="capture-all-trigger"
+                aria-label="全部抓图"
+                :aria-busy="captureAllLoading"
+                :disabled="captureAllLoading"
+                @click="captureAllWindowPictures">
+                <i v-if="captureAllLoading" class="i-lucide:loader-circle animate-spin"></i>
+                <i v-else class="i-lucide:images"></i>
+              </button>
+            </el-tooltip>
+
+            <el-tooltip :content="globalSoundTitle" placement="top" :teleported="false">
+              <button
+                type="button"
+                class="sound-all-trigger"
+                :class="{ active: soundWindowIndex !== undefined }"
+                :aria-label="globalSoundTitle"
+                :aria-pressed="soundWindowIndex !== undefined"
+                :disabled="soundControlLoading"
+                @click="toggleGlobalSound">
+                <i
+                  :class="
+                    soundWindowIndex !== undefined ? 'i-lucide:volume-2' : 'i-lucide:volume-x'
+                  "></i>
+              </button>
+            </el-tooltip>
+
+            <el-tooltip
+              content="分屏布局"
+              placement="top"
+              :disabled="splitPopoverVisible"
+              :teleported="false">
+              <button
+                ref="layoutTriggerRef"
+                type="button"
+                class="layout-trigger"
+                :class="{ active: splitPopoverVisible }"
+                aria-label="选择分屏布局"
+                :aria-busy="splitControlLoading"
+                :aria-expanded="splitPopoverVisible"
+                :disabled="splitControlLoading">
+                <i v-if="splitControlLoading" class="i-lucide:loader-circle animate-spin"></i>
+                <i v-else class="i-lucide:layout-grid"></i>
+              </button>
+            </el-tooltip>
+
+            <el-popover
+              v-model:visible="splitPopoverVisible"
+              :virtual-ref="layoutTriggerRef"
+              placement="top-end"
+              trigger="click"
+              :width="176"
+              :show-arrow="false"
+              :teleported="false"
+              virtual-triggering
+              popper-class="hikvision-split-popover">
+              <div class="split-menu" role="group" aria-label="分屏布局">
+                <button
+                  v-for="option in splitOptions"
+                  :key="option.count"
+                  type="button"
+                  class="split-menu-button"
+                  :class="{ active: currentSplitCount === option.count }"
+                  :title="`${option.count} 分屏`"
+                  :aria-label="`切换为 ${option.count} 分屏`"
+                  :aria-pressed="currentSplitCount === option.count"
+                  :disabled="splitControlLoading"
+                  @click="handleSplitChange(option.count, option.columns)">
+                  <span
+                    class="split-menu-icon"
+                    :style="{ '--split-icon': `url(${option.icon})` }"
+                    aria-hidden="true"></span>
+                </button>
+              </div>
+            </el-popover>
+
+            <el-tooltip
+              :content="stageFullscreen ? '退出全屏' : '全屏'"
+              placement="top"
+              :teleported="false">
+              <button
+                type="button"
+                class="fullscreen-trigger"
+                :aria-label="stageFullscreen ? '退出全屏' : '全屏'"
+                :aria-pressed="stageFullscreen"
+                :aria-busy="fullscreenControlLoading"
+                :disabled="fullscreenControlLoading"
+                @click="toggleFullscreen">
+                <i v-if="fullscreenControlLoading" class="i-lucide:loader-circle animate-spin"></i>
+                <i v-else-if="stageFullscreen" class="i-lucide:minimize"></i>
+                <i v-else class="i-lucide:maximize"></i>
+              </button>
+            </el-tooltip>
+          </div>
+        </div>
+      </section>
+    </main>
+  </div>
+</template>
+
+<style scoped lang="scss">
+@media (width <= 1200px) {
+  .monitor-panel {
+    --monitor-panel-width: 240px;
+  }
+}
+
+.hikvision-shell {
+  --navy: #0f2742;
+  --video-toolbar-height: 49px;
+
+  display: flex;
+  width: 100%;
+  overflow: hidden;
+  color: #1e293b;
+  background: #e8edf3;
+  border: 1px solid #cbd5e1;
+  border-radius: 8px;
+}
+
+.monitor-panel {
+  --monitor-panel-width: 280px;
+
+  display: flex;
+  width: var(--monitor-panel-width);
+  padding: 18px 14px;
+  overflow: hidden;
+  background: #f8fafc;
+  border-right: 1px solid #cbd5e1;
+  box-sizing: border-box;
+  flex: 0 0 var(--monitor-panel-width);
+  flex-direction: column;
+  gap: 14px;
+}
+
+.monitor-panel.is-collapsed {
+  width: 0;
+  flex-basis: 0;
+  padding-right: 0;
+  padding-left: 0;
+  border-right: 0;
+}
+
+.panel-title {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.panel-title h2 {
+  margin: 3px 0 0;
+  font-size: 20px;
+  color: var(--navy);
+}
+
+.eyebrow {
+  font-size: 10px;
+  font-weight: 700;
+  letter-spacing: 1.5px;
+  color: #64748b;
+}
+
+.monitor-tree-wrap {
+  min-height: 0;
+  flex: 1;
+  margin-right: -14px;
+  overflow: auto;
+}
+
+.monitor-tree {
+  min-width: 100%;
+  padding-right: 26px;
+  background: transparent;
+  box-sizing: border-box;
+
+  :deep(.el-tree-node__content) {
+    height: 38px;
+    margin-bottom: 3px;
+    border-radius: 6px;
+  }
+
+  :deep(.el-tree-node__content:hover) {
+    background: #e9eff6;
+  }
+
+  :deep(.el-tree-node.is-current > .el-tree-node__content) {
+    color: #0f3d69;
+    background: #dce9f6;
+    box-shadow: inset 3px 0 #2563eb;
+  }
+}
+
+.tree-node {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 7px;
+  padding-right: 8px;
+  color: #334155;
+}
+
+.tree-node.is-camera .node-icon {
+  color: #2563eb;
+}
+
+.tree-node.is-unavailable {
+  color: #94a3b8;
+}
+
+.node-icon {
+  flex: none;
+  color: #d97706;
+}
+
+.node-name {
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  font-size: 13px;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status-dot {
+  width: 7px;
+  height: 7px;
+  flex: none;
+  background: #94a3b8;
+  border-radius: 50%;
+}
+
+.status-dot.online {
+  background: #22c55e;
+  box-shadow: 0 0 0 3px rgb(34 197 94 / 12%);
+}
+
+.status-dot.offline {
+  background: #ef4444;
+  box-shadow: 0 0 0 3px rgb(239 68 68 / 10%);
+}
+
+.panel-tip {
+  font-size: 11px;
+  color: #94a3b8;
+  text-align: center;
+}
+
+.video-workspace {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  overflow: hidden;
+  background: #0b1118;
+}
+
+.video-stage {
+  position: relative;
+  display: flex;
+  min-width: 0;
+  min-height: 0;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  overflow: hidden;
+  background: #0b1118;
+}
+
+/*
+ * 全屏只展示监控画面和各视口自己的悬浮操作栏,不保留页面级底部工具栏。
+ * 将预留高度归零后,播放器和透明悬浮网格会同步补满工具栏释放出的 49px 空间。
+ */
+.video-stage:fullscreen {
+  --video-toolbar-height: 0px;
+}
+
+.video-stage:fullscreen .video-toolbar {
+  display: none;
+}
+
+/*
+ * 双击视口时只放大目标窗口。SDK 窗口与 Vue 悬浮单元格使用相同的下标,
+ * 两层同时隐藏非目标项,保证画面、标题和操作按钮仍准确重叠。
+ */
+.video-stage.is-single-window-fullscreen .player-container :deep(.parent-wnd) {
+  position: relative !important;
+}
+
+.video-stage.is-single-window-fullscreen .player-container :deep(.sub-wnd) {
+  display: none !important;
+}
+
+.video-stage.is-single-window-fullscreen
+  .player-container
+  :deep(.sub-wnd.is-single-fullscreen-target) {
+  position: absolute !important;
+  display: block !important;
+  width: 100% !important;
+  height: 100% !important;
+  inset: 0 !important;
+}
+
+.video-stage.is-single-window-fullscreen .window-loading-layer {
+  grid-template-rows: minmax(0, 1fr) !important;
+  grid-template-columns: minmax(0, 1fr) !important;
+}
+
+.video-stage.is-single-window-fullscreen .window-loading-cell {
+  display: none;
+}
+
+.video-stage.is-single-window-fullscreen .window-loading-cell.is-single-fullscreen-target {
+  display: block;
+}
+
+.player-container {
+  position: absolute;
+  width: 100%;
+  height: calc(100% - var(--video-toolbar-height));
+  overflow: hidden;
+  background: #0b1118;
+  box-sizing: border-box;
+  inset: 0 0 var(--video-toolbar-height);
+}
+
+.player-container :deep(.parent-wnd) {
+  width: 100% !important;
+  height: 100% !important;
+  overflow: hidden;
+}
+
+.player-container :deep(.sub-wnd) {
+  box-sizing: border-box !important;
+}
+
+.window-loading-layer {
+  position: absolute;
+  z-index: 2;
+  display: grid;
+  pointer-events: none;
+  inset: 0 0 var(--video-toolbar-height);
+}
+
+.window-loading-cell {
+  position: relative;
+  min-width: 0;
+  min-height: 0;
+}
+
+.window-loading-cell.is-active::after {
+  position: absolute;
+  z-index: 6;
+  pointer-events: none;
+  border: 2px solid #facc15;
+  content: '';
+  box-shadow:
+    inset 0 0 0 1px rgb(250 204 21 / 62%),
+    0 0 12px rgb(250 204 21 / 32%);
+  inset: 0;
+}
+
+.window-hover-title,
+.window-hover-actions {
+  position: absolute;
+  right: 0;
+  left: 0;
+  z-index: 5;
+  display: flex;
+  height: 40px;
+  padding: 0 14px;
+  color: #f8fafc;
+  text-shadow: 0 1px 2px rgb(0 0 0 / 72%);
+  pointer-events: none;
+  opacity: 0;
+  visibility: hidden;
+  backdrop-filter: blur(3px) saturate(115%);
+  box-sizing: border-box;
+  align-items: center;
+  transition:
+    opacity 0.18s ease,
+    transform 0.18s ease,
+    visibility 0.18s ease;
+}
+
+.window-loading-cell.is-hovered .window-hover-title,
+.window-loading-cell.is-hovered .window-hover-actions {
+  opacity: 1;
+  visibility: visible;
+  transform: translateY(0);
+}
+
+.window-hover-title {
+  top: 0;
+  justify-content: space-between;
+  background: linear-gradient(180deg, rgb(2 6 23 / 88%), rgb(15 23 42 / 54%));
+  border-bottom: 1px solid rgb(255 255 255 / 10%);
+  transform: translateY(-4px);
+}
+
+.window-title-main,
+.window-title-status {
+  display: inline-flex;
+  min-width: 0;
+  align-items: center;
+  gap: 6px;
+}
+
+.window-title-main {
+  flex: 1;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.01em;
+}
+
+.window-title-main span {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.window-title-status {
+  flex: none;
+  margin-left: 14px;
+  font-size: 14px;
+  color: #e2e8f0;
+}
+
+.window-close-button {
+  display: inline-flex;
+  width: 28px;
+  height: 28px;
+  padding: 0;
+  margin-left: 6px;
+  font-size: 16px;
+  color: #e2e8f0;
+  pointer-events: auto;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  border-radius: 6px;
+  transform: translateY(1px);
+  transition:
+    color 0.16s ease,
+    background-color 0.16s ease;
+  align-items: center;
+  justify-content: center;
+}
+
+.window-close-button:hover {
+  color: #fff;
+  background: rgb(239 68 68 / 78%);
+}
+
+.window-hover-actions {
+  bottom: 0;
+  gap: 8px;
+  pointer-events: auto;
+  background: linear-gradient(0deg, rgb(2 6 23 / 88%), rgb(15 23 42 / 48%));
+  border-top: 1px solid rgb(255 255 255 / 8%);
+  transform: translateY(4px);
+  justify-content: flex-end;
+}
+
+.window-action-button {
+  display: inline-flex;
+  width: 30px;
+  height: 30px;
+  padding: 0;
+  font-size: 16px;
+  color: #e2e8f0;
+  cursor: pointer;
+  background: rgb(255 255 255 / 8%);
+  border: 1px solid rgb(255 255 255 / 12%);
+  border-radius: 6px;
+  transform: translateY(-2px);
+  transition:
+    color 0.16s ease,
+    background-color 0.16s ease,
+    border-color 0.16s ease;
+  align-items: center;
+  justify-content: center;
+}
+
+.window-action-button:hover {
+  color: #fff;
+  background: rgb(59 130 246 / 68%);
+  border-color: rgb(147 197 253 / 72%);
+}
+
+.window-action-button.active {
+  color: #fff;
+  background: rgb(34 197 94 / 58%);
+  border-color: rgb(134 239 172 / 70%);
+}
+
+.window-action-button.is-talking {
+  color: #fff;
+  background: rgb(239 68 68 / 68%);
+  border-color: rgb(252 165 165 / 78%);
+}
+
+.window-action-button.is-zooming {
+  color: #fff;
+  background: rgb(59 130 246 / 68%);
+  border-color: rgb(147 197 253 / 78%);
+}
+
+/* 云台 Popover 打开时高亮入口,使用户能识别弹层当前归属的播放窗口。 */
+.window-action-button.is-ptz-open {
+  color: #fff;
+  background: rgb(14 165 233 / 72%);
+  border-color: rgb(125 211 252 / 82%);
+}
+
+.window-action-button:disabled {
+  cursor: not-allowed;
+  opacity: 0.56;
+}
+
+.window-loading-indicator {
+  position: absolute;
+  z-index: 2;
+  inset: 0;
+  display: flex;
+  font-size: 24px;
+  color: #60a5fa;
+  background: rgb(2 6 23 / 36%);
+  align-items: center;
+  justify-content: center;
+}
+
+.window-empty-indicator {
+  position: absolute;
+  z-index: 1;
+  inset: 0;
+  display: flex;
+  gap: 8px;
+  font-size: 12px;
+  color: rgb(148 163 184 / 82%);
+  background: rgb(2 6 23 / 18%);
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+}
+
+.window-empty-indicator .el-icon {
+  font-size: 22px;
+}
+
+.stage-placeholder {
+  position: relative;
+  z-index: 3;
+  color: #64748b;
+  text-align: center;
+}
+
+.stage-placeholder.is-error {
+  padding: 24px;
+  background: rgb(255 255 255 / 92%);
+  border-radius: 8px;
+}
+
+.stage-placeholder.is-error .el-icon {
+  color: #ef4444;
+}
+
+.stage-placeholder .el-icon {
+  font-size: 54px;
+  color: #cbd5e1;
+}
+
+.stage-placeholder p {
+  margin: 12px 0 5px;
+  font-size: 14px;
+  font-weight: 700;
+  color: #334155;
+}
+
+.video-toolbar {
+  position: absolute;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 4;
+  display: flex;
+  height: 49px;
+  padding: 0 14px;
+  color: #cbd5e1;
+  background: #18212c;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.toolbar-actions {
+  display: inline-flex;
+  gap: 10px;
+  align-items: center;
+}
+
+.sidebar-toggle,
+.stop-all-trigger,
+.capture-all-trigger,
+.sound-all-trigger,
+.fullscreen-trigger,
+.layout-trigger {
+  display: inline-flex;
+  width: 34px;
+  height: 34px;
+  padding: 0;
+  font-size: 22px;
+  color: #cbd5e1;
+  cursor: pointer;
+  background: transparent;
+  border: 0;
+  border-radius: 4px;
+  align-items: center;
+  justify-content: center;
+}
+
+.sidebar-toggle:hover,
+.stop-all-trigger:hover:not(:disabled),
+.capture-all-trigger:hover:not(:disabled),
+.sound-all-trigger:hover:not(:disabled),
+.sound-all-trigger.active,
+.fullscreen-trigger:hover:not(:disabled),
+.fullscreen-trigger[aria-pressed='true'],
+.layout-trigger:hover:not(:disabled),
+.layout-trigger.active {
+  color: #fff;
+  background: #2b3746;
+}
+
+.stop-all-trigger:disabled,
+.capture-all-trigger:disabled,
+.sound-all-trigger:disabled,
+.fullscreen-trigger:disabled,
+.layout-trigger:disabled,
+.split-menu-button:disabled {
+  color: #64748b;
+  cursor: not-allowed;
+}
+
+/* 弹层采用“标题 + 控制盘”的纵向结构。 */
+.ptz-popover-content {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+/* 云台标题区与方向按钮通过分隔线形成清晰层级。 */
+.ptz-control-title {
+  display: flex;
+  padding-bottom: 9px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #f8fafc;
+  border-bottom: 1px solid rgb(148 163 184 / 20%);
+  align-items: center;
+  gap: 7px;
+}
+
+.ptz-control-title i {
+  font-size: 15px;
+  color: #7dd3fc;
+  transform: translateY(-1px);
+}
+
+/* 3×3 网格承载八个方向,中间位置由 .ptz-control-center 占用。 */
+.ptz-control-panel {
+  position: relative;
+  display: grid;
+  grid-template-columns: repeat(3, 36px);
+  grid-template-rows: repeat(3, 36px);
+  gap: 6px;
+  justify-content: center;
+}
+
+/*
+ * touch-action 和 user-select 用于避免长按时触发页面滚动或文本选择,
+ * 从而保证 pointerup/pointercancel 能稳定结束云台动作。
+ */
+.ptz-direction-button {
+  display: inline-flex;
+  width: 36px;
+  height: 36px;
+  padding: 0;
+  font-size: 17px;
+  color: #dbeafe;
+  cursor: pointer;
+  background: #273544;
+  border: 1px solid #46576a;
+  border-radius: 7px;
+  transition:
+    color 0.16s ease,
+    background-color 0.16s ease,
+    border-color 0.16s ease,
+    transform 0.16s ease;
+  user-select: none;
+  touch-action: none;
+  align-items: center;
+  justify-content: center;
+}
+
+.ptz-direction-button:hover,
+.ptz-direction-button:focus-visible {
+  color: #fff;
+  background: #0ea5e9;
+  border-color: #7dd3fc;
+  outline: none;
+  transform: translateY(-1px);
+}
+
+/* 按压状态由接口操作状态驱动,不仅依赖浏览器瞬时的 :active。 */
+.ptz-direction-button.is-active {
+  color: #fff;
+  background: #0284c7;
+  border-color: #bae6fd;
+  transform: translateY(0);
+  box-shadow: 0 0 0 2px rgb(14 165 233 / 24%);
+}
+
+.ptz-direction-button:active {
+  background: #0284c7;
+  transform: translateY(0);
+}
+
+/* 中间仅作为云台中心的视觉标识,不可点击,也不会触发接口。 */
+.ptz-control-center {
+  display: inline-flex;
+  grid-area: 2 / 2;
+  width: 36px;
+  height: 36px;
+  font-size: 16px;
+  color: #7dd3fc;
+  background: rgb(14 165 233 / 10%);
+  border: 1px solid rgb(125 211 252 / 24%);
+  border-radius: 50%;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+/* Popover 会 Teleport 到 body,使用全局选择器覆盖 Element Plus 默认浅色外观。 */
+:global(.hikvision-ptz-popover.el-popper) {
+  min-width: 0;
+  padding: 12px;
+  background: #18212c;
+  border-color: #46576a;
+  box-shadow: 0 10px 28px rgb(2 6 23 / 46%);
+}
+
+.split-menu {
+  display: flex;
+  gap: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.split-menu-button {
+  display: inline-flex;
+  width: 34px;
+  height: 34px;
+  padding: 0;
+  color: #cbd5e1;
+  cursor: pointer;
+  background: transparent;
+  border: 1px solid transparent;
+  border-radius: 3px;
+  align-items: center;
+  justify-content: center;
+}
+
+.split-menu-button:hover {
+  color: #fff;
+  background: #4a515b;
+}
+
+.split-menu-button.active {
+  color: #f43f5e;
+  background: rgb(244 63 94 / 12%);
+  border-color: rgb(244 63 94 / 52%);
+}
+
+.split-menu-icon {
+  width: 24px;
+  height: 24px;
+  background: currentcolor;
+  mask: var(--split-icon) center / contain no-repeat;
+}
+
+:global(.hikvision-split-popover.el-popper) {
+  min-width: 0;
+  padding: 7px;
+  background: #3b4149;
+  border-color: #545c66;
+  box-shadow: 0 8px 20px rgb(15 23 42 / 32%);
+}
+</style>

Некоторые файлы не были показаны из-за большого количества измененных файлов