Просмотр исходного кода

✨ feat(组态编辑器): 新增视频监控组件并完善删除快捷键

- 封装单路海康视频组件,支持码流切换、抓图、声音、对讲、电子放大、云台控制及全屏
- 新增监控点选择属性,隔离编辑态与预览态,并支持组合组件预览和配置持久化
- 设置视频组件默认尺寸,补充 Delete/Backspace 删除快捷键及相关回归测试
Zimo 1 неделя назад
Родитель
Сommit
ec2ba4c492

Разница между файлами не показана из-за своего большого размера
+ 354 - 961
pnpm-lock.yaml


+ 1214 - 0
src/components/custom-components/hikvision-video-vue/index.vue

@@ -0,0 +1,1214 @@
+<template>
+  <div class="hikvision-video" :class="{ 'is-editor': editorMode }">
+    <div v-if="editorMode" class="editor-placeholder">
+      <i class="i-lucide:cctv"></i>
+      <strong>{{ camera?.name || '视频监控' }}</strong>
+      <span>{{ camera ? '预览时自动播放' : '请在右侧属性中选择监控点' }}</span>
+    </div>
+
+    <div v-else ref="stageRef" class="video-stage" @dblclick.stop="toggleFullscreen">
+      <div :id="playerContainerId" ref="playerContainerRef" class="player-container"></div>
+
+      <div class="video-overlay">
+        <div v-if="camera" class="hover-title">
+          <span class="title-main">
+            <i class="i-lucide:video"></i>
+            <span>{{ camera.name }}</span>
+          </span>
+          <span class="status">{{ previewStatus }}</span>
+          <button
+            type="button"
+            class="close-button"
+            title="关闭预览"
+            aria-label="关闭预览"
+            @dblclick.stop
+            @click.stop="stopPreview">
+            <i class="i-lucide:circle-x"></i>
+          </button>
+        </div>
+
+        <div v-if="loading" class="center-state">
+          <i class="i-lucide:loader-circle animate-spin"></i>
+          <span>正在加载监控画面</span>
+        </div>
+        <div v-else-if="errorMessage" class="center-state is-error">
+          <i class="i-lucide:triangle-alert"></i>
+          <span>{{ errorMessage }}</span>
+          <button v-if="camera" type="button" @click.stop="play">重新预览</button>
+        </div>
+        <div v-else-if="!playing" class="center-state">
+          <i class="i-lucide:cctv"></i>
+          <span>{{ camera ? '监控画面已关闭' : '未选择监控点' }}</span>
+          <button v-if="camera" type="button" @click.stop="play">开始预览</button>
+        </div>
+
+        <div v-if="camera && (playing || loading)" class="hover-actions" @dblclick.stop>
+          <el-tooltip :content="streamSwitchTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :disabled="!canOperate || streamSwitching"
+              :aria-label="streamSwitchTitle"
+              @click.stop="switchStream">
+              <i
+                :class="
+                  streamSwitching ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:refresh-cw'
+                "></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip content="抓取当前画面" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :disabled="!canOperate || capturing"
+              aria-label="抓取当前画面"
+              @click.stop="capturePicture">
+              <i :class="capturing ? 'i-lucide:loader-circle animate-spin' : 'i-lucide:camera'"></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip :content="zoomTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :class="{ active: zoomEnabled }"
+              :disabled="!canOperate || zoomLoading"
+              :aria-label="zoomTitle"
+              :aria-pressed="zoomEnabled"
+              @click.stop="toggleZoom">
+              <i
+                :class="
+                  zoomLoading
+                    ? 'i-lucide:loader-circle animate-spin'
+                    : zoomEnabled
+                      ? 'i-lucide:zoom-out'
+                      : 'i-lucide:zoom-in'
+                "></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip :content="soundTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :class="{ active: soundEnabled }"
+              :disabled="!canOperate || soundLoading"
+              :aria-label="soundTitle"
+              :aria-pressed="soundEnabled"
+              @click.stop="toggleSound">
+              <i :class="soundEnabled ? 'i-lucide:volume-2' : 'i-lucide:volume-x'"></i>
+            </button>
+          </el-tooltip>
+
+          <el-tooltip :content="talkTitle" placement="top" :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :class="{ 'is-talking': talking }"
+              :disabled="!canOperate || talkLoading"
+              :aria-label="talkTitle"
+              :aria-pressed="talking"
+              @click.stop="toggleTalk">
+              <i
+                :class="
+                  talkLoading
+                    ? 'i-lucide:loader-circle animate-spin'
+                    : talking
+                      ? 'i-lucide:mic'
+                      : 'i-lucide:mic-off'
+                "></i>
+            </button>
+          </el-tooltip>
+
+          <el-popover
+            v-model:visible="ptzVisible"
+            placement="top-end"
+            trigger="click"
+            :width="154"
+            :show-arrow="false"
+            :teleported="false"
+            popper-class="hikvision-single-ptz-popover"
+            @hide="stopPtzControl">
+            <template #reference>
+              <button
+                type="button"
+                class="action-button"
+                :class="{ active: ptzVisible }"
+                :disabled="!canOperate"
+                aria-label="打开云台控制"
+                :aria-expanded="ptzVisible">
+                <i class="i-lucide:move"></i>
+              </button>
+            </template>
+            <div class="ptz-content">
+              <div class="ptz-title"><i class="i-lucide:move"></i><span>云台控制</span></div>
+              <div class="ptz-panel" role="group" aria-label="云台方向控制">
+                <button
+                  v-for="direction in ptzDirections"
+                  :key="direction.command"
+                  type="button"
+                  class="ptz-button"
+                  :class="{ 'is-active': activePtzCommand === direction.command }"
+                  :style="{ gridArea: direction.gridArea }"
+                  :title="direction.label"
+                  :aria-label="direction.label"
+                  @pointerdown="startPtzControl($event, direction.command)"
+                  @pointerup="stopPtzFromPointer"
+                  @pointercancel="stopPtzFromPointer"
+                  @lostpointercapture="stopPtzFromPointer"
+                  @contextmenu.prevent
+                  @dragstart.prevent>
+                  <i :class="direction.icon"></i>
+                </button>
+                <span class="ptz-center"><i class="i-lucide:scan"></i></span>
+              </div>
+            </div>
+          </el-popover>
+
+          <el-tooltip
+            :content="fullscreen ? '退出全屏' : '全屏'"
+            placement="top"
+            :teleported="false">
+            <button
+              type="button"
+              class="action-button"
+              :disabled="fullscreenLoading"
+              :aria-label="fullscreen ? '退出全屏' : '全屏'"
+              :aria-pressed="fullscreen"
+              @click.stop="toggleFullscreen">
+              <i
+                :class="
+                  fullscreenLoading
+                    ? 'i-lucide:loader-circle animate-spin'
+                    : fullscreen
+                      ? 'i-lucide:minimize'
+                      : 'i-lucide:maximize'
+                "></i>
+            </button>
+          </el-tooltip>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {
+  HikvisionApi,
+  type HikvisionCameraNode,
+  type HikvisionPtzCommand,
+  type HikvisionStreamType
+} from '@/api/pms/hikvision'
+import {
+  getHikvisionPlayerBasePath,
+  loadHikvisionPlayer,
+  type HikvisionPlayerInstance
+} from '@/utils/hikvisionH5Player'
+import { ElMessage } from 'element-plus'
+import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+
+const props = withDefaults(
+  defineProps<{
+    camera?: HikvisionCameraNode | null
+    editorMode?: boolean
+    autoplay?: boolean
+  }>(),
+  {
+    camera: null,
+    editorMode: false,
+    autoplay: true
+  }
+)
+
+const emit = defineEmits<{
+  error: [error: unknown]
+  playing: [camera: HikvisionCameraNode]
+  stopped: []
+}>()
+
+const instanceUid = getCurrentInstance()?.uid ?? Math.floor(Math.random() * 1000000)
+const playerContainerId = `hikvision-single-player-${instanceUid}`
+const stageRef = ref<HTMLElement>()
+const playerContainerRef = ref<HTMLElement>()
+const loading = ref(false)
+const playing = ref(false)
+const interrupted = ref(false)
+const errorMessage = ref('')
+const streamType = ref<HikvisionStreamType>(0)
+const streamSwitching = ref(false)
+const capturing = ref(false)
+const soundEnabled = ref(false)
+const soundLoading = ref(false)
+const talking = ref(false)
+const talkLoading = ref(false)
+const zoomEnabled = ref(false)
+const zoomLoading = ref(false)
+const ptzVisible = ref(false)
+const activePtzCommand = ref<HikvisionPtzCommand>()
+const fullscreen = ref(false)
+const fullscreenLoading = ref(false)
+
+let player: HikvisionPlayerInstance | undefined
+let playerInitPromise: Promise<HikvisionPlayerInstance> | undefined
+let resizeObserver: ResizeObserver | undefined
+let resizeFrame: number | undefined
+let resizeRunning = false
+let resizePending = false
+let mounted = false
+let destroyed = false
+let operationId = 0
+let playIssuedOperationId: number | undefined
+let errorTimer: ReturnType<typeof window.setTimeout> | undefined
+let talkOperationId = 0
+let zoomOperationId = 0
+
+interface ActivePtzPress {
+  cameraIndexCode: string
+  command: HikvisionPtzCommand
+  pointerId: number
+  target: HTMLElement
+  startRequest: Promise<boolean>
+}
+
+let activePtzPress: ActivePtzPress | undefined
+let ptzBusy = false
+
+const PTZ_CONTROL_SPEED = 30
+const ptzDirections: Array<{
+  label: string
+  icon: string
+  gridArea: string
+  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'
+  }
+]
+
+const canOperate = computed(() => playing.value && !loading.value && !interrupted.value)
+const previewStatus = computed(() => {
+  if (loading.value) return '正在加载'
+  if (interrupted.value) return '画面中断'
+  if (playing.value) return '正在预览'
+  return '未预览'
+})
+const streamSwitchTitle = computed(() => (streamType.value === 0 ? '切换为子码流' : '切换为主码流'))
+const soundTitle = computed(() => (soundEnabled.value ? '关闭声音' : '开启声音'))
+const talkTitle = computed(() => {
+  if (talkLoading.value) return talking.value ? '正在停止对讲' : '正在开启对讲'
+  return talking.value ? '停止对讲' : '开启对讲'
+})
+const zoomTitle = computed(() => {
+  if (zoomLoading.value) return zoomEnabled.value ? '正在关闭电子放大' : '正在开启电子放大'
+  return zoomEnabled.value ? '关闭电子放大' : '开启电子放大'
+})
+
+function clearErrorTimer() {
+  if (errorTimer === undefined) return
+  window.clearTimeout(errorTimer)
+  errorTimer = undefined
+}
+
+function clearError() {
+  clearErrorTimer()
+  errorMessage.value = ''
+}
+
+function showError(message: string, error?: unknown) {
+  if (destroyed) return
+  clearErrorTimer()
+  errorMessage.value = message
+  if (error !== undefined) emit('error', error)
+  errorTimer = window.setTimeout(() => {
+    errorMessage.value = ''
+    errorTimer = undefined
+  }, 3000)
+}
+
+function getTalkErrorMessage(errorCode: number) {
+  if (errorCode === 0x12f950000) return '麦克风采集失败,请检查 HTTPS 和麦克风权限'
+  if (errorCode === 0x12f950001) return '监控设备的对讲音频编码不受支持'
+  return `对讲异常(${errorCode})`
+}
+
+async function initPlayer() {
+  if (player) return player
+  if (playerInitPromise) return playerInitPromise
+
+  playerInitPromise = (async () => {
+    let initializingPlayer: HikvisionPlayerInstance | undefined
+    try {
+      await nextTick()
+      const JSPlugin = await loadHikvisionPlayer()
+      if (destroyed || props.editorMode) throw new Error('播放器已停止初始化')
+      initializingPlayer = new JSPlugin({
+        szId: playerContainerId,
+        szBasePath: getHikvisionPlayerBasePath(),
+        iMaxSplit: 1,
+        iCurrentSplit: 1,
+        mseWorkerEnable: window.crossOriginIsolated,
+        bSupporDoubleClickFull: false,
+        oStyle: { borderSelect: '#facc15' }
+      })
+      await initializingPlayer.JS_SetWindowControlCallback({
+        firstFrameDisplay: (windowIndex) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          loading.value = false
+          interrupted.value = false
+          playing.value = true
+          if (props.camera) emit('playing', props.camera)
+        },
+        InterruptStream: (windowIndex, interruptTime) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          loading.value = false
+          interrupted.value = true
+          playing.value = false
+          void stopTalk()
+          void stopZoom()
+          showError(`监控画面已中断(${interruptTime} 秒未收到码流)`)
+        },
+        StreamEnd: (windowIndex) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          loading.value = false
+          playing.value = false
+          void stopTalk()
+          void stopZoom()
+          showError('监控画面已结束')
+        },
+        performanceLack: () => showError('当前设备性能不足,监控画面可能卡顿'),
+        pluginErrorHandler: (windowIndex, errorCode, error) => {
+          if (windowIndex !== 0 || playIssuedOperationId !== operationId) return
+          console.error('H5player 播放器错误', errorCode, error)
+          loading.value = false
+          playing.value = false
+          showError(`播放器异常(${errorCode})`, error)
+        },
+        talkPluginErrorHandler: (errorCode, error) => {
+          console.error('H5player 对讲错误', errorCode, error)
+          talkOperationId += 1
+          talking.value = false
+          talkLoading.value = false
+          showError(getTalkErrorMessage(errorCode), error)
+        }
+      })
+      if (destroyed || props.editorMode) throw new Error('播放器已停止初始化')
+      player = initializingPlayer
+      initializingPlayer = undefined
+      scheduleResize()
+      return player
+    } catch (error) {
+      await initializingPlayer?.JS_StopRealPlayAll().catch(() => undefined)
+      await Promise.resolve(initializingPlayer?.JS_Destroy()).catch(() => undefined)
+      throw error
+    } finally {
+      playerInitPromise = undefined
+    }
+  })()
+
+  return playerInitPromise
+}
+
+async function play() {
+  const camera = props.camera
+  if (!mounted || destroyed || props.editorMode || !camera) return
+  if (camera.available === false || camera.onlineStatus === false) {
+    showError(`${camera.name} 不在线,无法预览`)
+    return
+  }
+
+  const currentOperationId = ++operationId
+  playIssuedOperationId = undefined
+  loading.value = true
+  playing.value = false
+  interrupted.value = false
+  streamType.value = 0
+  clearError()
+
+  try {
+    const [currentPlayer, previewUrl] = await Promise.all([
+      initPlayer(),
+      HikvisionApi.getPreviewUrl(camera.indexCode, 0)
+    ])
+    if (currentOperationId !== operationId || destroyed) return
+    if (!previewUrl) throw new Error('预览地址为空')
+
+    await stopPtzControl()
+    await stopTalk()
+    await stopZoom()
+    await closeSound()
+    await currentPlayer.JS_Stop(0).catch(() => undefined)
+    if (currentOperationId !== operationId || destroyed) return
+
+    playIssuedOperationId = currentOperationId
+    await currentPlayer.JS_Play(previewUrl, { playURL: previewUrl, mode: 0, keepDecoder: 0 }, 0)
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('监控点预览失败', error)
+    loading.value = false
+    playing.value = false
+    playIssuedOperationId = undefined
+    showError(error instanceof Error ? error.message : '监控点预览失败', error)
+  }
+}
+
+async function switchStream() {
+  const camera = props.camera
+  if (!camera || !canOperate.value || streamSwitching.value) return
+  const nextStreamType: HikvisionStreamType = streamType.value === 0 ? 1 : 0
+  const currentOperationId = ++operationId
+  playIssuedOperationId = undefined
+  streamSwitching.value = true
+  loading.value = true
+  playing.value = false
+  clearError()
+
+  try {
+    const [currentPlayer, previewUrl] = await Promise.all([
+      initPlayer(),
+      HikvisionApi.getPreviewUrl(camera.indexCode, nextStreamType)
+    ])
+    if (currentOperationId !== operationId || destroyed) return
+    if (!previewUrl) throw new Error('切换码流的预览地址为空')
+    await stopPtzControl()
+    await stopTalk()
+    await stopZoom()
+    await closeSound()
+    await currentPlayer.JS_Stop(0)
+    if (currentOperationId !== operationId || destroyed) return
+    streamType.value = nextStreamType
+    playIssuedOperationId = currentOperationId
+    await currentPlayer.JS_Play(previewUrl, { playURL: previewUrl, mode: 0, keepDecoder: 0 }, 0)
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('切换监控码流失败', error)
+    loading.value = false
+    playing.value = false
+    showError(error instanceof Error ? error.message : '切换监控码流失败', error)
+  } finally {
+    if (currentOperationId === operationId) streamSwitching.value = false
+  }
+}
+
+function createCaptureFileName(camera: HikvisionCameraNode) {
+  const safeName = camera.name.replace(/[\\/:*?"<>|]/g, '_').trim() || camera.indexCode
+  const now = new Date()
+  const pad = (value: number) => String(value).padStart(2, '0')
+  return `${safeName}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(
+    now.getHours()
+  )}${pad(now.getMinutes())}${pad(now.getSeconds())}`
+}
+
+async function capturePicture() {
+  if (!props.camera || !player || !canOperate.value || capturing.value) return
+  const currentOperationId = operationId
+  capturing.value = true
+  clearError()
+  try {
+    await player.JS_CapturePicture(0, createCaptureFileName(props.camera), 'JPEG')
+    if (currentOperationId !== operationId || destroyed) return
+    ElMessage.success('抓图已保存到本地')
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('监控画面抓图失败', error)
+    showError(error instanceof Error ? error.message : '监控画面抓图失败', error)
+  } finally {
+    if (currentOperationId === operationId) capturing.value = false
+  }
+}
+
+async function closeSound() {
+  if (!soundEnabled.value) return true
+  try {
+    await player?.JS_CloseSound(0)
+    soundEnabled.value = false
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function toggleSound() {
+  if (!player || !canOperate.value || soundLoading.value) return
+  const currentOperationId = operationId
+  soundLoading.value = true
+  clearError()
+  try {
+    if (soundEnabled.value) {
+      await player.JS_CloseSound(0)
+      if (currentOperationId !== operationId || destroyed) return
+      soundEnabled.value = false
+      ElMessage.success('声音已关闭')
+    } else {
+      await player.JS_OpenSound(0)
+      if (currentOperationId !== operationId || destroyed) return
+      soundEnabled.value = true
+      ElMessage.success('声音已开启')
+    }
+  } catch (error) {
+    if (currentOperationId !== operationId || destroyed) return
+    console.error('切换监控声音失败', error)
+    showError(error instanceof Error ? error.message : '切换监控声音失败', error)
+  } finally {
+    if (currentOperationId === operationId) soundLoading.value = false
+  }
+}
+
+async function stopTalk(notify = false) {
+  const wasTalking = talking.value
+  const currentTalkOperationId = ++talkOperationId
+  talking.value = false
+  if (!wasTalking) {
+    talkLoading.value = false
+    return true
+  }
+  talkLoading.value = true
+  try {
+    await player?.JS_StopTalk()
+    if (notify) ElMessage.success('对讲已停止')
+    return true
+  } catch (error) {
+    if (notify) showError(error instanceof Error ? error.message : '停止对讲失败', error)
+    return false
+  } finally {
+    if (currentTalkOperationId === talkOperationId) talkLoading.value = false
+  }
+}
+
+async function toggleTalk() {
+  if (!props.camera || !player || !canOperate.value || talkLoading.value) return
+  if (talking.value) {
+    await stopTalk(true)
+    return
+  }
+  if (!window.isSecureContext) {
+    showError('对讲功能仅支持 HTTPS 安全环境')
+    return
+  }
+
+  const currentTalkOperationId = ++talkOperationId
+  talkLoading.value = true
+  clearError()
+  try {
+    const talkUrl = await HikvisionApi.getTalkUrl(props.camera.indexCode)
+    if (currentTalkOperationId !== talkOperationId || destroyed) return
+    if (!talkUrl) throw new Error('对讲地址为空')
+    await player.JS_StartTalk(talkUrl)
+    if (currentTalkOperationId !== talkOperationId || destroyed) {
+      await player.JS_StopTalk().catch(() => undefined)
+      return
+    }
+    talking.value = true
+    ElMessage.success('对讲已开启')
+  } catch (error) {
+    if (currentTalkOperationId !== talkOperationId || destroyed) return
+    console.error('开启监控对讲失败', error)
+    showError(error instanceof Error ? error.message : '开启对讲失败,请检查麦克风权限', error)
+  } finally {
+    if (currentTalkOperationId === talkOperationId) talkLoading.value = false
+  }
+}
+
+async function stopZoom() {
+  const currentZoomOperationId = ++zoomOperationId
+  if (!zoomEnabled.value && !zoomLoading.value) return true
+  zoomLoading.value = true
+  try {
+    await player?.JS_DisableZoom(0)
+    if (currentZoomOperationId !== zoomOperationId) return false
+    zoomEnabled.value = false
+    return true
+  } catch {
+    return false
+  } finally {
+    if (currentZoomOperationId === zoomOperationId) zoomLoading.value = false
+  }
+}
+
+async function toggleZoom() {
+  if (!player || !canOperate.value || zoomLoading.value) return
+  if (!window.isSecureContext) {
+    showError('电子放大仅支持 HTTPS 安全环境')
+    return
+  }
+  const enable = !zoomEnabled.value
+  const currentZoomOperationId = ++zoomOperationId
+  zoomLoading.value = true
+  clearError()
+  try {
+    if (enable) await player.JS_EnableZoom(0)
+    else await player.JS_DisableZoom(0)
+    if (currentZoomOperationId !== zoomOperationId) {
+      if (enable) await player.JS_DisableZoom(0).catch(() => undefined)
+      return
+    }
+    zoomEnabled.value = enable
+    ElMessage.success(enable ? '电子放大已开启,请在画面中框选区域' : '电子放大已关闭')
+  } catch (error) {
+    if (currentZoomOperationId !== zoomOperationId) return
+    console.error('切换电子放大失败', error)
+    showError(error instanceof Error ? error.message : '切换电子放大失败', error)
+  } finally {
+    if (currentZoomOperationId === zoomOperationId) zoomLoading.value = false
+  }
+}
+
+function startPtzControl(event: PointerEvent, command: HikvisionPtzCommand) {
+  if (
+    !event.isPrimary ||
+    event.button !== 0 ||
+    activePtzPress ||
+    ptzBusy ||
+    !props.camera ||
+    !canOperate.value
+  ) {
+    return
+  }
+  event.preventDefault()
+  const target = event.currentTarget as HTMLElement
+  try {
+    target.setPointerCapture(event.pointerId)
+  } catch {
+    // 旧浏览器不支持指针捕获时,仍由按钮本身的 pointerup 尝试停止。
+  }
+  const press: ActivePtzPress = {
+    cameraIndexCode: props.camera.indexCode,
+    command,
+    pointerId: event.pointerId,
+    target,
+    startRequest: Promise.resolve(true)
+  }
+  activePtzPress = press
+  ptzBusy = true
+  activePtzCommand.value = command
+  press.startRequest = HikvisionApi.controlPtz({
+    cameraIndexCode: press.cameraIndexCode,
+    action: 0,
+    command,
+    speed: PTZ_CONTROL_SPEED
+  })
+    .then(() => true)
+    .catch((error) => {
+      console.error('开启云台方向控制失败', error)
+      showError('当前监控点不支持云台控制', error)
+      return false
+    })
+}
+
+async function stopPtzControl(pointerId?: number) {
+  const press = activePtzPress
+  if (!press || (pointerId !== undefined && press.pointerId !== pointerId)) return
+  activePtzPress = undefined
+  activePtzCommand.value = undefined
+  try {
+    if (press.target.hasPointerCapture(press.pointerId)) {
+      press.target.releasePointerCapture(press.pointerId)
+    }
+  } catch {
+    // 元素销毁或浏览器已自动释放时无需处理。
+  }
+  const started = await press.startRequest
+  if (!started) {
+    ptzBusy = false
+    return
+  }
+  try {
+    await HikvisionApi.controlPtz({
+      cameraIndexCode: press.cameraIndexCode,
+      action: 1,
+      command: press.command
+    })
+  } catch (error) {
+    console.error('停止云台方向控制失败', error)
+    showError(error instanceof Error ? error.message : '停止云台方向控制失败', error)
+  } finally {
+    ptzBusy = false
+  }
+}
+
+function stopPtzFromPointer(event: PointerEvent) {
+  void stopPtzControl(event.pointerId)
+}
+
+async function stopPreview() {
+  operationId += 1
+  playIssuedOperationId = undefined
+  loading.value = false
+  streamSwitching.value = false
+  clearError()
+  try {
+    await stopPtzControl()
+    await stopTalk()
+    await stopZoom()
+    await closeSound()
+    await player?.JS_Stop(0)
+  } catch (error) {
+    console.error('关闭监控画面失败', error)
+    showError(error instanceof Error ? error.message : '关闭监控画面失败', error)
+  } finally {
+    playing.value = false
+    interrupted.value = false
+    ptzVisible.value = false
+    emit('stopped')
+  }
+}
+
+async function resizePlayer() {
+  resizeFrame = undefined
+  if (!player || !playerContainerRef.value) return
+  if (resizeRunning) {
+    resizePending = true
+    return
+  }
+  const { clientWidth, clientHeight } = playerContainerRef.value
+  if (!clientWidth || !clientHeight) return
+  resizeRunning = true
+  try {
+    await player.JS_Resize(clientWidth, clientHeight)
+  } catch {
+    // 布局变化与播放器销毁发生在同一帧时可以安全忽略。
+  } finally {
+    resizeRunning = false
+    if (resizePending && !destroyed) {
+      resizePending = false
+      scheduleResize()
+    }
+  }
+}
+
+function scheduleResize() {
+  if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+  resizeFrame = requestAnimationFrame(resizePlayer)
+}
+
+async function toggleFullscreen() {
+  if (fullscreenLoading.value || !stageRef.value) return
+  fullscreenLoading.value = true
+  try {
+    if (document.fullscreenElement === stageRef.value) await document.exitFullscreen()
+    else await stageRef.value.requestFullscreen()
+  } catch (error) {
+    showError(error instanceof Error ? error.message : '切换全屏失败', error)
+  } finally {
+    fullscreenLoading.value = false
+  }
+}
+
+function handleFullscreenChange() {
+  fullscreen.value = document.fullscreenElement === stageRef.value
+  void nextTick(scheduleResize)
+}
+
+async function teardownPlayer() {
+  operationId += 1
+  playIssuedOperationId = undefined
+  resizeObserver?.disconnect()
+  resizeObserver = undefined
+  if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
+  resizeFrame = undefined
+  resizePending = false
+  await stopPtzControl()
+  await stopTalk()
+  await stopZoom()
+  const currentPlayer = player
+  player = undefined
+  if (currentPlayer) {
+    await currentPlayer.JS_CloseSound(0).catch(() => undefined)
+    await currentPlayer.JS_StopTalk().catch(() => undefined)
+    await currentPlayer.JS_StopRealPlayAll().catch(() => undefined)
+    await Promise.resolve(currentPlayer.JS_Destroy()).catch(() => undefined)
+  }
+  loading.value = false
+  playing.value = false
+  interrupted.value = false
+  streamSwitching.value = false
+  capturing.value = false
+  soundEnabled.value = false
+  soundLoading.value = false
+  talkLoading.value = false
+  zoomEnabled.value = false
+  zoomLoading.value = false
+}
+
+async function syncCamera() {
+  if (!mounted || destroyed) return
+  await teardownPlayer()
+  if (props.editorMode || !props.camera || !props.autoplay) return
+  await nextTick()
+  if (playerContainerRef.value) {
+    resizeObserver = new ResizeObserver(scheduleResize)
+    resizeObserver.observe(playerContainerRef.value)
+  }
+  await play()
+}
+
+watch(
+  () => [props.camera?.indexCode, props.editorMode, props.autoplay] as const,
+  () => void syncCamera()
+)
+
+onMounted(() => {
+  mounted = true
+  destroyed = false
+  document.addEventListener('fullscreenchange', handleFullscreenChange)
+  void syncCamera()
+})
+
+onBeforeUnmount(() => {
+  destroyed = true
+  mounted = false
+  clearErrorTimer()
+  document.removeEventListener('fullscreenchange', handleFullscreenChange)
+  if (document.fullscreenElement === stageRef.value) void document.exitFullscreen()
+  void teardownPlayer()
+})
+
+defineExpose({
+  play,
+  stop: stopPreview,
+  capturePicture,
+  toggleFullscreen
+})
+</script>
+
+<style scoped lang="scss">
+.hikvision-video,
+.video-stage,
+.player-container,
+.video-overlay {
+  width: 100%;
+  height: 100%;
+}
+
+.hikvision-video {
+  min-width: 0;
+  min-height: 0;
+  overflow: hidden;
+  color: #f8fafc;
+  background: #0b1118;
+}
+
+.editor-placeholder {
+  display: flex;
+  width: 100%;
+  height: 100%;
+  min-width: 0;
+  min-height: 0;
+  padding: 18px;
+  color: #cbd5e1;
+  background: linear-gradient(rgb(96 165 250 / 8%) 1px, transparent 1px),
+    linear-gradient(90deg, rgb(96 165 250 / 8%) 1px, transparent 1px), #0b1118;
+  background-size: 24px 24px;
+  border: 1px solid rgb(96 165 250 / 42%);
+  box-sizing: border-box;
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.editor-placeholder > i {
+  font-size: clamp(24px, 15%, 48px);
+  color: #60a5fa;
+}
+
+.editor-placeholder strong,
+.editor-placeholder span {
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.editor-placeholder span {
+  font-size: 12px;
+  color: #94a3b8;
+}
+
+.video-stage {
+  position: relative;
+  overflow: hidden;
+  background: #0b1118;
+}
+
+.video-stage:fullscreen {
+  width: 100vw;
+  height: 100vh;
+}
+
+.player-container,
+.video-overlay {
+  position: absolute;
+  inset: 0;
+}
+
+.player-container :deep(.parent-wnd),
+.player-container :deep(.sub-wnd) {
+  width: 100% !important;
+  height: 100% !important;
+}
+
+.video-overlay {
+  z-index: 2;
+  pointer-events: none;
+}
+
+.hover-title,
+.hover-actions {
+  position: absolute;
+  right: 0;
+  left: 0;
+  z-index: 3;
+  display: flex;
+  min-height: 40px;
+  padding: 5px 12px;
+  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;
+}
+
+.video-stage:hover .hover-title,
+.video-stage:hover .hover-actions,
+.hover-title:focus-within,
+.hover-actions:focus-within {
+  opacity: 1;
+  visibility: visible;
+  transform: translateY(0);
+}
+
+.hover-title {
+  top: 0;
+  background: linear-gradient(180deg, rgb(2 6 23 / 90%), rgb(15 23 42 / 50%));
+  border-bottom: 1px solid rgb(255 255 255 / 10%);
+  transform: translateY(-4px);
+}
+
+.title-main {
+  display: inline-flex;
+  min-width: 0;
+  flex: 1;
+  font-size: 14px;
+  font-weight: 600;
+  align-items: center;
+  gap: 6px;
+}
+
+.title-main span {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.status {
+  margin-left: 10px;
+  font-size: 12px;
+  color: #e2e8f0;
+  white-space: nowrap;
+}
+
+.close-button,
+.action-button {
+  display: inline-flex;
+  width: 30px;
+  height: 30px;
+  padding: 0;
+  color: #e2e8f0;
+  pointer-events: auto;
+  cursor: pointer;
+  background: rgb(255 255 255 / 8%);
+  border: 1px solid rgb(255 255 255 / 12%);
+  border-radius: 6px;
+  align-items: center;
+  justify-content: center;
+}
+
+.close-button {
+  margin-left: 6px;
+  background: transparent;
+  border-color: transparent;
+}
+
+.close-button:hover {
+  color: #fff;
+  background: rgb(239 68 68 / 78%);
+}
+
+.hover-actions {
+  bottom: 0;
+  overflow: visible;
+  background: linear-gradient(0deg, rgb(2 6 23 / 90%), rgb(15 23 42 / 48%));
+  border-top: 1px solid rgb(255 255 255 / 8%);
+  transform: translateY(4px);
+  justify-content: flex-end;
+  gap: 7px;
+}
+
+.action-button:hover:not(:disabled),
+.action-button.active {
+  color: #fff;
+  background: rgb(59 130 246 / 68%);
+  border-color: rgb(147 197 253 / 72%);
+}
+
+.action-button.is-talking {
+  color: #fff;
+  background: rgb(239 68 68 / 68%);
+  border-color: rgb(252 165 165 / 78%);
+}
+
+.action-button:disabled {
+  cursor: not-allowed;
+  opacity: 0.5;
+}
+
+.center-state {
+  position: absolute;
+  inset: 0;
+  display: flex;
+  padding: 46px 16px;
+  font-size: 13px;
+  color: #94a3b8;
+  pointer-events: none;
+  background: rgb(2 6 23 / 28%);
+  align-items: center;
+  justify-content: center;
+  flex-direction: column;
+  gap: 8px;
+}
+
+.center-state > i {
+  font-size: 26px;
+  color: #60a5fa;
+}
+
+.center-state.is-error {
+  color: #fecaca;
+  background: rgb(69 10 10 / 72%);
+}
+
+.center-state.is-error > i {
+  color: #f87171;
+}
+
+.center-state button {
+  padding: 5px 12px;
+  color: #dbeafe;
+  pointer-events: auto;
+  cursor: pointer;
+  background: rgb(37 99 235 / 44%);
+  border: 1px solid rgb(147 197 253 / 50%);
+  border-radius: 5px;
+}
+
+.ptz-content {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+}
+
+.ptz-title {
+  display: flex;
+  padding-bottom: 8px;
+  font-size: 14px;
+  font-weight: 600;
+  color: #f8fafc;
+  border-bottom: 1px solid rgb(148 163 184 / 20%);
+  align-items: center;
+  gap: 7px;
+}
+
+.ptz-panel {
+  display: grid;
+  grid-template-columns: repeat(3, 36px);
+  grid-template-rows: repeat(3, 36px);
+  gap: 6px;
+  justify-content: center;
+}
+
+.ptz-button,
+.ptz-center {
+  display: inline-flex;
+  width: 36px;
+  height: 36px;
+  color: #dbeafe;
+  background: #273544;
+  border: 1px solid #46576a;
+  border-radius: 7px;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+
+.ptz-button {
+  padding: 0;
+  cursor: pointer;
+  user-select: none;
+  touch-action: none;
+}
+
+.ptz-button:hover,
+.ptz-button.is-active {
+  color: #fff;
+  background: #0284c7;
+  border-color: #7dd3fc;
+}
+
+.ptz-center {
+  grid-area: 2 / 2;
+  color: #7dd3fc;
+  background: rgb(14 165 233 / 10%);
+  border-radius: 50%;
+}
+
+:global(.hikvision-single-ptz-popover.el-popper) {
+  min-width: 0;
+  padding: 12px;
+  background: #18212c;
+  border-color: #46576a;
+  box-shadow: 0 10px 28px rgb(2 6 23 / 46%);
+}
+
+@media (width <= 420px), (height <= 180px) {
+  .hover-title,
+  .hover-actions {
+    min-height: 34px;
+    padding: 2px 6px;
+  }
+
+  .hover-actions {
+    gap: 3px;
+  }
+
+  .action-button,
+  .close-button {
+    width: 27px;
+    height: 27px;
+  }
+
+  .status {
+    display: none;
+  }
+}
+</style>

+ 5 - 1
src/components/mt-edit/components/group-render/index.vue

@@ -17,6 +17,7 @@
         :grid="groupRender.grid"
         :canvas-cfg="groupRender.canvasCfg"
         :canvas-dom="groupRender.canvasDom"
+        :preview-mode="groupRender.previewMode"
         :lock-state="false" />
     </div>
   </div>
@@ -33,8 +34,11 @@ type GroupRender = {
   grid: IGlobalStoreGridCfg
   canvasCfg: IGlobalStoreCanvasCfg
   canvasDom: HTMLElement | null
+  previewMode?: boolean
 }
-const groupRender = withDefaults(defineProps<GroupRender>(), {})
+const groupRender = withDefaults(defineProps<GroupRender>(), {
+  previewMode: false
+})
 </script>
 <style scoped>
 .mt-group {

+ 21 - 3
src/components/mt-edit/components/layout/main-panel/index.vue

@@ -159,6 +159,7 @@ import {
   getRectCoordinate,
   getRectCenterCoordinate,
   handleAlign,
+  isDeleteShortcutKey,
   isSystemShortcutKey
 } from '@/components/mt-edit/utils'
 import type {
@@ -331,6 +332,7 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
   // 竖线
   const is_horizontal_line = deep_find_cfg.id === 'sys-line-vertical'
   const is_status = deep_find_cfg.id === 'status-vue'
+  const is_hikvision_video = deep_find_cfg.id === 'hikvision-video-vue'
   //根据配置创建图形
   const { realityX, realityY } = getRealityXY(e, canvasAreaRef.value?.getBoundingClientRect())
   const create_item: IDoneJson = {
@@ -340,8 +342,24 @@ const onDrop = (e: DragEvent | TouchEvent, isTouch?: boolean) => {
     binfo: {
       left: alignToGrid(realityX / globalStore.canvasCfg.scale, grid_align_size.value),
       top: alignToGrid(realityY / globalStore.canvasCfg.scale, grid_align_size.value),
-      width: is_vertical_line ? 100 : is_horizontal_line ? 0 : is_status ? 36 : 50,
-      height: is_horizontal_line ? 100 : is_vertical_line ? 0 : is_status ? 36 : 50,
+      width: is_vertical_line
+        ? 100
+        : is_horizontal_line
+          ? 0
+          : is_status
+            ? 36
+            : is_hikvision_video
+              ? 480
+              : 50,
+      height: is_horizontal_line
+        ? 100
+        : is_vertical_line
+          ? 0
+          : is_status
+            ? 36
+            : is_hikvision_video
+              ? 270
+              : 50,
       angle: 0
     },
     resize: is_line ? false : true,
@@ -1182,7 +1200,7 @@ const onKeydown = (e: KeyboardEvent) => {
     onContextMenuPaste(10, 10)
   }
   //删除
-  else if (e.key.toLocaleLowerCase() === 'delete') {
+  else if (isDeleteShortcutKey(e)) {
     e.preventDefault()
     onContextMenuDelete()
   }

+ 268 - 0
src/components/mt-edit/components/layout/right-aside/hikvision-camera-select.vue

@@ -0,0 +1,268 @@
+<template>
+  <div class="camera-select">
+    <el-input
+      :model-value="modelValue?.name || ''"
+      readonly
+      clearable
+      placeholder="请选择监控点"
+      @click="openDialog"
+      @clear="clearSelection">
+      <template #append>
+        <el-button aria-label="选择监控点" @click="openDialog">
+          <el-icon><VideoCamera /></el-icon>
+        </el-button>
+      </template>
+    </el-input>
+
+    <el-dialog
+      v-model="dialogVisible"
+      title="选择监控点"
+      width="560px"
+      append-to-body
+      destroy-on-close
+      @opened="loadRootTree">
+      <div class="camera-dialog">
+        <el-input
+          v-model="keyword"
+          clearable
+          :prefix-icon="Search"
+          placeholder="输入监控点名称,回车搜索"
+          @keydown.enter="handleSearch"
+          @clear="loadRootTree" />
+
+        <div v-loading="loading" class="camera-tree-wrap">
+          <el-tree
+            v-if="!loading"
+            :key="treeVersion"
+            :data="treeData"
+            :props="treeProps"
+            :load="loadTreeNode"
+            :lazy="!searchMode"
+            :default-expand-all="searchMode"
+            :current-node-key="draftSelection?.indexCode"
+            node-key="indexCode"
+            highlight-current
+            :expand-on-click-node="false"
+            empty-text="暂无监控点"
+            @node-click="handleNodeClick">
+            <template #default="{ data }">
+              <div
+                class="camera-tree-node"
+                :class="{
+                  'is-camera': data.nodeType === 'camera',
+                  'is-offline': data.nodeType === 'camera' && !isCameraSelectable(data)
+                }">
+                <el-icon>
+                  <Location v-if="data.nodeType === 'region'" />
+                  <VideoCamera v-else />
+                </el-icon>
+                <span>{{ data.name }}</span>
+                <i
+                  v-if="data.nodeType === 'camera'"
+                  class="camera-status"
+                  :class="{ online: isCameraSelectable(data) }"></i>
+              </div>
+            </template>
+          </el-tree>
+        </div>
+
+        <div class="camera-selection-summary">
+          当前选择:<strong>{{ draftSelection?.name || '未选择' }}</strong>
+        </div>
+      </div>
+
+      <template #footer>
+        <el-button @click="dialogVisible = false">取消</el-button>
+        <el-button type="primary" :disabled="!draftSelection" @click="confirmSelection">
+          确定
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { HikvisionApi, type HikvisionCameraNode, type HikvisionTreeNode } from '@/api/pms/hikvision'
+import { Location, Search, VideoCamera } from '@element-plus/icons-vue'
+import type { LoadFunction } from 'element-plus/es/components/tree/src/tree.type'
+import { ElMessage } from 'element-plus'
+import { ref } from 'vue'
+
+const props = defineProps<{
+  modelValue?: HikvisionCameraNode | null
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: HikvisionCameraNode | null]
+}>()
+
+const dialogVisible = ref(false)
+const keyword = ref('')
+const loading = ref(false)
+const searchMode = ref(false)
+const treeVersion = ref(0)
+const treeData = ref<HikvisionTreeNode[]>([])
+const draftSelection = ref<HikvisionCameraNode | null>(null)
+let requestId = 0
+
+const treeProps = {
+  children: 'children',
+  label: 'name',
+  isLeaf: (data: HikvisionTreeNode) => data.nodeType === 'camera'
+}
+
+function isCameraSelectable(data: HikvisionTreeNode): data is HikvisionCameraNode {
+  return data.nodeType === 'camera' && data.available !== false && data.onlineStatus !== false
+}
+
+function openDialog() {
+  draftSelection.value = props.modelValue ? { ...props.modelValue } : null
+  keyword.value = ''
+  dialogVisible.value = true
+}
+
+async function loadRootTree() {
+  const currentRequestId = ++requestId
+  loading.value = true
+  try {
+    const data = await HikvisionApi.getTreeNodes('-1')
+    if (currentRequestId !== requestId) return
+    treeData.value = data
+    searchMode.value = false
+    treeVersion.value += 1
+  } catch {
+    if (currentRequestId === requestId) ElMessage.error('监控点目录加载失败')
+  } finally {
+    if (currentRequestId === requestId) loading.value = false
+  }
+}
+
+async function handleSearch(event?: KeyboardEvent) {
+  if (event?.isComposing || event?.keyCode === 229) return
+  const searchKeyword = keyword.value.trim()
+  if (!searchKeyword) {
+    await loadRootTree()
+    return
+  }
+
+  const currentRequestId = ++requestId
+  loading.value = true
+  try {
+    const data = await HikvisionApi.searchTree(searchKeyword)
+    if (currentRequestId !== requestId) return
+    treeData.value = data
+    searchMode.value = true
+    treeVersion.value += 1
+  } catch {
+    if (currentRequestId === requestId) ElMessage.error('监控点搜索失败')
+  } finally {
+    if (currentRequestId === requestId) loading.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 handleNodeClick(data: HikvisionTreeNode) {
+  if (data.nodeType !== 'camera') return
+  if (!isCameraSelectable(data)) {
+    ElMessage.warning(`${data.name} 不在线,不能选择`)
+    return
+  }
+  draftSelection.value = { ...data }
+}
+
+function clearSelection() {
+  emit('update:modelValue', null)
+}
+
+function confirmSelection() {
+  if (!draftSelection.value) return
+  emit('update:modelValue', { ...draftSelection.value })
+  dialogVisible.value = false
+}
+</script>
+
+<style scoped>
+.camera-select,
+.camera-select :deep(.el-input) {
+  width: 100%;
+}
+
+.camera-dialog {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.camera-tree-wrap {
+  height: 360px;
+  padding: 8px;
+  overflow: auto;
+  border: 1px solid var(--el-border-color-light);
+  border-radius: 6px;
+}
+
+.camera-tree-node {
+  display: flex;
+  min-width: 0;
+  flex: 1;
+  align-items: center;
+  gap: 7px;
+}
+
+.camera-tree-node span {
+  min-width: 0;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.camera-tree-node.is-camera {
+  color: var(--el-color-primary);
+}
+
+.camera-tree-node.is-offline {
+  color: var(--el-text-color-placeholder);
+}
+
+.camera-status {
+  width: 7px;
+  height: 7px;
+  margin-left: auto;
+  background: var(--el-color-danger);
+  border-radius: 50%;
+}
+
+.camera-status.online {
+  background: var(--el-color-success);
+}
+
+.camera-selection-summary {
+  color: var(--el-text-color-secondary);
+}
+
+.camera-selection-summary strong {
+  color: var(--el-text-color-primary);
+}
+</style>

+ 7 - 1
src/components/mt-edit/components/layout/right-aside/select-item-props-setting.vue

@@ -70,6 +70,10 @@
             v-else-if="property.config.type === 'statusColorRules'"
             v-model="property.config.val"
             @update:model-value="onPropertyChange" />
+          <hikvision-camera-select
+            v-else-if="property.config.type === 'hikvisionCamera'"
+            v-model="property.config.val"
+            @update:model-value="onPropertyChange" />
         </el-form-item>
       </div>
     </section>
@@ -89,6 +93,7 @@ import {
 } from 'element-plus'
 import JsonEdit from './json-edit.vue'
 import StatusColorRulesEditor from './status-color-rules-editor.vue'
+import HikvisionCameraSelect from './hikvision-camera-select.vue'
 type SelectItemPropsSettingProps = {
   itemId?: string
   propsInfo: ILeftAsideConfigItemPublicProps | undefined
@@ -114,7 +119,8 @@ const showGroupTitles = computed(
   () => propertyGroups.value.length > 1 || propertyGroups.value[0]?.name !== '组件属性'
 )
 const isWideProperty = (config: PropertyConfig) =>
-  config.wide || ['textArea', 'jsonEdit', 'statusColorRules'].includes(config.type)
+  config.wide ||
+  ['textArea', 'jsonEdit', 'statusColorRules', 'hikvisionCamera'].includes(config.type)
 </script>
 <style scoped>
 .property-groups {

+ 6 - 0
src/components/mt-edit/components/render-core/index.vue

@@ -43,6 +43,7 @@
           :grid="renderCoreProps.gridCfg"
           :done-json="renderCoreProps.doneJson"
           :lock-state="renderCoreProps.globalLock ? true : item.lock"
+          :preview-mode="renderCoreProps.preivewMode"
           :line-append-enable="renderCoreProps.lineAppendEnable"
           @update:item-json="onUpdateItemJson(index, $event)"
           @set-intention="(val) => renderCoreEmits('setIntention', val)"
@@ -63,6 +64,7 @@
       :grid="renderCoreProps.gridCfg"
       :done-json="renderCoreProps.doneJson"
       :lock-state="renderCoreProps.globalLock ? true : item.lock"
+      :preview-mode="renderCoreProps.preivewMode"
       :line-append-enable="renderCoreProps.lineAppendEnable"
       @update:item-json="onUpdateItemJson(index, $event)"
       @set-intention="(val) => renderCoreEmits('setIntention', val)"
@@ -93,6 +95,7 @@ import KvVue from '@/components/custom-components/kv-vue/index.vue'
 import SysButtonVue from '@/components/custom-components/sys-button-vue/index.vue'
 import WireframeVue from '@/components/custom-components/wireframe-vue/index.vue'
 import StatusVue from '@/components/custom-components/status-vue/index.vue'
+import HikvisionVideoVue from '@/components/custom-components/hikvision-video-vue/index.vue'
 import { ElPopover } from 'element-plus'
 import { normalizeCoordinate } from '@/components/mt-dzr/utils/drag-position'
 const instance = getCurrentInstance()
@@ -118,6 +121,9 @@ if (!now_include_keys.includes('wireframe-vue')) {
 if (!now_include_keys.includes('status-vue')) {
   instance?.appContext.app.component('status-vue', StatusVue)
 }
+if (!now_include_keys.includes('hikvision-video-vue')) {
+  instance?.appContext.app.component('hikvision-video-vue', HikvisionVideoVue)
+}
 type RenderCoreProps = {
   doneJson: IDoneJson[]
   canvasCfg: IGlobalStoreCanvasCfg

+ 10 - 3
src/components/mt-edit/components/render-item/index.vue

@@ -13,12 +13,13 @@
       :item-json="item_json"
       :grid="renderItemProps.grid"
       :canvas-cfg="renderItemProps.canvasCfg"
-      :canvas-dom="renderItemProps.canvasDom" />
+      :canvas-dom="renderItemProps.canvasDom"
+      :preview-mode="renderItemProps.previewMode" />
     <component
       v-else-if="item_json.type === 'vue'"
       draggable="false"
       :is="item_json.tag"
-      v-bind="prosToVBind(item_json.props)"
+      v-bind="getComponentProps(item_json)"
       @update:model-value="(val: any) => onUpdateModelValue(item_json.props, val)" />
     <img
       v-else-if="item_json.type === 'img'"
@@ -62,10 +63,12 @@ type RenderItemProps = {
   doneJson?: IDoneJson[]
   lockState: boolean
   lineAppendEnable?: boolean
+  previewMode?: boolean
 }
 const renderItemProps = withDefaults(defineProps<RenderItemProps>(), {
   doneJson: () => [],
-  lineAppendEnable: false
+  lineAppendEnable: false,
+  previewMode: false
 })
 const emits = defineEmits<{
   'update:itemJson': [value: IDoneJson]
@@ -78,6 +81,10 @@ const item_json = computed({
     emits('update:itemJson', value)
   }
 })
+const getComponentProps = (item: IDoneJson) => ({
+  ...prosToVBind(item.props),
+  ...(item.tag === 'hikvision-video-vue' ? { editorMode: !renderItemProps.previewMode } : {})
+})
 const onUpdateModelValue = (props: ILeftAsideConfigItemPublicProps, value: any) => {
   if (props.modelValue) {
     emits('update:itemJson', {

+ 74 - 0
src/components/mt-edit/store/__tests__/hikvision-video.spec.ts

@@ -0,0 +1,74 @@
+import { describe, expect, it } from 'vitest'
+import { genExportJson, useExportJsonToDoneJson } from '../../composables'
+import { configStore } from '../config'
+import type { IDoneJson } from '../types'
+
+const camera = {
+  indexCode: 'camera-001',
+  name: '一号监控点',
+  nodeType: 'camera' as const,
+  available: true,
+  onlineStatus: true
+}
+
+describe('组态视频监控组件', () => {
+  it('使用单监控点专用属性注册到系统组件中', () => {
+    const config = configStore.sysComponent.find((item) => item.id === 'hikvision-video-vue')
+
+    expect(config).toBeDefined()
+    expect(config?.type).toBe('vue')
+    expect(config?.props.camera).toMatchObject({
+      type: 'hikvisionCamera',
+      val: null,
+      bindable: false
+    })
+  })
+
+  it('导出并重新导入后保留监控点和值编辑器类型', () => {
+    const config = configStore.sysComponent.find((item) => item.id === 'hikvision-video-vue')!
+    const doneJson: IDoneJson[] = [
+      {
+        id: 'hikvision-video-vue-test',
+        title: config.title,
+        type: config.type,
+        tag: config.id,
+        binfo: { left: 0, top: 0, width: 480, height: 270, angle: 0 },
+        resize: true,
+        rotate: true,
+        lock: false,
+        active: false,
+        hide: false,
+        props: {
+          camera: {
+            ...config.props.camera,
+            val: camera
+          }
+        },
+        common_animations: config.common_animations,
+        events: []
+      }
+    ]
+
+    const { exportJson } = genExportJson(
+      {
+        width: 1920,
+        height: 1080,
+        scale: 1,
+        color: '',
+        img: '',
+        guide: true,
+        adsorp: true,
+        adsorp_diff: 3,
+        transform_origin: { x: 0, y: 0 },
+        drag_offset: { x: 0, y: 0 }
+      },
+      { enabled: true, align: true, size: 10 },
+      doneJson
+    )
+    const restored = useExportJsonToDoneJson(exportJson).importDoneJson[0]
+
+    expect(exportJson.json[0].props.camera).toEqual(camera)
+    expect(restored.props.camera.type).toBe('hikvisionCamera')
+    expect(restored.props.camera.val).toEqual(camera)
+  })
+})

+ 22 - 0
src/components/mt-edit/store/config.ts

@@ -749,6 +749,28 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       repeat: 'infinite'
     }
   },
+  {
+    id: 'hikvision-video-vue',
+    title: '视频监控',
+    type: 'vue',
+    thumbnail: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI2NCIgdmlld0JveD0iMCAwIDY0IDY0Ij48cmVjdCB4PSI2IiB5PSIxMiIgd2lkdGg9IjUyIiBoZWlnaHQ9IjM2IiByeD0iNCIgZmlsbD0iIzBmMTcyYSIgc3Ryb2tlPSIjNjBiNWZhIiBzdHJva2Utd2lkdGg9IjIiLz48cGF0aCBkPSJNMjYgMjJsMTQgOC0xNCA4VjIyeiIgZmlsbD0iI2ZmZiIvPjxwYXRoIGQ9Ik0xOCA1MmgxME0zNiA1MmgxMCIgc3Ryb2tlPSIjNjBiNWZhIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIvPjwvc3ZnPg==`,
+    props: {
+      camera: {
+        title: '监控点',
+        type: 'hikvisionCamera',
+        val: null,
+        group: '视频配置',
+        wide: true,
+        bindable: false
+      }
+    },
+    common_animations: {
+      val: '',
+      delay: 'delay-0s',
+      speed: 'slow',
+      repeat: 'infinite'
+    }
+  },
   {
     id: 'sys-button-vue',
     title: '按钮',

+ 2 - 2
src/components/mt-edit/store/context-menu.ts

@@ -1,6 +1,6 @@
 import { reactive } from 'vue'
 import type { ContextMenuInfoType, IContextMenu } from './types'
-import { getSystemShortcutKeyName } from '../utils'
+import { getDeleteShortcutKeyName, getSystemShortcutKeyName } from '../utils'
 
 const systemShortcutKey = getSystemShortcutKeyName()
 
@@ -27,7 +27,7 @@ export const contextMenuStore: IContextMenu = reactive({
       },
       delete: {
         title: '删除',
-        hot_key: 'Delete',
+        hot_key: getDeleteShortcutKeyName(),
         enable: false
       },
       group: {

+ 1 - 0
src/components/mt-edit/store/types.ts

@@ -7,6 +7,7 @@ export type ILeftAsideConfigItemPublicPropsType =
   | 'number'
   | 'jsonEdit'
   | 'statusColorRules'
+  | 'hikvisionCamera'
   | 'textArea'
 export interface IStatusColorRule {
   value: string

+ 12 - 0
src/components/mt-edit/utils/__tests__/keyboard-shortcut.spec.ts

@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { isDeleteShortcutKey } from '../index'
+
+describe('删除快捷键', () => {
+  it.each(['Delete', 'Backspace'])('支持 %s 键', (key) => {
+    expect(isDeleteShortcutKey({ key } as KeyboardEvent)).toBe(true)
+  })
+
+  it('不会把普通按键识别成删除快捷键', () => {
+    expect(isDeleteShortcutKey({ key: 'd' } as KeyboardEvent)).toBe(false)
+  })
+})

+ 6 - 0
src/components/mt-edit/utils/index.ts

@@ -16,6 +16,12 @@ export const isMacPlatform = () => {
 
 export const getSystemShortcutKeyName = () => (isMacPlatform() ? 'Cmd' : 'Ctrl')
 
+export const getDeleteShortcutKeyName = () =>
+  isMacPlatform() ? 'Delete (⌫)' : 'Delete / Backspace'
+
+export const isDeleteShortcutKey = (event: KeyboardEvent) =>
+  event.key === 'Delete' || event.key === 'Backspace'
+
 export const isSystemShortcutKey = (event: MouseEvent | KeyboardEvent | WheelEvent) =>
   isMacPlatform() ? event.metaKey : event.ctrlKey
 

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